diff --git a/app/src/main/java/com/toyou/toyouandroid/data/emotion/service/EmotionService.kt b/app/src/main/java/com/toyou/toyouandroid/data/emotion/service/EmotionService.kt index 5c4b8d5a..90470699 100644 --- a/app/src/main/java/com/toyou/toyouandroid/data/emotion/service/EmotionService.kt +++ b/app/src/main/java/com/toyou/toyouandroid/data/emotion/service/EmotionService.kt @@ -4,7 +4,6 @@ import com.toyou.toyouandroid.data.home.dto.response.CardDetail import com.toyou.toyouandroid.data.emotion.dto.EmotionRequest import com.toyou.toyouandroid.data.emotion.dto.EmotionResponse import com.toyou.toyouandroid.data.emotion.dto.YesterdayFriendsResponse -import retrofit2.Call import retrofit2.Response import retrofit2.http.Body import retrofit2.http.GET @@ -14,18 +13,13 @@ import retrofit2.http.Path interface EmotionService { @POST("users/emotions") - fun patchEmotion( - @Body emotion: EmotionRequest - ): Call - - @POST("users/emotions") - suspend fun patchEmotionSuspend( + suspend fun patchEmotion( @Body emotion: EmotionRequest ): Response @GET("diarycards/yesterday") - fun getYesterdayFriendCard(): Call + suspend fun getYesterdayFriendCard(): Response @GET("diarycards/{cardId}") - fun getDiaryCardDetail(@Path("cardId") cardId : Int): Call -} \ No newline at end of file + suspend fun getDiaryCardDetail(@Path("cardId") cardId: Int): Response +} diff --git a/app/src/main/java/com/toyou/toyouandroid/data/mypage/service/MypageService.kt b/app/src/main/java/com/toyou/toyouandroid/data/mypage/service/MypageService.kt index 620e2920..11ce0a7d 100644 --- a/app/src/main/java/com/toyou/toyouandroid/data/mypage/service/MypageService.kt +++ b/app/src/main/java/com/toyou/toyouandroid/data/mypage/service/MypageService.kt @@ -1,15 +1,11 @@ package com.toyou.toyouandroid.data.mypage.service import com.toyou.toyouandroid.data.mypage.dto.MypageResponse -import retrofit2.Call import retrofit2.Response import retrofit2.http.GET interface MypageService { @GET("/users/mypage") - fun getMypage(): Call - - @GET("/users/mypage") - suspend fun getMypageSuspend(): Response -} \ No newline at end of file + suspend fun getMypage(): Response +} diff --git a/app/src/main/java/com/toyou/toyouandroid/data/notice/service/NoticeService.kt b/app/src/main/java/com/toyou/toyouandroid/data/notice/service/NoticeService.kt index 7f70912f..d9b21b14 100644 --- a/app/src/main/java/com/toyou/toyouandroid/data/notice/service/NoticeService.kt +++ b/app/src/main/java/com/toyou/toyouandroid/data/notice/service/NoticeService.kt @@ -3,18 +3,18 @@ package com.toyou.toyouandroid.data.notice.service import com.toyou.toyouandroid.data.notice.dto.AlarmDeleteResponse import com.toyou.toyouandroid.data.notice.dto.AlarmResponse import com.toyou.toyouandroid.data.notice.dto.FriendsRequestResponse -import retrofit2.Call +import retrofit2.Response import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.Path interface NoticeService { @GET("/alarms") - fun getAlarms(): Call + suspend fun getAlarms(): Response @DELETE("alarms/{id}") - fun deleteAlarm(@Path("id") id: Int): Call + suspend fun deleteAlarm(@Path("id") id: Int): Response @GET("/friends/requests") - fun getFriendsRequest(): Call + suspend fun getFriendsRequest(): Response } \ No newline at end of file diff --git a/app/src/main/java/com/toyou/toyouandroid/data/onboarding/service/AuthService.kt b/app/src/main/java/com/toyou/toyouandroid/data/onboarding/service/AuthService.kt index 3b837f89..a44d4915 100644 --- a/app/src/main/java/com/toyou/toyouandroid/data/onboarding/service/AuthService.kt +++ b/app/src/main/java/com/toyou/toyouandroid/data/onboarding/service/AuthService.kt @@ -2,7 +2,6 @@ package com.toyou.toyouandroid.data.onboarding.service import com.toyou.toyouandroid.data.onboarding.dto.request.SignUpRequest import com.toyou.toyouandroid.data.onboarding.dto.response.SignUpResponse -import retrofit2.Call import retrofit2.Response import retrofit2.http.Body import retrofit2.http.DELETE @@ -11,38 +10,28 @@ import retrofit2.http.POST interface AuthService { @POST("auth/signup") - fun signUp( + suspend fun signUp( @Header("oauthAccessToken") accessToken: String, @Body request: SignUpRequest - ): Call + ): Response @POST("auth/reissue") - fun reissue( + suspend fun reissue( @Header("refreshToken") refreshToken: String - ): Call - - @POST("auth/logout") - fun logout( - @Header("refreshToken") refreshToken: String - ): Call + ): Response @POST("auth/logout") - suspend fun logoutSuspend( + suspend fun logout( @Header("refreshToken") refreshToken: String ): Response @POST("auth/kakao") - fun kakaoLogin( + suspend fun kakaoLogin( @Header("oauthAccessToken") accessToken: String - ): Call - - @DELETE("auth/unlink") - fun signOut( - @Header("refreshToken") refreshToken: String - ): Call + ): Response @DELETE("auth/unlink") - suspend fun signOutSuspend( + suspend fun signOut( @Header("refreshToken") refreshToken: String ): Response -} \ No newline at end of file +} diff --git a/app/src/main/java/com/toyou/toyouandroid/data/onboarding/service/OnboardingService.kt b/app/src/main/java/com/toyou/toyouandroid/data/onboarding/service/OnboardingService.kt index 7e2c704c..c11d12c4 100644 --- a/app/src/main/java/com/toyou/toyouandroid/data/onboarding/service/OnboardingService.kt +++ b/app/src/main/java/com/toyou/toyouandroid/data/onboarding/service/OnboardingService.kt @@ -4,7 +4,6 @@ import com.toyou.toyouandroid.data.onboarding.dto.NicknameCheckResponse import com.toyou.toyouandroid.data.onboarding.dto.PatchNicknameRequest import com.toyou.toyouandroid.data.onboarding.dto.PatchNicknameResponse import com.toyou.toyouandroid.data.onboarding.dto.PatchStatusRequest -import retrofit2.Call import retrofit2.Response import retrofit2.http.Body import retrofit2.http.GET @@ -14,34 +13,18 @@ import retrofit2.http.Query interface OnboardingService { @GET("users/nickname/check") - fun getNicknameCheck( - @Query("nickname") nickname: String, - @Query("userId") userId: Int - ): Call - - @GET("users/nickname/check") - suspend fun getNicknameCheckSuspend( + suspend fun getNicknameCheck( @Query("nickname") nickname: String, @Query("userId") userId: Int ): Response @PATCH("users/nickname") - fun patchNickname( - @Body request: PatchNicknameRequest - ): Call - - @PATCH("users/nickname") - suspend fun patchNicknameSuspend( + suspend fun patchNickname( @Body request: PatchNicknameRequest ): Response @PATCH("users/status") - fun patchStatus( - @Body request: PatchStatusRequest - ): Call - - @PATCH("users/status") - suspend fun patchStatusSuspend( + suspend fun patchStatus( @Body request: PatchStatusRequest ): Response -} \ No newline at end of file +} diff --git a/app/src/main/java/com/toyou/toyouandroid/data/record/service/RecordService.kt b/app/src/main/java/com/toyou/toyouandroid/data/record/service/RecordService.kt index e0beba81..8b63f14b 100644 --- a/app/src/main/java/com/toyou/toyouandroid/data/record/service/RecordService.kt +++ b/app/src/main/java/com/toyou/toyouandroid/data/record/service/RecordService.kt @@ -7,7 +7,7 @@ import com.toyou.toyouandroid.data.record.dto.DiaryCardPerDayResponse import com.toyou.toyouandroid.data.record.dto.DiaryCardResponse import com.toyou.toyouandroid.data.record.dto.PatchDiaryCardResponse import com.toyou.toyouandroid.network.BaseResponse -import retrofit2.Call +import retrofit2.Response import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.PATCH @@ -16,33 +16,33 @@ import retrofit2.http.Query interface RecordService { @GET("diarycards/mine") - fun getDiarycardsMine( + suspend fun getDiarycardsMine( @Query("year") year: Int, @Query("month") month: Int - ): Call + ): Response @GET("diarycards/friends") - fun getDiarycardsNumFriend( + suspend fun getDiarycardsNumFriend( @Query("year") year: Int, @Query("month") month: Int, - ): Call + ): Response @GET("diarycards/friends") - fun getDiarycardsPerDayFriend( + suspend fun getDiarycardsPerDayFriend( @Query("year") year: Int, @Query("month") month: Int, @Query("day") day: Int - ): Call + ): Response @DELETE("diarycards/{cardId}") - fun deleteDiarycard( + suspend fun deleteDiarycard( @Path("cardId") cardId: Int - ): Call + ): Response @PATCH("diarycards/{cardId}/exposure") - fun patchDiarycardExposure( + suspend fun patchDiarycardExposure( @Path("cardId") cardId: Int - ): Call + ): Response @GET("/diarycards/{cardId}") suspend fun getCardDetail( diff --git a/app/src/main/java/com/toyou/toyouandroid/di/AppModule.kt b/app/src/main/java/com/toyou/toyouandroid/di/AppModule.kt index 85d31a67..7e649aae 100644 --- a/app/src/main/java/com/toyou/toyouandroid/di/AppModule.kt +++ b/app/src/main/java/com/toyou/toyouandroid/di/AppModule.kt @@ -1,26 +1,18 @@ package com.toyou.toyouandroid.di -import android.content.Context +import com.toyou.core.datastore.TokenStorage import com.toyou.toyouandroid.data.onboarding.service.AuthService import com.toyou.toyouandroid.utils.TokenManager -import com.toyou.toyouandroid.utils.TokenStorage import dagger.Module import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object AppModule { - - @Provides - @Singleton - fun provideTokenStorage(@ApplicationContext context: Context): TokenStorage { - return TokenStorage(context) - } - + @Provides @Singleton fun provideTokenManager( diff --git a/app/src/main/java/com/toyou/toyouandroid/di/RepositoryModule.kt b/app/src/main/java/com/toyou/toyouandroid/di/RepositoryModule.kt index 5dfaf25d..43fe80c1 100644 --- a/app/src/main/java/com/toyou/toyouandroid/di/RepositoryModule.kt +++ b/app/src/main/java/com/toyou/toyouandroid/di/RepositoryModule.kt @@ -1,22 +1,54 @@ package com.toyou.toyouandroid.di +import com.toyou.toyouandroid.domain.create.repository.CreateRepositoryImpl +import com.toyou.toyouandroid.domain.create.repository.ICreateRepository +import com.toyou.toyouandroid.domain.home.repository.HomeRepositoryImpl +import com.toyou.toyouandroid.domain.home.repository.IHomeRepository +import com.toyou.toyouandroid.domain.notice.INoticeRepository +import com.toyou.toyouandroid.domain.notice.NoticeRepositoryImpl +import com.toyou.toyouandroid.domain.profile.repository.IProfileRepository +import com.toyou.toyouandroid.domain.profile.repository.ProfileRepositoryImpl +import com.toyou.toyouandroid.domain.record.IRecordRepository +import com.toyou.toyouandroid.domain.record.RecordRepositoryImpl +import com.toyou.toyouandroid.domain.social.repostitory.ISocialRepository +import com.toyou.toyouandroid.domain.social.repostitory.SocialRepositoryImpl +import com.toyou.toyouandroid.fcm.domain.FCMRepositoryImpl +import com.toyou.toyouandroid.fcm.domain.IFCMRepository +import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton -/** - * Repository들은 @Inject constructor와 @Singleton 어노테이션을 통해 - * Hilt가 자동으로 제공합니다. - * - * 적용된 Repository 목록: - * - HomeRepository - * - CreateRepository - * - SocialRepository - * - NoticeRepository - * - RecordRepository - * - ProfileRepository - * - FCMRepository - */ @Module @InstallIn(SingletonComponent::class) -object RepositoryModule \ No newline at end of file +abstract class RepositoryModule { + + @Binds + @Singleton + abstract fun bindHomeRepository(impl: HomeRepositoryImpl): IHomeRepository + + @Binds + @Singleton + abstract fun bindCreateRepository(impl: CreateRepositoryImpl): ICreateRepository + + @Binds + @Singleton + abstract fun bindSocialRepository(impl: SocialRepositoryImpl): ISocialRepository + + @Binds + @Singleton + abstract fun bindNoticeRepository(impl: NoticeRepositoryImpl): INoticeRepository + + @Binds + @Singleton + abstract fun bindRecordRepository(impl: RecordRepositoryImpl): IRecordRepository + + @Binds + @Singleton + abstract fun bindProfileRepository(impl: ProfileRepositoryImpl): IProfileRepository + + @Binds + @Singleton + abstract fun bindFCMRepository(impl: FCMRepositoryImpl): IFCMRepository +} diff --git a/app/src/main/java/com/toyou/toyouandroid/domain/create/repository/CreateRepository.kt b/app/src/main/java/com/toyou/toyouandroid/domain/create/repository/CreateRepository.kt index 838292fd..1290e9af 100644 --- a/app/src/main/java/com/toyou/toyouandroid/domain/create/repository/CreateRepository.kt +++ b/app/src/main/java/com/toyou/toyouandroid/domain/create/repository/CreateRepository.kt @@ -13,20 +13,19 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class CreateRepository @Inject constructor(private val createService: CreateService) { - //private val client = AuthNetworkModule.getClient().create(CreateService::class.java) +class CreateRepositoryImpl @Inject constructor( + private val createService: CreateService +) : ICreateRepository { - //suspend fun getAllData() = client.getQuestions() - - suspend fun getAllData() : BaseResponse{ + override suspend fun getAllData(): BaseResponse { return createService.getQuestions() } - suspend fun getHomeEntryData() : BaseResponse{ + override suspend fun getHomeEntryData(): BaseResponse { return createService.getHomeEntry() } - suspend fun patchCardData( + override suspend fun patchCardData( previewCardModels: List, exposure: Boolean, cardId: Int, @@ -34,7 +33,6 @@ class CreateRepository @Inject constructor(private val createService: CreateServ val answerDto = convertPreviewCardModelsToAnswerDto(previewCardModels, exposure) return try { val response = createService.patchCard(cardId, answerDto) - // 응답 처리 if (response.isSuccess) { Timber.tag("카드 수정 성공!").d(response.message) } else { @@ -48,7 +46,7 @@ class CreateRepository @Inject constructor(private val createService: CreateServ } } - suspend fun postCardData( + override suspend fun postCardData( previewCardModels: List, exposure: Boolean ): BaseResponse { @@ -57,7 +55,6 @@ class CreateRepository @Inject constructor(private val createService: CreateServ return try { val response = createService.postCard(request = answerDto) - // 응답 처리 if (response.isSuccess) { Timber.tag("post 성공").d(response.message) } else { @@ -71,40 +68,6 @@ class CreateRepository @Inject constructor(private val createService: CreateServ } } - - /*suspend fun getHomeEntryData() = client.getHomeEntry() - - - suspend fun patchCardData( - previewCardModels: List, - exposure: Boolean, - cardId: Int, - ) { - val answerDto = convertPreviewCardModelsToAnswerDto(previewCardModels, exposure) - try { - val response = client.patchCard(card = cardId, request = answerDto) - - // 응답 처리 - if (response.isSuccess) { - Timber.tag("카드 수정 성공!").d(response.message) - } else { - Timber.tag("카드 수정 실패!").d(response.message) - tokenManager.refreshToken( - onSuccess = { - CoroutineScope(Dispatchers.IO).launch { - patchCardData(previewCardModels, exposure, cardId) - } - }, - onFailure = { Timber.e("patchCardData API call failed") } - ) - } - } catch (e: Exception) { - e.printStackTrace() - Timber.tag("카드 수정 실패!").d("Exception: ${e.message}") - } - }*/ - - private fun convertPreviewCardModelsToAnswerDto( previewCardModels: List, exposure: Boolean, @@ -121,36 +84,4 @@ class CreateRepository @Inject constructor(private val createService: CreateServ exposure = exposure ) } - - // 데이터 전송 함수 - /*suspend fun postCardData( - previewCardModels: List, - exposure: Boolean - ) : Int { - val answerDto = convertPreviewCardModelsToAnswerDto(previewCardModels, exposure) - - try { - val response = client.postCard(request = answerDto) - // 응답 처리 - if (response.isSuccess) { - Timber.tag("post 성공").d(response.message) - return response.result.id - } else { - Timber.tag("post 실패").d(response.message) - tokenManager.refreshToken( - onSuccess = { - CoroutineScope(Dispatchers.IO).launch { - postCardData(previewCardModels, exposure) - } - }, - onFailure = { Timber.e("postToken API call failed") } - ) - return 0 - } - } catch (e: Exception) { - e.printStackTrace() - Timber.tag("post 실패").d(e.message.toString()) - return 0 - } - }*/ } diff --git a/app/src/main/java/com/toyou/toyouandroid/domain/create/repository/ICreateRepository.kt b/app/src/main/java/com/toyou/toyouandroid/domain/create/repository/ICreateRepository.kt new file mode 100644 index 00000000..006e7e61 --- /dev/null +++ b/app/src/main/java/com/toyou/toyouandroid/domain/create/repository/ICreateRepository.kt @@ -0,0 +1,21 @@ +package com.toyou.toyouandroid.domain.create.repository + +import com.toyou.toyouandroid.data.create.dto.response.AnswerPost +import com.toyou.toyouandroid.data.create.dto.response.HomeDto +import com.toyou.toyouandroid.data.create.dto.response.QuestionsDto +import com.toyou.toyouandroid.model.PreviewCardModel +import com.toyou.toyouandroid.network.BaseResponse + +interface ICreateRepository { + suspend fun getAllData(): BaseResponse + suspend fun getHomeEntryData(): BaseResponse + suspend fun patchCardData( + previewCardModels: List, + exposure: Boolean, + cardId: Int + ): BaseResponse + suspend fun postCardData( + previewCardModels: List, + exposure: Boolean + ): BaseResponse +} diff --git a/app/src/main/java/com/toyou/toyouandroid/domain/home/repository/HomeRepository.kt b/app/src/main/java/com/toyou/toyouandroid/domain/home/repository/HomeRepository.kt index 102ceb3a..db5f7bb0 100644 --- a/app/src/main/java/com/toyou/toyouandroid/domain/home/repository/HomeRepository.kt +++ b/app/src/main/java/com/toyou/toyouandroid/domain/home/repository/HomeRepository.kt @@ -1,14 +1,19 @@ package com.toyou.toyouandroid.domain.home.repository +import com.toyou.toyouandroid.data.home.dto.response.CardDetail +import com.toyou.toyouandroid.data.home.dto.response.YesterdayCardResponse import com.toyou.toyouandroid.data.home.service.HomeService +import com.toyou.toyouandroid.network.BaseResponse import javax.inject.Inject import javax.inject.Singleton @Singleton -class HomeRepository @Inject constructor( +class HomeRepositoryImpl @Inject constructor( private val homeService: HomeService -) { - suspend fun getCardDetail(id: Long) = homeService.getCardDetail(id) +) : IHomeRepository { + override suspend fun getCardDetail(id: Long): BaseResponse = + homeService.getCardDetail(id) - suspend fun getYesterdayCard() = homeService.getCardYesterday() + override suspend fun getYesterdayCard(): BaseResponse = + homeService.getCardYesterday() } \ No newline at end of file diff --git a/app/src/main/java/com/toyou/toyouandroid/domain/home/repository/IHomeRepository.kt b/app/src/main/java/com/toyou/toyouandroid/domain/home/repository/IHomeRepository.kt new file mode 100644 index 00000000..6b65e91c --- /dev/null +++ b/app/src/main/java/com/toyou/toyouandroid/domain/home/repository/IHomeRepository.kt @@ -0,0 +1,10 @@ +package com.toyou.toyouandroid.domain.home.repository + +import com.toyou.toyouandroid.data.home.dto.response.CardDetail +import com.toyou.toyouandroid.data.home.dto.response.YesterdayCardResponse +import com.toyou.toyouandroid.network.BaseResponse + +interface IHomeRepository { + suspend fun getCardDetail(id: Long): BaseResponse + suspend fun getYesterdayCard(): BaseResponse +} diff --git a/app/src/main/java/com/toyou/toyouandroid/domain/notice/INoticeRepository.kt b/app/src/main/java/com/toyou/toyouandroid/domain/notice/INoticeRepository.kt new file mode 100644 index 00000000..3e8eb4e8 --- /dev/null +++ b/app/src/main/java/com/toyou/toyouandroid/domain/notice/INoticeRepository.kt @@ -0,0 +1,12 @@ +package com.toyou.toyouandroid.domain.notice + +import com.toyou.toyouandroid.data.notice.dto.AlarmDeleteResponse +import com.toyou.toyouandroid.data.notice.dto.AlarmResponse +import com.toyou.toyouandroid.data.notice.dto.FriendsRequestResponse +import retrofit2.Response + +interface INoticeRepository { + suspend fun getNotices(): Response + suspend fun getFriendsRequestNotices(): Response + suspend fun deleteNotice(alarmId: Int): Response +} diff --git a/app/src/main/java/com/toyou/toyouandroid/domain/notice/NoticeRepository.kt b/app/src/main/java/com/toyou/toyouandroid/domain/notice/NoticeRepository.kt index 5390b94d..451c0cad 100644 --- a/app/src/main/java/com/toyou/toyouandroid/domain/notice/NoticeRepository.kt +++ b/app/src/main/java/com/toyou/toyouandroid/domain/notice/NoticeRepository.kt @@ -4,22 +4,24 @@ import com.toyou.toyouandroid.data.notice.dto.AlarmDeleteResponse import com.toyou.toyouandroid.data.notice.dto.AlarmResponse import com.toyou.toyouandroid.data.notice.dto.FriendsRequestResponse import com.toyou.toyouandroid.data.notice.service.NoticeService -import retrofit2.Call +import retrofit2.Response import javax.inject.Inject import javax.inject.Singleton @Singleton -class NoticeRepository @Inject constructor(private val noticeService: NoticeService) { +class NoticeRepositoryImpl @Inject constructor( + private val noticeService: NoticeService +) : INoticeRepository { - fun getNotices(): Call { + override suspend fun getNotices(): Response { return noticeService.getAlarms() } - fun getFriendsRequestNotices(): Call { + override suspend fun getFriendsRequestNotices(): Response { return noticeService.getFriendsRequest() } - fun deleteNotice(alarmId: Int): Call { + override suspend fun deleteNotice(alarmId: Int): Response { return noticeService.deleteAlarm(alarmId) } } \ No newline at end of file diff --git a/app/src/main/java/com/toyou/toyouandroid/domain/profile/repository/IProfileRepository.kt b/app/src/main/java/com/toyou/toyouandroid/domain/profile/repository/IProfileRepository.kt new file mode 100644 index 00000000..480e438b --- /dev/null +++ b/app/src/main/java/com/toyou/toyouandroid/domain/profile/repository/IProfileRepository.kt @@ -0,0 +1,11 @@ +package com.toyou.toyouandroid.domain.profile.repository + +import com.toyou.toyouandroid.data.onboarding.dto.NicknameCheckResponse +import com.toyou.toyouandroid.data.onboarding.dto.PatchNicknameResponse +import retrofit2.Response + +interface IProfileRepository { + suspend fun checkNickname(nickname: String, userId: Int): Response + suspend fun updateNickname(nickname: String): Response + suspend fun updateStatus(status: String): Response +} diff --git a/app/src/main/java/com/toyou/toyouandroid/domain/profile/repository/ProfileRepository.kt b/app/src/main/java/com/toyou/toyouandroid/domain/profile/repository/ProfileRepository.kt index 5ea39e56..cf7b577f 100644 --- a/app/src/main/java/com/toyou/toyouandroid/domain/profile/repository/ProfileRepository.kt +++ b/app/src/main/java/com/toyou/toyouandroid/domain/profile/repository/ProfileRepository.kt @@ -10,18 +10,19 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class ProfileRepository @Inject constructor( +class ProfileRepositoryImpl @Inject constructor( private val onboardingService: OnboardingService -) { - suspend fun checkNickname(nickname: String, userId: Int): Response { - return onboardingService.getNicknameCheckSuspend(nickname, userId) +) : IProfileRepository { + + override suspend fun checkNickname(nickname: String, userId: Int): Response { + return onboardingService.getNicknameCheck(nickname, userId) } - - suspend fun updateNickname(nickname: String): Response { - return onboardingService.patchNicknameSuspend(PatchNicknameRequest(nickname)) + + override suspend fun updateNickname(nickname: String): Response { + return onboardingService.patchNickname(PatchNicknameRequest(nickname)) } - - suspend fun updateStatus(status: String): Response { - return onboardingService.patchStatusSuspend(PatchStatusRequest(status)) + + override suspend fun updateStatus(status: String): Response { + return onboardingService.patchStatus(PatchStatusRequest(status)) } } \ No newline at end of file diff --git a/app/src/main/java/com/toyou/toyouandroid/domain/record/IRecordRepository.kt b/app/src/main/java/com/toyou/toyouandroid/domain/record/IRecordRepository.kt new file mode 100644 index 00000000..918940ef --- /dev/null +++ b/app/src/main/java/com/toyou/toyouandroid/domain/record/IRecordRepository.kt @@ -0,0 +1,19 @@ +package com.toyou.toyouandroid.domain.record + +import com.toyou.toyouandroid.data.home.dto.response.CardDetail +import com.toyou.toyouandroid.data.record.dto.DeleteDiaryCardResponse +import com.toyou.toyouandroid.data.record.dto.DiaryCardNumResponse +import com.toyou.toyouandroid.data.record.dto.DiaryCardPerDayResponse +import com.toyou.toyouandroid.data.record.dto.DiaryCardResponse +import com.toyou.toyouandroid.data.record.dto.PatchDiaryCardResponse +import com.toyou.toyouandroid.network.BaseResponse +import retrofit2.Response + +interface IRecordRepository { + suspend fun getMyRecord(year: Int, month: Int): Response + suspend fun getFriendRecordNum(year: Int, month: Int): Response + suspend fun getFriendRecordPerDay(year: Int, month: Int, day: Int): Response + suspend fun deleteDiaryCard(cardId: Int): Response + suspend fun patchDiaryCard(cardId: Int): Response + suspend fun getCardDetails(cardId: Long): BaseResponse +} diff --git a/app/src/main/java/com/toyou/toyouandroid/domain/record/RecordRepository.kt b/app/src/main/java/com/toyou/toyouandroid/domain/record/RecordRepository.kt index 293ba084..0fdbe341 100644 --- a/app/src/main/java/com/toyou/toyouandroid/domain/record/RecordRepository.kt +++ b/app/src/main/java/com/toyou/toyouandroid/domain/record/RecordRepository.kt @@ -8,34 +8,36 @@ import com.toyou.toyouandroid.data.record.dto.DiaryCardResponse import com.toyou.toyouandroid.data.record.dto.PatchDiaryCardResponse import com.toyou.toyouandroid.data.record.service.RecordService import com.toyou.toyouandroid.network.BaseResponse -import retrofit2.Call +import retrofit2.Response import javax.inject.Inject import javax.inject.Singleton @Singleton -class RecordRepository @Inject constructor(private val recordService: RecordService) { +class RecordRepositoryImpl @Inject constructor( + private val recordService: RecordService +) : IRecordRepository { - fun getMyRecord(year: Int, month: Int): Call { + override suspend fun getMyRecord(year: Int, month: Int): Response { return recordService.getDiarycardsMine(year, month) } - fun getFriendRecordNum(year: Int, month: Int): Call { + override suspend fun getFriendRecordNum(year: Int, month: Int): Response { return recordService.getDiarycardsNumFriend(year, month) } - fun getFriendRecordPerDay(year: Int, month: Int, day: Int): Call { + override suspend fun getFriendRecordPerDay(year: Int, month: Int, day: Int): Response { return recordService.getDiarycardsPerDayFriend(year, month, day) } - fun deleteDiaryCard(cardId: Int): Call { + override suspend fun deleteDiaryCard(cardId: Int): Response { return recordService.deleteDiarycard(cardId) } - fun patchDiaryCard(cardId: Int): Call { + override suspend fun patchDiaryCard(cardId: Int): Response { return recordService.patchDiarycardExposure(cardId) } - suspend fun getCardDetails(cardId: Long): BaseResponse { + override suspend fun getCardDetails(cardId: Long): BaseResponse { return recordService.getCardDetail(cardId) } } \ No newline at end of file diff --git a/app/src/main/java/com/toyou/toyouandroid/domain/social/repostitory/ISocialRepository.kt b/app/src/main/java/com/toyou/toyouandroid/domain/social/repostitory/ISocialRepository.kt new file mode 100644 index 00000000..6ee72273 --- /dev/null +++ b/app/src/main/java/com/toyou/toyouandroid/domain/social/repostitory/ISocialRepository.kt @@ -0,0 +1,17 @@ +package com.toyou.toyouandroid.domain.social.repostitory + +import com.toyou.toyouandroid.data.social.dto.request.QuestionDto +import com.toyou.toyouandroid.data.social.dto.request.RequestFriend +import com.toyou.toyouandroid.data.social.dto.response.FriendsDto +import com.toyou.toyouandroid.data.social.dto.response.ResponseFriend +import com.toyou.toyouandroid.data.social.dto.response.SearchFriendDto +import com.toyou.toyouandroid.network.BaseResponse + +interface ISocialRepository { + suspend fun getFriendsData(): BaseResponse + suspend fun getSearchData(name: String): BaseResponse + suspend fun patchApproveFriend(request: RequestFriend): BaseResponse + suspend fun deleteFriendData(request: RequestFriend): BaseResponse + suspend fun postRequest(request: RequestFriend): BaseResponse + suspend fun postQuestionData(questionDto: QuestionDto): BaseResponse +} diff --git a/app/src/main/java/com/toyou/toyouandroid/domain/social/repostitory/SocialRepository.kt b/app/src/main/java/com/toyou/toyouandroid/domain/social/repostitory/SocialRepository.kt index ad913d8e..86926a15 100644 --- a/app/src/main/java/com/toyou/toyouandroid/domain/social/repostitory/SocialRepository.kt +++ b/app/src/main/java/com/toyou/toyouandroid/domain/social/repostitory/SocialRepository.kt @@ -11,29 +11,31 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class SocialRepository @Inject constructor(private val socialService: SocialService) { +class SocialRepositoryImpl @Inject constructor( + private val socialService: SocialService +) : ISocialRepository { - suspend fun getFriendsData(): BaseResponse { + override suspend fun getFriendsData(): BaseResponse { return socialService.getFriends() } - suspend fun getSearchData(name : String): BaseResponse { + override suspend fun getSearchData(name: String): BaseResponse { return socialService.getSearchFriend(name) } - suspend fun patchApproveFriend(request: RequestFriend): BaseResponse { + override suspend fun patchApproveFriend(request: RequestFriend): BaseResponse { return socialService.patchApprove(request) } - suspend fun deleteFriendData(request: RequestFriend): BaseResponse { + override suspend fun deleteFriendData(request: RequestFriend): BaseResponse { return socialService.deleteFriend(request) } - suspend fun postRequest(request: RequestFriend): BaseResponse { + override suspend fun postRequest(request: RequestFriend): BaseResponse { return socialService.postFriendRequest(request) } - suspend fun postQuestionData(questionDto: QuestionDto): BaseResponse { + override suspend fun postQuestionData(questionDto: QuestionDto): BaseResponse { return socialService.postQuestion(questionDto) } } \ No newline at end of file diff --git a/app/src/main/java/com/toyou/toyouandroid/fcm/MyFirebaseMessagingService.kt b/app/src/main/java/com/toyou/toyouandroid/fcm/MyFirebaseMessagingService.kt index 2f4e179b..41926da1 100644 --- a/app/src/main/java/com/toyou/toyouandroid/fcm/MyFirebaseMessagingService.kt +++ b/app/src/main/java/com/toyou/toyouandroid/fcm/MyFirebaseMessagingService.kt @@ -6,7 +6,6 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent -import android.content.SharedPreferences import android.content.pm.PackageManager import android.os.Build import androidx.core.app.NotificationCompat @@ -14,31 +13,47 @@ import androidx.core.content.ContextCompat import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage +import com.toyou.core.datastore.NotificationPreferences +import com.toyou.core.datastore.TokenStorage import com.toyou.toyouandroid.R import com.toyou.toyouandroid.presentation.base.MainActivity -import com.toyou.toyouandroid.utils.TokenStorage +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent import timber.log.Timber class MyFirebaseMessagingService : FirebaseMessagingService() { - private lateinit var tokenStorage: TokenStorage - private var sharedPreferences: SharedPreferences? = null + @EntryPoint + @InstallIn(SingletonComponent::class) + interface MyFirebaseMessagingServiceEntryPoint { + fun tokenStorage(): TokenStorage + fun notificationPreferences(): NotificationPreferences + } + + private val tokenStorage: TokenStorage by lazy { + EntryPointAccessors.fromApplication( + applicationContext, + MyFirebaseMessagingServiceEntryPoint::class.java + ).tokenStorage() + } - override fun onCreate() { - super.onCreate() - tokenStorage = TokenStorage(applicationContext) - sharedPreferences = getSharedPreferences("FCM_PREFERENCES", Context.MODE_PRIVATE) + private val notificationPreferences: NotificationPreferences by lazy { + EntryPointAccessors.fromApplication( + applicationContext, + MyFirebaseMessagingServiceEntryPoint::class.java + ).notificationPreferences() } override fun onNewToken(token: String) { super.onNewToken(token) Timber.d("FCM 토큰: %s", token) - tokenStorage.saveFcmToken(token) + tokenStorage.saveFcmTokenSync(token) Timber.d("토큰이 저장되었습니다: %s", token) // 구독 여부가 저장되어 있으면 구독 - val isSubscribed = sharedPreferences?.getBoolean("isSubscribed", true) - if (isSubscribed == true) { + if (notificationPreferences.isSubscribed()) { subscribeToTopic() } } @@ -48,7 +63,7 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { .addOnCompleteListener { task -> if (task.isSuccessful) { Timber.d("topic 구독 성공") - sharedPreferences?.edit()?.putBoolean("isSubscribed", true)?.apply() + notificationPreferences.setSubscribedSync(true) } else { Timber.e(task.exception, "구독 실패") } @@ -60,7 +75,7 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { .addOnCompleteListener { task -> if (task.isSuccessful) { Timber.d("topic 구독 취소 성공") - sharedPreferences?.edit()?.putBoolean("isSubscribed", false)?.apply() + notificationPreferences.setSubscribedSync(false) } else { Timber.e(task.exception, "구독 취소 실패") } @@ -131,4 +146,4 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { private const val CHANNEL_ID = "FCM__channel_id" } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/toyou/toyouandroid/fcm/domain/FCMRepository.kt b/app/src/main/java/com/toyou/toyouandroid/fcm/domain/FCMRepository.kt index ac24931e..7441f1ce 100644 --- a/app/src/main/java/com/toyou/toyouandroid/fcm/domain/FCMRepository.kt +++ b/app/src/main/java/com/toyou/toyouandroid/fcm/domain/FCMRepository.kt @@ -9,24 +9,23 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class FCMRepository @Inject constructor(private val fcmService: FCMService) { +class FCMRepositoryImpl @Inject constructor( + private val fcmService: FCMService +) : IFCMRepository { - //private val client = AuthNetworkModule.getClient().create(FCMService::class.java) - - - suspend fun postToken(token: Token): BaseResponse { + override suspend fun postToken(token: Token): BaseResponse { return fcmService.postToken(token) } - suspend fun getToken(id:Long): BaseResponse { + + override suspend fun getToken(id: Long): BaseResponse { return fcmService.getToken(id) } - - suspend fun postFCM(request: FCM): BaseResponse { + override suspend fun postFCM(request: FCM): BaseResponse { return fcmService.postFCM(request) } - suspend fun patchToken(request: Token): BaseResponse { + override suspend fun patchToken(request: Token): BaseResponse { return fcmService.patchToken(request) } /*suspend fun postToken( diff --git a/app/src/main/java/com/toyou/toyouandroid/fcm/domain/IFCMRepository.kt b/app/src/main/java/com/toyou/toyouandroid/fcm/domain/IFCMRepository.kt new file mode 100644 index 00000000..9b85a42a --- /dev/null +++ b/app/src/main/java/com/toyou/toyouandroid/fcm/domain/IFCMRepository.kt @@ -0,0 +1,13 @@ +package com.toyou.toyouandroid.fcm.domain + +import com.toyou.toyouandroid.fcm.dto.request.FCM +import com.toyou.toyouandroid.fcm.dto.request.Token +import com.toyou.toyouandroid.fcm.dto.response.GetToken +import com.toyou.toyouandroid.network.BaseResponse + +interface IFCMRepository { + suspend fun postToken(token: Token): BaseResponse + suspend fun getToken(id: Long): BaseResponse + suspend fun postFCM(request: FCM): BaseResponse + suspend fun patchToken(request: Token): BaseResponse +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/emotionstamp/HomeOptionContract.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/emotionstamp/HomeOptionContract.kt new file mode 100644 index 00000000..48539ec6 --- /dev/null +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/emotionstamp/HomeOptionContract.kt @@ -0,0 +1,20 @@ +package com.toyou.toyouandroid.presentation.fragment.emotionstamp + +import com.toyou.core.common.mvi.UiAction +import com.toyou.core.common.mvi.UiEvent +import com.toyou.core.common.mvi.UiState +import com.toyou.toyouandroid.data.emotion.dto.EmotionResponse + +data class HomeOptionUiState( + val emotionResponse: EmotionResponse? = null, + val isLoading: Boolean = false +) : UiState + +sealed interface HomeOptionEvent : UiEvent { + data class ShowError(val message: String) : HomeOptionEvent + data object TokenExpired : HomeOptionEvent +} + +sealed interface HomeOptionAction : UiAction { + data class UpdateEmotion(val emotionRequest: com.toyou.toyouandroid.data.emotion.dto.EmotionRequest) : HomeOptionAction +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/emotionstamp/HomeOptionFragment.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/emotionstamp/HomeOptionFragment.kt index aad90a05..26d3ae5d 100644 --- a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/emotionstamp/HomeOptionFragment.kt +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/emotionstamp/HomeOptionFragment.kt @@ -170,11 +170,10 @@ class HomeOptionFragment : Fragment() { putString("text", getString(emotionTitleResId)) // 선택한 텍스트 전달 } - homeViewModel.updateHomeEmotion( - emotionData.homeEmotionDrawable.toString(), -// emotionData.homeEmotionTitle, -// emotionData.homeColorRes, -// emotionData.backgroundDrawable + homeViewModel.onAction( + com.toyou.toyouandroid.presentation.fragment.home.HomeAction.UpdateEmotion( + emotionData.homeEmotionDrawable.toString() + ) ) // 감정 우표 선택 API 호출 diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/emotionstamp/HomeOptionViewModel.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/emotionstamp/HomeOptionViewModel.kt index c62f153c..9dd002a6 100644 --- a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/emotionstamp/HomeOptionViewModel.kt +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/emotionstamp/HomeOptionViewModel.kt @@ -2,13 +2,15 @@ package com.toyou.toyouandroid.presentation.fragment.emotionstamp import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.toyou.core.common.mvi.MviViewModel import com.toyou.toyouandroid.data.emotion.dto.EmotionRequest import com.toyou.toyouandroid.data.emotion.dto.EmotionResponse import com.toyou.toyouandroid.data.emotion.service.EmotionService import com.toyou.toyouandroid.utils.TokenManager import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -17,7 +19,9 @@ import javax.inject.Inject class HomeOptionViewModel @Inject constructor( private val emotionService: EmotionService, private val tokenManager: TokenManager -) : ViewModel() { +) : MviViewModel( + initialState = HomeOptionUiState() +) { private val _emotionResponse = MutableLiveData() val emotionResponse: LiveData get() = _emotionResponse @@ -28,36 +32,57 @@ class HomeOptionViewModel @Inject constructor( private val _errorMessage = MutableLiveData() val errorMessage: LiveData get() = _errorMessage - fun updateEmotion(emotionRequest: EmotionRequest) { + init { + state.onEach { uiState -> + uiState.emotionResponse?.let { _emotionResponse.value = it } + _isLoading.value = uiState.isLoading + }.launchIn(viewModelScope) + } + + override fun handleAction(action: HomeOptionAction) { + when (action) { + is HomeOptionAction.UpdateEmotion -> performUpdateEmotion(action.emotionRequest) + } + } + + private fun performUpdateEmotion(emotionRequest: EmotionRequest) { viewModelScope.launch { - _isLoading.value = true + updateState { copy(isLoading = true) } _errorMessage.value = null - + try { - val response = emotionService.patchEmotionSuspend(emotionRequest) + val response = emotionService.patchEmotion(emotionRequest) if (response.isSuccessful) { - _emotionResponse.postValue(response.body()) + response.body()?.let { body -> + updateState { copy(emotionResponse = body, isLoading = false) } + } Timber.tag("emotionResponse").d("emotionResponse: $response") } else { Timber.tag("API Error").e("Failed to update emotion. Code: ${response.code()}, Message: ${response.message()}") if (response.code() == 401) { tokenManager.refreshToken( - onSuccess = { updateEmotion(emotionRequest) }, - onFailure = { + onSuccess = { performUpdateEmotion(emotionRequest) }, + onFailure = { Timber.e("Failed to refresh token and update emotion") + updateState { copy(isLoading = false) } _errorMessage.value = "인증 실패. 다시 로그인해주세요." + sendEvent(HomeOptionEvent.TokenExpired) } ) } else { + updateState { copy(isLoading = false) } _errorMessage.value = "감정 업데이트에 실패했습니다." + sendEvent(HomeOptionEvent.ShowError("감정 업데이트에 실패했습니다.")) } } } catch (e: Exception) { Timber.tag("API Failure").e(e, "Error occurred during API call") + updateState { copy(isLoading = false) } _errorMessage.value = "네트워크 오류가 발생했습니다." - } finally { - _isLoading.value = false + sendEvent(HomeOptionEvent.ShowError("네트워크 오류가 발생했습니다.")) } } } -} \ No newline at end of file + + fun updateEmotion(emotionRequest: EmotionRequest) = onAction(HomeOptionAction.UpdateEmotion(emotionRequest)) +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/home/HomeFragment.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/home/HomeFragment.kt index 2db53b3c..c700e4e2 100644 --- a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/home/HomeFragment.kt +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/home/HomeFragment.kt @@ -12,20 +12,22 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.navigation.NavController +import androidx.navigation.findNavController import androidx.recyclerview.widget.GridLayoutManager import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.toyou.core.common.mvi.collectEvent +import com.toyou.core.common.mvi.collectState import com.toyou.toyouandroid.R import com.toyou.toyouandroid.databinding.FragmentHomeBinding -import com.toyou.toyouandroid.presentation.base.MainActivity import com.toyou.toyouandroid.data.home.dto.response.YesterdayCard +import com.toyou.toyouandroid.presentation.base.MainActivity import com.toyou.toyouandroid.presentation.fragment.home.adapter.HomeBottomSheetAdapter import com.toyou.toyouandroid.presentation.fragment.notice.NoticeViewModel -import dagger.hilt.android.AndroidEntryPoint import com.toyou.toyouandroid.presentation.fragment.record.CardInfoViewModel import com.toyou.toyouandroid.presentation.viewmodel.CardViewModel import com.toyou.toyouandroid.presentation.viewmodel.UserViewModel +import dagger.hilt.android.AndroidEntryPoint import timber.log.Timber -import androidx.navigation.findNavController @AndroidEntryPoint class HomeFragment : Fragment() { @@ -86,9 +88,27 @@ class HomeFragment : Fragment() { (requireActivity() as MainActivity).hideBottomNavigation(false) + // MVI: State 수집 + viewLifecycleOwner.collectState(viewModel.state) { state -> + // UI 상태 업데이트는 여기서 처리 + Timber.tag("HomeFragment").d("State updated: $state") + } + + // MVI: Event 수집 + viewLifecycleOwner.collectEvent(viewModel.event) { event -> + when (event) { + is HomeEvent.ShowError -> { + Toast.makeText(requireContext(), event.message, Toast.LENGTH_SHORT).show() + } + is HomeEvent.TokenExpired -> { + // Token 만료 처리 (로그인 화면으로 이동 등) + Timber.tag("HomeFragment").d("Token expired") + } + } + } - //일기카드 조회 - viewModel.getYesterdayCard() + // MVI: Action 발행 - 일기카드 조회 + viewModel.onAction(HomeAction.LoadYesterdayCards) // 질문 개수에 따른 우체통 이미지 변경 diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/home/HomeUiState.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/home/HomeUiState.kt index c2c9b921..11d4bcc9 100644 --- a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/home/HomeUiState.kt +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/home/HomeUiState.kt @@ -1,5 +1,8 @@ package com.toyou.toyouandroid.presentation.fragment.home +import com.toyou.core.common.mvi.UiAction +import com.toyou.core.common.mvi.UiEvent +import com.toyou.core.common.mvi.UiState import com.toyou.toyouandroid.data.emotion.dto.DiaryCard import com.toyou.toyouandroid.data.home.dto.response.YesterdayCard @@ -10,4 +13,15 @@ data class HomeUiState( val yesterdayCards: List = emptyList(), val isLoading: Boolean = false, val isEmpty: Boolean = false -) \ No newline at end of file +) : UiState + +sealed interface HomeEvent : UiEvent { + data class ShowError(val message: String) : HomeEvent + data object TokenExpired : HomeEvent +} + +sealed interface HomeAction : UiAction { + data object LoadYesterdayCards : HomeAction + data class UpdateEmotion(val emotionText: String) : HomeAction + data object ResetState : HomeAction +} \ No newline at end of file diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/home/HomeViewModel.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/home/HomeViewModel.kt index 6e27eeec..bec45fbb 100644 --- a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/home/HomeViewModel.kt +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/home/HomeViewModel.kt @@ -1,10 +1,8 @@ package com.toyou.toyouandroid.presentation.fragment.home -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.toyou.toyouandroid.domain.home.repository.HomeRepository +import com.toyou.core.common.mvi.MviViewModel +import com.toyou.toyouandroid.domain.home.repository.IHomeRepository import com.toyou.toyouandroid.utils.TokenManager import com.toyou.toyouandroid.utils.calendar.getCurrentDate import dagger.hilt.android.lifecycle.HiltViewModel @@ -14,66 +12,75 @@ import javax.inject.Inject @HiltViewModel class HomeViewModel @Inject constructor( - private val homeRepository: HomeRepository, + private val homeRepository: IHomeRepository, private val tokenManager: TokenManager -) : ViewModel() { +) : MviViewModel( + initialState = HomeUiState(currentDate = getCurrentDate()) +) { - private val _uiState = MutableLiveData(HomeUiState()) - val uiState: LiveData get() = _uiState - - init { - _uiState.value = _uiState.value?.copy( - currentDate = getCurrentDate() - ) + override fun handleAction(action: HomeAction) { + when (action) { + is HomeAction.LoadYesterdayCards -> loadYesterdayCards() + is HomeAction.UpdateEmotion -> updateEmotion(action.emotionText) + is HomeAction.ResetState -> resetState() + } } - fun updateHomeEmotion(emotionText: String) { - _uiState.value = _uiState.value?.copy( - emotionText = emotionText - ) + private fun updateEmotion(emotionText: String) { + updateState { copy(emotionText = emotionText) } } - fun resetState() { - _uiState.value = _uiState.value?.copy( - emotionText = "멘트", - diaryCards = null, - yesterdayCards = emptyList() - ) + private fun resetState() { + updateState { + copy( + emotionText = "멘트", + diaryCards = null, + yesterdayCards = emptyList() + ) + } } - fun getYesterdayCard() { + private fun loadYesterdayCards() { viewModelScope.launch { - _uiState.value = _uiState.value?.copy(isLoading = true) + updateState { copy(isLoading = true) } try { val response = homeRepository.getYesterdayCard() Timber.tag("HomeViewModel").d("yesterdayCards: ${response.result}") if (response.isSuccess) { - _uiState.value = _uiState.value?.copy( - yesterdayCards = response.result.yesterday, - isLoading = false, - isEmpty = response.result.yesterday.isEmpty() - ) + updateState { + copy( + yesterdayCards = response.result.yesterday, + isLoading = false, + isEmpty = response.result.yesterday.isEmpty() + ) + } Timber.tag("HomeViewModel").d("yesterdayCards: ${response.result.yesterday}") } else { - _uiState.value = _uiState.value?.copy(isLoading = false) + updateState { copy(isLoading = false) } tokenManager.refreshToken( - onSuccess = { getYesterdayCard() }, - onFailure = { + onSuccess = { loadYesterdayCards() }, + onFailure = { Timber.tag("HomeViewModel").d("refresh error") - _uiState.value = _uiState.value?.copy( - isLoading = false, - yesterdayCards = emptyList() - ) + updateState { + copy( + isLoading = false, + yesterdayCards = emptyList() + ) + } + sendEvent(HomeEvent.TokenExpired) } ) } } catch (e: Exception) { Timber.tag("HomeViewModel").e(e, "Error getting yesterday cards") - _uiState.value = _uiState.value?.copy( - yesterdayCards = emptyList(), - isLoading = false, - isEmpty = true - ) + updateState { + copy( + yesterdayCards = emptyList(), + isLoading = false, + isEmpty = true + ) + } + sendEvent(HomeEvent.ShowError(e.message ?: "Unknown error")) } } } diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/MypageContract.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/MypageContract.kt new file mode 100644 index 00000000..a4e0a783 --- /dev/null +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/MypageContract.kt @@ -0,0 +1,24 @@ +package com.toyou.toyouandroid.presentation.fragment.mypage + +import com.toyou.core.common.mvi.UiAction +import com.toyou.core.common.mvi.UiEvent +import com.toyou.core.common.mvi.UiState + +data class MypageUiState( + val userId: Int? = null, + val nickname: String? = null, + val status: String? = null, + val friendNum: Int? = null, + val isLoading: Boolean = false +) : UiState + +sealed interface MypageEvent : UiEvent { + data class LogoutResult(val success: Boolean) : MypageEvent + data class SignOutResult(val success: Boolean) : MypageEvent +} + +sealed interface MypageAction : UiAction { + data object LoadMypage : MypageAction + data object Logout : MypageAction + data object SignOut : MypageAction +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/MypageDialogContract.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/MypageDialogContract.kt new file mode 100644 index 00000000..abae6fa6 --- /dev/null +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/MypageDialogContract.kt @@ -0,0 +1,36 @@ +package com.toyou.toyouandroid.presentation.fragment.mypage + +import com.toyou.core.common.mvi.UiAction +import com.toyou.core.common.mvi.UiEvent +import com.toyou.core.common.mvi.UiState + +data class MypageDialogUiState( + val title: String = "", + val subTitle: String? = null, + val leftButtonText: String = "", + val rightButtonText: String = "", + val leftButtonTextColor: Int = 0, + val rightButtonTextColor: Int = 0, + val leftButtonClickAction: (() -> Unit)? = null, + val rightButtonClickAction: (() -> Unit)? = null +) : UiState + +sealed interface MypageDialogEvent : UiEvent { + data object LeftButtonClicked : MypageDialogEvent + data object RightButtonClicked : MypageDialogEvent +} + +sealed interface MypageDialogAction : UiAction { + data class SetDialogData( + val title: String, + val subTitle: String?, + val leftButtonText: String, + val rightButtonText: String, + val leftButtonTextColor: Int, + val rightButtonTextColor: Int, + val leftButtonClickAction: () -> Unit, + val rightButtonClickAction: () -> Unit + ) : MypageDialogAction + data object LeftButtonClick : MypageDialogAction + data object RightButtonClick : MypageDialogAction +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/MypageDialogViewModel.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/MypageDialogViewModel.kt index 0d40615a..e552202a 100644 --- a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/MypageDialogViewModel.kt +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/MypageDialogViewModel.kt @@ -2,12 +2,17 @@ package com.toyou.toyouandroid.presentation.fragment.mypage import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.toyou.core.common.mvi.MviViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import javax.inject.Inject @HiltViewModel -class MypageDialogViewModel @Inject constructor() : ViewModel() { +class MypageDialogViewModel @Inject constructor() : MviViewModel( + MypageDialogUiState() +) { private val _title = MutableLiveData() val title: LiveData get() = _title @@ -26,8 +31,67 @@ class MypageDialogViewModel @Inject constructor() : ViewModel() { private val _rightButtonTextColor = MutableLiveData() val rightButtonTextColor: LiveData get() = _rightButtonTextColor - private val _leftButtonClickAction = MutableLiveData<() -> Unit>() - private val _rightButtonClickAction = MutableLiveData<() -> Unit>() + init { + state.onEach { uiState -> + _title.value = uiState.title + _subTitle.value = uiState.subTitle + _leftButtonText.value = uiState.leftButtonText + _rightButtonText.value = uiState.rightButtonText + _leftButtonTextColor.value = uiState.leftButtonTextColor + _rightButtonTextColor.value = uiState.rightButtonTextColor + }.launchIn(viewModelScope) + } + + override fun handleAction(action: MypageDialogAction) { + when (action) { + is MypageDialogAction.SetDialogData -> performSetDialogData( + action.title, + action.subTitle, + action.leftButtonText, + action.rightButtonText, + action.leftButtonTextColor, + action.rightButtonTextColor, + action.leftButtonClickAction, + action.rightButtonClickAction + ) + is MypageDialogAction.LeftButtonClick -> performLeftButtonClick() + is MypageDialogAction.RightButtonClick -> performRightButtonClick() + } + } + + private fun performSetDialogData( + title: String, + subTitle: String?, + leftButtonText: String, + rightButtonText: String, + leftButtonTextColor: Int, + rightButtonTextColor: Int, + leftButtonClickAction: () -> Unit, + rightButtonClickAction: () -> Unit + ) { + updateState { + copy( + title = title, + subTitle = subTitle, + leftButtonText = leftButtonText, + rightButtonText = rightButtonText, + leftButtonTextColor = leftButtonTextColor, + rightButtonTextColor = rightButtonTextColor, + leftButtonClickAction = leftButtonClickAction, + rightButtonClickAction = rightButtonClickAction + ) + } + } + + private fun performLeftButtonClick() { + currentState.leftButtonClickAction?.invoke() + sendEvent(MypageDialogEvent.LeftButtonClicked) + } + + private fun performRightButtonClick() { + currentState.rightButtonClickAction?.invoke() + sendEvent(MypageDialogEvent.RightButtonClicked) + } fun setDialogData( title: String, @@ -39,21 +103,25 @@ class MypageDialogViewModel @Inject constructor() : ViewModel() { leftButtonClickAction: () -> Unit, rightButtonClickAction: () -> Unit ) { - _title.value = title - _subTitle.value = subTitle - _leftButtonText.value = leftButtonText - _rightButtonText.value = rightButtonText - _leftButtonTextColor.value = leftButtonTextColor - _rightButtonTextColor.value = rightButtonTextColor - _leftButtonClickAction.value = leftButtonClickAction - _rightButtonClickAction.value = rightButtonClickAction + onAction( + MypageDialogAction.SetDialogData( + title, + subTitle, + leftButtonText, + rightButtonText, + leftButtonTextColor, + rightButtonTextColor, + leftButtonClickAction, + rightButtonClickAction + ) + ) } fun onLeftButtonClick() { - _leftButtonClickAction.value?.invoke() + onAction(MypageDialogAction.LeftButtonClick) } fun onRightButtonClick() { - _rightButtonClickAction.value?.invoke() + onAction(MypageDialogAction.RightButtonClick) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/MypageFragment.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/MypageFragment.kt index 6dab2ead..e2226b80 100644 --- a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/MypageFragment.kt +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/MypageFragment.kt @@ -1,9 +1,7 @@ package com.toyou.toyouandroid.presentation.fragment.mypage import android.content.ContentValues.TAG -import android.content.Context import android.content.Intent -import android.content.SharedPreferences import android.graphics.Color import android.os.Bundle import android.view.LayoutInflater @@ -21,8 +19,10 @@ import com.toyou.toyouandroid.presentation.fragment.onboarding.SignupNicknameVie import com.toyou.toyouandroid.presentation.fragment.home.HomeViewModel import com.toyou.toyouandroid.presentation.fragment.record.CalendarDialogViewModel import com.toyou.toyouandroid.presentation.viewmodel.ViewModelManager -import com.toyou.toyouandroid.utils.TutorialStorage +import com.toyou.core.datastore.TutorialStorage +import com.toyou.core.datastore.NotificationPreferences import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import timber.log.Timber import androidx.navigation.findNavController import androidx.core.net.toUri @@ -46,7 +46,11 @@ class MypageFragment : Fragment() { private val homeViewModel: HomeViewModel by viewModels() - private var sharedPreferences: SharedPreferences? = null + @Inject + lateinit var tutorialStorage: TutorialStorage + + @Inject + lateinit var notificationPreferences: NotificationPreferences private val mypageViewModel: MypageViewModel by viewModels() @@ -59,8 +63,6 @@ class MypageFragment : Fragment() { // MypageViewModel과 HomeViewModel은 Hilt로 주입됨 - sharedPreferences = requireActivity().getSharedPreferences("FCM_PREFERENCES", Context.MODE_PRIVATE) - return binding.root } @@ -187,8 +189,8 @@ class MypageFragment : Fragment() { } } - TutorialStorage(requireContext()).setTutorialNotShown() - sharedPreferences?.edit()?.putBoolean("isSubscribed", true)?.apply() + tutorialStorage.setTutorialNotShownSync() + notificationPreferences.setSubscribedSync(true) mypageViewModel.kakaoSignOut() mypageDialog?.dismiss() diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/MypageUiState.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/MypageUiState.kt deleted file mode 100644 index 364adc4e..00000000 --- a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/MypageUiState.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.toyou.toyouandroid.presentation.fragment.mypage - -data class MypageUiState( - val userId: Int? = null, - val nickname: String? = null, - val status: String? = null, - val friendNum: Int? = null, - val isLoading: Boolean = false -) \ No newline at end of file diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/MypageViewModel.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/MypageViewModel.kt index ad1f4a2f..df793c69 100644 --- a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/MypageViewModel.kt +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/MypageViewModel.kt @@ -2,12 +2,12 @@ package com.toyou.toyouandroid.presentation.fragment.mypage import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.toyou.core.common.mvi.MviViewModel import com.toyou.toyouandroid.data.mypage.service.MypageService import com.toyou.toyouandroid.data.onboarding.service.AuthService import com.toyou.toyouandroid.utils.TokenManager -import com.toyou.toyouandroid.utils.TokenStorage +import com.toyou.core.datastore.TokenStorage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import timber.log.Timber @@ -19,10 +19,10 @@ class MypageViewModel @Inject constructor( private val mypageService: MypageService, private val tokenStorage: TokenStorage, private val tokenManager: TokenManager -) : ViewModel() { +) : MviViewModel(MypageUiState()) { - private val _uiState = MutableLiveData(MypageUiState()) - val uiState: LiveData get() = _uiState + private val _uiStateLiveData = MutableLiveData(MypageUiState()) + val uiState: LiveData get() = _uiStateLiveData private val _logoutSuccess = MutableLiveData() val logoutSuccess: LiveData get() = _logoutSuccess @@ -30,15 +30,31 @@ class MypageViewModel @Inject constructor( private val _signOutSuccess = MutableLiveData() val signOutSuccess: LiveData get() = _signOutSuccess - fun setLogoutSuccess(value: Boolean) { - _logoutSuccess.value = value + init { + viewModelScope.launch { + state.collect { newState -> + _uiStateLiveData.value = newState + } + } + viewModelScope.launch { + event.collect { event -> + when (event) { + is MypageEvent.LogoutResult -> _logoutSuccess.value = event.success + is MypageEvent.SignOutResult -> _signOutSuccess.value = event.success + } + } + } } - fun setSignOutSuccess(value: Boolean) { - _signOutSuccess.value = value + override fun handleAction(action: MypageAction) { + when (action) { + is MypageAction.LoadMypage -> performUpdateMypage() + is MypageAction.Logout -> performKakaoLogout() + is MypageAction.SignOut -> performKakaoSignOut() + } } - fun kakaoLogout() { + private fun performKakaoLogout() { viewModelScope.launch { val refreshToken = tokenStorage.getRefreshToken().toString() val accessToken = tokenStorage.getAccessToken().toString() @@ -46,35 +62,35 @@ class MypageViewModel @Inject constructor( Timber.d("accessToken: $accessToken") try { - val response = authService.logoutSuspend(refreshToken) + val response = authService.logout(refreshToken) if (response.isSuccessful) { Timber.i("Logout successfully") - _logoutSuccess.value = true + sendEvent(MypageEvent.LogoutResult(true)) tokenStorage.clearTokens() } else { val errorMessage = response.errorBody()?.string() ?: "Unknown error: ${response.message()}" Timber.e("API Error: $errorMessage") - + if (response.code() == 401) { tokenManager.refreshToken( - onSuccess = { kakaoLogout() }, - onFailure = { + onSuccess = { performKakaoLogout() }, + onFailure = { Timber.e("Failed to refresh token and kakao logout") - _logoutSuccess.value = false + sendEvent(MypageEvent.LogoutResult(false)) } ) } else { - _logoutSuccess.value = false + sendEvent(MypageEvent.LogoutResult(false)) } } } catch (e: Exception) { Timber.e("Network Failure: ${e.message}") - _logoutSuccess.value = false + sendEvent(MypageEvent.LogoutResult(false)) } } } - fun kakaoSignOut() { + private fun performKakaoSignOut() { viewModelScope.launch { val refreshToken = tokenStorage.getRefreshToken().toString() val accessToken = tokenStorage.getAccessToken().toString() @@ -82,60 +98,82 @@ class MypageViewModel @Inject constructor( Timber.d("accessToken: $accessToken") try { - val response = authService.signOutSuspend(refreshToken) + val response = authService.signOut(refreshToken) if (response.isSuccessful) { Timber.i("SignOut successfully") - _signOutSuccess.value = true + sendEvent(MypageEvent.SignOutResult(true)) tokenStorage.clearTokens() } else { val errorMessage = response.errorBody()?.string() ?: "Unknown error" Timber.e("API Error: $errorMessage") tokenManager.refreshToken( - onSuccess = { kakaoSignOut() }, - onFailure = { + onSuccess = { performKakaoSignOut() }, + onFailure = { Timber.e("Failed to refresh token and kakao signout") - _signOutSuccess.value = false + sendEvent(MypageEvent.SignOutResult(false)) } ) } } catch (e: Exception) { Timber.e("Network Failure: ${e.message}") - _signOutSuccess.value = false + sendEvent(MypageEvent.SignOutResult(false)) } } } - fun updateMypage() { + private fun performUpdateMypage() { viewModelScope.launch { - _uiState.value = _uiState.value?.copy(isLoading = true) + updateState { copy(isLoading = true) } try { - val response = mypageService.getMypageSuspend() + val response = mypageService.getMypage() if (response.isSuccessful) { response.body()?.result?.let { result -> - _uiState.value = MypageUiState( - userId = result.userId, - nickname = result.nickname, - status = result.status, - friendNum = result.friendNum, - isLoading = false - ) + updateState { + copy( + userId = result.userId, + nickname = result.nickname, + status = result.status, + friendNum = result.friendNum, + isLoading = false + ) + } Timber.tag("updateMypage").d("Mypage updated: $result") } } else { Timber.tag("API Error").e("Failed to update Mypage. Code: ${response.code()}, Message: ${response.message()}") - _uiState.value = _uiState.value?.copy(isLoading = false) + updateState { copy(isLoading = false) } tokenManager.refreshToken( - onSuccess = { updateMypage() }, - onFailure = { + onSuccess = { performUpdateMypage() }, + onFailure = { Timber.e("Failed to refresh token and get mypage") - _uiState.value = _uiState.value?.copy(isLoading = false) + updateState { copy(isLoading = false) } } ) } } catch (e: Exception) { Timber.tag("API Failure").e(e, "Error occurred during API call") - _uiState.value = _uiState.value?.copy(isLoading = false) + updateState { copy(isLoading = false) } } } } -} \ No newline at end of file + + fun setLogoutSuccess(value: Boolean) { + _logoutSuccess.value = value + } + + fun setSignOutSuccess(value: Boolean) { + _signOutSuccess.value = value + } + + fun kakaoLogout() { + onAction(MypageAction.Logout) + } + + fun kakaoSignOut() { + onAction(MypageAction.SignOut) + } + + fun updateMypage() { + onAction(MypageAction.LoadMypage) + } +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/NoticeSettingFragment.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/NoticeSettingFragment.kt index 5e5aae5b..eb5f34a8 100644 --- a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/NoticeSettingFragment.kt +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/NoticeSettingFragment.kt @@ -1,7 +1,5 @@ package com.toyou.toyouandroid.presentation.fragment.mypage -import android.content.Context -import android.content.SharedPreferences import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -10,11 +8,13 @@ import android.widget.Toast import androidx.fragment.app.Fragment import androidx.navigation.NavController import androidx.navigation.Navigation +import com.toyou.core.datastore.NotificationPreferences import com.toyou.toyouandroid.R import com.toyou.toyouandroid.databinding.FragmentNoticeSettingBinding import com.toyou.toyouandroid.fcm.MyFirebaseMessagingService import dagger.hilt.android.AndroidEntryPoint import timber.log.Timber +import javax.inject.Inject @AndroidEntryPoint class NoticeSettingFragment : Fragment() { @@ -26,7 +26,9 @@ class NoticeSettingFragment : Fragment() { get() = requireNotNull(_binding){"FragmentNoticeSettingBinding -> null"} private lateinit var myFirebaseMessagingService: MyFirebaseMessagingService - private lateinit var sharedPreferences: SharedPreferences + + @Inject + lateinit var notificationPreferences: NotificationPreferences override fun onCreateView( inflater: LayoutInflater, @@ -48,11 +50,9 @@ class NoticeSettingFragment : Fragment() { } myFirebaseMessagingService = MyFirebaseMessagingService() - sharedPreferences = - context?.getSharedPreferences("FCM_PREFERENCES", Context.MODE_PRIVATE) ?: return - // 기존 구독 상태를 SharedPreferences에서 불러오기 - val isSubscribed = sharedPreferences.getBoolean("isSubscribed", false) + // 기존 구독 상태를 DataStore에서 불러오기 + val isSubscribed = notificationPreferences.isSubscribed() // SwitchCompat 초기 상태 설정 binding.noticeToggle.isChecked = isSubscribed @@ -70,7 +70,7 @@ class NoticeSettingFragment : Fragment() { Toast.makeText(context, "알림 수신을 거부하였습니다", Toast.LENGTH_SHORT).show() } - sharedPreferences.edit().putBoolean("isSubscribed", isChecked).apply() + notificationPreferences.setSubscribedSync(isChecked) } } @@ -78,4 +78,4 @@ class NoticeSettingFragment : Fragment() { super.onDestroyView() _binding = null } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/ProfileContract.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/ProfileContract.kt new file mode 100644 index 00000000..af28ab00 --- /dev/null +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/ProfileContract.kt @@ -0,0 +1,52 @@ +package com.toyou.toyouandroid.presentation.fragment.mypage + +import com.toyou.core.common.mvi.UiAction +import com.toyou.core.common.mvi.UiEvent +import com.toyou.core.common.mvi.UiState + +data class ProfileUiState( + val title: String = "회원가입", + val textCount: String = "0/15", + val nickname: String = "", + val status: String = "", + val isDuplicateCheckEnabled: Boolean = false, + val isNextButtonEnabled: Boolean = false, + val duplicateCheckMessage: String = "중복된 닉네임인지 확인해주세요", + val isNicknameValid: Boolean = false, + val selectedStatusType: StatusType? = null +) : UiState + +sealed interface ProfileEvent : UiEvent { + data object NicknameChangedSuccess : ProfileEvent + data class DuplicateCheckMessageChanged(val type: DuplicateCheckMessageType) : ProfileEvent +} + +sealed interface ProfileAction : UiAction { + data class UpdateTextCount(val count: Int) : ProfileAction + data class SetNickname(val nickname: String) : ProfileAction + data class UpdateLength15(val length: Int) : ProfileAction + data object DuplicateBtnActivate : ProfileAction + data object ResetNicknameEditState : ProfileAction + data class CheckDuplicate(val userNickname: String, val userId: Int) : ProfileAction + data object ChangeNickname : ProfileAction + data object ChangeStatus : ProfileAction + data class OnStatusButtonClicked(val statusType: StatusType) : ProfileAction +} + +enum class StatusType(val value: String) { + SCHOOL("SCHOOL"), + COLLEGE("COLLEGE"), + OFFICE("OFFICE"), + ETC("ETC") +} + +enum class DuplicateCheckMessageType(val message: String) { + CHECK_REQUIRED("중복된 닉네임인지 확인해주세요"), + LENGTH_EXCEEDED("15자 이내로 입력해주세요."), + AVAILABLE("사용 가능한 닉네임입니다."), + ALREADY_IN_USE("이미 사용 중인 닉네임입니다."), + ALREADY_IN_USE_SAME("이미 사용 중인 닉네임입니다."), + CHECK_FAILED("닉네임 확인에 실패했습니다."), + UPDATE_FAILED("닉네임 변경에 실패했습니다."), + SERVER_ERROR("서버에 연결할 수 없습니다.") +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/ProfileUiState.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/ProfileUiState.kt deleted file mode 100644 index 8cde6d90..00000000 --- a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/ProfileUiState.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.toyou.toyouandroid.presentation.fragment.mypage - -data class ProfileUiState( - val title: String = "회원가입", - val textCount: String = "0/15", - val nickname: String = "", - val status: String = "", - val isDuplicateCheckEnabled: Boolean = false, - val isNextButtonEnabled: Boolean = false, - val duplicateCheckMessage: String = "중복된 닉네임인지 확인해주세요", - val isNicknameValid: Boolean = false, - val selectedStatusType: StatusType? = null -) - -enum class StatusType(val value: String) { - SCHOOL("SCHOOL"), - COLLEGE("COLLEGE"), - OFFICE("OFFICE"), - ETC("ETC") -} \ No newline at end of file diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/ProfileViewModel.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/ProfileViewModel.kt index fa9a5ac3..b2564554 100644 --- a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/ProfileViewModel.kt +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/mypage/ProfileViewModel.kt @@ -2,188 +2,239 @@ package com.toyou.toyouandroid.presentation.fragment.mypage import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.toyou.toyouandroid.domain.profile.repository.ProfileRepository +import com.toyou.core.common.mvi.MviViewModel +import com.toyou.toyouandroid.domain.profile.repository.IProfileRepository import com.toyou.toyouandroid.utils.TokenManager import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @HiltViewModel class ProfileViewModel @Inject constructor( - private val profileRepository: ProfileRepository, + private val profileRepository: IProfileRepository, private val tokenManager: TokenManager -) : ViewModel() { - - private val _uiState = MutableLiveData(ProfileUiState()) - val uiState: LiveData get() = _uiState - - // 데이터 바인딩 호환성을 위한 프로퍼티 - val textCount: LiveData = MutableLiveData("0/15") - val nickname: LiveData = MutableLiveData() - val duplicateCheckButtonTextColor: LiveData = MutableLiveData(0xFFA6A6A6.toInt()) - val duplicateCheckButtonBackground: LiveData = MutableLiveData(com.toyou.toyouandroid.R.drawable.next_button) - val duplicateCheckMessage: LiveData = MutableLiveData("중복된 닉네임인지 확인해주세요") - val duplicateCheckMessageColor: LiveData = MutableLiveData(0xFF000000.toInt()) - val nextButtonBackground: LiveData = MutableLiveData(com.toyou.toyouandroid.R.drawable.next_button) - val nextButtonTextColor: LiveData = MutableLiveData(0xFFA6A6A6.toInt()) - val isNextButtonEnabled: LiveData = MutableLiveData(false) - +) : MviViewModel(ProfileUiState()) { + + private val _textCount = MutableLiveData("0/15") + val textCount: LiveData get() = _textCount + + private val _nickname = MutableLiveData() + val nickname: LiveData get() = _nickname + + private val _duplicateCheckButtonTextColor = MutableLiveData(0xFFA6A6A6.toInt()) + val duplicateCheckButtonTextColor: LiveData get() = _duplicateCheckButtonTextColor + + private val _duplicateCheckButtonBackground = MutableLiveData(com.toyou.toyouandroid.R.drawable.next_button) + val duplicateCheckButtonBackground: LiveData get() = _duplicateCheckButtonBackground + + private val _duplicateCheckMessage = MutableLiveData("중복된 닉네임인지 확인해주세요") + val duplicateCheckMessage: LiveData get() = _duplicateCheckMessage + + private val _duplicateCheckMessageColor = MutableLiveData(0xFF000000.toInt()) + val duplicateCheckMessageColor: LiveData get() = _duplicateCheckMessageColor + + private val _nextButtonBackground = MutableLiveData(com.toyou.toyouandroid.R.drawable.next_button) + val nextButtonBackground: LiveData get() = _nextButtonBackground + + private val _nextButtonTextColor = MutableLiveData(0xFFA6A6A6.toInt()) + val nextButtonTextColor: LiveData get() = _nextButtonTextColor + + private val _isNextButtonEnabled = MutableLiveData(false) + val isNextButtonEnabled: LiveData get() = _isNextButtonEnabled + private val _nicknameChangedSuccess = MutableLiveData() val nicknameChangedSuccess: LiveData get() = _nicknameChangedSuccess - + private val _duplicateCheckMessageType = MutableLiveData() val duplicateCheckMessageType: LiveData get() = _duplicateCheckMessageType - + + @Deprecated("Use state instead", ReplaceWith("state")) + private val _uiState = MutableLiveData(ProfileUiState()) + @Deprecated("Use state instead", ReplaceWith("state")) + val uiState: LiveData get() = _uiState + init { - _uiState.value = ProfileUiState(title = "회원가입") + updateState { copy(title = "회원가입") } + performSyncLegacyLiveData() } - - fun updateTextCount(count: Int) { + + override fun handleAction(action: ProfileAction) { + when (action) { + is ProfileAction.UpdateTextCount -> performUpdateTextCount(action.count) + is ProfileAction.SetNickname -> performSetNickname(action.nickname) + is ProfileAction.UpdateLength15 -> performUpdateLength15(action.length) + is ProfileAction.DuplicateBtnActivate -> performDuplicateBtnActivate() + is ProfileAction.ResetNicknameEditState -> performResetNicknameEditState() + is ProfileAction.CheckDuplicate -> performCheckDuplicate(action.userNickname, action.userId) + is ProfileAction.ChangeNickname -> performChangeNickname() + is ProfileAction.ChangeStatus -> performChangeStatus() + is ProfileAction.OnStatusButtonClicked -> performOnStatusButtonClicked(action.statusType) + } + } + + private fun performSyncLegacyLiveData() { + state.onEach { uiState -> + _uiState.value = uiState + _textCount.value = uiState.textCount + _nickname.value = uiState.nickname + _duplicateCheckMessage.value = uiState.duplicateCheckMessage + _isNextButtonEnabled.value = uiState.isNextButtonEnabled + }.launchIn(viewModelScope) + } + + private fun performUpdateTextCount(count: Int) { val countText = "($count/15)" - _uiState.value = _uiState.value?.copy( - textCount = countText - ) - (textCount as MutableLiveData).value = countText + updateState { copy(textCount = countText) } } - - fun setNickname(newNickname: String) { - _uiState.value = _uiState.value?.copy( - nickname = newNickname, - isDuplicateCheckEnabled = newNickname.isNotEmpty() - ) - (nickname as MutableLiveData).value = newNickname + + private fun performSetNickname(newNickname: String) { + updateState { + copy( + nickname = newNickname, + isDuplicateCheckEnabled = newNickname.isNotEmpty() + ) + } } - - fun updateLength15(length: Int) { + + private fun performUpdateLength15(length: Int) { val messageType = if (length >= 15) { DuplicateCheckMessageType.LENGTH_EXCEEDED } else { DuplicateCheckMessageType.CHECK_REQUIRED } + sendEvent(ProfileEvent.DuplicateCheckMessageChanged(messageType)) _duplicateCheckMessageType.value = messageType - _uiState.value = _uiState.value?.copy( - duplicateCheckMessage = messageType.message - ) + updateState { copy(duplicateCheckMessage = messageType.message) } } - - fun duplicateBtnActivate() { - _uiState.value = _uiState.value?.copy( - isDuplicateCheckEnabled = true - ) - (duplicateCheckButtonTextColor as MutableLiveData).value = 0xFF000000.toInt() - (duplicateCheckButtonBackground as MutableLiveData).value = com.toyou.toyouandroid.R.drawable.signupnickname_doublecheck_activate + + private fun performDuplicateBtnActivate() { + updateState { copy(isDuplicateCheckEnabled = true) } + _duplicateCheckButtonTextColor.value = 0xFF000000.toInt() + _duplicateCheckButtonBackground.value = com.toyou.toyouandroid.R.drawable.signupnickname_doublecheck_activate } - - fun resetNicknameEditState() { - _uiState.value = _uiState.value?.copy( - duplicateCheckMessage = DuplicateCheckMessageType.CHECK_REQUIRED.message, - isNextButtonEnabled = false, - isNicknameValid = false - ) + + private fun performResetNicknameEditState() { + updateState { + copy( + duplicateCheckMessage = DuplicateCheckMessageType.CHECK_REQUIRED.message, + isNextButtonEnabled = false, + isNicknameValid = false + ) + } + sendEvent(ProfileEvent.DuplicateCheckMessageChanged(DuplicateCheckMessageType.CHECK_REQUIRED)) _duplicateCheckMessageType.value = DuplicateCheckMessageType.CHECK_REQUIRED } - - fun checkDuplicate(userNickname: String, userId: Int) { - val nickname = _uiState.value?.nickname ?: return - + + private fun performCheckDuplicate(userNickname: String, userId: Int) { + val nickname = currentState.nickname + if (nickname.isEmpty()) return + viewModelScope.launch { try { val response = profileRepository.checkNickname(nickname, userId) if (response.isSuccessful) { val exists = response.body()?.result?.exists ?: false - handleNicknameCheckResult(exists, userNickname, nickname) + performHandleNicknameCheckResult(exists, userNickname, nickname) } else { - handleNicknameCheckError() + performHandleNicknameCheckError() tokenManager.refreshToken( - onSuccess = { checkDuplicate(userNickname, userId) }, + onSuccess = { performCheckDuplicate(userNickname, userId) }, onFailure = { Timber.e("Failed to refresh token and check nickname") } ) } } catch (e: Exception) { Timber.tag("API Failure").e(e, "Error checking nickname") + sendEvent(ProfileEvent.DuplicateCheckMessageChanged(DuplicateCheckMessageType.SERVER_ERROR)) _duplicateCheckMessageType.value = DuplicateCheckMessageType.SERVER_ERROR - _uiState.value = _uiState.value?.copy( - duplicateCheckMessage = DuplicateCheckMessageType.SERVER_ERROR.message, - isNicknameValid = false, - isNextButtonEnabled = false - ) + updateState { + copy( + duplicateCheckMessage = DuplicateCheckMessageType.SERVER_ERROR.message, + isNicknameValid = false, + isNextButtonEnabled = false + ) + } } } } - - private fun handleNicknameCheckResult(exists: Boolean, userNickname: String, nickname: String) { + + private fun performHandleNicknameCheckResult(exists: Boolean, userNickname: String, nickname: String) { val messageType = when { !exists -> DuplicateCheckMessageType.AVAILABLE userNickname == nickname -> DuplicateCheckMessageType.ALREADY_IN_USE_SAME else -> DuplicateCheckMessageType.ALREADY_IN_USE } - + + sendEvent(ProfileEvent.DuplicateCheckMessageChanged(messageType)) _duplicateCheckMessageType.value = messageType val isValid = !exists || (userNickname == nickname) - - _uiState.value = _uiState.value?.copy( - duplicateCheckMessage = messageType.message, - isNicknameValid = isValid, - isNextButtonEnabled = isValid - ) - - // 호환성 프로퍼티 업데이트 - (duplicateCheckMessage as MutableLiveData).value = messageType.message - (duplicateCheckMessageColor as MutableLiveData).value = when { + + updateState { + copy( + duplicateCheckMessage = messageType.message, + isNicknameValid = isValid, + isNextButtonEnabled = isValid + ) + } + + _duplicateCheckMessageColor.value = when { !exists -> 0xFFEA9797.toInt() else -> 0xFFFF0000.toInt() } - (isNextButtonEnabled as MutableLiveData).value = isValid if (isValid) { - (nextButtonTextColor as MutableLiveData).value = 0xFF000000.toInt() - (nextButtonBackground as MutableLiveData).value = com.toyou.toyouandroid.R.drawable.next_button_enabled + _nextButtonTextColor.value = 0xFF000000.toInt() + _nextButtonBackground.value = com.toyou.toyouandroid.R.drawable.next_button_enabled } } - - private fun handleNicknameCheckError() { + + private fun performHandleNicknameCheckError() { + sendEvent(ProfileEvent.DuplicateCheckMessageChanged(DuplicateCheckMessageType.CHECK_FAILED)) _duplicateCheckMessageType.value = DuplicateCheckMessageType.CHECK_FAILED - _uiState.value = _uiState.value?.copy( - duplicateCheckMessage = DuplicateCheckMessageType.CHECK_FAILED.message, - isNicknameValid = false, - isNextButtonEnabled = false - ) + updateState { + copy( + duplicateCheckMessage = DuplicateCheckMessageType.CHECK_FAILED.message, + isNicknameValid = false, + isNextButtonEnabled = false + ) + } } - - fun changeNickname() { - val nickname = _uiState.value?.nickname ?: return - + + private fun performChangeNickname() { + val nickname = currentState.nickname + if (nickname.isEmpty()) return + viewModelScope.launch { try { val response = profileRepository.updateNickname(nickname) if (response.isSuccessful) { _nicknameChangedSuccess.postValue(true) + sendEvent(ProfileEvent.NicknameChangedSuccess) Timber.tag("changeNickname").d("$response") } else { + sendEvent(ProfileEvent.DuplicateCheckMessageChanged(DuplicateCheckMessageType.UPDATE_FAILED)) _duplicateCheckMessageType.value = DuplicateCheckMessageType.UPDATE_FAILED - _uiState.value = _uiState.value?.copy( - duplicateCheckMessage = DuplicateCheckMessageType.UPDATE_FAILED.message - ) + updateState { copy(duplicateCheckMessage = DuplicateCheckMessageType.UPDATE_FAILED.message) } tokenManager.refreshToken( - onSuccess = { changeNickname() }, + onSuccess = { performChangeNickname() }, onFailure = { Timber.e("Failed to refresh token and update nickname") } ) } } catch (e: Exception) { Timber.tag("API Failure").e(e, "Error updating nickname") + sendEvent(ProfileEvent.DuplicateCheckMessageChanged(DuplicateCheckMessageType.SERVER_ERROR)) _duplicateCheckMessageType.value = DuplicateCheckMessageType.SERVER_ERROR - _uiState.value = _uiState.value?.copy( - duplicateCheckMessage = DuplicateCheckMessageType.SERVER_ERROR.message - ) + updateState { copy(duplicateCheckMessage = DuplicateCheckMessageType.SERVER_ERROR.message) } } } } - - fun changeStatus() { - val status = _uiState.value?.status ?: return - + + private fun performChangeStatus() { + val status = currentState.status + if (status.isEmpty()) return + viewModelScope.launch { try { val response = profileRepository.updateStatus(status) @@ -191,7 +242,7 @@ class ProfileViewModel @Inject constructor( Timber.tag("changeStatus").d("${response.body()}") } else { tokenManager.refreshToken( - onSuccess = { changeStatus() }, + onSuccess = { performChangeStatus() }, onFailure = { Timber.e("Failed to refresh token and update status") } ) } @@ -200,25 +251,61 @@ class ProfileViewModel @Inject constructor( } } } - + + private fun performOnStatusButtonClicked(statusType: StatusType) { + if (currentState.selectedStatusType == statusType) return + + updateState { + copy( + selectedStatusType = statusType, + status = statusType.value, + isNextButtonEnabled = true + ) + } + } + + @Deprecated("Use onAction(ProfileAction.UpdateTextCount) instead") + fun updateTextCount(count: Int) { + onAction(ProfileAction.UpdateTextCount(count)) + } + + @Deprecated("Use onAction(ProfileAction.SetNickname) instead") + fun setNickname(newNickname: String) { + onAction(ProfileAction.SetNickname(newNickname)) + } + + @Deprecated("Use onAction(ProfileAction.UpdateLength15) instead") + fun updateLength15(length: Int) { + onAction(ProfileAction.UpdateLength15(length)) + } + + @Deprecated("Use onAction(ProfileAction.DuplicateBtnActivate) instead") + fun duplicateBtnActivate() { + onAction(ProfileAction.DuplicateBtnActivate) + } + + @Deprecated("Use onAction(ProfileAction.ResetNicknameEditState) instead") + fun resetNicknameEditState() { + onAction(ProfileAction.ResetNicknameEditState) + } + + @Deprecated("Use onAction(ProfileAction.CheckDuplicate) instead") + fun checkDuplicate(userNickname: String, userId: Int) { + onAction(ProfileAction.CheckDuplicate(userNickname, userId)) + } + + @Deprecated("Use onAction(ProfileAction.ChangeNickname) instead") + fun changeNickname() { + onAction(ProfileAction.ChangeNickname) + } + + @Deprecated("Use onAction(ProfileAction.ChangeStatus) instead") + fun changeStatus() { + onAction(ProfileAction.ChangeStatus) + } + + @Deprecated("Use onAction(ProfileAction.OnStatusButtonClicked) instead") fun onStatusButtonClicked(statusType: StatusType) { - if (_uiState.value?.selectedStatusType == statusType) return - - _uiState.value = _uiState.value?.copy( - selectedStatusType = statusType, - status = statusType.value, - isNextButtonEnabled = true - ) + onAction(ProfileAction.OnStatusButtonClicked(statusType)) } } - -enum class DuplicateCheckMessageType(val message: String) { - CHECK_REQUIRED("중복된 닉네임인지 확인해주세요"), - LENGTH_EXCEEDED("15자 이내로 입력해주세요."), - AVAILABLE("사용 가능한 닉네임입니다."), - ALREADY_IN_USE("이미 사용 중인 닉네임입니다."), - ALREADY_IN_USE_SAME("이미 사용 중인 닉네임입니다."), - CHECK_FAILED("닉네임 확인에 실패했습니다."), - UPDATE_FAILED("닉네임 변경에 실패했습니다."), - SERVER_ERROR("서버에 연결할 수 없습니다.") -} \ No newline at end of file diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/notice/NoticeContract.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/notice/NoticeContract.kt new file mode 100644 index 00000000..5e9b255f --- /dev/null +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/notice/NoticeContract.kt @@ -0,0 +1,26 @@ +package com.toyou.toyouandroid.presentation.fragment.notice + +import com.toyou.core.common.mvi.UiAction +import com.toyou.core.common.mvi.UiEvent +import com.toyou.core.common.mvi.UiState + +data class NoticeUiState( + val noticeItems: List = emptyList(), + val generalNotices: List = emptyList(), + val friendRequestNotices: List = emptyList(), + val hasNotifications: Boolean = false, + val isLoading: Boolean = false +) : UiState + +sealed interface NoticeEvent : UiEvent { + data class ShowError(val message: String) : NoticeEvent + data object TokenExpired : NoticeEvent + data class NoticeDeleted(val alarmId: Int, val position: Int) : NoticeEvent + data class NoticeDeleteFailed(val alarmId: Int, val position: Int) : NoticeEvent +} + +sealed interface NoticeAction : UiAction { + data object FetchNotices : NoticeAction + data object FetchFriendsRequestNotices : NoticeAction + data class DeleteNotice(val alarmId: Int, val position: Int) : NoticeAction +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/notice/NoticeDialogContract.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/notice/NoticeDialogContract.kt new file mode 100644 index 00000000..13a090ff --- /dev/null +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/notice/NoticeDialogContract.kt @@ -0,0 +1,24 @@ +package com.toyou.toyouandroid.presentation.fragment.notice + +import com.toyou.core.common.mvi.UiAction +import com.toyou.core.common.mvi.UiEvent +import com.toyou.core.common.mvi.UiState + +data class NoticeDialogUiState( + val title: String = "", + val leftButtonText: String = "", + val leftButtonClickAction: (() -> Unit)? = null +) : UiState + +sealed interface NoticeDialogEvent : UiEvent { + data object LeftButtonClicked : NoticeDialogEvent +} + +sealed interface NoticeDialogAction : UiAction { + data class SetDialogData( + val title: String, + val leftButtonText: String, + val leftButtonClickAction: () -> Unit + ) : NoticeDialogAction + data object LeftButtonClick : NoticeDialogAction +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/notice/NoticeDialogViewModel.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/notice/NoticeDialogViewModel.kt index 7adf669c..18ade641 100644 --- a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/notice/NoticeDialogViewModel.kt +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/notice/NoticeDialogViewModel.kt @@ -2,31 +2,75 @@ package com.toyou.toyouandroid.presentation.fragment.notice import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.toyou.core.common.mvi.MviViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import javax.inject.Inject @HiltViewModel -class NoticeDialogViewModel @Inject constructor() : ViewModel() { +class NoticeDialogViewModel @Inject constructor() : MviViewModel( + NoticeDialogUiState() +) { private val _title = MutableLiveData() val title: LiveData get() = _title private val _leftButtonText = MutableLiveData() val leftButtonText: LiveData get() = _leftButtonText - private val _leftButtonClickAction = MutableLiveData<() -> Unit>() + init { + state.onEach { uiState -> + _title.value = uiState.title + _leftButtonText.value = uiState.leftButtonText + }.launchIn(viewModelScope) + } + + override fun handleAction(action: NoticeDialogAction) { + when (action) { + is NoticeDialogAction.SetDialogData -> performSetDialogData( + action.title, + action.leftButtonText, + action.leftButtonClickAction + ) + is NoticeDialogAction.LeftButtonClick -> performLeftButtonClick() + } + } + + private fun performSetDialogData( + title: String, + leftButtonText: String, + leftButtonClickAction: () -> Unit + ) { + updateState { + copy( + title = title, + leftButtonText = leftButtonText, + leftButtonClickAction = leftButtonClickAction + ) + } + } + + private fun performLeftButtonClick() { + currentState.leftButtonClickAction?.invoke() + sendEvent(NoticeDialogEvent.LeftButtonClicked) + } fun setDialogData( title: String, leftButtonText: String, - leftButtonClickAction: () -> Unit, + leftButtonClickAction: () -> Unit ) { - _title.value = title - _leftButtonText.value = leftButtonText - _leftButtonClickAction.value = leftButtonClickAction + onAction( + NoticeDialogAction.SetDialogData( + title, + leftButtonText, + leftButtonClickAction + ) + ) } fun onLeftButtonClick() { - _leftButtonClickAction.value?.invoke() + onAction(NoticeDialogAction.LeftButtonClick) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/notice/NoticeViewModel.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/notice/NoticeViewModel.kt index f76014c0..7b6305b5 100644 --- a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/notice/NoticeViewModel.kt +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/notice/NoticeViewModel.kt @@ -2,158 +2,170 @@ package com.toyou.toyouandroid.presentation.fragment.notice import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.toyou.toyouandroid.data.notice.dto.AlarmDeleteResponse -import com.toyou.toyouandroid.data.notice.dto.AlarmResponse -import com.toyou.toyouandroid.data.notice.dto.FriendsRequestResponse -import com.toyou.toyouandroid.domain.notice.NoticeRepository +import com.toyou.core.common.mvi.MviViewModel +import com.toyou.toyouandroid.domain.notice.INoticeRepository import com.toyou.toyouandroid.utils.TokenManager import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response import timber.log.Timber import javax.inject.Inject @HiltViewModel class NoticeViewModel @Inject constructor( - private val repository: NoticeRepository, + private val repository: INoticeRepository, private val tokenManager: TokenManager -) : ViewModel() { +) : MviViewModel( + initialState = NoticeUiState() +) { private val _noticeItems = MutableLiveData>(emptyList()) val noticeItems: LiveData> get() = _noticeItems - // 일반 알림 전용 LiveData private val _generalNotices = MutableLiveData>(emptyList()) val generalNotices: LiveData> get() = _generalNotices - // 친구 요청 알림 전용 LiveData private val _friendRequestNotices = MutableLiveData>(emptyList()) val friendRequestNotices: LiveData> get() = _friendRequestNotices private val _hasNotifications = MutableLiveData() val hasNotifications: LiveData get() = _hasNotifications - fun fetchNotices() { + init { + state.onEach { uiState -> + _noticeItems.value = uiState.noticeItems + _generalNotices.value = uiState.generalNotices + _friendRequestNotices.value = uiState.friendRequestNotices + _hasNotifications.value = uiState.hasNotifications + }.launchIn(viewModelScope) + } + + override fun handleAction(action: NoticeAction) { + when (action) { + is NoticeAction.FetchNotices -> performFetchNotices() + is NoticeAction.FetchFriendsRequestNotices -> performFetchFriendsRequestNotices() + is NoticeAction.DeleteNotice -> performDeleteNotice(action.alarmId, action.position) + } + } + + private fun performFetchNotices() { viewModelScope.launch { - val response = repository.getNotices() - response.enqueue(object : Callback { - override fun onResponse( - call: Call, - response: Response - ) { - if (response.isSuccessful) { - val alarmResponse = response.body() - Timber.d("AlarmResponse: $alarmResponse") - val items = alarmResponse?.result?.alarmList?.mapNotNull { alarm -> - when (alarm.alarmType) { - "FRIEND_REQUEST" -> NoticeItem.NoticeFriendRequestItem( - alarm.nickname, - alarm.alarmId - ) - "REQUEST_ACCEPTED" -> NoticeItem.NoticeFriendRequestAcceptedItem( - alarm.nickname, - alarm.alarmId - ) - "NEW_QUESTION" -> NoticeItem.NoticeCardCheckItem( - alarm.nickname, - alarm.alarmId - ) - else -> null - } - }?.reversed() ?: emptyList() + updateState { copy(isLoading = true) } + try { + val response = repository.getNotices() + if (response.isSuccessful) { + val alarmResponse = response.body() + Timber.d("AlarmResponse: $alarmResponse") + val items = alarmResponse?.result?.alarmList?.mapNotNull { alarm -> + when (alarm.alarmType) { + "FRIEND_REQUEST" -> NoticeItem.NoticeFriendRequestItem( + alarm.nickname, + alarm.alarmId + ) + "REQUEST_ACCEPTED" -> NoticeItem.NoticeFriendRequestAcceptedItem( + alarm.nickname, + alarm.alarmId + ) + "NEW_QUESTION" -> NoticeItem.NoticeCardCheckItem( + alarm.nickname, + alarm.alarmId + ) + else -> null + } + }?.reversed() ?: emptyList() - _hasNotifications.value = items.isNotEmpty() - _generalNotices.value = items - } else { - tokenManager.refreshToken( - onSuccess = { fetchNotices() }, // 토큰 갱신 후 다시 요청 - onFailure = { Timber.e("Failed to refresh token and get notices") } + updateState { + copy( + generalNotices = items, + hasNotifications = items.isNotEmpty(), + isLoading = false ) } + } else { + updateState { copy(isLoading = false) } + tokenManager.refreshToken( + onSuccess = { performFetchNotices() }, + onFailure = { Timber.e("Failed to refresh token and get notices") } + ) } - - override fun onFailure(call: Call, t: Throwable) { - // 에러 처리 로직 - } - }) + } catch (e: Exception) { + updateState { copy(isLoading = false) } + sendEvent(NoticeEvent.ShowError(e.message ?: "Unknown error")) + } } } - fun fetchFriendsRequestNotices() { + private fun performFetchFriendsRequestNotices() { viewModelScope.launch { - val response = repository.getFriendsRequestNotices() - response.enqueue(object : Callback { - override fun onResponse( - call: Call, - response: Response - ) { - if (response.isSuccessful) { - val friendsRequestResponse = response.body() - Timber.d("FriendsRequestResponse: $friendsRequestResponse") - val items = friendsRequestResponse?.result?.friendsRequestList?.map { friendRequest -> - NoticeItem.NoticeFriendRequestItem( - nickname = friendRequest.nickname, - alarmId = friendRequest.userId // 혹은 API에서 제공하는 고유한 알림 식별자 사용 - ) - }?.reversed() ?: emptyList() + updateState { copy(isLoading = true) } + try { + val response = repository.getFriendsRequestNotices() + if (response.isSuccessful) { + val friendsRequestResponse = response.body() + Timber.d("FriendsRequestResponse: $friendsRequestResponse") + val items = friendsRequestResponse?.result?.friendsRequestList?.map { friendRequest -> + NoticeItem.NoticeFriendRequestItem( + nickname = friendRequest.nickname, + alarmId = friendRequest.userId + ) + }?.reversed() ?: emptyList() - _hasNotifications.value = items.isNotEmpty() - _friendRequestNotices.value = items - } else { - tokenManager.refreshToken( - onSuccess = { fetchFriendsRequestNotices() }, // 토큰 갱신 후 다시 요청 - onFailure = { Timber.e("Failed to refresh token and get notices") } + updateState { + copy( + friendRequestNotices = items, + hasNotifications = items.isNotEmpty(), + isLoading = false ) } + } else { + updateState { copy(isLoading = false) } + tokenManager.refreshToken( + onSuccess = { performFetchFriendsRequestNotices() }, + onFailure = { Timber.e("Failed to refresh token and get notices") } + ) } - - override fun onFailure(call: Call, t: Throwable) { - TODO("Not yet implemented") - } - }) + } catch (e: Exception) { + updateState { copy(isLoading = false) } + sendEvent(NoticeEvent.ShowError(e.message ?: "Unknown error")) + } } } - fun deleteNotice(alarmId: Int, position: Int, retryCount: Int = 0) { - val maxRetries = 5 // 최대 재시도 횟수 + private fun performDeleteNotice(alarmId: Int, position: Int, retryCount: Int = 0) { + val maxRetries = 5 viewModelScope.launch { - val response = repository.deleteNotice(alarmId) - response.enqueue(object : Callback { - override fun onResponse( - call: Call, - response: Response - ) { - if (response.isSuccessful) { -// val updatedList = _noticeItems.value?.toMutableList()?.apply { -// removeAt(position) -// } -// _noticeItems.value = updatedList!! + try { + val response = repository.deleteNotice(alarmId) + if (response.isSuccessful) { + sendEvent(NoticeEvent.NoticeDeleted(alarmId, position)) + } else { + if (retryCount < maxRetries) { + tokenManager.refreshToken( + onSuccess = { + Timber.d("Retrying token refresh... ($retryCount/$maxRetries)") + performDeleteNotice(alarmId, position, retryCount + 1) + }, + onFailure = { + Timber.e("Failed to refresh token after $retryCount retries") + sendEvent(NoticeEvent.NoticeDeleteFailed(alarmId, position)) + } + ) } else { - if (retryCount < maxRetries) { - tokenManager.refreshToken( - onSuccess = { - Timber.d("Retrying token refresh... ($retryCount/$maxRetries)") - deleteNotice(alarmId, position, retryCount + 1) // 재호출 - }, - onFailure = { - Timber.e("Failed to refresh token after $retryCount retries") - } - ) - } else { - Timber.e("Max token refresh retries reached ($maxRetries)") - } + Timber.e("Max token refresh retries reached ($maxRetries)") + sendEvent(NoticeEvent.NoticeDeleteFailed(alarmId, position)) } } - - override fun onFailure(call: Call, t: Throwable) { - Timber.e("Delete notice API call failed: ${t.message}") - } - }) + } catch (e: Exception) { + Timber.e("Delete notice API call failed: ${e.message}") + sendEvent(NoticeEvent.NoticeDeleteFailed(alarmId, position)) + } } } + + fun fetchNotices() = onAction(NoticeAction.FetchNotices) + fun fetchFriendsRequestNotices() = onAction(NoticeAction.FetchFriendsRequestNotices) + fun deleteNotice(alarmId: Int, position: Int) = onAction(NoticeAction.DeleteNotice(alarmId, position)) } diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/LoginContract.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/LoginContract.kt new file mode 100644 index 00000000..a3a80d84 --- /dev/null +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/LoginContract.kt @@ -0,0 +1,36 @@ +package com.toyou.toyouandroid.presentation.fragment.onboarding + +import com.toyou.core.common.mvi.UiAction +import com.toyou.core.common.mvi.UiEvent +import com.toyou.core.common.mvi.UiState +import com.toyou.toyouandroid.data.onboarding.dto.request.SignUpRequest + +data class LoginUiState( + val loginSuccess: Boolean = false, + val checkIfTokenExists: Boolean = false, + val isInitialization: Boolean = false, + val oAuthAccessToken: String = "", + val navigationEvent: Boolean? = null +) : UiState + +sealed interface LoginEvent : UiEvent { + data object LoginSucceeded : LoginEvent + data object LoginFailed : LoginEvent + data object SignUpSucceeded : LoginEvent + data object SignUpFailed : LoginEvent + data object ReissueSucceeded : LoginEvent + data object ReissueFailed : LoginEvent + data class ShowError(val message: String) : LoginEvent +} + +sealed interface LoginAction : UiAction { + data class SetLoginSuccess(val value: Boolean) : LoginAction + data class SetIfTokenExists(val value: Boolean) : LoginAction + data class SetInitialization(val value: Boolean) : LoginAction + data object CheckIfTokenExists : LoginAction + data class KakaoLogin(val accessToken: String) : LoginAction + data class SetOAuthAccessToken(val oAuthAccessToken: String) : LoginAction + data class SignUp(val signUpRequest: SignUpRequest) : LoginAction + data class ReissueJWT(val refreshToken: String) : LoginAction + data class PatchFcm(val token: String) : LoginAction +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/LoginFragment.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/LoginFragment.kt index a6db3728..c89bf44b 100644 --- a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/LoginFragment.kt +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/LoginFragment.kt @@ -19,7 +19,8 @@ import com.kakao.sdk.user.UserApiClient import com.toyou.toyouandroid.R import com.toyou.toyouandroid.databinding.FragmentLoginBinding import com.toyou.toyouandroid.presentation.base.MainActivity -import com.toyou.toyouandroid.utils.TutorialStorage +import com.toyou.core.datastore.TutorialStorage +import javax.inject.Inject import dagger.hilt.android.AndroidEntryPoint import timber.log.Timber @@ -31,7 +32,8 @@ class LoginFragment : Fragment() { private val binding: FragmentLoginBinding get() = requireNotNull(_binding){"FragmentLoginBinding -> null"} - private lateinit var tutorialStorage: TutorialStorage + @Inject + lateinit var tutorialStorage: TutorialStorage private val loginViewModel: LoginViewModel by activityViewModels() @@ -42,8 +44,6 @@ class LoginFragment : Fragment() { ): View { _binding = FragmentLoginBinding.inflate(inflater, container, false) - tutorialStorage = TutorialStorage(requireContext()) - return binding.root } @@ -134,7 +134,7 @@ class LoginFragment : Fragment() { private fun checkTutorial() { if (!tutorialStorage.isTutorialShown()) { navController.navigate(R.id.action_navigation_login_to_tutorial_fragment) - tutorialStorage.setTutorialShown() + tutorialStorage.setTutorialShownSync() } else { // 튜토리얼을 본 적이 있으면 홈 화면으로 바로 이동 // 액세스 토큰이 있으면 홈 화면으로 이동 diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/LoginViewModel.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/LoginViewModel.kt index d95eacb8..a1b91a72 100644 --- a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/LoginViewModel.kt +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/LoginViewModel.kt @@ -2,24 +2,21 @@ package com.toyou.toyouandroid.presentation.fragment.onboarding import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.toyou.core.common.mvi.MviViewModel import com.toyou.toyouandroid.data.onboarding.dto.request.SignUpRequest -import com.toyou.toyouandroid.data.onboarding.dto.response.SignUpResponse import com.toyou.toyouandroid.data.onboarding.service.AuthService -import com.toyou.toyouandroid.fcm.domain.FCMRepository +import com.toyou.toyouandroid.fcm.domain.IFCMRepository import com.toyou.toyouandroid.fcm.dto.request.Token import com.toyou.toyouandroid.network.AuthNetworkModule import com.toyou.toyouandroid.utils.TokenManager -import com.toyou.toyouandroid.utils.TokenStorage +import com.toyou.core.datastore.TokenStorage import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response import timber.log.Timber import javax.inject.Inject @@ -28,180 +25,179 @@ class LoginViewModel @Inject constructor( private val authService: AuthService, private val tokenStorage: TokenStorage, private val tokenManager: TokenManager, - private val fcmRepository: FCMRepository -) : ViewModel() { + private val fcmRepository: IFCMRepository +) : MviViewModel( + initialState = LoginUiState() +) { + private var isSendingToken = false private val _loginSuccess = MutableLiveData() val loginSuccess: LiveData get() = _loginSuccess - //private val fcmRepository by lazy { FCMRepository(tokenManager) } - - fun setLoginSuccess(value: Boolean) { - _loginSuccess.value = value - Timber.d("Login Success value: $value") - } - private val _checkIfTokenExists = MutableLiveData() val checkIfTokenExists: LiveData get() = _checkIfTokenExists private val _isInitialization = MutableLiveData() val isInitialization: LiveData get() = _isInitialization - fun setIfTokenExists(value: Boolean) { - _checkIfTokenExists.value = value + private val _oAuthAccessToken = MutableLiveData() + val oAuthAccessToken: LiveData get() = _oAuthAccessToken + + private val _navigationEvent = MutableLiveData() + val navigationEvent: LiveData get() = _navigationEvent + + init { + state.onEach { uiState -> + _loginSuccess.value = uiState.loginSuccess + _checkIfTokenExists.value = uiState.checkIfTokenExists + _isInitialization.value = uiState.isInitialization + _oAuthAccessToken.value = uiState.oAuthAccessToken + uiState.navigationEvent?.let { _navigationEvent.value = it } + }.launchIn(viewModelScope) + } + + override fun handleAction(action: LoginAction) { + when (action) { + is LoginAction.SetLoginSuccess -> performSetLoginSuccess(action.value) + is LoginAction.SetIfTokenExists -> performSetIfTokenExists(action.value) + is LoginAction.SetInitialization -> performSetInitialization(action.value) + is LoginAction.CheckIfTokenExists -> performCheckIfTokenExists() + is LoginAction.KakaoLogin -> performKakaoLogin(action.accessToken) + is LoginAction.SetOAuthAccessToken -> performSetOAuthAccessToken(action.oAuthAccessToken) + is LoginAction.SignUp -> performSignUp(action.signUpRequest) + is LoginAction.ReissueJWT -> performReissueJWT(action.refreshToken) + is LoginAction.PatchFcm -> performPatchFcm(action.token) + } + } + + private fun performSetLoginSuccess(value: Boolean) { + updateState { copy(loginSuccess = value) } + Timber.d("Login Success value: $value") + } + + private fun performSetIfTokenExists(value: Boolean) { + updateState { copy(checkIfTokenExists = value) } Timber.d("checkIfTokenExists: $value") } - fun setInitialization(value: Boolean) { - _isInitialization.value = value + private fun performSetInitialization(value: Boolean) { + updateState { copy(isInitialization = value) } } - fun checkIfTokenExists() { + private fun performCheckIfTokenExists() { tokenStorage.let { storage -> val accessToken = storage.getAccessToken() if (accessToken == "") { - // 액세스 토큰이 없으면 회원가입 동의 화면으로 이동 Timber.d("User Info Not Existed") - _checkIfTokenExists.value = false - }else { + updateState { copy(checkIfTokenExists = false) } + } else { Timber.d("User Info Existed: $accessToken") - _checkIfTokenExists.value = true + updateState { copy(checkIfTokenExists = true) } } } } - fun kakaoLogin(accessToken: String) { + private fun performKakaoLogin(accessToken: String) { viewModelScope.launch { Timber.d("Attempting to log in with Kakao token: $accessToken") - - authService.kakaoLogin(accessToken).enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - if (response.isSuccessful) { - response.headers()["access_token"]?.let { newAccessToken -> - response.headers()["refresh_token"]?.let { newRefreshToken -> - Timber.d("Tokens received from server - Access: $newAccessToken, Refresh: $newRefreshToken") - // 암호화된 토큰 저장소에 저장 - tokenStorage.saveTokens(newAccessToken, newRefreshToken) - sendTokenToServer(tokenStorage.getFcmToken().toString()) - - // 인증 네트워크 모듈에 access token 저장 - AuthNetworkModule.setAccessToken(newAccessToken) - - _loginSuccess.postValue(true) - - Timber.i("Tokens saved successfully") - } ?: Timber.e("Refresh token missing in response headers") - } ?: Timber.e("Access token missing in response headers") - } else { - val errorMessage = response.errorBody()?.string() ?: "Unknown error" - Timber.e("API Error: $errorMessage") - - _loginSuccess.postValue(false) - } - } - - override fun onFailure(call: Call, t: Throwable) { - val errorMessage = t.message ?: "Unknown error" - Timber.e("Network Failure: $errorMessage") - - _loginSuccess.postValue(false) + try { + val response = authService.kakaoLogin(accessToken) + if (response.isSuccessful) { + response.headers()["access_token"]?.let { newAccessToken -> + response.headers()["refresh_token"]?.let { newRefreshToken -> + Timber.d("Tokens received from server - Access: $newAccessToken, Refresh: $newRefreshToken") + tokenStorage.saveTokens(newAccessToken, newRefreshToken) + sendTokenToServer(tokenStorage.getFcmToken().toString()) + AuthNetworkModule.setAccessToken(newAccessToken) + updateState { copy(loginSuccess = true) } + sendEvent(LoginEvent.LoginSucceeded) + Timber.i("Tokens saved successfully") + } ?: Timber.e("Refresh token missing in response headers") + } ?: Timber.e("Access token missing in response headers") + } else { + val errorMessage = response.errorBody()?.string() ?: "Unknown error" + Timber.e("API Error: $errorMessage") + updateState { copy(loginSuccess = false) } + sendEvent(LoginEvent.LoginFailed) } - }) + } catch (e: Exception) { + val errorMessage = e.message ?: "Unknown error" + Timber.e("Network Failure: $errorMessage") + updateState { copy(loginSuccess = false) } + sendEvent(LoginEvent.LoginFailed) + } } } - private val _oAuthAccessToken = MutableLiveData() - val oAuthAccessToken: LiveData get() = _oAuthAccessToken - - fun setOAuthAccessToken(oAuthAccessToken: String) { - _oAuthAccessToken.value = oAuthAccessToken + private fun performSetOAuthAccessToken(oAuthAccessToken: String) { + updateState { copy(oAuthAccessToken = oAuthAccessToken) } } - fun signUp(signUpRequest: SignUpRequest) { + private fun performSignUp(signUpRequest: SignUpRequest) { viewModelScope.launch { - val accessToken = _oAuthAccessToken.value ?: "" + val accessToken = currentState.oAuthAccessToken Timber.d("Attempting to sign up with Kakao token: $accessToken, signUpRequest: $signUpRequest") - - authService.signUp(accessToken, signUpRequest).enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - if (response.isSuccessful) { - response.headers()["access_token"]?.let { newAccessToken -> - response.headers()["refresh_token"]?.let { newRefreshToken -> - Timber.d("Tokens received from server - Access: $newAccessToken, Refresh: $newRefreshToken") - - // 암호화된 토큰 저장소에 저장 - tokenStorage.saveTokens(newAccessToken, newRefreshToken) - sendTokenToServer(tokenStorage.getFcmToken().toString()) - - // 인증 네트워크 모듈에 access token 저장 - AuthNetworkModule.setAccessToken(newAccessToken) - - Timber.i("Tokens saved successfully") - } ?: Timber.e("Refresh token missing in response headers") - } ?: Timber.e("Access token missing in response headers") - } else { - val errorMessage = response.errorBody()?.string() ?: "Unknown error" - Timber.e("API Error: $errorMessage") - } - } - - override fun onFailure(call: Call, t: Throwable) { - val errorMessage = t.message ?: "Unknown error" - Timber.e("Network Failure: $errorMessage") + try { + val response = authService.signUp(accessToken, signUpRequest) + if (response.isSuccessful) { + response.headers()["access_token"]?.let { newAccessToken -> + response.headers()["refresh_token"]?.let { newRefreshToken -> + Timber.d("Tokens received from server - Access: $newAccessToken, Refresh: $newRefreshToken") + tokenStorage.saveTokens(newAccessToken, newRefreshToken) + sendTokenToServer(tokenStorage.getFcmToken().toString()) + AuthNetworkModule.setAccessToken(newAccessToken) + sendEvent(LoginEvent.SignUpSucceeded) + Timber.i("Tokens saved successfully") + } ?: Timber.e("Refresh token missing in response headers") + } ?: Timber.e("Access token missing in response headers") + } else { + val errorMessage = response.errorBody()?.string() ?: "Unknown error" + Timber.e("API Error: $errorMessage") + sendEvent(LoginEvent.SignUpFailed) } - }) + } catch (e: Exception) { + val errorMessage = e.message ?: "Unknown error" + Timber.e("Network Failure: $errorMessage") + sendEvent(LoginEvent.SignUpFailed) + } } } - private val _navigationEvent = MutableLiveData() - val navigationEvent: LiveData get() = _navigationEvent - - fun reissueJWT(refreshToken: String) { + private fun performReissueJWT(refreshToken: String) { viewModelScope.launch { Timber.d("Attempting to reissueJWT: $refreshToken") - - authService.reissue(refreshToken).enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - if (response.isSuccessful) { - response.headers()["access_token"]?.let { newAccessToken -> - response.headers()["refresh_token"]?.let { newRefreshToken -> - Timber.d("Tokens received from server - Access: $newAccessToken, Refresh: $newRefreshToken") - - // 암호화된 토큰 저장소에 저장 - tokenStorage.saveTokens(newAccessToken, newRefreshToken) - Timber.i("Tokens saved successfully") - - // 인증 네트워크 모듈에 access token 저장 - AuthNetworkModule.setAccessToken(newAccessToken) - - tokenStorage.saveTokens(newAccessToken, newRefreshToken) - //sendTokenToServer(tokenStorage.getFcmToken().toString()) - _navigationEvent.value = true // 성공하면 홈 화면으로 이동 - - } ?: Timber.e("Refresh token missing in response headers") - } ?: Timber.e("Access token missing in response headers") - } else { - val errorMessage = response.errorBody()?.string() ?: "Unknown error" - Timber.e("API Error: $errorMessage") - _navigationEvent.value = false // 성공하면 로그인 화면으로 이동 - } - } - - override fun onFailure(call: Call, t: Throwable) { - val errorMessage = t.message ?: "Unknown error" - Timber.e("Network Failure: $errorMessage") - _navigationEvent.value = false // 성공하면 로그인 화면으로 이동 + try { + val response = authService.reissue(refreshToken) + if (response.isSuccessful) { + response.headers()["access_token"]?.let { newAccessToken -> + response.headers()["refresh_token"]?.let { newRefreshToken -> + Timber.d("Tokens received from server - Access: $newAccessToken, Refresh: $newRefreshToken") + tokenStorage.saveTokens(newAccessToken, newRefreshToken) + Timber.i("Tokens saved successfully") + AuthNetworkModule.setAccessToken(newAccessToken) + tokenStorage.saveTokens(newAccessToken, newRefreshToken) + updateState { copy(navigationEvent = true) } + sendEvent(LoginEvent.ReissueSucceeded) + } ?: Timber.e("Refresh token missing in response headers") + } ?: Timber.e("Access token missing in response headers") + } else { + val errorMessage = response.errorBody()?.string() ?: "Unknown error" + Timber.e("API Error: $errorMessage") + updateState { copy(navigationEvent = false) } + sendEvent(LoginEvent.ReissueFailed) } - }) + } catch (e: Exception) { + val errorMessage = e.message ?: "Unknown error" + Timber.e("Network Failure: $errorMessage") + updateState { copy(navigationEvent = false) } + sendEvent(LoginEvent.ReissueFailed) + } } } - private var isSendingToken = false // 호출 여부를 추적하는 플래그 private fun sendTokenToServer(token: String, retryCount: Int = 0) { - val maxRetries = 2 - // 재시도 횟수가 maxRetries를 초과하면 더 이상 호출하지 않음 if (retryCount > maxRetries) { Timber.e("sendTokenToServer failed after $maxRetries retries. Aborting.") return @@ -216,22 +212,22 @@ class LoginViewModel @Inject constructor( val tokenRequest = Token(token) - CoroutineScope(Dispatchers.IO).launch { + viewModelScope.launch(Dispatchers.IO) { try { val response = fcmRepository.postToken(tokenRequest) if (response.isSuccess) { Timber.tag("sendTokenToServer").d("토큰 전송 성공") - tokenStorage.setTokenSent(true) // 전송 성공 상태 저장 + tokenStorage.setTokenSent(true) } else { Timber.tag("sendTokenToServer").d("토큰 전송 실패: ${response.message}") tokenManager.refreshToken( onSuccess = { Timber.d("Token refreshed successfully. Retrying sendTokenToServer.") - sendTokenToServer(token, retryCount + 1) // 재시도 + sendTokenToServer(token, retryCount + 1) }, onFailure = { Timber.e("sendTokenToServer API Call Failed - Refresh token failed.") - isSendingToken = false // 플래그 초기화 + isSendingToken = false } ) } @@ -240,20 +236,20 @@ class LoginViewModel @Inject constructor( tokenManager.refreshToken( onSuccess = { Timber.d("Token refreshed successfully. Retrying sendTokenToServer.") - sendTokenToServer(token, retryCount + 1) // 재시도 + sendTokenToServer(token, retryCount + 1) }, onFailure = { Timber.e("sendTokenToServer API Call Failed - Refresh token failed.") - isSendingToken = false // 플래그 초기화 + isSendingToken = false } ) } finally { - isSendingToken = false // 호출 완료 후 플래그 초기화 + isSendingToken = false } } } - fun patchFcm(token: String) { + private fun performPatchFcm(token: String) { val tokenRequest = Token(token) viewModelScope.launch { try { @@ -265,18 +261,27 @@ class LoginViewModel @Inject constructor( } else { Timber.tag("patchTokenToServer").d("토큰 전송 실패: ${response.message}") tokenManager.refreshToken( - onSuccess = { patchFcm(token) }, + onSuccess = { performPatchFcm(token) }, onFailure = { Timber.e("patchFcm API Call Failed - Refresh token failed") } ) } } catch (e: Exception) { Timber.tag("patchTokenToServer").e("Exception occurred: ${e.message}") tokenManager.refreshToken( - onSuccess = { patchFcm(token) }, + onSuccess = { performPatchFcm(token) }, onFailure = { Timber.e("patchFcm API Call Failed - Refresh token failed") } ) } } } -} \ No newline at end of file + fun setLoginSuccess(value: Boolean) = onAction(LoginAction.SetLoginSuccess(value)) + fun setIfTokenExists(value: Boolean) = onAction(LoginAction.SetIfTokenExists(value)) + fun setInitialization(value: Boolean) = onAction(LoginAction.SetInitialization(value)) + fun checkIfTokenExists() = onAction(LoginAction.CheckIfTokenExists) + fun kakaoLogin(accessToken: String) = onAction(LoginAction.KakaoLogin(accessToken)) + fun setOAuthAccessToken(oAuthAccessToken: String) = onAction(LoginAction.SetOAuthAccessToken(oAuthAccessToken)) + fun signUp(signUpRequest: SignUpRequest) = onAction(LoginAction.SignUp(signUpRequest)) + fun reissueJWT(refreshToken: String) = onAction(LoginAction.ReissueJWT(refreshToken)) + fun patchFcm(token: String) = onAction(LoginAction.PatchFcm(token)) +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/SignupAgreeContract.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/SignupAgreeContract.kt new file mode 100644 index 00000000..0d5056ad --- /dev/null +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/SignupAgreeContract.kt @@ -0,0 +1,19 @@ +package com.toyou.toyouandroid.presentation.fragment.onboarding + +import com.toyou.core.common.mvi.UiAction +import com.toyou.core.common.mvi.UiEvent +import com.toyou.core.common.mvi.UiState +import com.toyou.toyouandroid.R + +data class SignupAgreeUiState( + val imageStates: List = listOf(false, false, false, false), + val isNextButtonEnabled: Boolean = false, + val nextButtonTextColor: Int = 0xFFA6A6A6.toInt(), + val nextButtonBackground: Int = R.drawable.next_button +) : UiState + +sealed interface SignupAgreeEvent : UiEvent + +sealed interface SignupAgreeAction : UiAction { + data class ImageClicked(val index: Int, val newImageResId: Int) : SignupAgreeAction +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/SignupAgreeViewModel.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/SignupAgreeViewModel.kt index 50cee2ad..495bd55a 100644 --- a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/SignupAgreeViewModel.kt +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/SignupAgreeViewModel.kt @@ -2,61 +2,92 @@ package com.toyou.toyouandroid.presentation.fragment.onboarding import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.toyou.core.common.mvi.MviViewModel import com.toyou.toyouandroid.R import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class SignupAgreeViewModel @Inject constructor() : ViewModel() { +class SignupAgreeViewModel @Inject constructor() : MviViewModel( + SignupAgreeUiState() +) { private val _imageStates = MutableLiveData(listOf(false, false, false, false)) val imageStates: LiveData> get() = _imageStates private val _isNextButtonEnabled = MutableLiveData(false) - val isNextButtonEnabled: LiveData = _isNextButtonEnabled + val isNextButtonEnabled: LiveData get() = _isNextButtonEnabled private val _nextButtonTextColor = MutableLiveData().apply { value = 0xFFA6A6A6.toInt() } - val nextButtonTextColor: LiveData = _nextButtonTextColor + val nextButtonTextColor: LiveData get() = _nextButtonTextColor private val _nextButtonBackground = MutableLiveData().apply { value = R.drawable.next_button } - val nextButtonBackground: LiveData = _nextButtonBackground + val nextButtonBackground: LiveData get() = _nextButtonBackground - private val targetImageResId = R.drawable.checkbox_checked // 목표 이미지의 리소스 ID + private val targetImageResId = R.drawable.checkbox_checked - fun onImageClicked(index: Int, newImageResId: Int) { - val newImageStates = _imageStates.value?.toMutableList() ?: mutableListOf() + init { + viewModelScope.launch { + state.collect { newState -> + _imageStates.value = newState.imageStates + _isNextButtonEnabled.value = newState.isNextButtonEnabled + _nextButtonTextColor.value = newState.nextButtonTextColor + _nextButtonBackground.value = newState.nextButtonBackground + } + } + } + + override fun handleAction(action: SignupAgreeAction) { + when (action) { + is SignupAgreeAction.ImageClicked -> performImageClicked(action.index, action.newImageResId) + } + } + + private fun performImageClicked(index: Int, newImageResId: Int) { + val newImageStates = currentState.imageStates.toMutableList() if (index == 0) { - // 첫 번째 이미지뷰 클릭 시, 모든 이미지 상태를 업데이트 val newState = newImageResId == targetImageResId for (i in newImageStates.indices) { newImageStates[i] = newState } } else { - // 다른 이미지뷰 클릭 시, 해당 이미지 상태만 업데이트 newImageStates[index] = newImageResId == targetImageResId } - _imageStates.value = newImageStates - val isSecondImageChecked = newImageStates[1] val isThirdImageChecked = newImageStates[2] val isFourthImageChecked = newImageStates[3] val allRequiredImagesChecked = isSecondImageChecked && isThirdImageChecked && isFourthImageChecked - _isNextButtonEnabled.value = allRequiredImagesChecked - // 다음 버튼의 텍스트 색깔과 배경을 업데이트 + val nextButtonTextColor: Int + val nextButtonBackground: Int + if (allRequiredImagesChecked) { - _nextButtonTextColor.value = 0xFF000000.toInt() // 활성화 상태의 텍스트 색상 - _nextButtonBackground.value = R.drawable.next_button_enabled // 활성화 상태의 배경 + nextButtonTextColor = 0xFF000000.toInt() + nextButtonBackground = R.drawable.next_button_enabled } else { - _nextButtonTextColor.value = 0xFFA6A6A6.toInt() // 비활성화 상태의 텍스트 색상 - _nextButtonBackground.value = R.drawable.next_button // 비활성화 상태의 배경 + nextButtonTextColor = 0xFFA6A6A6.toInt() + nextButtonBackground = R.drawable.next_button + } + + updateState { + copy( + imageStates = newImageStates, + isNextButtonEnabled = allRequiredImagesChecked, + nextButtonTextColor = nextButtonTextColor, + nextButtonBackground = nextButtonBackground + ) } } -} \ No newline at end of file + + fun onImageClicked(index: Int, newImageResId: Int) { + onAction(SignupAgreeAction.ImageClicked(index, newImageResId)) + } +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/SignupNicknameContract.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/SignupNicknameContract.kt new file mode 100644 index 00000000..d56c385b --- /dev/null +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/SignupNicknameContract.kt @@ -0,0 +1,36 @@ +package com.toyou.toyouandroid.presentation.fragment.onboarding + +import com.toyou.core.common.mvi.UiAction +import com.toyou.core.common.mvi.UiEvent +import com.toyou.core.common.mvi.UiState +import com.toyou.toyouandroid.presentation.fragment.mypage.DuplicateCheckMessageType + +data class SignupNicknameUiState( + val title: String = "회원가입", + val textCount: String = "0/15", + val nickname: String = "", + val isDuplicateCheckEnabled: Boolean = false, + val isNextButtonEnabled: Boolean = false, + val duplicateCheckMessage: String = "중복된 닉네임인지 확인해주세요", + val isNicknameValid: Boolean = false, + val duplicateCheckMessageType: DuplicateCheckMessageType = DuplicateCheckMessageType.CHECK_REQUIRED, + val duplicateCheckButtonTextColor: Int = 0xFFA6A6A6.toInt(), + val duplicateCheckButtonBackground: Int = com.toyou.toyouandroid.R.drawable.next_button, + val duplicateCheckMessageColor: Int = 0xFF000000.toInt(), + val nextButtonBackground: Int = com.toyou.toyouandroid.R.drawable.next_button, + val nextButtonTextColor: Int = 0xFFA6A6A6.toInt() +) : UiState + +sealed interface SignupNicknameEvent : UiEvent { + data class ShowError(val message: String) : SignupNicknameEvent + data object ServerError : SignupNicknameEvent +} + +sealed interface SignupNicknameAction : UiAction { + data class UpdateTextCount(val count: Int) : SignupNicknameAction + data class SetNickname(val nickname: String) : SignupNicknameAction + data class UpdateLength15(val length: Int) : SignupNicknameAction + data object ActivateDuplicateButton : SignupNicknameAction + data object ResetState : SignupNicknameAction + data class CheckDuplicate(val userId: Int) : SignupNicknameAction +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/SignupNicknameViewModel.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/SignupNicknameViewModel.kt index 9ebd9bde..e2e24884 100644 --- a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/SignupNicknameViewModel.kt +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/SignupNicknameViewModel.kt @@ -2,25 +2,28 @@ package com.toyou.toyouandroid.presentation.fragment.onboarding import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.toyou.toyouandroid.domain.profile.repository.ProfileRepository +import com.toyou.core.common.mvi.MviViewModel +import com.toyou.toyouandroid.domain.profile.repository.IProfileRepository import com.toyou.toyouandroid.presentation.fragment.mypage.DuplicateCheckMessageType import com.toyou.toyouandroid.presentation.fragment.mypage.ProfileUiState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @HiltViewModel class SignupNicknameViewModel @Inject constructor( - private val profileRepository: ProfileRepository -) : ViewModel() { - + private val profileRepository: IProfileRepository +) : MviViewModel( + initialState = SignupNicknameUiState() +) { + private val _uiState = MutableLiveData(ProfileUiState()) - val uiState: LiveData get() = _uiState - - // 데이터 바인딩 호환성을 위한 프로퍼티 + val uiStateLegacy: LiveData get() = _uiState + val nickname: LiveData = MutableLiveData() val textCount: LiveData = MutableLiveData("0/15") val duplicateCheckButtonTextColor: LiveData = MutableLiveData(0xFFA6A6A6.toInt()) @@ -30,109 +33,159 @@ class SignupNicknameViewModel @Inject constructor( val nextButtonBackground: LiveData = MutableLiveData(com.toyou.toyouandroid.R.drawable.next_button) val nextButtonTextColor: LiveData = MutableLiveData(0xFFA6A6A6.toInt()) val isNextButtonEnabled: LiveData = MutableLiveData(false) - + private val _duplicateCheckMessageType = MutableLiveData() val duplicateCheckMessageType: LiveData get() = _duplicateCheckMessageType - + init { - _uiState.value = ProfileUiState(title = "회원가입") + state.onEach { mviState -> + _uiState.value = ProfileUiState( + title = mviState.title, + textCount = mviState.textCount, + nickname = mviState.nickname, + isDuplicateCheckEnabled = mviState.isDuplicateCheckEnabled, + isNextButtonEnabled = mviState.isNextButtonEnabled, + duplicateCheckMessage = mviState.duplicateCheckMessage, + isNicknameValid = mviState.isNicknameValid + ) + (nickname as MutableLiveData).value = mviState.nickname + (textCount as MutableLiveData).value = mviState.textCount + (duplicateCheckButtonTextColor as MutableLiveData).value = mviState.duplicateCheckButtonTextColor + (duplicateCheckButtonBackground as MutableLiveData).value = mviState.duplicateCheckButtonBackground + (duplicateCheckMessage as MutableLiveData).value = mviState.duplicateCheckMessage + (duplicateCheckMessageColor as MutableLiveData).value = mviState.duplicateCheckMessageColor + (nextButtonBackground as MutableLiveData).value = mviState.nextButtonBackground + (nextButtonTextColor as MutableLiveData).value = mviState.nextButtonTextColor + (isNextButtonEnabled as MutableLiveData).value = mviState.isNextButtonEnabled + _duplicateCheckMessageType.value = mviState.duplicateCheckMessageType + }.launchIn(viewModelScope) } - - fun updateTextCount(count: Int) { + + override fun handleAction(action: SignupNicknameAction) { + when (action) { + is SignupNicknameAction.UpdateTextCount -> performUpdateTextCount(action.count) + is SignupNicknameAction.SetNickname -> performSetNickname(action.nickname) + is SignupNicknameAction.UpdateLength15 -> performUpdateLength15(action.length) + is SignupNicknameAction.ActivateDuplicateButton -> performActivateDuplicateButton() + is SignupNicknameAction.ResetState -> performResetState() + is SignupNicknameAction.CheckDuplicate -> performCheckDuplicate(action.userId) + } + } + + private fun performUpdateTextCount(count: Int) { val countText = "($count/15)" - _uiState.value = _uiState.value?.copy( - textCount = countText - ) - (textCount as MutableLiveData).value = countText + updateState { copy(textCount = countText) } } - - fun setNickname(newNickname: String) { - _uiState.value = _uiState.value?.copy( - nickname = newNickname, - isDuplicateCheckEnabled = newNickname.isNotEmpty() - ) - (nickname as MutableLiveData).value = newNickname + + private fun performSetNickname(newNickname: String) { + updateState { + copy( + nickname = newNickname, + isDuplicateCheckEnabled = newNickname.isNotEmpty() + ) + } } - - fun updateLength15(length: Int) { + + private fun performUpdateLength15(length: Int) { val messageType = if (length >= 15) { DuplicateCheckMessageType.LENGTH_EXCEEDED } else { DuplicateCheckMessageType.CHECK_REQUIRED } - _duplicateCheckMessageType.value = messageType - _uiState.value = _uiState.value?.copy( - duplicateCheckMessage = messageType.message - ) + updateState { + copy( + duplicateCheckMessageType = messageType, + duplicateCheckMessage = messageType.message + ) + } } - - fun duplicateBtnActivate() { - _uiState.value = _uiState.value?.copy( - isDuplicateCheckEnabled = true - ) - (duplicateCheckButtonTextColor as MutableLiveData).value = 0xFF000000.toInt() - (duplicateCheckButtonBackground as MutableLiveData).value = com.toyou.toyouandroid.R.drawable.signupnickname_doublecheck_activate + + private fun performActivateDuplicateButton() { + updateState { + copy( + isDuplicateCheckEnabled = true, + duplicateCheckButtonTextColor = 0xFF000000.toInt(), + duplicateCheckButtonBackground = com.toyou.toyouandroid.R.drawable.signupnickname_doublecheck_activate + ) + } } - - fun resetState() { - _uiState.value = ProfileUiState(title = "회원가입") - _duplicateCheckMessageType.value = DuplicateCheckMessageType.CHECK_REQUIRED + + private fun performResetState() { + updateState { + SignupNicknameUiState() + } } - - fun checkDuplicate(userId: Int) { - val nickname = _uiState.value?.nickname ?: return - + + private fun performCheckDuplicate(userId: Int) { + val nickname = currentState.nickname + if (nickname.isEmpty()) return + viewModelScope.launch { try { val response = profileRepository.checkNickname(nickname, userId) if (response.isSuccessful) { val exists = response.body()?.result?.exists ?: false - handleNicknameCheckResult(exists) + performHandleNicknameCheckResult(exists) } else { - handleNicknameCheckError() + performHandleNicknameCheckError() } } catch (e: Exception) { Timber.tag("API Failure").e(e, "Error checking nickname") - _duplicateCheckMessageType.value = DuplicateCheckMessageType.SERVER_ERROR - _uiState.value = _uiState.value?.copy( - duplicateCheckMessage = DuplicateCheckMessageType.SERVER_ERROR.message, - isNicknameValid = false, - isNextButtonEnabled = false - ) + updateState { + copy( + duplicateCheckMessageType = DuplicateCheckMessageType.SERVER_ERROR, + duplicateCheckMessage = DuplicateCheckMessageType.SERVER_ERROR.message, + isNicknameValid = false, + isNextButtonEnabled = false + ) + } + sendEvent(SignupNicknameEvent.ServerError) } } } - - private fun handleNicknameCheckResult(exists: Boolean) { + + private fun performHandleNicknameCheckResult(exists: Boolean) { val messageType = if (!exists) { DuplicateCheckMessageType.AVAILABLE } else { DuplicateCheckMessageType.ALREADY_IN_USE } - - _duplicateCheckMessageType.value = messageType - _uiState.value = _uiState.value?.copy( - duplicateCheckMessage = messageType.message, - isNicknameValid = !exists, - isNextButtonEnabled = !exists - ) - - // 호환성 프로퍼티 업데이트 - (duplicateCheckMessage as MutableLiveData).value = messageType.message - (duplicateCheckMessageColor as MutableLiveData).value = if (!exists) 0xFFEA9797.toInt() else 0xFFFF0000.toInt() - (isNextButtonEnabled as MutableLiveData).value = !exists - if (!exists) { - (nextButtonTextColor as MutableLiveData).value = 0xFF000000.toInt() - (nextButtonBackground as MutableLiveData).value = com.toyou.toyouandroid.R.drawable.next_button_enabled + + val messageColor = if (!exists) 0xFFEA9797.toInt() else 0xFFFF0000.toInt() + + updateState { + copy( + duplicateCheckMessageType = messageType, + duplicateCheckMessage = messageType.message, + duplicateCheckMessageColor = messageColor, + isNicknameValid = !exists, + isNextButtonEnabled = !exists, + nextButtonTextColor = if (!exists) 0xFF000000.toInt() else nextButtonTextColor, + nextButtonBackground = if (!exists) com.toyou.toyouandroid.R.drawable.next_button_enabled else nextButtonBackground + ) } } - - private fun handleNicknameCheckError() { - _duplicateCheckMessageType.value = DuplicateCheckMessageType.CHECK_FAILED - _uiState.value = _uiState.value?.copy( - duplicateCheckMessage = DuplicateCheckMessageType.CHECK_FAILED.message, - isNicknameValid = false, - isNextButtonEnabled = false - ) + + private fun performHandleNicknameCheckError() { + updateState { + copy( + duplicateCheckMessageType = DuplicateCheckMessageType.CHECK_FAILED, + duplicateCheckMessage = DuplicateCheckMessageType.CHECK_FAILED.message, + isNicknameValid = false, + isNextButtonEnabled = false + ) + } } -} \ No newline at end of file + + fun updateTextCount(count: Int) = onAction(SignupNicknameAction.UpdateTextCount(count)) + + fun setNickname(newNickname: String) = onAction(SignupNicknameAction.SetNickname(newNickname)) + + fun updateLength15(length: Int) = onAction(SignupNicknameAction.UpdateLength15(length)) + + fun duplicateBtnActivate() = onAction(SignupNicknameAction.ActivateDuplicateButton) + + fun resetState() = onAction(SignupNicknameAction.ResetState) + + fun checkDuplicate(userId: Int) = onAction(SignupNicknameAction.CheckDuplicate(userId)) +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/SignupStatusContract.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/SignupStatusContract.kt new file mode 100644 index 00000000..06131aa3 --- /dev/null +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/SignupStatusContract.kt @@ -0,0 +1,20 @@ +package com.toyou.toyouandroid.presentation.fragment.onboarding + +import com.toyou.core.common.mvi.UiAction +import com.toyou.core.common.mvi.UiEvent +import com.toyou.core.common.mvi.UiState +import com.toyou.toyouandroid.R + +data class SignupStatusUiState( + val selectedButtonId: Int? = null, + val isNextButtonEnabled: Boolean = false, + val nextButtonTextColor: Int = 0xFFA6A6A6.toInt(), + val nextButtonBackground: Int = R.drawable.next_button, + val status: String = "" +) : UiState + +sealed interface SignupStatusEvent : UiEvent + +sealed interface SignupStatusAction : UiAction { + data class ButtonClicked(val buttonId: Int) : SignupStatusAction +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/SignupStatusFragment.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/SignupStatusFragment.kt index 65480790..14930f72 100644 --- a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/SignupStatusFragment.kt +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/SignupStatusFragment.kt @@ -12,7 +12,8 @@ import com.toyou.toyouandroid.R import com.toyou.toyouandroid.databinding.FragmentSignupstatusBinding import com.toyou.toyouandroid.presentation.base.MainActivity import com.toyou.toyouandroid.data.onboarding.dto.request.SignUpRequest -import com.toyou.toyouandroid.utils.TutorialStorage +import com.toyou.core.datastore.TutorialStorage +import javax.inject.Inject import dagger.hilt.android.AndroidEntryPoint import timber.log.Timber @@ -27,7 +28,8 @@ class SignupStatusFragment : Fragment() { private val signUpStatusViewModel: SignupStatusViewModel by activityViewModels() private val signupNicknameViewModel: SignupNicknameViewModel by activityViewModels() - private lateinit var tutorialStorage: TutorialStorage + @Inject + lateinit var tutorialStorage: TutorialStorage private val loginViewModel: LoginViewModel by activityViewModels() @@ -41,8 +43,6 @@ class SignupStatusFragment : Fragment() { binding.viewModel = signUpStatusViewModel binding.lifecycleOwner = this - tutorialStorage = TutorialStorage(requireContext()) - return binding.root } @@ -99,7 +99,7 @@ class SignupStatusFragment : Fragment() { loginViewModel.signUp(signUpRequest) navController.navigate(R.id.action_navigation_signup_status_to_tutorial_fragment) - tutorialStorage.setTutorialShown() + tutorialStorage.setTutorialShownSync() } } diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/SignupStatusViewModel.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/SignupStatusViewModel.kt index c1fe4911..f1e655f9 100644 --- a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/SignupStatusViewModel.kt +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/SignupStatusViewModel.kt @@ -2,13 +2,18 @@ package com.toyou.toyouandroid.presentation.fragment.onboarding import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.toyou.core.common.mvi.MviViewModel import com.toyou.toyouandroid.R import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class SignupStatusViewModel @Inject constructor() : ViewModel() { +class SignupStatusViewModel @Inject constructor() : MviViewModel(SignupStatusUiState()) { + + private val _uiStateLiveData = MutableLiveData(SignupStatusUiState()) + val uiState: LiveData get() = _uiStateLiveData private val _selectedButtonId = MutableLiveData(null) val selectedButtonId: LiveData get() = _selectedButtonId @@ -29,27 +34,56 @@ class SignupStatusViewModel @Inject constructor() : ViewModel() { private val _status = MutableLiveData() val status: LiveData get() = _status - fun onButtonClicked(buttonId: Int) { - if (_selectedButtonId.value == buttonId) return - _selectedButtonId.value = buttonId - - when (buttonId) { - R.id.signup_status_option_1 -> _status.value = "SCHOOL" - R.id.signup_status_option_2 -> _status.value = "COLLEGE" - R.id.signup_status_option_3 -> _status.value = "OFFICE" - R.id.signup_status_option_4 -> _status.value = "ETC" + init { + viewModelScope.launch { + state.collect { newState -> + _uiStateLiveData.value = newState + _selectedButtonId.value = newState.selectedButtonId + _isNextButtonEnabled.value = newState.isNextButtonEnabled + _nextButtonTextColor.value = newState.nextButtonTextColor + _nextButtonBackground.value = newState.nextButtonBackground + _status.value = newState.status + } } + } - _isNextButtonEnabled.value = true - _nextButtonTextColor.value = 0xFF000000.toInt() - _nextButtonBackground.value = R.drawable.next_button_enabled + override fun handleAction(action: SignupStatusAction) { + when (action) { + is SignupStatusAction.ButtonClicked -> performButtonClicked(action.buttonId) + } + } + + private fun performButtonClicked(buttonId: Int) { + if (currentState.selectedButtonId == buttonId) return + + val status = when (buttonId) { + R.id.signup_status_option_1 -> "SCHOOL" + R.id.signup_status_option_2 -> "COLLEGE" + R.id.signup_status_option_3 -> "OFFICE" + R.id.signup_status_option_4 -> "ETC" + else -> "" + } + + updateState { + copy( + selectedButtonId = buttonId, + status = status, + isNextButtonEnabled = true, + nextButtonTextColor = 0xFF000000.toInt(), + nextButtonBackground = R.drawable.next_button_enabled + ) + } } fun getButtonBackground(buttonId: Int): Int { - return if (_selectedButtonId.value == buttonId) { - R.drawable.signupnickname_doublecheck_activate // 선택된 상태의 배경 + return if (currentState.selectedButtonId == buttonId) { + R.drawable.signupnickname_doublecheck_activate } else { - R.drawable.signupnickname_input // 기본 상태의 배경 + R.drawable.signupnickname_input } } -} \ No newline at end of file + + fun onButtonClicked(buttonId: Int) { + onAction(SignupStatusAction.ButtonClicked(buttonId)) + } +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/SplashFragment.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/SplashFragment.kt index 164c7dac..ddcd98fa 100644 --- a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/SplashFragment.kt +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/onboarding/SplashFragment.kt @@ -17,7 +17,8 @@ import com.toyou.toyouandroid.R import com.toyou.toyouandroid.databinding.FragmentSplashBinding import com.toyou.toyouandroid.presentation.base.MainActivity import com.toyou.toyouandroid.presentation.viewmodel.UserViewModel -import com.toyou.toyouandroid.utils.TokenStorage +import com.toyou.core.datastore.TokenStorage +import javax.inject.Inject import dagger.hilt.android.AndroidEntryPoint import timber.log.Timber @@ -29,6 +30,9 @@ class SplashFragment : Fragment() { private val binding: FragmentSplashBinding get() = requireNotNull(_binding){"FragmentSplashBinding -> null"} + @Inject + lateinit var tokenStorage: TokenStorage + private val loginViewModel: LoginViewModel by viewModels() private val userViewModel: UserViewModel by activityViewModels() @@ -69,8 +73,8 @@ class SplashFragment : Fragment() { } }) - val refreshToken = TokenStorage(requireContext()).getRefreshToken() - val fcmToken = TokenStorage(requireContext()).getFcmToken() + val refreshToken = tokenStorage.getRefreshToken() + val fcmToken = tokenStorage.getFcmToken() if (refreshToken != null) { loginViewModel.reissueJWT(refreshToken) diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/CalendarDialogContract.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/CalendarDialogContract.kt new file mode 100644 index 00000000..871d0bbd --- /dev/null +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/CalendarDialogContract.kt @@ -0,0 +1,34 @@ +package com.toyou.toyouandroid.presentation.fragment.record + +import com.toyou.core.common.mvi.UiAction +import com.toyou.core.common.mvi.UiEvent +import com.toyou.core.common.mvi.UiState + +data class CalendarDialogUiState( + val title: String = "", + val leftButtonText: String = "", + val rightButtonText: String = "", + val leftButtonTextColor: Int = 0, + val rightButtonTextColor: Int = 0, + val leftButtonClickAction: (() -> Unit)? = null, + val rightButtonClickAction: (() -> Unit)? = null +) : UiState + +sealed interface CalendarDialogEvent : UiEvent { + data object LeftButtonClicked : CalendarDialogEvent + data object RightButtonClicked : CalendarDialogEvent +} + +sealed interface CalendarDialogAction : UiAction { + data class SetDialogData( + val title: String, + val leftButtonText: String, + val rightButtonText: String, + val leftButtonTextColor: Int, + val rightButtonTextColor: Int, + val leftButtonClickAction: () -> Unit, + val rightButtonClickAction: () -> Unit + ) : CalendarDialogAction + data object LeftButtonClick : CalendarDialogAction + data object RightButtonClick : CalendarDialogAction +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/CalendarDialogViewModel.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/CalendarDialogViewModel.kt index 1bc74cc8..264ae65b 100644 --- a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/CalendarDialogViewModel.kt +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/CalendarDialogViewModel.kt @@ -2,12 +2,17 @@ package com.toyou.toyouandroid.presentation.fragment.record import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.toyou.core.common.mvi.MviViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import javax.inject.Inject @HiltViewModel -class CalendarDialogViewModel @Inject constructor() : ViewModel() { +class CalendarDialogViewModel @Inject constructor() : MviViewModel( + CalendarDialogUiState() +) { private val _title = MutableLiveData() val title: LiveData get() = _title @@ -23,8 +28,63 @@ class CalendarDialogViewModel @Inject constructor() : ViewModel() { private val _rightButtonTextColor = MutableLiveData() val rightButtonTextColor: LiveData get() = _rightButtonTextColor - private val _leftButtonClickAction = MutableLiveData<() -> Unit>() - private val _rightButtonClickAction = MutableLiveData<() -> Unit>() + init { + state.onEach { uiState -> + _title.value = uiState.title + _leftButtonText.value = uiState.leftButtonText + _rightButtonText.value = uiState.rightButtonText + _leftButtonTextColor.value = uiState.leftButtonTextColor + _rightButtonTextColor.value = uiState.rightButtonTextColor + }.launchIn(viewModelScope) + } + + override fun handleAction(action: CalendarDialogAction) { + when (action) { + is CalendarDialogAction.SetDialogData -> performSetDialogData( + action.title, + action.leftButtonText, + action.rightButtonText, + action.leftButtonTextColor, + action.rightButtonTextColor, + action.leftButtonClickAction, + action.rightButtonClickAction + ) + is CalendarDialogAction.LeftButtonClick -> performLeftButtonClick() + is CalendarDialogAction.RightButtonClick -> performRightButtonClick() + } + } + + private fun performSetDialogData( + title: String, + leftButtonText: String, + rightButtonText: String, + leftButtonTextColor: Int, + rightButtonTextColor: Int, + leftButtonClickAction: () -> Unit, + rightButtonClickAction: () -> Unit + ) { + updateState { + copy( + title = title, + leftButtonText = leftButtonText, + rightButtonText = rightButtonText, + leftButtonTextColor = leftButtonTextColor, + rightButtonTextColor = rightButtonTextColor, + leftButtonClickAction = leftButtonClickAction, + rightButtonClickAction = rightButtonClickAction + ) + } + } + + private fun performLeftButtonClick() { + currentState.leftButtonClickAction?.invoke() + sendEvent(CalendarDialogEvent.LeftButtonClicked) + } + + private fun performRightButtonClick() { + currentState.rightButtonClickAction?.invoke() + sendEvent(CalendarDialogEvent.RightButtonClicked) + } fun setDialogData( title: String, @@ -35,20 +95,24 @@ class CalendarDialogViewModel @Inject constructor() : ViewModel() { leftButtonClickAction: () -> Unit, rightButtonClickAction: () -> Unit ) { - _title.value = title - _leftButtonText.value = leftButtonText - _rightButtonText.value = rightButtonText - _leftButtonTextColor.value = leftButtonTextColor - _rightButtonTextColor.value = rightButtonTextColor - _leftButtonClickAction.value = leftButtonClickAction - _rightButtonClickAction.value = rightButtonClickAction + onAction( + CalendarDialogAction.SetDialogData( + title, + leftButtonText, + rightButtonText, + leftButtonTextColor, + rightButtonTextColor, + leftButtonClickAction, + rightButtonClickAction + ) + ) } fun onLeftButtonClick() { - _leftButtonClickAction.value?.invoke() + onAction(CalendarDialogAction.LeftButtonClick) } fun onRightButtonClick() { - _rightButtonClickAction.value?.invoke() + onAction(CalendarDialogAction.RightButtonClick) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/CardInfoContract.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/CardInfoContract.kt new file mode 100644 index 00000000..6d907354 --- /dev/null +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/CardInfoContract.kt @@ -0,0 +1,30 @@ +package com.toyou.toyouandroid.presentation.fragment.record + +import com.toyou.core.common.mvi.UiAction +import com.toyou.core.common.mvi.UiEvent +import com.toyou.core.common.mvi.UiState +import com.toyou.toyouandroid.model.CardModel +import com.toyou.toyouandroid.model.PreviewCardModel + +data class CardInfoUiState( + val cards: List = emptyList(), + val previewCards: List = emptyList(), + val exposure: Boolean = false, + val answer: String = "", + val cardId: Int = 0, + val date: String = "", + val emotion: String = "", + val receiver: String = "", + val isLoading: Boolean = false +) : UiState + +sealed interface CardInfoEvent : UiEvent { + data class ShowError(val message: String) : CardInfoEvent + data object CardDetailLoaded : CardInfoEvent + data object CardDetailFailed : CardInfoEvent +} + +sealed interface CardInfoAction : UiAction { + data class GetCardDetail(val id: Long) : CardInfoAction + data class UpdateAnswer(val answer: String) : CardInfoAction +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/CardInfoViewModel.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/CardInfoViewModel.kt index 88a2579c..80a9be28 100644 --- a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/CardInfoViewModel.kt +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/CardInfoViewModel.kt @@ -2,31 +2,38 @@ package com.toyou.toyouandroid.presentation.fragment.record import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.toyou.toyouandroid.domain.record.RecordRepository +import com.toyou.core.common.mvi.MviViewModel +import com.toyou.toyouandroid.domain.record.IRecordRepository import com.toyou.toyouandroid.model.CardModel import com.toyou.toyouandroid.model.PreviewCardModel import com.toyou.toyouandroid.utils.TokenManager import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @HiltViewModel class CardInfoViewModel @Inject constructor( - private val recordRepository: RecordRepository, + private val recordRepository: IRecordRepository, private val tokenManager: TokenManager -) : ViewModel() { +) : MviViewModel( + initialState = CardInfoUiState() +) { + private val _cards = MutableLiveData>() val cards: LiveData> get() = _cards + private val _previewCards = MutableLiveData>() - val previewCards : LiveData> get() = _previewCards + val previewCards: LiveData> get() = _previewCards - val exposure : LiveData get() = _exposure private val _exposure = MutableLiveData() + val exposure: LiveData get() = _exposure val answer = MutableLiveData() + private val _cardId = MutableLiveData().apply { value = 0 } val cardId: LiveData get() = _cardId @@ -39,21 +46,38 @@ class CardInfoViewModel @Inject constructor( private val _receiver = MutableLiveData() val receiver: LiveData get() = _receiver - fun getCardDetail(id : Long) { + init { + state.onEach { uiState -> + _cards.value = uiState.cards + _previewCards.value = uiState.previewCards + _exposure.value = uiState.exposure + answer.value = uiState.answer + _cardId.value = uiState.cardId + _date.value = uiState.date + _emotion.value = uiState.emotion + _receiver.value = uiState.receiver + }.launchIn(viewModelScope) + } + + override fun handleAction(action: CardInfoAction) { + when (action) { + is CardInfoAction.GetCardDetail -> performGetCardDetail(action.id) + is CardInfoAction.UpdateAnswer -> performUpdateAnswer(action.answer) + } + } + + private fun performGetCardDetail(id: Long) { viewModelScope.launch { + updateState { copy(isLoading = true) } try { val response = recordRepository.getCardDetails(id) if (response.isSuccess) { val detailCard = response.result val previewCardList = mutableListOf() - _exposure.value = detailCard.exposure - _date.value = detailCard.date - _emotion.value = detailCard.emotion - _receiver.value = detailCard.receiver detailCard.questions.let { questionList -> questionList.forEach { question -> - val previewCard = when(question.type) { + val previewCard = when (question.type) { "OPTIONAL" -> { PreviewCardModel( question = question.content, @@ -64,7 +88,6 @@ class CardInfoViewModel @Inject constructor( id = question.id ) } - "SHORT_ANSWER" -> { PreviewCardModel( question = question.content, @@ -75,7 +98,6 @@ class CardInfoViewModel @Inject constructor( id = question.id ) } - else -> { PreviewCardModel( question = question.content, @@ -88,26 +110,46 @@ class CardInfoViewModel @Inject constructor( } } previewCardList.add(previewCard) - _previewCards.value = previewCardList } } + updateState { + copy( + exposure = detailCard.exposure, + date = detailCard.date, + emotion = detailCard.emotion, + receiver = detailCard.receiver, + previewCards = previewCardList, + isLoading = false + ) + } + sendEvent(CardInfoEvent.CardDetailLoaded) } else { - // 오류 처리 Timber.tag("CardViewModel").d("detail API 호출 실패: ${response.message}") - + updateState { copy(isLoading = false) } tokenManager.refreshToken( - onSuccess = { getCardDetail(id) }, // 토큰 갱신 후 다시 요청 - onFailure = { Timber.e("Failed to refresh token and get card detail") } + onSuccess = { performGetCardDetail(id) }, + onFailure = { + Timber.e("Failed to refresh token and get card detail") + sendEvent(CardInfoEvent.CardDetailFailed) + } ) } } catch (e: Exception) { Timber.tag("CardViewModel").d("detail 예외 발생: ${e.message}") + updateState { copy(isLoading = false) } + sendEvent(CardInfoEvent.ShowError(e.message ?: "Unknown error")) } } } + private fun performUpdateAnswer(answer: String) { + updateState { copy(answer = answer) } + } + + fun getCardDetail(id: Long) = onAction(CardInfoAction.GetCardDetail(id)) + fun getAnswerLength(answer: String): Int { return answer.length } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/friend/FriendCardContract.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/friend/FriendCardContract.kt new file mode 100644 index 00000000..ef11c08d --- /dev/null +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/friend/FriendCardContract.kt @@ -0,0 +1,37 @@ +package com.toyou.toyouandroid.presentation.fragment.record.friend + +import com.toyou.core.common.mvi.UiAction +import com.toyou.core.common.mvi.UiEvent +import com.toyou.core.common.mvi.UiState +import com.toyou.toyouandroid.model.CardModel +import com.toyou.toyouandroid.model.CardShortModel +import com.toyou.toyouandroid.model.ChooseModel +import com.toyou.toyouandroid.model.PreviewCardModel +import com.toyou.toyouandroid.model.PreviewChooseModel + +data class FriendCardUiState( + val cards: List = emptyList(), + val shortCards: List = emptyList(), + val previewCards: List = emptyList(), + val chooseCards: List = emptyList(), + val previewChoose: List = emptyList(), + val exposure: Boolean = false, + val answer: String = "", + val cardId: Int = 0, + val isAllAnswersFilled: Boolean = false, + val date: String = "", + val emotion: String = "", + val receiver: String = "" +) : UiState + +sealed interface FriendCardEvent : UiEvent { + data class ShowError(val message: String) : FriendCardEvent +} + +sealed interface FriendCardAction : UiAction { + data class GetCardDetail(val id: Long) : FriendCardAction + data class SetCardId(val cardId: Int) : FriendCardAction + data class UpdateAnswer(val answer: String) : FriendCardAction + data object ClearAllData : FriendCardAction + data object ClearAll : FriendCardAction +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/friend/FriendCardViewModel.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/friend/FriendCardViewModel.kt index 96c07995..5d08743d 100644 --- a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/friend/FriendCardViewModel.kt +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/friend/FriendCardViewModel.kt @@ -2,9 +2,9 @@ package com.toyou.toyouandroid.presentation.fragment.record.friend import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.toyou.toyouandroid.domain.record.RecordRepository +import com.toyou.core.common.mvi.MviViewModel +import com.toyou.toyouandroid.domain.record.IRecordRepository import com.toyou.toyouandroid.model.CardModel import com.toyou.toyouandroid.model.CardShortModel import com.toyou.toyouandroid.model.ChooseModel @@ -12,39 +12,43 @@ import com.toyou.toyouandroid.model.PreviewCardModel import com.toyou.toyouandroid.model.PreviewChooseModel import com.toyou.toyouandroid.utils.TokenManager import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @HiltViewModel class FriendCardViewModel @Inject constructor( - private val recordRepository: RecordRepository, + private val recordRepository: IRecordRepository, private val tokenManager: TokenManager -) : ViewModel() { +) : MviViewModel( + initialState = FriendCardUiState() +) { + private val _cards = MutableLiveData>() val cards: LiveData> get() = _cards + private val _shortCards = MutableLiveData>() val shortCards: LiveData> get() = _shortCards + private val _previewCards = MutableLiveData>() - val previewCards : LiveData> get() = _previewCards + val previewCards: LiveData> get() = _previewCards private val _chooseCards = MutableLiveData>() - val chooseCards : LiveData> get() = _chooseCards + val chooseCards: LiveData> get() = _chooseCards + private val _previewChoose = MutableLiveData>() - val previewChoose : LiveData> get() = _previewChoose + val previewChoose: LiveData> get() = _previewChoose - val exposure : LiveData get() = _exposure private val _exposure = MutableLiveData() + val exposure: LiveData get() = _exposure val answer = MutableLiveData() + private val _cardId = MutableLiveData().apply { value = 0 } val cardId: LiveData get() = _cardId - // cardId 설정을 위한 함수 추가 - fun setCardId(cardId: Int) { - _cardId.value = cardId - } - private val _isAllAnswersFilled = MutableLiveData(false) val isAllAnswersFilled: LiveData get() = _isAllAnswersFilled @@ -57,21 +61,44 @@ class FriendCardViewModel @Inject constructor( private val _receiver = MutableLiveData() val receiver: LiveData get() = _receiver - fun getCardDetail(id : Long) { + init { + state.onEach { uiState -> + _cards.value = uiState.cards + _shortCards.value = uiState.shortCards + _previewCards.value = uiState.previewCards + _chooseCards.value = uiState.chooseCards + _previewChoose.value = uiState.previewChoose + _exposure.value = uiState.exposure + answer.value = uiState.answer + _cardId.value = uiState.cardId + _isAllAnswersFilled.value = uiState.isAllAnswersFilled + _date.value = uiState.date + _emotion.value = uiState.emotion + _receiver.value = uiState.receiver + }.launchIn(viewModelScope) + } + + override fun handleAction(action: FriendCardAction) { + when (action) { + is FriendCardAction.GetCardDetail -> performGetCardDetail(action.id) + is FriendCardAction.SetCardId -> performSetCardId(action.cardId) + is FriendCardAction.UpdateAnswer -> performUpdateAnswer(action.answer) + is FriendCardAction.ClearAllData -> performClearAllData() + is FriendCardAction.ClearAll -> performClearAll() + } + } + + private fun performGetCardDetail(id: Long) { viewModelScope.launch { try { val response = recordRepository.getCardDetails(id) if (response.isSuccess) { val detailCard = response.result val previewCardList = mutableListOf() - _exposure.value = detailCard.exposure - _date.value = detailCard.date - _emotion.value = detailCard.emotion - _receiver.value = detailCard.receiver detailCard.questions.let { questionList -> questionList.forEach { question -> - val previewCard = when(question.type) { + val previewCard = when (question.type) { "OPTIONAL" -> { PreviewCardModel( question = question.content, @@ -82,7 +109,6 @@ class FriendCardViewModel @Inject constructor( id = question.id ) } - "SHORT_ANSWER" -> { PreviewCardModel( question = question.content, @@ -93,7 +119,6 @@ class FriendCardViewModel @Inject constructor( id = question.id ) } - else -> { PreviewCardModel( question = question.content, @@ -106,15 +131,22 @@ class FriendCardViewModel @Inject constructor( } } previewCardList.add(previewCard) - _previewCards.value = previewCardList } } + updateState { + copy( + exposure = detailCard.exposure, + date = detailCard.date, + emotion = detailCard.emotion, + receiver = detailCard.receiver, + previewCards = previewCardList + ) + } } else { - // 오류 처리 Timber.tag("CardViewModel").d("detail API 호출 실패: ${response.message}") tokenManager.refreshToken( - onSuccess = { getCardDetail(id) }, + onSuccess = { performGetCardDetail(id) }, onFailure = { Timber.e("getCardDetail API call failed") } ) } @@ -124,19 +156,41 @@ class FriendCardViewModel @Inject constructor( } } - fun getAnswerLength(answer: String): Int { - return answer.length + private fun performSetCardId(cardId: Int) { + updateState { copy(cardId = cardId) } } - fun clearAllData() { - _previewCards.value = emptyList() - _previewChoose.value = emptyList() - _exposure.value = false + private fun performUpdateAnswer(answer: String) { + updateState { copy(answer = answer) } + } + + private fun performClearAllData() { + updateState { + copy( + previewCards = emptyList(), + previewChoose = emptyList(), + exposure = false + ) + } } - fun clearAll(){ - _cards.value = emptyList() - _chooseCards.value = emptyList() - _shortCards.value = emptyList() + private fun performClearAll() { + updateState { + copy( + cards = emptyList(), + chooseCards = emptyList(), + shortCards = emptyList() + ) + } } -} \ No newline at end of file + + fun setCardId(cardId: Int) = onAction(FriendCardAction.SetCardId(cardId)) + + fun getCardDetail(id: Long) = onAction(FriendCardAction.GetCardDetail(id)) + + fun getAnswerLength(answer: String): Int = answer.length + + fun clearAllData() = onAction(FriendCardAction.ClearAllData) + + fun clearAll() = onAction(FriendCardAction.ClearAll) +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/friend/FriendRecordContract.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/friend/FriendRecordContract.kt new file mode 100644 index 00000000..52cfa89c --- /dev/null +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/friend/FriendRecordContract.kt @@ -0,0 +1,23 @@ +package com.toyou.toyouandroid.presentation.fragment.record.friend + +import com.toyou.core.common.mvi.UiAction +import com.toyou.core.common.mvi.UiEvent +import com.toyou.core.common.mvi.UiState +import com.toyou.toyouandroid.data.record.dto.DiaryCardNum +import com.toyou.toyouandroid.data.record.dto.DiaryCardPerDay + +data class FriendRecordUiState( + val diaryCardsNum: List = emptyList(), + val diaryCardPerDay: List = emptyList(), + val isLoading: Boolean = false +) : UiState + +sealed interface FriendRecordEvent : UiEvent { + data class ShowError(val message: String) : FriendRecordEvent + data object TokenExpired : FriendRecordEvent +} + +sealed interface FriendRecordAction : UiAction { + data class LoadDiaryCardsNum(val year: Int, val month: Int) : FriendRecordAction + data class LoadDiaryCardPerDay(val year: Int, val month: Int, val day: Int) : FriendRecordAction +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/friend/FriendRecordViewModel.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/friend/FriendRecordViewModel.kt index 5cafbe62..84b75c46 100644 --- a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/friend/FriendRecordViewModel.kt +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/friend/FriendRecordViewModel.kt @@ -2,114 +2,110 @@ package com.toyou.toyouandroid.presentation.fragment.record.friend import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.toyou.core.common.mvi.MviViewModel import com.toyou.toyouandroid.data.record.dto.DiaryCardNum -import com.toyou.toyouandroid.data.record.dto.DiaryCardNumResponse import com.toyou.toyouandroid.data.record.dto.DiaryCardPerDay -import com.toyou.toyouandroid.data.record.dto.DiaryCardPerDayResponse -import com.toyou.toyouandroid.domain.record.RecordRepository +import com.toyou.toyouandroid.domain.record.IRecordRepository import com.toyou.toyouandroid.utils.TokenManager import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response import timber.log.Timber import javax.inject.Inject @HiltViewModel class FriendRecordViewModel @Inject constructor( - private val repository: RecordRepository, + private val repository: IRecordRepository, private val tokenManager: TokenManager -) : ViewModel() { +) : MviViewModel( + initialState = FriendRecordUiState() +) { private val _diaryCardsNum = MutableLiveData>() val diaryCardsNum: LiveData> get() = _diaryCardsNum - fun loadDiaryCardsNum(year: Int, month: Int) { - Timber.tag("FriendRecordViewModel").d("loadDiaryCards called with year: $year, month: $month") + private val _diaryCardPerDay = MutableLiveData>() + val diaryCardPerDay: LiveData> get() = _diaryCardPerDay - viewModelScope.launch { - val response = repository.getFriendRecordNum(year, month) - response.enqueue(object : Callback { - override fun onResponse( - call: Call, - response: Response - ) { - if (response.isSuccessful) { - val cardList = response.body()?.result?.cardList ?: emptyList() - _diaryCardsNum.value = cardList - Timber.tag("FriendRecordViewModel").d("API Success: Received ${cardList.size} diary cards") - Timber.tag("FriendRecordViewModel").d("API Success: Received $cardList") + init { + state.onEach { uiState -> + _diaryCardsNum.value = uiState.diaryCardsNum + _diaryCardPerDay.value = uiState.diaryCardPerDay + }.launchIn(viewModelScope) + } - } else { - // 오류 처리 - val errorMessage = response.message() - handleError(errorMessage) - Timber.tag("FriendRecordViewModel").d("API Error: $errorMessage") + override fun handleAction(action: FriendRecordAction) { + when (action) { + is FriendRecordAction.LoadDiaryCardsNum -> performLoadDiaryCardsNum(action.year, action.month) + is FriendRecordAction.LoadDiaryCardPerDay -> performLoadDiaryCardPerDay(action.year, action.month, action.day) + } + } - tokenManager.refreshToken( - onSuccess = { loadDiaryCardsNum(year, month) }, - onFailure = { Timber.e("loadDiaryCards API call failed") } - ) - } - } + private fun performLoadDiaryCardsNum(year: Int, month: Int) { + Timber.tag("FriendRecordViewModel").d("loadDiaryCards called with year: $year, month: $month") - override fun onFailure(call: Call, t: Throwable) { - // 네트워크 오류 처리 - val errorMessage = t.message ?: "Unknown error" - handleError(errorMessage) - Timber.tag("FriendRecordViewModel").d("Network Failure: $errorMessage") + viewModelScope.launch { + try { + val response = repository.getFriendRecordNum(year, month) + if (response.isSuccessful) { + val cardList = response.body()?.result?.cardList ?: emptyList() + updateState { copy(diaryCardsNum = cardList) } + Timber.tag("FriendRecordViewModel").d("API Success: Received ${cardList.size} diary cards") + Timber.tag("FriendRecordViewModel").d("API Success: Received $cardList") + } else { + val errorMessage = response.message() + performHandleError(errorMessage) + Timber.tag("FriendRecordViewModel").d("API Error: $errorMessage") + + tokenManager.refreshToken( + onSuccess = { performLoadDiaryCardsNum(year, month) }, + onFailure = { Timber.e("loadDiaryCards API call failed") } + ) } - }) + } catch (e: Exception) { + val errorMessage = e.message ?: "Unknown error" + performHandleError(errorMessage) + Timber.tag("FriendRecordViewModel").d("Network Failure: $errorMessage") + } } } - private val _diaryCardPerDay = MutableLiveData>() - val diaryCardPerDay: LiveData> get() = _diaryCardPerDay - - fun loadDiaryCardPerDay(year: Int, month: Int, day: Int) { + private fun performLoadDiaryCardPerDay(year: Int, month: Int, day: Int) { Timber.tag("FriendRecordViewModel").d("loadDiaryCards called with year: $year, month: $month") viewModelScope.launch { - val response = repository.getFriendRecordPerDay(year, month, day) - response.enqueue(object : Callback { - override fun onResponse( - call: Call, - response: Response - ) { - if (response.isSuccessful) { - val cardList = response.body()?.result?.cardList ?: emptyList() - _diaryCardPerDay.value = cardList - Timber.tag("FriendRecordViewModel").d("API Success: Received ${cardList.size} diary cards") - Timber.tag("FriendRecordViewModel").d("API Success: Received $cardList") + try { + val response = repository.getFriendRecordPerDay(year, month, day) + if (response.isSuccessful) { + val cardList = response.body()?.result?.cardList ?: emptyList() + updateState { copy(diaryCardPerDay = cardList) } + Timber.tag("FriendRecordViewModel").d("API Success: Received ${cardList.size} diary cards") + Timber.tag("FriendRecordViewModel").d("API Success: Received $cardList") + } else { + val errorMessage = response.message() + performHandleError(errorMessage) + Timber.tag("FriendRecordViewModel").d("API Error: $errorMessage") - } else { - // 오류 처리 - val errorMessage = response.message() - handleError(errorMessage) - Timber.tag("FriendRecordViewModel").d("API Error: $errorMessage") - - tokenManager.refreshToken( - onSuccess = { loadDiaryCardPerDay(year, month, day) }, - onFailure = { Timber.e("loadDiaryCards API call failed") } - ) - } - } - - override fun onFailure(call: Call, t: Throwable) { - // 네트워크 오류 처리 - val errorMessage = t.message ?: "Unknown error" - handleError(errorMessage) - Timber.tag("FriendRecordViewModel").d("Network Failure: $errorMessage") + tokenManager.refreshToken( + onSuccess = { performLoadDiaryCardPerDay(year, month, day) }, + onFailure = { Timber.e("loadDiaryCards API call failed") } + ) } - }) + } catch (e: Exception) { + val errorMessage = e.message ?: "Unknown error" + performHandleError(errorMessage) + Timber.tag("FriendRecordViewModel").d("Network Failure: $errorMessage") + } } } - private fun handleError(message: String) { - Timber.tag("MyRecordViewModel").d("Error: $message") - // 오류 메시지 처리 로직 (예: 사용자에게 알림, UI 업데이트 등) + private fun performHandleError(message: String) { + Timber.tag("FriendRecordViewModel").d("Error: $message") + sendEvent(FriendRecordEvent.ShowError(message)) } -} \ No newline at end of file + + fun loadDiaryCardsNum(year: Int, month: Int) = onAction(FriendRecordAction.LoadDiaryCardsNum(year, month)) + fun loadDiaryCardPerDay(year: Int, month: Int, day: Int) = onAction(FriendRecordAction.LoadDiaryCardPerDay(year, month, day)) +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/my/MyCardContract.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/my/MyCardContract.kt new file mode 100644 index 00000000..7583b9cd --- /dev/null +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/my/MyCardContract.kt @@ -0,0 +1,38 @@ +package com.toyou.toyouandroid.presentation.fragment.record.my + +import com.toyou.core.common.mvi.UiAction +import com.toyou.core.common.mvi.UiEvent +import com.toyou.core.common.mvi.UiState +import com.toyou.toyouandroid.model.CardModel +import com.toyou.toyouandroid.model.CardShortModel +import com.toyou.toyouandroid.model.ChooseModel +import com.toyou.toyouandroid.model.PreviewCardModel +import com.toyou.toyouandroid.model.PreviewChooseModel + +data class MyCardUiState( + val cards: List = emptyList(), + val shortCards: List = emptyList(), + val previewCards: List = emptyList(), + val chooseCards: List = emptyList(), + val previewChoose: List = emptyList(), + val exposure: Boolean = false, + val answer: String = "", + val cardId: Int = 0, + val date: String = "", + val emotion: String = "", + val receiver: String = "", + val isLoading: Boolean = false +) : UiState + +sealed interface MyCardEvent : UiEvent { + data class ShowError(val message: String) : MyCardEvent +} + +sealed interface MyCardAction : UiAction { + data class LoadCardDetail(val id: Long) : MyCardAction + data class SetCardId(val cardId: Int) : MyCardAction + data class SetAnswer(val answer: String) : MyCardAction + data object ToggleExposure : MyCardAction + data object ClearAllData : MyCardAction + data object ClearAll : MyCardAction +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/my/MyCardViewModel.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/my/MyCardViewModel.kt index 7940e12f..8405685f 100644 --- a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/my/MyCardViewModel.kt +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/my/MyCardViewModel.kt @@ -2,9 +2,9 @@ package com.toyou.toyouandroid.presentation.fragment.record.my import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.toyou.toyouandroid.domain.record.RecordRepository +import com.toyou.core.common.mvi.MviViewModel +import com.toyou.toyouandroid.domain.record.IRecordRepository import com.toyou.toyouandroid.model.CardModel import com.toyou.toyouandroid.model.CardShortModel import com.toyou.toyouandroid.model.ChooseModel @@ -18,33 +18,33 @@ import javax.inject.Inject @HiltViewModel class MyCardViewModel @Inject constructor( - private val recordRepository: RecordRepository, + private val recordRepository: IRecordRepository, private val tokenManager: TokenManager -) : ViewModel() { +) : MviViewModel(MyCardUiState()) { + private val _cards = MutableLiveData>() val cards: LiveData> get() = _cards + private val _shortCards = MutableLiveData>() val shortCards: LiveData> get() = _shortCards + private val _previewCards = MutableLiveData>() - val previewCards : LiveData> get() = _previewCards + val previewCards: LiveData> get() = _previewCards private val _chooseCards = MutableLiveData>() - val chooseCards : LiveData> get() = _chooseCards + val chooseCards: LiveData> get() = _chooseCards + private val _previewChoose = MutableLiveData>() - val previewChoose : LiveData> get() = _previewChoose + val previewChoose: LiveData> get() = _previewChoose val _exposure = MutableLiveData() - val exposure : LiveData get() = _exposure + val exposure: LiveData get() = _exposure val answer = MutableLiveData() + private val _cardId = MutableLiveData().apply { value = 0 } val cardId: LiveData get() = _cardId - // cardId 설정을 위한 함수 추가 - fun setCardId(cardId: Int) { - _cardId.value = cardId - } - private val _date = MutableLiveData() val date: LiveData get() = _date @@ -54,21 +54,47 @@ class MyCardViewModel @Inject constructor( private val _receiver = MutableLiveData() val receiver: LiveData get() = _receiver - fun getCardDetail(id : Long) { + init { viewModelScope.launch { + state.collect { newState -> + _cards.value = newState.cards + _shortCards.value = newState.shortCards + _previewCards.value = newState.previewCards + _chooseCards.value = newState.chooseCards + _previewChoose.value = newState.previewChoose + _exposure.value = newState.exposure + answer.value = newState.answer + _cardId.value = newState.cardId + _date.value = newState.date + _emotion.value = newState.emotion + _receiver.value = newState.receiver + } + } + } + + override fun handleAction(action: MyCardAction) { + when (action) { + is MyCardAction.LoadCardDetail -> performGetCardDetail(action.id) + is MyCardAction.SetCardId -> performSetCardId(action.cardId) + is MyCardAction.SetAnswer -> performSetAnswer(action.answer) + is MyCardAction.ToggleExposure -> performToggleExposure() + is MyCardAction.ClearAllData -> performClearAllData() + is MyCardAction.ClearAll -> performClearAll() + } + } + + private fun performGetCardDetail(id: Long) { + viewModelScope.launch { + updateState { copy(isLoading = true) } try { val response = recordRepository.getCardDetails(id) if (response.isSuccess) { val detailCard = response.result val previewCardList = mutableListOf() - _exposure.value = detailCard.exposure - _date.value = detailCard.date - _emotion.value = detailCard.emotion - _receiver.value = detailCard.receiver detailCard.questions.let { questionList -> questionList.forEach { question -> - val previewCard = when(question.type) { + val previewCard = when (question.type) { "OPTIONAL" -> { PreviewCardModel( question = question.content, @@ -79,7 +105,6 @@ class MyCardViewModel @Inject constructor( id = question.id ) } - "SHORT_ANSWER" -> { PreviewCardModel( question = question.content, @@ -90,7 +115,6 @@ class MyCardViewModel @Inject constructor( id = question.id ) } - else -> { PreviewCardModel( question = question.content, @@ -103,43 +127,89 @@ class MyCardViewModel @Inject constructor( } } previewCardList.add(previewCard) - _previewCards.value = previewCardList } } + updateState { + copy( + exposure = detailCard.exposure, + date = detailCard.date, + emotion = detailCard.emotion, + receiver = detailCard.receiver, + previewCards = previewCardList, + isLoading = false + ) + } } else { - // 오류 처리 Timber.tag("CardViewModel").d("detail API 호출 실패: ${response.message}") - + updateState { copy(isLoading = false) } tokenManager.refreshToken( - onSuccess = { getCardDetail(id) }, // 토큰 갱신 후 다시 요청 + onSuccess = { performGetCardDetail(id) }, onFailure = { Timber.e("Failed to refresh token and get card detail") } ) } } catch (e: Exception) { Timber.tag("CardViewModel").d("detail 예외 발생: ${e.message}") + updateState { copy(isLoading = false) } + sendEvent(MyCardEvent.ShowError(e.message ?: "Unknown error")) } } } + private fun performSetCardId(cardId: Int) { + updateState { copy(cardId = cardId) } + } + + private fun performSetAnswer(answer: String) { + updateState { copy(answer = answer) } + } + + private fun performToggleExposure() { + updateState { copy(exposure = !exposure) } + Timber.tag("isLockSelected").d(currentState.exposure.toString()) + } + + private fun performClearAllData() { + updateState { + copy( + previewCards = emptyList(), + previewChoose = emptyList(), + exposure = false + ) + } + } + + private fun performClearAll() { + updateState { + copy( + cards = emptyList(), + chooseCards = emptyList(), + shortCards = emptyList() + ) + } + } + fun getAnswerLength(answer: String): Int { return answer.length } + fun setCardId(cardId: Int) { + onAction(MyCardAction.SetCardId(cardId)) + } + + fun getCardDetail(id: Long) { + onAction(MyCardAction.LoadCardDetail(id)) + } + fun isLockSelected() { - _exposure.value = _exposure.value?.not() - Timber.tag("isLockSelected").d(_exposure.value.toString()) + onAction(MyCardAction.ToggleExposure) } fun clearAllData() { - _previewCards.value = emptyList() - _previewChoose.value = emptyList() - _exposure.value = false + onAction(MyCardAction.ClearAllData) } - fun clearAll(){ - _cards.value = emptyList() - _chooseCards.value = emptyList() - _shortCards.value = emptyList() + fun clearAll() { + onAction(MyCardAction.ClearAll) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/my/MyRecordContract.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/my/MyRecordContract.kt new file mode 100644 index 00000000..97d9fd81 --- /dev/null +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/my/MyRecordContract.kt @@ -0,0 +1,23 @@ +package com.toyou.toyouandroid.presentation.fragment.record.my + +import com.toyou.core.common.mvi.UiAction +import com.toyou.core.common.mvi.UiEvent +import com.toyou.core.common.mvi.UiState +import com.toyou.toyouandroid.data.record.dto.DiaryCard + +data class MyRecordUiState( + val diaryCards: List = emptyList(), + val isLoading: Boolean = false +) : UiState + +sealed interface MyRecordEvent : UiEvent { + data class ShowError(val message: String) : MyRecordEvent + data object DeleteSuccess : MyRecordEvent + data object PatchSuccess : MyRecordEvent +} + +sealed interface MyRecordAction : UiAction { + data class LoadDiaryCards(val year: Int, val month: Int) : MyRecordAction + data class DeleteDiaryCard(val cardId: Int) : MyRecordAction + data class PatchDiaryCard(val cardId: Int) : MyRecordAction +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/my/MyRecordViewModel.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/my/MyRecordViewModel.kt index f117607f..ceb6e254 100644 --- a/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/my/MyRecordViewModel.kt +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/fragment/record/my/MyRecordViewModel.kt @@ -2,145 +2,133 @@ package com.toyou.toyouandroid.presentation.fragment.record.my import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.toyou.toyouandroid.data.record.dto.DeleteDiaryCardResponse +import com.toyou.core.common.mvi.MviViewModel import com.toyou.toyouandroid.data.record.dto.DiaryCard -import com.toyou.toyouandroid.data.record.dto.DiaryCardResponse -import com.toyou.toyouandroid.data.record.dto.PatchDiaryCardResponse -import com.toyou.toyouandroid.domain.record.RecordRepository +import com.toyou.toyouandroid.domain.record.IRecordRepository import com.toyou.toyouandroid.utils.TokenManager import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response import timber.log.Timber import javax.inject.Inject @HiltViewModel class MyRecordViewModel @Inject constructor( - private val repository: RecordRepository, + private val repository: IRecordRepository, private val tokenManager: TokenManager -) : ViewModel() { +) : MviViewModel( + initialState = MyRecordUiState() +) { private val _diaryCards = MutableLiveData>() val diaryCards: LiveData> get() = _diaryCards - fun loadDiaryCards(year: Int, month: Int) { + init { + state.onEach { uiState -> + _diaryCards.value = uiState.diaryCards + }.launchIn(viewModelScope) + } + + override fun handleAction(action: MyRecordAction) { + when (action) { + is MyRecordAction.LoadDiaryCards -> performLoadDiaryCards(action.year, action.month) + is MyRecordAction.DeleteDiaryCard -> performDeleteDiaryCard(action.cardId) + is MyRecordAction.PatchDiaryCard -> performPatchDiaryCard(action.cardId) + } + } + + private fun performLoadDiaryCards(year: Int, month: Int) { Timber.tag("MyRecordViewModel").d("loadDiaryCards called with year: $year, month: $month") viewModelScope.launch { - val response = repository.getMyRecord(year, month) - response.enqueue(object : Callback { - override fun onResponse( - call: Call, - response: Response - ) { - if (response.isSuccessful) { - val cardList = response.body()?.result?.cardList ?: emptyList() - _diaryCards.value = cardList - Timber.tag("MyRecordViewModel").d("API Success: Received ${cardList.size} diary cards") - Timber.tag("MyRecordViewModel").d("API Success: Received $cardList") - - } else { - // 오류 처리 - val errorMessage = response.message() - handleError(errorMessage) - Timber.tag("MyRecordViewModel").d("API Error: $errorMessage") - - tokenManager.refreshToken( - onSuccess = { loadDiaryCards(year, month) }, - onFailure = { Timber.e("loadDiaryCards API call failed") } - ) - } - } - - override fun onFailure(call: Call, t: Throwable) { - // 네트워크 오류 처리 - val errorMessage = t.message ?: "Unknown error" - handleError(errorMessage) - Timber.tag("MyRecordViewModel").d("Network Failure: $errorMessage") + updateState { copy(isLoading = true) } + try { + val response = repository.getMyRecord(year, month) + if (response.isSuccessful) { + val cardList = response.body()?.result?.cardList ?: emptyList() + updateState { copy(diaryCards = cardList, isLoading = false) } + Timber.tag("MyRecordViewModel").d("API Success: Received ${cardList.size} diary cards") + Timber.tag("MyRecordViewModel").d("API Success: Received $cardList") + } else { + val errorMessage = response.message() + updateState { copy(isLoading = false) } + sendEvent(MyRecordEvent.ShowError(errorMessage)) + Timber.tag("MyRecordViewModel").d("API Error: $errorMessage") + + tokenManager.refreshToken( + onSuccess = { performLoadDiaryCards(year, month) }, + onFailure = { Timber.e("loadDiaryCards API call failed") } + ) } - }) + } catch (e: Exception) { + val errorMessage = e.message ?: "Unknown error" + updateState { copy(isLoading = false) } + sendEvent(MyRecordEvent.ShowError(errorMessage)) + Timber.tag("MyRecordViewModel").d("Network Failure: $errorMessage") + } } } - fun deleteDiaryCard(cardId: Int) { + private fun performDeleteDiaryCard(cardId: Int) { Timber.tag("MyRecordViewModel").d("$cardId") viewModelScope.launch { - val response = repository.deleteDiaryCard(cardId) - response.enqueue(object : Callback { - override fun onResponse( - call: Call, - response: Response - ) { - if (response.isSuccessful) { - Timber.tag("MyRecordViewModel").d("API Success: Received $response") - } else { - // 오류 처리 - val errorMessage = response.message() - handleError(errorMessage) - Timber.tag("MyRecordViewModel").d("API Error: $errorMessage") - - tokenManager.refreshToken( - onSuccess = { deleteDiaryCard(cardId) }, - onFailure = { Timber.e("deleteDiaryCard API call failed") } - ) - } - } - - override fun onFailure(call: Call, t: Throwable) { - // 네트워크 오류 처리 - val errorMessage = t.message ?: "Unknown error" - handleError(errorMessage) - Timber.tag("MyRecordViewModel").d("Network Failure: $errorMessage") + try { + val response = repository.deleteDiaryCard(cardId) + if (response.isSuccessful) { + Timber.tag("MyRecordViewModel").d("API Success: Received $response") + sendEvent(MyRecordEvent.DeleteSuccess) + } else { + val errorMessage = response.message() + sendEvent(MyRecordEvent.ShowError(errorMessage)) + Timber.tag("MyRecordViewModel").d("API Error: $errorMessage") + + tokenManager.refreshToken( + onSuccess = { performDeleteDiaryCard(cardId) }, + onFailure = { Timber.e("deleteDiaryCard API call failed") } + ) } - }) + } catch (e: Exception) { + val errorMessage = e.message ?: "Unknown error" + sendEvent(MyRecordEvent.ShowError(errorMessage)) + Timber.tag("MyRecordViewModel").d("Network Failure: $errorMessage") + } } } - fun patchDiaryCard(cardId: Int) { + private fun performPatchDiaryCard(cardId: Int) { Timber.tag("MyRecordViewModel").d("$cardId") viewModelScope.launch { - val response = repository.patchDiaryCard(cardId) - response.enqueue(object : Callback { - override fun onResponse( - call: Call, - response: Response - ) { - if (response.isSuccessful) { - Timber.tag("MyRecordViewModel").d("API Success: Received $response") - } else { - // 오류 처리 - val errorMessage = response.message() - val errorBody = response.errorBody()?.string() // 에러 본문을 읽기 - Timber.tag("MyRecordViewModel").d("API Error: $errorMessage") - Timber.tag("MyRecordViewModel").d("Error Body: $errorBody") - - handleError(errorMessage) - - tokenManager.refreshToken( - onSuccess = { patchDiaryCard(cardId) }, - onFailure = { Timber.e("patchDiaryCard API call failed") } - ) - } + try { + val response = repository.patchDiaryCard(cardId) + if (response.isSuccessful) { + Timber.tag("MyRecordViewModel").d("API Success: Received $response") + sendEvent(MyRecordEvent.PatchSuccess) + } else { + val errorMessage = response.message() + val errorBody = response.errorBody()?.string() + Timber.tag("MyRecordViewModel").d("API Error: $errorMessage") + Timber.tag("MyRecordViewModel").d("Error Body: $errorBody") + + sendEvent(MyRecordEvent.ShowError(errorMessage)) + + tokenManager.refreshToken( + onSuccess = { performPatchDiaryCard(cardId) }, + onFailure = { Timber.e("patchDiaryCard API call failed") } + ) } - - override fun onFailure(call: Call, t: Throwable) { - // 네트워크 오류 처리 - val errorMessage = t.message ?: "Unknown error" - handleError(errorMessage) - Timber.tag("MyRecordViewModel").d("Network Failure: $errorMessage") - } - }) + } catch (e: Exception) { + val errorMessage = e.message ?: "Unknown error" + sendEvent(MyRecordEvent.ShowError(errorMessage)) + Timber.tag("MyRecordViewModel").d("Network Failure: $errorMessage") + } } } - private fun handleError(message: String) { - Timber.tag("MyRecordViewModel").d("Error: $message") - // 오류 메시지 처리 로직 (예: 사용자에게 알림, UI 업데이트 등) - } -} \ No newline at end of file + fun loadDiaryCards(year: Int, month: Int) = onAction(MyRecordAction.LoadDiaryCards(year, month)) + fun deleteDiaryCard(cardId: Int) = onAction(MyRecordAction.DeleteDiaryCard(cardId)) + fun patchDiaryCard(cardId: Int) = onAction(MyRecordAction.PatchDiaryCard(cardId)) +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/viewmodel/CardContract.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/viewmodel/CardContract.kt new file mode 100644 index 00000000..1e502c87 --- /dev/null +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/viewmodel/CardContract.kt @@ -0,0 +1,57 @@ +package com.toyou.toyouandroid.presentation.viewmodel + +import com.toyou.core.common.mvi.UiAction +import com.toyou.core.common.mvi.UiEvent +import com.toyou.core.common.mvi.UiState +import com.toyou.toyouandroid.model.CardModel +import com.toyou.toyouandroid.model.CardShortModel +import com.toyou.toyouandroid.model.ChooseModel +import com.toyou.toyouandroid.model.PreviewCardModel +import com.toyou.toyouandroid.model.PreviewChooseModel + +data class CardUiState( + val cards: List = emptyList(), + val shortCards: List = emptyList(), + val previewCards: List = emptyList(), + val chooseCards: List = emptyList(), + val previewChoose: List = emptyList(), + val exposure: Boolean = true, + val answer: String = "", + val cardId: Int = 0, + val isAllAnswersFilled: Boolean = false, + val countSelection: Int = 0, + val lockDisabled: Boolean = false, + val date: String = "", + val emotion: String = "", + val receiver: String = "" +) : UiState + +sealed interface CardEvent : UiEvent { + data object SendDataSuccess : CardEvent + data object SendDataFailed : CardEvent + data object PatchCardSuccess : CardEvent + data object PatchCardFailed : CardEvent + data class ShowError(val message: String) : CardEvent +} + +sealed interface CardAction : UiAction { + data class SetCardId(val cardId: Int) : CardAction + data class DisableLock(val lock: Boolean) : CardAction + data class SetCardCount(val count: Int, val count2: Int, val count3: Int) : CardAction + data class UpdateCardInputStatus(val index: Int, val isFilled: Boolean) : CardAction + data class UpdateCardInputStatusLong(val index: Int, val isFilled: Boolean) : CardAction + data class UpdateCardInputStatusChoose(val index: Int, val isFilled: Boolean) : CardAction + data class SendData(val previewCardModels: List, val exposure: Boolean) : CardAction + data object GetAllData : CardAction + data class UpdateButtonState(val position: Int, val isSelected: Boolean) : CardAction + data class UpdateShortButtonState(val position: Int, val isSelected: Boolean) : CardAction + data class UpdateChooseButton(val position: Int, val isSelected: Boolean) : CardAction + data object UpdateAllPreviews : CardAction + data object ClearAllData : CardAction + data object ClearAll : CardAction + data class PatchCard(val previewCardModels: List, val exposure: Boolean, val id: Int) : CardAction + data class CountSelect(val selection: Boolean) : CardAction + data object ResetSelect : CardAction + data object ToggleLock : CardAction + data class UpdateAnswer(val answer: String) : CardAction +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/viewmodel/CardViewModel.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/viewmodel/CardViewModel.kt index 76b1b979..77800fd7 100644 --- a/app/src/main/java/com/toyou/toyouandroid/presentation/viewmodel/CardViewModel.kt +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/viewmodel/CardViewModel.kt @@ -2,10 +2,10 @@ package com.toyou.toyouandroid.presentation.viewmodel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.toyou.core.common.mvi.MviViewModel import com.toyou.toyouandroid.data.create.dto.response.QuestionsDto -import com.toyou.toyouandroid.domain.create.repository.CreateRepository +import com.toyou.toyouandroid.domain.create.repository.ICreateRepository import com.toyou.toyouandroid.model.CardModel import com.toyou.toyouandroid.model.CardShortModel import com.toyou.toyouandroid.model.ChooseModel @@ -13,6 +13,8 @@ import com.toyou.toyouandroid.model.PreviewCardModel import com.toyou.toyouandroid.model.PreviewChooseModel import com.toyou.toyouandroid.utils.TokenManager import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -20,89 +22,136 @@ import javax.inject.Inject @HiltViewModel class CardViewModel @Inject constructor( private val tokenManager: TokenManager, - private val repository: CreateRepository -) : ViewModel() { + private val repository: ICreateRepository +) : MviViewModel( + initialState = CardUiState() +) { + private var inputStatus: MutableList = mutableListOf() + private var inputLongStatus: MutableList = mutableListOf() + private var inputChooseStatus: MutableList = mutableListOf() + var toastShow: Boolean = false + private val _cards = MutableLiveData>() val cards: LiveData> get() = _cards + private val _shortCards = MutableLiveData>() val shortCards: LiveData> get() = _shortCards + private val _previewCards = MutableLiveData>() - val previewCards : LiveData> get() = _previewCards + val previewCards: LiveData> get() = _previewCards private val _chooseCards = MutableLiveData>() - val chooseCards : LiveData> get() = _chooseCards + val chooseCards: LiveData> get() = _chooseCards + private val _previewChoose = MutableLiveData>() - val previewChoose : LiveData> get() = _previewChoose - //private val repository = CreateRepository(tokenManager) -// private val homeRepository = HomeRepository() + val previewChoose: LiveData> get() = _previewChoose - val exposure : LiveData get() = _exposure private val _exposure = MutableLiveData() + val exposure: LiveData get() = _exposure val answer = MutableLiveData() + private val _cardId = MutableLiveData().apply { value = 0 } val cardId: LiveData get() = _cardId - // cardId 설정을 위한 함수 추가 - fun setCardId(cardId: Int) { - _cardId.value = cardId - } - private val _isAllAnswersFilled = MutableLiveData(false) val isAllAnswersFilled: LiveData get() = _isAllAnswersFilled - private var inputStatus: MutableList = mutableListOf() - private var inputLongStatus : MutableList = mutableListOf() - private var inputChooseStatus : MutableList = mutableListOf() - private val _countSelection = MutableLiveData() - val countSelection : LiveDataget() = _countSelection - var toastShow : Boolean = false + val countSelection: LiveData get() = _countSelection + private val _lockDisabled = MutableLiveData(false) - val lockDisabled : LiveData get() = _lockDisabled + val lockDisabled: LiveData get() = _lockDisabled + + private val _date = MutableLiveData() + val date: LiveData get() = _date + + private val _emotion = MutableLiveData() + val emotion: LiveData get() = _emotion + + private val _receiver = MutableLiveData() + val receiver: LiveData get() = _receiver init { - _exposure.value = true + state.onEach { uiState -> + _cards.value = uiState.cards + _shortCards.value = uiState.shortCards + _previewCards.value = uiState.previewCards + _chooseCards.value = uiState.chooseCards + _previewChoose.value = uiState.previewChoose + _exposure.value = uiState.exposure + _cardId.value = uiState.cardId + _isAllAnswersFilled.value = uiState.isAllAnswersFilled + _countSelection.value = uiState.countSelection + _lockDisabled.value = uiState.lockDisabled + _date.value = uiState.date + _emotion.value = uiState.emotion + _receiver.value = uiState.receiver + }.launchIn(viewModelScope) } - private fun initializeCountSelection() { - _countSelection.value = _previewCards.value?.size ?: 0 - Timber.tag("선택9 초기화") + override fun handleAction(action: CardAction) { + when (action) { + is CardAction.SetCardId -> performSetCardId(action.cardId) + is CardAction.DisableLock -> performDisableLock(action.lock) + is CardAction.SetCardCount -> performSetCardCount(action.count, action.count2, action.count3) + is CardAction.UpdateCardInputStatus -> performUpdateCardInputStatus(action.index, action.isFilled) + is CardAction.UpdateCardInputStatusLong -> performUpdateCardInputStatusLong(action.index, action.isFilled) + is CardAction.UpdateCardInputStatusChoose -> performUpdateCardInputStatusChoose(action.index, action.isFilled) + is CardAction.SendData -> performSendData(action.previewCardModels, action.exposure) + is CardAction.GetAllData -> performGetAllData() + is CardAction.UpdateButtonState -> performUpdateButtonState(action.position, action.isSelected) + is CardAction.UpdateShortButtonState -> performUpdateShortButtonState(action.position, action.isSelected) + is CardAction.UpdateChooseButton -> performUpdateChooseButton(action.position, action.isSelected) + is CardAction.UpdateAllPreviews -> performUpdateAllPreviews() + is CardAction.ClearAllData -> performClearAllData() + is CardAction.ClearAll -> performClearAll() + is CardAction.PatchCard -> performPatchCard(action.previewCardModels, action.exposure, action.id) + is CardAction.CountSelect -> performCountSelect(action.selection) + is CardAction.ResetSelect -> performResetSelect() + is CardAction.ToggleLock -> performToggleLock() + is CardAction.UpdateAnswer -> performUpdateAnswer(action.answer) + } + } + + private fun performSetCardId(cardId: Int) { + updateState { copy(cardId = cardId) } } - fun disableLock(lock : Boolean){ - if (lock) - _lockDisabled.value = true - else - _lockDisabled.value = false + private fun performDisableLock(lock: Boolean) { + updateState { copy(lockDisabled = lock) } } - fun setCardCount(count: Int, count2 : Int, count3 : Int) { - inputStatus = MutableList(count){false} - inputLongStatus = MutableList(count2){false} - inputChooseStatus = MutableList(count3){false} + private fun performSetCardCount(count: Int, count2: Int, count3: Int) { + inputStatus = MutableList(count) { false } + inputLongStatus = MutableList(count2) { false } + inputChooseStatus = MutableList(count3) { false } } - fun updateCardInputStatus(index: Int, isFilled: Boolean) { + private fun performUpdateCardInputStatus(index: Int, isFilled: Boolean) { inputStatus[index] = isFilled - checkIfAllAnswersFilled() // 입력 상태가 변경될 때마다 확인 + performCheckIfAllAnswersFilled() } - fun updateCardInputStatusLong(index: Int, isFilled: Boolean) { + private fun performUpdateCardInputStatusLong(index: Int, isFilled: Boolean) { inputLongStatus[index] = isFilled - checkIfAllAnswersFilled() + performCheckIfAllAnswersFilled() } - fun updateCardInputStatusChoose(index: Int, isFilled: Boolean) { + + private fun performUpdateCardInputStatusChoose(index: Int, isFilled: Boolean) { inputChooseStatus[index] = isFilled - checkIfAllAnswersFilled() + performCheckIfAllAnswersFilled() } - private fun checkIfAllAnswersFilled() { - _isAllAnswersFilled.value = inputStatus.count { it } == inputStatus.size && inputLongStatus.count { it } == inputLongStatus.size && inputChooseStatus.count { it } == inputChooseStatus.size - Timber.tag("선택9${isAllAnswersFilled.value.toString()}") + private fun performCheckIfAllAnswersFilled() { + val allFilled = inputStatus.count { it } == inputStatus.size && + inputLongStatus.count { it } == inputLongStatus.size && + inputChooseStatus.count { it } == inputChooseStatus.size + updateState { copy(isAllAnswersFilled = allFilled) } + Timber.tag("선택9${currentState.isAllAnswersFilled}") } - fun sendData(previewCardModels: List, exposure: Boolean) { + private fun performSendData(previewCardModels: List, exposure: Boolean) { viewModelScope.launch { try { val response = repository.postCardData(previewCardModels, exposure) @@ -110,47 +159,43 @@ class CardViewModel @Inject constructor( if (response.isSuccess) { Timber.tag("sendData").d("카드 전송 성공") response.result.let { answerPost -> - // ID 값이 필요한 추가 작업 수행 - _cardId.value = answerPost.id - Timber.tag("sendData").d("카드 ID: ${_cardId.value}") + updateState { copy(cardId = answerPost.id) } + Timber.tag("sendData").d("카드 ID: ${currentState.cardId}") } + sendEvent(CardEvent.SendDataSuccess) } else { Timber.tag("sendData").d("카드 전송 실패: ${response.message}") - tokenManager.refreshToken( - onSuccess = { sendData(previewCardModels, exposure) }, + onSuccess = { performSendData(previewCardModels, exposure) }, onFailure = { Timber.e("sendData API Call Failed") } ) } } catch (e: Exception) { Timber.tag("sendData").e("Exception: ${e.message}") - tokenManager.refreshToken( - onSuccess = { sendData(previewCardModels, exposure) }, + onSuccess = { performSendData(previewCardModels, exposure) }, onFailure = { Timber.e("sendData API Call Failed") } ) } } } - fun getAllData() { + private fun performGetAllData() { viewModelScope.launch { try { val response = repository.getAllData() if (response.isSuccess) { val questionsDto = response.result - if (previewCards.value == null) - questionsDto.let { - mapToModels(it) - Timber.tag("get").d(questionsDto.toString()) - } - else - mapToPatchModels(questionsDto) + if (currentState.previewCards.isEmpty()) { + performMapToModels(questionsDto) + Timber.tag("get").d(questionsDto.toString()) + } else { + performMapToPatchModels(questionsDto) + } } else { - // 오류 처리 tokenManager.refreshToken( - onSuccess = { getAllData() }, - onFailure = { Timber.tag("CardViewModel").d("refresh error")} + onSuccess = { performGetAllData() }, + onFailure = { Timber.tag("CardViewModel").d("refresh error") } ) } } catch (_: Exception) { @@ -158,98 +203,17 @@ class CardViewModel @Inject constructor( } } - private val _date = MutableLiveData() - val date: LiveData get() = _date - - private val _emotion = MutableLiveData() - val emotion: LiveData get() = _emotion - - private val _receiver = MutableLiveData() - val receiver: LiveData get() = _receiver - -// fun getCardDetail(id : Long) { -// viewModelScope.launch { -// try { -// val response = homeRepository.getCardDetail(id) -// if (response.isSuccess) { -// val detailCard = response.result -// val previewCardList = mutableListOf() -// _exposure.value = detailCard.exposure -// _date.value = detailCard.date -// _emotion.value = detailCard.emotion -// _receiver.value = detailCard.receiver -// -// detailCard.questions.let { questionList -> -// questionList.forEach { question -> -// val previewCard = when(question.type) { -// "OPTIONAL" -> { -// PreviewCardModel( -// question = question.content, -// fromWho = question.questioner, -// options = question.options, -// type = question.options!!.size, -// answer = question.answer, -// id = question.id -// ) -// } -// -// "SHORT_ANSWER" -> { -// PreviewCardModel( -// question = question.content, -// fromWho = question.questioner, -// options = question.options, -// type = 0, -// answer = question.answer, -// id = question.id -// ) -// } -// -// else -> { -// PreviewCardModel( -// question = question.content, -// fromWho = question.questioner, -// options = question.options, -// type = 1, -// answer = question.answer, -// id = question.id -// ) -// } -// } -// previewCardList.add(previewCard) -// _previewCards.value = previewCardList -// } -// } -// -// } else { -// // 오류 처리 -// Timber.tag("CardViewModel").d("detail API 호출 실패: ${response.message}") -// tokenManager.refreshToken( -// onSuccess = { getCardDetail(id) }, -// onFailure = { Timber.tag("CardViewModel").d("refresh error")} -// ) -// } -// } catch (e: Exception) { -// Timber.tag("CardViewModel").d("detail 예외 발생: ${e.message}") -// tokenManager.refreshToken( -// onSuccess = { getCardDetail(id) }, -// onFailure = { Timber.tag("CardViewModel").d("refresh error")} -// ) -// } -// } -// } - - private fun mapToPatchModels(questionsDto: QuestionsDto) { + private fun performMapToPatchModels(questionsDto: QuestionsDto) { val cardModels = mutableListOf() val chooseModels = mutableListOf() val cardShortModel = mutableListOf() - initializeCountSelection() + performInitializeCountSelection() for (question in questionsDto.questions) { when (question.type) { "OPTIONAL" -> { - // 선택된 상태인지 확인 (previewCards에서 동일한 id가 있는지 확인) - val isSelected = _previewCards.value?.any { it.id == question.id && it.type == 2 } ?: false - || _previewCards.value?.any { it.id == question.id && it.type == 3 } ?: false + val isSelected = currentState.previewCards.any { it.id == question.id && it.type == 2 } + || currentState.previewCards.any { it.id == question.id && it.type == 3 } chooseModels.add( ChooseModel( @@ -263,8 +227,7 @@ class CardViewModel @Inject constructor( ) } "LONG_ANSWER" -> { - // 선택된 상태인지 확인 (previewCards에서 동일한 id가 있는지 확인) - val isSelected = _previewCards.value?.any { it.id == question.id && it.type == 1 } ?: false + val isSelected = currentState.previewCards.any { it.id == question.id && it.type == 1 } cardModels.add( CardModel( @@ -272,13 +235,12 @@ class CardViewModel @Inject constructor( fromWho = question.questioner, questionType = 1, id = question.id, - isButtonSelected = isSelected // 선택 여부 설정 + isButtonSelected = isSelected ) ) } else -> { - // 선택된 상태인지 확인 (previewCards에서 동일한 id가 있는지 확인) - val isSelected = _previewCards.value?.any { it.id == question.id && it.type == 0 } ?: false + val isSelected = currentState.previewCards.any { it.id == question.id && it.type == 0 } cardShortModel.add( CardShortModel( @@ -286,25 +248,27 @@ class CardViewModel @Inject constructor( fromWho = question.questioner, questionType = 0, id = question.id, - isButtonSelected = isSelected // 선택 여부 설정 + isButtonSelected = isSelected ) ) } } } - _cards.value = cardModels - _chooseCards.value = chooseModels - _shortCards.value = cardShortModel + updateState { + copy( + cards = cardModels, + chooseCards = chooseModels, + shortCards = cardShortModel + ) + } } - - // 응답 데이터 매핑 - private fun mapToModels(questionsDto: QuestionsDto) { + private fun performMapToModels(questionsDto: QuestionsDto) { val cardModels = mutableListOf() val chooseModels = mutableListOf() val cardShortModel = mutableListOf() - initializeCountSelection() + performInitializeCountSelection() for (question in questionsDto.questions) { when (question.type) { @@ -316,7 +280,6 @@ class CardViewModel @Inject constructor( options = question.options, type = question.options.size, id = question.id - ) ) } @@ -327,87 +290,86 @@ class CardViewModel @Inject constructor( fromWho = question.questioner, questionType = 1, id = question.id - ) ) } - else ->{ + else -> { cardShortModel.add( CardShortModel( message = question.content, fromWho = question.questioner, questionType = 0, id = question.id - ) ) } } } - _cards.value = cardModels - _chooseCards.value = chooseModels - _shortCards.value = cardShortModel + updateState { + copy( + cards = cardModels, + chooseCards = chooseModels, + shortCards = cardShortModel + ) + } } - - fun getAnswerLength(answer: String): Int { - return answer.length + private fun performInitializeCountSelection() { + updateState { copy(countSelection = previewCards.size) } + Timber.tag("선택9 초기화") } - fun isLockSelected() { - _exposure.value = _exposure.value?.not() ?: true - Timber.tag("lock").d(_exposure.value.toString()) - //toastShow = true + private fun performToggleLock() { + updateState { copy(exposure = !exposure) } + Timber.tag("lock").d(currentState.exposure.toString()) } - fun updateButtonState(position : Int, isSelected : Boolean){ - _cards.value = _cards.value?.mapIndexed { index, card -> - if (index == position) { - card.copy(isButtonSelected = isSelected) - } else { - card - } + private fun performUpdateButtonState(position: Int, isSelected: Boolean) { + updateState { + copy( + cards = cards.mapIndexed { index, card -> + if (index == position) card.copy(isButtonSelected = isSelected) else card + } + ) } - } - fun updateShortButtonState(position : Int, isSelected : Boolean){ - _shortCards.value = _shortCards.value?.mapIndexed { index, card -> - if (index == position) { - card.copy(isButtonSelected = isSelected) - } else { - card - } + private fun performUpdateShortButtonState(position: Int, isSelected: Boolean) { + updateState { + copy( + shortCards = shortCards.mapIndexed { index, card -> + if (index == position) card.copy(isButtonSelected = isSelected) else card + } + ) } - } - fun updateChooseButton(position: Int, isSelected: Boolean){ - _chooseCards.value = _chooseCards.value?.mapIndexed { index, card -> - if (index == position) { - card.copy(isButtonSelected = isSelected) - } else { - card - } + private fun performUpdateChooseButton(position: Int, isSelected: Boolean) { + updateState { + copy( + chooseCards = chooseCards.mapIndexed { index, card -> + if (index == position) card.copy(isButtonSelected = isSelected) else card + } + ) } } - fun updateAllPreviews() { - val selectedCardIds = _cards.value?.filter { it.isButtonSelected }?.map { it.id }?.toSet() ?: emptySet() - val selectedShortCardIds = _shortCards.value?.filter { it.isButtonSelected }?.map { it.id }?.toSet() ?: emptySet() - val selectedChooseCardIds = _chooseCards.value?.filter { it.isButtonSelected }?.map { it.id }?.toSet() ?: emptySet() + private fun performUpdateAllPreviews() { + val selectedCardIds = currentState.cards.filter { it.isButtonSelected }.map { it.id }.toSet() + val selectedShortCardIds = currentState.shortCards.filter { it.isButtonSelected }.map { it.id }.toSet() + val selectedChooseCardIds = currentState.chooseCards.filter { it.isButtonSelected }.map { it.id }.toSet() - // 기존 미리보기 목록 중 선택된 ID가 포함된 카드만 필터링하여 새로운 목록으로 설정 - val existingCards = _previewCards.value?.filter { it.id in selectedCardIds || it.id in selectedShortCardIds || it.id in selectedChooseCardIds }?.toMutableList() ?: mutableListOf() + val existingCards = currentState.previewCards.filter { + it.id in selectedCardIds || it.id in selectedShortCardIds || it.id in selectedChooseCardIds + }.toMutableList() val existingIds = existingCards.map { it.id }.toSet() - val selectedNewCards = _cards.value?.filter { it.isButtonSelected && it.id !in existingIds } ?: emptyList() - val selectedNewShortCards = _shortCards.value?.filter { it.isButtonSelected && it.id !in existingIds } ?: emptyList() - val selectedNewChooseCards = _chooseCards.value?.filter { it.isButtonSelected && it.id !in existingIds } ?: emptyList() + val selectedNewCards = currentState.cards.filter { it.isButtonSelected && it.id !in existingIds } + val selectedNewShortCards = currentState.shortCards.filter { it.isButtonSelected && it.id !in existingIds } + val selectedNewChooseCards = currentState.chooseCards.filter { it.isButtonSelected && it.id !in existingIds } - // 선택된 카드를 PreviewCardModel로 변환 val newCards = selectedNewCards.map { PreviewCardModel(answer = "", question = it.message, type = it.questionType, fromWho = it.fromWho, options = null, id = it.id) } @@ -420,89 +382,113 @@ class CardViewModel @Inject constructor( PreviewCardModel(question = it.message, fromWho = it.fromWho, options = it.options, type = it.type, answer = "", id = it.id) } - // 선택된 카드의 개수 계산 val newCardsCount = selectedNewCards.size + existingCards.filter { it.type == 1 }.size val newShortCardsCount = selectedNewShortCards.size + existingCards.filter { it.type == 0 }.size - val newChooseCardsCount = selectedNewChooseCards.size + existingCards.filter { it.type == 2 || it.type ==3 }.size + val newChooseCardsCount = selectedNewChooseCards.size + existingCards.filter { it.type == 2 || it.type == 3 }.size Timber.tag("카드 선택 개수").d("New Cards: $newCardsCount, Short Cards: $newShortCardsCount, Choose Cards: $newChooseCardsCount") - setCardCount(newShortCardsCount, newCardsCount, newChooseCardsCount) + performSetCardCount(newShortCardsCount, newCardsCount, newChooseCardsCount) - // 새로운 카드를 기존 목록에 추가 existingCards.addAll(newCards) existingCards.addAll(newShortCards) existingCards.addAll(newChooseCards) - _previewCards.value = existingCards.distinct() - - - _cards.value = _cards.value?.map { - it.copy(isButtonSelected = false) - } - _chooseCards.value = _chooseCards.value?.map { - it.copy(isButtonSelected = false) - } - _shortCards.value = _shortCards.value?.map { - it.copy(isButtonSelected = false) + updateState { + copy( + previewCards = existingCards.distinct(), + cards = cards.map { it.copy(isButtonSelected = false) }, + chooseCards = chooseCards.map { it.copy(isButtonSelected = false) }, + shortCards = shortCards.map { it.copy(isButtonSelected = false) } + ) } - Timber.tag("미리보기").d(previewCards.value.toString()) + Timber.tag("미리보기").d(currentState.previewCards.toString()) } - fun clearAllData() { - _previewCards.value = emptyList() - _previewChoose.value = emptyList() - _isAllAnswersFilled.value = false + private fun performClearAllData() { + updateState { + copy( + previewCards = emptyList(), + previewChoose = emptyList(), + isAllAnswersFilled = false + ) + } } - fun clearAll(){ - _cards.value = emptyList() - _chooseCards.value = emptyList() - _shortCards.value = emptyList() - _isAllAnswersFilled.value = false + private fun performClearAll() { + updateState { + copy( + cards = emptyList(), + chooseCards = emptyList(), + shortCards = emptyList(), + isAllAnswersFilled = false + ) + } } - fun patchCard(previewCardModels: List, exposure: Boolean, id: Int) { + + private fun performPatchCard(previewCardModels: List, exposure: Boolean, id: Int) { viewModelScope.launch { try { val response = repository.patchCardData(previewCardModels, exposure, id) if (response.isSuccess) { Timber.tag("patchCard").d("카드 수정 성공: ${response.message}") + sendEvent(CardEvent.PatchCardSuccess) } else { Timber.tag("patchCard").d("카드 수정 실패: ${response.message}") tokenManager.refreshToken( - onSuccess = { patchCard(previewCardModels, exposure, id) }, + onSuccess = { performPatchCard(previewCardModels, exposure, id) }, onFailure = { Timber.e("patchCard API Call Failed") } ) } } catch (e: Exception) { Timber.e("patchCard 예외 발생: ${e.message}") tokenManager.refreshToken( - onSuccess = { patchCard(previewCardModels, exposure, id) }, + onSuccess = { performPatchCard(previewCardModels, exposure, id) }, onFailure = { Timber.e("patchCard API Call Failed") } ) } } } - fun countSelect(selection : Boolean){ - + private fun performCountSelect(selection: Boolean) { if (selection) { - _countSelection.value?.let { - _countSelection.value = it + 1 - } - } - else{ - _countSelection.value?.let { - _countSelection.value = it - 1 - } + updateState { copy(countSelection = countSelection + 1) } + } else { + updateState { copy(countSelection = countSelection - 1) } } + Timber.tag("선택5").d(currentState.countSelection.toString()) + } - Timber.tag("선택5").d(countSelection.value.toString()) + private fun performResetSelect() { + Timber.tag("initialize3${currentState.countSelection}") + updateState { copy(countSelection = 0) } } - fun resetSelect(){ - Timber.tag("initialize3${_countSelection.value}") - _countSelection.value = 0 + private fun performUpdateAnswer(answer: String) { + updateState { copy(answer = answer) } } -} \ No newline at end of file + + fun getAnswerLength(answer: String): Int { + return answer.length + } + + fun setCardId(cardId: Int) = onAction(CardAction.SetCardId(cardId)) + fun disableLock(lock: Boolean) = onAction(CardAction.DisableLock(lock)) + fun setCardCount(count: Int, count2: Int, count3: Int) = onAction(CardAction.SetCardCount(count, count2, count3)) + fun updateCardInputStatus(index: Int, isFilled: Boolean) = onAction(CardAction.UpdateCardInputStatus(index, isFilled)) + fun updateCardInputStatusLong(index: Int, isFilled: Boolean) = onAction(CardAction.UpdateCardInputStatusLong(index, isFilled)) + fun updateCardInputStatusChoose(index: Int, isFilled: Boolean) = onAction(CardAction.UpdateCardInputStatusChoose(index, isFilled)) + fun sendData(previewCardModels: List, exposure: Boolean) = onAction(CardAction.SendData(previewCardModels, exposure)) + fun getAllData() = onAction(CardAction.GetAllData) + fun updateButtonState(position: Int, isSelected: Boolean) = onAction(CardAction.UpdateButtonState(position, isSelected)) + fun updateShortButtonState(position: Int, isSelected: Boolean) = onAction(CardAction.UpdateShortButtonState(position, isSelected)) + fun updateChooseButton(position: Int, isSelected: Boolean) = onAction(CardAction.UpdateChooseButton(position, isSelected)) + fun updateAllPreviews() = onAction(CardAction.UpdateAllPreviews) + fun clearAllData() = onAction(CardAction.ClearAllData) + fun clearAll() = onAction(CardAction.ClearAll) + fun patchCard(previewCardModels: List, exposure: Boolean, id: Int) = onAction(CardAction.PatchCard(previewCardModels, exposure, id)) + fun countSelect(selection: Boolean) = onAction(CardAction.CountSelect(selection)) + fun resetSelect() = onAction(CardAction.ResetSelect) + fun isLockSelected() = onAction(CardAction.ToggleLock) +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/viewmodel/SocialContract.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/viewmodel/SocialContract.kt new file mode 100644 index 00000000..ef56356f --- /dev/null +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/viewmodel/SocialContract.kt @@ -0,0 +1,90 @@ +package com.toyou.toyouandroid.presentation.viewmodel + +import com.toyou.core.common.mvi.UiAction +import com.toyou.core.common.mvi.UiEvent +import com.toyou.core.common.mvi.UiState +import com.toyou.toyouandroid.data.social.dto.request.QuestionDto +import com.toyou.toyouandroid.model.FriendListModel + +data class SocialUiState( + // Friends + val friends: List = emptyList(), + val isLoading: Boolean = false, + + // Character selection + val selectedChar: Int = -1, + val nextBtnEnabled: Boolean = false, + + // Question + val questionDto: QuestionDto = QuestionDto(0, "", "", false, null), + val selectedEmotion: Int? = null, + val selectedEmotionMent: String = "", + val optionList: List = emptyList(), + + // Search + val isFriend: String = "", + val searchName: String = "", + val searchFriendId: Long = 0L +) : UiState + +sealed interface SocialEvent : UiEvent { + // Friend request events + data object FriendRequestCompleted : SocialEvent + data object FriendRequestCanceled : SocialEvent + data object FriendRequestRemoved : SocialEvent + + // Approval events + data class ApprovalSuccess(val alarmId: Int, val position: Int) : SocialEvent + data class ApprovalFailed(val alarmId: Int, val position: Int) : SocialEvent + + // Error events + data class ShowError(val message: String) : SocialEvent + data object TokenExpired : SocialEvent + + // Question events + data object QuestionSent : SocialEvent +} + +sealed interface SocialAction : UiAction { + // Data loading + data object LoadFriends : SocialAction + data class SearchFriend(val name: String) : SocialAction + + // Character selection + data class SelectCharacter(val position: Int) : SocialAction + + // Target friend + data class SetTargetFriend( + val friendName: String, + val emotion: Int?, + val ment: String? + ) : SocialAction + data class SetTypeFriend(val type: String) : SocialAction + + // Question management + data class UpdateQuestionOptions(val options: List) : SocialAction + data object UpdateOption : SocialAction + data object RemoveOptions : SocialAction + data object RemoveContent : SocialAction + data class SetAnonymous(val isAnonymous: Boolean) : SocialAction + data class SendQuestion(val myName: String) : SocialAction + + // Friend requests + data class SendFriendRequest(val friendId: Long, val myName: String) : SocialAction + data class DeleteFriend(val friendName: String?, val friendId: Long?) : SocialAction + data class ApproveNotice( + val name: String, + val myName: String, + val alarmId: Int, + val position: Int + ) : SocialAction + data class PatchApprove(val friendId: Long, val myName: String) : SocialAction + + // Reset + data object ResetFriendRequest : SocialAction + data object ResetFriendRequestCanceled : SocialAction + data object ResetFriendRequestRemove : SocialAction + data object ResetFriendState : SocialAction + data object ResetQuestionData : SocialAction + data object ResetApproveSuccess : SocialAction +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/viewmodel/SocialViewModel.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/viewmodel/SocialViewModel.kt index a850a1f9..8cbf8f01 100644 --- a/app/src/main/java/com/toyou/toyouandroid/presentation/viewmodel/SocialViewModel.kt +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/viewmodel/SocialViewModel.kt @@ -2,19 +2,21 @@ package com.toyou.toyouandroid.presentation.viewmodel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.toyou.core.common.mvi.MviViewModel import com.toyou.toyouandroid.data.social.dto.request.QuestionDto import com.toyou.toyouandroid.data.social.dto.request.RequestFriend import com.toyou.toyouandroid.data.social.dto.response.FriendsDto -import com.toyou.toyouandroid.domain.social.repostitory.SocialRepository -import com.toyou.toyouandroid.fcm.domain.FCMRepository +import com.toyou.toyouandroid.domain.social.repostitory.ISocialRepository +import com.toyou.toyouandroid.fcm.domain.IFCMRepository import com.toyou.toyouandroid.fcm.dto.request.FCM import com.toyou.toyouandroid.model.FriendListModel import com.toyou.toyouandroid.presentation.fragment.notice.ApprovalResult import com.toyou.toyouandroid.utils.TokenManager import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import retrofit2.HttpException @@ -23,25 +25,35 @@ import javax.inject.Inject @HiltViewModel class SocialViewModel @Inject constructor( - private val repository: SocialRepository, + private val repository: ISocialRepository, private val tokenManager: TokenManager, - private val fcmRepository: FCMRepository -) : ViewModel() { - //private val fcmRepository = FCMRepository(tokenManager) - + private val fcmRepository: IFCMRepository +) : MviViewModel( + initialState = SocialUiState() +) { + private var retryCount = 0 + private val maxRetryCount = 1 + private var retrieveTokens: List? = null + + // ============================================================ + // Legacy LiveData for XML Data Binding Compatibility + // These will be removed after Compose migration + // ============================================================ private val _friends = MutableLiveData>() val friends: LiveData> get() = _friends - private val _clickedPosition = MutableLiveData>() - private val _selectedChar = MutableLiveData() + private val _selectedChar = MutableLiveData(-1) val selectedChar: LiveData get() = _selectedChar - private val _nextBtnEnabled = MutableLiveData() + + private val _nextBtnEnabled = MutableLiveData(false) val nextBtnEnabled: LiveData get() = _nextBtnEnabled - private val _questionDto = MutableLiveData() + private val _questionDto = MutableLiveData(QuestionDto(0, "", "", false, null)) val questionDto: LiveData get() = _questionDto - private val _selectedEmotion = MutableLiveData() - val selectedEmotion: LiveData get() = _selectedEmotion + + private val _selectedEmotion = MutableLiveData() + val selectedEmotion: LiveData get() = _selectedEmotion + private val _selectedEmotionMent = MutableLiveData() val selectedEmotionMent: LiveData get() = _selectedEmotionMent @@ -55,90 +67,137 @@ class SocialViewModel @Inject constructor( val searchName: LiveData get() = _searchName private val _searchFriendId = MutableLiveData() - val searchFriendId : LiveData get() = _searchFriendId - - private val _friendRequest = MutableLiveData() - val friendRequest: LiveData get() = _friendRequest - private val _retrieveToken = MutableLiveData?>() - val retrieveToken: LiveData?> get() = _retrieveToken - private val _fcm = MutableLiveData() - val fcm : LiveData get() = _fcm + val searchFriendId: LiveData get() = _searchFriendId + private val _friendRequestCompleted = MutableLiveData() val friendRequestCompleted: LiveData get() = _friendRequestCompleted + private val _friendRequestCanceled = MutableLiveData() - val friendRequestCanceled : LiveData get() = _friendRequestCanceled + val friendRequestCanceled: LiveData get() = _friendRequestCanceled + private val _friendRequestRemove = MutableLiveData() - val friendRequestRemove : LiveData get() = _friendRequestRemove + val friendRequestRemove: LiveData get() = _friendRequestRemove + private val _approveSuccess = MutableLiveData() + val approveSuccess: LiveData get() = _approveSuccess init { - loadInitQuestionType() - _selectedChar.value = -1 - _nextBtnEnabled.value = false - _questionDto.value = QuestionDto(0, "", "", false, null) // 초기화 - } - - fun setTargetFriend(friendName: String, emotion: Int?, ment: String?) { - val currentQuestionDto = _questionDto.value ?: QuestionDto(0, "", "", false, null) - - val targetId = _friends.value?.find { it.name == friendName }?.id ?: 0L - _questionDto.value = currentQuestionDto.copy(targetId = targetId) - _selectedEmotion!!.value = emotion - _selectedEmotionMent!!.value = ment + // Sync StateFlow to LiveData for backward compatibility + state.onEach { uiState -> + _friends.value = uiState.friends + _selectedChar.value = uiState.selectedChar + _nextBtnEnabled.value = uiState.nextBtnEnabled + _questionDto.value = uiState.questionDto + _selectedEmotion.value = uiState.selectedEmotion + _selectedEmotionMent.value = uiState.selectedEmotionMent + _optionList.value = uiState.optionList + _isFriend.value = uiState.isFriend + _searchName.value = uiState.searchName + _searchFriendId.value = uiState.searchFriendId + }.launchIn(viewModelScope) + + // Sync Events to LiveData + event.onEach { event -> + when (event) { + is SocialEvent.FriendRequestCompleted -> _friendRequestCompleted.value = true + is SocialEvent.FriendRequestCanceled -> _friendRequestCanceled.value = true + is SocialEvent.FriendRequestRemoved -> _friendRequestRemove.value = true + is SocialEvent.ApprovalSuccess -> _approveSuccess.value = ApprovalResult(true, event.alarmId, event.position) + is SocialEvent.ApprovalFailed -> _approveSuccess.value = ApprovalResult(false, event.alarmId, event.position) + else -> { /* handled elsewhere */ } + } + }.launchIn(viewModelScope) } - fun setTypeFriend(type: String) { - _questionDto.value?.let { currentQuestionDto -> - _questionDto.value = currentQuestionDto.copy(type = type) - Timber.tag("타겟2").d(_questionDto.value.toString()) + // Legacy reset functions for backward compatibility + fun resetFriendRequest() { _friendRequestCompleted.value = false } + fun resetFriendRequestCanceled() { _friendRequestCanceled.value = false } + fun resetFriendRequestRemove() { _friendRequestRemove.value = false } + fun resetApproveSuccess() { _approveSuccess.value = ApprovalResult(false, -1, -1) } + // ============================================================ + + override fun handleAction(action: SocialAction) { + when (action) { + is SocialAction.LoadFriends -> performLoadFriends() + is SocialAction.SearchFriend -> performSearchFriend(action.name) + is SocialAction.SelectCharacter -> performSelectCharacter(action.position) + is SocialAction.SetTargetFriend -> performSetTargetFriend(action.friendName, action.emotion, action.ment) + is SocialAction.SetTypeFriend -> performSetTypeFriend(action.type) + is SocialAction.UpdateQuestionOptions -> performUpdateQuestionOptions(action.options) + is SocialAction.UpdateOption -> performUpdateOption() + is SocialAction.RemoveOptions -> performRemoveOptions() + is SocialAction.RemoveContent -> performRemoveContent() + is SocialAction.SetAnonymous -> performSetAnonymous(action.isAnonymous) + is SocialAction.SendQuestion -> performSendQuestion(action.myName) + is SocialAction.SendFriendRequest -> performSendFriendRequest(action.friendId, action.myName) + is SocialAction.DeleteFriend -> performDeleteFriend(action.friendName, action.friendId) + is SocialAction.ApproveNotice -> performApproveNotice(action.name, action.myName, action.alarmId, action.position) + is SocialAction.PatchApprove -> performPatchApprove(action.friendId, action.myName) + is SocialAction.ResetFriendRequest -> { /* No-op, handled via events */ } + is SocialAction.ResetFriendRequestCanceled -> { /* No-op, handled via events */ } + is SocialAction.ResetFriendRequestRemove -> { /* No-op, handled via events */ } + is SocialAction.ResetFriendState -> performResetFriendState() + is SocialAction.ResetQuestionData -> performResetQuestionData() + is SocialAction.ResetApproveSuccess -> { /* No-op, handled via events */ } } } - // 토큰 재발급 정상 호출 완료 - fun getFriendsData() { + private fun performLoadFriends() { viewModelScope.launch { + updateState { copy(isLoading = true) } try { val response = repository.getFriendsData() if (response.isSuccess) { Timber.d("API 호출 성공: ${response.message}") - val friendsDto = response.result - mapToFriendModels(friendsDto) + val friendModels = mapToFriendModels(response.result) + updateState { copy(friends = friendModels, isLoading = false) } } else { Timber.tag("SocialViewModel").e("API 호출 실패: ${response.message}") + updateState { copy(isLoading = false) } tokenManager.refreshToken( - onSuccess = { getFriendsData() }, - onFailure = { Timber.e("getFriendsData API call failed") } + onSuccess = { performLoadFriends() }, + onFailure = { + Timber.e("getFriendsData API call failed") + sendEvent(SocialEvent.TokenExpired) + } ) } } catch (e: Exception) { Timber.tag("SocialViewModel").e("예외 발생: ${e.message}") + updateState { copy(isLoading = false) } tokenManager.refreshToken( - onSuccess = { getFriendsData() }, - onFailure = { Timber.e("getFriendsData API call failed") } + onSuccess = { performLoadFriends() }, + onFailure = { + Timber.e("getFriendsData API call failed") + sendEvent(SocialEvent.ShowError(e.message ?: "Unknown error")) + } ) } } } - fun getSearchData(name: String) { + private fun performSearchFriend(name: String) { viewModelScope.launch { try { val response = repository.getSearchData(name) if (response.isSuccess) { response.result.let { result -> - _isFriend.value = result.status - _searchName.value = result.name - _searchFriendId.value = result.userId - Timber.tag("search API 성공").d(_isFriend.value.toString()) + updateState { + copy( + isFriend = result.status, + searchName = result.name, + searchFriendId = result.userId + ) + } + Timber.tag("search API 성공").d(result.status) } - retryCount = 0 // 성공 시 재시도 횟수 초기화 + retryCount = 0 } else { Timber.tag("search API 실패").d("API 호출 실패: ${response.message}") - if (retryCount < maxRetryCount) { retryCount++ tokenManager.refreshToken( - onSuccess = { getSearchData(name) }, + onSuccess = { performSearchFriend(name) }, onFailure = { Timber.e("getSearchData API call failed") } ) } else { @@ -146,446 +205,399 @@ class SocialViewModel @Inject constructor( } } } catch (e: HttpException) { - val errorBody = e.response()?.errorBody()?.string() - Timber.tag("search API 실패").e("서버 응답 메시지: $errorBody") - - if (retryCount < maxRetryCount) { - retryCount++ - tokenManager.refreshToken( - onSuccess = { getSearchData(name) }, - onFailure = { Timber.e("getSearchData API call failed") } - ) - } else { - Timber.e("최대 재시도 도달, 추가 호출 중단") - } - - when { - errorBody?.contains("USER400") == true -> { - _isFriend.value = "400" - } - errorBody?.contains("USER401") == true -> { - _isFriend.value = "401" - } - else -> { - _isFriend.value = "400" - } - } + handleSearchError(e, name) } catch (e: Exception) { Timber.tag("search API 실패").e("예외 발생: ${e.message}") - _isFriend.value = "예상치 못한 오류가 발생했습니다." - e.printStackTrace() - + updateState { copy(isFriend = "예상치 못한 오류가 발생했습니다.") } if (retryCount < maxRetryCount) { retryCount++ tokenManager.refreshToken( - onSuccess = { getSearchData(name) }, + onSuccess = { performSearchFriend(name) }, onFailure = { Timber.e("getSearchData API call failed") } ) } else { Timber.e("최대 재시도 도달, 추가 호출 중단") - retryCount = 0; + retryCount = 0 } } } } + private fun handleSearchError(e: HttpException, name: String) { + val errorBody = e.response()?.errorBody()?.string() + Timber.tag("search API 실패").e("서버 응답 메시지: $errorBody") - private fun mapToFriendModels(friendsDto: FriendsDto) { - val friendListModel = mutableListOf() - - for (friend in friendsDto.friends) { - friendListModel.add( - FriendListModel( - id = friend.userId, - name = friend.nickname, - message = friend.ment ?: "", - emotion = emotionType(friend.emotion) - ) + if (retryCount < maxRetryCount) { + retryCount++ + tokenManager.refreshToken( + onSuccess = { performSearchFriend(name) }, + onFailure = { Timber.e("getSearchData API call failed") } ) + } else { + Timber.e("최대 재시도 도달, 추가 호출 중단") } - _friends.value = friendListModel - } - private fun emotionType(type: String?): Int? { - return when (type) { - "HAPPY" -> 1 - "EXCITED" -> 2 - "NORMAL" -> 3 - "NERVOUS" -> 4 - "ANGRY" -> 5 - else -> null + val status = when { + errorBody?.contains("USER400") == true -> "400" + errorBody?.contains("USER401") == true -> "401" + else -> "400" } + updateState { copy(isFriend = status) } } - private fun loadInitQuestionType() { - val initialMap = mapOf( - 1 to false, - 2 to false, - 3 to false - ) - _clickedPosition.value = initialMap + private fun performSelectCharacter(position: Int) { + val newSelected = if (currentState.selectedChar == position) -1 else position + updateState { + copy( + selectedChar = newSelected, + nextBtnEnabled = newSelected != -1 + ) + } } - fun onCharSelected(position: Int) { - _selectedChar.value = if (_selectedChar.value == position) -1 else position - _nextBtnEnabled.value = _selectedChar.value != -1 + private fun performSetTargetFriend(friendName: String, emotion: Int?, ment: String?) { + val targetId = currentState.friends.find { it.name == friendName }?.id ?: 0L + updateState { + copy( + questionDto = questionDto.copy(targetId = targetId), + selectedEmotion = emotion, + selectedEmotionMent = ment ?: "" + ) + } } - fun getAnswerLength(answer: String): Int { - return answer.length + private fun performSetTypeFriend(type: String) { + updateState { + copy(questionDto = questionDto.copy(type = type)) + } + Timber.tag("타겟2").d(currentState.questionDto.toString()) } - fun updateQuestionOptions(newOptions: List) { - _questionDto.value?.let { currentQuestionDto -> - _questionDto.value = currentQuestionDto.copy(options = newOptions) - Timber.tag("SocialViewModel").d("옵션 업데이트: ${_questionDto.value}") + private fun performUpdateQuestionOptions(options: List) { + updateState { + copy(questionDto = questionDto.copy(options = options)) } + Timber.tag("SocialViewModel").d("옵션 업데이트: ${currentState.questionDto}") } - - fun updateOption() { - _optionList.value = _questionDto.value!!.options!! + private fun performUpdateOption() { + currentState.questionDto.options?.let { options -> + updateState { copy(optionList = options) } + } } - fun removeOptions() { - _questionDto.value?.options = null + private fun performRemoveOptions() { + updateState { + copy(questionDto = questionDto.copy(options = null)) + } } - fun removeContent() { - _questionDto.value?.content = "" + private fun performRemoveContent() { + updateState { + copy(questionDto = questionDto.copy(content = "")) + } } - fun isAnonymous(isChecked: Boolean) { - if (isChecked) _questionDto.value?.anonymous = true - else _questionDto.value?.anonymous = false + private fun performSetAnonymous(isAnonymous: Boolean) { + updateState { + copy(questionDto = questionDto.copy(anonymous = isAnonymous)) + } } - // 토큰 재발급 정상 호출 완료 - fun sendQuestion(myName: String) { + private fun performSendQuestion(myName: String) { viewModelScope.launch { try { - _questionDto.value?.let { currentQuestionDto -> - val response = repository.postQuestionData(currentQuestionDto) - if (response.isSuccess) { - Timber.tag("SocialViewModel").d("질문 전송 성공") - - retrieveTokenFromServer(questionDto.value!!.targetId) - if (_questionDto.value!!.anonymous){ - _retrieveToken.value?.let { tokens -> - for (token in tokens) { - postFCM("익명", token, 3) - } - } - }else { - _retrieveToken.value?.let { tokens -> - for (token in tokens) { - postFCM(myName, token, 3) - } - } - } - } else { - Timber.tag("SocialViewModel").d("질문 전송 실패") + val response = repository.postQuestionData(currentState.questionDto) + if (response.isSuccess) { + Timber.tag("SocialViewModel").d("질문 전송 성공") + retrieveTokenFromServer(currentState.questionDto.targetId) - tokenManager.refreshToken( - onSuccess = { sendQuestion(myName) }, - onFailure = { Timber.e("sendQuestion API Call Failed")} - ) + val senderName = if (currentState.questionDto.anonymous) "익명" else myName + retrieveTokens?.let { tokens -> + tokens.forEach { token -> + postFCM(senderName, token, 3) + } } - } ?: run { - Timber.tag("SocialViewModel").d("questionDto null") + sendEvent(SocialEvent.QuestionSent) + } else { + Timber.tag("SocialViewModel").d("질문 전송 실패") + tokenManager.refreshToken( + onSuccess = { performSendQuestion(myName) }, + onFailure = { Timber.e("sendQuestion API Call Failed") } + ) } } catch (e: Exception) { Timber.tag("SocialViewModel").e(e.message.toString()) tokenManager.refreshToken( - onSuccess = { sendQuestion(myName) }, - onFailure = { Timber.e("sendQuestion API Call Failed")} + onSuccess = { performSendQuestion(myName) }, + onFailure = { Timber.e("sendQuestion API Call Failed") } ) } } - } - // 토큰 재발급 정상 호출 완료 - fun sendFriendRequest(friendId: Long, myName: String) { - _friendRequest.value = RequestFriend(userId = friendId) + private fun performSendFriendRequest(friendId: Long, myName: String) { + val request = RequestFriend(userId = friendId) viewModelScope.launch { try { - _friendRequest.value?.let { name -> - val response = repository.postRequest(name) - if (response.isSuccess) { - Timber.tag("SocialViewModel").d("친구 요청 전송 성공") - - // 작업이 성공적으로 완료되었음을 표시 - retrieveTokenFromServer(friendId) - _retrieveToken.value?.let { tokens -> - for (token in tokens) { - postFCM(myName, token, 1) - } + val response = repository.postRequest(request) + if (response.isSuccess) { + Timber.tag("SocialViewModel").d("친구 요청 전송 성공") + retrieveTokenFromServer(friendId) + retrieveTokens?.let { tokens -> + tokens.forEach { token -> + postFCM(myName, token, 1) } - _friendRequestCompleted.postValue(true) - } else { - Timber.tag("SocialViewModel").d("친구 삭제 실패: ${response.message}") - tokenManager.refreshToken( - onSuccess = { sendFriendRequest(friendId, myName)}, - onFailure = { Timber.e("sendFriendRequest Failed")} - ) } - } ?: run { - Timber.tag("SocialViewModel").e("friend Request null") + sendEvent(SocialEvent.FriendRequestCompleted) + } else { + Timber.tag("SocialViewModel").d("친구 요청 실패: ${response.message}") + tokenManager.refreshToken( + onSuccess = { performSendFriendRequest(friendId, myName) }, + onFailure = { Timber.e("sendFriendRequest Failed") } + ) } } catch (e: Exception) { - // 오류 처리 Timber.tag("api 실패!").e(e.message.toString()) - _friendRequestCompleted.postValue(false) + sendEvent(SocialEvent.ShowError(e.message ?: "친구 요청 실패")) tokenManager.refreshToken( - onSuccess = { sendFriendRequest(friendId, myName)}, - onFailure = { Timber.e("sendFriendRequest Failed")} + onSuccess = { performSendFriendRequest(friendId, myName) }, + onFailure = { Timber.e("sendFriendRequest Failed") } ) } } } - fun resetFriendRequest() { - _friendRequestCompleted.value = false - } - fun resetFriendRequestCanceled() { - _friendRequestCanceled.value = false - } - fun resetFriendRequestRemove() { - _friendRequestRemove.value = false - } - - fun resetFriendState(){ - Timber.tag("destroy").d(_isFriend.value.toString()) - _isFriend.value = "no" - } - - // 토큰 재발급 정상 호출 완료 - fun deleteFriend(friendName: String?, friendId: Long?) { - - val targetId = when { //만약 id가 주어진다면 id 넣고 name이 주어진다면 name 넣기 + private fun performDeleteFriend(friendName: String?, friendId: Long?) { + val targetId = when { friendId != null && friendId > 0L -> friendId - !friendName.isNullOrBlank() -> _friends.value?.find { it.name == friendName }?.id ?: 0L + !friendName.isNullOrBlank() -> currentState.friends.find { it.name == friendName }?.id ?: 0L else -> 0L } - _friendRequest.value = RequestFriend(userId = targetId) + val request = RequestFriend(userId = targetId) viewModelScope.launch { try { - _friendRequest.value?.let { friendRequest -> - val response = repository.deleteFriendData(friendRequest) - if (response.isSuccess) { - Timber.tag("SocialViewModel").d("친구 삭제 성공") - _friendRequestRemove.postValue(true) - retryCount = 0 // 성공 시 재시도 횟수 초기화 + val response = repository.deleteFriendData(request) + if (response.isSuccess) { + Timber.tag("SocialViewModel").d("친구 삭제 성공") + sendEvent(SocialEvent.FriendRequestRemoved) + retryCount = 0 + } else { + Timber.tag("SocialViewModel").d("친구 삭제 실패: ${response.message}") + if (retryCount < maxRetryCount) { + retryCount++ + tokenManager.refreshToken( + onSuccess = { performDeleteFriend(friendName, friendId) }, + onFailure = { Timber.e("deleteFriend API Call Failed") } + ) } else { - Timber.tag("SocialViewModel").d("친구 삭제 실패: ${response.message}") - - if (retryCount < maxRetryCount) { - retryCount++ - tokenManager.refreshToken( - onSuccess = { deleteFriend(friendName, friendId) }, - onFailure = { Timber.e("deleteFriend API Call Failed") } - ) - } else { - Timber.e("최대 재시도 도달, 추가 호출 중단") - } + Timber.e("최대 재시도 도달, 추가 호출 중단") } - } ?: run { - Timber.tag("SocialViewModel").e("friendRequest null") } } catch (e: Exception) { Timber.e("Exception occurred: ${e.message}") - if (retryCount < maxRetryCount) { retryCount++ tokenManager.refreshToken( - onSuccess = { deleteFriend(friendName, friendId) }, + onSuccess = { performDeleteFriend(friendName, friendId) }, onFailure = { Timber.e("deleteFriend API Call Failed") } ) } else { Timber.e("최대 재시도 도달, 추가 호출 중단") - retryCount = 0; + retryCount = 0 } } } } - fun postFCM(name: String, token: String, type: Int, retryCount: Int = 0) { - val maxRetries = 5 // 최대 재시도 횟수 - val fcm = when (type) { - 1 -> FCM(token = token, title = "친구 요청", body = "${name}님이 친구 요청을 보냈습니다.") - 2 -> FCM(token = token, title = "친구 수락", body = "${name}님이 친구 요청을 수락했습니다.") - 3 -> FCM(token = token, title = "질문 전송", body = "${name}님이 질문을 보냈습니다. 확인보세요!") - else -> null - } - - if (fcm == null) { - Timber.tag("fcm api 실패!").e("Invalid type: $type") - return - } - + private fun performApproveNotice(name: String, myName: String, alarmId: Int, position: Int) { + val request = RequestFriend(userId = alarmId.toLong()) viewModelScope.launch { try { - fcmRepository.postFCM(fcm).let { - Timber.tag("socialViewModel postFCM").d("성공") - // 호출 성공 시 retryCount 초기화 - Timber.tag("socialViewModel postFCM").d("API 호출 성공 - 재시도 횟수 초기화") - } - } catch (e: Exception) { - Timber.tag("socialViewModel postFCM").e("Exception occurred: ${e.message}") - - if (retryCount < maxRetries) { - Timber.tag("socialViewModel postFCM").d("Retrying... (${retryCount + 1}/$maxRetries)") - postFCM(name, token, type, retryCount + 1) + val response = repository.patchApproveFriend(request) + if (response.isSuccess) { + sendEvent(SocialEvent.ApprovalSuccess(alarmId, position)) } else { - Timber.tag("socialViewModel postFCM").e("Max retry attempts reached.") - } - } - } - } - - - private val _approveSuccess = MutableLiveData() - val approveSuccess: LiveData get() = _approveSuccess - - fun resetApproveSuccess() { - _approveSuccess.value = ApprovalResult(false, -1, -1) // 초기값으로 설정 - } - - fun patchApproveNotice(name: String, myName: String, alarmId: Int, position: Int) { - val id = 0L //이후 수정할 부분!!@@ - - _friendRequest.value = RequestFriend(userId = alarmId.toLong()) - viewModelScope.launch { - try { - _friendRequest.value?.let { request -> - val isApproved = repository.patchApproveFriend(request) - - if (isApproved.isSuccess) { - _approveSuccess.postValue(ApprovalResult(true, alarmId, position)) - } else { - _approveSuccess.postValue(ApprovalResult(false, alarmId, position)) - tokenManager.refreshToken( - onSuccess = { patchApprove(alarmId.toLong(), myName) }, - onFailure = { Timber.e("patchApprove API call failed") } - ) - } - } ?: run { - Timber.e("Friend request is null") - _approveSuccess.postValue(ApprovalResult(false, alarmId, position)) + sendEvent(SocialEvent.ApprovalFailed(alarmId, position)) + tokenManager.refreshToken( + onSuccess = { performPatchApprove(alarmId.toLong(), myName) }, + onFailure = { Timber.e("patchApprove API call failed") } + ) } - retrieveTokenFromServer(id) - _retrieveToken.value?.let { tokens -> - for (token in tokens) { + retrieveTokenFromServer(0L) + retrieveTokens?.let { tokens -> + tokens.forEach { token -> postFCM(myName, token, 2) } } ?: run { Timber.e("Token retrieval failed") - _approveSuccess.postValue(ApprovalResult(false, alarmId, position)) + sendEvent(SocialEvent.ApprovalFailed(alarmId, position)) } } catch (e: Exception) { Timber.e("Exception occurred: ${e.message}") - _approveSuccess.postValue(ApprovalResult(false, alarmId, position)) + sendEvent(SocialEvent.ApprovalFailed(alarmId, position)) } } } - // 토큰 재발급 정상 호출 완료 - fun patchApprove(friendId: Long, myName: String) { - _friendRequest.value = RequestFriend(friendId) + private fun performPatchApprove(friendId: Long, myName: String) { + val request = RequestFriend(friendId) viewModelScope.launch { try { - _friendRequest.value?.let { request -> - val response = repository.patchApproveFriend(request) - if (response.isSuccess) { - _friendRequestCanceled.postValue(true) - } else { - Timber.tag("SocialViewModel").d("API 호출 실패: ${response.message}") - tokenManager.refreshToken( - onSuccess = { patchApprove(friendId, myName) }, - onFailure = { Timber.e("patchApprove API call failed") } - ) - } - } ?: run { - Timber.e("Friend request is null") + val response = repository.patchApproveFriend(request) + if (response.isSuccess) { + sendEvent(SocialEvent.FriendRequestCanceled) + } else { + Timber.tag("SocialViewModel").d("API 호출 실패: ${response.message}") + tokenManager.refreshToken( + onSuccess = { performPatchApprove(friendId, myName) }, + onFailure = { Timber.e("patchApprove API call failed") } + ) } retrieveTokenFromServer(friendId) - _retrieveToken.value?.let { tokens -> - for (token in tokens) { + retrieveTokens?.let { tokens -> + tokens.forEach { token -> postFCM(myName, token, 2) } - } ?: run { } } catch (e: Exception) { Timber.e("Exception occurred: ${e.message}") tokenManager.refreshToken( - onSuccess = { patchApprove(friendId, myName) }, + onSuccess = { performPatchApprove(friendId, myName) }, onFailure = { Timber.e("patchApprove API call failed") } ) } } } - private var retryCount = 0 // 재시도 횟수 관리 - private val maxRetryCount = 1 // 최대 재시도 횟수 + private fun postFCM(name: String, token: String, type: Int, retryCount: Int = 0) { + val maxRetries = 5 + val fcm = when (type) { + 1 -> FCM(token = token, title = "친구 요청", body = "${name}님이 친구 요청을 보냈습니다.") + 2 -> FCM(token = token, title = "친구 수락", body = "${name}님이 친구 요청을 수락했습니다.") + 3 -> FCM(token = token, title = "질문 전송", body = "${name}님이 질문을 보냈습니다. 확인보세요!") + else -> null + } ?: return - private fun retrieveTokenFromServer(id: Long) { - resetToken() viewModelScope.launch { try { - val response = withContext(Dispatchers.IO) { - fcmRepository.getToken(id) - } - - if (response.isSuccess) { - withContext(Dispatchers.Main) { - _retrieveToken.value = response.result.tokens - } - Timber.tag("Token Retrieval").d(_retrieveToken.value.toString()) - retryCount = 0 + fcmRepository.postFCM(fcm) + Timber.tag("socialViewModel postFCM").d("성공") + } catch (e: Exception) { + Timber.tag("socialViewModel postFCM").e("Exception occurred: ${e.message}") + if (retryCount < maxRetries) { + Timber.tag("socialViewModel postFCM").d("Retrying... (${retryCount + 1}/$maxRetries)") + postFCM(name, token, type, retryCount + 1) } else { - Timber.tag("Token Retrieval").d("토큰 조회 실패: ${response.message}") - if (retryCount < maxRetryCount) { - retryCount++ - tokenManager.refreshToken( - onSuccess = { retrieveTokenFromServer(id) }, - onFailure = { Timber.e("Token Retrieval failed - Refresh token failed") } - ) - } else { - Timber.tag("Token Retrieval").e("최대 재시도 도달, 추가 호출 중단") - } + Timber.tag("socialViewModel postFCM").e("Max retry attempts reached.") } - } catch (e: Exception) { - Timber.tag("Token Retrieval").e("Exception occurred: ${e.message}") + } + } + } + + private suspend fun retrieveTokenFromServer(id: Long) { + retrieveTokens = null + try { + val response = withContext(Dispatchers.IO) { + fcmRepository.getToken(id) + } + + if (response.isSuccess) { + retrieveTokens = response.result.tokens + Timber.tag("Token Retrieval").d(retrieveTokens.toString()) + retryCount = 0 + } else { + Timber.tag("Token Retrieval").d("토큰 조회 실패: ${response.message}") if (retryCount < maxRetryCount) { retryCount++ tokenManager.refreshToken( - onSuccess = { retrieveTokenFromServer(id) }, + onSuccess = { viewModelScope.launch { retrieveTokenFromServer(id) } }, onFailure = { Timber.e("Token Retrieval failed - Refresh token failed") } ) } else { Timber.tag("Token Retrieval").e("최대 재시도 도달, 추가 호출 중단") } } + } catch (e: Exception) { + Timber.tag("Token Retrieval").e("Exception occurred: ${e.message}") + if (retryCount < maxRetryCount) { + retryCount++ + tokenManager.refreshToken( + onSuccess = { viewModelScope.launch { retrieveTokenFromServer(id) } }, + onFailure = { Timber.e("Token Retrieval failed - Refresh token failed") } + ) + } else { + Timber.tag("Token Retrieval").e("최대 재시도 도달, 추가 호출 중단") + } } } - fun resetQuestionData() { - _selectedChar.value = -1 - _nextBtnEnabled.value = false - _questionDto.value = QuestionDto(0, "", "", false, null) // 초기화 - _selectedEmotion.value = 0 - _selectedEmotionMent.value = "" - _optionList.value = emptyList() + private fun performResetFriendState() { + Timber.tag("destroy").d(currentState.isFriend) + updateState { copy(isFriend = "no") } + } + + private fun performResetQuestionData() { + updateState { + copy( + selectedChar = -1, + nextBtnEnabled = false, + questionDto = QuestionDto(0, "", "", false, null), + selectedEmotion = null, + selectedEmotionMent = "", + optionList = emptyList() + ) + } + } + private fun mapToFriendModels(friendsDto: FriendsDto): List { + return friendsDto.friends.map { friend -> + FriendListModel( + id = friend.userId, + name = friend.nickname, + message = friend.ment ?: "", + emotion = emotionType(friend.emotion) + ) + } } - private fun resetToken() { - _retrieveToken.postValue(null) // 백그라운드에서도 안전하게 실행 + private fun emotionType(type: String?): Int? { + return when (type) { + "HAPPY" -> 1 + "EXCITED" -> 2 + "NORMAL" -> 3 + "NERVOUS" -> 4 + "ANGRY" -> 5 + else -> null + } } + // ============================================================ + // Legacy public functions for gradual migration + // These wrap MVI actions for backward compatibility + // ============================================================ + fun getAnswerLength(answer: String): Int = answer.length + + fun getFriendsData() = onAction(SocialAction.LoadFriends) + fun getSearchData(name: String) = onAction(SocialAction.SearchFriend(name)) + fun onCharSelected(position: Int) = onAction(SocialAction.SelectCharacter(position)) + fun setTargetFriend(friendName: String, emotion: Int?, ment: String?) = + onAction(SocialAction.SetTargetFriend(friendName, emotion, ment)) + fun setTypeFriend(type: String) = onAction(SocialAction.SetTypeFriend(type)) + fun updateQuestionOptions(newOptions: List) = onAction(SocialAction.UpdateQuestionOptions(newOptions)) + fun updateOption() = onAction(SocialAction.UpdateOption) + fun removeOptions() = onAction(SocialAction.RemoveOptions) + fun removeContent() = onAction(SocialAction.RemoveContent) + fun isAnonymous(isChecked: Boolean) = onAction(SocialAction.SetAnonymous(isChecked)) + fun sendQuestion(myName: String) = onAction(SocialAction.SendQuestion(myName)) + fun sendFriendRequest(friendId: Long, myName: String) = onAction(SocialAction.SendFriendRequest(friendId, myName)) + fun deleteFriend(friendName: String?, friendId: Long?) = onAction(SocialAction.DeleteFriend(friendName, friendId)) + fun patchApproveNotice(name: String, myName: String, alarmId: Int, position: Int) = + onAction(SocialAction.ApproveNotice(name, myName, alarmId, position)) + fun patchApprove(friendId: Long, myName: String) = onAction(SocialAction.PatchApprove(friendId, myName)) + fun resetFriendState() = onAction(SocialAction.ResetFriendState) + fun resetQuestionData() = onAction(SocialAction.ResetQuestionData) } - diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/viewmodel/UserContract.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/viewmodel/UserContract.kt new file mode 100644 index 00000000..a8169913 --- /dev/null +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/viewmodel/UserContract.kt @@ -0,0 +1,24 @@ +package com.toyou.toyouandroid.presentation.viewmodel + +import com.toyou.core.common.mvi.UiAction +import com.toyou.core.common.mvi.UiEvent +import com.toyou.core.common.mvi.UiState + +data class UserUiState( + val cardId: Int? = null, + val emotion: String? = null, + val nickname: String = "", + val cardNum: Int = 0, + val uncheckedAlarm: Boolean = false, + val isLoading: Boolean = false +) : UiState + +sealed interface UserEvent : UiEvent { + data class ShowError(val message: String) : UserEvent + data object TokenExpired : UserEvent +} + +sealed interface UserAction : UiAction { + data object LoadHomeEntry : UserAction + data class UpdateCardId(val cardId: Int?) : UserAction +} diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/viewmodel/UserViewModel.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/viewmodel/UserViewModel.kt index c0445e76..dba92586 100644 --- a/app/src/main/java/com/toyou/toyouandroid/presentation/viewmodel/UserViewModel.kt +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/viewmodel/UserViewModel.kt @@ -2,11 +2,13 @@ package com.toyou.toyouandroid.presentation.viewmodel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.toyou.toyouandroid.domain.create.repository.CreateRepository +import com.toyou.core.common.mvi.MviViewModel +import com.toyou.toyouandroid.domain.create.repository.ICreateRepository import com.toyou.toyouandroid.utils.TokenManager import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -14,56 +16,94 @@ import javax.inject.Inject @HiltViewModel class UserViewModel @Inject constructor( private val tokenManager: TokenManager, - private val repository: CreateRepository -) : ViewModel() { + private val repository: ICreateRepository +) : MviViewModel( + initialState = UserUiState() +) { private val _cardId = MutableLiveData() val cardId: LiveData get() = _cardId private val _emotion = MutableLiveData() - val emotion : LiveData get() = _emotion + val emotion: LiveData get() = _emotion private val _nickname = MutableLiveData() - val nickname : LiveData get() = _nickname + val nickname: LiveData get() = _nickname private val _cardNum = MutableLiveData() - val cardNum : LiveData get() = _cardNum + val cardNum: LiveData get() = _cardNum private val _uncheckedAlarm = MutableLiveData() - val uncheckedAlarm : LiveData get() = _uncheckedAlarm + val uncheckedAlarm: LiveData get() = _uncheckedAlarm - fun getHomeEntry() { + init { + state.onEach { uiState -> + _cardId.value = uiState.cardId + _emotion.value = uiState.emotion + _nickname.value = uiState.nickname + _cardNum.value = uiState.cardNum + _uncheckedAlarm.value = uiState.uncheckedAlarm + }.launchIn(viewModelScope) + } + + override fun handleAction(action: UserAction) { + when (action) { + is UserAction.LoadHomeEntry -> performLoadHomeEntry() + is UserAction.UpdateCardId -> performUpdateCardId(action.cardId) + } + } + + private fun performLoadHomeEntry() { viewModelScope.launch { + updateState { copy(isLoading = true) } try { val response = repository.getHomeEntryData() if (response.isSuccess) { - _cardId.value = response.result.id - _emotion.value = response.result.emotion - _nickname.value = response.result.nickname - _cardNum.value = response.result.question - _uncheckedAlarm.value = response.result.alarm - + updateState { + copy( + cardId = response.result.id, + emotion = response.result.emotion, + nickname = response.result.nickname, + cardNum = response.result.question, + uncheckedAlarm = response.result.alarm, + isLoading = false + ) + } Timber.tag("UserViewModel").d("API 성공, ${response.result}") } else { Timber.tag("UserViewModel").d("API 실패: ${response.message}") + updateState { copy(isLoading = false) } tokenManager.refreshToken( - onSuccess = { getHomeEntry() }, - onFailure = { Timber.e("getHomeEntry API call failed") } + onSuccess = { performLoadHomeEntry() }, + onFailure = { + Timber.e("getHomeEntry API call failed") + sendEvent(UserEvent.TokenExpired) + } ) } } catch (e: Exception) { Timber.tag("UserViewModel").d("예외 발생: ${e.message}") + updateState { copy(isLoading = false) } tokenManager.refreshToken( - onSuccess = { getHomeEntry() }, - onFailure = { Timber.e("getHomeEntry API call failed") } + onSuccess = { performLoadHomeEntry() }, + onFailure = { + Timber.e("getHomeEntry API call failed") + sendEvent(UserEvent.ShowError(e.message ?: "Unknown error")) + } ) } } } + private fun performUpdateCardId(cardId: Int?) { + updateState { copy(cardId = cardId) } + } + + fun getHomeEntry() = onAction(UserAction.LoadHomeEntry) + fun updateCardIdFromOtherViewModel(otherViewModel: CardViewModel) { otherViewModel.cardId.observeForever { newCardId -> - _cardId.value = newCardId + onAction(UserAction.UpdateCardId(newCardId)) } } } diff --git a/app/src/main/java/com/toyou/toyouandroid/presentation/viewmodel/ViewModelManager.kt b/app/src/main/java/com/toyou/toyouandroid/presentation/viewmodel/ViewModelManager.kt index b4dcc046..8a154c5e 100644 --- a/app/src/main/java/com/toyou/toyouandroid/presentation/viewmodel/ViewModelManager.kt +++ b/app/src/main/java/com/toyou/toyouandroid/presentation/viewmodel/ViewModelManager.kt @@ -1,14 +1,15 @@ package com.toyou.toyouandroid.presentation.viewmodel -import com.toyou.toyouandroid.presentation.fragment.onboarding.SignupNicknameViewModel +import com.toyou.toyouandroid.presentation.fragment.home.HomeAction import com.toyou.toyouandroid.presentation.fragment.home.HomeViewModel +import com.toyou.toyouandroid.presentation.fragment.onboarding.SignupNicknameViewModel class ViewModelManager( private val signupNicknameViewModel: SignupNicknameViewModel, private val homeViewModel: HomeViewModel, - ) { +) { fun resetAllViewModels() { signupNicknameViewModel.resetState() - homeViewModel.resetState() + homeViewModel.onAction(HomeAction.ResetState) } } diff --git a/app/src/main/java/com/toyou/toyouandroid/utils/TokenManager.kt b/app/src/main/java/com/toyou/toyouandroid/utils/TokenManager.kt index 57c7079a..9aeba95b 100644 --- a/app/src/main/java/com/toyou/toyouandroid/utils/TokenManager.kt +++ b/app/src/main/java/com/toyou/toyouandroid/utils/TokenManager.kt @@ -1,45 +1,55 @@ package com.toyou.toyouandroid.utils -import com.toyou.toyouandroid.data.onboarding.dto.response.SignUpResponse import com.toyou.toyouandroid.data.onboarding.service.AuthService import com.toyou.toyouandroid.network.AuthNetworkModule -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response +import com.toyou.core.datastore.TokenStorage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import timber.log.Timber - -class TokenManager(private val authService: AuthService, private val tokenStorage: TokenStorage) { - - fun refreshToken(onSuccess: (String) -> Unit, onFailure: () -> Unit) { - authService.reissue(tokenStorage.getRefreshToken().toString()).enqueue(object : - Callback { - override fun onResponse(call: Call, response: Response) { - if (response.isSuccessful) { - response.headers()["access_token"]?.let { newAccessToken -> - response.headers()["refresh_token"]?.let { newRefreshToken -> - Timber.d("Tokens received from server - Access: $newAccessToken, Refresh: $newRefreshToken") - - // 암호화된 토큰 저장소에 저장 - tokenStorage.saveTokens(newAccessToken, newRefreshToken) - Timber.i("Tokens saved successfully") - - // 인증 네트워크 모듈에 재발급받은 access token 저장 - AuthNetworkModule.setAccessToken(newAccessToken) - - // 성공 콜백 실행 - onSuccess(newAccessToken) - - } ?: Timber.e("Refresh token missing in response headers") - } ?: Timber.e("Access token missing in response headers") +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class TokenManager @Inject constructor( + private val authService: AuthService, + private val tokenStorage: TokenStorage +) { + suspend fun refreshTokenSuspend(): Result { + return try { + val response = authService.reissue(tokenStorage.getRefreshToken().toString()) + if (response.isSuccessful) { + val newAccessToken = response.headers()["access_token"] + val newRefreshToken = response.headers()["refresh_token"] + if (newAccessToken != null && newRefreshToken != null) { + Timber.d("Tokens received from server - Access: $newAccessToken, Refresh: $newRefreshToken") + tokenStorage.saveTokens(newAccessToken, newRefreshToken) + AuthNetworkModule.setAccessToken(newAccessToken) + Timber.i("Tokens saved successfully") + Result.success(newAccessToken) } else { - val errorMessage = response.errorBody()?.string() ?: "Unknown error" - Timber.e("API Error: $errorMessage") + Timber.e("Token missing in response headers") + Result.failure(Exception("Token missing in response headers")) } + } else { + val errorMessage = response.errorBody()?.string() ?: "Unknown error" + Timber.e("API Error: $errorMessage") + Result.failure(Exception(errorMessage)) } + } catch (e: Exception) { + Timber.e("Token refresh failed: ${e.message}") + Result.failure(e) + } + } - override fun onFailure(call: Call, t: Throwable) { + fun refreshToken(onSuccess: (String) -> Unit, onFailure: () -> Unit) { + CoroutineScope(Dispatchers.IO).launch { + val result = refreshTokenSuspend() + result.onSuccess { token -> + onSuccess(token) + }.onFailure { onFailure() } - }) + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/toyou/toyouandroid/utils/TokenStorage.kt b/app/src/main/java/com/toyou/toyouandroid/utils/TokenStorage.kt deleted file mode 100644 index ce332a4a..00000000 --- a/app/src/main/java/com/toyou/toyouandroid/utils/TokenStorage.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.toyou.toyouandroid.utils - -import android.content.Context - -class TokenStorage(context: Context) { - private val sharedPreferences = context.getSharedPreferences("token_prefs", Context.MODE_PRIVATE) - - fun saveTokens(accessToken: String, refreshToken: String) { - sharedPreferences.edit().apply { - putString("access_token", accessToken) - putString("refresh_token", refreshToken) - apply() - } - } - - fun saveFcmToken(fcmToken: String){ - sharedPreferences.edit().apply { - putString("fcm_token", fcmToken) - apply() - } - } - - fun getAccessToken(): String? = sharedPreferences.getString("access_token", null) - fun getRefreshToken(): String? = sharedPreferences.getString("refresh_token", null) - - fun getFcmToken() : String? = sharedPreferences.getString("fcm_token", null) - - fun clearTokens() { - sharedPreferences.edit().apply { - remove("access_token") - remove("refresh_token") - apply() - } - } - - companion object { - private const val KEY_IS_TOKEN_SENT = "is_token_sent" - } - - fun isTokenSent(): Boolean { - return sharedPreferences.getBoolean(KEY_IS_TOKEN_SENT, false) - } - - fun setTokenSent(value: Boolean) { - sharedPreferences.edit().putBoolean(KEY_IS_TOKEN_SENT, value).apply() - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/toyou/toyouandroid/utils/TutorialStorage.kt b/app/src/main/java/com/toyou/toyouandroid/utils/TutorialStorage.kt deleted file mode 100644 index 350b005b..00000000 --- a/app/src/main/java/com/toyou/toyouandroid/utils/TutorialStorage.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.toyou.toyouandroid.utils - -import android.content.Context - -class TutorialStorage(context: Context) { - private val preferences = context.getSharedPreferences("tutorial_prefs", Context.MODE_PRIVATE) - - companion object { - private const val TUTORIAL_SHOWN = "tutorial_shown" - } - - fun isTutorialShown(): Boolean { - return preferences.getBoolean(TUTORIAL_SHOWN, false) - } - - fun setTutorialShown() { - preferences.edit().putBoolean(TUTORIAL_SHOWN, true).apply() - } - - fun setTutorialNotShown() { - preferences.edit().putBoolean(TUTORIAL_SHOWN, false).apply() - } -} \ No newline at end of file diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index ba1ca7e7..d267e751 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -12,5 +12,9 @@ dependencies { implementation(libs.timber) // Coroutines - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + implementation(libs.kotlinx.coroutines.android) + + // Lifecycle for MVI base + implementation(libs.androidx.lifecycle.viewmodel) + implementation(libs.androidx.lifecycle.runtime) } diff --git a/core/common/src/main/kotlin/com/toyou/core/common/mvi/MviContract.kt b/core/common/src/main/kotlin/com/toyou/core/common/mvi/MviContract.kt new file mode 100644 index 00000000..be3b36c1 --- /dev/null +++ b/core/common/src/main/kotlin/com/toyou/core/common/mvi/MviContract.kt @@ -0,0 +1,18 @@ +package com.toyou.core.common.mvi + +/** + * MVI 패턴의 기본 계약 인터페이스들 + * + * State: 화면의 현재 상태 (지속적) + * Event: 일회성 부수효과 (Navigation, Snackbar 등) + * Action: 사용자 인텐트/액션 + */ + +/** UI 상태를 나타내는 마커 인터페이스 */ +interface UiState + +/** 일회성 이벤트(Side Effect)를 나타내는 마커 인터페이스 */ +interface UiEvent + +/** 사용자 액션/인텐트를 나타내는 마커 인터페이스 */ +interface UiAction diff --git a/core/common/src/main/kotlin/com/toyou/core/common/mvi/MviExtensions.kt b/core/common/src/main/kotlin/com/toyou/core/common/mvi/MviExtensions.kt new file mode 100644 index 00000000..80ff1d56 --- /dev/null +++ b/core/common/src/main/kotlin/com/toyou/core/common/mvi/MviExtensions.kt @@ -0,0 +1,55 @@ +package com.toyou.core.common.mvi + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +/** + * Fragment/Activity에서 StateFlow를 안전하게 수집 + * + * Usage: + * ``` + * viewLifecycleOwner.collectState(viewModel.state) { state -> + * // UI 업데이트 + * } + * ``` + */ +fun LifecycleOwner.collectState( + flow: Flow, + state: Lifecycle.State = Lifecycle.State.STARTED, + collector: suspend (T) -> Unit +) { + lifecycleScope.launch { + repeatOnLifecycle(state) { + flow.collect(collector) + } + } +} + +/** + * Fragment/Activity에서 Event를 안전하게 수집 + * + * Usage: + * ``` + * viewLifecycleOwner.collectEvent(viewModel.event) { event -> + * when (event) { + * is HomeEvent.NavigateToDetail -> navigateToDetail() + * is HomeEvent.ShowError -> showError(event.message) + * } + * } + * ``` + */ +fun LifecycleOwner.collectEvent( + flow: Flow, + state: Lifecycle.State = Lifecycle.State.STARTED, + collector: suspend (T) -> Unit +) { + lifecycleScope.launch { + repeatOnLifecycle(state) { + flow.collect(collector) + } + } +} diff --git a/core/common/src/main/kotlin/com/toyou/core/common/mvi/MviViewModel.kt b/core/common/src/main/kotlin/com/toyou/core/common/mvi/MviViewModel.kt new file mode 100644 index 00000000..05c374eb --- /dev/null +++ b/core/common/src/main/kotlin/com/toyou/core/common/mvi/MviViewModel.kt @@ -0,0 +1,78 @@ +package com.toyou.core.common.mvi + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch + +/** + * MVI 패턴을 위한 Base ViewModel + * + * @param S UI 상태 타입 (UiState 구현체) + * @param E 이벤트 타입 (UiEvent 구현체) - Navigation, Snackbar 등 일회성 + * @param A 액션 타입 (UiAction 구현체) - 사용자 인텐트 + * + * Usage: + * ``` + * class HomeViewModel : MviViewModel(HomeState()) { + * override fun handleAction(action: HomeAction) { + * when (action) { + * is HomeAction.LoadData -> loadData() + * is HomeAction.Refresh -> refresh() + * } + * } + * } + * ``` + */ +abstract class MviViewModel( + initialState: S +) : ViewModel() { + + private val _state = MutableStateFlow(initialState) + val state: StateFlow = _state.asStateFlow() + + private val _event = Channel(Channel.BUFFERED) + val event = _event.receiveAsFlow() + + /** + * 현재 상태를 반환 + */ + protected val currentState: S + get() = _state.value + + /** + * 상태 업데이트 + * @param reduce 현재 상태를 받아 새 상태를 반환하는 함수 + */ + protected fun updateState(reduce: S.() -> S) { + _state.value = currentState.reduce() + } + + /** + * 일회성 이벤트 발행 + * Navigation, Snackbar, Toast 등에 사용 + */ + protected fun sendEvent(event: E) { + viewModelScope.launch { + _event.send(event) + } + } + + /** + * 사용자 액션 처리 + * View에서 호출하여 인텐트 전달 + */ + fun onAction(action: A) { + handleAction(action) + } + + /** + * 액션 처리 로직 구현 + * 하위 클래스에서 when 문으로 각 액션 처리 + */ + protected abstract fun handleAction(action: A) +} diff --git a/core/datastore/src/main/kotlin/com/toyou/core/datastore/NotificationPreferences.kt b/core/datastore/src/main/kotlin/com/toyou/core/datastore/NotificationPreferences.kt new file mode 100644 index 00000000..6e0d768a --- /dev/null +++ b/core/datastore/src/main/kotlin/com/toyou/core/datastore/NotificationPreferences.kt @@ -0,0 +1,47 @@ +package com.toyou.core.datastore + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.notificationDataStore: DataStore by preferencesDataStore(name = "fcm_preferences") + +@Singleton +class NotificationPreferences @Inject constructor( + @ApplicationContext private val context: Context +) { + private val dataStore = context.notificationDataStore + + val isSubscribedFlow: Flow = dataStore.data.map { prefs -> + prefs[KEY_IS_SUBSCRIBED] ?: true + } + + suspend fun setSubscribed(value: Boolean) { + dataStore.edit { prefs -> + prefs[KEY_IS_SUBSCRIBED] = value + } + } + + // Blocking versions for backward compatibility + fun isSubscribed(): Boolean = runBlocking { + dataStore.data.map { it[KEY_IS_SUBSCRIBED] ?: true }.first() + } + + fun setSubscribedSync(value: Boolean) = runBlocking { + setSubscribed(value) + } + + companion object { + private val KEY_IS_SUBSCRIBED = booleanPreferencesKey("isSubscribed") + } +} diff --git a/core/datastore/src/main/kotlin/com/toyou/core/datastore/TokenStorage.kt b/core/datastore/src/main/kotlin/com/toyou/core/datastore/TokenStorage.kt index 2104dca6..9fc878ac 100644 --- a/core/datastore/src/main/kotlin/com/toyou/core/datastore/TokenStorage.kt +++ b/core/datastore/src/main/kotlin/com/toyou/core/datastore/TokenStorage.kt @@ -1,55 +1,110 @@ package com.toyou.core.datastore import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking import javax.inject.Inject import javax.inject.Singleton +private val Context.tokenDataStore: DataStore by preferencesDataStore(name = "token_prefs") + @Singleton class TokenStorage @Inject constructor( - @ApplicationContext context: Context + @ApplicationContext private val context: Context ) { - private val sharedPreferences = context.getSharedPreferences("token_prefs", Context.MODE_PRIVATE) + private val dataStore = context.tokenDataStore + + val accessTokenFlow: Flow = dataStore.data.map { prefs -> + prefs[KEY_ACCESS_TOKEN] + } + + val refreshTokenFlow: Flow = dataStore.data.map { prefs -> + prefs[KEY_REFRESH_TOKEN] + } + + val fcmTokenFlow: Flow = dataStore.data.map { prefs -> + prefs[KEY_FCM_TOKEN] + } - fun saveTokens(accessToken: String, refreshToken: String) { - sharedPreferences.edit().apply { - putString(KEY_ACCESS_TOKEN, accessToken) - putString(KEY_REFRESH_TOKEN, refreshToken) - apply() + suspend fun saveTokens(accessToken: String, refreshToken: String) { + dataStore.edit { prefs -> + prefs[KEY_ACCESS_TOKEN] = accessToken + prefs[KEY_REFRESH_TOKEN] = refreshToken } } - fun saveFcmToken(fcmToken: String) { - sharedPreferences.edit().apply { - putString(KEY_FCM_TOKEN, fcmToken) - apply() + suspend fun saveFcmToken(fcmToken: String) { + dataStore.edit { prefs -> + prefs[KEY_FCM_TOKEN] = fcmToken } } - fun getAccessToken(): String? = sharedPreferences.getString(KEY_ACCESS_TOKEN, null) - fun getRefreshToken(): String? = sharedPreferences.getString(KEY_REFRESH_TOKEN, null) - fun getFcmToken(): String? = sharedPreferences.getString(KEY_FCM_TOKEN, null) + suspend fun clearTokens() { + dataStore.edit { prefs -> + prefs.remove(KEY_ACCESS_TOKEN) + prefs.remove(KEY_REFRESH_TOKEN) + } + } - fun clearTokens() { - sharedPreferences.edit().apply { - remove(KEY_ACCESS_TOKEN) - remove(KEY_REFRESH_TOKEN) - apply() + suspend fun setTokenSent(value: Boolean) { + dataStore.edit { prefs -> + prefs[KEY_IS_TOKEN_SENT] = value } } - fun isTokenSent(): Boolean { - return sharedPreferences.getBoolean(KEY_IS_TOKEN_SENT, false) + suspend fun isTokenSentSuspend(): Boolean { + return dataStore.data.map { prefs -> + prefs[KEY_IS_TOKEN_SENT] ?: false + }.first() + } + + // Blocking versions for backward compatibility during migration + fun getAccessToken(): String? = runBlocking { + dataStore.data.map { it[KEY_ACCESS_TOKEN] }.first() + } + + fun getRefreshToken(): String? = runBlocking { + dataStore.data.map { it[KEY_REFRESH_TOKEN] }.first() + } + + fun getFcmToken(): String? = runBlocking { + dataStore.data.map { it[KEY_FCM_TOKEN] }.first() + } + + fun isTokenSent(): Boolean = runBlocking { + dataStore.data.map { it[KEY_IS_TOKEN_SENT] ?: false }.first() + } + + // Blocking write methods for backward compatibility + fun saveTokensSync(accessToken: String, refreshToken: String) = runBlocking { + saveTokens(accessToken, refreshToken) + } + + fun saveFcmTokenSync(fcmToken: String) = runBlocking { + saveFcmToken(fcmToken) + } + + fun clearTokensSync() = runBlocking { + clearTokens() } - fun setTokenSent(value: Boolean) { - sharedPreferences.edit().putBoolean(KEY_IS_TOKEN_SENT, value).apply() + fun setTokenSentSync(value: Boolean) = runBlocking { + setTokenSent(value) } companion object { - private const val KEY_ACCESS_TOKEN = "access_token" - private const val KEY_REFRESH_TOKEN = "refresh_token" - private const val KEY_FCM_TOKEN = "fcm_token" - private const val KEY_IS_TOKEN_SENT = "is_token_sent" + private val KEY_ACCESS_TOKEN = stringPreferencesKey("access_token") + private val KEY_REFRESH_TOKEN = stringPreferencesKey("refresh_token") + private val KEY_FCM_TOKEN = stringPreferencesKey("fcm_token") + private val KEY_IS_TOKEN_SENT = booleanPreferencesKey("is_token_sent") } } diff --git a/core/datastore/src/main/kotlin/com/toyou/core/datastore/TutorialStorage.kt b/core/datastore/src/main/kotlin/com/toyou/core/datastore/TutorialStorage.kt index 86e855d6..1beef729 100644 --- a/core/datastore/src/main/kotlin/com/toyou/core/datastore/TutorialStorage.kt +++ b/core/datastore/src/main/kotlin/com/toyou/core/datastore/TutorialStorage.kt @@ -1,29 +1,57 @@ package com.toyou.core.datastore import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking import javax.inject.Inject import javax.inject.Singleton +private val Context.tutorialDataStore: DataStore by preferencesDataStore(name = "tutorial_prefs") + @Singleton class TutorialStorage @Inject constructor( - @ApplicationContext context: Context + @ApplicationContext private val context: Context ) { - private val preferences = context.getSharedPreferences("tutorial_prefs", Context.MODE_PRIVATE) + private val dataStore = context.tutorialDataStore + + val tutorialShownFlow: Flow = dataStore.data.map { prefs -> + prefs[KEY_TUTORIAL_SHOWN] ?: false + } + + suspend fun setTutorialShown() { + dataStore.edit { prefs -> + prefs[KEY_TUTORIAL_SHOWN] = true + } + } + + suspend fun setTutorialNotShown() { + dataStore.edit { prefs -> + prefs[KEY_TUTORIAL_SHOWN] = false + } + } - fun isTutorialShown(): Boolean { - return preferences.getBoolean(KEY_TUTORIAL_SHOWN, false) + // Blocking versions for backward compatibility + fun isTutorialShown(): Boolean = runBlocking { + dataStore.data.map { it[KEY_TUTORIAL_SHOWN] ?: false }.first() } - fun setTutorialShown() { - preferences.edit().putBoolean(KEY_TUTORIAL_SHOWN, true).apply() + fun setTutorialShownSync() = runBlocking { + setTutorialShown() } - fun setTutorialNotShown() { - preferences.edit().putBoolean(KEY_TUTORIAL_SHOWN, false).apply() + fun setTutorialNotShownSync() = runBlocking { + setTutorialNotShown() } companion object { - private const val KEY_TUTORIAL_SHOWN = "tutorial_shown" + private val KEY_TUTORIAL_SHOWN = booleanPreferencesKey("tutorial_shown") } } diff --git a/core/datastore/src/main/kotlin/com/toyou/core/datastore/UserPreferences.kt b/core/datastore/src/main/kotlin/com/toyou/core/datastore/UserPreferences.kt index 5c77233e..ac3b5482 100644 --- a/core/datastore/src/main/kotlin/com/toyou/core/datastore/UserPreferences.kt +++ b/core/datastore/src/main/kotlin/com/toyou/core/datastore/UserPreferences.kt @@ -1,35 +1,90 @@ package com.toyou.core.datastore import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking import javax.inject.Inject import javax.inject.Singleton +private val Context.userDataStore: DataStore by preferencesDataStore(name = "user_prefs") + @Singleton class UserPreferences @Inject constructor( - @ApplicationContext context: Context + @ApplicationContext private val context: Context ) { - private val preferences = context.getSharedPreferences("user_prefs", Context.MODE_PRIVATE) + private val dataStore = context.userDataStore + + val userIdFlow: Flow = dataStore.data.map { prefs -> + prefs[KEY_USER_ID] ?: -1 + } + + val nicknameFlow: Flow = dataStore.data.map { prefs -> + prefs[KEY_NICKNAME] + } + + val statusFlow: Flow = dataStore.data.map { prefs -> + prefs[KEY_STATUS] + } + + suspend fun setUserId(value: Int) { + dataStore.edit { prefs -> + prefs[KEY_USER_ID] = value + } + } + + suspend fun setNickname(value: String?) { + dataStore.edit { prefs -> + if (value != null) { + prefs[KEY_NICKNAME] = value + } else { + prefs.remove(KEY_NICKNAME) + } + } + } + + suspend fun setStatus(value: String?) { + dataStore.edit { prefs -> + if (value != null) { + prefs[KEY_STATUS] = value + } else { + prefs.remove(KEY_STATUS) + } + } + } + + suspend fun clear() { + dataStore.edit { prefs -> + prefs.clear() + } + } + // Blocking versions for backward compatibility var userId: Int - get() = preferences.getInt(KEY_USER_ID, -1) - set(value) = preferences.edit().putInt(KEY_USER_ID, value).apply() + get() = runBlocking { dataStore.data.map { it[KEY_USER_ID] ?: -1 }.first() } + set(value) = runBlocking { setUserId(value) } var nickname: String? - get() = preferences.getString(KEY_NICKNAME, null) - set(value) = preferences.edit().putString(KEY_NICKNAME, value).apply() + get() = runBlocking { dataStore.data.map { it[KEY_NICKNAME] }.first() } + set(value) = runBlocking { setNickname(value) } var status: String? - get() = preferences.getString(KEY_STATUS, null) - set(value) = preferences.edit().putString(KEY_STATUS, value).apply() + get() = runBlocking { dataStore.data.map { it[KEY_STATUS] }.first() } + set(value) = runBlocking { setStatus(value) } - fun clear() { - preferences.edit().clear().apply() - } + fun clearSync() = runBlocking { clear() } companion object { - private const val KEY_USER_ID = "user_id" - private const val KEY_NICKNAME = "nickname" - private const val KEY_STATUS = "status" + private val KEY_USER_ID = intPreferencesKey("user_id") + private val KEY_NICKNAME = stringPreferencesKey("nickname") + private val KEY_STATUS = stringPreferencesKey("status") } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 28259472..8e3174c2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -48,6 +48,9 @@ kakaoSdk = "2.20.3" material = "1.12.0" dotsIndicator = "5.0" +# Coroutines +coroutines = "1.7.3" + # Utils timber = "4.7.1" @@ -123,6 +126,10 @@ kakao-sdk = { group = "com.kakao.sdk", name = "v2-user", version.ref = "kakaoSdk material = { group = "com.google.android.material", name = "material", version.ref = "material" } dotsIndicator = { group = "com.tbuonomo", name = "dotsindicator", version.ref = "dotsIndicator" } +# Coroutines +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } + # Utils timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }