diff --git a/core/common/src/main/java/com/neki/android/core/common/exception/ApiException.kt b/core/common/src/main/java/com/neki/android/core/common/exception/ApiException.kt new file mode 100644 index 000000000..bb2c14629 --- /dev/null +++ b/core/common/src/main/java/com/neki/android/core/common/exception/ApiException.kt @@ -0,0 +1,11 @@ +package com.neki.android.core.common.exception + +class ServerApiException( + val code: Int, + message: String, +) : Throwable(message) + +class ClientApiException( + val code: Int, + message: String, +) : Throwable(message) diff --git a/core/common/src/main/java/com/neki/android/core/common/exception/RandomPoseRetryExhaustedException.kt b/core/common/src/main/java/com/neki/android/core/common/exception/RandomPoseRetryExhaustedException.kt deleted file mode 100644 index a08f3ac50..000000000 --- a/core/common/src/main/java/com/neki/android/core/common/exception/RandomPoseRetryExhaustedException.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.neki.android.core.common.exception - -class RandomPoseRetryExhaustedException( - message: String, -) : RuntimeException(message) diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt index e81cbfb08..3aee14dbf 100644 --- a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt @@ -6,6 +6,8 @@ import com.neki.android.core.model.Pose import com.neki.android.core.model.SortOrder import kotlinx.coroutines.flow.Flow +const val NO_MORE_RANDOM_POSE = 400 + interface PoseRepository { fun getPosesFlow( @@ -22,14 +24,12 @@ interface PoseRepository { suspend fun getSingleRandomPose( headCount: PeopleCount, excludeIds: Set, - maxRetry: Int, ): Result suspend fun getMultipleRandomPose( headCount: PeopleCount, excludeIds: Set, poseSize: Int, - maxRetry: Int, ): Result> suspend fun updateScrap(poseId: Long, scrap: Boolean): Result diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/UserRepository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/UserRepository.kt index 892385422..e5506a604 100644 --- a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/UserRepository.kt +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/UserRepository.kt @@ -1,9 +1,13 @@ package com.neki.android.core.dataapi.repository import com.neki.android.core.model.UserInfo +import kotlinx.coroutines.flow.Flow interface UserRepository { + val hasVisitedRandomPose: Flow + suspend fun setRandomPoseVisited() suspend fun getUserInfo(): Result suspend fun updateUserInfo(nickname: String): Result + suspend fun updateProfileImage(mediaId: Long?): Result } diff --git a/core/data/src/main/java/com/neki/android/core/data/local/di/DataStoreModule.kt b/core/data/src/main/java/com/neki/android/core/data/local/di/DataStoreModule.kt index 568cc7405..3905e74ef 100644 --- a/core/data/src/main/java/com/neki/android/core/data/local/di/DataStoreModule.kt +++ b/core/data/src/main/java/com/neki/android/core/data/local/di/DataStoreModule.kt @@ -17,6 +17,9 @@ private val Context.authDataStore: DataStore by preferencesDataStor private const val TOKEN_DATASTORE = "token_datastore" private val Context.tokenDataStore: DataStore by preferencesDataStore(name = TOKEN_DATASTORE) +private const val USER_DATASTORE = "user_datastore" +private val Context.userDataStore: DataStore by preferencesDataStore(name = USER_DATASTORE) + @InstallIn(SingletonComponent::class) @Module internal object DataStoreModule { @@ -30,4 +33,9 @@ internal object DataStoreModule { @Singleton @Provides fun provideTokenDataStore(@ApplicationContext context: Context): DataStore = context.tokenDataStore + + @UserDataStore + @Singleton + @Provides + fun provideUserDataStore(@ApplicationContext context: Context): DataStore = context.userDataStore } diff --git a/core/data/src/main/java/com/neki/android/core/data/local/di/DataStoreQualifier.kt b/core/data/src/main/java/com/neki/android/core/data/local/di/DataStoreQualifier.kt index 87863741f..979e811c6 100644 --- a/core/data/src/main/java/com/neki/android/core/data/local/di/DataStoreQualifier.kt +++ b/core/data/src/main/java/com/neki/android/core/data/local/di/DataStoreQualifier.kt @@ -9,3 +9,7 @@ annotation class AuthDataStore @Qualifier @Retention(AnnotationRetention.BINARY) annotation class TokenDataStore + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class UserDataStore diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/api/PoseService.kt b/core/data/src/main/java/com/neki/android/core/data/remote/api/PoseService.kt index 5ecc93fe7..98fe8f121 100644 --- a/core/data/src/main/java/com/neki/android/core/data/remote/api/PoseService.kt +++ b/core/data/src/main/java/com/neki/android/core/data/remote/api/PoseService.kt @@ -37,9 +37,10 @@ class PoseService @Inject constructor( } // 랜덤 포즈 조회 - suspend fun getRandomPose(headCount: String): BasicResponse { + suspend fun getRandomPose(headCount: String, excludeIds: String): BasicResponse { return client.get("/api/poses/random") { parameter("headCount", headCount) + parameter("excludeIds", excludeIds) }.body() } diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt index 53084699f..49c499375 100644 --- a/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt @@ -3,15 +3,17 @@ package com.neki.android.core.data.repository.impl import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData -import com.neki.android.core.common.exception.RandomPoseRetryExhaustedException +import com.neki.android.core.common.exception.ClientApiException import com.neki.android.core.data.paging.PosePagingSource import com.neki.android.core.data.paging.ScrapPosePagingSource import com.neki.android.core.data.remote.api.PoseService import com.neki.android.core.data.util.runSuspendCatching +import com.neki.android.core.dataapi.repository.NO_MORE_RANDOM_POSE import com.neki.android.core.dataapi.repository.PoseRepository import com.neki.android.core.model.PeopleCount import com.neki.android.core.model.Pose import com.neki.android.core.model.SortOrder +import io.ktor.client.plugins.ClientRequestException import kotlinx.coroutines.flow.Flow import javax.inject.Inject @@ -69,38 +71,47 @@ class PoseRepositoryImpl @Inject constructor( override suspend fun getSingleRandomPose( headCount: PeopleCount, excludeIds: Set, - maxRetry: Int, ): Result = runSuspendCatching { - repeat(maxRetry) { - val pose = poseService.getRandomPose(headCount = headCount.name).data.toModel() - if (pose.id !in excludeIds) { - return@runSuspendCatching pose - } + val excludeIdsString = excludeIds.joinToString(",") + + return@runSuspendCatching try { + poseService.getRandomPose( + headCount = headCount.name, + excludeIds = excludeIdsString, + ).data.toModel() + } catch (e: ClientRequestException) { + if (e.response.status.value == NO_MORE_RANDOM_POSE) + throw ClientApiException(NO_MORE_RANDOM_POSE, e.message) + else throw e } - throw RandomPoseRetryExhaustedException("새로운 포즈를 찾지 못했어요") } override suspend fun getMultipleRandomPose( headCount: PeopleCount, excludeIds: Set, poseSize: Int, - maxRetry: Int, ): Result> = runSuspendCatching { val result = mutableListOf() val collectedIds = excludeIds.toMutableSet() - var retryCount = 0 + repeat(poseSize) { + val excludeIdsString = collectedIds.joinToString(",") + val pose = try { + poseService.getRandomPose( + headCount = headCount.name, + excludeIds = excludeIdsString, + ).data.toModel() + } catch (e: ClientRequestException) { + // Http Error Code 이지만, 클라이언트에서 성공으로 취급 + if (e.response.status.value == NO_MORE_RANDOM_POSE) return@runSuspendCatching result + else throw e + } - while (result.size < poseSize && retryCount < maxRetry) { - val pose = poseService.getRandomPose(headCount = headCount.name).data.toModel() if (pose.id !in collectedIds) { result.add(pose) collectedIds.add(pose.id) - } else { - retryCount++ } } - - result.ifEmpty { throw RandomPoseRetryExhaustedException("새로운 포즈를 찾지 못했어요") } + return@runSuspendCatching result } override suspend fun updateScrap(poseId: Long, scrap: Boolean): Result = runSuspendCatching { diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/UserRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/UserRepositoryImpl.kt index c708f61e3..b5de53a80 100644 --- a/core/data/src/main/java/com/neki/android/core/data/repository/impl/UserRepositoryImpl.kt +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/UserRepositoryImpl.kt @@ -1,16 +1,35 @@ package com.neki.android.core.data.repository.impl +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import com.neki.android.core.data.local.di.UserDataStore import com.neki.android.core.data.remote.api.UserService import com.neki.android.core.data.remote.model.request.UpdateProfileImageRequest import com.neki.android.core.data.remote.model.request.UpdateUserInfoRequest import com.neki.android.core.data.util.runSuspendCatching import com.neki.android.core.dataapi.repository.UserRepository import com.neki.android.core.model.UserInfo +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import javax.inject.Inject class UserRepositoryImpl @Inject constructor( + @UserDataStore private val dataStore: DataStore, private val userService: UserService, ) : UserRepository { + override val hasVisitedRandomPose: Flow = + dataStore.data.map { preferences -> + preferences[HAS_VISITED_RANDOM_POSE] ?: false + } + + override suspend fun setRandomPoseVisited() { + dataStore.edit { preferences -> + preferences[HAS_VISITED_RANDOM_POSE] = true + } + } + override suspend fun getUserInfo(): Result = runSuspendCatching { userService.getUserInfo().data.toModel() } @@ -22,4 +41,8 @@ class UserRepositoryImpl @Inject constructor( override suspend fun updateProfileImage(mediaId: Long?): Result = runSuspendCatching { userService.updateProfileImage(UpdateProfileImageRequest(mediaId)) } + + companion object { + private val HAS_VISITED_RANDOM_POSE = booleanPreferencesKey("is_first_visit_random_pose") + } } diff --git a/detekt-config.yml b/detekt-config.yml index f034288ca..d16a96a1c 100644 --- a/detekt-config.yml +++ b/detekt-config.yml @@ -52,6 +52,8 @@ formatting: exceptions: TooGenericExceptionCaught: active: false + SwallowedException: + active: false style: FunctionOnlyReturningConstant: diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/navigation/PoseEntryProvider.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/navigation/PoseEntryProvider.kt index 7c338421f..a77564d3d 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/navigation/PoseEntryProvider.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/navigation/PoseEntryProvider.kt @@ -16,6 +16,7 @@ import com.neki.android.feature.pose.impl.detail.PoseDetailViewModel import com.neki.android.feature.pose.impl.main.PoseIntent import com.neki.android.feature.pose.impl.main.PoseRoute import com.neki.android.feature.pose.impl.main.PoseViewModel +import com.neki.android.feature.pose.impl.random.RandomPoseIntent import com.neki.android.feature.pose.impl.random.RandomPoseRoute import com.neki.android.feature.pose.impl.random.RandomPoseViewModel import dagger.Module @@ -68,12 +69,23 @@ private fun EntryProviderScope.poseEntry(navigator: Navigator) { } entry { key -> + val resultBus = LocalResultEventBus.current + val viewModel = hiltViewModel( + creationCallback = { factory -> + factory.create(key.peopleCount) + }, + ) + + ResultEffect(resultBus) { result -> + when (result) { + is PoseResult.ScrapChanged -> { + viewModel.store.onIntent(RandomPoseIntent.ScrapChanged(result.poseId, result.isScrapped)) + } + } + } + RandomPoseRoute( - viewModel = hiltViewModel( - creationCallback = { factory -> - factory.create(key.peopleCount) - }, - ), + viewModel = viewModel, navigateBack = navigator::goBack, navigateToPoseDetail = navigator::navigateToPoseDetail, ) diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseContract.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseContract.kt index 27e34861b..3c61f360c 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseContract.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseContract.kt @@ -10,6 +10,7 @@ data class RandomPoseUiState( val currentIndex: Int = 0, val poseList: ImmutableList = persistentListOf(), val committedScraps: Map = emptyMap(), + val hasNewPose: Boolean = true, ) { val currentPose: Pose? get() = poseList.getOrNull(currentIndex) @@ -33,6 +34,7 @@ sealed interface RandomPoseIntent { data object ClickScrapIcon : RandomPoseIntent data object ClickLeftSwipe : RandomPoseIntent data object ClickRightSwipe : RandomPoseIntent + data class ScrapChanged(val poseId: Long, val isScrapped: Boolean) : RandomPoseIntent } sealed interface RandomPoseEffect { diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt index 24d50dbd1..71a6aeb16 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt @@ -3,8 +3,10 @@ package com.neki.android.feature.pose.impl.random import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.neki.android.core.common.coroutine.di.ApplicationScope -import com.neki.android.core.common.exception.RandomPoseRetryExhaustedException +import com.neki.android.core.common.exception.ClientApiException +import com.neki.android.core.dataapi.repository.NO_MORE_RANDOM_POSE import com.neki.android.core.dataapi.repository.PoseRepository +import com.neki.android.core.dataapi.repository.UserRepository import com.neki.android.core.model.PeopleCount import com.neki.android.core.ui.MviIntentStore import com.neki.android.core.ui.mviIntentStore @@ -17,6 +19,7 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import timber.log.Timber @@ -24,6 +27,7 @@ import timber.log.Timber internal class RandomPoseViewModel @AssistedInject constructor( @Assisted private val peopleCount: PeopleCount, private val poseRepository: PoseRepository, + private val userRepository: UserRepository, @ApplicationScope private val applicationScope: CoroutineScope, ) : ViewModel() { @@ -48,7 +52,7 @@ internal class RandomPoseViewModel @AssistedInject constructor( postSideEffect: (RandomPoseEffect) -> Unit, ) { when (intent) { - RandomPoseIntent.EnterRandomPoseScreen -> fetchInitialPoses(reduce, postSideEffect) + RandomPoseIntent.EnterRandomPoseScreen -> fetchInitialData(state, reduce, postSideEffect) // 튜토리얼 RandomPoseIntent.ClickLeftSwipe -> handleMovePrevious(state, reduce, postSideEffect) @@ -63,60 +67,61 @@ internal class RandomPoseViewModel @AssistedInject constructor( } } - RandomPoseIntent.ClickScrapIcon -> handleScrapToggle(state, reduce) + RandomPoseIntent.ClickScrapIcon -> { + val currentPost = state.currentPose ?: return + handleScrapToggle(currentPost.id, !currentPost.isScrapped, reduce) + } + + is RandomPoseIntent.ScrapChanged -> handleScrapToggle(intent.poseId, intent.isScrapped, reduce) } } private fun handleScrapToggle( - state: RandomPoseUiState, + poseId: Long, + newScrapStatus: Boolean, reduce: (RandomPoseUiState.() -> RandomPoseUiState) -> Unit, ) { - state.currentPose?.let { currentPose -> - val poseId = currentPose.id - val newScrapStatus = !currentPose.isScrapped - - // UI 즉시 업데이트 - reduce { - copy( - poseList = poseList.map { pose -> - if (pose.id == poseId) { - pose.copy(isScrapped = newScrapStatus) - } else { - pose - } - }.toImmutableList(), - ) - } + // UI 즉시 업데이트 + reduce { + copy( + poseList = poseList.map { pose -> + if (pose.id == poseId) { + pose.copy(isScrapped = newScrapStatus) + } else { + pose + } + }.toImmutableList(), + ) + } - // 해당 포즈의 이전 Job 취소 후 새로운 Job 시작 - scrapJobs[poseId]?.cancel() - scrapJobs[poseId] = viewModelScope.launch { - delay(500) - val committedScrap = store.uiState.value.committedScraps[poseId] - if (committedScrap == newScrapStatus || committedScrap == null) return@launch - - poseRepository.updateScrap(poseId, newScrapStatus) - .onSuccess { - Timber.d("updateScrap success for poseId: $poseId") - reduce { - copy(committedScraps = committedScraps + (poseId to newScrapStatus)) - } + // 해당 포즈의 이전 Job 취소 후 새로운 Job 시작 + scrapJobs[poseId]?.cancel() + scrapJobs[poseId] = viewModelScope.launch { + delay(500) + val committedScrap = store.uiState.value.committedScraps[poseId] + if (committedScrap == newScrapStatus || committedScrap == null) return@launch + + poseRepository.updateScrap(poseId, newScrapStatus) + .onSuccess { + Timber.d("updateScrap success for poseId: $poseId") + reduce { + copy(committedScraps = committedScraps + (poseId to newScrapStatus)) } - .onFailure { error -> - Timber.e(error, "updateScrap failed for poseId: $poseId") - reduce { - copy( - poseList = poseList.map { pose -> - if (pose.id == poseId) { - pose.copy(isScrapped = committedScrap) - } else { - pose - } - }.toImmutableList(), - ) - } + } + .onFailure { error -> + Timber.e(error, "updateScrap failed for poseId: $poseId") + reduce { + copy( + poseList = poseList.map { pose -> + if (pose.id == poseId) { + pose.copy(isScrapped = committedScrap) + } else { + pose + } + }.toImmutableList(), + ) } - } + } } } @@ -150,27 +155,56 @@ internal class RandomPoseViewModel @AssistedInject constructor( postSideEffect(RandomPoseEffect.SwipePoseImage(nextIndex)) // 여분 포즈가 POSE_PREFETCH_THRESHOLD 이하이면 다음 포즈 미리 캐싱 - if (state.poseList.lastIndex - nextIndex < PoseConst.POSE_PREFETCH_THRESHOLD) { + if (state.poseList.lastIndex - nextIndex < PoseConst.POSE_PREFETCH_THRESHOLD && state.hasNewPose) { viewModelScope.launch { poseRepository.getSingleRandomPose( headCount = peopleCount, excludeIds = state.randomPoseIds, - maxRetry = PoseConst.MAXIMUM_RANDOM_POSE_RETRY_COUNT, ).onSuccess { pose -> - reduce { copy(poseList = (poseList + pose).toImmutableList()) } + reduce { + copy( + poseList = (poseList + pose).toImmutableList(), + committedScraps = committedScraps + (pose.id to pose.isScrapped), + ) + } }.onFailure { error -> - if (error is RandomPoseRetryExhaustedException) - Timber.e(error, "중복 포즈") - else Timber.e(error) + if (error is ClientApiException && error.code == NO_MORE_RANDOM_POSE) { + reduce { copy(hasNewPose = false) } + postSideEffect(RandomPoseEffect.ShowToast("모든 포즈를 불러왔어요")) + } else Timber.e(error) } } } } + private fun fetchInitialData( + state: RandomPoseUiState, + reduce: (RandomPoseUiState.() -> RandomPoseUiState) -> Unit, + postSideEffect: (RandomPoseEffect) -> Unit, + ) { + checkFirstVisit(reduce) + fetchInitialPoses(state, reduce, postSideEffect) + } + + private fun checkFirstVisit( + reduce: (RandomPoseUiState.() -> RandomPoseUiState) -> Unit, + ) { + viewModelScope.launch { + if (!userRepository.hasVisitedRandomPose.first()) { + userRepository.setRandomPoseVisited() + } else { + reduce { copy(isShowTutorial = false) } + } + } + } + private fun fetchInitialPoses( + state: RandomPoseUiState, reduce: (RandomPoseUiState.() -> RandomPoseUiState) -> Unit, postSideEffect: (RandomPoseEffect) -> Unit, ) { + if (state.poseList.isNotEmpty()) return + viewModelScope.launch { reduce { copy(isLoading = true) } @@ -179,7 +213,6 @@ internal class RandomPoseViewModel @AssistedInject constructor( headCount = peopleCount, excludeIds = emptySet(), poseSize = PoseConst.INITIAL_POSE_LOAD_COUNT, - maxRetry = PoseConst.MAXIMUM_RANDOM_POSE_RETRY_COUNT, ).onSuccess { data -> reduce { copy( @@ -190,11 +223,9 @@ internal class RandomPoseViewModel @AssistedInject constructor( ) } }.onFailure { error -> - Timber.e(error) reduce { copy(isLoading = false) } - if (error is RandomPoseRetryExhaustedException) - Timber.e(error, "중복 포즈") - else Timber.e(error) + postSideEffect(RandomPoseEffect.ShowToast("포즈를 불러오는데 실패했어요")) + Timber.e(error) } } }