diff --git a/app/src/main/java/com/neki/android/app/AuthScreen.kt b/app/src/main/java/com/neki/android/app/AuthScreen.kt new file mode 100644 index 000000000..664789b99 --- /dev/null +++ b/app/src/main/java/com/neki/android/app/AuthScreen.kt @@ -0,0 +1,30 @@ +package com.neki.android.app + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Modifier +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.ui.NavDisplay + +@Composable +fun AuthScreen( + entries: SnapshotStateList>, + onBack: () -> Unit, +) { + Scaffold( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding(), + ) { innerPadding -> + NavDisplay( + modifier = Modifier.padding(innerPadding), + entries = entries, + onBack = onBack, + ) + } +} diff --git a/app/src/main/java/com/neki/android/app/MainActivity.kt b/app/src/main/java/com/neki/android/app/MainActivity.kt index 9116b7619..1adc359ec 100644 --- a/app/src/main/java/com/neki/android/app/MainActivity.kt +++ b/app/src/main/java/com/neki/android/app/MainActivity.kt @@ -20,7 +20,9 @@ import com.neki.android.core.navigation.result.ResultEventBus import com.neki.android.core.navigation.root.RootNavKey import com.neki.android.core.navigation.root.RootNavigationState import com.neki.android.core.navigation.toEntries -import com.neki.android.feature.auth.impl.LoginRoute +import com.neki.android.core.navigation.auth.AuthNavigatorImpl +import com.neki.android.core.navigation.auth.toEntries +import com.neki.android.feature.auth.impl.navigation.authEntryProvider import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import javax.inject.Inject @@ -31,6 +33,9 @@ class MainActivity : ComponentActivity() { @Inject lateinit var rootNavigationState: RootNavigationState + @Inject + lateinit var authNavigator: AuthNavigatorImpl + @Inject lateinit var navigator: NavigatorImpl @@ -52,9 +57,14 @@ class MainActivity : ComponentActivity() { NekiTheme { CompositionLocalProvider(LocalResultEventBus provides resultBus) { when (rootNavigationState.currentRootKey) { - RootNavKey.Login -> { - LoginRoute( - navigateToMain = { navigator.navigateRoot(RootNavKey.Main) }, + RootNavKey.Auth -> { + AuthScreen( + entries = authNavigator.state.toEntries( + entryProvider = entryProvider { + authEntryProvider(authNavigator).invoke(this) + }, + ), + onBack = { authNavigator.goBack() }, ) } @@ -70,7 +80,7 @@ class MainActivity : ComponentActivity() { ), onTabSelected = { navigator.navigate(it) }, onBack = { navigator.goBack() }, - navigateToLogin = { navigator.navigateRoot(RootNavKey.Login) }, + navigateToLogin = { navigator.navigateRoot(RootNavKey.Auth) }, ) } } @@ -92,7 +102,7 @@ class MainActivity : ComponentActivity() { Toast.LENGTH_SHORT, ).show() - navigator.navigateRoot(RootNavKey.Login) + navigator.navigateRoot(RootNavKey.Auth) } } } 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 f28a13f8a..ccdc2f86d 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,9 +1,11 @@ package com.neki.android.app.navigation.di +import com.neki.android.app.navigation.keys.START_AUTH_NAV_KEY 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.auth.AuthNavigationState import com.neki.android.core.navigation.root.RootNavigationState import dagger.Module import dagger.Provides @@ -17,18 +19,26 @@ internal object NavigationModule { @Provides @ActivityRetainedScoped - fun providesNavigationState(): NavigationState { - return NavigationState( - startKey = START_NAV_KEY, - topLevelKeys = TOP_LEVEL_NAV_KEYS.toSet(), + fun providesRootNavigationState(): RootNavigationState { + return RootNavigationState( + startKey = START_ROOT_NAV_KEY, ) } @Provides @ActivityRetainedScoped - fun providesRootNavigationState(): RootNavigationState { - return RootNavigationState( - startKey = START_ROOT_NAV_KEY, + fun providesAuthNavigationState(): AuthNavigationState { + return AuthNavigationState( + startKey = START_AUTH_NAV_KEY, + ) + } + + @Provides + @ActivityRetainedScoped + fun providesNavigationState(): NavigationState { + return NavigationState( + startKey = START_NAV_KEY, + topLevelKeys = TOP_LEVEL_NAV_KEYS.toSet(), ) } } 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 50a2d285c..b28a628ce 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 @@ -3,7 +3,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 +import com.neki.android.feature.auth.api.AuthNavKey -internal val START_ROOT_NAV_KEY = RootNavKey.Login +internal val START_ROOT_NAV_KEY = RootNavKey.Auth +internal val START_AUTH_NAV_KEY = AuthNavKey.Splash internal val START_NAV_KEY = ArchiveNavKey.Archive internal val TOP_LEVEL_NAV_KEYS = TopLevelNavItem.entries.map { it.navKey } 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 index c75f5b2d0..246eccc21 100644 --- 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 @@ -3,10 +3,9 @@ package com.neki.android.core.common.kakao import android.content.Context import com.kakao.sdk.user.UserApiClient -class KakaoAuthHelper( - private val context: Context, -) { +object KakaoAuthHelper { fun login( + context: Context, onSuccess: (String) -> Unit, onFailure: (String) -> Unit, ) { 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 deleted file mode 100644 index e145d556f..000000000 --- a/core/data-api/src/main/java/com/neki/android/core/dataapi/datastore/DataStoreKey.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.neki.android.core.dataapi.datastore - -import androidx.datastore.preferences.core.stringPreferencesKey - -object DataStoreKey { - val ACCESS_TOKEN = stringPreferencesKey("access_token") - val REFRESH_TOKEN = stringPreferencesKey("refresh_token") -} 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 dfff3f25c..a39d26993 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 @@ -1,9 +1,13 @@ package com.neki.android.core.dataapi.repository import com.neki.android.core.model.Auth +import kotlinx.coroutines.flow.Flow interface AuthRepository { suspend fun loginWithKakao(idToken: String): Result suspend fun updateAccessToken(refreshToken: String): Result suspend fun withdrawAccount(): Result + + fun hasCompletedOnboarding(): Flow + suspend fun setCompletedOnboarding(value: Boolean) } diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/DataStoreRepository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/DataStoreRepository.kt deleted file mode 100644 index 2ce25268d..000000000 --- a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/DataStoreRepository.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.neki.android.core.dataapi.repository - -import androidx.datastore.preferences.core.Preferences -import kotlinx.coroutines.flow.Flow - -interface DataStoreRepository { - suspend fun setBoolean(key: Preferences.Key, value: Boolean) - fun getBoolean(key: Preferences.Key): Flow -} diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/TokenRepository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/TokenRepository.kt index a0227f5df..2518c6a39 100644 --- a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/TokenRepository.kt +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/TokenRepository.kt @@ -7,7 +7,7 @@ interface TokenRepository { accessToken: String, refreshToken: String, ) - fun isSavedTokens(): Flow + fun hasTokens(): Flow fun getAccessToken(): Flow fun getRefreshToken(): Flow suspend fun clearTokens() 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 7622d34ee..dc15fae4c 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 @@ -88,7 +88,7 @@ internal object NetworkModule { install(Auth) { bearer { loadTokens { - if (tokenRepository.isSavedTokens().first()) { + if (tokenRepository.hasTokens().first()) { BearerTokens( accessToken = tokenRepository.getAccessToken().first(), refreshToken = tokenRepository.getRefreshToken().first(), 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 76b47b01a..4458da5ab 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 @@ -2,7 +2,6 @@ package com.neki.android.core.data.repository.di import com.neki.android.core.data.auth.AuthEventManagerImpl import com.neki.android.core.data.repository.impl.AuthRepositoryImpl -import com.neki.android.core.data.repository.impl.DataStoreRepositoryImpl import com.neki.android.core.data.repository.impl.MediaUploadRepositoryImpl import com.neki.android.core.data.repository.impl.FolderRepositoryImpl import com.neki.android.core.data.repository.impl.MapRepositoryImpl @@ -13,7 +12,6 @@ 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 -import com.neki.android.core.dataapi.repository.DataStoreRepository import com.neki.android.core.dataapi.repository.MediaUploadRepository import com.neki.android.core.dataapi.repository.MapRepository import com.neki.android.core.dataapi.repository.PhotoRepository @@ -30,12 +28,6 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) internal interface RepositoryModule { - @Binds - @Singleton - fun bindDataStoreRepositoryImpl( - dataStoreRepositoryImpl: DataStoreRepositoryImpl, - ): DataStoreRepository - @Binds @Singleton fun bindAuthRepositoryImpl( 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 efe52e23a..fad4a521f 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,14 +1,22 @@ package com.neki.android.core.data.repository.impl +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import com.neki.android.core.data.local.di.AuthDataStore 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 import com.neki.android.core.dataapi.repository.AuthRepository import com.neki.android.core.model.Auth +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import javax.inject.Inject class AuthRepositoryImpl @Inject constructor( + @AuthDataStore private val dataStore: DataStore, private val authService: AuthService, ) : AuthRepository { override suspend fun loginWithKakao(idToken: String): Result = runSuspendCatching { @@ -30,4 +38,20 @@ class AuthRepositoryImpl @Inject constructor( override suspend fun withdrawAccount(): Result = runSuspendCatching { authService.withdrawAccount() } + + override fun hasCompletedOnboarding(): Flow { + return dataStore.data.map { preferences -> + preferences[HAS_COMPLETED_ONBOARDING] ?: false + } + } + + override suspend fun setCompletedOnboarding(value: Boolean) { + dataStore.edit { preferences -> + preferences[HAS_COMPLETED_ONBOARDING] = value + } + } + + companion object { + private val HAS_COMPLETED_ONBOARDING = booleanPreferencesKey("has_completed_onboarding") + } } diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/DataStoreRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/DataStoreRepositoryImpl.kt deleted file mode 100644 index 64bd4ca1c..000000000 --- a/core/data/src/main/java/com/neki/android/core/data/repository/impl/DataStoreRepositoryImpl.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.neki.android.core.data.repository.impl - -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit -import com.neki.android.core.dataapi.repository.DataStoreRepository -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map -import com.neki.android.core.data.local.di.AuthDataStore -import javax.inject.Inject - -class DataStoreRepositoryImpl @Inject constructor( - @AuthDataStore private val dataStore: DataStore, -) : DataStoreRepository { - - override suspend fun setBoolean(key: Preferences.Key, value: Boolean) { - dataStore.edit { preferences -> - preferences[key] = value - } - } - - override fun getBoolean(key: Preferences.Key): Flow { - return dataStore.data.map { preferences -> - preferences[key] ?: false - } - } -} 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 0c0d390ec..e17495b51 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 @@ -3,8 +3,8 @@ package com.neki.android.core.data.repository.impl import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey 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 @@ -16,21 +16,27 @@ class TokenRepositoryImpl @Inject constructor( @TokenDataStore private val dataStore: DataStore, private val authCacheManager: AuthCacheManager, ) : TokenRepository { + + companion object { + val ACCESS_TOKEN = stringPreferencesKey("access_token") + val REFRESH_TOKEN = stringPreferencesKey("refresh_token") + } + override suspend fun saveTokens( accessToken: String, refreshToken: String, ) { dataStore.edit { preferences -> - preferences[DataStoreKey.ACCESS_TOKEN] = CryptoManager.encrypt(accessToken) - preferences[DataStoreKey.REFRESH_TOKEN] = CryptoManager.encrypt(refreshToken) + preferences[ACCESS_TOKEN] = CryptoManager.encrypt(accessToken) + preferences[REFRESH_TOKEN] = CryptoManager.encrypt(refreshToken) } authCacheManager.invalidateTokenCache() } - override fun isSavedTokens(): Flow { + override fun hasTokens(): Flow { return dataStore.data.map { preferences -> - val accessToken = preferences[DataStoreKey.ACCESS_TOKEN]?.let { CryptoManager.decrypt(it) } - val refreshToken = preferences[DataStoreKey.REFRESH_TOKEN]?.let { CryptoManager.decrypt(it) } + val accessToken = preferences[ACCESS_TOKEN]?.let { CryptoManager.decrypt(it) } + val refreshToken = preferences[REFRESH_TOKEN]?.let { CryptoManager.decrypt(it) } !accessToken.isNullOrBlank() && !refreshToken.isNullOrBlank() } @@ -38,20 +44,20 @@ class TokenRepositoryImpl @Inject constructor( override fun getAccessToken(): Flow { return dataStore.data.map { preferences -> - preferences[DataStoreKey.ACCESS_TOKEN]?.let { CryptoManager.decrypt(it) } ?: "" + preferences[ACCESS_TOKEN]?.let { CryptoManager.decrypt(it) } ?: "" } } override fun getRefreshToken(): Flow { return dataStore.data.map { preferences -> - preferences[DataStoreKey.REFRESH_TOKEN]?.let { CryptoManager.decrypt(it) } ?: "" + preferences[REFRESH_TOKEN]?.let { CryptoManager.decrypt(it) } ?: "" } } override suspend fun clearTokens() { dataStore.edit { preferences -> - preferences.remove(DataStoreKey.ACCESS_TOKEN) - preferences.remove(DataStoreKey.REFRESH_TOKEN) + preferences.remove(ACCESS_TOKEN) + preferences.remove(REFRESH_TOKEN) } authCacheManager.invalidateTokenCache() } diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/logo/NekiAppLogo.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/logo/NekiAppLogo.kt new file mode 100644 index 000000000..a0d403374 --- /dev/null +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/logo/NekiAppLogo.kt @@ -0,0 +1,60 @@ +package com.neki.android.core.designsystem.logo + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +private fun NekiAppLogo( + color: Color, + modifier: Modifier = Modifier, +) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.icon_neki_logo_white), + contentDescription = null, + tint = color, + modifier = modifier, + ) +} + +@Composable +fun WhiteNekiAppLogo( + modifier: Modifier = Modifier, +) { + NekiAppLogo( + color = NekiTheme.colorScheme.white, + modifier = modifier, + ) +} + +@Composable +fun PrimaryNekiAppLogo( + modifier: Modifier = Modifier, +) { + NekiAppLogo( + color = NekiTheme.colorScheme.primary400, + modifier = modifier, + ) +} + +@Preview +@Composable +private fun WhiteNekiAppLogoPreview() { + NekiTheme { + WhiteNekiAppLogo() + } +} + +@Preview +@Composable +private fun PrimaryNekiAppLogoPreview() { + NekiTheme { + PrimaryNekiAppLogo() + } +} diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/ui/theme/Type.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/ui/theme/Type.kt index 8c025fd1b..979251439 100644 --- a/core/designsystem/src/main/java/com/neki/android/core/designsystem/ui/theme/Type.kt +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/ui/theme/Type.kt @@ -31,6 +31,19 @@ private val pretendardStyle = TextStyle( ) internal val defaultNekiTypography = NekiTypography( + // Title 28 + title28Bold = pretendardStyle.copy( + fontWeight = FontWeight.Bold, + fontSize = 28.sp, + lineHeight = 40.sp, + letterSpacing = (-0.02).em, + ), + title28SemiBold = pretendardStyle.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 28.sp, + lineHeight = 40.sp, + letterSpacing = (-0.02).em, + ), // Title 24 title24Bold = pretendardStyle.copy( fontWeight = FontWeight.Bold, @@ -162,6 +175,9 @@ internal val defaultNekiTypography = NekiTypography( @Immutable data class NekiTypography( + // Title 28 + val title28Bold: TextStyle, + val title28SemiBold: TextStyle, // Title 24 val title24Bold: TextStyle, val title24SemiBold: TextStyle, diff --git a/core/designsystem/src/main/res/drawable/icon_agreement.xml b/core/designsystem/src/main/res/drawable/icon_agreement.xml new file mode 100644 index 000000000..1fb90bab3 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_agreement.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/icon_kakao_talk.xml b/core/designsystem/src/main/res/drawable/icon_kakao_talk.xml new file mode 100644 index 000000000..d27f04532 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_kakao_talk.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/icon_neki_logo_white.xml b/core/designsystem/src/main/res/drawable/icon_neki_logo_white.xml new file mode 100644 index 000000000..82ce5421f --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_neki_logo_white.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/icon_onboarding_01.xml b/core/designsystem/src/main/res/drawable/icon_onboarding_01.xml new file mode 100644 index 000000000..cc15d3f8c --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_onboarding_01.xml @@ -0,0 +1,237 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/icon_onboarding_02.xml b/core/designsystem/src/main/res/drawable/icon_onboarding_02.xml new file mode 100644 index 000000000..f81169cf2 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_onboarding_02.xml @@ -0,0 +1,542 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/icon_onboarding_03.xml b/core/designsystem/src/main/res/drawable/icon_onboarding_03.xml new file mode 100644 index 000000000..75097e0d2 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_onboarding_03.xml @@ -0,0 +1,656 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/icon_splash_text.xml b/core/designsystem/src/main/res/drawable/icon_splash_text.xml new file mode 100644 index 000000000..dd35444c0 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/icon_splash_text.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + diff --git a/core/navigation/src/main/java/com/neki/android/core/navigation/HiltSharedViewModelStoreNavEntryDecorator.kt b/core/navigation/src/main/java/com/neki/android/core/navigation/HiltSharedViewModelStoreNavEntryDecorator.kt index 00cf422ca..65c0eb2dd 100644 --- a/core/navigation/src/main/java/com/neki/android/core/navigation/HiltSharedViewModelStoreNavEntryDecorator.kt +++ b/core/navigation/src/main/java/com/neki/android/core/navigation/HiltSharedViewModelStoreNavEntryDecorator.kt @@ -51,7 +51,7 @@ fun rememberHiltSharedViewModelStoreNavEntryDecorator( * hiltViewModel()과 함께 사용 가능. */ class HiltSharedViewModelStoreNavEntryDecorator( - viewModelStore: ViewModelStore, + private val viewModelStore: ViewModelStore, removeViewModelStoreOnPop: () -> Boolean, ) : NavEntryDecorator( onPop = { key -> @@ -95,6 +95,10 @@ class HiltSharedViewModelStoreNavEntryDecorator( } }, ) { + fun clearAll() { + viewModelStore.getHiltEntryViewModel().clearAll() + } + companion object { private const val PARENT_CONTENT_KEY = "hilt_shared_decorator_parent_content_key" @@ -116,6 +120,11 @@ private class HiltEntryViewModel : ViewModel() { owners.remove(key)?.clear() } + fun clearAll() { + owners.forEach { (_, store) -> store.clear() } + owners.clear() + } + override fun onCleared() { owners.forEach { (_, store) -> store.clear() } } diff --git a/core/navigation/src/main/java/com/neki/android/core/navigation/NavigationState.kt b/core/navigation/src/main/java/com/neki/android/core/navigation/NavigationState.kt index a0fd494ae..b09c206f4 100644 --- a/core/navigation/src/main/java/com/neki/android/core/navigation/NavigationState.kt +++ b/core/navigation/src/main/java/com/neki/android/core/navigation/NavigationState.kt @@ -1,6 +1,7 @@ package com.neki.android.core.navigation import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf @@ -32,11 +33,12 @@ class NavigationState @Inject constructor( fun NavigationState.toEntries( entryProvider: (NavKey) -> NavEntry, ): SnapshotStateList> { + val sharedViewModelStoreNavEntryDecorator = rememberHiltSharedViewModelStoreNavEntryDecorator() val decoratedEntries = subStacks.mapValues { (_, stack) -> val decorators = listOf( rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator(), - rememberHiltSharedViewModelStoreNavEntryDecorator(), + sharedViewModelStoreNavEntryDecorator, ) rememberDecoratedNavEntries( backStack = stack, @@ -45,6 +47,12 @@ fun NavigationState.toEntries( ) } + DisposableEffect(Unit) { + onDispose { + sharedViewModelStoreNavEntryDecorator.clearAll() + } + } + return topLevelStack .flatMap { decoratedEntries[it] ?: emptyList() } .toMutableStateList() diff --git a/core/navigation/src/main/java/com/neki/android/core/navigation/auth/AuthNavigationState.kt b/core/navigation/src/main/java/com/neki/android/core/navigation/auth/AuthNavigationState.kt new file mode 100644 index 000000000..c185cc3af --- /dev/null +++ b/core/navigation/src/main/java/com/neki/android/core/navigation/auth/AuthNavigationState.kt @@ -0,0 +1,47 @@ +package com.neki.android.core.navigation.auth + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.toMutableStateList +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberDecoratedNavEntries +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import com.neki.android.core.navigation.rememberHiltSharedViewModelStoreNavEntryDecorator +import javax.inject.Inject + +class AuthNavigationState @Inject constructor( + val startKey: NavKey, +) { + val stack: SnapshotStateList = mutableStateListOf(startKey) + val currentKey: NavKey by derivedStateOf { stack.last() } +} + +@Composable +fun AuthNavigationState.toEntries( + entryProvider: (NavKey) -> NavEntry, +): SnapshotStateList> { + val sharedViewModelStoreNavEntryDecorator = rememberHiltSharedViewModelStoreNavEntryDecorator() + val decorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + sharedViewModelStoreNavEntryDecorator, + ) + + DisposableEffect(Unit) { + onDispose { + sharedViewModelStoreNavEntryDecorator.clearAll() + } + } + + return rememberDecoratedNavEntries( + backStack = stack, + entryDecorators = decorators, + entryProvider = entryProvider, + ).toMutableStateList() +} diff --git a/core/navigation/src/main/java/com/neki/android/core/navigation/auth/AuthNavigator.kt b/core/navigation/src/main/java/com/neki/android/core/navigation/auth/AuthNavigator.kt new file mode 100644 index 000000000..de0036fb3 --- /dev/null +++ b/core/navigation/src/main/java/com/neki/android/core/navigation/auth/AuthNavigator.kt @@ -0,0 +1,11 @@ +package com.neki.android.core.navigation.auth + +import androidx.navigation3.runtime.NavKey +import com.neki.android.core.navigation.root.RootNavKey + +interface AuthNavigator { + fun navigateRoot(rootNavKey: RootNavKey) + fun navigate(key: NavKey) + fun navigateAndClear(key: NavKey) + fun goBack(): Boolean +} diff --git a/core/navigation/src/main/java/com/neki/android/core/navigation/auth/AuthNavigatorImpl.kt b/core/navigation/src/main/java/com/neki/android/core/navigation/auth/AuthNavigatorImpl.kt new file mode 100644 index 000000000..1a88ace00 --- /dev/null +++ b/core/navigation/src/main/java/com/neki/android/core/navigation/auth/AuthNavigatorImpl.kt @@ -0,0 +1,36 @@ +package com.neki.android.core.navigation.auth + +import androidx.navigation3.runtime.NavKey +import com.neki.android.core.navigation.root.RootNavKey +import com.neki.android.core.navigation.root.RootNavigationState +import dagger.hilt.android.scopes.ActivityRetainedScoped +import javax.inject.Inject + +@ActivityRetainedScoped +class AuthNavigatorImpl @Inject constructor( + private val rootState: RootNavigationState, + val state: AuthNavigationState, +) : AuthNavigator { + override fun navigateRoot(rootNavKey: RootNavKey) { + state.stack.clear() + state.stack.add(state.startKey) + rootState.stack.clear() + rootState.stack.add(rootNavKey) + } + + override fun navigate(key: NavKey) { + state.stack.add(key) + } + + override fun navigateAndClear(key: NavKey) { + state.stack.clear() + state.stack.add(key) + } + + override fun goBack(): Boolean { + return if (state.stack.size > 1) { + state.stack.removeLast() + true + } else false + } +} diff --git a/core/navigation/src/main/java/com/neki/android/core/navigation/root/RootNavKey.kt b/core/navigation/src/main/java/com/neki/android/core/navigation/root/RootNavKey.kt index 3405cfefa..6aea41824 100644 --- a/core/navigation/src/main/java/com/neki/android/core/navigation/root/RootNavKey.kt +++ b/core/navigation/src/main/java/com/neki/android/core/navigation/root/RootNavKey.kt @@ -3,6 +3,6 @@ package com.neki.android.core.navigation.root import androidx.navigation3.runtime.NavKey sealed interface RootNavKey : NavKey { - data object Login : RootNavKey + data object Auth : RootNavKey data object Main : RootNavKey } diff --git a/feature/auth/api/src/main/kotlin/com/neki/android/feature/auth/api/AuthNavKey.kt b/feature/auth/api/src/main/kotlin/com/neki/android/feature/auth/api/AuthNavKey.kt new file mode 100644 index 000000000..4a768ce39 --- /dev/null +++ b/feature/auth/api/src/main/kotlin/com/neki/android/feature/auth/api/AuthNavKey.kt @@ -0,0 +1,32 @@ +package com.neki.android.feature.auth.api + +import androidx.navigation3.runtime.NavKey +import com.neki.android.core.navigation.auth.AuthNavigator +import kotlinx.serialization.Serializable + +sealed interface AuthNavKey : NavKey { + + @Serializable + data object Splash : AuthNavKey + + @Serializable + data object Onboarding : AuthNavKey + + @Serializable + data object Login : AuthNavKey + + @Serializable + data object Term : AuthNavKey +} + +fun AuthNavigator.navigateToOnboardingAndClear() { + navigateAndClear(AuthNavKey.Onboarding) +} + +fun AuthNavigator.navigateToLoginAndClear() { + navigateAndClear(AuthNavKey.Login) +} + +fun AuthNavigator.navigateToTerm() { + navigate(AuthNavKey.Term) +} diff --git a/feature/auth/api/src/main/kotlin/com/neki/android/feature/auth/api/LoginNavKey.kt b/feature/auth/api/src/main/kotlin/com/neki/android/feature/auth/api/LoginNavKey.kt deleted file mode 100644 index 7f4011c8e..000000000 --- a/feature/auth/api/src/main/kotlin/com/neki/android/feature/auth/api/LoginNavKey.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.neki.android.feature.auth.api - -import androidx.navigation3.runtime.NavKey -import com.neki.android.core.navigation.Navigator -import kotlinx.serialization.Serializable - -sealed interface LoginNavKey : NavKey { - - @Serializable - data object Login : LoginNavKey -} - -fun Navigator.navigateToLogin() { - navigate(LoginNavKey.Login) -} diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/LoginContract.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/LoginContract.kt deleted file mode 100644 index 5da06c3c4..000000000 --- a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/LoginContract.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.neki.android.feature.auth.impl - -data class LoginState( - val isLoading: Boolean = false, -) - -sealed interface LoginIntent { - data object EnterLoginScreen : LoginIntent - data object ClickKakaoLogin : LoginIntent - data class SuccessLogin(val idToken: String) : LoginIntent - data object FailLogin : LoginIntent -} - -sealed interface LoginSideEffect { - data object NavigateToHome : LoginSideEffect - data object NavigateToKakaoRedirectingUri : LoginSideEffect - data class ShowToastMessage(val message: String) : LoginSideEffect -} 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 deleted file mode 100644 index 9c89cffcd..000000000 --- a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/LoginViewModel.kt +++ /dev/null @@ -1,78 +0,0 @@ -package com.neki.android.feature.auth.impl - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.neki.android.core.dataapi.auth.AuthEventManager -import com.neki.android.core.dataapi.repository.AuthRepository -import com.neki.android.core.dataapi.repository.TokenRepository -import com.neki.android.core.ui.MviIntentStore -import com.neki.android.core.ui.mviIntentStore -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import timber.log.Timber -import javax.inject.Inject - -@HiltViewModel -class LoginViewModel @Inject constructor( - private val authEventManager: AuthEventManager, - private val tokenRepository: TokenRepository, - private val authRepository: AuthRepository, -) : ViewModel() { - val store: MviIntentStore = - mviIntentStore( - initialState = LoginState(), - onIntent = ::onIntent, - initialFetchData = { store.onIntent(LoginIntent.EnterLoginScreen) }, - ) - - private fun onIntent( - intent: LoginIntent, - state: LoginState, - reduce: (LoginState.() -> LoginState) -> Unit, - postSideEffect: (LoginSideEffect) -> Unit, - ) { - when (intent) { - LoginIntent.EnterLoginScreen -> fetchInitialData(postSideEffect) - LoginIntent.ClickKakaoLogin -> postSideEffect(LoginSideEffect.NavigateToKakaoRedirectingUri) - is LoginIntent.SuccessLogin -> loginFromKakao(intent.idToken, reduce, postSideEffect) - LoginIntent.FailLogin -> postSideEffect(LoginSideEffect.ShowToastMessage("카카오 로그인에 실패했습니다.")) - } - } - - private fun fetchInitialData(postSideEffect: (LoginSideEffect) -> Unit) = viewModelScope.launch { - if (tokenRepository.isSavedTokens().first()) { - authRepository.updateAccessToken( - refreshToken = tokenRepository.getRefreshToken().first(), - ).onSuccess { - tokenRepository.saveTokens(it.accessToken, it.refreshToken) - postSideEffect(LoginSideEffect.NavigateToHome) - }.onFailure { exception -> - Timber.e(exception) - authEventManager.emitTokenExpired() - } - } else { - Timber.d("저장된 JWT 토큰이 없습니다.") - } - } - - private fun loginFromKakao( - idToken: String, - reduce: (LoginState.() -> LoginState) -> Unit, - postSideEffect: (LoginSideEffect) -> Unit, - ) = viewModelScope.launch { - reduce { copy(isLoading = true) } - authRepository.loginWithKakao(idToken) - .onSuccess { - tokenRepository.saveTokens( - accessToken = it.accessToken, - refreshToken = it.refreshToken, - ) - postSideEffect(LoginSideEffect.NavigateToHome) - } - .onFailure { exception -> - Timber.e(exception) - } - reduce { copy(isLoading = false) } - } -} diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/component/GradientBackground.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/component/GradientBackground.kt new file mode 100644 index 000000000..950d3572c --- /dev/null +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/component/GradientBackground.kt @@ -0,0 +1,34 @@ +package com.neki.android.feature.auth.impl.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun GradientBackground() { + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + NekiTheme.colorScheme.primary500, + NekiTheme.colorScheme.primary300, + ), + ), + ), + ) +} + +@ComponentPreview +@Composable +private fun GradientBackgroundPreview() { + NekiTheme { + GradientBackground() + } +} diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/component/LoginContent.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/component/LoginContent.kt deleted file mode 100644 index 0f09dba9d..000000000 --- a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/component/LoginContent.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.neki.android.feature.auth.impl.component - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.Button -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.sp - -@Composable -fun LoginContent( - modifier: Modifier = Modifier, - onClickKakaoLogin: () -> Unit, -) { - Box( - modifier = modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Button( - onClick = onClickKakaoLogin, - ) { - Text( - text = "카카오 로그인", - fontSize = 22.sp, - ) - } - } -} diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/login/LoginContract.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/login/LoginContract.kt new file mode 100644 index 000000000..7ef21b9fa --- /dev/null +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/login/LoginContract.kt @@ -0,0 +1,35 @@ +package com.neki.android.feature.auth.impl.login + +import com.neki.android.feature.auth.impl.term.model.TermAgreement +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf + +data class LoginState( + val isLoading: Boolean = false, + val kakaoIdToken: String = "", + val agreedTerms: ImmutableSet = persistentSetOf(), + val isAllRequiredAgreed: Boolean = false, +) + +sealed interface LoginIntent { + data object ClickKakaoLogin : LoginIntent + data class SuccessLogin(val idToken: String) : LoginIntent + data object FailLogin : LoginIntent + + // Term + data object ClickAgreeAll : LoginIntent + data class ClickAgreeTerm(val term: TermAgreement) : LoginIntent + data class ClickTermNavigateUrl(val term: TermAgreement) : LoginIntent + data object ClickNext : LoginIntent + data object ClickBack : LoginIntent + data object ResetTermState : LoginIntent +} + +sealed interface LoginSideEffect { + data object NavigateToTerm : LoginSideEffect + data object NavigateToMain : LoginSideEffect + data object NavigateBack : LoginSideEffect + data object NavigateToKakaoRedirectingUri : LoginSideEffect + data class NavigateUrl(val url: String) : LoginSideEffect + data class ShowToastMessage(val message: String) : LoginSideEffect +} 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/login/LoginScreen.kt similarity index 57% rename from feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/LoginScreen.kt rename to feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/login/LoginScreen.kt index 0e9504ca8..110d80e61 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/login/LoginScreen.kt @@ -1,34 +1,35 @@ -package com.neki.android.feature.auth.impl +package com.neki.android.feature.auth.impl.login -import android.widget.Toast +import androidx.compose.foundation.layout.Box 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.tooling.preview.Preview import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.neki.android.core.common.kakao.KakaoAuthHelper +import com.neki.android.core.designsystem.ComponentPreview 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.core.ui.toast.NekiToast +import com.neki.android.feature.auth.impl.login.component.LoginBackground +import com.neki.android.feature.auth.impl.login.component.LoginBottomContent import timber.log.Timber @Composable -fun LoginRoute( +internal fun LoginRoute( viewModel: LoginViewModel = hiltViewModel(), - navigateToMain: () -> Unit, + navigateToTerm: () -> Unit, ) { - val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() - val context = LocalContext.current - val kakaoAuthHelper = remember { KakaoAuthHelper(context) } + val nekiToast = remember { NekiToast(context) } viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> when (sideEffect) { - LoginSideEffect.NavigateToHome -> navigateToMain() + LoginSideEffect.NavigateToTerm -> navigateToTerm() LoginSideEffect.NavigateToKakaoRedirectingUri -> { - kakaoAuthHelper.login( + KakaoAuthHelper.login( + context = context, onSuccess = { idToken -> Timber.d("로그인 성공 $idToken") viewModel.store.onIntent(LoginIntent.SuccessLogin(idToken)) @@ -40,28 +41,32 @@ fun LoginRoute( } is LoginSideEffect.ShowToastMessage -> { - Toast.makeText(context, sideEffect.message, Toast.LENGTH_SHORT).show() + nekiToast.showToast(text = sideEffect.message) } + + else -> {} } } LoginScreen( - uiState = uiState, onIntent = viewModel.store::onIntent, ) } @Composable -fun LoginScreen( - uiState: LoginState = LoginState(), +private fun LoginScreen( onIntent: (LoginIntent) -> Unit = {}, ) { - LoginContent( - onClickKakaoLogin = { onIntent(LoginIntent.ClickKakaoLogin) }, - ) + Box { + LoginBackground() + LoginBottomContent( + modifier = Modifier.align(Alignment.BottomCenter), + onClick = { onIntent(LoginIntent.ClickKakaoLogin) }, + ) + } } -@Preview(showBackground = true) +@ComponentPreview @Composable private fun LoginScreenPreview() { NekiTheme { diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/login/LoginViewModel.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/login/LoginViewModel.kt new file mode 100644 index 000000000..f0ea9fe00 --- /dev/null +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/login/LoginViewModel.kt @@ -0,0 +1,103 @@ +package com.neki.android.feature.auth.impl.login + +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.ui.MviIntentStore +import com.neki.android.core.ui.mviIntentStore +import com.neki.android.feature.auth.impl.term.model.TermAgreement +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toPersistentSet +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class LoginViewModel @Inject constructor( + private val tokenRepository: TokenRepository, + private val authRepository: AuthRepository, +) : ViewModel() { + val store: MviIntentStore = + mviIntentStore( + initialState = LoginState(), + onIntent = ::onIntent, + ) + + private fun onIntent( + intent: LoginIntent, + state: LoginState, + reduce: (LoginState.() -> LoginState) -> Unit, + postSideEffect: (LoginSideEffect) -> Unit, + ) { + when (intent) { + LoginIntent.ClickKakaoLogin -> postSideEffect(LoginSideEffect.NavigateToKakaoRedirectingUri) + is LoginIntent.SuccessLogin -> { + reduce { copy(kakaoIdToken = intent.idToken) } + postSideEffect(LoginSideEffect.NavigateToTerm) + } + + LoginIntent.FailLogin -> postSideEffect(LoginSideEffect.ShowToastMessage("카카오 로그인에 실패했습니다.")) + + LoginIntent.ClickAgreeAll -> { + if (state.isAllRequiredAgreed) { + reduce { copy(agreedTerms = persistentSetOf(), isAllRequiredAgreed = false) } + } else { + reduce { copy(agreedTerms = TermAgreement.allRequiredTerms, isAllRequiredAgreed = true) } + } + } + + is LoginIntent.ClickAgreeTerm -> { + val newAgreedTerms = if (intent.term in state.agreedTerms) { + (state.agreedTerms - intent.term).toPersistentSet() + } else { + (state.agreedTerms + intent.term).toPersistentSet() + } + val isAllRequiredAgreed = TermAgreement.allRequiredTerms.all { it in newAgreedTerms } + reduce { copy(agreedTerms = newAgreedTerms, isAllRequiredAgreed = isAllRequiredAgreed) } + } + + is LoginIntent.ClickTermNavigateUrl -> { + postSideEffect(LoginSideEffect.NavigateUrl(intent.term.url)) + } + + LoginIntent.ClickNext -> { + val idToken = state.kakaoIdToken + if (state.isAllRequiredAgreed) { + loginWithKakao(idToken, reduce, postSideEffect) + } + } + + LoginIntent.ClickBack -> { + postSideEffect(LoginSideEffect.NavigateBack) + } + + LoginIntent.ResetTermState -> { + reduce { copy(agreedTerms = persistentSetOf(), isAllRequiredAgreed = false) } + } + } + } + + private fun loginWithKakao( + idToken: String, + reduce: (LoginState.() -> LoginState) -> Unit, + postSideEffect: (LoginSideEffect) -> Unit, + ) = viewModelScope.launch { + reduce { copy(isLoading = true) } + authRepository.loginWithKakao(idToken) + .onSuccess { + tokenRepository.saveTokens( + accessToken = it.accessToken, + refreshToken = it.refreshToken, + ) + authRepository.setCompletedOnboarding(true) + postSideEffect(LoginSideEffect.NavigateToMain) + } + .onFailure { exception -> + Timber.e(exception) + postSideEffect(LoginSideEffect.ShowToastMessage("로그인에 실패했습니다. 다시 시도해주세요.")) + } + reduce { copy(isLoading = false) } + } +} diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/login/component/LoginBackground.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/login/component/LoginBackground.kt new file mode 100644 index 000000000..de4ba14eb --- /dev/null +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/login/component/LoginBackground.kt @@ -0,0 +1,66 @@ +package com.neki.android.feature.auth.impl.login.component + +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.logo.WhiteNekiAppLogo +import com.neki.android.core.designsystem.logo.WhiteNekiTypoLogo +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.ui.compose.VerticalSpacer +import com.neki.android.feature.auth.impl.component.GradientBackground + +@Composable +internal fun LoginBackground() { + Box( + modifier = Modifier.fillMaxSize(), + ) { + GradientBackground() + Column( + modifier = Modifier.padding(start = 32.dp, top = 164.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(15.36.dp), + ) { + WhiteNekiAppLogo( + modifier = Modifier.size(width = 33.3.dp, height = 37.6.dp), + ) + WhiteNekiTypoLogo( + modifier = Modifier.size(width = 93.8.dp, height = 36.6.dp), + ) + } + VerticalSpacer(24.dp) + Text( + text = "네컷의 순간이 \n이어지는 곳", + style = NekiTheme.typography.title24Bold, + fontSize = 32.sp, + color = NekiTheme.colorScheme.white, + ) + VerticalSpacer(12.dp) + Text( + text = "위치, 포즈, 아카이빙까지", + style = NekiTheme.typography.body16SemiBold, + color = NekiTheme.colorScheme.white, + ) + } + } +} + +@ComponentPreview +@Composable +private fun LoginBackgroundPreview() { + NekiTheme { + LoginBackground() + } +} diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/login/component/LoginBottomContent.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/login/component/LoginBottomContent.kt new file mode 100644 index 000000000..652c3a42a --- /dev/null +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/login/component/LoginBottomContent.kt @@ -0,0 +1,88 @@ +package com.neki.android.feature.auth.impl.login.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +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.res.vectorResource +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.clickableSingle +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun LoginBottomContent( + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + Column( + modifier = modifier + .fillMaxWidth() + .background( + color = NekiTheme.colorScheme.white, + shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp), + ) + .padding(top = 32.dp, bottom = 56.dp, start = 20.dp, end = 20.dp), + ) { + KakaoLoginButton( + onClick = onClick, + ) + } +} + +@Composable +private fun KakaoLoginButton( + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + Box( + modifier = modifier + .fillMaxWidth() + .background( + shape = RoundedCornerShape(12.dp), + color = Color(0xFFF9DB00), + ) + .clip(RoundedCornerShape(12.dp)) + .clickableSingle(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 16.dp), + ) { + Icon( + modifier = Modifier.align(Alignment.CenterStart), + imageVector = ImageVector.vectorResource(R.drawable.icon_kakao_talk), + contentDescription = null, + ) + Text( + modifier = Modifier.align(Alignment.Center), + text = "카카오로 계속하기", + style = NekiTheme.typography.title18Bold, + color = NekiTheme.colorScheme.gray900, + ) + } +} + +@ComponentPreview +@Composable +private fun LoginBottomContentPreview() { + NekiTheme { + LoginBottomContent() + } +} + +@ComponentPreview +@Composable +private fun KakaoLoginButtonPreview() { + NekiTheme { + KakaoLoginButton() + } +} diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/navigation/AuthEntryProvider.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/navigation/AuthEntryProvider.kt new file mode 100644 index 000000000..5a5257ac1 --- /dev/null +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/navigation/AuthEntryProvider.kt @@ -0,0 +1,56 @@ +package com.neki.android.feature.auth.impl.navigation + +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.neki.android.core.navigation.HiltSharedViewModelStoreNavEntryDecorator +import com.neki.android.core.navigation.auth.AuthNavigator +import com.neki.android.core.navigation.root.RootNavKey +import com.neki.android.feature.auth.api.AuthNavKey +import com.neki.android.feature.auth.api.navigateToLoginAndClear +import com.neki.android.feature.auth.api.navigateToOnboardingAndClear +import com.neki.android.feature.auth.api.navigateToTerm +import com.neki.android.feature.auth.impl.login.LoginRoute +import com.neki.android.feature.auth.impl.onboarding.OnboardingRoute +import com.neki.android.feature.auth.impl.splash.SplashRoute +import com.neki.android.feature.auth.impl.term.TermRoute + +typealias AuthEntryProviderInstaller = EntryProviderScope.() -> Unit + +fun authEntryProvider(authNavigator: AuthNavigator): AuthEntryProviderInstaller = { + authEntry(authNavigator) +} + +private fun EntryProviderScope.authEntry(navigator: AuthNavigator) { + entry { + SplashRoute( + navigateToOnboarding = navigator::navigateToOnboardingAndClear, + navigateToLogin = navigator::navigateToLoginAndClear, + navigateToMain = { navigator.navigateRoot(RootNavKey.Main) }, + ) + } + + entry { + OnboardingRoute( + navigateToLogin = navigator::navigateToLoginAndClear, + ) + } + + entry( + clazzContentKey = { key -> key.toString() }, + ) { + LoginRoute( + navigateToTerm = navigator::navigateToTerm, + ) + } + + entry( + metadata = HiltSharedViewModelStoreNavEntryDecorator.parent( + AuthNavKey.Login.toString(), + ), + ) { + TermRoute( + navigateToMain = { navigator.navigateRoot(RootNavKey.Main) }, + navigateBack = navigator::goBack, + ) + } +} diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/onboarding/OnboardingScreen.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/onboarding/OnboardingScreen.kt new file mode 100644 index 000000000..20d8ee4f6 --- /dev/null +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/onboarding/OnboardingScreen.kt @@ -0,0 +1,86 @@ +package com.neki.android.feature.auth.impl.onboarding + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.button.CTAButtonPrimary +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.ui.compose.VerticalSpacer +import com.neki.android.feature.auth.impl.onboarding.component.OnboardingPageContent +import com.neki.android.feature.auth.impl.onboarding.component.PagerIndicator +import com.neki.android.feature.auth.impl.onboarding.component.TextSection +import com.neki.android.feature.auth.impl.onboarding.model.OnboardingPage + +@Composable +internal fun OnboardingRoute( + navigateToLogin: () -> Unit, +) { + OnboardingScreen( + onClickLoginButton = navigateToLogin, + ) +} + +@Composable +private fun OnboardingScreen( + onClickLoginButton: () -> Unit = {}, +) { + val pages = OnboardingPage.entries + val pageSize = pages.size + val infinitePageCount = Int.MAX_VALUE + val initialPage = infinitePageCount / 2 - (infinitePageCount / 2) % pageSize + val pagerState = rememberPagerState(initialPage = initialPage) { infinitePageCount } + + Column( + modifier = Modifier + .fillMaxSize() + .background(color = NekiTheme.colorScheme.white), + ) { + VerticalSpacer(60f) + HorizontalPager( + modifier = Modifier + .fillMaxWidth() + .weight(400f), + state = pagerState, + ) { page -> + Column { + TextSection( + modifier = Modifier + .fillMaxWidth() + .padding(start = 32.dp, bottom = 28.dp), + page = pages[page % pageSize], + ) + OnboardingPageContent(imageRes = pages[page % pageSize].imageRes) + } + } + VerticalSpacer(38f) + PagerIndicator( + modifier = Modifier.align(Alignment.CenterHorizontally), + pageCount = pageSize, + currentPage = pagerState.currentPage % pageSize, + ) + CTAButtonPrimary( + modifier = Modifier + .padding(horizontal = 20.dp, vertical = 28.dp) + .fillMaxWidth(), + text = "회원가입 및 로그인", + onClick = onClickLoginButton, + ) + } +} + +@ComponentPreview +@Composable +private fun OnboardingScreenPreview() { + NekiTheme { + OnboardingScreen() + } +} diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/onboarding/component/OnboardingPage.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/onboarding/component/OnboardingPage.kt new file mode 100644 index 000000000..bab158624 --- /dev/null +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/onboarding/component/OnboardingPage.kt @@ -0,0 +1,39 @@ +package com.neki.android.feature.auth.impl.onboarding.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun OnboardingPageContent( + @DrawableRes imageRes: Int, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(id = imageRes), + contentDescription = null, + ) + } +} + +@ComponentPreview +@Composable +private fun OnboardingPageContentPreview() { + NekiTheme { + OnboardingPageContent( + imageRes = R.drawable.icon_onboarding_01, + ) + } +} diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/onboarding/component/PagerIndicator.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/onboarding/component/PagerIndicator.kt new file mode 100644 index 000000000..fb9b05d7d --- /dev/null +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/onboarding/component/PagerIndicator.kt @@ -0,0 +1,53 @@ +package com.neki.android.feature.auth.impl.onboarding.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun PagerIndicator( + pageCount: Int, + currentPage: Int, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(11.dp), + ) { + repeat(pageCount) { index -> + val isSelected = currentPage == index + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background( + if (isSelected) { + NekiTheme.colorScheme.primary400 + } else { + NekiTheme.colorScheme.gray50 + }, + ), + ) + } + } +} + +@ComponentPreview +@Composable +private fun PagerIndicatorPreview() { + NekiTheme { + PagerIndicator( + pageCount = 3, + currentPage = 0, + ) + } +} diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/onboarding/component/TextSection.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/onboarding/component/TextSection.kt new file mode 100644 index 000000000..6f5ad1815 --- /dev/null +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/onboarding/component/TextSection.kt @@ -0,0 +1,57 @@ +package com.neki.android.feature.auth.impl.onboarding.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.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.feature.auth.impl.onboarding.model.OnboardingPage + +@Composable +internal fun TextSection( + page: OnboardingPage, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Box( + modifier = Modifier + .background( + shape = CircleShape, + color = NekiTheme.colorScheme.primary25, + ) + .padding(horizontal = 12.dp, vertical = 4.dp), + ) { + Text( + text = page.title, + style = NekiTheme.typography.body16SemiBold, + color = NekiTheme.colorScheme.primary400, + ) + } + Text( + text = page.description, + style = NekiTheme.typography.title28Bold, + color = NekiTheme.colorScheme.gray900, + ) + } +} + +@ComponentPreview +@Composable +private fun OnboardingTextSectionPreview() { + NekiTheme { + TextSection( + page = OnboardingPage.BOOTH_SEARCH, + modifier = Modifier.padding(16.dp), + ) + } +} diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/onboarding/model/OnboardingPage.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/onboarding/model/OnboardingPage.kt new file mode 100644 index 000000000..63e7ebed5 --- /dev/null +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/onboarding/model/OnboardingPage.kt @@ -0,0 +1,26 @@ +package com.neki.android.feature.auth.impl.onboarding.model + +import androidx.annotation.DrawableRes +import com.neki.android.core.designsystem.R + +enum class OnboardingPage( + @DrawableRes val imageRes: Int, + val title: String, + val description: String, +) { + BOOTH_SEARCH( + imageRes = R.drawable.icon_onboarding_01, + title = "빠른 네컷 부스 탐색", + description = "네컷 부스 정보를\n빠르게 쉽게 찾아요", + ), + POSE_RECOMMEND( + imageRes = R.drawable.icon_onboarding_02, + title = "포즈 걱정 없는 촬영 경험", + description = "인원수에 맞는\n포즈를 추천받아요", + ), + PHOTO_ARCHIVE( + imageRes = R.drawable.icon_onboarding_03, + title = "네컷 사진 아카이빙", + description = "흩어지기 쉬운 사진을\n한곳에 모아요", + ), +} diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/splash/SplashContract.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/splash/SplashContract.kt new file mode 100644 index 000000000..9983b8041 --- /dev/null +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/splash/SplashContract.kt @@ -0,0 +1,13 @@ +package com.neki.android.feature.auth.impl.splash + +data object SplashState + +sealed interface SplashIntent { + data object EnterSplashScreen : SplashIntent +} + +sealed interface SplashSideEffect { + data object NavigateToOnboarding : SplashSideEffect + data object NavigateToLogin : SplashSideEffect + data object NavigateToMain : SplashSideEffect +} diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/splash/SplashScreen.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/splash/SplashScreen.kt new file mode 100644 index 000000000..606bf6f5d --- /dev/null +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/splash/SplashScreen.kt @@ -0,0 +1,59 @@ +package com.neki.android.feature.auth.impl.splash + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +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.compose.collectWithLifecycle +import com.neki.android.feature.auth.impl.splash.component.SplashBackground +import kotlinx.coroutines.delay + +@Composable +internal fun SplashRoute( + viewModel: SplashViewModel = hiltViewModel(), + navigateToOnboarding: () -> Unit, + navigateToLogin: () -> Unit, + navigateToMain: () -> Unit, +) { + val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() + + viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> + when (sideEffect) { + SplashSideEffect.NavigateToOnboarding -> { + delay(1000) + navigateToOnboarding() + } + + SplashSideEffect.NavigateToLogin -> { + delay(1000) + navigateToLogin() + } + + SplashSideEffect.NavigateToMain -> { + delay(1000) + navigateToMain() + } + } + } + + SplashScreen() +} + +@Composable +private fun SplashScreen() { + SplashBackground( + modifier = Modifier.fillMaxSize(), + ) +} + +@ComponentPreview +@Composable +private fun SplashScreenPreview() { + NekiTheme { + SplashScreen() + } +} diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/splash/SplashViewModel.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/splash/SplashViewModel.kt new file mode 100644 index 000000000..fd95037ff --- /dev/null +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/splash/SplashViewModel.kt @@ -0,0 +1,62 @@ +package com.neki.android.feature.auth.impl.splash + +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.ui.MviIntentStore +import com.neki.android.core.ui.mviIntentStore +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class SplashViewModel @Inject constructor( + private val tokenRepository: TokenRepository, + private val authRepository: AuthRepository, +) : ViewModel() { + + val store: MviIntentStore = + mviIntentStore( + initialState = SplashState, + onIntent = ::onIntent, + initialFetchData = { store.onIntent(SplashIntent.EnterSplashScreen) }, + ) + + private fun onIntent( + intent: SplashIntent, + state: SplashState, + reduce: (SplashState.() -> SplashState) -> Unit, + postSideEffect: (SplashSideEffect) -> Unit, + ) { + when (intent) { + SplashIntent.EnterSplashScreen -> fetchAuthState(postSideEffect) + } + } + + private fun fetchAuthState(postSideEffect: (SplashSideEffect) -> Unit) { + viewModelScope.launch { + val hasCompletedOnboarding = authRepository.hasCompletedOnboarding().first() + if (!hasCompletedOnboarding) { + postSideEffect(SplashSideEffect.NavigateToOnboarding) + return@launch + } + + if (tokenRepository.hasTokens().first()) { + authRepository.updateAccessToken( + refreshToken = tokenRepository.getRefreshToken().first(), + ).onSuccess { + tokenRepository.saveTokens(it.accessToken, it.refreshToken) + postSideEffect(SplashSideEffect.NavigateToMain) + }.onFailure { exception -> + Timber.e(exception) + postSideEffect(SplashSideEffect.NavigateToLogin) + } + } else { + postSideEffect(SplashSideEffect.NavigateToLogin) + } + } + } +} diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/splash/component/SplashBackground.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/splash/component/SplashBackground.kt new file mode 100644 index 000000000..2c69dedd7 --- /dev/null +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/splash/component/SplashBackground.kt @@ -0,0 +1,54 @@ +package com.neki.android.feature.auth.impl.splash.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.logo.WhiteNekiAppLogo +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.feature.auth.impl.component.GradientBackground + +@Composable +internal fun SplashBackground( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier, + ) { + GradientBackground() + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 133.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(32.dp), + ) { + WhiteNekiAppLogo( + modifier = Modifier.size(width = 73.89.dp, height = 80.41.dp), + ) + Image( + imageVector = ImageVector.vectorResource(R.drawable.icon_splash_text), + contentDescription = null, + ) + } + } +} + +@ComponentPreview +@Composable +private fun SplashBackgroundPreview() { + NekiTheme { + SplashBackground() + } +} diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/term/TermScreen.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/term/TermScreen.kt new file mode 100644 index 000000000..3b87c6a05 --- /dev/null +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/term/TermScreen.kt @@ -0,0 +1,112 @@ +package com.neki.android.feature.auth.impl.term + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.button.CTAButtonPrimary +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.core.ui.toast.NekiToast +import com.neki.android.feature.auth.impl.login.LoginIntent +import com.neki.android.feature.auth.impl.login.LoginSideEffect +import com.neki.android.feature.auth.impl.login.LoginState +import com.neki.android.feature.auth.impl.login.LoginViewModel +import com.neki.android.feature.auth.impl.term.component.TermContent +import com.neki.android.feature.auth.impl.term.component.TermTopBar + +@Composable +internal fun TermRoute( + viewModel: LoginViewModel = hiltViewModel(), + navigateToMain: () -> Unit, + navigateBack: () -> Unit, +) { + val context = LocalContext.current + val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() + val nekiToast = remember { NekiToast(context) } + + DisposableEffect(Unit) { + onDispose { + viewModel.store.onIntent(LoginIntent.ResetTermState) + } + } + + viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> + when (sideEffect) { + LoginSideEffect.NavigateToMain -> navigateToMain() + LoginSideEffect.NavigateBack -> navigateBack() + is LoginSideEffect.NavigateUrl -> { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(sideEffect.url)) + context.startActivity(intent) + } + + is LoginSideEffect.ShowToastMessage -> { + nekiToast.showToast(sideEffect.message) + } + + else -> {} + } + } + + TermScreen( + uiState = uiState, + onIntent = viewModel.store::onIntent, + ) +} + +@Composable +private fun TermScreen( + uiState: LoginState = LoginState(), + onIntent: (LoginIntent) -> Unit = {}, +) { + Column { + TermTopBar( + onClickBack = { onIntent(LoginIntent.ClickBack) }, + ) + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = 12.dp, start = 20.dp, end = 20.dp, bottom = 34.dp), + ) { + TermContent( + modifier = Modifier.weight(1f), + agreedTerms = uiState.agreedTerms, + isAllRequiredAgreed = uiState.isAllRequiredAgreed, + onClickAgreeAll = { onIntent(LoginIntent.ClickAgreeAll) }, + onClickAgreeTerm = { onIntent(LoginIntent.ClickAgreeTerm(it)) }, + onClickTermDetail = { onIntent(LoginIntent.ClickTermNavigateUrl(it)) }, + ) + CTAButtonPrimary( + modifier = Modifier.fillMaxWidth(), + text = "다음으로", + onClick = { onIntent(LoginIntent.ClickNext) }, + enabled = uiState.isAllRequiredAgreed, + ) + } + } + + if (uiState.isLoading) { + LoadingDialog() + } +} + +@ComponentPreview +@Composable +private fun TermScreenPreview() { + NekiTheme { + TermScreen() + } +} diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/term/component/AgreementSection.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/term/component/AgreementSection.kt new file mode 100644 index 000000000..fdf2b3622 --- /dev/null +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/term/component/AgreementSection.kt @@ -0,0 +1,79 @@ +package com.neki.android.feature.auth.impl.term.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.noRippleClickable +import com.neki.android.core.designsystem.modifier.noRippleClickableSingle +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.feature.auth.impl.term.model.TermAgreement + +@Composable +internal fun AgreementSection( + agreement: TermAgreement, + isAgreed: Boolean = false, + onClickAgree: () -> Unit = {}, + onClickNavigateUrl: () -> Unit = {}, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier + .weight(1f) + .noRippleClickable(onClick = onClickAgree) + .padding(start = 10.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier + .padding(end = 10.dp) + .size(24.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_check), + contentDescription = null, + tint = if (isAgreed) NekiTheme.colorScheme.primary500 else NekiTheme.colorScheme.gray200, + ) + Text( + modifier = Modifier.padding(end = 2.dp), + text = if (agreement.isRequired) "(필수)" else "(선택)", + style = NekiTheme.typography.body14Medium, + color = NekiTheme.colorScheme.gray500, + ) + Text( + text = agreement.title, + style = NekiTheme.typography.body16Medium, + color = NekiTheme.colorScheme.gray900, + ) + } + Icon( + modifier = Modifier + .size(24.dp) + .noRippleClickableSingle(onClick = onClickNavigateUrl), + imageVector = ImageVector.vectorResource(R.drawable.icon_arrow_right), + contentDescription = null, + tint = NekiTheme.colorScheme.gray300, + ) + } +} + +@ComponentPreview +@Composable +private fun TermSectionPreview() { + NekiTheme { + AgreementSection(agreement = TermAgreement.SERVICE_TERMS) + } +} diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/term/component/TermContent.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/term/component/TermContent.kt new file mode 100644 index 000000000..bbf7402da --- /dev/null +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/term/component/TermContent.kt @@ -0,0 +1,100 @@ +package com.neki.android.feature.auth.impl.term.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +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.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.R +import com.neki.android.core.designsystem.modifier.noRippleClickable +import com.neki.android.core.designsystem.ui.theme.NekiTheme +import com.neki.android.core.ui.compose.VerticalSpacer +import com.neki.android.feature.auth.impl.term.model.TermAgreement +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf + +@Composable +internal fun TermContent( + modifier: Modifier = Modifier, + agreedTerms: ImmutableSet = persistentSetOf(), + isAllRequiredAgreed: Boolean = false, + onClickAgreeAll: () -> Unit = {}, + onClickAgreeTerm: (TermAgreement) -> Unit = {}, + onClickTermDetail: (TermAgreement) -> Unit = {}, +) { + Column( + modifier = modifier, + ) { + Image( + modifier = Modifier.padding(bottom = 12.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_agreement), + contentDescription = null, + ) + Text( + modifier = Modifier.padding(bottom = 24.dp), + text = "편리한 네키 이용을 위한\n필수 약관에 동의해주세요.", + style = NekiTheme.typography.title24SemiBold, + color = NekiTheme.colorScheme.gray900, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .noRippleClickable(onClick = onClickAgreeAll) + .border( + width = 1.dp, + color = if (isAllRequiredAgreed) NekiTheme.colorScheme.primary500 else NekiTheme.colorScheme.gray100, + shape = RoundedCornerShape(12.dp), + ) + .background( + shape = RoundedCornerShape(12.dp), + color = NekiTheme.colorScheme.white, + ) + .padding(vertical = 18.dp, horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + modifier = Modifier.size(24.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_check), + contentDescription = null, + tint = if (isAllRequiredAgreed) NekiTheme.colorScheme.primary500 else NekiTheme.colorScheme.gray200, + ) + Text( + text = "약관 전체 동의", + style = NekiTheme.typography.title18SemiBold, + color = NekiTheme.colorScheme.gray900, + ) + } + VerticalSpacer(12.dp) + TermAgreement.entries.forEach { agreement -> + AgreementSection( + agreement = agreement, + isAgreed = agreement in agreedTerms, + onClickAgree = { onClickAgreeTerm(agreement) }, + onClickNavigateUrl = { onClickTermDetail(agreement) }, + ) + } + } +} + +@ComponentPreview +@Composable +private fun TermContentPreview() { + NekiTheme { + TermContent() + } +} diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/term/component/TermTopBar.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/term/component/TermTopBar.kt new file mode 100644 index 000000000..77c45b98c --- /dev/null +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/term/component/TermTopBar.kt @@ -0,0 +1,24 @@ +package com.neki.android.feature.auth.impl.term.component + +import androidx.compose.runtime.Composable +import com.neki.android.core.designsystem.ComponentPreview +import com.neki.android.core.designsystem.topbar.BackTitleTopBar +import com.neki.android.core.designsystem.ui.theme.NekiTheme + +@Composable +internal fun TermTopBar( + onClickBack: () -> Unit = {}, +) { + BackTitleTopBar( + title = "이용약관", + onBack = onClickBack, + ) +} + +@ComponentPreview +@Composable +private fun TermTopBarPreview() { + NekiTheme { + TermTopBar() + } +} diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/term/model/TermAgreement.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/term/model/TermAgreement.kt new file mode 100644 index 000000000..b78c28014 --- /dev/null +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/term/model/TermAgreement.kt @@ -0,0 +1,30 @@ +package com.neki.android.feature.auth.impl.term.model + +import kotlinx.collections.immutable.toPersistentSet + +enum class TermAgreement( + val title: String, + val isRequired: Boolean, + val url: String, +) { + SERVICE_TERMS( + title = "서비스 이용 약관", + isRequired = true, + url = "https://lydian-tip-26b.notion.site/2ee0d9441db0807c8684ce3e2d4b8aca?source=copy_link", + ), + PRIVACY_POLICY( + title = "개인정보 수집/이용 동의", + isRequired = true, + url = "https://lydian-tip-26b.notion.site/2ee0d9441db0807cb850f78145db6dd3?pvs=74", + ), + LOCATION_POLICY( + title = "위치정보 수집 및 이용 동의", + isRequired = true, + url = "https://lydian-tip-26b.notion.site/2ee0d9441db080b48223fb0b3263da08?pvs=74", + ), + ; + + companion object { + val allRequiredTerms = entries.filter { it.isRequired }.toPersistentSet() + } +} 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 6c935dd3d..503aafe8f 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 @@ -190,6 +190,7 @@ internal class MyPageViewModel @Inject constructor( authRepository.withdrawAccount() .onSuccess { tokenRepository.clearTokens() + authRepository.setCompletedOnboarding(false) reduce { copy(isLoading = false) } postSideEffect(MyPageEffect.UnlinkWithKakao) } 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 915afaeb2..b8d5f41e3 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 @@ -59,7 +59,7 @@ private fun EntryProviderScope.myPageEntry(navigator: Navigator) { ProfileSettingRoute( navigateBack = navigator::goBack, navigateToEditProfile = navigator::navigateToEditProfile, - navigateToLogin = { navigator.navigateRoot(RootNavKey.Login) }, + navigateToLogin = { navigator.navigateRoot(RootNavKey.Auth) }, ) } 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 index 6fcf446d9..f153decaa 100644 --- 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 @@ -4,13 +4,12 @@ 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.common.kakao.KakaoAuthHelper import com.neki.android.core.designsystem.ComponentPreview import com.neki.android.core.designsystem.dialog.DoubleButtonAlertDialog import com.neki.android.core.designsystem.ui.theme.NekiTheme @@ -24,7 +23,6 @@ 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 @@ -34,9 +32,7 @@ internal fun ProfileSettingRoute( 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) { @@ -44,13 +40,13 @@ internal fun ProfileSettingRoute( MyPageEffect.NavigateToEditProfile -> navigateToEditProfile() MyPageEffect.NavigateToLogin -> navigateToLogin() MyPageEffect.LogoutWithKakao -> { - kakaoAuthHelper.logout( + KakaoAuthHelper.logout( onSuccess = { navigateToLogin() }, onFailure = { Timber.e(it) }, ) } MyPageEffect.UnlinkWithKakao -> { - kakaoAuthHelper.unlink( + KakaoAuthHelper.unlink( onSuccess = { navigateToLogin() }, onFailure = { Timber.e(it) }, )