From 069ef6e5374e7f450695ffa30b40af4e44c37c1d Mon Sep 17 00:00:00 2001 From: dada4 Date: Fri, 31 Jul 2026 17:02:20 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83,?= =?UTF-8?q?=20=ED=9A=8C=EC=9B=90=ED=83=88=ED=87=B4=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/kr/co/call/callfromai/AppScreen.kt | 1 + .../repositoryImpl/MyPageRepositoryImpl.kt | 36 +++++++-- .../domain/repository/MyPageRepository.kt | 4 +- .../java/kr/co/call/network/api/MyPageApi.kt | 14 ++++ .../kr/co/call/network/di/NetworkModule.kt | 9 +++ .../kr/co/call/network/util/SafeApiCall.kt | 21 ++++- feature/mypage/impl/build.gradle.kts | 3 + .../co/call/impl/auth/KakaoLogoutManager.kt | 23 ++++++ .../co/call/impl/entry/MyPageEntryBuilder.kt | 2 + .../kr/co/call/impl/screen/MyPageScreen.kt | 78 +++++++++++++++---- .../call/impl/viewmodel/MyPageSideEffect.kt | 5 +- .../kr/co/call/impl/viewmodel/MyPageState.kt | 3 + .../co/call/impl/viewmodel/MyPageViewModel.kt | 51 +++++++++++- 13 files changed, 223 insertions(+), 27 deletions(-) create mode 100644 core/network/src/main/java/kr/co/call/network/api/MyPageApi.kt create mode 100644 feature/mypage/impl/src/main/java/kr/co/call/impl/auth/KakaoLogoutManager.kt diff --git a/app/src/main/java/kr/co/call/callfromai/AppScreen.kt b/app/src/main/java/kr/co/call/callfromai/AppScreen.kt index 7756e3a6..bdc7591a 100644 --- a/app/src/main/java/kr/co/call/callfromai/AppScreen.kt +++ b/app/src/main/java/kr/co/call/callfromai/AppScreen.kt @@ -229,6 +229,7 @@ fun AppScreen( } ) myPageEntry( + navigateToLogin = {appNavigator.replaceAll(LoginNavKey)}, navigateToFaq = { appNavigator.navigate(FaqNavKey) }, navigateToTerms = { appNavigator.navigate(TermNavKey) }, navigateToCharacterManagement = { appNavigator.navigate(CharacterManagementNavKey) }, diff --git a/core/data/src/main/java/kr/co/call/data/repositoryImpl/MyPageRepositoryImpl.kt b/core/data/src/main/java/kr/co/call/data/repositoryImpl/MyPageRepositoryImpl.kt index 9f3a40f6..af83e596 100644 --- a/core/data/src/main/java/kr/co/call/data/repositoryImpl/MyPageRepositoryImpl.kt +++ b/core/data/src/main/java/kr/co/call/data/repositoryImpl/MyPageRepositoryImpl.kt @@ -1,12 +1,22 @@ package kr.co.call.data.repositoryImpl import kotlinx.coroutines.delay +import kr.co.call.data.util.runRepositoryCatching +import kr.co.call.datastore.TokenDataStore import kr.co.call.domain.model.mypage.MyPageProfile import kr.co.call.domain.repository.MyPageRepository +import kr.co.call.network.api.MyPageApi +import kr.co.call.network.util.ErrorResponseParser +import kr.co.call.network.util.safeApiCallUnit +import kr.co.call.network.util.safeEmptyApiCall import javax.inject.Inject // TODO: API 연동 전 임시 구현체 -class MyPageRepositoryImpl @Inject constructor() : MyPageRepository { +class MyPageRepositoryImpl @Inject constructor( + private val myPageApi: MyPageApi, + private val tokenDataStore: TokenDataStore, + private val errorResponseParser: ErrorResponseParser +) : MyPageRepository { override suspend fun getMyProfile(): Result { delay(500) @@ -21,11 +31,23 @@ class MyPageRepositoryImpl @Inject constructor() : MyPageRepository { } } - override suspend fun logout() { - delay(300) - } + override suspend fun logout():Result = + runRepositoryCatching { + safeEmptyApiCall(errorResponseParser){ + myPageApi.logout() + } + //서버 로그아웃 성공 후 기기 토큰 삭제 + tokenDataStore.clearTokens() + } + + override suspend fun deleteAccount(): Result = + runRepositoryCatching { + //토큰이 남아 있을 때 탈퇴 api를 먼저 호출 + safeApiCallUnit(errorResponseParser){ + myPageApi.deleteAccount() + } + //서버 탈퇴 성공 후 기기의 토큰 삭제 + tokenDataStore.clearTokens() + } - override suspend fun deleteAccount() { - delay(300) - } } \ No newline at end of file diff --git a/core/domain/src/main/java/kr/co/call/domain/repository/MyPageRepository.kt b/core/domain/src/main/java/kr/co/call/domain/repository/MyPageRepository.kt index 6f953611..e7173d1e 100644 --- a/core/domain/src/main/java/kr/co/call/domain/repository/MyPageRepository.kt +++ b/core/domain/src/main/java/kr/co/call/domain/repository/MyPageRepository.kt @@ -4,6 +4,6 @@ import kr.co.call.domain.model.mypage.MyPageProfile interface MyPageRepository { suspend fun getMyProfile(): Result - suspend fun logout() - suspend fun deleteAccount() + suspend fun logout(): Result + suspend fun deleteAccount(): Result } \ No newline at end of file diff --git a/core/network/src/main/java/kr/co/call/network/api/MyPageApi.kt b/core/network/src/main/java/kr/co/call/network/api/MyPageApi.kt new file mode 100644 index 00000000..7558086e --- /dev/null +++ b/core/network/src/main/java/kr/co/call/network/api/MyPageApi.kt @@ -0,0 +1,14 @@ +package kr.co.call.network.api + +import kr.co.call.network.dto.ApiResponse +import retrofit2.Response +import retrofit2.http.DELETE +import retrofit2.http.POST + +interface MyPageApi { + @POST("auth/logout") + suspend fun logout(): Response + + @DELETE("members/me") + suspend fun deleteAccount(): ApiResponse +} \ No newline at end of file diff --git a/core/network/src/main/java/kr/co/call/network/di/NetworkModule.kt b/core/network/src/main/java/kr/co/call/network/di/NetworkModule.kt index d9bd93a2..e56a2177 100644 --- a/core/network/src/main/java/kr/co/call/network/di/NetworkModule.kt +++ b/core/network/src/main/java/kr/co/call/network/di/NetworkModule.kt @@ -9,6 +9,7 @@ import dagger.hilt.components.SingletonComponent import kr.co.call.network.BuildConfig import kr.co.call.network.api.AgreementApi import kr.co.call.network.api.LoginApi +import kr.co.call.network.api.MyPageApi import kr.co.call.network.api.TokenReissueApi import kr.co.call.network.interceptor.AuthInterceptor import kr.co.call.network.interceptor.TokenAuthenticator @@ -156,4 +157,12 @@ object NetworkModule { ): TokenReissueApi{ return retrofit.create(TokenReissueApi::class.java) } + + @Provides + @Singleton + fun provideMyPageApi( + retrofit: Retrofit, + ): MyPageApi{ + return retrofit.create(MyPageApi::class.java) + } } diff --git a/core/network/src/main/java/kr/co/call/network/util/SafeApiCall.kt b/core/network/src/main/java/kr/co/call/network/util/SafeApiCall.kt index aa48f41e..22483c48 100644 --- a/core/network/src/main/java/kr/co/call/network/util/SafeApiCall.kt +++ b/core/network/src/main/java/kr/co/call/network/util/SafeApiCall.kt @@ -3,6 +3,7 @@ package kr.co.call.network.util import kr.co.call.network.dto.ApiResponse import kr.co.call.network.exception.ApiException import retrofit2.HttpException +import retrofit2.Response import kotlin.coroutines.cancellation.CancellationException /** @@ -60,6 +61,24 @@ suspend fun safeApiCallUnit( } } +suspend fun safeEmptyApiCall( + parser: ErrorResponseParser, + call: suspend()-> Response, +){ + try { + val response=call() + if (!response.isSuccessful){ + throw HttpException(response) + } + }catch (e: CancellationException){ + throw e + }catch (e: ApiException){ + throw e + }catch(e: HttpException){ + throw e.toApiException(parser) + } +} + private suspend fun HttpException.toApiException(parser: ErrorResponseParser): ApiException { val body = parser.parse(this) return ApiException( @@ -79,4 +98,4 @@ private fun ApiResponse<*>.throwIfNotSuccess() { message = message.ifBlank { "요청을 처리하지 못했습니다." }, ) } -} \ No newline at end of file +} diff --git a/feature/mypage/impl/build.gradle.kts b/feature/mypage/impl/build.gradle.kts index b6395e0c..3c9189d0 100644 --- a/feature/mypage/impl/build.gradle.kts +++ b/feature/mypage/impl/build.gradle.kts @@ -39,4 +39,7 @@ dependencies { // coil implementation(libs.coil.compose) + + //kakao + implementation(libs.kakao.user) } \ No newline at end of file diff --git a/feature/mypage/impl/src/main/java/kr/co/call/impl/auth/KakaoLogoutManager.kt b/feature/mypage/impl/src/main/java/kr/co/call/impl/auth/KakaoLogoutManager.kt new file mode 100644 index 00000000..03076fff --- /dev/null +++ b/feature/mypage/impl/src/main/java/kr/co/call/impl/auth/KakaoLogoutManager.kt @@ -0,0 +1,23 @@ +package kr.co.call.impl.auth + +import kotlinx.coroutines.suspendCancellableCoroutine +import com.kakao.sdk.user.UserApiClient +import javax.inject.Inject +import kotlin.coroutines.resume + +class KakaoLogoutManager @Inject constructor(){ + suspend fun logout(): Result = + suspendCancellableCoroutine { continuation -> + UserApiClient.instance.logout callback@{ error-> + if (!continuation.isActive){ + return@callback + } + val result = if (error == null){ + Result.success(Unit) + }else { + Result.failure(error) + } + continuation.resume(result) + } + } +} \ No newline at end of file diff --git a/feature/mypage/impl/src/main/java/kr/co/call/impl/entry/MyPageEntryBuilder.kt b/feature/mypage/impl/src/main/java/kr/co/call/impl/entry/MyPageEntryBuilder.kt index e1f677b3..1e1b0cf5 100644 --- a/feature/mypage/impl/src/main/java/kr/co/call/impl/entry/MyPageEntryBuilder.kt +++ b/feature/mypage/impl/src/main/java/kr/co/call/impl/entry/MyPageEntryBuilder.kt @@ -30,11 +30,13 @@ fun EntryProviderScope.myPageEntry( navigateToEditProfile: () -> Unit, navigateToSubscription: () -> Unit, navigateToDisturbTime: () -> Unit, + navigateToLogin:()->Unit, navigateToCallTimeManagement: () -> Unit, onBack: () -> Unit = {}, ) { entry { MyPageScreen( + onNavigateToLogin = navigateToLogin, onNavigateToCharacterManagement = navigateToCharacterManagement, navigateToFaq = navigateToFaq, navigateToTerms = navigateToTerms, diff --git a/feature/mypage/impl/src/main/java/kr/co/call/impl/screen/MyPageScreen.kt b/feature/mypage/impl/src/main/java/kr/co/call/impl/screen/MyPageScreen.kt index ebb6ca60..26487893 100644 --- a/feature/mypage/impl/src/main/java/kr/co/call/impl/screen/MyPageScreen.kt +++ b/feature/mypage/impl/src/main/java/kr/co/call/impl/screen/MyPageScreen.kt @@ -1,5 +1,6 @@ package kr.co.call.impl.screen +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -18,6 +19,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -39,6 +41,7 @@ import org.orbitmvi.orbit.compose.collectSideEffect @Composable fun MyPageScreen( modifier: Modifier = Modifier, + onNavigateToLogin: ()->Unit, onNavigateToProfile: () -> Unit = {}, viewModel: MyPageViewModel = hiltViewModel(), navigateToFaq: () -> Unit, @@ -49,21 +52,57 @@ fun MyPageScreen( var showLogoutDialog by remember { mutableStateOf(false) } var showDeleteAccountDialog by remember { mutableStateOf(false) } var showComingSoon by remember { mutableStateOf(false) } + val context = LocalContext.current viewModel.collectSideEffect { sideEffect -> when (sideEffect) { is MyPageSideEffect.NavigateToProfileDetail -> onNavigateToProfile() - is MyPageSideEffect.NavigateToChargeTicket -> { showComingSoon = true } - is MyPageSideEffect.NavigateToPurchaseTicket -> { showComingSoon = true } - is MyPageSideEffect.NavigateToTicketHistory -> { showComingSoon = true } + is MyPageSideEffect.NavigateToChargeTicket -> { + showComingSoon = true + } + + is MyPageSideEffect.NavigateToPurchaseTicket -> { + showComingSoon = true + } + + is MyPageSideEffect.NavigateToTicketHistory -> { + showComingSoon = true + } + is MyPageSideEffect.NavigateToCharacterManagement -> onNavigateToCharacterManagement() - is MyPageSideEffect.NavigateToFaq -> { navigateToFaq() } - is MyPageSideEffect.NavigateToInquiry -> { showComingSoon = true } - is MyPageSideEffect.NavigateToTerms -> { navigateToTerms() } - is MyPageSideEffect.ShowLogoutConfirmDialog -> { showLogoutDialog = true } - is MyPageSideEffect.ShowDeleteAccountConfirmDialog -> { showDeleteAccountDialog=true } - is MyPageSideEffect.NavigateToLogin -> { showLogoutDialog = false /*TODO: 로그인 화면으로 */} - is MyPageSideEffect.NavigateToLanding -> { showDeleteAccountDialog = false /*TODO: 랜딩 화면으로*/} + is MyPageSideEffect.NavigateToFaq -> { + navigateToFaq() + } + + is MyPageSideEffect.NavigateToInquiry -> { + showComingSoon = true + } + + is MyPageSideEffect.NavigateToTerms -> { + navigateToTerms() + } + + is MyPageSideEffect.ShowLogoutConfirmDialog -> { + showLogoutDialog = true + } + + is MyPageSideEffect.ShowDeleteAccountConfirmDialog -> { + showDeleteAccountDialog = true + } + + is MyPageSideEffect.NavigateToLogin -> { + showLogoutDialog = false + showDeleteAccountDialog = false + onNavigateToLogin() + } + + is MyPageSideEffect.ShowMessage -> { + Toast.makeText( + context, + sideEffect.message, + Toast.LENGTH_SHORT, + ).show() + } } } @@ -76,16 +115,27 @@ fun MyPageScreen( // 로그아웃 다이얼로그 if (showLogoutDialog) { MyPageLogoutDialog( - onConfirm = { viewModel.handleIntent(MyPageIntent.ConfirmLogout) }, - onDismiss = { showLogoutDialog = false }, + onConfirm = { + if (state.authStatus != LoadStatus.Loading) { + viewModel.handleIntent(MyPageIntent.ConfirmLogout) + } + }, + onDismiss = {if (state.authStatus != LoadStatus.Loading){ + showLogoutDialog=false + } + }, ) } // 계정 삭제 다이얼로그 if (showDeleteAccountDialog) { MyPageDeleteAccountDialog( - onConfirm = { viewModel.handleIntent(MyPageIntent.ConfirmDeleteAccount) }, - onDismiss = { showDeleteAccountDialog = false }, + onConfirm ={ if (state.authStatus != LoadStatus.Loading){ viewModel.handleIntent(MyPageIntent.ConfirmDeleteAccount)} }, + onDismiss = { + if(state.authStatus != LoadStatus.Loading){ + showDeleteAccountDialog = false + } + }, ) } diff --git a/feature/mypage/impl/src/main/java/kr/co/call/impl/viewmodel/MyPageSideEffect.kt b/feature/mypage/impl/src/main/java/kr/co/call/impl/viewmodel/MyPageSideEffect.kt index 7634ac6c..ff674214 100644 --- a/feature/mypage/impl/src/main/java/kr/co/call/impl/viewmodel/MyPageSideEffect.kt +++ b/feature/mypage/impl/src/main/java/kr/co/call/impl/viewmodel/MyPageSideEffect.kt @@ -12,5 +12,8 @@ sealed interface MyPageSideEffect { data object ShowLogoutConfirmDialog : MyPageSideEffect data object ShowDeleteAccountConfirmDialog : MyPageSideEffect data object NavigateToLogin : MyPageSideEffect - data object NavigateToLanding : MyPageSideEffect + + data class ShowMessage( + val message: String, + ): MyPageSideEffect } \ No newline at end of file diff --git a/feature/mypage/impl/src/main/java/kr/co/call/impl/viewmodel/MyPageState.kt b/feature/mypage/impl/src/main/java/kr/co/call/impl/viewmodel/MyPageState.kt index bbde6d71..07cd2086 100644 --- a/feature/mypage/impl/src/main/java/kr/co/call/impl/viewmodel/MyPageState.kt +++ b/feature/mypage/impl/src/main/java/kr/co/call/impl/viewmodel/MyPageState.kt @@ -8,5 +8,8 @@ data class MyPageState( val tier: String = "", val remainingTicketCount: Int = 0, val appVersion: String = "", + //프로필 조회 상태 val loadStatus: LoadStatus = LoadStatus.Idle, + //로그아웃, 탈퇴 상태 + val authStatus:LoadStatus=LoadStatus.Idle, ) \ No newline at end of file diff --git a/feature/mypage/impl/src/main/java/kr/co/call/impl/viewmodel/MyPageViewModel.kt b/feature/mypage/impl/src/main/java/kr/co/call/impl/viewmodel/MyPageViewModel.kt index 8d009dca..532a8c01 100644 --- a/feature/mypage/impl/src/main/java/kr/co/call/impl/viewmodel/MyPageViewModel.kt +++ b/feature/mypage/impl/src/main/java/kr/co/call/impl/viewmodel/MyPageViewModel.kt @@ -5,14 +5,17 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CancellationException import kr.co.call.domain.repository.MyPageRepository import kr.co.call.domain.util.LoadStatus +import kr.co.call.impl.auth.KakaoLogoutManager import org.orbitmvi.orbit.Container import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.viewmodel.container +import timber.log.Timber import javax.inject.Inject @HiltViewModel class MyPageViewModel @Inject constructor( private val myPageRepository: MyPageRepository, + private val kakaoLogoutManager: KakaoLogoutManager, ) : ViewModel(), ContainerHost { override val container: Container = container( @@ -60,13 +63,57 @@ class MyPageViewModel @Inject constructor( } private fun logout() = intent { + if (state.authStatus== LoadStatus.Loading)return@intent + reduce{ + state.copy(authStatus = LoadStatus.Loading) + } myPageRepository.logout() - postSideEffect(MyPageSideEffect.NavigateToLogin) + .onSuccess { + kakaoLogoutManager.logout() + .onFailure{error-> + Timber.w(error, "카카오 SDK 로그아웃 실패") + } + postSideEffect(MyPageSideEffect.NavigateToLogin) + } + .onFailure { error -> + if (error is CancellationException) throw error + reduce{ + state.copy(authStatus = LoadStatus.Idle) + } + postSideEffect( + MyPageSideEffect.ShowMessage( + message=error.message + ?: "로그아웃에 실패했습니다.", + ), + ) + } } private fun deleteAccount() = intent { + if (state.authStatus== LoadStatus.Loading) return@intent + + reduce{ + state.copy(authStatus = LoadStatus.Loading) + } myPageRepository.deleteAccount() - postSideEffect(MyPageSideEffect.NavigateToLanding) + .onSuccess { + kakaoLogoutManager.logout() + .onFailure {error-> + Timber.w(error, "탈퇴 후 카카오 로그아웃 실패") + } + postSideEffect(MyPageSideEffect.NavigateToLogin) + }.onFailure { error -> + if (error is CancellationException) throw error + reduce { + state.copy(authStatus = LoadStatus.Idle) + } + postSideEffect( + MyPageSideEffect.ShowMessage( + message=error.message + ?:"회원 탈퇴에 실패했습니다.", + ), + ) + } } private fun navigate(effect: MyPageSideEffect) = intent { From 5e3a1a04d3362151c67d2d3b8f300c73537d3555 Mon Sep 17 00:00:00 2001 From: dada4 Date: Fri, 31 Jul 2026 17:28:05 +0900 Subject: [PATCH 2/9] =?UTF-8?q?fix:=20=EC=95=A1=EC=84=B8=EC=8A=A4=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EB=A7=8C=EB=A3=8C=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/kr/co/call/network/interceptor/AuthInterceptor.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/core/network/src/main/java/kr/co/call/network/interceptor/AuthInterceptor.kt b/core/network/src/main/java/kr/co/call/network/interceptor/AuthInterceptor.kt index b8bccea3..312c5b34 100644 --- a/core/network/src/main/java/kr/co/call/network/interceptor/AuthInterceptor.kt +++ b/core/network/src/main/java/kr/co/call/network/interceptor/AuthInterceptor.kt @@ -23,6 +23,7 @@ class AuthInterceptor @Inject constructor( } //저장된 access 토큰이 없으면 원래 요청 그대로 전송 val accessToken = tokenDataStore.getCachedAccessToken() + ?: runBlocking{tokenDataStore.getCachedAccessToken()} if (accessToken.isNullOrBlank()) { return chain.proceed(originalRequest) } From ae490281216a934c5994d8ee19e3555061118472 Mon Sep 17 00:00:00 2001 From: dada4 Date: Fri, 31 Jul 2026 21:58:48 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83.=20=ED=9A=8C=EC=9B=90=ED=83=88=ED=87=B4=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kr/co/call/data/di/RepositoryModule.kt | 8 ++ .../repositoryImpl/LoginRepositoryImpl.kt | 6 +- .../repositoryImpl/MyPageRepositoryImpl.kt | 5 +- .../OnboardingRepositoryImpl.kt | 69 +++++++++++ .../call/datastore/AccountStateDataStore.kt | 33 +++++ .../model/onboarding/OnboardingSubmission.kt | 36 ++++++ .../domain/repository/OnboardingRepository.kt | 9 ++ core/network/build.gradle.kts | 2 +- .../kr/co/call/network/api/AICharacterApi.kt | 13 ++ .../java/kr/co/call/network/api/MyPageApi.kt | 8 ++ .../kr/co/call/network/di/NetworkModule.kt | 9 ++ .../onboarding/CreateCharacterRequestDto.kt | 21 ++++ .../dto/onboarding/UpdateMemberRequestDto.kt | 11 ++ .../network/interceptor/AuthInterceptor.kt | 4 +- .../component/TemporaryProfileImageData.kt | 5 +- .../call/impl/entry/OnboardingEntryBuilder.kt | 34 +++++- .../co/call/impl/screen/Onboarding1Screen.kt | 19 ++- .../co/call/impl/screen/Onboarding2Screen.kt | 17 ++- .../co/call/impl/screen/Onboarding6Screen.kt | 28 ++++- .../impl/viewmodel/OnboardingSideEffect.kt | 11 ++ .../call/impl/viewmodel/OnboardingUiState.kt | 7 +- .../impl/viewmodel/OnboardingViewModel.kt | 113 +++++++++++++++++- .../kr/co/call/impl/viewmodel/model/Job.kt | 17 +++ .../impl/viewmodel/state/Onboarding1State.kt | 2 + .../impl/viewmodel/state/Onboarding2State.kt | 2 + 25 files changed, 459 insertions(+), 30 deletions(-) create mode 100644 core/data/src/main/java/kr/co/call/data/repositoryImpl/OnboardingRepositoryImpl.kt create mode 100644 core/datastore/src/main/java/kr/co/call/datastore/AccountStateDataStore.kt create mode 100644 core/domain/src/main/java/kr/co/call/domain/model/onboarding/OnboardingSubmission.kt create mode 100644 core/domain/src/main/java/kr/co/call/domain/repository/OnboardingRepository.kt create mode 100644 core/network/src/main/java/kr/co/call/network/api/AICharacterApi.kt create mode 100644 core/network/src/main/java/kr/co/call/network/dto/onboarding/CreateCharacterRequestDto.kt create mode 100644 core/network/src/main/java/kr/co/call/network/dto/onboarding/UpdateMemberRequestDto.kt create mode 100644 feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/OnboardingSideEffect.kt create mode 100644 feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/model/Job.kt diff --git a/core/data/src/main/java/kr/co/call/data/di/RepositoryModule.kt b/core/data/src/main/java/kr/co/call/data/di/RepositoryModule.kt index 8e7c2564..7bc54dc6 100644 --- a/core/data/src/main/java/kr/co/call/data/di/RepositoryModule.kt +++ b/core/data/src/main/java/kr/co/call/data/di/RepositoryModule.kt @@ -10,6 +10,8 @@ import kr.co.call.data.repositoryImpl.CallRecordMockRepository import kr.co.call.data.repositoryImpl.FaqRepositoryImpl import kr.co.call.data.repositoryImpl.ChatRepositoryImpl import kr.co.call.data.repositoryImpl.HomeMockRepository +import kr.co.call.data.repositoryImpl.OnboardingRepositoryImpl +import kr.co.call.domain.repository.OnboardingRepository import kr.co.call.domain.repository.AICharacterRepository import kr.co.call.domain.repository.CallRecordRepository import kr.co.call.domain.repository.ChatRepository @@ -97,4 +99,10 @@ abstract class RepositoryModule { abstract fun bindCallSessionRepository( impl: AndroidCallSessionRepository, ): CallSessionRepository + + @Binds + @Singleton + abstract fun bindOnboardingRepository( + impl: OnboardingRepositoryImpl, + ): OnboardingRepository } diff --git a/core/data/src/main/java/kr/co/call/data/repositoryImpl/LoginRepositoryImpl.kt b/core/data/src/main/java/kr/co/call/data/repositoryImpl/LoginRepositoryImpl.kt index 2830fa5f..3693c7ae 100644 --- a/core/data/src/main/java/kr/co/call/data/repositoryImpl/LoginRepositoryImpl.kt +++ b/core/data/src/main/java/kr/co/call/data/repositoryImpl/LoginRepositoryImpl.kt @@ -2,6 +2,7 @@ package kr.co.call.data.repositoryImpl import javax.inject.Inject import kr.co.call.data.util.runRepositoryCatching +import kr.co.call.datastore.AccountStateDataStore import kr.co.call.datastore.TokenDataStore import kr.co.call.domain.model.login.LoginToken import kr.co.call.domain.repository.LoginRepository @@ -17,6 +18,7 @@ import kr.co.call.network.util.safeApiCall class LoginRepositoryImpl @Inject constructor( private val loginApi: LoginApi, private val tokenDataStore: TokenDataStore, + private val accountStateDataStore: AccountStateDataStore, private val errorResponseParser: ErrorResponseParser, ) : LoginRepository { @@ -45,10 +47,12 @@ class LoginRepositoryImpl @Inject constructor( refreshToken = result.refreshToken, ) + val forceOnboarding=accountStateDataStore.shouldForceOnboarding() + LoginToken( accessToken = result.accessToken, refreshToken = result.refreshToken, - needsOnboarding = result.needsOnboarding, + needsOnboarding = result.needsOnboarding || forceOnboarding, needsTermsAgreement = result.needsTermsAgreement, ) } diff --git a/core/data/src/main/java/kr/co/call/data/repositoryImpl/MyPageRepositoryImpl.kt b/core/data/src/main/java/kr/co/call/data/repositoryImpl/MyPageRepositoryImpl.kt index af83e596..f0b8717b 100644 --- a/core/data/src/main/java/kr/co/call/data/repositoryImpl/MyPageRepositoryImpl.kt +++ b/core/data/src/main/java/kr/co/call/data/repositoryImpl/MyPageRepositoryImpl.kt @@ -2,6 +2,7 @@ package kr.co.call.data.repositoryImpl import kotlinx.coroutines.delay import kr.co.call.data.util.runRepositoryCatching +import kr.co.call.datastore.AccountStateDataStore import kr.co.call.datastore.TokenDataStore import kr.co.call.domain.model.mypage.MyPageProfile import kr.co.call.domain.repository.MyPageRepository @@ -11,10 +12,10 @@ import kr.co.call.network.util.safeApiCallUnit import kr.co.call.network.util.safeEmptyApiCall import javax.inject.Inject -// TODO: API 연동 전 임시 구현체 class MyPageRepositoryImpl @Inject constructor( private val myPageApi: MyPageApi, private val tokenDataStore: TokenDataStore, + private val accountStateDataStore: AccountStateDataStore, private val errorResponseParser: ErrorResponseParser ) : MyPageRepository { @@ -39,7 +40,6 @@ class MyPageRepositoryImpl @Inject constructor( //서버 로그아웃 성공 후 기기 토큰 삭제 tokenDataStore.clearTokens() } - override suspend fun deleteAccount(): Result = runRepositoryCatching { //토큰이 남아 있을 때 탈퇴 api를 먼저 호출 @@ -48,6 +48,7 @@ class MyPageRepositoryImpl @Inject constructor( } //서버 탈퇴 성공 후 기기의 토큰 삭제 tokenDataStore.clearTokens() + accountStateDataStore.markWithdrawn() } } \ No newline at end of file diff --git a/core/data/src/main/java/kr/co/call/data/repositoryImpl/OnboardingRepositoryImpl.kt b/core/data/src/main/java/kr/co/call/data/repositoryImpl/OnboardingRepositoryImpl.kt new file mode 100644 index 00000000..5de17ba9 --- /dev/null +++ b/core/data/src/main/java/kr/co/call/data/repositoryImpl/OnboardingRepositoryImpl.kt @@ -0,0 +1,69 @@ +package kr.co.call.data.repositoryImpl + +import javax.inject.Inject +import kr.co.call.data.util.runRepositoryCatching +import kr.co.call.datastore.AccountStateDataStore +import kr.co.call.domain.model.onboarding.OnboardingSubmission +import kr.co.call.domain.repository.OnboardingRepository +import kr.co.call.network.api.AICharacterApi +import kr.co.call.network.api.MyPageApi +import kr.co.call.network.dto.onboarding.CharacterTraitRequestDto +import kr.co.call.network.dto.onboarding.CreateCharacterRequestDto +import kr.co.call.network.dto.onboarding.UpdateMemberRequestDto +import kr.co.call.network.util.ErrorResponseParser +import kr.co.call.network.util.safeApiCallUnit + +class OnboardingRepositoryImpl @Inject constructor( + private val myPageApi: MyPageApi, + private val aiCharacterApi: AICharacterApi, + private val accountStateDataStore: AccountStateDataStore, + private val errorResponseParser: ErrorResponseParser, +) : OnboardingRepository { + + override suspend fun submitOnboarding( + submission: OnboardingSubmission, + ): Result = + runRepositoryCatching { + // 1. 사용자 정보 저장 + safeApiCallUnit(errorResponseParser) { + myPageApi.updateMember( + request = UpdateMemberRequestDto( + lastName = submission.member.lastName, + firstName = submission.member.firstName, + imageUrl = submission.member.imageUrl, + gender = submission.member.gender, + birth = submission.member.birth, + mbti = submission.member.mbti, + job = submission.member.job, + ), + ) + } + + // 2. 캐릭터 생성 + safeApiCallUnit(errorResponseParser) { + aiCharacterApi.createCharacter( + request = CreateCharacterRequestDto( + lastName = submission.character.lastName, + firstName = submission.character.firstName, + gender = submission.character.gender, + age = submission.character.age, + job = submission.character.job, + imageUrl = submission.character.imageUrl, + spiceLevel = submission.character.spiceLevel, + preferTime = submission.character.preferTime, + mbti = submission.character.mbti, + speechStyle = submission.character.speechStyle, + relationshipStage = + submission.character.relationshipStage, + traits = submission.character.traits.map { trait -> + CharacterTraitRequestDto( + trait = trait.trait, + priority = trait.priority, + ) + }, + ), + ) + } + accountStateDataStore.clearForceOnboarding() + } +} \ No newline at end of file diff --git a/core/datastore/src/main/java/kr/co/call/datastore/AccountStateDataStore.kt b/core/datastore/src/main/java/kr/co/call/datastore/AccountStateDataStore.kt new file mode 100644 index 00000000..b29c699a --- /dev/null +++ b/core/datastore/src/main/java/kr/co/call/datastore/AccountStateDataStore.kt @@ -0,0 +1,33 @@ +package kr.co.call.datastore + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import kotlinx.coroutines.flow.first +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AccountStateDataStore @Inject constructor( + private val dataStore: DataStore, +){ + suspend fun markWithdrawn(){ + dataStore.edit {preferences-> + preferences[FORCE_ONBOARDING]=true + } + } + suspend fun shouldForceOnboarding(): Boolean{ + return dataStore.data.first()[FORCE_ONBOARDING] ?:false + } + suspend fun clearForceOnboarding() { + dataStore.edit { preferences -> + preferences.remove(FORCE_ONBOARDING) + } + } + + private companion object { + val FORCE_ONBOARDING = + booleanPreferencesKey("force_onboarding_after_withdrawal") + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/kr/co/call/domain/model/onboarding/OnboardingSubmission.kt b/core/domain/src/main/java/kr/co/call/domain/model/onboarding/OnboardingSubmission.kt new file mode 100644 index 00000000..cba2f91a --- /dev/null +++ b/core/domain/src/main/java/kr/co/call/domain/model/onboarding/OnboardingSubmission.kt @@ -0,0 +1,36 @@ +package kr.co.call.domain.model.onboarding + +data class OnboardingSubmission( + val member: MemberOnboardingInput, + val character: CharacterOnboardingInput, +) + +data class MemberOnboardingInput( + val lastName: String, + val firstName: String, + val imageUrl: String, + val gender: String, + val birth: String, + val mbti: String, + val job: String, +) + +data class CharacterOnboardingInput( + val lastName: String, + val firstName: String, + val gender: String, + val age: Int, + val job: String, + val imageUrl: String, + val spiceLevel: Int, + val preferTime: String, + val mbti: String, + val speechStyle: String, + val relationshipStage: String, + val traits: List, +) + +data class CharacterTraitInput( + val trait: String, + val priority: Int, +) \ No newline at end of file diff --git a/core/domain/src/main/java/kr/co/call/domain/repository/OnboardingRepository.kt b/core/domain/src/main/java/kr/co/call/domain/repository/OnboardingRepository.kt new file mode 100644 index 00000000..a1337e03 --- /dev/null +++ b/core/domain/src/main/java/kr/co/call/domain/repository/OnboardingRepository.kt @@ -0,0 +1,9 @@ +package kr.co.call.domain.repository + +import kr.co.call.domain.model.onboarding.OnboardingSubmission + +interface OnboardingRepository { + suspend fun submitOnboarding( + submission: OnboardingSubmission, + ):Result +} \ No newline at end of file diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 0d717111..b34120ae 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -41,7 +41,7 @@ dependencies { implementation(project(":core:datastore")) // retrofit, okhttp - implementation(libs.retrofit) + api(libs.retrofit) implementation(libs.retrofit.gson.converter) implementation(libs.okhttp) implementation(libs.okhttp.logging.interceptor) diff --git a/core/network/src/main/java/kr/co/call/network/api/AICharacterApi.kt b/core/network/src/main/java/kr/co/call/network/api/AICharacterApi.kt new file mode 100644 index 00000000..882e24ec --- /dev/null +++ b/core/network/src/main/java/kr/co/call/network/api/AICharacterApi.kt @@ -0,0 +1,13 @@ +package kr.co.call.network.api + +import kr.co.call.network.dto.ApiResponse +import kr.co.call.network.dto.onboarding.CreateCharacterRequestDto +import retrofit2.http.Body +import retrofit2.http.POST + +interface AICharacterApi { + @POST("characters") + suspend fun createCharacter( + @Body request: CreateCharacterRequestDto, + ): ApiResponse +} \ No newline at end of file diff --git a/core/network/src/main/java/kr/co/call/network/api/MyPageApi.kt b/core/network/src/main/java/kr/co/call/network/api/MyPageApi.kt index 7558086e..e73ef8ff 100644 --- a/core/network/src/main/java/kr/co/call/network/api/MyPageApi.kt +++ b/core/network/src/main/java/kr/co/call/network/api/MyPageApi.kt @@ -1,8 +1,11 @@ package kr.co.call.network.api import kr.co.call.network.dto.ApiResponse +import kr.co.call.network.dto.onboarding.UpdateMemberRequestDto import retrofit2.Response +import retrofit2.http.Body import retrofit2.http.DELETE +import retrofit2.http.PATCH import retrofit2.http.POST interface MyPageApi { @@ -11,4 +14,9 @@ interface MyPageApi { @DELETE("members/me") suspend fun deleteAccount(): ApiResponse + + @PATCH("members/me") + suspend fun updateMember( + @Body request: UpdateMemberRequestDto, + ): ApiResponse } \ No newline at end of file diff --git a/core/network/src/main/java/kr/co/call/network/di/NetworkModule.kt b/core/network/src/main/java/kr/co/call/network/di/NetworkModule.kt index e56a2177..dade0b67 100644 --- a/core/network/src/main/java/kr/co/call/network/di/NetworkModule.kt +++ b/core/network/src/main/java/kr/co/call/network/di/NetworkModule.kt @@ -7,6 +7,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import kr.co.call.network.BuildConfig +import kr.co.call.network.api.AICharacterApi import kr.co.call.network.api.AgreementApi import kr.co.call.network.api.LoginApi import kr.co.call.network.api.MyPageApi @@ -165,4 +166,12 @@ object NetworkModule { ): MyPageApi{ return retrofit.create(MyPageApi::class.java) } + + @Provides + @Singleton + fun provideAICharacterApi( + retrofit: Retrofit, + ): AICharacterApi{ + return retrofit.create(AICharacterApi::class.java) + } } diff --git a/core/network/src/main/java/kr/co/call/network/dto/onboarding/CreateCharacterRequestDto.kt b/core/network/src/main/java/kr/co/call/network/dto/onboarding/CreateCharacterRequestDto.kt new file mode 100644 index 00000000..51f41565 --- /dev/null +++ b/core/network/src/main/java/kr/co/call/network/dto/onboarding/CreateCharacterRequestDto.kt @@ -0,0 +1,21 @@ +package kr.co.call.network.dto.onboarding + +data class CreateCharacterRequestDto( + val lastName: String, + val firstName: String, + val gender: String, + val age: Int, + val job: String, + val imageUrl: String, + val spiceLevel: Int, + val preferTime: String, + val mbti: String, + val speechStyle: String, + val relationshipStage: String, + val traits: List +) + +data class CharacterTraitRequestDto( + val trait: String, + val priority: Int, +) \ No newline at end of file diff --git a/core/network/src/main/java/kr/co/call/network/dto/onboarding/UpdateMemberRequestDto.kt b/core/network/src/main/java/kr/co/call/network/dto/onboarding/UpdateMemberRequestDto.kt new file mode 100644 index 00000000..eb50f220 --- /dev/null +++ b/core/network/src/main/java/kr/co/call/network/dto/onboarding/UpdateMemberRequestDto.kt @@ -0,0 +1,11 @@ +package kr.co.call.network.dto.onboarding + +data class UpdateMemberRequestDto( + val lastName: String, + val firstName: String, + val imageUrl: String, + val gender: String, + val birth: String, + val mbti: String, + val job: String, +) \ No newline at end of file diff --git a/core/network/src/main/java/kr/co/call/network/interceptor/AuthInterceptor.kt b/core/network/src/main/java/kr/co/call/network/interceptor/AuthInterceptor.kt index 312c5b34..83248a26 100644 --- a/core/network/src/main/java/kr/co/call/network/interceptor/AuthInterceptor.kt +++ b/core/network/src/main/java/kr/co/call/network/interceptor/AuthInterceptor.kt @@ -23,7 +23,9 @@ class AuthInterceptor @Inject constructor( } //저장된 access 토큰이 없으면 원래 요청 그대로 전송 val accessToken = tokenDataStore.getCachedAccessToken() - ?: runBlocking{tokenDataStore.getCachedAccessToken()} + ?: runBlocking{ + tokenDataStore.getAccessToken() + } if (accessToken.isNullOrBlank()) { return chain.proceed(originalRequest) } diff --git a/feature/onboarding/impl/src/main/java/kr/co/call/impl/component/TemporaryProfileImageData.kt b/feature/onboarding/impl/src/main/java/kr/co/call/impl/component/TemporaryProfileImageData.kt index 16618ddd..38273150 100644 --- a/feature/onboarding/impl/src/main/java/kr/co/call/impl/component/TemporaryProfileImageData.kt +++ b/feature/onboarding/impl/src/main/java/kr/co/call/impl/component/TemporaryProfileImageData.kt @@ -5,11 +5,12 @@ import kr.co.call.designsystem.component.profileimage.ProfileImageOption //서버에서 사진 받아오기 전까지 임시로 사용 internal object TemporaryProfileImageData { - + const val DEFAULT_PROFILE_IMAGE_URL = + "https://callfromai-images.s3.ap-northeast-2.amazonaws.com/male_1.png" val maleImages = List(5) { index -> ProfileImageOption( id = "temporary_male_${index + 1}", - imageUrl = "", + imageUrl = DEFAULT_PROFILE_IMAGE_URL, ) } diff --git a/feature/onboarding/impl/src/main/java/kr/co/call/impl/entry/OnboardingEntryBuilder.kt b/feature/onboarding/impl/src/main/java/kr/co/call/impl/entry/OnboardingEntryBuilder.kt index 97887cc9..0137de85 100644 --- a/feature/onboarding/impl/src/main/java/kr/co/call/impl/entry/OnboardingEntryBuilder.kt +++ b/feature/onboarding/impl/src/main/java/kr/co/call/impl/entry/OnboardingEntryBuilder.kt @@ -1,5 +1,6 @@ package kr.co.call.impl.entry +import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.LocalActivity import androidx.compose.runtime.Composable @@ -20,7 +21,11 @@ import kr.co.call.impl.screen.Onboarding5Screen import kr.co.call.impl.screen.Onboarding6Screen import androidx.hilt.navigation.compose.hiltViewModel import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext +import kr.co.call.domain.util.LoadStatus +import kr.co.call.impl.viewmodel.OnboardingSideEffect import kr.co.call.impl.viewmodel.OnboardingViewModel +import org.orbitmvi.orbit.compose.collectSideEffect fun EntryProviderScope.onboardingEntry( onOnboarding1Next: () -> Unit, @@ -45,6 +50,8 @@ fun EntryProviderScope.onboardingEntry( birthday = state.birthday, job = state.job, mbti = state.mbti, + gender=state.gender, + imageUrl=state.imageUrl, ) onOnboarding1Next() }, @@ -62,6 +69,8 @@ fun EntryProviderScope.onboardingEntry( firstName = state.firstName, job = state.job, mbti = state.mbti, + gender=state.gender, + imageUrl = state.imageUrl, ) onOnboarding2Next() @@ -107,13 +116,34 @@ fun EntryProviderScope.onboardingEntry( } entry { + val context=LocalContext.current val onboardingViewModel = sharedOnboardingViewModel() val uiState by onboardingViewModel.container.stateFlow .collectAsStateWithLifecycle() + onboardingViewModel.collectSideEffect { sideEffect -> + when (sideEffect) { + is OnboardingSideEffect.OnboardingCompleted -> { + if (sideEffect.callNow) { + onOnboarding6CallNow() + } else { + onOnboarding6CallLater() + } + } + + is OnboardingSideEffect.ShowMessage -> { + Toast.makeText( + context, + sideEffect.message, + Toast.LENGTH_SHORT, + ).show() + } + } + } Onboarding6Screen( firstName = uiState.aiFirstName, - onCallNowClick = onOnboarding6CallNow, - onCallLaterClick = onOnboarding6CallLater, + isLoading = uiState.submitStatus== LoadStatus.Loading, + onCallNowClick ={onboardingViewModel.submitOnboarding(callNow=true)}, + onCallLaterClick ={onboardingViewModel.submitOnboarding(callNow=false)}, ) } } diff --git a/feature/onboarding/impl/src/main/java/kr/co/call/impl/screen/Onboarding1Screen.kt b/feature/onboarding/impl/src/main/java/kr/co/call/impl/screen/Onboarding1Screen.kt index 36214009..f7758216 100644 --- a/feature/onboarding/impl/src/main/java/kr/co/call/impl/screen/Onboarding1Screen.kt +++ b/feature/onboarding/impl/src/main/java/kr/co/call/impl/screen/Onboarding1Screen.kt @@ -43,6 +43,7 @@ import kr.co.call.impl.component.ProfileChoice import kr.co.call.impl.component.TemporaryProfileImageData import kr.co.call.impl.component.TopTitle import kr.co.call.impl.viewmodel.model.Mbti +import kr.co.call.impl.viewmodel.model.MemberJob import kr.co.call.impl.viewmodel.state.Onboarding1State import kr.co.call.impl.viewmodel.state.ProfileChoiceState @@ -65,8 +66,8 @@ fun Onboarding1Screen ( var birthday by remember { mutableStateOf(null) } - var job by rememberSaveable { - mutableStateOf("") + var selectedJob by rememberSaveable { + mutableStateOf(null) } var mbti by rememberSaveable { mutableStateOf("") @@ -101,7 +102,7 @@ fun Onboarding1Screen ( lastName.isNotBlank() && firstName.isNotBlank() && birthday != null && - job.isNotBlank() + selectedJob!=null Box( modifier = modifier.fillMaxSize() ) { @@ -179,10 +180,14 @@ fun Onboarding1Screen ( MemberChoice( modifier = Modifier.fillMaxWidth(), label = "직업", - selectedOption = job, + selectedOption = selectedJob?.label.orEmpty(), placeholder = "직업을 선택해주세요", options = listOf("대학생", "직장인", "기타",), - onOptionSelected = { job = it }, + onOptionSelected = {selectedLabel -> + selectedJob=MemberJob.entries.firstOrNull(){job-> + job.label == selectedLabel + } + }, required = true, ) Spacer(modifier = Modifier.height(24.dp)) @@ -218,8 +223,10 @@ fun Onboarding1Screen ( lastName=lastName, firstName=firstName, birthday=selectedBirthday, - job=job, + job=checkNotNull(selectedJob).name, mbti=mbti, + gender=selectedGender.name, + imageUrl=savedProfileImageUrl.orEmpty(), ) ) } diff --git a/feature/onboarding/impl/src/main/java/kr/co/call/impl/screen/Onboarding2Screen.kt b/feature/onboarding/impl/src/main/java/kr/co/call/impl/screen/Onboarding2Screen.kt index 612fb4bd..9c41039b 100644 --- a/feature/onboarding/impl/src/main/java/kr/co/call/impl/screen/Onboarding2Screen.kt +++ b/feature/onboarding/impl/src/main/java/kr/co/call/impl/screen/Onboarding2Screen.kt @@ -44,6 +44,7 @@ import kr.co.call.impl.component.NameBox import kr.co.call.impl.component.ProfileChoice import kr.co.call.impl.component.TemporaryProfileImageData import kr.co.call.impl.component.TopTitle +import kr.co.call.impl.viewmodel.model.CharacterJob import kr.co.call.impl.viewmodel.model.Mbti import kr.co.call.impl.viewmodel.state.Onboarding2State import kr.co.call.impl.viewmodel.state.ProfileChoiceState @@ -63,7 +64,7 @@ fun Onboarding2Screen( var age by rememberSaveable { mutableStateOf("") } var lastName by rememberSaveable { mutableStateOf("") } var firstName by rememberSaveable { mutableStateOf("") } - var job by rememberSaveable { mutableStateOf("") } + var selectedJob by rememberSaveable { mutableStateOf< CharacterJob?>(null) } var mbti by rememberSaveable { mutableStateOf("") } var editingNameField by rememberSaveable { @@ -96,7 +97,7 @@ fun Onboarding2Screen( val canMoveNext = age.isNotBlank() && lastName.isNotBlank() && firstName.isNotBlank() && - job.isNotBlank() + selectedJob != null Box( modifier = modifier @@ -180,10 +181,14 @@ fun Onboarding2Screen( MemberChoice( modifier = Modifier.fillMaxWidth(), label = "직업", - selectedOption = job, + selectedOption = selectedJob?.label.orEmpty(), placeholder = "직업을 선택해주세요", options = listOf("대학생", "직장인", "기타"), - onOptionSelected = { job = it }, + onOptionSelected = {selectedLabel-> + selectedJob=CharacterJob.entries.firstOrNull{ job-> + job.label==selectedLabel + } + }, required = true, ) @@ -225,8 +230,10 @@ fun Onboarding2Screen( age=age, lastName=lastName, firstName=firstName, - job=job, + job=checkNotNull(selectedJob).name, mbti=mbti, + gender=selectedGender.name, + imageUrl=savedProfileImageUrl.orEmpty(), ) ) }, diff --git a/feature/onboarding/impl/src/main/java/kr/co/call/impl/screen/Onboarding6Screen.kt b/feature/onboarding/impl/src/main/java/kr/co/call/impl/screen/Onboarding6Screen.kt index 4fab5bb9..cf545714 100644 --- a/feature/onboarding/impl/src/main/java/kr/co/call/impl/screen/Onboarding6Screen.kt +++ b/feature/onboarding/impl/src/main/java/kr/co/call/impl/screen/Onboarding6Screen.kt @@ -52,6 +52,7 @@ import kr.co.call.onboarding.impl.R @Composable fun Onboarding6Screen( firstName: String, + isLoading: Boolean, onCallNowClick:()->Unit, onCallLaterClick:()->Unit, modifier: Modifier =Modifier, @@ -94,7 +95,9 @@ fun Onboarding6Screen( isCallNowPressed = it }, text="지금 전화할래", - onClick= { isCallDialogVisible=true }, + onClick= { if (!isLoading){ + isCallDialogVisible=true} + }, containerColor=CallTheme.colors.mainVariant3, contentColor=if(isCallNowPressed){ Black @@ -109,7 +112,11 @@ fun Onboarding6Screen( isCallLaterPressed = it }, text="조금 이따할래", - onClick=onCallLaterClick, + onClick={ + if (!isLoading){ + onCallLaterClick() + } + }, containerColor= Gray200, contentColor=if(isCallLaterPressed){ Gray800 }else{ Gray600}, @@ -163,13 +170,21 @@ fun Onboarding6Screen( positiveText = "연결", negativeText = "취소", onPositiveClick = { - isCallDialogVisible=false - onCallNowClick() + if (!isLoading) { + isCallDialogVisible = false + onCallNowClick() + } }, onNegativeClick = { - isCallDialogVisible=false + if (!isLoading) { + isCallDialogVisible = false + } + }, + onDismissRequest = { + if (!isLoading){ + isCallDialogVisible=false + } }, - onDismissRequest = {}, ) } } @@ -204,6 +219,7 @@ private fun Onboarding6ScreenPreview() { CallFromAiTheme { Onboarding6Screen( firstName = "민준", + isLoading = false, onCallNowClick = {}, onCallLaterClick = {}, ) diff --git a/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/OnboardingSideEffect.kt b/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/OnboardingSideEffect.kt new file mode 100644 index 00000000..39e4c7c3 --- /dev/null +++ b/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/OnboardingSideEffect.kt @@ -0,0 +1,11 @@ +package kr.co.call.impl.viewmodel + +sealed interface OnboardingSideEffect{ + data class OnboardingCompleted( + val callNow:Boolean, + ): OnboardingSideEffect + + data class ShowMessage( + val message: String, + ):OnboardingSideEffect +} \ No newline at end of file diff --git a/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/OnboardingUiState.kt b/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/OnboardingUiState.kt index b24cfd34..0609128b 100644 --- a/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/OnboardingUiState.kt +++ b/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/OnboardingUiState.kt @@ -1,6 +1,6 @@ package kr.co.call.impl.viewmodel -import androidx.compose.runtime.Composable +import kr.co.call.domain.util.LoadStatus import kr.co.call.impl.component.PreferTime import kr.co.call.impl.viewmodel.model.Trait import kr.co.call.impl.viewmodel.model.Relationship @@ -14,6 +14,8 @@ data class OnboardingUiState( val userBirthday: LocalDate = LocalDate.of(2000, 1, 1), val userJob: String = "", val userMbti: String = "", + val userGender: String="", + val userImageUrl: String="", //이상형 정보 val aiFirstName: String = "", @@ -21,6 +23,8 @@ data class OnboardingUiState( val aiAge: String = "", val aiJob: String = "", val aiMbti: String = "", + val aiGender: String="", + val aiImageUrl: String="", //3단계 val speechStyle: SpeechStyle? = null, @@ -35,4 +39,5 @@ data class OnboardingUiState( val isCreatingAi: Boolean=false, val createAiError: String?=null, + val submitStatus: LoadStatus= LoadStatus.Idle, ) \ No newline at end of file diff --git a/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/OnboardingViewModel.kt b/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/OnboardingViewModel.kt index 8b1628c7..679ba653 100644 --- a/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/OnboardingViewModel.kt +++ b/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/OnboardingViewModel.kt @@ -2,6 +2,13 @@ package kr.co.call.impl.viewmodel import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import kr.co.call.domain.exception.toUserMessage +import kr.co.call.domain.model.onboarding.CharacterOnboardingInput +import kr.co.call.domain.model.onboarding.CharacterTraitInput +import kr.co.call.domain.model.onboarding.MemberOnboardingInput +import kr.co.call.domain.model.onboarding.OnboardingSubmission +import kr.co.call.domain.repository.OnboardingRepository +import kr.co.call.domain.util.LoadStatus import kr.co.call.impl.component.PreferTime import kr.co.call.impl.viewmodel.model.Trait import kr.co.call.impl.viewmodel.model.Relationship @@ -10,11 +17,15 @@ import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.viewmodel.container import java.time.LocalDate import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException @HiltViewModel -class OnboardingViewModel @Inject constructor() : ViewModel(), - ContainerHost { - override val container=container( +class OnboardingViewModel @Inject constructor( + private val onboardingRepository: OnboardingRepository, +) : ViewModel(), + ContainerHost { + override val container= + container( initialState= OnboardingUiState(), ) @@ -24,6 +35,8 @@ class OnboardingViewModel @Inject constructor() : ViewModel(), birthday: LocalDate, job: String, mbti: String, + gender: String, + imageUrl: String, ) =intent{ reduce{ state.copy( @@ -32,6 +45,8 @@ class OnboardingViewModel @Inject constructor() : ViewModel(), userBirthday = birthday, userJob = job, userMbti = mbti, + userGender = gender, + userImageUrl = imageUrl, ) } } @@ -42,6 +57,8 @@ class OnboardingViewModel @Inject constructor() : ViewModel(), firstName: String, job: String, mbti: String, + gender:String, + imageUrl: String, )=intent { reduce { state.copy( @@ -50,6 +67,8 @@ class OnboardingViewModel @Inject constructor() : ViewModel(), aiAge = age, aiJob = job, aiMbti = mbti, + aiGender = gender, + aiImageUrl = imageUrl, ) } } @@ -96,4 +115,92 @@ class OnboardingViewModel @Inject constructor() : ViewModel(), ) } } + fun submitOnboarding( + callNow: Boolean, + ) = intent { + if (state.submitStatus == LoadStatus.Loading) return@intent + + val age = state.aiAge.toIntOrNull() + val speechStyle = state.speechStyle + val relationship = state.relationship + val preferTime = state.preferTime + + if ( + age == null || + speechStyle == null || + relationship == null || + preferTime == null || + state.traits.isEmpty() + ) { + postSideEffect( + OnboardingSideEffect.ShowMessage( + message = "온보딩 정보를 확인해주세요.", + ), + ) + return@intent + } + + reduce { + state.copy(submitStatus = LoadStatus.Loading) + } + + val submission = OnboardingSubmission( + member = MemberOnboardingInput( + lastName = state.userLastName, + firstName = state.userFirstName, + imageUrl = state.userImageUrl, + gender = state.userGender, + birth = state.userBirthday.toString(), + mbti = state.userMbti, + job = state.userJob, + ), + character = CharacterOnboardingInput( + lastName = state.aiLastName, + firstName = state.aiFirstName, + gender = state.aiGender, + age = age, + job = state.aiJob, + imageUrl = state.aiImageUrl, + spiceLevel = state.temperature, + preferTime = preferTime.name, + mbti = state.aiMbti, + speechStyle = speechStyle.name, + relationshipStage = relationship.name, + traits = state.traits.mapIndexed { index, trait -> + CharacterTraitInput( + trait = trait.keyword, + priority = index + 1, + ) + }, + ), + ) + + onboardingRepository.submitOnboarding(submission) + .onSuccess { + reduce { + state.copy(submitStatus = LoadStatus.Idle) + } + + postSideEffect( + OnboardingSideEffect.OnboardingCompleted( + callNow = callNow, + ), + ) + } + .onFailure { error -> + if (error is CancellationException) throw error + + reduce { + state.copy(submitStatus = LoadStatus.Idle) + } + + postSideEffect( + OnboardingSideEffect.ShowMessage( + message = error.toUserMessage( + default = "온보딩 저장에 실패했습니다.", + ), + ), + ) + } + } } \ No newline at end of file diff --git a/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/model/Job.kt b/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/model/Job.kt new file mode 100644 index 00000000..c3758cb4 --- /dev/null +++ b/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/model/Job.kt @@ -0,0 +1,17 @@ +package kr.co.call.impl.viewmodel.model + +enum class MemberJob( + val label: String, +){ + UNIVERSITY_STUDENT("대학생"), + EMPLOYEE("직장인"), + OTHER("기타") +} + +enum class CharacterJob( + val label: String, +){ + STUDENT("대학생"), + EMPLOYED("직장인"), + UMEMPLOYED("무직") +} \ No newline at end of file diff --git a/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/state/Onboarding1State.kt b/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/state/Onboarding1State.kt index 18f0c7be..78d3e983 100644 --- a/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/state/Onboarding1State.kt +++ b/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/state/Onboarding1State.kt @@ -8,4 +8,6 @@ data class Onboarding1State ( val birthday: LocalDate=LocalDate.now(), val job: String="", val mbti: String="", + val gender: String="", + val imageUrl: String="" ) \ No newline at end of file diff --git a/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/state/Onboarding2State.kt b/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/state/Onboarding2State.kt index e56608d8..90c3386c 100644 --- a/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/state/Onboarding2State.kt +++ b/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/state/Onboarding2State.kt @@ -6,4 +6,6 @@ data class Onboarding2State ( val firstName: String="", val job: String="", val mbti: String="", + val gender: String="", + val imageUrl: String="", ) \ No newline at end of file From e5984052caba2aa62c76d4fa8388309564350e30 Mon Sep 17 00:00:00 2001 From: dada4 Date: Sat, 1 Aug 2026 00:46:53 +0900 Subject: [PATCH 4/9] =?UTF-8?q?fix:=20pr=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/kr/co/call/callfromai/AppScreen.kt | 10 ++ .../kr/co/call/callfromai/AppViewModel.kt | 11 +- .../callfromai/sideeffect/AppSideEffect.kt | 4 +- app/src/main/res/xml/backup_rules.xml | 4 +- .../main/res/xml/data_extraction_rules.xml | 4 +- .../repositoryImpl/LoginRepositoryImpl.kt | 8 +- .../repositoryImpl/MyPageRepositoryImpl.kt | 19 +-- .../OnboardingRepositoryImpl.kt | 9 +- .../kr/co/call/data/util/SafeApiResult.kt | 11 ++ .../call/datastore/AccountStateDataStore.kt | 33 ----- .../kr/co/call/datastore/TokenDataStore.kt | 120 +----------------- ...ardingSubmission.kt => OnboardingInput.kt} | 6 +- .../domain/repository/OnboardingRepository.kt | 4 +- core/network/build.gradle.kts | 2 +- .../java/kr/co/call/network/api/MyPageApi.kt | 5 +- .../network/interceptor/AuthInterceptor.kt | 12 +- .../network/interceptor/TokenAuthenticator.kt | 9 +- .../call/impl/viewmodel/LandingViewModel.kt | 4 +- .../co/call/impl/viewmodel/MyPageViewModel.kt | 51 +++++--- .../call/impl/entry/OnboardingEntryBuilder.kt | 41 +++--- .../co/call/impl/screen/Onboarding2Screen.kt | 17 ++- .../co/call/impl/screen/Onboarding5Screen.kt | 5 +- .../impl/viewmodel/OnboardingSideEffect.kt | 6 +- .../impl/viewmodel/OnboardingViewModel.kt | 20 ++- .../kr/co/call/impl/viewmodel/model/Job.kt | 2 +- 25 files changed, 164 insertions(+), 253 deletions(-) delete mode 100644 core/datastore/src/main/java/kr/co/call/datastore/AccountStateDataStore.kt rename core/domain/src/main/java/kr/co/call/domain/model/onboarding/{OnboardingSubmission.kt => OnboardingInput.kt} (73%) diff --git a/app/src/main/java/kr/co/call/callfromai/AppScreen.kt b/app/src/main/java/kr/co/call/callfromai/AppScreen.kt index bdc7591a..604251bd 100644 --- a/app/src/main/java/kr/co/call/callfromai/AppScreen.kt +++ b/app/src/main/java/kr/co/call/callfromai/AppScreen.kt @@ -53,6 +53,7 @@ import kr.co.call.api.TermNavKey import kr.co.call.callfromai.ui.MainBottomBar import kr.co.call.callfromai.ui.MainTab import kr.co.call.callfromai.util.toMainTab +import kr.co.call.callfromai.sideeffect.AppSideEffect import kr.co.call.designsystem.component.LocalBottomBarPadding import kr.co.call.impl.entry.chattingEntry import kr.co.call.impl.entry.callEntry @@ -60,6 +61,7 @@ import kr.co.call.impl.entry.homeEntry import kr.co.call.impl.entry.loginEntry import kr.co.call.impl.entry.myPageEntry import kr.co.call.impl.entry.onboardingEntry +import org.orbitmvi.orbit.compose.collectSideEffect /** * 애플리케이션 화면 내비게이션의 메인 진입점입니다. @@ -80,6 +82,14 @@ fun AppScreen( val appNavigator = remember(backStack) { AppNavigator(backStack) } val currentKey = backStack.lastOrNull() + viewModel.collectSideEffect { sideEffect -> + when (sideEffect) { + AppSideEffect.NavigateToLogin -> { + appNavigator.replaceAll(LoginNavKey) + } + } + } + // TODO: 각자 구현하면서 피그마 보고 추가하기!! val showBottomBar = when (currentKey) { is HomeNavKey, diff --git a/app/src/main/java/kr/co/call/callfromai/AppViewModel.kt b/app/src/main/java/kr/co/call/callfromai/AppViewModel.kt index c3d20434..3fe8e7f2 100644 --- a/app/src/main/java/kr/co/call/callfromai/AppViewModel.kt +++ b/app/src/main/java/kr/co/call/callfromai/AppViewModel.kt @@ -5,6 +5,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kr.co.call.callfromai.intent.AppIntent import kr.co.call.callfromai.sideeffect.AppSideEffect import kr.co.call.callfromai.state.AppState +import kr.co.call.datastore.AuthSessionManager import org.orbitmvi.orbit.Container import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.viewmodel.container @@ -12,13 +13,21 @@ import javax.inject.Inject @HiltViewModel class AppViewModel @Inject constructor( - + private val authSessionManager: AuthSessionManager, ) : ViewModel(), ContainerHost { override val container: Container = container( initialState = AppState() ) + init { + intent { + authSessionManager.sessionExpired.collect { + postSideEffect(AppSideEffect.NavigateToLogin) + } + } + } + fun handleIntent(intent: AppIntent) { when (intent) { else -> Unit diff --git a/app/src/main/java/kr/co/call/callfromai/sideeffect/AppSideEffect.kt b/app/src/main/java/kr/co/call/callfromai/sideeffect/AppSideEffect.kt index 73c143bb..2dc7db5e 100644 --- a/app/src/main/java/kr/co/call/callfromai/sideeffect/AppSideEffect.kt +++ b/app/src/main/java/kr/co/call/callfromai/sideeffect/AppSideEffect.kt @@ -27,5 +27,5 @@ package kr.co.call.callfromai.sideeffect * ``` */ sealed interface AppSideEffect { - -} \ No newline at end of file + data object NavigateToLogin : AppSideEffect +} diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index 4df92558..8208a921 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -10,4 +10,6 @@ --> - \ No newline at end of file + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index 9ee9997b..0fcfd6e8 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -9,6 +9,8 @@ --> + + - \ No newline at end of file + diff --git a/core/data/src/main/java/kr/co/call/data/repositoryImpl/LoginRepositoryImpl.kt b/core/data/src/main/java/kr/co/call/data/repositoryImpl/LoginRepositoryImpl.kt index 28bfa49c..0abbad22 100644 --- a/core/data/src/main/java/kr/co/call/data/repositoryImpl/LoginRepositoryImpl.kt +++ b/core/data/src/main/java/kr/co/call/data/repositoryImpl/LoginRepositoryImpl.kt @@ -1,7 +1,6 @@ package kr.co.call.data.repositoryImpl import javax.inject.Inject -import kr.co.call.datastore.AccountStateDataStore import kr.co.call.data.util.toAppResult import kr.co.call.datastore.TokenDataStore import kr.co.call.domain.model.login.LoginToken @@ -18,7 +17,6 @@ import kr.co.call.network.util.safeApiCall class LoginRepositoryImpl @Inject constructor( private val loginApi: LoginApi, private val tokenDataStore: TokenDataStore, - private val accountStateDataStore: AccountStateDataStore, private val errorResponseParser: ErrorResponseParser, ) : LoginRepository { @@ -42,17 +40,15 @@ class LoginRepositoryImpl @Inject constructor( ) { "로그인 응답 토큰 값이 비어 있습니다" } - tokenDataStore.saveTokens( + tokenDataStore.setTokens( accessToken = result.accessToken, refreshToken = result.refreshToken, ) - val forceOnboarding=accountStateDataStore.shouldForceOnboarding() - LoginToken( accessToken = result.accessToken, refreshToken = result.refreshToken, - needsOnboarding = result.needsOnboarding || forceOnboarding, + needsOnboarding = result.needsOnboarding, needsTermsAgreement = result.needsTermsAgreement, ) }.toAppResult() diff --git a/core/data/src/main/java/kr/co/call/data/repositoryImpl/MyPageRepositoryImpl.kt b/core/data/src/main/java/kr/co/call/data/repositoryImpl/MyPageRepositoryImpl.kt index f0b8717b..af628634 100644 --- a/core/data/src/main/java/kr/co/call/data/repositoryImpl/MyPageRepositoryImpl.kt +++ b/core/data/src/main/java/kr/co/call/data/repositoryImpl/MyPageRepositoryImpl.kt @@ -1,21 +1,20 @@ package kr.co.call.data.repositoryImpl +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kr.co.call.data.util.runRepositoryCatching -import kr.co.call.datastore.AccountStateDataStore import kr.co.call.datastore.TokenDataStore import kr.co.call.domain.model.mypage.MyPageProfile import kr.co.call.domain.repository.MyPageRepository import kr.co.call.network.api.MyPageApi import kr.co.call.network.util.ErrorResponseParser import kr.co.call.network.util.safeApiCallUnit -import kr.co.call.network.util.safeEmptyApiCall +import timber.log.Timber import javax.inject.Inject class MyPageRepositoryImpl @Inject constructor( private val myPageApi: MyPageApi, private val tokenDataStore: TokenDataStore, - private val accountStateDataStore: AccountStateDataStore, private val errorResponseParser: ErrorResponseParser ) : MyPageRepository { @@ -34,7 +33,7 @@ class MyPageRepositoryImpl @Inject constructor( override suspend fun logout():Result = runRepositoryCatching { - safeEmptyApiCall(errorResponseParser){ + safeApiCallUnit(errorResponseParser){ myPageApi.logout() } //서버 로그아웃 성공 후 기기 토큰 삭제 @@ -46,9 +45,13 @@ class MyPageRepositoryImpl @Inject constructor( safeApiCallUnit(errorResponseParser){ myPageApi.deleteAccount() } - //서버 탈퇴 성공 후 기기의 토큰 삭제 - tokenDataStore.clearTokens() - accountStateDataStore.markWithdrawn() + runCatching{ + tokenDataStore.clearTokens() + }.onFailure { error-> + if (error is CancellationException) throw error + Timber.w(error, "회원 탈퇴 후 토큰 삭제 실패") + } + Unit } -} \ No newline at end of file +} diff --git a/core/data/src/main/java/kr/co/call/data/repositoryImpl/OnboardingRepositoryImpl.kt b/core/data/src/main/java/kr/co/call/data/repositoryImpl/OnboardingRepositoryImpl.kt index 5de17ba9..117bd743 100644 --- a/core/data/src/main/java/kr/co/call/data/repositoryImpl/OnboardingRepositoryImpl.kt +++ b/core/data/src/main/java/kr/co/call/data/repositoryImpl/OnboardingRepositoryImpl.kt @@ -2,8 +2,7 @@ package kr.co.call.data.repositoryImpl import javax.inject.Inject import kr.co.call.data.util.runRepositoryCatching -import kr.co.call.datastore.AccountStateDataStore -import kr.co.call.domain.model.onboarding.OnboardingSubmission +import kr.co.call.domain.model.onboarding.OnboardingInput import kr.co.call.domain.repository.OnboardingRepository import kr.co.call.network.api.AICharacterApi import kr.co.call.network.api.MyPageApi @@ -16,12 +15,11 @@ import kr.co.call.network.util.safeApiCallUnit class OnboardingRepositoryImpl @Inject constructor( private val myPageApi: MyPageApi, private val aiCharacterApi: AICharacterApi, - private val accountStateDataStore: AccountStateDataStore, private val errorResponseParser: ErrorResponseParser, ) : OnboardingRepository { override suspend fun submitOnboarding( - submission: OnboardingSubmission, + submission: OnboardingInput, ): Result = runRepositoryCatching { // 1. 사용자 정보 저장 @@ -64,6 +62,5 @@ class OnboardingRepositoryImpl @Inject constructor( ), ) } - accountStateDataStore.clearForceOnboarding() } -} \ No newline at end of file +} diff --git a/core/data/src/main/java/kr/co/call/data/util/SafeApiResult.kt b/core/data/src/main/java/kr/co/call/data/util/SafeApiResult.kt index 0aeb2ee4..12aa3870 100644 --- a/core/data/src/main/java/kr/co/call/data/util/SafeApiResult.kt +++ b/core/data/src/main/java/kr/co/call/data/util/SafeApiResult.kt @@ -5,6 +5,17 @@ import kr.co.call.network.util.ErrorResponseParser import kr.co.call.network.util.safeApiCall import kr.co.call.network.util.safeApiCallUnit +/** + * Repository 작업에서 발생한 예외를 앱의 도메인 예외로 변환한다. + * 코루틴 취소와 치명적인 [Error]는 [toAppResult]에서 다시 전파된다. + */ +suspend fun runRepositoryCatching( + block: suspend () -> T, +): Result = + runCatching { + block() + }.toAppResult() + /** * [safeApiCall] + stdlib [runCatching] + [toAppResult] 조합. * Repository 에서 runCatching / fold 보일러플레이트 없이 바로 [Result] 를 받는다. diff --git a/core/datastore/src/main/java/kr/co/call/datastore/AccountStateDataStore.kt b/core/datastore/src/main/java/kr/co/call/datastore/AccountStateDataStore.kt deleted file mode 100644 index b29c699a..00000000 --- a/core/datastore/src/main/java/kr/co/call/datastore/AccountStateDataStore.kt +++ /dev/null @@ -1,33 +0,0 @@ -package kr.co.call.datastore - -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.edit -import kotlinx.coroutines.flow.first -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class AccountStateDataStore @Inject constructor( - private val dataStore: DataStore, -){ - suspend fun markWithdrawn(){ - dataStore.edit {preferences-> - preferences[FORCE_ONBOARDING]=true - } - } - suspend fun shouldForceOnboarding(): Boolean{ - return dataStore.data.first()[FORCE_ONBOARDING] ?:false - } - suspend fun clearForceOnboarding() { - dataStore.edit { preferences -> - preferences.remove(FORCE_ONBOARDING) - } - } - - private companion object { - val FORCE_ONBOARDING = - booleanPreferencesKey("force_onboarding_after_withdrawal") - } -} \ No newline at end of file diff --git a/core/datastore/src/main/java/kr/co/call/datastore/TokenDataStore.kt b/core/datastore/src/main/java/kr/co/call/datastore/TokenDataStore.kt index dbe6beeb..53be46bf 100644 --- a/core/datastore/src/main/java/kr/co/call/datastore/TokenDataStore.kt +++ b/core/datastore/src/main/java/kr/co/call/datastore/TokenDataStore.kt @@ -3,19 +3,10 @@ package kr.co.call.datastore import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.stringPreferencesKey -import java.io.IOException -import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject import javax.inject.Singleton -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kr.co.call.common.di.ApplicationScope -import timber.log.Timber /** * DataStore에 저장된 Access Token과 Refresh Token을 함께 표현한다. @@ -26,100 +17,17 @@ data class StoredTokens( val refreshToken: String? = null, ) -/** - * 서버에서 발급한 Access Token과 Refresh Token을 DataStore에 저장한다. - * AuthInterceptor가 빠르게 읽을 수 있도록 같은 토큰을 메모리에도 캐시한다. - */ +/** 서버에서 발급한 Access Token과 Refresh Token을 DataStore에서 관리한다. */ @Singleton class TokenDataStore @Inject constructor( private val dataStore: DataStore, - @ApplicationScope private val applicationScope: CoroutineScope, ) { - /** - * OkHttp Interceptor에서 DataStore를 매번 읽지 않도록 - * 현재 토큰 값을 메모리에 보관한다. - */ - private val cachedTokens = AtomicReference( - StoredTokens(), - ) - - /** - * DataStore 파일을 읽는 과정에서 IOException이 발생하면 - * 앱을 종료하지 않고 빈 Preferences를 전달해 비로그인 상태로 처리한다. - */ - private val safeData: Flow = dataStore.data - .catch { error -> - if (error is IOException) { - Timber.e( - error, - "토큰 DataStore 읽기 실패", - ) - - emit(emptyPreferences()) - } else { - throw error - } - } - - init { - /** - * 앱이 실행되는 동안 DataStore의 변경 내용을 계속 관찰한다. - * 토큰이 저장되거나 삭제되면 메모리 캐시에도 같은 값을 반영한다. - */ - applicationScope.launch { - safeData.collect { preferences -> - cachedTokens.set( - preferences.toStoredTokens(), - ) - } - } - } - - /** - * AuthInterceptor가 사용할 Access Token을 메모리에서 가져온다. - * 디스크를 읽지 않으므로 OkHttp 네트워크 스레드를 차단하지 않는다. - */ - fun getCachedAccessToken(): String? { - return cachedTokens - .get() - .accessToken - ?.takeIf { it.isNotBlank() } + suspend fun getTokens(): StoredTokens { + return dataStore.data.first().toStoredTokens() } - /** - * DataStore에 저장된 Access Token과 Refresh Token을 한 번에 읽는다. - * 읽은 토큰은 이후 빠르게 사용할 수 있도록 캐시에도 반영한다. - */ - suspend fun getStoredTokens(): StoredTokens { - val tokens = safeData - .first() - .toStoredTokens() - - cachedTokens.set(tokens) - - return tokens - } - - /** - * API 인증 헤더에 사용할 Access Token을 가져온다. - * 자동 로그인 여부를 확인할 때도 이 함수를 사용한다. - */ - suspend fun getAccessToken(): String? { - return getStoredTokens().accessToken - } - - /** - * Access Token 재발급에 필요한 Refresh Token을 가져온다. - */ - suspend fun getRefreshToken(): String? { - return getStoredTokens().refreshToken - } - - /** - * 로그인 또는 토큰 재발급에 성공했을 때 새 토큰을 저장한다. - * 빈 문자열이 인증 토큰으로 저장되지 않도록 저장 전에 검증한다. - */ - suspend fun saveTokens( + /** 로그인 또는 토큰 재발급에 성공했을 때 새 토큰을 저장한다. */ + suspend fun setTokens( accessToken: String, refreshToken: String, ) { @@ -135,28 +43,14 @@ class TokenDataStore @Inject constructor( preferences[ACCESS_TOKEN] = accessToken preferences[REFRESH_TOKEN] = refreshToken } - - cachedTokens.set( - StoredTokens( - accessToken = accessToken, - refreshToken = refreshToken, - ), - ) } - /** - * Refresh Token까지 만료되거나 로그아웃한 경우 토큰을 삭제한다. - * DataStore와 메모리 캐시를 모두 초기화해 자동 로그인을 방지한다. - */ + /** 로그아웃하거나 세션이 만료됐을 때 저장된 토큰을 삭제한다. */ suspend fun clearTokens() { dataStore.edit { preferences -> preferences.remove(ACCESS_TOKEN) preferences.remove(REFRESH_TOKEN) } - - cachedTokens.set( - StoredTokens(), - ) } /** @@ -176,4 +70,4 @@ class TokenDataStore @Inject constructor( val REFRESH_TOKEN = stringPreferencesKey("refresh_token") } -} \ No newline at end of file +} diff --git a/core/domain/src/main/java/kr/co/call/domain/model/onboarding/OnboardingSubmission.kt b/core/domain/src/main/java/kr/co/call/domain/model/onboarding/OnboardingInput.kt similarity index 73% rename from core/domain/src/main/java/kr/co/call/domain/model/onboarding/OnboardingSubmission.kt rename to core/domain/src/main/java/kr/co/call/domain/model/onboarding/OnboardingInput.kt index cba2f91a..599b5400 100644 --- a/core/domain/src/main/java/kr/co/call/domain/model/onboarding/OnboardingSubmission.kt +++ b/core/domain/src/main/java/kr/co/call/domain/model/onboarding/OnboardingInput.kt @@ -1,10 +1,12 @@ package kr.co.call.domain.model.onboarding -data class OnboardingSubmission( +//온보딩 완료에 필요한 사용자 정보와 AI 캐릭터 정보를 하나로 묶어 Repository에 전달하는 Domain 모델 +data class OnboardingInput( val member: MemberOnboardingInput, val character: CharacterOnboardingInput, ) +//사용자 본인의 정보 data class MemberOnboardingInput( val lastName: String, val firstName: String, @@ -15,6 +17,7 @@ data class MemberOnboardingInput( val job: String, ) +//ai 캐릭터의 정보 data class CharacterOnboardingInput( val lastName: String, val firstName: String, @@ -30,6 +33,7 @@ data class CharacterOnboardingInput( val traits: List, ) +//특성(ex.유머러스한)과 우선순위를 묶는 모델 data class CharacterTraitInput( val trait: String, val priority: Int, diff --git a/core/domain/src/main/java/kr/co/call/domain/repository/OnboardingRepository.kt b/core/domain/src/main/java/kr/co/call/domain/repository/OnboardingRepository.kt index a1337e03..71e2b51f 100644 --- a/core/domain/src/main/java/kr/co/call/domain/repository/OnboardingRepository.kt +++ b/core/domain/src/main/java/kr/co/call/domain/repository/OnboardingRepository.kt @@ -1,9 +1,9 @@ package kr.co.call.domain.repository -import kr.co.call.domain.model.onboarding.OnboardingSubmission +import kr.co.call.domain.model.onboarding.OnboardingInput interface OnboardingRepository { suspend fun submitOnboarding( - submission: OnboardingSubmission, + submission: OnboardingInput, ):Result } \ No newline at end of file diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index b34120ae..0d717111 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -41,7 +41,7 @@ dependencies { implementation(project(":core:datastore")) // retrofit, okhttp - api(libs.retrofit) + implementation(libs.retrofit) implementation(libs.retrofit.gson.converter) implementation(libs.okhttp) implementation(libs.okhttp.logging.interceptor) diff --git a/core/network/src/main/java/kr/co/call/network/api/MyPageApi.kt b/core/network/src/main/java/kr/co/call/network/api/MyPageApi.kt index e73ef8ff..02e186f7 100644 --- a/core/network/src/main/java/kr/co/call/network/api/MyPageApi.kt +++ b/core/network/src/main/java/kr/co/call/network/api/MyPageApi.kt @@ -2,7 +2,6 @@ package kr.co.call.network.api import kr.co.call.network.dto.ApiResponse import kr.co.call.network.dto.onboarding.UpdateMemberRequestDto -import retrofit2.Response import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.PATCH @@ -10,7 +9,7 @@ import retrofit2.http.POST interface MyPageApi { @POST("auth/logout") - suspend fun logout(): Response + suspend fun logout(): ApiResponse @DELETE("members/me") suspend fun deleteAccount(): ApiResponse @@ -19,4 +18,4 @@ interface MyPageApi { suspend fun updateMember( @Body request: UpdateMemberRequestDto, ): ApiResponse -} \ No newline at end of file +} diff --git a/core/network/src/main/java/kr/co/call/network/interceptor/AuthInterceptor.kt b/core/network/src/main/java/kr/co/call/network/interceptor/AuthInterceptor.kt index 83248a26..0a56d063 100644 --- a/core/network/src/main/java/kr/co/call/network/interceptor/AuthInterceptor.kt +++ b/core/network/src/main/java/kr/co/call/network/interceptor/AuthInterceptor.kt @@ -1,8 +1,7 @@ package kr.co.call.network.interceptor -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking import kr.co.call.datastore.TokenDataStore +import kotlinx.coroutines.runBlocking import okhttp3.Interceptor import okhttp3.Response import javax.inject.Inject @@ -22,10 +21,9 @@ class AuthInterceptor @Inject constructor( return chain.proceed(originalRequest) } //저장된 access 토큰이 없으면 원래 요청 그대로 전송 - val accessToken = tokenDataStore.getCachedAccessToken() - ?: runBlocking{ - tokenDataStore.getAccessToken() - } + val accessToken = runBlocking { + tokenDataStore.getTokens().accessToken + } if (accessToken.isNullOrBlank()) { return chain.proceed(originalRequest) } @@ -47,4 +45,4 @@ class AuthInterceptor @Inject constructor( "/auth/reissue", ) } -} \ No newline at end of file +} diff --git a/core/network/src/main/java/kr/co/call/network/interceptor/TokenAuthenticator.kt b/core/network/src/main/java/kr/co/call/network/interceptor/TokenAuthenticator.kt index c1b12fc2..e53b3ec7 100644 --- a/core/network/src/main/java/kr/co/call/network/interceptor/TokenAuthenticator.kt +++ b/core/network/src/main/java/kr/co/call/network/interceptor/TokenAuthenticator.kt @@ -4,6 +4,7 @@ import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CancellationException import kotlinx.coroutines.runBlocking +import kr.co.call.datastore.AuthSessionManager import kr.co.call.datastore.TokenDataStore import kr.co.call.network.api.TokenReissueApi import kr.co.call.network.dto.login.TokenReissueRequestDto @@ -22,6 +23,7 @@ import timber.log.Timber class TokenAuthenticator @Inject constructor( private val tokenDataStore: TokenDataStore, private val tokenReissueApi: TokenReissueApi, + private val authSessionManager: AuthSessionManager, ) : Authenticator { /** @@ -70,7 +72,7 @@ class TokenAuthenticator @Inject constructor( return synchronized(refreshLock) { runBlocking { val storedTokens = - tokenDataStore.getStoredTokens() + tokenDataStore.getTokens() val currentAccessToken = storedTokens.accessToken @@ -142,7 +144,7 @@ class TokenAuthenticator @Inject constructor( newTokens != null ) { try { - tokenDataStore.saveTokens( + tokenDataStore.setTokens( accessToken = newTokens.accessToken, refreshToken = newTokens.refreshToken, ) @@ -170,6 +172,7 @@ class TokenAuthenticator @Inject constructor( if (reissueResponse.code() == 401) { try { tokenDataStore.clearTokens() + authSessionManager.notifySessionExpired() } catch (error: CancellationException) { throw error } catch (error: Throwable) { @@ -247,4 +250,4 @@ class TokenAuthenticator @Inject constructor( "/auth/reissue", ) } -} \ No newline at end of file +} diff --git a/feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/LandingViewModel.kt b/feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/LandingViewModel.kt index 1dae3d8e..8cd6b0d1 100644 --- a/feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/LandingViewModel.kt +++ b/feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/LandingViewModel.kt @@ -32,7 +32,7 @@ class LandingViewModel @Inject constructor( val splashStartTime=System.currentTimeMillis() - val accessToken = tokenDataStore.getAccessToken() + val accessToken = tokenDataStore.getTokens().accessToken val elapsedTime = System.currentTimeMillis() - splashStartTime val remainingTime = SPLASH_DURATION_MILLIS - elapsedTime if (remainingTime>0L){ @@ -48,4 +48,4 @@ class LandingViewModel @Inject constructor( private companion object { const val SPLASH_DURATION_MILLIS=3_000L } -} \ No newline at end of file +} diff --git a/feature/mypage/impl/src/main/java/kr/co/call/impl/viewmodel/MyPageViewModel.kt b/feature/mypage/impl/src/main/java/kr/co/call/impl/viewmodel/MyPageViewModel.kt index 532a8c01..1b9c041a 100644 --- a/feature/mypage/impl/src/main/java/kr/co/call/impl/viewmodel/MyPageViewModel.kt +++ b/feature/mypage/impl/src/main/java/kr/co/call/impl/viewmodel/MyPageViewModel.kt @@ -67,26 +67,39 @@ class MyPageViewModel @Inject constructor( reduce{ state.copy(authStatus = LoadStatus.Loading) } - myPageRepository.logout() - .onSuccess { - kakaoLogoutManager.logout() - .onFailure{error-> - Timber.w(error, "카카오 SDK 로그아웃 실패") - } - postSideEffect(MyPageSideEffect.NavigateToLogin) + val serverLogoutError = myPageRepository.logout().exceptionOrNull() + if (serverLogoutError != null) { + if (serverLogoutError is CancellationException) throw serverLogoutError + + reduce { + state.copy(authStatus = LoadStatus.Idle) } - .onFailure { error -> - if (error is CancellationException) throw error - reduce{ - state.copy(authStatus = LoadStatus.Idle) - } - postSideEffect( - MyPageSideEffect.ShowMessage( - message=error.message - ?: "로그아웃에 실패했습니다.", - ), - ) + postSideEffect( + MyPageSideEffect.ShowMessage( + message = serverLogoutError.message + ?: "로그아웃에 실패했습니다.", + ), + ) + return@intent + } + + val kakaoLogoutError = kakaoLogoutManager.logout().exceptionOrNull() + if (kakaoLogoutError != null) { + if (kakaoLogoutError is CancellationException) throw kakaoLogoutError + + Timber.w(kakaoLogoutError, "카카오 SDK 로그아웃 실패") + reduce { + state.copy(authStatus = LoadStatus.Idle) } + postSideEffect( + MyPageSideEffect.ShowMessage( + message = "카카오 로그아웃에 실패했습니다. 다시 시도해 주세요.", + ), + ) + return@intent + } + + postSideEffect(MyPageSideEffect.NavigateToLogin) } private fun deleteAccount() = intent { @@ -119,4 +132,4 @@ class MyPageViewModel @Inject constructor( private fun navigate(effect: MyPageSideEffect) = intent { postSideEffect(effect) } -} \ No newline at end of file +} diff --git a/feature/onboarding/impl/src/main/java/kr/co/call/impl/entry/OnboardingEntryBuilder.kt b/feature/onboarding/impl/src/main/java/kr/co/call/impl/entry/OnboardingEntryBuilder.kt index 0137de85..3e75ba8f 100644 --- a/feature/onboarding/impl/src/main/java/kr/co/call/impl/entry/OnboardingEntryBuilder.kt +++ b/feature/onboarding/impl/src/main/java/kr/co/call/impl/entry/OnboardingEntryBuilder.kt @@ -105,31 +105,13 @@ fun EntryProviderScope.onboardingEntry( } entry { - val onboardingViewModel = sharedOnboardingViewModel() - Onboarding5Screen( - onBackClick = onBackFromOnboarding5, - onNextClick = { preferTime -> - onboardingViewModel.updatePreferTime(preferTime) - onOnboarding5Next() - }, - ) - } - - entry { - val context=LocalContext.current + val context = LocalContext.current val onboardingViewModel = sharedOnboardingViewModel() val uiState by onboardingViewModel.container.stateFlow .collectAsStateWithLifecycle() onboardingViewModel.collectSideEffect { sideEffect -> when (sideEffect) { - is OnboardingSideEffect.OnboardingCompleted -> { - if (sideEffect.callNow) { - onOnboarding6CallNow() - } else { - onOnboarding6CallLater() - } - } - + OnboardingSideEffect.OnboardingSubmitted -> onOnboarding5Next() is OnboardingSideEffect.ShowMessage -> { Toast.makeText( context, @@ -139,11 +121,24 @@ fun EntryProviderScope.onboardingEntry( } } } + Onboarding5Screen( + onBackClick = onBackFromOnboarding5, + onNextClick = { preferTime -> + onboardingViewModel.submitOnboarding(preferTime) + }, + isLoading = uiState.submitStatus == LoadStatus.Loading, + ) + } + + entry { + val onboardingViewModel = sharedOnboardingViewModel() + val uiState by onboardingViewModel.container.stateFlow + .collectAsStateWithLifecycle() Onboarding6Screen( firstName = uiState.aiFirstName, - isLoading = uiState.submitStatus== LoadStatus.Loading, - onCallNowClick ={onboardingViewModel.submitOnboarding(callNow=true)}, - onCallLaterClick ={onboardingViewModel.submitOnboarding(callNow=false)}, + isLoading = false, + onCallNowClick = onOnboarding6CallNow, + onCallLaterClick = onOnboarding6CallLater, ) } } diff --git a/feature/onboarding/impl/src/main/java/kr/co/call/impl/screen/Onboarding2Screen.kt b/feature/onboarding/impl/src/main/java/kr/co/call/impl/screen/Onboarding2Screen.kt index 9c41039b..4d6cf225 100644 --- a/feature/onboarding/impl/src/main/java/kr/co/call/impl/screen/Onboarding2Screen.kt +++ b/feature/onboarding/impl/src/main/java/kr/co/call/impl/screen/Onboarding2Screen.kt @@ -2,6 +2,8 @@ package kr.co.call.impl.screen import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -121,6 +123,7 @@ fun Onboarding2Screen( Column( modifier = Modifier .weight(1f) + .verticalScroll(rememberScrollState()) .padding(horizontal = 27.dp), ) { Spacer(modifier = Modifier.height(25.dp)) @@ -181,12 +184,18 @@ fun Onboarding2Screen( MemberChoice( modifier = Modifier.fillMaxWidth(), label = "직업", - selectedOption = selectedJob?.label.orEmpty(), + selectedOption = when (selectedJob) { + CharacterJob.UMEMPLOYED -> "기타" + else -> selectedJob?.label.orEmpty() + }, placeholder = "직업을 선택해주세요", options = listOf("대학생", "직장인", "기타"), onOptionSelected = {selectedLabel-> - selectedJob=CharacterJob.entries.firstOrNull{ job-> - job.label==selectedLabel + selectedJob = when (selectedLabel) { + "기타" -> CharacterJob.UMEMPLOYED + else -> CharacterJob.entries.firstOrNull { job -> + job.label == selectedLabel + } } }, required = true, @@ -202,6 +211,8 @@ fun Onboarding2Screen( options = Mbti.entries.map { it.name }, onOptionSelected = { mbti = it }, ) + + Spacer(modifier = Modifier.height(24.dp)) } Column( diff --git a/feature/onboarding/impl/src/main/java/kr/co/call/impl/screen/Onboarding5Screen.kt b/feature/onboarding/impl/src/main/java/kr/co/call/impl/screen/Onboarding5Screen.kt index 0e2bb017..43177027 100644 --- a/feature/onboarding/impl/src/main/java/kr/co/call/impl/screen/Onboarding5Screen.kt +++ b/feature/onboarding/impl/src/main/java/kr/co/call/impl/screen/Onboarding5Screen.kt @@ -29,6 +29,7 @@ import kr.co.call.designsystem.theme.CallFromAiTheme fun Onboarding5Screen( onBackClick: () -> Unit, onNextClick: (PreferTime) -> Unit, + isLoading: Boolean = false, initialPreferTime: PreferTime? = null, modifier: Modifier = Modifier, ){ @@ -86,7 +87,7 @@ fun Onboarding5Screen( bottom = 18.dp, ), text = "다음", - enabled=selectedPreferTime != null, + enabled = selectedPreferTime != null && !isLoading, onClick = { selectedPreferTime?.let(onNextClick) }, ) } @@ -108,4 +109,4 @@ private fun Onboarding5ScreenPreview() { initialPreferTime = PreferTime.DAY, ) } -} \ No newline at end of file +} diff --git a/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/OnboardingSideEffect.kt b/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/OnboardingSideEffect.kt index 39e4c7c3..17812abb 100644 --- a/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/OnboardingSideEffect.kt +++ b/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/OnboardingSideEffect.kt @@ -1,11 +1,9 @@ package kr.co.call.impl.viewmodel sealed interface OnboardingSideEffect{ - data class OnboardingCompleted( - val callNow:Boolean, - ): OnboardingSideEffect + data object OnboardingSubmitted : OnboardingSideEffect data class ShowMessage( val message: String, ):OnboardingSideEffect -} \ No newline at end of file +} diff --git a/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/OnboardingViewModel.kt b/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/OnboardingViewModel.kt index 679ba653..0c12454f 100644 --- a/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/OnboardingViewModel.kt +++ b/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/OnboardingViewModel.kt @@ -6,7 +6,7 @@ import kr.co.call.domain.exception.toUserMessage import kr.co.call.domain.model.onboarding.CharacterOnboardingInput import kr.co.call.domain.model.onboarding.CharacterTraitInput import kr.co.call.domain.model.onboarding.MemberOnboardingInput -import kr.co.call.domain.model.onboarding.OnboardingSubmission +import kr.co.call.domain.model.onboarding.OnboardingInput import kr.co.call.domain.repository.OnboardingRepository import kr.co.call.domain.util.LoadStatus import kr.co.call.impl.component.PreferTime @@ -116,20 +116,17 @@ class OnboardingViewModel @Inject constructor( } } fun submitOnboarding( - callNow: Boolean, + preferTime: PreferTime, ) = intent { if (state.submitStatus == LoadStatus.Loading) return@intent val age = state.aiAge.toIntOrNull() val speechStyle = state.speechStyle val relationship = state.relationship - val preferTime = state.preferTime - if ( age == null || speechStyle == null || relationship == null || - preferTime == null || state.traits.isEmpty() ) { postSideEffect( @@ -141,10 +138,13 @@ class OnboardingViewModel @Inject constructor( } reduce { - state.copy(submitStatus = LoadStatus.Loading) + state.copy( + preferTime = preferTime, + submitStatus = LoadStatus.Loading, + ) } - val submission = OnboardingSubmission( + val submission = OnboardingInput( member = MemberOnboardingInput( lastName = state.userLastName, firstName = state.userFirstName, @@ -182,9 +182,7 @@ class OnboardingViewModel @Inject constructor( } postSideEffect( - OnboardingSideEffect.OnboardingCompleted( - callNow = callNow, - ), + OnboardingSideEffect.OnboardingSubmitted, ) } .onFailure { error -> @@ -203,4 +201,4 @@ class OnboardingViewModel @Inject constructor( ) } } -} \ No newline at end of file +} diff --git a/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/model/Job.kt b/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/model/Job.kt index c3758cb4..1714f211 100644 --- a/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/model/Job.kt +++ b/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/model/Job.kt @@ -14,4 +14,4 @@ enum class CharacterJob( STUDENT("대학생"), EMPLOYED("직장인"), UMEMPLOYED("무직") -} \ No newline at end of file +} From de00f171a2c0d543221ed17ba915cb774189ef39 Mon Sep 17 00:00:00 2001 From: dada4 Date: Sat, 1 Aug 2026 00:58:50 +0900 Subject: [PATCH 5/9] =?UTF-8?q?fix:=20pr=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repositoryImpl/MyPageRepositoryImpl.kt | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/core/data/src/main/java/kr/co/call/data/repositoryImpl/MyPageRepositoryImpl.kt b/core/data/src/main/java/kr/co/call/data/repositoryImpl/MyPageRepositoryImpl.kt index af628634..4501b41a 100644 --- a/core/data/src/main/java/kr/co/call/data/repositoryImpl/MyPageRepositoryImpl.kt +++ b/core/data/src/main/java/kr/co/call/data/repositoryImpl/MyPageRepositoryImpl.kt @@ -2,13 +2,12 @@ package kr.co.call.data.repositoryImpl import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay -import kr.co.call.data.util.runRepositoryCatching +import kr.co.call.data.util.safeApiResultUnit import kr.co.call.datastore.TokenDataStore import kr.co.call.domain.model.mypage.MyPageProfile import kr.co.call.domain.repository.MyPageRepository import kr.co.call.network.api.MyPageApi import kr.co.call.network.util.ErrorResponseParser -import kr.co.call.network.util.safeApiCallUnit import timber.log.Timber import javax.inject.Inject @@ -31,27 +30,24 @@ class MyPageRepositoryImpl @Inject constructor( } } - override suspend fun logout():Result = - runRepositoryCatching { - safeApiCallUnit(errorResponseParser){ - myPageApi.logout() - } - //서버 로그아웃 성공 후 기기 토큰 삭제 + override suspend fun logout(): Result = + safeApiResultUnit(errorResponseParser) { + myPageApi.logout() + }.onSuccess { tokenDataStore.clearTokens() } + override suspend fun deleteAccount(): Result = - runRepositoryCatching { - //토큰이 남아 있을 때 탈퇴 api를 먼저 호출 - safeApiCallUnit(errorResponseParser){ - myPageApi.deleteAccount() - } - runCatching{ + safeApiResultUnit(errorResponseParser) { + myPageApi.deleteAccount() + }.onSuccess { + try { tokenDataStore.clearTokens() - }.onFailure { error-> - if (error is CancellationException) throw error + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { Timber.w(error, "회원 탈퇴 후 토큰 삭제 실패") } - Unit } } From cc6295100f9c8cbd4cb5ed4343ac377d04eee75b Mon Sep 17 00:00:00 2001 From: dada4 Date: Sat, 1 Aug 2026 01:20:15 +0900 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20pr=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/res/xml/backup_rules.xml | 2 +- app/src/main/res/xml/data_extraction_rules.xml | 2 +- core/data/src/main/java/kr/co/call/data/util/SafeApiResult.kt | 1 + .../impl/src/main/java/kr/co/call/impl/viewmodel/model/Job.kt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index 8208a921..2a193d7a 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -11,5 +11,5 @@ --> - + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index 0fcfd6e8..841558e1 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -10,7 +10,7 @@ --> - +