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..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, @@ -229,6 +239,7 @@ fun AppScreen( } ) myPageEntry( + navigateToLogin = {appNavigator.replaceAll(LoginNavKey)}, navigateToFaq = { appNavigator.navigate(FaqNavKey) }, navigateToTerms = { appNavigator.navigate(TermNavKey) }, navigateToCharacterManagement = { appNavigator.navigate(CharacterManagementNavKey) }, 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..2a193d7a 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..841558e1 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/di/RepositoryModule.kt b/core/data/src/main/java/kr/co/call/data/di/RepositoryModule.kt index e68bf483..8a9f8891 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 @@ -9,6 +9,9 @@ import kr.co.call.data.repositoryImpl.AICharacterRepositoryImpl import kr.co.call.data.repositoryImpl.CallRecordRepositoryImpl 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 @@ -95,4 +98,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 721df027..ac879509 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,14 +1,13 @@ package kr.co.call.data.repositoryImpl import javax.inject.Inject -import kr.co.call.data.util.toAppResult +import kr.co.call.data.util.safeApiResult import kr.co.call.datastore.TokenDataStore import kr.co.call.domain.model.login.LoginToken import kr.co.call.domain.repository.LoginRepository import kr.co.call.network.api.LoginApi import kr.co.call.network.dto.login.LoginRequestDto import kr.co.call.network.util.ErrorResponseParser -import kr.co.call.network.util.safeApiCall /** * 카카오 로그인 서버 통신을 실제로 수행하는 Repository 구현체입니다. @@ -27,20 +26,19 @@ class LoginRepositoryImpl @Inject constructor( override suspend fun loginWithKakao( kakaoAccessToken: String, ): Result = - runCatching { - val result = safeApiCall(errorResponseParser) { - loginApi.login( - request = LoginRequestDto( - kakaoAccessToken = kakaoAccessToken, - ), - ) - } + safeApiResult(errorResponseParser) { + loginApi.login( + request = LoginRequestDto( + kakaoAccessToken = kakaoAccessToken, + ), + ) + }.mapCatching { result -> require( result.accessToken.isNotBlank() && result.refreshToken.isNotBlank(), ) { "로그인 응답 토큰 값이 비어 있습니다" } - tokenDataStore.saveTokens( + tokenDataStore.setTokens( accessToken = result.accessToken, refreshToken = result.refreshToken, ) @@ -51,5 +49,5 @@ class LoginRepositoryImpl @Inject constructor( 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 9f3a40f6..3c3c04e3 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,31 +1,53 @@ package kr.co.call.data.repositoryImpl +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay +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 timber.log.Timber 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) - return runCatching { + return Result.success( MyPageProfile( profileImageUrl = "", nickname = "김수현", tier = "Basic", remainingTicketCount = 18, appVersion = "1.0.0", - ) - } + ), + ) } - override suspend fun logout() { - delay(300) - } + override suspend fun logout(): Result = + safeApiResultUnit(errorResponseParser) { + myPageApi.logout() + }.mapCatching { + tokenDataStore.clearTokens() + } - override suspend fun deleteAccount() { - delay(300) - } -} \ No newline at end of file + override suspend fun deleteAccount(): Result = + safeApiResultUnit(errorResponseParser) { + myPageApi.deleteAccount() + }.onSuccess { + try { + tokenDataStore.clearTokens() + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + Timber.w(error, "회원 탈퇴 후 토큰 삭제 실패") + } + } + +} 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..c44f7dd2 --- /dev/null +++ b/core/data/src/main/java/kr/co/call/data/repositoryImpl/OnboardingRepositoryImpl.kt @@ -0,0 +1,65 @@ +package kr.co.call.data.repositoryImpl + +import javax.inject.Inject +import kr.co.call.data.util.safeApiResultUnit +import kr.co.call.domain.model.onboarding.CharacterOnboardingInput +import kr.co.call.domain.model.onboarding.MemberOnboardingInput +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 + +class OnboardingRepositoryImpl @Inject constructor( + private val myPageApi: MyPageApi, + private val aiCharacterApi: AICharacterApi, + private val errorResponseParser: ErrorResponseParser, +) : OnboardingRepository { + + override suspend fun submitMemberOnboarding( + submission: MemberOnboardingInput, + ): Result = + safeApiResultUnit(errorResponseParser) { + myPageApi.updateMember( + request = UpdateMemberRequestDto( + lastName = submission.lastName, + firstName = submission.firstName, + imageUrl = submission.imageUrl, + gender = submission.gender, + birth = submission.birth, + mbti = submission.mbti, + job = submission.job, + ), + ) + } + + override suspend fun submitCharacterOnboarding( + submission: CharacterOnboardingInput, + ): Result = + safeApiResultUnit(errorResponseParser) { + aiCharacterApi.createCharacter( + request = CreateCharacterRequestDto( + lastName = submission.lastName, + firstName = submission.firstName, + gender = submission.gender, + age = submission.age, + job = submission.job, + imageUrl = submission.imageUrl, + spiceLevel = submission.spiceLevel, + preferTime = submission.preferTime, + mbti = submission.mbti, + speechStyle = submission.speechStyle, + relationshipStage = + submission.relationshipStage, + traits = submission.traits.map { trait -> + CharacterTraitRequestDto( + trait = trait.trait, + priority = trait.priority, + ) + }, + ), + ) + } +} 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/OnboardingInput.kt b/core/domain/src/main/java/kr/co/call/domain/model/onboarding/OnboardingInput.kt new file mode 100644 index 00000000..e7ae5dde --- /dev/null +++ b/core/domain/src/main/java/kr/co/call/domain/model/onboarding/OnboardingInput.kt @@ -0,0 +1,34 @@ +package kr.co.call.domain.model.onboarding + +//사용자 본인의 정보 +data class MemberOnboardingInput( + val lastName: String, + val firstName: String, + val imageUrl: String, + val gender: String, + val birth: String, + val mbti: String, + val job: String, +) + +//ai 캐릭터의 정보 +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, +) + +//특성(ex.유머러스한)과 우선순위를 묶는 모델 +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/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/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..e5d4d6ce --- /dev/null +++ b/core/domain/src/main/java/kr/co/call/domain/repository/OnboardingRepository.kt @@ -0,0 +1,13 @@ +package kr.co.call.domain.repository + +import kr.co.call.domain.model.onboarding.CharacterOnboardingInput +import kr.co.call.domain.model.onboarding.MemberOnboardingInput + +interface OnboardingRepository { + suspend fun submitMemberOnboarding( + submission: MemberOnboardingInput, + ):Result + suspend fun submitCharacterOnboarding( + submission: CharacterOnboardingInput + ):Result +} \ No newline at end of file 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 new file mode 100644 index 00000000..4bc10539 --- /dev/null +++ b/core/network/src/main/java/kr/co/call/network/api/MyPageApi.kt @@ -0,0 +1,21 @@ +package kr.co.call.network.api + +import kr.co.call.network.dto.ApiResponse +import kr.co.call.network.dto.onboarding.UpdateMemberRequestDto +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.PATCH +import retrofit2.http.POST + +interface MyPageApi { + @POST("auth/logout") + suspend fun logout(): ApiResponse + + @DELETE("members/me") + suspend fun deleteAccount(): ApiResponse + + @PATCH("members/me") + suspend fun updateMember( + @Body request: UpdateMemberRequestDto, + ): ApiResponse +} 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 dab08987..447af48f 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,10 +7,12 @@ 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.CharacterApi import kr.co.call.network.api.HomeApi 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 @@ -150,6 +152,23 @@ object NetworkModule { fun provideTokenReissueApi( @Named("reissueRetrofit") retrofit: Retrofit, - ): TokenReissueApi = - retrofit.create(TokenReissueApi::class.java) + ): TokenReissueApi{ + return retrofit.create(TokenReissueApi::class.java) + } + + @Provides + @Singleton + fun provideMyPageApi( + retrofit: Retrofit, + ): 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 b8bccea3..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,7 +21,9 @@ class AuthInterceptor @Inject constructor( return chain.proceed(originalRequest) } //저장된 access 토큰이 없으면 원래 요청 그대로 전송 - val accessToken = tokenDataStore.getCachedAccessToken() + val accessToken = runBlocking { + tokenDataStore.getTokens().accessToken + } if (accessToken.isNullOrBlank()) { return chain.proceed(originalRequest) } @@ -44,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/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..a00cf33d 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 @@ -79,4 +79,4 @@ private fun ApiResponse<*>.throwIfNotSuccess() { message = message.ifBlank { "요청을 처리하지 못했습니다." }, ) } -} \ 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/login/impl/src/main/java/kr/co/call/impl/viewmodel/LoginViewModel.kt b/feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/LoginViewModel.kt index 5597e2f0..5abfb2a7 100644 --- a/feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/LoginViewModel.kt +++ b/feature/login/impl/src/main/java/kr/co/call/impl/viewmodel/LoginViewModel.kt @@ -7,6 +7,7 @@ import kr.co.call.domain.util.LoadStatus import org.orbitmvi.orbit.ContainerHost import javax.inject.Inject import org.orbitmvi.orbit.viewmodel.container +import timber.log.Timber /** * 카카오 로그인 이후 서버 로그인 요청과 UI 상태를 관리하는 ViewModel @@ -43,6 +44,12 @@ class LoginViewModel @Inject constructor( LoadStatus.Idle } + Timber.d( + "로그인 분기: needsTermsAgreement=%s, needsOnboarding=%s", + loginToken.needsTermsAgreement, + loginToken.needsOnboarding, + ) + val sideEffect= when { loginToken.needsTermsAgreement -> 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..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 @@ -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,16 +63,73 @@ class MyPageViewModel @Inject constructor( } private fun logout() = intent { - myPageRepository.logout() + if (state.authStatus== LoadStatus.Loading)return@intent + reduce{ + state.copy(authStatus = LoadStatus.Loading) + } + val serverLogoutError = myPageRepository.logout().exceptionOrNull() + if (serverLogoutError != null) { + if (serverLogoutError is CancellationException) throw serverLogoutError + + reduce { + state.copy(authStatus = LoadStatus.Idle) + } + 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 { + 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 { postSideEffect(effect) } -} \ No newline at end of file +} 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..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 @@ -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() @@ -96,13 +105,28 @@ fun EntryProviderScope.onboardingEntry( } entry { + val context = LocalContext.current val onboardingViewModel = sharedOnboardingViewModel() + val uiState by onboardingViewModel.container.stateFlow + .collectAsStateWithLifecycle() + onboardingViewModel.collectSideEffect { sideEffect -> + when (sideEffect) { + OnboardingSideEffect.OnboardingSubmitted -> onOnboarding5Next() + is OnboardingSideEffect.ShowMessage -> { + Toast.makeText( + context, + sideEffect.message, + Toast.LENGTH_SHORT, + ).show() + } + } + } Onboarding5Screen( onBackClick = onBackFromOnboarding5, onNextClick = { preferTime -> - onboardingViewModel.updatePreferTime(preferTime) - onOnboarding5Next() + onboardingViewModel.submitOnboarding(preferTime) }, + isLoading = uiState.submitStatus == LoadStatus.Loading, ) } @@ -112,6 +136,7 @@ fun EntryProviderScope.onboardingEntry( .collectAsStateWithLifecycle() Onboarding6Screen( firstName = uiState.aiFirstName, + isLoading = false, onCallNowClick = onOnboarding6CallNow, onCallLaterClick = onOnboarding6CallLater, ) 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..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 @@ -44,6 +46,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 +66,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 +99,7 @@ fun Onboarding2Screen( val canMoveNext = age.isNotBlank() && lastName.isNotBlank() && firstName.isNotBlank() && - job.isNotBlank() + selectedJob != null Box( modifier = modifier @@ -120,6 +123,7 @@ fun Onboarding2Screen( Column( modifier = Modifier .weight(1f) + .verticalScroll(rememberScrollState()) .padding(horizontal = 27.dp), ) { Spacer(modifier = Modifier.height(25.dp)) @@ -180,10 +184,20 @@ fun Onboarding2Screen( MemberChoice( modifier = Modifier.fillMaxWidth(), label = "직업", - selectedOption = job, + selectedOption = when (selectedJob) { + CharacterJob.UMEMPLOYED -> "기타" + else -> selectedJob?.label.orEmpty() + }, placeholder = "직업을 선택해주세요", options = listOf("대학생", "직장인", "기타"), - onOptionSelected = { job = it }, + onOptionSelected = {selectedLabel-> + selectedJob = when (selectedLabel) { + "기타" -> CharacterJob.UMEMPLOYED + else -> CharacterJob.entries.firstOrNull { job -> + job.label == selectedLabel + } + } + }, required = true, ) @@ -197,6 +211,8 @@ fun Onboarding2Screen( options = Mbti.entries.map { it.name }, onOptionSelected = { mbti = it }, ) + + Spacer(modifier = Modifier.height(24.dp)) } Column( @@ -225,8 +241,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/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/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..17812abb --- /dev/null +++ b/feature/onboarding/impl/src/main/java/kr/co/call/impl/viewmodel/OnboardingSideEffect.kt @@ -0,0 +1,9 @@ +package kr.co.call.impl.viewmodel + +sealed interface OnboardingSideEffect{ + data object OnboardingSubmitted : OnboardingSideEffect + + data class ShowMessage( + val message: String, + ):OnboardingSideEffect +} 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..b061becf 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,12 @@ 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.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 +16,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 +34,8 @@ class OnboardingViewModel @Inject constructor() : ViewModel(), birthday: LocalDate, job: String, mbti: String, + gender: String, + imageUrl: String, ) =intent{ reduce{ state.copy( @@ -32,6 +44,8 @@ class OnboardingViewModel @Inject constructor() : ViewModel(), userBirthday = birthday, userJob = job, userMbti = mbti, + userGender = gender, + userImageUrl = imageUrl, ) } } @@ -42,6 +56,8 @@ class OnboardingViewModel @Inject constructor() : ViewModel(), firstName: String, job: String, mbti: String, + gender:String, + imageUrl: String, )=intent { reduce { state.copy( @@ -50,6 +66,8 @@ class OnboardingViewModel @Inject constructor() : ViewModel(), aiAge = age, aiJob = job, aiMbti = mbti, + aiGender = gender, + aiImageUrl = imageUrl, ) } } @@ -96,4 +114,100 @@ class OnboardingViewModel @Inject constructor() : ViewModel(), ) } } -} \ No newline at end of file + fun submitOnboarding( + preferTime: PreferTime, + ) = intent { + if (state.submitStatus == LoadStatus.Loading) return@intent + + val age = state.aiAge.toIntOrNull() + val speechStyle = state.speechStyle + val relationship = state.relationship + if ( + age == null || + speechStyle == null || + relationship == null || + state.traits.isEmpty() + ) { + postSideEffect( + OnboardingSideEffect.ShowMessage( + message = "온보딩 정보를 확인해주세요.", + ), + ) + return@intent + } + + reduce { + state.copy( + preferTime = preferTime, + submitStatus = LoadStatus.Loading, + ) + } + + val memberSubmission = MemberOnboardingInput( + lastName = state.userLastName, + firstName = state.userFirstName, + imageUrl = state.userImageUrl, + gender = state.userGender, + birth = state.userBirthday.toString(), + mbti = state.userMbti, + job = state.userJob, + ) + + val characterSubmission = 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, + ) + }, + ) + + val submitResult: Result = + onboardingRepository.submitMemberOnboarding(memberSubmission) + .fold( + onSuccess = { + onboardingRepository.submitCharacterOnboarding(characterSubmission) + }, + onFailure = { error -> + Result.failure(error) + }, + ) + + submitResult + .onSuccess { + reduce { + state.copy(submitStatus = LoadStatus.Idle) + } + + postSideEffect( + OnboardingSideEffect.OnboardingSubmitted, + ) + } + .onFailure { error -> + if (error is CancellationException) throw error + + reduce { + state.copy(submitStatus = LoadStatus.Idle) + } + + postSideEffect( + OnboardingSideEffect.ShowMessage( + message = error.toUserMessage( + default = "온보딩 저장에 실패했습니다.", + ), + ), + ) + } + } +} 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..b93111f3 --- /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("기타") +} 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