diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/TermRepository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/TermRepository.kt new file mode 100644 index 000000000..a895b290b --- /dev/null +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/TermRepository.kt @@ -0,0 +1,8 @@ +package com.neki.android.core.dataapi.repository + +import com.neki.android.core.model.Term + +interface TermRepository { + suspend fun getTerms(): Result> + suspend fun agreeTerms(termIds: List): Result +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/api/TermService.kt b/core/data/src/main/java/com/neki/android/core/data/remote/api/TermService.kt new file mode 100644 index 000000000..ff7009c86 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/api/TermService.kt @@ -0,0 +1,26 @@ +package com.neki.android.core.data.remote.api + +import com.neki.android.core.data.remote.model.request.TermAgreementsRequest +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.TermsResponse +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import javax.inject.Inject + +class TermService @Inject constructor( + private val client: HttpClient, +) { + suspend fun getTerms(): BasicResponse { + return client.get("/api/terms").body() + } + + suspend fun agreeTerms(request: TermAgreementsRequest): BasicNullableResponse { + return client.post("/api/terms/agreements") { + 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 dc15fae4c..9225e9cfc 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 @@ -51,6 +51,7 @@ internal object NetworkModule { val sendWithoutAuthUrls = listOf( "/api/auth/kakao/login", "/api/auth/refresh", + "/api/terms", ) private val json = Json { diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/request/TermAgreementsRequest.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/TermAgreementsRequest.kt new file mode 100644 index 000000000..362f0377b --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/TermAgreementsRequest.kt @@ -0,0 +1,15 @@ +package com.neki.android.core.data.remote.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TermAgreementsRequest( + @SerialName("agreements") val agreements: List, +) { + @Serializable + data class Agreement( + @SerialName("termId") val termId: Long, + @SerialName("agreed") val agreed: Boolean, + ) +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/TermResponse.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/TermResponse.kt new file mode 100644 index 000000000..981d42e24 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/TermResponse.kt @@ -0,0 +1,28 @@ +package com.neki.android.core.data.remote.model.response + +import com.neki.android.core.model.Term +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TermsResponse( + @SerialName("terms") val terms: List = emptyList(), +) { + @Serializable + data class TermResponse( + @SerialName("id") val id: Long = 0L, + @SerialName("termType") val termType: String = "", + @SerialName("title") val title: String = "", + @SerialName("url") val url: String = "", + @SerialName("isRequired") val isRequired: Boolean = false, + ) { + internal fun toModel(): Term = Term( + id = id, + title = title, + url = url, + isRequired = isRequired, + ) + } + + fun toModels(): List = terms.map { it.toModel() } +} 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 index 9b5f1d207..e1e0688f1 100644 --- 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 @@ -6,16 +6,18 @@ 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, + @SerialName("userId") val userId: Long = 0L, + @SerialName("name") val name: String = "", + @SerialName("email") val email: String = "", + @SerialName("profileImageUrl") val profileImageUrl: String = "", + @SerialName("providerType") val providerType: String = "", + @SerialName("agreeTerms") val agreeTerms: Boolean = false, ) { fun toModel() = UserInfo( id = userId, nickname = name, profileImageUrl = profileImageUrl, loginType = providerType, + isRequiredTermsAgreed = agreeTerms, ) } 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 4458da5ab..fcc707779 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 @@ -7,6 +7,7 @@ import com.neki.android.core.data.repository.impl.FolderRepositoryImpl 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.TermRepositoryImpl 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 @@ -16,6 +17,7 @@ import com.neki.android.core.dataapi.repository.MediaUploadRepository 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.TermRepository import com.neki.android.core.dataapi.repository.TokenRepository import com.neki.android.core.dataapi.repository.UserRepository import dagger.Binds @@ -81,4 +83,10 @@ internal interface RepositoryModule { fun bindPoseRepositoryImpl( poseRepositoryImpl: PoseRepositoryImpl, ): PoseRepository + + @Binds + @Singleton + fun bindTermRepositoryImpl( + termRepositoryImpl: TermRepositoryImpl, + ): TermRepository } diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/TermRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/TermRepositoryImpl.kt new file mode 100644 index 000000000..2e4d7deb5 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/TermRepositoryImpl.kt @@ -0,0 +1,25 @@ +package com.neki.android.core.data.repository.impl + +import com.neki.android.core.data.remote.api.TermService +import com.neki.android.core.data.remote.model.request.TermAgreementsRequest +import com.neki.android.core.data.util.runSuspendCatching +import com.neki.android.core.dataapi.repository.TermRepository +import com.neki.android.core.model.Term +import javax.inject.Inject + +class TermRepositoryImpl @Inject constructor( + private val termService: TermService, +) : TermRepository { + override suspend fun getTerms(): Result> = runSuspendCatching { + termService.getTerms().data.toModels() + } + + override suspend fun agreeTerms(termIds: List): Result = runSuspendCatching { + val request = TermAgreementsRequest( + agreements = termIds.map { termId -> + TermAgreementsRequest.Agreement(termId = termId, agreed = true) + }, + ) + termService.agreeTerms(request).data + } +} diff --git a/core/designsystem/src/main/res/drawable/icon_onboarding_01.xml b/core/designsystem/src/main/res/drawable/icon_onboarding_01.xml deleted file mode 100644 index cc15d3f8c..000000000 --- a/core/designsystem/src/main/res/drawable/icon_onboarding_01.xml +++ /dev/null @@ -1,237 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/core/designsystem/src/main/res/drawable/icon_onboarding_02.xml b/core/designsystem/src/main/res/drawable/icon_onboarding_02.xml deleted file mode 100644 index f81169cf2..000000000 --- a/core/designsystem/src/main/res/drawable/icon_onboarding_02.xml +++ /dev/null @@ -1,542 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/core/designsystem/src/main/res/drawable/icon_onboarding_03.xml b/core/designsystem/src/main/res/drawable/icon_onboarding_03.xml deleted file mode 100644 index 75097e0d2..000000000 --- a/core/designsystem/src/main/res/drawable/icon_onboarding_03.xml +++ /dev/null @@ -1,656 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/core/designsystem/src/main/res/drawable/image_onboarding_01.png b/core/designsystem/src/main/res/drawable/image_onboarding_01.png new file mode 100644 index 000000000..59f38f839 Binary files /dev/null and b/core/designsystem/src/main/res/drawable/image_onboarding_01.png differ diff --git a/core/designsystem/src/main/res/drawable/image_onboarding_02.png b/core/designsystem/src/main/res/drawable/image_onboarding_02.png new file mode 100644 index 000000000..94ea10542 Binary files /dev/null and b/core/designsystem/src/main/res/drawable/image_onboarding_02.png differ diff --git a/core/designsystem/src/main/res/drawable/image_onboarding_03.png b/core/designsystem/src/main/res/drawable/image_onboarding_03.png new file mode 100644 index 000000000..bd9512428 Binary files /dev/null and b/core/designsystem/src/main/res/drawable/image_onboarding_03.png differ diff --git a/core/model/src/main/java/com/neki/android/core/model/Term.kt b/core/model/src/main/java/com/neki/android/core/model/Term.kt new file mode 100644 index 000000000..54152fda4 --- /dev/null +++ b/core/model/src/main/java/com/neki/android/core/model/Term.kt @@ -0,0 +1,12 @@ +package com.neki.android.core.model + +import androidx.compose.runtime.Immutable + +@Immutable +data class Term( + val id: Long = 0L, + val title: String = "", + val url: String = "", + val isRequired: Boolean = false, + val isChecked: Boolean = false, +) 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 index 9f370f406..34eee1054 100644 --- 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 @@ -1,8 +1,12 @@ package com.neki.android.core.model +import androidx.compose.runtime.Immutable + +@Immutable data class UserInfo( val id: Long = 0L, val nickname: String = "", val profileImageUrl: String = "", val loginType: String = "", + val isRequiredTermsAgreed: Boolean = false, ) 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 index 7ef21b9fa..2e591253e 100644 --- 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 @@ -1,35 +1,18 @@ 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 + data class SuccessKakaoLogin(val idToken: String) : LoginIntent + data object FailKakaoLogin : LoginIntent } sealed interface LoginSideEffect { - data object NavigateToTerm : LoginSideEffect data object NavigateToMain : LoginSideEffect - data object NavigateBack : LoginSideEffect + data object NavigateToTerm : 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/login/LoginScreen.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/login/LoginScreen.kt index 110d80e61..9b041a2ae 100644 --- a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/login/LoginScreen.kt +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/login/LoginScreen.kt @@ -2,14 +2,17 @@ package com.neki.android.feature.auth.impl.login 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.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.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.component.LoginBackground @@ -20,41 +23,43 @@ import timber.log.Timber internal fun LoginRoute( viewModel: LoginViewModel = hiltViewModel(), navigateToTerm: () -> Unit, + navigateToMain: () -> Unit, ) { val context = LocalContext.current + val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() val nekiToast = remember { NekiToast(context) } viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> when (sideEffect) { + LoginSideEffect.NavigateToMain -> navigateToMain() LoginSideEffect.NavigateToTerm -> navigateToTerm() LoginSideEffect.NavigateToKakaoRedirectingUri -> { KakaoAuthHelper.login( context = context, onSuccess = { idToken -> - Timber.d("로그인 성공 $idToken") - viewModel.store.onIntent(LoginIntent.SuccessLogin(idToken)) + Timber.d("카카오 로그인 성공 $idToken") + viewModel.store.onIntent(LoginIntent.SuccessKakaoLogin(idToken)) }, onFailure = { message -> - Timber.d("로그인 실패 $message") + Timber.d("카카오 로그인 실패 $message") + viewModel.store.onIntent(LoginIntent.FailKakaoLogin) }, ) } - is LoginSideEffect.ShowToastMessage -> { - nekiToast.showToast(text = sideEffect.message) - } - - else -> {} + is LoginSideEffect.ShowToastMessage -> nekiToast.showToast(text = sideEffect.message) } } LoginScreen( + uiState = uiState, onIntent = viewModel.store::onIntent, ) } @Composable private fun LoginScreen( + uiState: LoginState = LoginState(), onIntent: (LoginIntent) -> Unit = {}, ) { Box { @@ -64,6 +69,10 @@ private fun LoginScreen( onClick = { onIntent(LoginIntent.ClickKakaoLogin) }, ) } + + if (uiState.isLoading) { + LoadingDialog() + } } @ComponentPreview 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 index f0ea9fe00..31a6d17a3 100644 --- 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 @@ -4,20 +4,19 @@ 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.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, + private val tokenRepository: TokenRepository, + private val userRepository: UserRepository, ) : ViewModel() { val store: MviIntentStore = mviIntentStore( @@ -33,71 +32,46 @@ class LoginViewModel @Inject constructor( ) { 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) } - } + is LoginIntent.SuccessKakaoLogin -> signUp(intent.idToken, reduce, postSideEffect) + LoginIntent.FailKakaoLogin -> postSideEffect(LoginSideEffect.ShowToastMessage("카카오 로그인에 실패했습니다.")) } } - private fun loginWithKakao( - idToken: String, + private fun signUp( + kakaoIdToken: String, reduce: (LoginState.() -> LoginState) -> Unit, postSideEffect: (LoginSideEffect) -> Unit, ) = viewModelScope.launch { reduce { copy(isLoading = true) } - authRepository.loginWithKakao(idToken) - .onSuccess { + authRepository.loginWithKakao(kakaoIdToken) + .onSuccess { authResult -> tokenRepository.saveTokens( - accessToken = it.accessToken, - refreshToken = it.refreshToken, + accessToken = authResult.accessToken, + refreshToken = authResult.refreshToken, ) - authRepository.setCompletedOnboarding(true) - postSideEffect(LoginSideEffect.NavigateToMain) + checkTermAgreementState(postSideEffect) } .onFailure { exception -> Timber.e(exception) - postSideEffect(LoginSideEffect.ShowToastMessage("로그인에 실패했습니다. 다시 시도해주세요.")) + postSideEffect(LoginSideEffect.ShowToastMessage("가입에 실패했습니다. 다시 시도해주세요.")) } reduce { copy(isLoading = false) } } + + private suspend fun checkTermAgreementState( + postSideEffect: (LoginSideEffect) -> Unit, + ) { + userRepository.getUserInfo() + .onSuccess { userInfo -> + if (userInfo.isRequiredTermsAgreed) { + postSideEffect(LoginSideEffect.NavigateToMain) + } else { + postSideEffect(LoginSideEffect.NavigateToTerm) + } + } + .onFailure { exception -> + Timber.e(exception) + postSideEffect(LoginSideEffect.NavigateToTerm) + } + } } 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 index 5a5257ac1..e23135868 100644 --- 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 @@ -2,7 +2,6 @@ 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 @@ -35,19 +34,14 @@ private fun EntryProviderScope.authEntry(navigator: AuthNavigator) { ) } - entry( - clazzContentKey = { key -> key.toString() }, - ) { + entry { LoginRoute( navigateToTerm = navigator::navigateToTerm, + navigateToMain = { navigator.navigateRoot(RootNavKey.Main) }, ) } - entry( - metadata = HiltSharedViewModelStoreNavEntryDecorator.parent( - AuthNavKey.Login.toString(), - ), - ) { + entry { 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/component/OnboardingPage.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/onboarding/component/OnboardingPage.kt index bab158624..1ffbb4810 100644 --- 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 @@ -33,7 +33,7 @@ internal fun OnboardingPageContent( private fun OnboardingPageContentPreview() { NekiTheme { OnboardingPageContent( - imageRes = R.drawable.icon_onboarding_01, + imageRes = R.drawable.image_onboarding_01, ) } } 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 index 63e7ebed5..1c7518256 100644 --- 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 @@ -9,17 +9,17 @@ enum class OnboardingPage( val description: String, ) { BOOTH_SEARCH( - imageRes = R.drawable.icon_onboarding_01, + imageRes = R.drawable.image_onboarding_01, title = "빠른 네컷 부스 탐색", description = "네컷 부스 정보를\n빠르게 쉽게 찾아요", ), POSE_RECOMMEND( - imageRes = R.drawable.icon_onboarding_02, + imageRes = R.drawable.image_onboarding_02, title = "포즈 걱정 없는 촬영 경험", description = "인원수에 맞는\n포즈를 추천받아요", ), PHOTO_ARCHIVE( - imageRes = R.drawable.icon_onboarding_03, + imageRes = R.drawable.image_onboarding_03, title = "네컷 사진 아카이빙", description = "흩어지기 쉬운 사진을\n한곳에 모아요", ), diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/term/TermContract.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/term/TermContract.kt new file mode 100644 index 000000000..f384bba7f --- /dev/null +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/term/TermContract.kt @@ -0,0 +1,29 @@ +package com.neki.android.feature.auth.impl.term + +import com.neki.android.core.model.Term +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +data class TermState( + val isLoading: Boolean = false, + val terms: ImmutableList = persistentListOf(), +) { + val isAllRequiredTermChecked: Boolean + get() = terms.filter { it.isRequired }.all { it.isChecked } +} + +sealed interface TermIntent { + data object EnterTermScreen : TermIntent + data object ClickAgreeAll : TermIntent + data class ClickAgreeTerm(val term: Term) : TermIntent + data class ClickTermNavigateUrl(val term: Term) : TermIntent + data object ClickNext : TermIntent + data object ClickBack : TermIntent +} + +sealed interface TermSideEffect { + data object NavigateToMain : TermSideEffect + data object NavigateBack : TermSideEffect + data class NavigateUrl(val url: String) : TermSideEffect + data class ShowToastMessage(val message: String) : TermSideEffect +} 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 index 3b87c6a05..08160f1b2 100644 --- 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 @@ -7,7 +7,6 @@ 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 @@ -21,16 +20,12 @@ 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(), + viewModel: TermViewModel = hiltViewModel(), navigateToMain: () -> Unit, navigateBack: () -> Unit, ) { @@ -38,26 +33,18 @@ internal fun TermRoute( 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 -> { + TermSideEffect.NavigateToMain -> navigateToMain() + TermSideEffect.NavigateBack -> navigateBack() + is TermSideEffect.NavigateUrl -> { val intent = Intent(Intent.ACTION_VIEW, Uri.parse(sideEffect.url)) context.startActivity(intent) } - is LoginSideEffect.ShowToastMessage -> { + is TermSideEffect.ShowToastMessage -> { nekiToast.showToast(sideEffect.message) } - - else -> {} } } @@ -69,12 +56,12 @@ internal fun TermRoute( @Composable private fun TermScreen( - uiState: LoginState = LoginState(), - onIntent: (LoginIntent) -> Unit = {}, + uiState: TermState = TermState(), + onIntent: (TermIntent) -> Unit = {}, ) { Column { TermTopBar( - onClickBack = { onIntent(LoginIntent.ClickBack) }, + onClickBack = { onIntent(TermIntent.ClickBack) }, ) Column( modifier = Modifier @@ -83,17 +70,17 @@ private fun TermScreen( ) { 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)) }, + terms = uiState.terms, + isAllRequiredTermChecked = uiState.isAllRequiredTermChecked, + onClickAgreeAll = { onIntent(TermIntent.ClickAgreeAll) }, + onClickAgreeTerm = { onIntent(TermIntent.ClickAgreeTerm(it)) }, + onClickTermDetail = { onIntent(TermIntent.ClickTermNavigateUrl(it)) }, ) CTAButtonPrimary( modifier = Modifier.fillMaxWidth(), text = "다음으로", - onClick = { onIntent(LoginIntent.ClickNext) }, - enabled = uiState.isAllRequiredAgreed, + onClick = { onIntent(TermIntent.ClickNext) }, + enabled = uiState.isAllRequiredTermChecked, ) } } diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/term/TermViewModel.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/term/TermViewModel.kt new file mode 100644 index 000000000..5ea3fbbeb --- /dev/null +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/term/TermViewModel.kt @@ -0,0 +1,100 @@ +package com.neki.android.feature.auth.impl.term + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.neki.android.core.dataapi.repository.AuthRepository +import com.neki.android.core.dataapi.repository.TermRepository +import com.neki.android.core.ui.MviIntentStore +import com.neki.android.core.ui.mviIntentStore +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class TermViewModel @Inject constructor( + private val termRepository: TermRepository, + private val authRepository: AuthRepository, +) : ViewModel() { + + val store: MviIntentStore = + mviIntentStore( + initialState = TermState(), + onIntent = ::onIntent, + initialFetchData = { store.onIntent(TermIntent.EnterTermScreen) }, + ) + + private fun onIntent( + intent: TermIntent, + state: TermState, + reduce: (TermState.() -> TermState) -> Unit, + postSideEffect: (TermSideEffect) -> Unit, + ) { + when (intent) { + TermIntent.EnterTermScreen -> fetchTerms(reduce) + + TermIntent.ClickAgreeAll -> { + val shouldCheckAll = !state.isAllRequiredTermChecked + val updatedTerms = state.terms.map { term -> + if (term.isRequired) term.copy(isChecked = shouldCheckAll) else term + }.toImmutableList() + reduce { copy(terms = updatedTerms) } + } + + is TermIntent.ClickAgreeTerm -> { + val updatedTerms = state.terms.map { term -> + if (term.id == intent.term.id) term.copy(isChecked = !term.isChecked) else term + }.toImmutableList() + reduce { copy(terms = updatedTerms) } + } + + is TermIntent.ClickTermNavigateUrl -> { + postSideEffect(TermSideEffect.NavigateUrl(intent.term.url)) + } + + TermIntent.ClickNext -> { + if (state.isAllRequiredTermChecked) { + agreeTerms(state, reduce, postSideEffect) + } + } + + TermIntent.ClickBack -> { + postSideEffect(TermSideEffect.NavigateBack) + } + } + } + + private fun fetchTerms(reduce: (TermState.() -> TermState) -> Unit) { + viewModelScope.launch { + reduce { copy(isLoading = true) } + termRepository.getTerms() + .onSuccess { terms -> + reduce { copy(isLoading = false, terms = terms.toImmutableList()) } + } + .onFailure { error -> + Timber.e(error) + reduce { copy(isLoading = false) } + } + } + } + + private fun agreeTerms( + state: TermState, + reduce: (TermState.() -> TermState) -> Unit, + postSideEffect: (TermSideEffect) -> Unit, + ) = viewModelScope.launch { + reduce { copy(isLoading = true) } + val checkedTermIds = state.terms.filter { it.isChecked }.map { it.id } + termRepository.agreeTerms(checkedTermIds) + .onSuccess { + authRepository.setCompletedOnboarding(true) + postSideEffect(TermSideEffect.NavigateToMain) + } + .onFailure { exception -> + Timber.e(exception) + postSideEffect(TermSideEffect.ShowToastMessage("약관 동의에 실패했습니다. 다시 시도해주세요.")) + } + reduce { copy(isLoading = false) } + } +} 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 index fdf2b3622..f39bd6c86 100644 --- 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 @@ -18,12 +18,11 @@ 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 +import com.neki.android.core.model.Term @Composable internal fun AgreementSection( - agreement: TermAgreement, - isAgreed: Boolean = false, + term: Term, onClickAgree: () -> Unit = {}, onClickNavigateUrl: () -> Unit = {}, ) { @@ -45,16 +44,16 @@ internal fun AgreementSection( .size(24.dp), imageVector = ImageVector.vectorResource(R.drawable.icon_check), contentDescription = null, - tint = if (isAgreed) NekiTheme.colorScheme.primary500 else NekiTheme.colorScheme.gray200, + tint = if (term.isChecked) NekiTheme.colorScheme.primary500 else NekiTheme.colorScheme.gray200, ) Text( modifier = Modifier.padding(end = 2.dp), - text = if (agreement.isRequired) "(필수)" else "(선택)", + text = if (term.isRequired) "(필수)" else "(선택)", style = NekiTheme.typography.body14Medium, color = NekiTheme.colorScheme.gray500, ) Text( - text = agreement.title, + text = term.title, style = NekiTheme.typography.body16Medium, color = NekiTheme.colorScheme.gray900, ) @@ -74,6 +73,6 @@ internal fun AgreementSection( @Composable private fun TermSectionPreview() { NekiTheme { - AgreementSection(agreement = TermAgreement.SERVICE_TERMS) + AgreementSection(term = Term(title = "서비스 이용 약관", isRequired = true)) } } 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 index bbf7402da..50b13cd7f 100644 --- 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 @@ -22,19 +22,19 @@ 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.model.Term 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 +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf @Composable internal fun TermContent( modifier: Modifier = Modifier, - agreedTerms: ImmutableSet = persistentSetOf(), - isAllRequiredAgreed: Boolean = false, + terms: ImmutableList = persistentListOf(), + isAllRequiredTermChecked: Boolean = false, onClickAgreeAll: () -> Unit = {}, - onClickAgreeTerm: (TermAgreement) -> Unit = {}, - onClickTermDetail: (TermAgreement) -> Unit = {}, + onClickAgreeTerm: (Term) -> Unit = {}, + onClickTermDetail: (Term) -> Unit = {}, ) { Column( modifier = modifier, @@ -56,7 +56,7 @@ internal fun TermContent( .noRippleClickable(onClick = onClickAgreeAll) .border( width = 1.dp, - color = if (isAllRequiredAgreed) NekiTheme.colorScheme.primary500 else NekiTheme.colorScheme.gray100, + color = if (isAllRequiredTermChecked) NekiTheme.colorScheme.primary500 else NekiTheme.colorScheme.gray100, shape = RoundedCornerShape(12.dp), ) .background( @@ -71,7 +71,7 @@ internal fun TermContent( modifier = Modifier.size(24.dp), imageVector = ImageVector.vectorResource(R.drawable.icon_check), contentDescription = null, - tint = if (isAllRequiredAgreed) NekiTheme.colorScheme.primary500 else NekiTheme.colorScheme.gray200, + tint = if (isAllRequiredTermChecked) NekiTheme.colorScheme.primary500 else NekiTheme.colorScheme.gray200, ) Text( text = "약관 전체 동의", @@ -80,12 +80,11 @@ internal fun TermContent( ) } VerticalSpacer(12.dp) - TermAgreement.entries.forEach { agreement -> + terms.forEach { term -> AgreementSection( - agreement = agreement, - isAgreed = agreement in agreedTerms, - onClickAgree = { onClickAgreeTerm(agreement) }, - onClickNavigateUrl = { onClickTermDetail(agreement) }, + term = term, + onClickAgree = { onClickAgreeTerm(term) }, + onClickNavigateUrl = { onClickTermDetail(term) }, ) } } 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 deleted file mode 100644 index b78c28014..000000000 --- a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/term/model/TermAgreement.kt +++ /dev/null @@ -1,30 +0,0 @@ -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() - } -}