diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0e2a74226..09d4680a9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,6 +4,7 @@ import kotlin.apply plugins { alias(libs.plugins.neki.android.application) alias(libs.plugins.neki.android.application.compose) + alias(libs.plugins.oss.licenses) } val localPropertiesFile = project.rootProject.file("local.properties") @@ -28,6 +29,26 @@ android { properties["KAKAO_NATIVE_APP_KEY"].toString() ) } + + signingConfigs { + create("release") { + storeFile = rootProject.file("neki_key_store.jks") + storePassword = properties["STORE_PASSWORD"].toString() + keyAlias = properties["KEY_ALIAS"].toString() + keyPassword = properties["KEY_PASSWORD"].toString() + } + } + buildTypes { + getByName("release") { + isMinifyEnabled = true + signingConfig = signingConfigs.getByName("release") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + } dependencies { diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 481bb4348..9aa01c402 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -5,17 +5,191 @@ # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +# Keep line number information for debugging stack traces. +-keepattributes SourceFile,LineNumberTable +-renamesourcefileattribute SourceFile + +# ======================== +# Project Classes +# ======================== +-keep class com.neki.android.** { *; } +-keepclassmembers class com.neki.android.** { *; } + +# ======================== +# Kotlin +# ======================== +-keep class kotlin.Metadata { *; } +-keepattributes RuntimeVisibleAnnotations +-keepattributes *Annotation* + +# Kotlin Coroutines +-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} +-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} +-keepclassmembers class kotlinx.coroutines.** { + volatile ; +} +-keepclassmembernames class kotlinx.** { + volatile ; +} + +# ======================== +# Ktor +# ======================== +-keep class io.ktor.** { *; } +-keepclassmembers class io.ktor.** { *; } +-dontwarn io.ktor.** + +# ======================== +# kotlinx.serialization +# ======================== +-keepattributes *Annotation*, InnerClasses +-dontnote kotlinx.serialization.AnnotationsKt + +-keepclassmembers @kotlinx.serialization.Serializable class ** { + *** Companion; +} +-if @kotlinx.serialization.Serializable class ** +-keepclassmembers class <1>$Companion { + kotlinx.serialization.KSerializer serializer(...); +} +-if @kotlinx.serialization.Serializable class ** { + static **$* *; +} +-keepclassmembers class <2>$<3> { + kotlinx.serialization.KSerializer serializer(...); +} +-if @kotlinx.serialization.Serializable class ** { + public static ** INSTANCE; +} +-keepclassmembers class <1> { + public static <1> INSTANCE; + kotlinx.serialization.KSerializer serializer(...); +} +-keepclassmembers class * { + @kotlinx.serialization.SerialName ; +} +-keep,includedescriptorclasses class com.neki.android.**$$serializer { *; } +-keepclassmembers class com.neki.android.** { + *** Companion; +} + +# ======================== +# Kakao SDK +# ======================== +-keep class com.kakao.sdk.** { *; } +-keepclassmembers class com.kakao.sdk.** { *; } +-dontwarn com.kakao.sdk.** + +# Kakao SDK enums (TokenNotFound 등) +-keepclassmembers enum com.kakao.sdk.** { + public static **[] values(); + public static ** valueOf(java.lang.String); + ; +} + +# ======================== +# Hilt / Dagger +# ======================== +-keep class dagger.** { *; } +-keep class javax.inject.** { *; } +-keep class * extends dagger.hilt.android.internal.managers.ComponentSupplier { *; } +-keep class * extends dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper { *; } +-keepclassmembers class * { + @dagger.hilt.* ; + @dagger.hilt.* ; + @javax.inject.* ; + @javax.inject.* ; +} +-dontwarn dagger.internal.codegen.** +-dontwarn dagger.hilt.internal.** + +# ======================== +# Android / Jetpack +# ======================== +# Lifecycle +-keep class androidx.lifecycle.** { *; } +-keepclassmembers class * implements androidx.lifecycle.LifecycleObserver { + (...); +} + +# Navigation +-keep class androidx.navigation.** { *; } + +# Compose +-keep class androidx.compose.** { *; } +-dontwarn androidx.compose.** + +# DataStore +-keep class androidx.datastore.** { *; } +-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite { + ; +} + +# Paging +-keep class androidx.paging.** { *; } + +# ======================== +# Enums (General) +# ======================== +-keepclassmembers enum * { + public static **[] values(); + public static ** valueOf(java.lang.String); + ; +} + +# ======================== +# Timber +# ======================== +-dontwarn org.jetbrains.annotations.** + +# ======================== +# OSS Licenses +# ======================== +-keep class com.google.android.gms.oss.licenses.** { *; } + +# ======================== +# Coil +# ======================== +-keep class coil3.** { *; } +-dontwarn coil3.** + +# ======================== +# Naver Maps +# ======================== +-keep class com.naver.maps.** { *; } +-dontwarn com.naver.maps.** + +# ======================== +# OkHttp (used by Coil) +# ======================== +-dontwarn okhttp3.** +-dontwarn okio.** +-keep class okhttp3.** { *; } +-keep class okio.** { *; } + +# ======================== +# ML Kit Barcode +# ======================== +-keep class com.google.mlkit.** { *; } +-dontwarn com.google.mlkit.** + +# ======================== +# Play Services +# ======================== +-keep class com.google.android.gms.** { *; } +-dontwarn com.google.android.gms.** + +# ======================== +# CameraX +# ======================== +-keep class androidx.camera.** { *; } +-dontwarn androidx.camera.** + +# ======================== +# Missing class warnings suppression +# ======================== +-dontwarn java.lang.invoke.StringConcatFactory +-dontwarn org.slf4j.** +-dontwarn org.bouncycastle.** +-dontwarn org.conscrypt.** +-dontwarn org.openjsse.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9f794bc49..f442abbee 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + @@ -40,7 +41,12 @@ - + + diff --git a/app/src/main/java/com/neki/android/app/navigation/di/NavigationModule.kt b/app/src/main/java/com/neki/android/app/navigation/di/NavigationModule.kt index b601d9609..f28a13f8a 100644 --- a/app/src/main/java/com/neki/android/app/navigation/di/NavigationModule.kt +++ b/app/src/main/java/com/neki/android/app/navigation/di/NavigationModule.kt @@ -1,8 +1,10 @@ package com.neki.android.app.navigation.di import com.neki.android.app.navigation.keys.START_NAV_KEY +import com.neki.android.app.navigation.keys.START_ROOT_NAV_KEY import com.neki.android.app.navigation.keys.TOP_LEVEL_NAV_KEYS import com.neki.android.core.navigation.NavigationState +import com.neki.android.core.navigation.root.RootNavigationState import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -21,4 +23,12 @@ internal object NavigationModule { topLevelKeys = TOP_LEVEL_NAV_KEYS.toSet(), ) } + + @Provides + @ActivityRetainedScoped + fun providesRootNavigationState(): RootNavigationState { + return RootNavigationState( + startKey = START_ROOT_NAV_KEY, + ) + } } diff --git a/app/src/main/java/com/neki/android/app/navigation/keys/Keys.kt b/app/src/main/java/com/neki/android/app/navigation/keys/Keys.kt index 2809e30ce..50a2d285c 100644 --- a/app/src/main/java/com/neki/android/app/navigation/keys/Keys.kt +++ b/app/src/main/java/com/neki/android/app/navigation/keys/Keys.kt @@ -1,7 +1,9 @@ package com.neki.android.app.navigation.keys import com.neki.android.app.navigation.TopLevelNavItem +import com.neki.android.core.navigation.root.RootNavKey import com.neki.android.feature.archive.api.ArchiveNavKey +internal val START_ROOT_NAV_KEY = RootNavKey.Login internal val START_NAV_KEY = ArchiveNavKey.Archive internal val TOP_LEVEL_NAV_KEYS = TopLevelNavItem.entries.map { it.navKey } diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index d5abfe1eb..208d2f239 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,9 +1,13 @@ - + + + diff --git a/build-logic/src/main/java/com/neki/android/buildlogic/extensions/Android.kt b/build-logic/src/main/java/com/neki/android/buildlogic/extensions/Android.kt index 66bc1865f..9d8291e26 100644 --- a/build-logic/src/main/java/com/neki/android/buildlogic/extensions/Android.kt +++ b/build-logic/src/main/java/com/neki/android/buildlogic/extensions/Android.kt @@ -26,16 +26,6 @@ internal fun Project.configureAndroid( jvmTarget = BuildConst.JDK_VERSION.toString() } - buildTypes { - getByName("release") { - isMinifyEnabled = true - proguardFiles( - getDefaultProguardFile("proguard-android.txt"), - "proguard-rules.pro", - ) - } - } - dependencies { add("detektPlugins", libs.findLibrary("detekt.formatting").get()) } diff --git a/build.gradle.kts b/build.gradle.kts index 72150526f..6ba648c59 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,6 +11,7 @@ plugins { alias(libs.plugins.ksp) apply false alias(libs.plugins.hilt) apply false alias(libs.plugins.detekt) apply false + alias(libs.plugins.oss.licenses) apply false } subprojects { @@ -24,4 +25,4 @@ subprojects { toolVersion = rootProject.libs.versions.detekt.get() config.setFrom(files("$rootDir/detekt-config.yml")) } -} \ No newline at end of file +} diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index b3377e80e..e9144caad 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -10,7 +10,7 @@ android { dependencies { api(libs.timber) + api(libs.kakao.user) implementation(libs.androidx.security.crypto) implementation(libs.androidx.core.ktx) - } diff --git a/core/common/src/main/java/com/neki/android/core/common/kakao/KakaoAuthHelper.kt b/core/common/src/main/java/com/neki/android/core/common/kakao/KakaoAuthHelper.kt new file mode 100644 index 000000000..c75f5b2d0 --- /dev/null +++ b/core/common/src/main/java/com/neki/android/core/common/kakao/KakaoAuthHelper.kt @@ -0,0 +1,57 @@ +package com.neki.android.core.common.kakao + +import android.content.Context +import com.kakao.sdk.user.UserApiClient + +class KakaoAuthHelper( + private val context: Context, +) { + fun login( + onSuccess: (String) -> Unit, + onFailure: (String) -> Unit, + ) { + if (UserApiClient.instance.isKakaoTalkLoginAvailable(context)) { + UserApiClient.instance.loginWithKakaoTalk(context) { token, error -> + if (error != null) { + onFailure(error.message ?: "카카오 로그인에 실패했습니다.") + } else if (token != null) { + onSuccess(token.idToken!!) + } + } + } else { + UserApiClient.instance.loginWithKakaoAccount(context) { token, error -> + if (error != null) { + onFailure(error.message ?: "카카오 로그인에 실패했습니다.") + } else if (token != null) { + onSuccess(token.idToken!!) + } + } + } + } + + fun logout( + onSuccess: () -> Unit, + onFailure: (String) -> Unit, + ) { + UserApiClient.instance.logout { error -> + if (error != null) { + onFailure(error.message ?: "카카오 로그아웃에 실패했습니다.") + } else { + onSuccess() + } + } + } + + fun unlink( + onSuccess: () -> Unit, + onFailure: (String) -> Unit, + ) { + UserApiClient.instance.unlink { error -> + if (error != null) { + onFailure(error.message ?: "카카오 연결 해제에 실패했습니다.") + } else { + onSuccess() + } + } + } +} diff --git a/core/common/src/main/java/com/neki/android/core/common/permission/CameraPermissionManager.kt b/core/common/src/main/java/com/neki/android/core/common/permission/CameraPermissionManager.kt new file mode 100644 index 000000000..f68fc9d24 --- /dev/null +++ b/core/common/src/main/java/com/neki/android/core/common/permission/CameraPermissionManager.kt @@ -0,0 +1,19 @@ +package com.neki.android.core.common.permission + +import android.Manifest +import android.app.Activity +import android.content.Context +import androidx.core.content.ContextCompat +import androidx.core.content.PermissionChecker + +object CameraPermissionManager { + const val CAMERA_PERMISSION = Manifest.permission.CAMERA + + fun isGrantedCameraPermission(context: Context): Boolean { + return ContextCompat.checkSelfPermission(context, CAMERA_PERMISSION) == PermissionChecker.PERMISSION_GRANTED + } + + fun shouldShowCameraRationale(activity: Activity): Boolean { + return activity.shouldShowRequestPermissionRationale(CAMERA_PERMISSION) + } +} diff --git a/core/common/src/main/java/com/neki/android/core/common/permission/LocationPermissionManager.kt b/core/common/src/main/java/com/neki/android/core/common/permission/LocationPermissionManager.kt index 86204ff5d..9649afbf8 100644 --- a/core/common/src/main/java/com/neki/android/core/common/permission/LocationPermissionManager.kt +++ b/core/common/src/main/java/com/neki/android/core/common/permission/LocationPermissionManager.kt @@ -13,17 +13,14 @@ object LocationPermissionManager { ) fun isGrantedLocationPermission(context: Context): Boolean { - return ContextCompat.checkSelfPermission( - context, - Manifest.permission.ACCESS_FINE_LOCATION, - ) == PermissionChecker.PERMISSION_GRANTED || - ContextCompat.checkSelfPermission( - context, - Manifest.permission.ACCESS_COARSE_LOCATION, - ) == PermissionChecker.PERMISSION_GRANTED + return LOCATION_PERMISSIONS.any { permission -> + ContextCompat.checkSelfPermission(context, permission) == PermissionChecker.PERMISSION_GRANTED + } } fun shouldShowLocationRationale(activity: Activity): Boolean { - return activity.shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION) + return LOCATION_PERMISSIONS.any { permission -> + activity.shouldShowRequestPermissionRationale(permission) + } } } diff --git a/core/common/src/main/java/com/neki/android/core/common/permission/NotificationPermissionManager.kt b/core/common/src/main/java/com/neki/android/core/common/permission/NotificationPermissionManager.kt new file mode 100644 index 000000000..76c53d32d --- /dev/null +++ b/core/common/src/main/java/com/neki/android/core/common/permission/NotificationPermissionManager.kt @@ -0,0 +1,25 @@ +package com.neki.android.core.common.permission + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.os.Build +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.core.content.PermissionChecker + +object NotificationPermissionManager { + const val NOTIFICATION_PERMISSION = Manifest.permission.POST_NOTIFICATIONS + + fun isGrantedNotificationPermission(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission(context, NOTIFICATION_PERMISSION) == PermissionChecker.PERMISSION_GRANTED + } else NotificationManagerCompat.from(context).areNotificationsEnabled() + } + + fun shouldShowNotificationRationale(activity: Activity): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + activity.shouldShowRequestPermissionRationale(NOTIFICATION_PERMISSION) + } else false + } +} diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/auth/AuthCacheManager.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/auth/AuthCacheManager.kt new file mode 100644 index 000000000..02653e70c --- /dev/null +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/auth/AuthCacheManager.kt @@ -0,0 +1,5 @@ +package com.neki.android.core.dataapi.auth + +interface AuthCacheManager { + fun invalidateTokenCache() +} diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/datastore/DataStoreKey.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/datastore/DataStoreKey.kt index c2d3a66b4..e145d556f 100644 --- a/core/data-api/src/main/java/com/neki/android/core/dataapi/datastore/DataStoreKey.kt +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/datastore/DataStoreKey.kt @@ -1,11 +1,8 @@ package com.neki.android.core.dataapi.datastore -import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey object DataStoreKey { val ACCESS_TOKEN = stringPreferencesKey("access_token") val REFRESH_TOKEN = stringPreferencesKey("refresh_token") - - val IS_FIRST_LOCATION_PERMISSION = booleanPreferencesKey("is_first_location_permission") } diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/AuthRepository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/AuthRepository.kt index b97190512..dfff3f25c 100644 --- a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/AuthRepository.kt +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/AuthRepository.kt @@ -5,4 +5,5 @@ import com.neki.android.core.model.Auth interface AuthRepository { suspend fun loginWithKakao(idToken: String): Result suspend fun updateAccessToken(refreshToken: String): Result + suspend fun withdrawAccount(): Result } diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/UserRepository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/UserRepository.kt new file mode 100644 index 000000000..892385422 --- /dev/null +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/UserRepository.kt @@ -0,0 +1,9 @@ +package com.neki.android.core.dataapi.repository + +import com.neki.android.core.model.UserInfo + +interface UserRepository { + suspend fun getUserInfo(): Result + suspend fun updateUserInfo(nickname: String): Result + suspend fun updateProfileImage(mediaId: Long?): Result +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/api/ApiService.kt b/core/data/src/main/java/com/neki/android/core/data/remote/api/AuthService.kt similarity index 74% rename from core/data/src/main/java/com/neki/android/core/data/remote/api/ApiService.kt rename to core/data/src/main/java/com/neki/android/core/data/remote/api/AuthService.kt index b0f9b878e..c2ee66b3f 100644 --- a/core/data/src/main/java/com/neki/android/core/data/remote/api/ApiService.kt +++ b/core/data/src/main/java/com/neki/android/core/data/remote/api/AuthService.kt @@ -4,12 +4,15 @@ import com.neki.android.core.data.remote.model.request.KakaoLoginRequest import com.neki.android.core.data.remote.model.request.RefreshTokenRequest import com.neki.android.core.data.remote.model.response.AuthResponse import com.neki.android.core.data.remote.model.response.BasicResponse +import com.neki.android.core.data.remote.model.response.BasicNullableResponse import io.ktor.client.HttpClient import io.ktor.client.call.body +import io.ktor.client.request.delete import io.ktor.client.request.post import io.ktor.client.request.setBody +import javax.inject.Inject -class ApiService( +class AuthService @Inject constructor( private val client: HttpClient, ) { // 카카오 로그인 @@ -21,4 +24,9 @@ class ApiService( suspend fun updateAccessToken(requestBody: RefreshTokenRequest): BasicResponse { return client.post("/api/auth/refresh") { setBody(requestBody) }.body() } + + // 회원 탈퇴 + suspend fun withdrawAccount(): BasicNullableResponse { + return client.delete("/api/users/me").body() + } } diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/api/UserService.kt b/core/data/src/main/java/com/neki/android/core/data/remote/api/UserService.kt new file mode 100644 index 000000000..b000a3194 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/api/UserService.kt @@ -0,0 +1,32 @@ +package com.neki.android.core.data.remote.api + +import com.neki.android.core.data.remote.model.request.UpdateProfileImageRequest +import com.neki.android.core.data.remote.model.request.UpdateUserInfoRequest +import com.neki.android.core.data.remote.model.response.BasicNullableResponse +import com.neki.android.core.data.remote.model.response.BasicResponse +import com.neki.android.core.data.remote.model.response.UserInfoResponse +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.patch +import io.ktor.client.request.setBody +import javax.inject.Inject + +class UserService @Inject constructor( + private val client: HttpClient, +) { + // 사용자 정보 조회 + suspend fun getUserInfo(): BasicResponse { + return client.get("/api/users/info").body() + } + + // 사용자 프로필 정보 변경(닉네임) + suspend fun updateUserInfo(request: UpdateUserInfoRequest): BasicNullableResponse { + return client.patch("/api/users/me") { setBody(request) }.body() + } + + // 사용자 프로필 이미지 변경 + suspend fun updateProfileImage(request: UpdateProfileImageRequest): BasicNullableResponse { + return client.patch("/api/users/me/profile-image") { setBody(request) }.body() + } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/di/NetworkModule.kt b/core/data/src/main/java/com/neki/android/core/data/remote/di/NetworkModule.kt index dcc77eca1..7622d34ee 100644 --- a/core/data/src/main/java/com/neki/android/core/data/remote/di/NetworkModule.kt +++ b/core/data/src/main/java/com/neki/android/core/data/remote/di/NetworkModule.kt @@ -2,11 +2,11 @@ package com.neki.android.core.data.remote.di import com.neki.android.core.common.const.Const.TAG_REST_API import com.neki.android.core.data.BuildConfig -import com.neki.android.core.data.remote.api.ApiService import com.neki.android.core.data.remote.model.request.RefreshTokenRequest import com.neki.android.core.data.remote.model.response.AuthResponse import com.neki.android.core.data.remote.model.response.BasicResponse import com.neki.android.core.data.remote.qualifier.UploadHttpClient +import com.neki.android.core.dataapi.auth.AuthCacheManager import com.neki.android.core.dataapi.auth.AuthEventManager import com.neki.android.core.dataapi.repository.TokenRepository import dagger.Module @@ -19,12 +19,14 @@ import io.ktor.client.engine.android.Android import io.ktor.client.plugins.DefaultRequest import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.auth.Auth +import io.ktor.client.plugins.auth.providers.BearerAuthProvider import io.ktor.client.plugins.auth.providers.BearerTokens import io.ktor.client.plugins.auth.providers.bearer import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logging +import io.ktor.client.plugins.plugin import io.ktor.client.request.header import io.ktor.client.request.post import io.ktor.client.request.setBody @@ -33,6 +35,7 @@ import io.ktor.http.HttpHeaders import io.ktor.http.encodedPath import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.flow.first +import dagger.Lazy import kotlinx.serialization.json.Json import timber.log.Timber import javax.inject.Singleton @@ -59,9 +62,16 @@ internal object NetworkModule { @Provides @Singleton - fun provideApiService( - client: HttpClient, - ): ApiService = ApiService(client) + fun provideAuthCacheManager( + httpClient: Lazy, + ): AuthCacheManager = object : AuthCacheManager { + override fun invalidateTokenCache() { + httpClient.get().plugin(Auth).providers + .filterIsInstance() + .firstOrNull() + ?.clearToken() + } + } @Provides @Singleton @@ -78,7 +88,6 @@ internal object NetworkModule { install(Auth) { bearer { loadTokens { - Timber.d("BearerAuth - loadTokens") if (tokenRepository.isSavedTokens().first()) { BearerTokens( accessToken = tokenRepository.getAccessToken().first(), @@ -88,7 +97,6 @@ internal object NetworkModule { } refreshTokens { - Timber.d("BearerAuth - AccessToken 갱신 시도") if (oldTokens != null) { return@refreshTokens try { val response = client.post("/api/auth/refresh") { @@ -121,8 +129,6 @@ internal object NetworkModule { val shouldNotAuth = sendWithoutAuthUrls.any { request.url.encodedPath == it } - - Timber.d("Bearer 인증 필요 API 여부 : $shouldNotAuth") !shouldNotAuth } } diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/request/UpdateProfileImageRequest.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/UpdateProfileImageRequest.kt new file mode 100644 index 000000000..fc9cbc8d4 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/UpdateProfileImageRequest.kt @@ -0,0 +1,9 @@ +package com.neki.android.core.data.remote.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UpdateProfileImageRequest( + @SerialName("mediaId") val mediaId: Long?, +) diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/request/UpdateUserInfoRequest.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/UpdateUserInfoRequest.kt new file mode 100644 index 000000000..dffb682fc --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/UpdateUserInfoRequest.kt @@ -0,0 +1,9 @@ +package com.neki.android.core.data.remote.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UpdateUserInfoRequest( + @SerialName("name") val nickname: String, +) diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/UserInfoResponse.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/UserInfoResponse.kt new file mode 100644 index 000000000..9b5f1d207 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/UserInfoResponse.kt @@ -0,0 +1,21 @@ +package com.neki.android.core.data.remote.model.response + +import com.neki.android.core.model.UserInfo +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UserInfoResponse( + @SerialName("userId") val userId: Long, + @SerialName("name") val name: String, + @SerialName("email") val email: String, + @SerialName("profileImageUrl") val profileImageUrl: String, + @SerialName("providerType") val providerType: String, +) { + fun toModel() = UserInfo( + id = userId, + nickname = name, + profileImageUrl = profileImageUrl, + loginType = providerType, + ) +} diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/di/RepositoryModule.kt b/core/data/src/main/java/com/neki/android/core/data/repository/di/RepositoryModule.kt index d22855fb2..76b47b01a 100644 --- a/core/data/src/main/java/com/neki/android/core/data/repository/di/RepositoryModule.kt +++ b/core/data/src/main/java/com/neki/android/core/data/repository/di/RepositoryModule.kt @@ -9,6 +9,7 @@ import com.neki.android.core.data.repository.impl.MapRepositoryImpl import com.neki.android.core.data.repository.impl.PhotoRepositoryImpl import com.neki.android.core.data.repository.impl.PoseRepositoryImpl import com.neki.android.core.data.repository.impl.TokenRepositoryImpl +import com.neki.android.core.data.repository.impl.UserRepositoryImpl import com.neki.android.core.dataapi.auth.AuthEventManager import com.neki.android.core.dataapi.repository.FolderRepository import com.neki.android.core.dataapi.repository.AuthRepository @@ -18,6 +19,7 @@ import com.neki.android.core.dataapi.repository.MapRepository import com.neki.android.core.dataapi.repository.PhotoRepository import com.neki.android.core.dataapi.repository.PoseRepository import com.neki.android.core.dataapi.repository.TokenRepository +import com.neki.android.core.dataapi.repository.UserRepository import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -40,6 +42,12 @@ internal interface RepositoryModule { authRepositoryImpl: AuthRepositoryImpl, ): AuthRepository + @Binds + @Singleton + fun bindUserRepositoryImpl( + userRepositoryImpl: UserRepositoryImpl, + ): UserRepository + @Binds @Singleton fun bindTokenRepositoryImpl( diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/AuthRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/AuthRepositoryImpl.kt index 1204f9370..efe52e23a 100644 --- a/core/data/src/main/java/com/neki/android/core/data/repository/impl/AuthRepositoryImpl.kt +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/AuthRepositoryImpl.kt @@ -1,6 +1,6 @@ package com.neki.android.core.data.repository.impl -import com.neki.android.core.data.remote.api.ApiService +import com.neki.android.core.data.remote.api.AuthService import com.neki.android.core.data.remote.model.request.KakaoLoginRequest import com.neki.android.core.data.remote.model.request.RefreshTokenRequest import com.neki.android.core.data.util.runSuspendCatching @@ -9,10 +9,10 @@ import com.neki.android.core.model.Auth import javax.inject.Inject class AuthRepositoryImpl @Inject constructor( - private val apiService: ApiService, + private val authService: AuthService, ) : AuthRepository { override suspend fun loginWithKakao(idToken: String): Result = runSuspendCatching { - apiService.loginWithKakao( + authService.loginWithKakao( requestBody = KakaoLoginRequest( idToken = idToken, ), @@ -20,10 +20,14 @@ class AuthRepositoryImpl @Inject constructor( } override suspend fun updateAccessToken(refreshToken: String): Result = runSuspendCatching { - apiService.updateAccessToken( + authService.updateAccessToken( requestBody = RefreshTokenRequest( refreshToken = refreshToken, ), ).data.toModel() } + + override suspend fun withdrawAccount(): Result = runSuspendCatching { + authService.withdrawAccount() + } } diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/TokenRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/TokenRepositoryImpl.kt index 3ce7c6286..0c0d390ec 100644 --- a/core/data/src/main/java/com/neki/android/core/data/repository/impl/TokenRepositoryImpl.kt +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/TokenRepositoryImpl.kt @@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import com.neki.android.core.common.crypto.CryptoManager import com.neki.android.core.dataapi.datastore.DataStoreKey +import com.neki.android.core.dataapi.auth.AuthCacheManager import com.neki.android.core.dataapi.repository.TokenRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -13,6 +14,7 @@ import javax.inject.Inject class TokenRepositoryImpl @Inject constructor( @TokenDataStore private val dataStore: DataStore, + private val authCacheManager: AuthCacheManager, ) : TokenRepository { override suspend fun saveTokens( accessToken: String, @@ -22,6 +24,7 @@ class TokenRepositoryImpl @Inject constructor( preferences[DataStoreKey.ACCESS_TOKEN] = CryptoManager.encrypt(accessToken) preferences[DataStoreKey.REFRESH_TOKEN] = CryptoManager.encrypt(refreshToken) } + authCacheManager.invalidateTokenCache() } override fun isSavedTokens(): Flow { @@ -50,5 +53,6 @@ class TokenRepositoryImpl @Inject constructor( preferences.remove(DataStoreKey.ACCESS_TOKEN) preferences.remove(DataStoreKey.REFRESH_TOKEN) } + authCacheManager.invalidateTokenCache() } } diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/UserRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/UserRepositoryImpl.kt new file mode 100644 index 000000000..c708f61e3 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/UserRepositoryImpl.kt @@ -0,0 +1,25 @@ +package com.neki.android.core.data.repository.impl + +import com.neki.android.core.data.remote.api.UserService +import com.neki.android.core.data.remote.model.request.UpdateProfileImageRequest +import com.neki.android.core.data.remote.model.request.UpdateUserInfoRequest +import com.neki.android.core.data.util.runSuspendCatching +import com.neki.android.core.dataapi.repository.UserRepository +import com.neki.android.core.model.UserInfo +import javax.inject.Inject + +class UserRepositoryImpl @Inject constructor( + private val userService: UserService, +) : UserRepository { + override suspend fun getUserInfo(): Result = runSuspendCatching { + userService.getUserInfo().data.toModel() + } + + override suspend fun updateUserInfo(nickname: String): Result = runSuspendCatching { + userService.updateUserInfo(UpdateUserInfoRequest(nickname)) + } + + override suspend fun updateProfileImage(mediaId: Long?): Result = runSuspendCatching { + userService.updateProfileImage(UpdateProfileImageRequest(mediaId)) + } +} diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/dialog/WarningDialog.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/dialog/WarningDialog.kt index 653fbf63a..bc2607c6a 100644 --- a/core/designsystem/src/main/java/com/neki/android/core/designsystem/dialog/WarningDialog.kt +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/dialog/WarningDialog.kt @@ -30,7 +30,9 @@ import com.neki.android.core.designsystem.ui.theme.NekiTheme fun WarningDialog( content: String, onDismissRequest: () -> Unit, - properties: DialogProperties = DialogProperties(), + properties: DialogProperties = DialogProperties( + usePlatformDefaultWidth = false, + ), ) { Dialog( onDismissRequest = onDismissRequest, diff --git a/core/designsystem/src/main/res/drawable/image_empty_profile_image.png b/core/designsystem/src/main/res/drawable/image_empty_profile_image.png new file mode 100644 index 000000000..3c36f1f7d Binary files /dev/null and b/core/designsystem/src/main/res/drawable/image_empty_profile_image.png differ diff --git a/core/designsystem/src/main/res/font/bowlbyone_regular.ttf b/core/designsystem/src/main/res/font/bowlbyone_regular.ttf deleted file mode 100644 index 2a37ce259..000000000 Binary files a/core/designsystem/src/main/res/font/bowlbyone_regular.ttf and /dev/null differ diff --git a/core/designsystem/src/main/res/font/pretendard_bold.ttf b/core/designsystem/src/main/res/font/pretendard_bold.ttf index e69de29bb..fb07fc65e 100644 Binary files a/core/designsystem/src/main/res/font/pretendard_bold.ttf and b/core/designsystem/src/main/res/font/pretendard_bold.ttf differ diff --git a/core/designsystem/src/main/res/font/pretendard_medium.ttf b/core/designsystem/src/main/res/font/pretendard_medium.ttf index e69de29bb..1db67c68f 100644 Binary files a/core/designsystem/src/main/res/font/pretendard_medium.ttf and b/core/designsystem/src/main/res/font/pretendard_medium.ttf differ diff --git a/core/designsystem/src/main/res/font/pretendard_regular.ttf b/core/designsystem/src/main/res/font/pretendard_regular.ttf index e69de29bb..01147e999 100644 Binary files a/core/designsystem/src/main/res/font/pretendard_regular.ttf and b/core/designsystem/src/main/res/font/pretendard_regular.ttf differ diff --git a/core/designsystem/src/main/res/font/pretendard_semibold.ttf b/core/designsystem/src/main/res/font/pretendard_semibold.ttf index e69de29bb..9f2690f09 100644 Binary files a/core/designsystem/src/main/res/font/pretendard_semibold.ttf and b/core/designsystem/src/main/res/font/pretendard_semibold.ttf differ diff --git a/core/domain/src/main/java/com/neki/android/core/domain/extension/ContentTypeUtil.kt b/core/domain/src/main/java/com/neki/android/core/domain/extension/ContentTypeUtil.kt new file mode 100644 index 000000000..cb30b0b3e --- /dev/null +++ b/core/domain/src/main/java/com/neki/android/core/domain/extension/ContentTypeUtil.kt @@ -0,0 +1,14 @@ +package com.neki.android.core.domain.extension + +import com.neki.android.core.model.ContentType +import java.util.UUID + +object ContentTypeUtil { + fun generateFileName(contentType: ContentType): String { + val extension = when (contentType) { + ContentType.JPEG -> "jpeg" + ContentType.PNG -> "png" + } + return "${UUID.randomUUID()}.$extension" + } +} diff --git a/core/domain/src/main/java/com/neki/android/core/domain/usecase/OptionalUseCase.kt b/core/domain/src/main/java/com/neki/android/core/domain/usecase/OptionalUseCase.kt deleted file mode 100644 index c7ef9333b..000000000 --- a/core/domain/src/main/java/com/neki/android/core/domain/usecase/OptionalUseCase.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.neki.android.core.domain.usecase - -class OptionalUseCase diff --git a/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadMultiplePhotoUseCase.kt b/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadMultiplePhotoUseCase.kt index d9e8286e2..424a47355 100644 --- a/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadMultiplePhotoUseCase.kt +++ b/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadMultiplePhotoUseCase.kt @@ -4,13 +4,13 @@ import android.net.Uri import com.neki.android.core.data.util.runSuspendCatching import com.neki.android.core.dataapi.repository.MediaUploadRepository import com.neki.android.core.dataapi.repository.PhotoRepository +import com.neki.android.core.domain.extension.ContentTypeUtil.generateFileName import com.neki.android.core.model.ContentType import com.neki.android.core.model.Media import com.neki.android.core.model.MediaType import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope -import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -66,12 +66,4 @@ class UploadMultiplePhotoUseCase @Inject constructor( ) } } - - private fun generateFileName(contentType: ContentType): String { - val extension = when (contentType) { - ContentType.JPEG -> "jpeg" - ContentType.PNG -> "png" - } - return "${UUID.randomUUID()}.$extension" - } } diff --git a/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadProfileImageUseCase.kt b/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadProfileImageUseCase.kt new file mode 100644 index 000000000..7b190e512 --- /dev/null +++ b/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadProfileImageUseCase.kt @@ -0,0 +1,46 @@ +package com.neki.android.core.domain.usecase + +import android.net.Uri +import com.neki.android.core.data.util.runSuspendCatching +import com.neki.android.core.dataapi.repository.MediaUploadRepository +import com.neki.android.core.dataapi.repository.UserRepository +import com.neki.android.core.domain.extension.ContentTypeUtil +import com.neki.android.core.model.ContentType +import com.neki.android.core.model.MediaType +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class UploadProfileImageUseCase @Inject constructor( + private val mediaUploadRepository: MediaUploadRepository, + private val userRepository: UserRepository, +) { + suspend operator fun invoke( + uri: Uri?, + contentType: ContentType = ContentType.JPEG, + ): Result = runSuspendCatching { + if (uri == null) { + // null 이면 1, 2 과정 없이 바로 기본 프로필 이미지로 변경 요청 + userRepository.updateProfileImage(null).getOrThrow() + } else { + val fileName = ContentTypeUtil.generateFileName(contentType) + + // 1. 업로드 티켓 발급 (mediaId, presignedUrl) + val (mediaId, presignedUrl) = mediaUploadRepository.getSingleUploadTicket( + fileName = fileName, + contentType = contentType.label, + mediaType = MediaType.USER_PROFILE.name, + ).getOrThrow() + + // 2. Presigned URL로 이미지 업로드 + mediaUploadRepository.uploadImageFromUri( + uploadUrl = presignedUrl, + uri = uri, + contentType = contentType, + ).getOrThrow() + + // 3. 프로필 이미지 갱신 + userRepository.updateProfileImage(mediaId).getOrThrow() + } + } +} diff --git a/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadSinglePhotoUseCase.kt b/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadSinglePhotoUseCase.kt index 3f9531370..90f04f1c3 100644 --- a/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadSinglePhotoUseCase.kt +++ b/core/domain/src/main/java/com/neki/android/core/domain/usecase/UploadSinglePhotoUseCase.kt @@ -3,10 +3,10 @@ package com.neki.android.core.domain.usecase import com.neki.android.core.data.util.runSuspendCatching import com.neki.android.core.dataapi.repository.MediaUploadRepository import com.neki.android.core.dataapi.repository.PhotoRepository +import com.neki.android.core.domain.extension.ContentTypeUtil import com.neki.android.core.model.ContentType import com.neki.android.core.model.Media import com.neki.android.core.model.MediaType -import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -20,7 +20,7 @@ class UploadSinglePhotoUseCase @Inject constructor( contentType: ContentType = ContentType.JPEG, folderId: Long? = null, ): Result = runSuspendCatching { - val fileName = generateFileName(contentType) + val fileName = ContentTypeUtil.generateFileName(contentType) // 1. 업로드 티켓 발급 (mediaId, presignedUrl) val (mediaId, presignedUrl) = mediaUploadRepository.getSingleUploadTicket( @@ -49,12 +49,4 @@ class UploadSinglePhotoUseCase @Inject constructor( contentType = contentType, ) } - - private fun generateFileName(contentType: ContentType): String { - val extension = when (contentType) { - ContentType.JPEG -> "jpeg" - ContentType.PNG -> "png" - } - return "${UUID.randomUUID()}.$extension" - } } diff --git a/core/model/src/main/java/com/neki/android/core/model/UserInfo.kt b/core/model/src/main/java/com/neki/android/core/model/UserInfo.kt new file mode 100644 index 000000000..9f370f406 --- /dev/null +++ b/core/model/src/main/java/com/neki/android/core/model/UserInfo.kt @@ -0,0 +1,8 @@ +package com.neki.android.core.model + +data class UserInfo( + val id: Long = 0L, + val nickname: String = "", + val profileImageUrl: String = "", + val loginType: String = "", +) diff --git a/core/navigation/src/main/java/com/neki/android/core/navigation/NavigatorImpl.kt b/core/navigation/src/main/java/com/neki/android/core/navigation/NavigatorImpl.kt index 10a36158e..0ba4f709c 100644 --- a/core/navigation/src/main/java/com/neki/android/core/navigation/NavigatorImpl.kt +++ b/core/navigation/src/main/java/com/neki/android/core/navigation/NavigatorImpl.kt @@ -12,10 +12,20 @@ class NavigatorImpl @Inject constructor( val state: NavigationState, ) : Navigator { override fun navigateRoot(rootNavKey: RootNavKey) { + clearRootSubStack() rootState.stack.clear() rootState.stack.add(rootNavKey) } + private fun clearRootSubStack() { + state.topLevelStack.clear() + state.topLevelStack.add(state.startKey) + state.subStacks.forEach { (key, stack) -> + stack.clear() + stack.add(key) + } + } + override fun navigate(key: NavKey) { when (key) { state.currentTopLevelKey -> clearSubStack() diff --git a/core/navigation/src/main/java/com/neki/android/core/navigation/root/RootNavigationState.kt b/core/navigation/src/main/java/com/neki/android/core/navigation/root/RootNavigationState.kt index a95c0ed4b..48710eba6 100644 --- a/core/navigation/src/main/java/com/neki/android/core/navigation/root/RootNavigationState.kt +++ b/core/navigation/src/main/java/com/neki/android/core/navigation/root/RootNavigationState.kt @@ -4,11 +4,11 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.snapshots.SnapshotStateList -import dagger.hilt.android.scopes.ActivityRetainedScoped import javax.inject.Inject -@ActivityRetainedScoped -class RootNavigationState @Inject constructor() { - internal val stack: SnapshotStateList = mutableStateListOf(RootNavKey.Main) +class RootNavigationState @Inject constructor( + val startKey: RootNavKey, +) { + internal val stack: SnapshotStateList = mutableStateListOf(startKey) val currentRootKey: RootNavKey by derivedStateOf { stack.last() } } diff --git a/feature/auth/impl/build.gradle.kts b/feature/auth/impl/build.gradle.kts index 030538d6e..9e8f836f0 100644 --- a/feature/auth/impl/build.gradle.kts +++ b/feature/auth/impl/build.gradle.kts @@ -10,5 +10,4 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(projects.feature.auth.api) - api(libs.kakao.user) } diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/LoginScreen.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/LoginScreen.kt index 20cc6ecff..0e9504ca8 100644 --- a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/LoginScreen.kt +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/LoginScreen.kt @@ -2,7 +2,6 @@ package com.neki.android.feature.auth.impl import android.widget.Toast import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext @@ -11,8 +10,8 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.neki.android.core.designsystem.ui.theme.NekiTheme import com.neki.android.core.ui.compose.collectWithLifecycle +import com.neki.android.core.common.kakao.KakaoAuthHelper import com.neki.android.feature.auth.impl.component.LoginContent -import com.neki.android.feature.auth.impl.util.KakaoLoginHelper import timber.log.Timber @Composable @@ -23,19 +22,15 @@ fun LoginRoute( val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() val context = LocalContext.current - val kakaoLoginHelper = remember { KakaoLoginHelper(context) } + val kakaoAuthHelper = remember { KakaoAuthHelper(context) } viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> when (sideEffect) { - LoginSideEffect.NavigateToHome -> { - Timber.d("홈 화면으로 이동하거나 API를 호출하거나") - } - + LoginSideEffect.NavigateToHome -> navigateToMain() LoginSideEffect.NavigateToKakaoRedirectingUri -> { - kakaoLoginHelper.loginWithKakao( + kakaoAuthHelper.login( onSuccess = { idToken -> Timber.d("로그인 성공 $idToken") - navigateToMain() // 제거 예정 viewModel.store.onIntent(LoginIntent.SuccessLogin(idToken)) }, onFailure = { message -> @@ -50,10 +45,6 @@ fun LoginRoute( } } - LaunchedEffect(Unit) { - viewModel.store.onIntent(LoginIntent.EnterLoginScreen) - } - LoginScreen( uiState = uiState, onIntent = viewModel.store::onIntent, diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/LoginViewModel.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/LoginViewModel.kt index be41c5717..9c89cffcd 100644 --- a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/LoginViewModel.kt +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/LoginViewModel.kt @@ -23,6 +23,7 @@ class LoginViewModel @Inject constructor( mviIntentStore( initialState = LoginState(), onIntent = ::onIntent, + initialFetchData = { store.onIntent(LoginIntent.EnterLoginScreen) }, ) private fun onIntent( @@ -32,29 +33,26 @@ class LoginViewModel @Inject constructor( postSideEffect: (LoginSideEffect) -> Unit, ) { when (intent) { - LoginIntent.EnterLoginScreen -> checkLoginState(reduce, postSideEffect) + LoginIntent.EnterLoginScreen -> fetchInitialData(postSideEffect) LoginIntent.ClickKakaoLogin -> postSideEffect(LoginSideEffect.NavigateToKakaoRedirectingUri) is LoginIntent.SuccessLogin -> loginFromKakao(intent.idToken, reduce, postSideEffect) LoginIntent.FailLogin -> postSideEffect(LoginSideEffect.ShowToastMessage("카카오 로그인에 실패했습니다.")) } } - private fun checkLoginState( - reduce: (LoginState.() -> LoginState) -> Unit, - postSideEffect: (LoginSideEffect) -> Unit, - ) = viewModelScope.launch { + private fun fetchInitialData(postSideEffect: (LoginSideEffect) -> Unit) = viewModelScope.launch { if (tokenRepository.isSavedTokens().first()) { - Timber.d("JWT 토큰 O") authRepository.updateAccessToken( refreshToken = tokenRepository.getRefreshToken().first(), ).onSuccess { + tokenRepository.saveTokens(it.accessToken, it.refreshToken) postSideEffect(LoginSideEffect.NavigateToHome) - }.onFailure { - Timber.d(it.message.toString()) + }.onFailure { exception -> + Timber.e(exception) authEventManager.emitTokenExpired() } } else { - Timber.d("JWT 토큰 X") + Timber.d("저장된 JWT 토큰이 없습니다.") } } @@ -70,11 +68,10 @@ class LoginViewModel @Inject constructor( accessToken = it.accessToken, refreshToken = it.refreshToken, ) - postSideEffect(LoginSideEffect.NavigateToHome) } - .onFailure { - Timber.d(it.message.toString()) + .onFailure { exception -> + Timber.e(exception) } reduce { copy(isLoading = false) } } diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/util/KakaoLoginHelper.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/util/KakaoLoginHelper.kt deleted file mode 100644 index 3c5fdf57d..000000000 --- a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/util/KakaoLoginHelper.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.neki.android.feature.auth.impl.util - -import android.content.Context -import com.kakao.sdk.user.UserApiClient - -class KakaoLoginHelper( - private val context: Context, -) { - fun loginWithKakao( - onSuccess: (String) -> Unit, - onFailure: (String) -> Unit, - ) { - // 카카오톡 설치 여부 확인 - if (UserApiClient.Companion.instance.isKakaoTalkLoginAvailable(context)) { - // 카카오톡으로 로그인 - UserApiClient.Companion.instance.loginWithKakaoTalk(context) { token, error -> - if (error != null) { - onFailure(error.message ?: "카카오 로그인에 실패했습니다.") - } else if (token != null) { - onSuccess(token.idToken!!) - } - } - } else { - // 카카오 계정으로 로그인 - UserApiClient.Companion.instance.loginWithKakaoAccount(context) { token, error -> - if (error != null) { - onFailure(error.message ?: "카카오 로그인에 실패했습니다.") - } else if (token != null) { - onSuccess(token.idToken!!) - } - } - } - } -} diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapScreen.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapScreen.kt index f8dd144f4..9c123bf89 100644 --- a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapScreen.kt +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapScreen.kt @@ -299,7 +299,6 @@ fun MapScreen( WarningDialog( content = "가까운 네컷 사진 브랜드는\n1km 기준으로 표시돼요.", onDismissRequest = { onIntent(MapIntent.ClickCloseInfoIcon) }, - properties = DialogProperties(usePlatformDefaultWidth = false), ) } @@ -317,7 +316,6 @@ fun MapScreen( buttonText = "확인", onDismissRequest = { onIntent(MapIntent.DismissLocationPermissionDialog) }, onClick = { onIntent(MapIntent.ConfirmLocationPermissionDialog) }, - properties = DialogProperties(usePlatformDefaultWidth = false), ) } diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapViewModel.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapViewModel.kt index 21fd807a1..f4785bd88 100644 --- a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapViewModel.kt +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapViewModel.kt @@ -47,7 +47,7 @@ class MapViewModel @Inject constructor( postSideEffect: (MapEffect) -> Unit, ) { when (intent) { - MapIntent.EnterMapScreen -> loadBrands(state, reduce) + MapIntent.EnterMapScreen -> fetchInitialData(reduce) is MapIntent.GrantedLocationPermission -> getCurrentLocation(reduce, postSideEffect) is MapIntent.LoadPhotoBoothsByBounds -> loadPhotoBoothsByPolygon(intent.mapBounds, state, reduce, postSideEffect) MapIntent.ClickCurrentLocationIcon -> { @@ -273,10 +273,7 @@ class MapViewModel @Inject constructor( postSideEffect(MapEffect.MoveCameraToPosition(locLatLng)) } - private fun loadBrands( - state: MapState, - reduce: (MapState.() -> MapState) -> Unit, - ) { + private fun fetchInitialData(reduce: (MapState.() -> MapState) -> Unit) { viewModelScope.launch { reduce { copy(isLoading = true) } diff --git a/feature/mypage/api/src/main/java/com/neki/android/feature/mypage/api/MyPageNavKey.kt b/feature/mypage/api/src/main/java/com/neki/android/feature/mypage/api/MyPageNavKey.kt index e78263e08..5f3e7cd40 100644 --- a/feature/mypage/api/src/main/java/com/neki/android/feature/mypage/api/MyPageNavKey.kt +++ b/feature/mypage/api/src/main/java/com/neki/android/feature/mypage/api/MyPageNavKey.kt @@ -14,6 +14,9 @@ sealed interface MyPageNavKey : NavKey { @Serializable data object Profile : MyPageNavKey + + @Serializable + data object EditProfile : MyPageNavKey } fun Navigator.navigateToMyPage() { @@ -27,3 +30,7 @@ fun Navigator.navigateToPermission() { fun Navigator.navigateToProfile() { navigate(MyPageNavKey.Profile) } + +fun Navigator.navigateToEditProfile() { + navigate(MyPageNavKey.EditProfile) +} diff --git a/feature/mypage/impl/build.gradle.kts b/feature/mypage/impl/build.gradle.kts index 5a1a6bb7e..67243f0d0 100644 --- a/feature/mypage/impl/build.gradle.kts +++ b/feature/mypage/impl/build.gradle.kts @@ -10,4 +10,6 @@ dependencies { implementation(projects.feature.mypage.api) implementation(libs.androidx.activity.compose) + implementation(libs.androidx.appcompat) + implementation(libs.oss.licenses) } diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/component/PermissionSectionItem.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/component/PermissionSectionItem.kt index 35b459323..cc6a7b210 100644 --- a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/component/PermissionSectionItem.kt +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/component/PermissionSectionItem.kt @@ -20,7 +20,7 @@ import com.neki.android.core.designsystem.modifier.noRippleClickableSingle import com.neki.android.core.designsystem.ui.theme.NekiTheme @Composable -fun PermissionSectionItem( +internal fun PermissionSectionItem( title: String, subTitle: String, isGranted: Boolean, diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/component/SectionItem.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/component/SectionItem.kt index bfa650b83..6d1c71877 100644 --- a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/component/SectionItem.kt +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/component/SectionItem.kt @@ -19,7 +19,7 @@ import com.neki.android.core.designsystem.modifier.noRippleClickableSingle import com.neki.android.core.designsystem.ui.theme.NekiTheme @Composable -fun SectionItem( +internal fun SectionItem( text: String, onClick: () -> Unit = {}, trailingContent: @Composable (() -> Unit)? = null, @@ -63,13 +63,13 @@ fun SectionArrowItem( @Composable fun SectionVersionItem( - version: String, + appVersion: String, ) { SectionItem( text = "앱 버전 정보", trailingContent = { Text( - text = version, + text = "v$appVersion", color = NekiTheme.colorScheme.gray500, style = NekiTheme.typography.body14Medium, ) @@ -100,7 +100,7 @@ private fun SectionArrowItemPreview() { private fun SectionVersionItemPreview() { NekiTheme { SectionVersionItem( - version = "v1.3.1", + appVersion = "1.3.1", ) } } diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/component/SectionTitleText.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/component/SectionTitleText.kt index 04e0108d7..62fff6400 100644 --- a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/component/SectionTitleText.kt +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/component/SectionTitleText.kt @@ -11,7 +11,7 @@ import com.neki.android.core.designsystem.ComponentPreview import com.neki.android.core.designsystem.ui.theme.NekiTheme @Composable -fun SectionTitleText( +internal fun SectionTitleText( text: String, paddingTop: Dp = 12.dp, paddingBottom: Dp = 4.dp, diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageContract.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageContract.kt index f612ddf32..43896cd48 100644 --- a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageContract.kt +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageContract.kt @@ -1,34 +1,36 @@ package com.neki.android.feature.mypage.impl.main -import android.net.Uri +import com.neki.android.core.model.UserInfo +import com.neki.android.feature.mypage.impl.main.const.ServiceInfoMenu import com.neki.android.feature.mypage.impl.permission.const.NekiPermission +import com.neki.android.feature.mypage.impl.profile.model.EditProfileImageType data class MyPageState( val isLoading: Boolean = false, - val userName: String = "오종석", - val profileImageUri: Uri? = null, - val appVersion: String = "v1.3.1", + val userInfo: UserInfo = UserInfo(), + val appVersion: String = "", + val profileImageState: EditProfileImageType = EditProfileImageType.OriginalImageUrl(""), val isShowLogoutDialog: Boolean = false, - val isShowSignOutDialog: Boolean = false, + val isShowWithdrawDialog: Boolean = false, val isShowImageChooseDialog: Boolean = false, - val profileMode: ProfileMode = ProfileMode.SETTING, // Permission - val isCameraGranted: Boolean = false, - val isLocationGranted: Boolean = false, - val isStorageGranted: Boolean = false, - val isNotificationGranted: Boolean = false, + val isGrantedCamera: Boolean = false, + val isGrantedLocation: Boolean = false, + val isGrantedNotification: Boolean = false, val isShowPermissionDialog: Boolean = false, - val selectedPermission: NekiPermission? = null, + val clickedPermission: NekiPermission? = null, ) sealed interface MyPageIntent { + // Init + data object EnterMypageScreen : MyPageIntent + data class SetAppVersion(val appVersion: String) : MyPageIntent + // MyPage Main data object ClickNotificationIcon : MyPageIntent data object ClickProfileCard : MyPageIntent data object ClickPermission : MyPageIntent - data object ClickInquiry : MyPageIntent - data object ClickTermsOfService : MyPageIntent - data object ClickPrivacyPolicy : MyPageIntent + data class ClickServiceInfoMenu(val menu: ServiceInfoMenu) : MyPageIntent data object ClickOpenSourceLicense : MyPageIntent // Profile @@ -36,32 +38,35 @@ sealed interface MyPageIntent { data object ClickEditIcon : MyPageIntent data object ClickCameraIcon : MyPageIntent data object DismissImageChooseDialog : MyPageIntent - data class SelectProfileImage(val uri: Uri?) : MyPageIntent + data class SelectProfileImage(val image: EditProfileImageType) : MyPageIntent data class ClickEditComplete(val nickname: String) : MyPageIntent data object ClickLogout : MyPageIntent data object DismissLogoutDialog : MyPageIntent data object ConfirmLogout : MyPageIntent - data object ClickSignOut : MyPageIntent - data object DismissSignOutDialog : MyPageIntent - data object ConfirmSignOut : MyPageIntent + data object ClickWithdraw : MyPageIntent + data object DismissWithdrawDialog : MyPageIntent + data object ConfirmWithdraw : MyPageIntent // Permission data class ClickPermissionItem(val permission: NekiPermission) : MyPageIntent data object DismissPermissionDialog : MyPageIntent data object ConfirmPermissionDialog : MyPageIntent + data class UpdatePermissionState(val permission: NekiPermission, val isGranted: Boolean) : MyPageIntent + data class ShowPermissionDeniedDialog(val permission: NekiPermission) : MyPageIntent } sealed interface MyPageEffect { data object NavigateToNotification : MyPageEffect data object NavigateToProfile : MyPageEffect + data object NavigateToEditProfile : MyPageEffect data object NavigateToPermission : MyPageEffect - data object NavigateToInquiry : MyPageEffect - data object NavigateToTermsOfService : MyPageEffect - data object NavigateToPrivacyPolicy : MyPageEffect - data object NavigateToOpenSourceLicense : MyPageEffect + data class OpenExternalLink(val url: String) : MyPageEffect data object NavigateBack : MyPageEffect data object NavigateToLogin : MyPageEffect data class MoveAppSettings(val permission: NekiPermission) : MyPageEffect + data class RequestPermission(val permission: NekiPermission) : MyPageEffect + data object OpenOssLicenses : MyPageEffect + data object LogoutWithKakao : MyPageEffect + data object UnlinkWithKakao : MyPageEffect + data class PreloadImageAndNavigateBack(val url: String) : MyPageEffect } - -enum class ProfileMode { SETTING, EDIT } diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageScreen.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageScreen.kt index 47fe0e580..f3a7c097b 100644 --- a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageScreen.kt +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageScreen.kt @@ -1,5 +1,7 @@ package com.neki.android.feature.mypage.impl.main +import android.content.Intent +import android.net.Uri import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -7,19 +9,24 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.google.android.gms.oss.licenses.OssLicensesMenuActivity import com.neki.android.core.designsystem.ComponentPreview import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.ui.component.LoadingDialog import com.neki.android.core.ui.compose.collectWithLifecycle import com.neki.android.feature.mypage.impl.component.SectionArrowItem import com.neki.android.feature.mypage.impl.component.SectionTitleText import com.neki.android.feature.mypage.impl.component.SectionVersionItem import com.neki.android.feature.mypage.impl.main.component.MainTopBar import com.neki.android.feature.mypage.impl.main.component.ProfileCard +import com.neki.android.feature.mypage.impl.main.const.ServiceInfoMenu @Composable internal fun MyPageRoute( @@ -27,20 +34,28 @@ internal fun MyPageRoute( navigateToPermission: () -> Unit, navigateToProfile: () -> Unit, ) { + val context = LocalContext.current val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { + val appVersion = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "" + viewModel.store.onIntent(MyPageIntent.SetAppVersion(appVersion)) + } + viewModel.store.sideEffects.collectWithLifecycle { effect -> when (effect) { + MyPageEffect.NavigateBack -> {} + MyPageEffect.NavigateToLogin -> {} MyPageEffect.NavigateToNotification -> {} MyPageEffect.NavigateToProfile -> navigateToProfile() MyPageEffect.NavigateToPermission -> navigateToPermission() - MyPageEffect.NavigateToInquiry -> {} - MyPageEffect.NavigateToTermsOfService -> {} - MyPageEffect.NavigateToPrivacyPolicy -> {} - MyPageEffect.NavigateToOpenSourceLicense -> {} - MyPageEffect.NavigateBack -> {} - MyPageEffect.NavigateToLogin -> {} - is MyPageEffect.MoveAppSettings -> {} + is MyPageEffect.OpenExternalLink -> context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(effect.url))) + MyPageEffect.OpenOssLicenses -> { + OssLicensesMenuActivity.setActivityTitle("오픈소스 라이선스 목록") + context.startActivity(Intent(context, OssLicensesMenuActivity::class.java)) + } + + else -> {} } } @@ -56,14 +71,15 @@ fun MyPageScreen( onIntent: (MyPageIntent) -> Unit, ) { Column( - modifier = Modifier - .fillMaxSize(), + modifier = Modifier.fillMaxSize(), ) { MainTopBar( onClickIcon = { onIntent(MyPageIntent.ClickNotificationIcon) }, ) ProfileCard( - name = "오종석", + profileImageUrl = uiState.userInfo.profileImageUrl, + name = uiState.userInfo.nickname, + loginType = uiState.userInfo.loginType, onClickCard = { onIntent(MyPageIntent.ClickProfileCard) }, ) Box( @@ -73,26 +89,20 @@ fun MyPageScreen( .background(color = NekiTheme.colorScheme.gray25), ) Column { - SectionTitleText(text = "권한 설정") + SectionTitleText(text = "권한") SectionArrowItem( - text = "기기 권한", + text = "권한 설정하기", onClick = { onIntent(MyPageIntent.ClickPermission) }, ) } Column { SectionTitleText(text = "서비스 정보 및 지원") - SectionArrowItem( - text = "Neki에 문의하기", - onClick = { onIntent(MyPageIntent.ClickInquiry) }, - ) - SectionArrowItem( - text = "이용약관", - onClick = { onIntent(MyPageIntent.ClickTermsOfService) }, - ) - SectionArrowItem( - text = "개인정보 처리방침", - onClick = { onIntent(MyPageIntent.ClickPrivacyPolicy) }, - ) + ServiceInfoMenu.entries.forEach { menu -> + SectionArrowItem( + text = menu.text, + onClick = { onIntent(MyPageIntent.ClickServiceInfoMenu(menu)) }, + ) + } SectionArrowItem( text = "오픈소스 라이선스", onClick = { onIntent(MyPageIntent.ClickOpenSourceLicense) }, @@ -100,6 +110,10 @@ fun MyPageScreen( SectionVersionItem(uiState.appVersion) } } + + if (uiState.isLoading) { + LoadingDialog() + } } @ComponentPreview @@ -107,7 +121,7 @@ fun MyPageScreen( private fun MyPageScreenPreview() { NekiTheme { MyPageScreen( - uiState = MyPageState(), + uiState = MyPageState(appVersion = "1.1.0"), onIntent = {}, ) } diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageViewModel.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageViewModel.kt index d74fb019e..6c935dd3d 100644 --- a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageViewModel.kt +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageViewModel.kt @@ -1,18 +1,35 @@ package com.neki.android.feature.mypage.impl.main import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.neki.android.core.dataapi.repository.AuthRepository +import com.neki.android.core.dataapi.repository.TokenRepository +import com.neki.android.core.dataapi.repository.UserRepository +import com.neki.android.core.domain.usecase.UploadProfileImageUseCase import com.neki.android.core.ui.MviIntentStore import com.neki.android.core.ui.mviIntentStore +import com.neki.android.feature.mypage.impl.permission.const.NekiPermission +import com.neki.android.feature.mypage.impl.profile.model.EditProfileImageType import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @HiltViewModel -internal class MyPageViewModel @Inject constructor() : ViewModel() { +internal class MyPageViewModel @Inject constructor( + private val uploadProfileImageUseCase: UploadProfileImageUseCase, + private val userRepository: UserRepository, + private val authRepository: AuthRepository, + private val tokenRepository: TokenRepository, +) : ViewModel() { val store: MviIntentStore = mviIntentStore( initialState = MyPageState(), onIntent = ::onIntent, + initialFetchData = { store.onIntent(MyPageIntent.EnterMypageScreen) }, ) private fun onIntent( @@ -22,59 +39,163 @@ internal class MyPageViewModel @Inject constructor() : ViewModel() { postSideEffect: (MyPageEffect) -> Unit, ) { when (intent) { + MyPageIntent.EnterMypageScreen -> fetchInitialData(reduce) + is MyPageIntent.SetAppVersion -> reduce { copy(appVersion = intent.appVersion) } // MyPage Main MyPageIntent.ClickNotificationIcon -> postSideEffect(MyPageEffect.NavigateToNotification) MyPageIntent.ClickProfileCard -> postSideEffect(MyPageEffect.NavigateToProfile) MyPageIntent.ClickPermission -> postSideEffect(MyPageEffect.NavigateToPermission) - MyPageIntent.ClickInquiry -> postSideEffect(MyPageEffect.NavigateToInquiry) - MyPageIntent.ClickTermsOfService -> postSideEffect(MyPageEffect.NavigateToTermsOfService) - MyPageIntent.ClickPrivacyPolicy -> postSideEffect(MyPageEffect.NavigateToPrivacyPolicy) - MyPageIntent.ClickOpenSourceLicense -> postSideEffect(MyPageEffect.NavigateToOpenSourceLicense) + is MyPageIntent.ClickServiceInfoMenu -> postSideEffect(MyPageEffect.OpenExternalLink(intent.menu.url)) + MyPageIntent.ClickOpenSourceLicense -> postSideEffect(MyPageEffect.OpenOssLicenses) // Profile MyPageIntent.ClickBackIcon -> { - if (state.profileMode == ProfileMode.EDIT) { - reduce { copy(profileMode = ProfileMode.SETTING) } - } else { - postSideEffect(MyPageEffect.NavigateBack) - } + reduce { copy(profileImageState = EditProfileImageType.OriginalImageUrl(state.userInfo.profileImageUrl)) } + postSideEffect(MyPageEffect.NavigateBack) } - MyPageIntent.ClickEditIcon -> reduce { copy(profileMode = ProfileMode.EDIT) } + + MyPageIntent.ClickEditIcon -> postSideEffect(MyPageEffect.NavigateToEditProfile) MyPageIntent.ClickCameraIcon -> reduce { copy(isShowImageChooseDialog = true) } MyPageIntent.DismissImageChooseDialog -> reduce { copy(isShowImageChooseDialog = false) } - is MyPageIntent.SelectProfileImage -> reduce { copy(profileImageUri = intent.uri, isShowImageChooseDialog = false) } + is MyPageIntent.SelectProfileImage -> reduce { copy(profileImageState = intent.image, isShowImageChooseDialog = false) } is MyPageIntent.ClickEditComplete -> { - reduce { copy(userName = intent.nickname, profileMode = ProfileMode.SETTING) } + val isNicknameChanged = state.userInfo.nickname != intent.nickname + val isProfileImageChanged = state.profileImageState !is EditProfileImageType.OriginalImageUrl + updateProfile(state, intent.nickname, isNicknameChanged, isProfileImageChanged, reduce, postSideEffect) } + MyPageIntent.ClickLogout -> reduce { copy(isShowLogoutDialog = true) } MyPageIntent.DismissLogoutDialog -> reduce { copy(isShowLogoutDialog = false) } MyPageIntent.ConfirmLogout -> { reduce { copy(isShowLogoutDialog = false) } - // TODO: 실제 로그아웃 처리 - postSideEffect(MyPageEffect.NavigateToLogin) + logout(postSideEffect) } - MyPageIntent.ClickSignOut -> reduce { copy(isShowSignOutDialog = true) } - MyPageIntent.DismissSignOutDialog -> reduce { copy(isShowSignOutDialog = false) } - MyPageIntent.ConfirmSignOut -> { - reduce { copy(isShowSignOutDialog = false) } - // TODO: 실제 탈퇴 처리 - postSideEffect(MyPageEffect.NavigateToLogin) + + MyPageIntent.ClickWithdraw -> reduce { copy(isShowWithdrawDialog = true) } + MyPageIntent.DismissWithdrawDialog -> reduce { copy(isShowWithdrawDialog = false) } + MyPageIntent.ConfirmWithdraw -> { + reduce { copy(isShowWithdrawDialog = false) } + withdrawAccount(reduce, postSideEffect) } // Permission is MyPageIntent.ClickPermissionItem -> { - reduce { copy(isShowPermissionDialog = true, selectedPermission = intent.permission) } + postSideEffect(MyPageEffect.RequestPermission(intent.permission)) } + MyPageIntent.DismissPermissionDialog -> { - reduce { copy(isShowPermissionDialog = false, selectedPermission = null) } + reduce { copy(isShowPermissionDialog = false, clickedPermission = null) } } + MyPageIntent.ConfirmPermissionDialog -> { - val permission = state.selectedPermission - reduce { copy(isShowPermissionDialog = false, selectedPermission = null) } + val permission = state.clickedPermission + reduce { copy(isShowPermissionDialog = false, clickedPermission = null) } if (permission != null) { postSideEffect(MyPageEffect.MoveAppSettings(permission)) } } + + is MyPageIntent.UpdatePermissionState -> { + when (intent.permission) { + NekiPermission.CAMERA -> reduce { copy(isGrantedCamera = intent.isGranted) } + NekiPermission.LOCATION -> reduce { copy(isGrantedLocation = intent.isGranted) } + NekiPermission.NOTIFICATION -> reduce { copy(isGrantedNotification = intent.isGranted) } + } + } + + is MyPageIntent.ShowPermissionDeniedDialog -> { + reduce { copy(isShowPermissionDialog = true, clickedPermission = intent.permission) } + } + } + } + + private fun fetchInitialData(reduce: (MyPageState.() -> MyPageState) -> Unit) = viewModelScope.launch { + reduce { copy(isLoading = true) } + userRepository.getUserInfo() + .onSuccess { user -> + reduce { + copy( + isLoading = false, + userInfo = user, + ) + } + } + .onFailure { + Timber.e(it) + reduce { copy(isLoading = false) } + } + } + + private fun updateProfile( + state: MyPageState, + nickname: String, + isNicknameChanged: Boolean, + isProfileImageChanged: Boolean, + reduce: (MyPageState.() -> MyPageState) -> Unit, + postSideEffect: (MyPageEffect) -> Unit, + ) = viewModelScope.launch { + if (!isNicknameChanged && !isProfileImageChanged) { + postSideEffect(MyPageEffect.NavigateBack) + return@launch } + + reduce { copy(isLoading = true) } + + buildList { + if (isNicknameChanged) add(async { userRepository.updateUserInfo(nickname = nickname) }) + if (isProfileImageChanged) { + val uri = (state.profileImageState as? EditProfileImageType.ImageUri)?.uri + add(async { uploadProfileImageUseCase(uri = uri) }) + } + }.awaitAll() + + userRepository.getUserInfo() + .onSuccess { user -> + reduce { + copy( + isLoading = false, + profileImageState = EditProfileImageType.OriginalImageUrl(user.profileImageUrl), + userInfo = user, + ) + } + + if (isProfileImageChanged) { + postSideEffect(MyPageEffect.PreloadImageAndNavigateBack(user.profileImageUrl)) + } else { + postSideEffect(MyPageEffect.NavigateBack) + } + } + .onFailure { + Timber.e(it) + reduce { + copy( + isLoading = false, + profileImageState = EditProfileImageType.OriginalImageUrl(state.userInfo.profileImageUrl), + ) + } + postSideEffect(MyPageEffect.NavigateBack) + } + } + + private fun logout(postSideEffect: (MyPageEffect) -> Unit) = viewModelScope.launch { + tokenRepository.clearTokens() + postSideEffect(MyPageEffect.LogoutWithKakao) + } + + private fun withdrawAccount( + reduce: (MyPageState.() -> MyPageState) -> Unit, + postSideEffect: (MyPageEffect) -> Unit, + ) = viewModelScope.launch { + reduce { copy(isLoading = true) } + authRepository.withdrawAccount() + .onSuccess { + tokenRepository.clearTokens() + reduce { copy(isLoading = false) } + postSideEffect(MyPageEffect.UnlinkWithKakao) + } + .onFailure { + Timber.e(it) + reduce { copy(isLoading = false) } + } } } diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/component/ProfileCard.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/component/ProfileCard.kt index e782ef942..07c4c2120 100644 --- a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/component/ProfileCard.kt +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/component/ProfileCard.kt @@ -1,8 +1,6 @@ package com.neki.android.feature.mypage.impl.main.component -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -14,9 +12,12 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage import com.neki.android.core.designsystem.ComponentPreview import com.neki.android.core.designsystem.R import com.neki.android.core.designsystem.modifier.clickableSingle @@ -24,10 +25,10 @@ import com.neki.android.core.designsystem.ui.theme.NekiTheme import com.neki.android.core.ui.compose.HorizontalSpacer @Composable -fun ProfileCard( +internal fun ProfileCard( profileImageUrl: String = "", name: String, - loginType: String = "KAKAO", + loginType: String, onClickCard: () -> Unit = {}, ) { Row( @@ -37,13 +38,13 @@ fun ProfileCard( .padding(horizontal = 20.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { - Box( + AsyncImage( modifier = Modifier .size(78.dp) - .background( - color = NekiTheme.colorScheme.gray800, - shape = CircleShape, - ), + .clip(CircleShape), + model = profileImageUrl.ifEmpty { R.drawable.image_empty_profile_image }, + contentDescription = null, + contentScale = ContentScale.Crop, ) HorizontalSpacer(16.dp) Column( @@ -77,6 +78,7 @@ private fun ProfileCardPreview() { NekiTheme { ProfileCard( name = "오종석", + loginType = "KAKAO", ) } } diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/const/ServiceInfoMenu.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/const/ServiceInfoMenu.kt new file mode 100644 index 000000000..4ab6d0de5 --- /dev/null +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/const/ServiceInfoMenu.kt @@ -0,0 +1,19 @@ +package com.neki.android.feature.mypage.impl.main.const + +enum class ServiceInfoMenu( + val text: String, + val url: String, +) { + INQUIRY( + text = "Neki에 문의하기", + url = "https://tally.so/r/obGpRX", + ), + TERMS_OF_SERVICE( + text = "이용약관", + url = "https://lydian-tip-26b.notion.site/2ee0d9441db0807c8684ce3e2d4b8aca?source=copy_link", + ), + PRIVACY_POLICY( + text = "개인정보 처리방침", + url = "https://lydian-tip-26b.notion.site/2ee0d9441db0807cb850f78145db6dd3?pvs=74", + ), +} diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/navigation/MyPageEntryProvider.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/navigation/MyPageEntryProvider.kt index 3670f14fb..915afaeb2 100644 --- a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/navigation/MyPageEntryProvider.kt +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/navigation/MyPageEntryProvider.kt @@ -5,12 +5,15 @@ import androidx.navigation3.runtime.NavKey import com.neki.android.core.navigation.EntryProviderInstaller import com.neki.android.core.navigation.HiltSharedViewModelStoreNavEntryDecorator import com.neki.android.core.navigation.Navigator +import com.neki.android.core.navigation.root.RootNavKey import com.neki.android.feature.mypage.api.MyPageNavKey +import com.neki.android.feature.mypage.api.navigateToEditProfile import com.neki.android.feature.mypage.api.navigateToPermission import com.neki.android.feature.mypage.api.navigateToProfile import com.neki.android.feature.mypage.impl.main.MyPageRoute import com.neki.android.feature.mypage.impl.permission.PermissionRoute -import com.neki.android.feature.mypage.impl.profile.ProfileRoute +import com.neki.android.feature.mypage.impl.profile.EditProfileRoute +import com.neki.android.feature.mypage.impl.profile.ProfileSettingRoute import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -53,7 +56,19 @@ private fun EntryProviderScope.myPageEntry(navigator: Navigator) { MyPageNavKey.MyPage.toString(), ), ) { - ProfileRoute( + ProfileSettingRoute( + navigateBack = navigator::goBack, + navigateToEditProfile = navigator::navigateToEditProfile, + navigateToLogin = { navigator.navigateRoot(RootNavKey.Login) }, + ) + } + + entry( + metadata = HiltSharedViewModelStoreNavEntryDecorator.parent( + MyPageNavKey.MyPage.toString(), + ), + ) { + EditProfileRoute( navigateBack = navigator::goBack, ) } diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/permission/PermissionScreen.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/permission/PermissionScreen.kt index a7f83f036..19cacdd49 100644 --- a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/permission/PermissionScreen.kt +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/permission/PermissionScreen.kt @@ -1,13 +1,23 @@ package com.neki.android.feature.mypage.impl.permission +import android.os.Build +import androidx.activity.compose.LocalActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.platform.LocalContext import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.neki.android.core.common.permission.CameraPermissionManager +import com.neki.android.core.common.permission.LocationPermissionManager +import com.neki.android.core.common.permission.NotificationPermissionManager +import com.neki.android.core.common.permission.navigateToAppSettings import com.neki.android.core.designsystem.ComponentPreview import com.neki.android.core.designsystem.dialog.DoubleButtonAlertDialog import com.neki.android.core.designsystem.topbar.BackTitleTopBar @@ -15,11 +25,11 @@ import com.neki.android.core.designsystem.ui.theme.NekiTheme import com.neki.android.core.ui.compose.collectWithLifecycle import com.neki.android.feature.mypage.impl.component.PermissionSectionItem import com.neki.android.feature.mypage.impl.component.SectionTitleText -import com.neki.android.feature.mypage.impl.permission.const.NekiPermission import com.neki.android.feature.mypage.impl.main.MyPageEffect import com.neki.android.feature.mypage.impl.main.MyPageIntent import com.neki.android.feature.mypage.impl.main.MyPageState import com.neki.android.feature.mypage.impl.main.MyPageViewModel +import com.neki.android.feature.mypage.impl.permission.const.NekiPermission @Composable internal fun PermissionRoute( @@ -27,14 +37,77 @@ internal fun PermissionRoute( navigateBack: () -> Unit = {}, ) { val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() + val activity = LocalActivity.current!! + val context = LocalContext.current + + fun checkPermissions() { + NekiPermission.entries.forEach { permission -> + viewModel.store.onIntent( + MyPageIntent.UpdatePermissionState( + permission = permission, + isGranted = when (permission) { + NekiPermission.CAMERA -> CameraPermissionManager.isGrantedCameraPermission(context) + NekiPermission.LOCATION -> LocationPermissionManager.isGrantedLocationPermission(context) + NekiPermission.NOTIFICATION -> NotificationPermissionManager.isGrantedNotificationPermission(context) + }, + ), + ) + } + } + + LaunchedEffect(Unit) { + checkPermissions() + } + + LifecycleResumeEffect(Unit) { + checkPermissions() + onPauseOrDispose {} + } + + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions(), + ) { permissions -> + val permission = when { + permissions.containsKey(CameraPermissionManager.CAMERA_PERMISSION) -> NekiPermission.CAMERA + LocationPermissionManager.LOCATION_PERMISSIONS.any { permissions.containsKey(it) } -> NekiPermission.LOCATION + permissions.containsKey(NotificationPermissionManager.NOTIFICATION_PERMISSION) -> NekiPermission.NOTIFICATION + else -> return@rememberLauncherForActivityResult + } + val isGranted = permissions.values.any { it } + viewModel.store.onIntent(MyPageIntent.UpdatePermissionState(permission, isGranted)) + + if (!isGranted) { + val shouldShowRationale = when (permission) { + NekiPermission.CAMERA -> CameraPermissionManager.shouldShowCameraRationale(activity) + NekiPermission.LOCATION -> LocationPermissionManager.shouldShowLocationRationale(activity) + NekiPermission.NOTIFICATION -> NotificationPermissionManager.shouldShowNotificationRationale(activity) + } + if (!shouldShowRationale) { + viewModel.store.onIntent(MyPageIntent.ShowPermissionDeniedDialog(permission)) + } + } + } viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> when (sideEffect) { MyPageEffect.NavigateBack -> navigateBack() - is MyPageEffect.MoveAppSettings -> { - // TODO: 앱 설정 화면으로 이동 + is MyPageEffect.RequestPermission -> { + when (sideEffect.permission) { + NekiPermission.CAMERA -> permissionLauncher.launch(arrayOf(CameraPermissionManager.CAMERA_PERMISSION)) + NekiPermission.LOCATION -> permissionLauncher.launch(LocationPermissionManager.LOCATION_PERMISSIONS) + NekiPermission.NOTIFICATION -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissionLauncher.launch(arrayOf(NotificationPermissionManager.NOTIFICATION_PERMISSION)) + } else { + if (!NotificationPermissionManager.isGrantedNotificationPermission(context)) { + viewModel.store.onIntent(MyPageIntent.ShowPermissionDeniedDialog(NekiPermission.NOTIFICATION)) + } + } + } + } } + is MyPageEffect.MoveAppSettings -> navigateToAppSettings(context) else -> {} } } @@ -58,39 +131,26 @@ fun PermissionScreen( onBack = { onIntent(MyPageIntent.ClickBackIcon) }, ) SectionTitleText(text = "권한 설정") - PermissionSectionItem( - title = NekiPermission.CAMERA.title, - subTitle = NekiPermission.CAMERA.subTitle, - isGranted = uiState.isCameraGranted, - onClick = { onIntent(MyPageIntent.ClickPermissionItem(NekiPermission.CAMERA)) }, - ) - PermissionSectionItem( - title = NekiPermission.LOCATION.title, - subTitle = NekiPermission.LOCATION.subTitle, - isGranted = uiState.isLocationGranted, - onClick = { onIntent(MyPageIntent.ClickPermissionItem(NekiPermission.LOCATION)) }, - ) - PermissionSectionItem( - title = NekiPermission.STORAGE.title, - subTitle = NekiPermission.STORAGE.subTitle, - isGranted = uiState.isStorageGranted, - onClick = { onIntent(MyPageIntent.ClickPermissionItem(NekiPermission.STORAGE)) }, - ) - PermissionSectionItem( - title = NekiPermission.NOTIFICATION.title, - subTitle = NekiPermission.NOTIFICATION.subTitle, - isGranted = uiState.isNotificationGranted, - onClick = { onIntent(MyPageIntent.ClickPermissionItem(NekiPermission.NOTIFICATION)) }, - ) + NekiPermission.entries.forEach { permission -> + PermissionSectionItem( + title = permission.title, + subTitle = permission.subTitle, + isGranted = when (permission) { + NekiPermission.CAMERA -> uiState.isGrantedCamera + NekiPermission.LOCATION -> uiState.isGrantedLocation + NekiPermission.NOTIFICATION -> uiState.isGrantedNotification + }, + onClick = { onIntent(MyPageIntent.ClickPermissionItem(permission)) }, + ) + } } - if (uiState.isShowPermissionDialog && uiState.selectedPermission != null) { + if (uiState.isShowPermissionDialog && uiState.clickedPermission != null) { DoubleButtonAlertDialog( - title = uiState.selectedPermission.title, - content = uiState.selectedPermission.subTitle, + title = uiState.clickedPermission.title, + content = uiState.clickedPermission.dialogContent, grayButtonText = "취소", - primaryButtonText = "확인", - properties = DialogProperties(usePlatformDefaultWidth = false), + primaryButtonText = "허용", onDismissRequest = { onIntent(MyPageIntent.DismissPermissionDialog) }, onClickGrayButton = { onIntent(MyPageIntent.DismissPermissionDialog) }, onClickPrimaryButton = { onIntent(MyPageIntent.ConfirmPermissionDialog) }, diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/permission/const/NekiPermission.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/permission/const/NekiPermission.kt index 48b4d1d3d..85ce065b7 100644 --- a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/permission/const/NekiPermission.kt +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/permission/const/NekiPermission.kt @@ -3,21 +3,21 @@ package com.neki.android.feature.mypage.impl.permission.const enum class NekiPermission( val title: String, val subTitle: String, + val dialogContent: String, ) { CAMERA( title = "카메라", subTitle = "QR 촬영에 필요해요.", + dialogContent = "카메라 권한을 허용해주세요.", ), LOCATION( title = "위치", subTitle = "주변 포토부스 탐색에 필요해요.", - ), - STORAGE( - title = "저장소", - subTitle = "사진 저장 및 업로드에 필요해요.", + dialogContent = "위치 권한을 허용해주세요.", ), NOTIFICATION( title = "알림", subTitle = "저장 사진 및 추억 리마인드에 필요해요.", + dialogContent = "알림 권한을 허용해주세요.", ), } diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/EditProfileScreen.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/EditProfileScreen.kt new file mode 100644 index 000000000..c8308f518 --- /dev/null +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/EditProfileScreen.kt @@ -0,0 +1,202 @@ +package com.neki.android.feature.mypage.impl.profile + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.maxLength +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.Text +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.ui.component.LoadingDialog +import com.neki.android.core.ui.compose.collectWithLifecycle +import coil3.imageLoader +import coil3.request.ImageRequest +import com.neki.android.core.designsystem.R +import com.neki.android.feature.mypage.impl.main.MyPageEffect +import com.neki.android.feature.mypage.impl.main.MyPageIntent +import com.neki.android.feature.mypage.impl.main.MyPageState +import com.neki.android.feature.mypage.impl.main.MyPageViewModel +import com.neki.android.feature.mypage.impl.profile.model.EditProfileImageType +import com.neki.android.feature.mypage.impl.profile.component.EditProfileImage +import com.neki.android.feature.mypage.impl.profile.component.ProfileEditTopBar +import com.neki.android.feature.mypage.impl.profile.component.ProfileImageChooseDialog +import timber.log.Timber + +@Composable +internal fun EditProfileRoute( + viewModel: MyPageViewModel = hiltViewModel(), + navigateBack: () -> Unit, +) { + val context = LocalContext.current + val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() + + viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> + when (sideEffect) { + MyPageEffect.NavigateBack -> navigateBack() + is MyPageEffect.PreloadImageAndNavigateBack -> { + val request = ImageRequest.Builder(context) + .data(sideEffect.url) + .build() + context.imageLoader.execute(request) + navigateBack() + } + else -> {} + } + } + + EditProfileScreen( + uiState = uiState, + onIntent = viewModel.store::onIntent, + ) +} + +@Composable +fun EditProfileScreen( + uiState: MyPageState = MyPageState(), + onIntent: (MyPageIntent) -> Unit = {}, +) { + var displayProfileImage by remember { + mutableStateOf(uiState.userInfo.profileImageUrl) + } + + LaunchedEffect(uiState.profileImageState) { + when (uiState.profileImageState) { + is EditProfileImageType.OriginalImageUrl -> {} + is EditProfileImageType.ImageUri -> displayProfileImage = uiState.profileImageState.uri + EditProfileImageType.Default -> displayProfileImage = R.drawable.image_empty_profile_image + } + } + + val textFieldState = rememberTextFieldState(uiState.userInfo.nickname) + + val photoPicker = rememberLauncherForActivityResult(contract = ActivityResultContracts.PickVisualMedia()) { uri -> + if (uri != null) { + onIntent(MyPageIntent.SelectProfileImage(EditProfileImageType.ImageUri(uri))) + } else { + Timber.d("No media selected") + } + } + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ProfileEditTopBar( + enabled = textFieldState.text.isNotEmpty(), + onBack = { onIntent(MyPageIntent.ClickBackIcon) }, + onClickComplete = { + onIntent(MyPageIntent.ClickEditComplete(nickname = textFieldState.text.toString())) + }, + ) + EditProfileImage( + profileImage = displayProfileImage, + onClickCameraIcon = { onIntent(MyPageIntent.ClickCameraIcon) }, + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = "닉네임", + style = NekiTheme.typography.body14Medium, + color = NekiTheme.colorScheme.gray700, + ) + BasicTextField( + state = textFieldState, + modifier = Modifier + .fillMaxWidth() + .background( + color = NekiTheme.colorScheme.white, + shape = RoundedCornerShape(8.dp), + ) + .border( + width = 1.dp, + color = if (textFieldState.text.isEmpty()) NekiTheme.colorScheme.gray75 else NekiTheme.colorScheme.gray700, + shape = RoundedCornerShape(8.dp), + ) + .padding(horizontal = 16.dp, vertical = 13.dp), + textStyle = NekiTheme.typography.body16Medium.copy( + color = NekiTheme.colorScheme.gray900, + ), + inputTransformation = InputTransformation.maxLength(12), + cursorBrush = SolidColor(NekiTheme.colorScheme.gray800), + lineLimits = TextFieldLineLimits.SingleLine, + decorator = { innerTextField -> + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Box(modifier = Modifier.weight(1f)) { + if (textFieldState.text.isEmpty()) { + Text( + text = "닉네임을 입력해주세요.", + style = NekiTheme.typography.body16Regular, + color = NekiTheme.colorScheme.gray300, + ) + } + innerTextField() + } + Text( + text = "${textFieldState.text.length}/12", + style = NekiTheme.typography.caption12Regular, + color = NekiTheme.colorScheme.gray300, + ) + } + }, + ) + } + } + + if (uiState.isShowImageChooseDialog) { + ProfileImageChooseDialog( + onDismissRequest = { onIntent(MyPageIntent.DismissImageChooseDialog) }, + onClickDefaultProfile = { onIntent(MyPageIntent.SelectProfileImage(EditProfileImageType.Default)) }, + onClickSelectPhoto = { + onIntent(MyPageIntent.DismissImageChooseDialog) + photoPicker.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), + ) + }, + ) + } + + if (uiState.isLoading) { + LoadingDialog() + } +} + +@ComponentPreview +@Composable +private fun EditProfileScreenPreview() { + NekiTheme { + EditProfileScreen() + } +} diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/ProfileScreen.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/ProfileScreen.kt deleted file mode 100644 index f7608f608..000000000 --- a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/ProfileScreen.kt +++ /dev/null @@ -1,339 +0,0 @@ -package com.neki.android.feature.mypage.impl.profile - -import android.net.Uri -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.input.InputTransformation -import androidx.compose.foundation.text.input.TextFieldLineLimits -import androidx.compose.foundation.text.input.maxLength -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.PickVisualMediaRequest -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.DialogProperties -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil3.compose.AsyncImage -import com.neki.android.core.designsystem.ComponentPreview -import com.neki.android.core.designsystem.R -import com.neki.android.core.designsystem.dialog.DoubleButtonAlertDialog -import com.neki.android.core.designsystem.modifier.noRippleClickableSingle -import com.neki.android.core.designsystem.ui.theme.NekiTheme -import com.neki.android.core.ui.compose.VerticalSpacer -import com.neki.android.core.ui.compose.collectWithLifecycle -import com.neki.android.feature.mypage.impl.component.SectionItem -import com.neki.android.feature.mypage.impl.component.SectionTitleText -import com.neki.android.feature.mypage.impl.main.MyPageEffect -import com.neki.android.feature.mypage.impl.main.MyPageIntent -import com.neki.android.feature.mypage.impl.main.MyPageState -import com.neki.android.feature.mypage.impl.main.MyPageViewModel -import com.neki.android.feature.mypage.impl.main.ProfileMode -import com.neki.android.feature.mypage.impl.profile.component.ProfileEditTopBar -import com.neki.android.feature.mypage.impl.profile.component.ProfileImageChooseDialog -import com.neki.android.feature.mypage.impl.profile.component.ProfileSettingTopBar -import timber.log.Timber - -@Composable -internal fun ProfileRoute( - viewModel: MyPageViewModel = hiltViewModel(), - navigateBack: () -> Unit, -) { - val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() - - viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> - when (sideEffect) { - MyPageEffect.NavigateBack -> navigateBack() - MyPageEffect.NavigateToLogin -> { - // TODO: 로그인 화면으로 이동 - } - - else -> {} - } - } - - ProfileScreen( - uiState = uiState, - onIntent = viewModel.store::onIntent, - ) -} - -@Composable -fun ProfileScreen( - uiState: MyPageState = MyPageState(), - onIntent: (MyPageIntent) -> Unit = {}, -) { - Column( - modifier = Modifier.fillMaxSize(), - ) { - when (uiState.profileMode) { - ProfileMode.SETTING -> { - ProfileSettingContent( - userName = uiState.userName, - onBack = { onIntent(MyPageIntent.ClickBackIcon) }, - onClickEdit = { onIntent(MyPageIntent.ClickEditIcon) }, - onClickLogout = { onIntent(MyPageIntent.ClickLogout) }, - onClickSignOut = { onIntent(MyPageIntent.ClickSignOut) }, - ) - } - - ProfileMode.EDIT -> { - ProfileEditContent( - initialNickname = uiState.userName, - profileImageUri = uiState.profileImageUri, - isShowImageChooseDialog = uiState.isShowImageChooseDialog, - onBack = { onIntent(MyPageIntent.ClickBackIcon) }, - onClickCameraIcon = { onIntent(MyPageIntent.ClickCameraIcon) }, - onDismissImageChooseDialog = { onIntent(MyPageIntent.DismissImageChooseDialog) }, - onSelectImage = { uri -> onIntent(MyPageIntent.SelectProfileImage(uri)) }, - onComplete = { nickname -> onIntent(MyPageIntent.ClickEditComplete(nickname)) }, - ) - } - } - } - - if (uiState.isShowLogoutDialog) { - DoubleButtonAlertDialog( - title = "로그아웃을 하시겠습니까?", - content = "다시 로그인해야 서비스를 이용할 수 있어요.", - grayButtonText = "취소", - primaryButtonText = "확인", - properties = DialogProperties(usePlatformDefaultWidth = false), - onDismissRequest = { onIntent(MyPageIntent.DismissLogoutDialog) }, - onClickGrayButton = { onIntent(MyPageIntent.DismissLogoutDialog) }, - onClickPrimaryButton = { onIntent(MyPageIntent.ConfirmLogout) }, - ) - } - - if (uiState.isShowSignOutDialog) { - DoubleButtonAlertDialog( - title = "정말 탈퇴하시겠어요?", - content = "계정을 탈퇴하면 사진과 정보가 모두 삭제되며,\n삭제된 데이터는 복구할 수 없어요.", - grayButtonText = "취소", - primaryButtonText = "탈퇴 확정", - properties = DialogProperties(usePlatformDefaultWidth = false), - onDismissRequest = { onIntent(MyPageIntent.DismissSignOutDialog) }, - onClickGrayButton = { onIntent(MyPageIntent.DismissSignOutDialog) }, - onClickPrimaryButton = { onIntent(MyPageIntent.ConfirmSignOut) }, - ) - } -} - -@Composable -private fun ProfileSettingContent( - userName: String, - onBack: () -> Unit, - onClickEdit: () -> Unit, - onClickLogout: () -> Unit, - onClickSignOut: () -> Unit, -) { - ProfileSettingTopBar( - onBack = onBack, - ) - Box( - modifier = Modifier - .fillMaxWidth() - .padding(top = 20.dp, bottom = 16.dp), - contentAlignment = Alignment.Center, - ) { - AsyncImage( - modifier = Modifier - .size(142.dp) - .clip(CircleShape), - model = R.drawable.icon_life_four_cut, - contentDescription = null, - ) - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = userName, - style = NekiTheme.typography.title20Medium, - color = NekiTheme.colorScheme.gray900, - ) - Icon( - modifier = Modifier.noRippleClickableSingle { onClickEdit() }, - imageVector = ImageVector.vectorResource(R.drawable.icon_edit), - contentDescription = null, - tint = Color.Unspecified, - ) - } - VerticalSpacer(27.dp) - SectionTitleText(text = "서비스 정보 및 지원") - SectionItem( - text = "로그아웃", - onClick = onClickLogout, - ) - SectionItem( - text = "탈퇴하기", - onClick = onClickSignOut, - ) -} - -@Composable -private fun ProfileEditContent( - initialNickname: String, - profileImageUri: Uri?, - isShowImageChooseDialog: Boolean, - onBack: () -> Unit, - onClickCameraIcon: () -> Unit, - onDismissImageChooseDialog: () -> Unit, - onSelectImage: (Uri?) -> Unit, - onComplete: (String) -> Unit, -) { - val textFieldState = rememberTextFieldState(initialNickname) - - val photoPicker = rememberLauncherForActivityResult(contract = ActivityResultContracts.PickVisualMedia()) { uri -> - if (uri != null) { - onSelectImage(uri) - } else { - Timber.d("No media selected") - } - } - - ProfileEditTopBar( - enabled = textFieldState.text.isNotEmpty(), - onBack = onBack, - onClickComplete = { onComplete(textFieldState.text.toString()) }, - ) - Box( - modifier = Modifier - .fillMaxWidth() - .padding(top = 20.dp, bottom = 16.dp), - contentAlignment = Alignment.Center, - ) { - AsyncImage( - modifier = Modifier - .size(142.dp) - .clip(CircleShape), - model = profileImageUri ?: R.drawable.icon_life_four_cut, - contentDescription = null, - ) - Box( - modifier = Modifier - .padding(start = 102.dp, top = 102.dp) - .border( - width = 1.dp, - shape = CircleShape, - color = NekiTheme.colorScheme.primary400, - ) - .background( - color = NekiTheme.colorScheme.white, - shape = CircleShape, - ) - .padding(8.dp) - .noRippleClickableSingle(onClick = onClickCameraIcon), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = ImageVector.vectorResource(R.drawable.icon_camera), - contentDescription = null, - tint = NekiTheme.colorScheme.primary400, - ) - } - } - - if (isShowImageChooseDialog) { - ProfileImageChooseDialog( - onDismissRequest = onDismissImageChooseDialog, - onClickDefaultProfile = { onSelectImage(null) }, - onClickSelectPhoto = { - onDismissImageChooseDialog() - photoPicker.launch( - PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), - ) - }, - ) - } - VerticalSpacer(28.dp) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - Text( - text = "닉네임", - style = NekiTheme.typography.body14Medium, - color = NekiTheme.colorScheme.gray700, - ) - BasicTextField( - state = textFieldState, - modifier = Modifier - .fillMaxWidth() - .background( - color = NekiTheme.colorScheme.white, - shape = RoundedCornerShape(8.dp), - ) - .border( - width = 1.dp, - color = if (textFieldState.text.isEmpty()) NekiTheme.colorScheme.gray75 else NekiTheme.colorScheme.gray700, - shape = RoundedCornerShape(8.dp), - ) - .padding(horizontal = 16.dp, vertical = 13.dp), - textStyle = NekiTheme.typography.body16Medium.copy( - color = NekiTheme.colorScheme.gray900, - ), - inputTransformation = InputTransformation.maxLength(10), - cursorBrush = SolidColor(NekiTheme.colorScheme.gray800), - lineLimits = TextFieldLineLimits.SingleLine, - decorator = { innerTextField -> - Box { - if (textFieldState.text.isEmpty()) { - Text( - text = "닉네임을 입력해주세요.", - style = NekiTheme.typography.body16Regular, - color = NekiTheme.colorScheme.gray300, - ) - } - innerTextField() - } - }, - ) - } -} - -@ComponentPreview -@Composable -private fun ProfileScreenSettingPreview() { - NekiTheme { - ProfileScreen( - uiState = MyPageState(profileMode = ProfileMode.SETTING), - ) - } -} - -@ComponentPreview -@Composable -private fun ProfileScreenEditPreview() { - NekiTheme { - ProfileScreen( - uiState = MyPageState(profileMode = ProfileMode.EDIT), - ) - } -} diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/ProfileSettingScreen.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/ProfileSettingScreen.kt new file mode 100644 index 000000000..6fcf446d9 --- /dev/null +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/ProfileSettingScreen.kt @@ -0,0 +1,133 @@ +package com.neki.android.feature.mypage.impl.profile + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.window.DialogProperties +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.dialog.DoubleButtonAlertDialog +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.ui.component.LoadingDialog +import com.neki.android.core.ui.compose.collectWithLifecycle +import com.neki.android.feature.mypage.impl.component.SectionItem +import com.neki.android.feature.mypage.impl.component.SectionTitleText +import com.neki.android.feature.mypage.impl.main.MyPageEffect +import com.neki.android.feature.mypage.impl.main.MyPageIntent +import com.neki.android.feature.mypage.impl.main.MyPageState +import com.neki.android.feature.mypage.impl.main.MyPageViewModel +import com.neki.android.feature.mypage.impl.profile.component.ProfileSettingTopBar +import com.neki.android.feature.mypage.impl.profile.component.SettingProfileImage +import com.neki.android.core.common.kakao.KakaoAuthHelper +import timber.log.Timber + +@Composable +internal fun ProfileSettingRoute( + viewModel: MyPageViewModel = hiltViewModel(), + navigateBack: () -> Unit, + navigateToEditProfile: () -> Unit, + navigateToLogin: () -> Unit, +) { + val context = LocalContext.current + val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() + val kakaoAuthHelper = remember { KakaoAuthHelper(context) } + + viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> + when (sideEffect) { + MyPageEffect.NavigateBack -> navigateBack() + MyPageEffect.NavigateToEditProfile -> navigateToEditProfile() + MyPageEffect.NavigateToLogin -> navigateToLogin() + MyPageEffect.LogoutWithKakao -> { + kakaoAuthHelper.logout( + onSuccess = { navigateToLogin() }, + onFailure = { Timber.e(it) }, + ) + } + MyPageEffect.UnlinkWithKakao -> { + kakaoAuthHelper.unlink( + onSuccess = { navigateToLogin() }, + onFailure = { Timber.e(it) }, + ) + } + else -> {} + } + } + + ProfileSettingScreen( + uiState = uiState, + onIntent = viewModel.store::onIntent, + ) +} + +@Composable +fun ProfileSettingScreen( + uiState: MyPageState = MyPageState(), + onIntent: (MyPageIntent) -> Unit = {}, +) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ProfileSettingTopBar( + onBack = { onIntent(MyPageIntent.ClickBackIcon) }, + ) + SettingProfileImage( + nickname = uiState.userInfo.nickname, + profileImage = uiState.userInfo.profileImageUrl, + onClickEdit = { onIntent(MyPageIntent.ClickEditIcon) }, + ) + SectionTitleText(text = "서비스 정보 및 지원") + SectionItem( + text = "로그아웃", + onClick = { onIntent(MyPageIntent.ClickLogout) }, + ) + SectionItem( + text = "탈퇴하기", + onClick = { onIntent(MyPageIntent.ClickWithdraw) }, + ) + } + + if (uiState.isShowLogoutDialog) { + DoubleButtonAlertDialog( + title = "로그아웃을 하시겠습니까?", + content = "다시 로그인해야 서비스를 이용할 수 있어요.", + grayButtonText = "취소", + primaryButtonText = "확인", + properties = DialogProperties(usePlatformDefaultWidth = false), + onDismissRequest = { onIntent(MyPageIntent.DismissLogoutDialog) }, + onClickGrayButton = { onIntent(MyPageIntent.DismissLogoutDialog) }, + onClickPrimaryButton = { onIntent(MyPageIntent.ConfirmLogout) }, + ) + } + + if (uiState.isShowWithdrawDialog) { + DoubleButtonAlertDialog( + title = "정말 탈퇴하시겠어요?", + content = "계정을 탈퇴하면 사진과 정보가 모두 삭제되며,\n삭제된 데이터는 복구할 수 없어요.", + grayButtonText = "취소", + primaryButtonText = "탈퇴 확정", + properties = DialogProperties(usePlatformDefaultWidth = false), + onDismissRequest = { onIntent(MyPageIntent.DismissWithdrawDialog) }, + onClickGrayButton = { onIntent(MyPageIntent.DismissWithdrawDialog) }, + onClickPrimaryButton = { onIntent(MyPageIntent.ConfirmWithdraw) }, + ) + } + + if (uiState.isLoading) { + LoadingDialog() + } +} + +@ComponentPreview +@Composable +private fun ProfileSettingScreenPreview() { + NekiTheme { + ProfileSettingScreen() + } +} diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/component/EditProfileImage.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/component/EditProfileImage.kt new file mode 100644 index 000000000..4e37d9399 --- /dev/null +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/component/EditProfileImage.kt @@ -0,0 +1,75 @@ +package com.neki.android.feature.mypage.impl.profile.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.noRippleClickableSingle +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun EditProfileImage( + profileImage: Any? = null, + imageSize: Dp = 142.dp, + onClickCameraIcon: () -> Unit, +) { + Box( + modifier = Modifier.padding(top = 20.dp, bottom = 28.dp), + ) { + AsyncImage( + modifier = Modifier + .size(imageSize) + .clip(CircleShape), + model = profileImage ?: R.drawable.image_empty_profile_image, + contentDescription = null, + contentScale = ContentScale.Crop, + ) + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .border( + width = 1.dp, + shape = CircleShape, + color = NekiTheme.colorScheme.primary400, + ) + .background( + color = NekiTheme.colorScheme.white, + shape = CircleShape, + ) + .padding(8.dp) + .noRippleClickableSingle(onClick = onClickCameraIcon), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.icon_camera), + contentDescription = null, + tint = NekiTheme.colorScheme.primary400, + ) + } + } +} + +@ComponentPreview +@Composable +private fun EditProfileImagePreview() { + NekiTheme { + EditProfileImage( + onClickCameraIcon = {}, + ) + } +} diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/component/SettingProfileImage.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/component/SettingProfileImage.kt new file mode 100644 index 000000000..a3f1787e8 --- /dev/null +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/component/SettingProfileImage.kt @@ -0,0 +1,76 @@ +package com.neki.android.feature.mypage.impl.profile.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.noRippleClickableSingle +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.ui.compose.VerticalSpacer + +@Composable +internal fun SettingProfileImage( + nickname: String, + profileImage: Any? = null, + imageSize: Dp = 142.dp, + onClickEdit: () -> Unit, +) { + Column( + modifier = Modifier.padding(top = 20.dp, bottom = 27.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AsyncImage( + modifier = Modifier + .size(imageSize) + .clip(CircleShape), + model = profileImage ?: R.drawable.image_empty_profile_image, + contentDescription = null, + contentScale = ContentScale.Crop, + ) + VerticalSpacer(16.dp) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = nickname, + style = NekiTheme.typography.title20Medium, + color = NekiTheme.colorScheme.gray900, + ) + Icon( + modifier = Modifier.noRippleClickableSingle(onClick = onClickEdit), + imageVector = ImageVector.vectorResource(R.drawable.icon_edit), + contentDescription = null, + tint = Color.Unspecified, + ) + } + } +} + +@ComponentPreview +@Composable +private fun SettingProfileImagePreview() { + NekiTheme { + SettingProfileImage( + nickname = "네키네키", + onClickEdit = {}, + ) + } +} diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/model/EditProfileImageType.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/model/EditProfileImageType.kt new file mode 100644 index 000000000..fa09955b8 --- /dev/null +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/profile/model/EditProfileImageType.kt @@ -0,0 +1,9 @@ +package com.neki.android.feature.mypage.impl.profile.model + +import android.net.Uri + +sealed interface EditProfileImageType { + data class OriginalImageUrl(val url: String) : EditProfileImageType + data class ImageUri(val uri: Uri) : EditProfileImageType + data object Default : EditProfileImageType +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ce6124c21..cc46f00d4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -37,6 +37,8 @@ barcodeScanning = "17.3.0" kakao = "2.23.1" coil = "3.3.0" haze = "1.7.1" +ossLicensesLib = "17.2.1" +ossLicensesPlugin = "0.10.7" [libraries] androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "cameraX" } @@ -107,6 +109,8 @@ haze-materials = { group = "dev.chrisbanes.haze", name = "haze-materials", versi androidx-paging-runtime = { group = "androidx.paging", name = "paging-runtime", version.ref = "paging" } +oss-licenses = { group = "com.google.android.gms", name = "play-services-oss-licenses", version.ref = "ossLicensesLib" } + # Dependencies of the included build-logic kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } android-gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" } @@ -133,3 +137,4 @@ ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } android-library = { id = "com.android.library", version.ref = "agp" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } +oss-licenses = { id = "com.google.android.gms.oss-licenses-plugin", version.ref = "ossLicensesPlugin" }