diff --git a/app/src/main/java/com/threegap/bitnagil/di/data/CoroutineModule.kt b/app/src/main/java/com/threegap/bitnagil/di/data/CoroutineModule.kt new file mode 100644 index 00000000..3da3a9e5 --- /dev/null +++ b/app/src/main/java/com/threegap/bitnagil/di/data/CoroutineModule.kt @@ -0,0 +1,18 @@ +package com.threegap.bitnagil.di.data + +import com.threegap.bitnagil.data.di.IoDispatcher +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +@Module +@InstallIn(SingletonComponent::class) +object CoroutineModule { + + @Provides + @IoDispatcher + fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO +} diff --git a/app/src/main/java/com/threegap/bitnagil/di/data/DataSourceModule.kt b/app/src/main/java/com/threegap/bitnagil/di/data/DataSourceModule.kt index 61535f14..48cc6f37 100644 --- a/app/src/main/java/com/threegap/bitnagil/di/data/DataSourceModule.kt +++ b/app/src/main/java/com/threegap/bitnagil/di/data/DataSourceModule.kt @@ -20,7 +20,9 @@ import com.threegap.bitnagil.data.recommendroutine.datasource.RecommendRoutineDa import com.threegap.bitnagil.data.recommendroutine.datasourceImpl.RecommendRoutineDataSourceImpl import com.threegap.bitnagil.data.report.datasource.ReportDataSource import com.threegap.bitnagil.data.report.datasourceImpl.ReportDataSourceImpl +import com.threegap.bitnagil.data.routine.datasource.RoutineLocalDataSource import com.threegap.bitnagil.data.routine.datasource.RoutineRemoteDataSource +import com.threegap.bitnagil.data.routine.datasourceImpl.RoutineLocalDataSourceImpl import com.threegap.bitnagil.data.routine.datasourceImpl.RoutineRemoteDataSourceImpl import com.threegap.bitnagil.data.user.datasource.UserLocalDataSource import com.threegap.bitnagil.data.user.datasource.UserRemoteDataSource @@ -52,7 +54,11 @@ abstract class DataSourceModule { @Binds @Singleton - abstract fun bindRoutineDataSource(routineDataSourceImpl: RoutineRemoteDataSourceImpl): RoutineRemoteDataSource + abstract fun bindRoutineRemoteDataSource(routineDataSourceImpl: RoutineRemoteDataSourceImpl): RoutineRemoteDataSource + + @Binds + @Singleton + abstract fun bindRoutineLocalDataSource(impl: RoutineLocalDataSourceImpl): RoutineLocalDataSource @Binds @Singleton diff --git a/app/src/main/java/com/threegap/bitnagil/navigation/home/HomeBottomNavigationBar.kt b/app/src/main/java/com/threegap/bitnagil/navigation/home/HomeBottomNavigationBar.kt index b7ace86d..965310e1 100644 --- a/app/src/main/java/com/threegap/bitnagil/navigation/home/HomeBottomNavigationBar.kt +++ b/app/src/main/java/com/threegap/bitnagil/navigation/home/HomeBottomNavigationBar.kt @@ -20,19 +20,16 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import androidx.navigation.compose.currentBackStackEntryAsState import com.threegap.bitnagil.designsystem.BitnagilTheme import com.threegap.bitnagil.designsystem.component.atom.BitnagilIcon @Composable fun HomeBottomNavigationBar( - navController: NavController, + selectedTab: HomeRoute, + onTabSelected: (HomeRoute) -> Unit, + modifier: Modifier = Modifier, ) { - val navBackStackEntry by navController.currentBackStackEntryAsState() - val currentRoute = navBackStackEntry?.destination?.route - - Column { + Column(modifier = modifier) { HorizontalDivider( modifier = Modifier.fillMaxWidth(), thickness = 1.dp, @@ -47,19 +44,13 @@ fun HomeBottomNavigationBar( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, ) { - HomeRoute.entries.map { homeRoute -> + homeTabList.forEach { homeTab -> HomeBottomNavigationItem( modifier = Modifier.weight(1f), - icon = homeRoute.icon, - title = homeRoute.title, - onClick = { - if (currentRoute != homeRoute.route) { - navController.navigate(homeRoute.route) { - popUpTo(0) { inclusive = true } - } - } - }, - selected = currentRoute == homeRoute.route, + icon = homeTab.icon, + title = homeTab.title, + selected = selectedTab == homeTab.route, + onClick = { onTabSelected(homeTab.route) }, ) } } @@ -71,8 +62,8 @@ private fun HomeBottomNavigationItem( modifier: Modifier = Modifier, icon: Int, title: String, - onClick: () -> Unit, selected: Boolean, + onClick: () -> Unit, ) { val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() @@ -111,9 +102,8 @@ private fun HomeBottomNavigationItem( @Composable @Preview private fun HomeBottomNavigationBarPreview() { - val navigator = rememberHomeNavigator() - HomeBottomNavigationBar( - navController = navigator.navController, + selectedTab = HomeRoute.Home, + onTabSelected = {}, ) } diff --git a/app/src/main/java/com/threegap/bitnagil/navigation/home/HomeNavHost.kt b/app/src/main/java/com/threegap/bitnagil/navigation/home/HomeNavHost.kt index 067e207d..f90cde81 100644 --- a/app/src/main/java/com/threegap/bitnagil/navigation/home/HomeNavHost.kt +++ b/app/src/main/java/com/threegap/bitnagil/navigation/home/HomeNavHost.kt @@ -60,11 +60,16 @@ fun HomeNavHost( activity?.setStatusBarContentColor(isLightContent = isHomeTab) } + val selectedBottomTab = navigator.currentHomeRoute ?: navigator.startDestination + Box(modifier = modifier.fillMaxSize()) { Scaffold( modifier = Modifier.fillMaxSize(), bottomBar = { - HomeBottomNavigationBar(navController = navigator.navController) + HomeBottomNavigationBar( + selectedTab = selectedBottomTab, + onTabSelected = navigator::navigateTo, + ) }, contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp), content = { innerPadding -> @@ -73,7 +78,7 @@ fun HomeNavHost( startDestination = navigator.startDestination, modifier = modifier.padding(innerPadding), ) { - composable(HomeRoute.Home.route) { + composable { HomeScreenContainer( navigateToGuide = navigateToGuide, navigateToRegisterRoutine = { @@ -86,14 +91,14 @@ fun HomeNavHost( ) } - composable(HomeRoute.RecommendRoutine.route) { + composable { RecommendRoutineScreenContainer( navigateToEmotion = navigateToEmotion, navigateToRegisterRoutine = navigateToRegisterRoutine, ) } - composable(HomeRoute.MyPage.route) { + composable { MyPageScreenContainer( navigateToSetting = navigateToSetting, navigateToOnBoarding = navigateToOnBoarding, diff --git a/app/src/main/java/com/threegap/bitnagil/navigation/home/HomeNavigator.kt b/app/src/main/java/com/threegap/bitnagil/navigation/home/HomeNavigator.kt index 541b8487..e9b62cbb 100644 --- a/app/src/main/java/com/threegap/bitnagil/navigation/home/HomeNavigator.kt +++ b/app/src/main/java/com/threegap/bitnagil/navigation/home/HomeNavigator.kt @@ -2,6 +2,7 @@ package com.threegap.bitnagil.navigation.home import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController @@ -9,17 +10,31 @@ import androidx.navigation.compose.rememberNavController class HomeNavigator( val navController: NavHostController, ) { - val startDestination = HomeRoute.Home.route + val startDestination: HomeRoute = HomeRoute.Home - val currentRoute: String? - @Composable get() = navController.currentBackStackEntryAsState().value?.destination?.route + val currentHomeRoute: HomeRoute? + @Composable get() { + val destination = navController.currentBackStackEntryAsState().value?.destination + return when { + destination?.hasRoute(HomeRoute.Home::class) == true -> HomeRoute.Home + destination?.hasRoute(HomeRoute.RecommendRoutine::class) == true -> HomeRoute.RecommendRoutine + destination?.hasRoute(HomeRoute.MyPage::class) == true -> HomeRoute.MyPage + else -> null + } + } val isHomeRoute: Boolean - @Composable get() = currentRoute == HomeRoute.Home.route + @Composable get() = currentHomeRoute == HomeRoute.Home @Composable fun shouldShowFloatingAction(): Boolean = - currentRoute in setOf(HomeRoute.Home.route, HomeRoute.RecommendRoutine.route) + currentHomeRoute == HomeRoute.Home || currentHomeRoute == HomeRoute.RecommendRoutine + + fun navigateTo(route: HomeRoute) { + navController.navigate(route) { + popUpTo(0) { inclusive = true } + } + } } @Composable diff --git a/app/src/main/java/com/threegap/bitnagil/navigation/home/HomeRoute.kt b/app/src/main/java/com/threegap/bitnagil/navigation/home/HomeRoute.kt index 405615c6..7c268581 100644 --- a/app/src/main/java/com/threegap/bitnagil/navigation/home/HomeRoute.kt +++ b/app/src/main/java/com/threegap/bitnagil/navigation/home/HomeRoute.kt @@ -1,28 +1,28 @@ package com.threegap.bitnagil.navigation.home import com.threegap.bitnagil.R +import kotlinx.serialization.Serializable -enum class HomeRoute( - val route: String, - val title: String, - val icon: Int, -) { - Home( - route = "home/home", - title = "홈", - icon = R.drawable.ic_home, - ), +@Serializable +sealed interface HomeRoute { + @Serializable + data object Home : HomeRoute - RecommendRoutine( - route = "home/recommend_routine", - title = "추천 루틴", - icon = R.drawable.ic_routine_recommend, - ), + @Serializable + data object RecommendRoutine : HomeRoute - MyPage( - route = "home/my_page", - title = "마이페이지", - icon = R.drawable.ic_profile, - ), - ; + @Serializable + data object MyPage : HomeRoute } + +data class HomeTab( + val route: HomeRoute, + val title: String, + val icon: Int, +) + +val homeTabList = listOf( + HomeTab(HomeRoute.Home, "홈", R.drawable.ic_home), + HomeTab(HomeRoute.RecommendRoutine, "추천 루틴", R.drawable.ic_routine_recommend), + HomeTab(HomeRoute.MyPage, "마이페이지", R.drawable.ic_profile), +) diff --git a/core/designsystem/src/main/java/com/threegap/bitnagil/designsystem/component/block/BitnagilConfirmDialog.kt b/core/designsystem/src/main/java/com/threegap/bitnagil/designsystem/component/block/BitnagilConfirmDialog.kt new file mode 100644 index 00000000..25657d02 --- /dev/null +++ b/core/designsystem/src/main/java/com/threegap/bitnagil/designsystem/component/block/BitnagilConfirmDialog.kt @@ -0,0 +1,85 @@ +package com.threegap.bitnagil.designsystem.component.block + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import com.threegap.bitnagil.designsystem.BitnagilTheme +import com.threegap.bitnagil.designsystem.modifier.clickableWithoutRipple + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BitnagilConfirmDialog( + title: String, + description: String, + confirmButtonText: String, + onConfirm: () -> Unit, + modifier: Modifier = Modifier, + dismissOnBackPress: Boolean = true, + dismissOnClickOutside: Boolean = true, +) { + BasicAlertDialog( + onDismissRequest = onConfirm, + modifier = modifier, + properties = DialogProperties( + dismissOnBackPress = dismissOnBackPress, + dismissOnClickOutside = dismissOnClickOutside, + ), + ) { + Column( + modifier = Modifier + .background( + color = BitnagilTheme.colors.white, + shape = RoundedCornerShape(12.dp), + ) + .padding(vertical = 20.dp, horizontal = 24.dp), + ) { + Text( + text = title, + color = BitnagilTheme.colors.coolGray10, + style = BitnagilTheme.typography.subtitle1SemiBold, + modifier = Modifier.padding(bottom = 6.dp), + ) + + Text( + text = description, + color = BitnagilTheme.colors.coolGray40, + style = BitnagilTheme.typography.body2Medium, + modifier = Modifier.padding(bottom = 18.dp), + ) + + Text( + text = confirmButtonText, + color = BitnagilTheme.colors.orange500, + style = BitnagilTheme.typography.body2Medium, + textAlign = TextAlign.End, + modifier = Modifier + .fillMaxWidth() + .clickableWithoutRipple { onConfirm() }, + ) + } + } +} + +@Preview +@Composable +private fun Preview() { + BitnagilConfirmDialog( + title = "루틴 완료를 저장하지 못했어요", + description = "네트워크 연결에 문제가 있었던 것 같아요.\n다시 한 번 시도해 주세요.", + confirmButtonText = "확인", + onConfirm = {}, + ) +} diff --git a/data/src/main/java/com/threegap/bitnagil/data/di/CoroutineQualifier.kt b/data/src/main/java/com/threegap/bitnagil/data/di/CoroutineQualifier.kt new file mode 100644 index 00000000..d71344c1 --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/di/CoroutineQualifier.kt @@ -0,0 +1,7 @@ +package com.threegap.bitnagil.data.di + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class IoDispatcher diff --git a/data/src/main/java/com/threegap/bitnagil/data/onboarding/repositoryImpl/OnBoardingRepositoryImpl.kt b/data/src/main/java/com/threegap/bitnagil/data/onboarding/repositoryImpl/OnBoardingRepositoryImpl.kt index 83fafa21..353dcc4b 100644 --- a/data/src/main/java/com/threegap/bitnagil/data/onboarding/repositoryImpl/OnBoardingRepositoryImpl.kt +++ b/data/src/main/java/com/threegap/bitnagil/data/onboarding/repositoryImpl/OnBoardingRepositoryImpl.kt @@ -4,19 +4,16 @@ import com.threegap.bitnagil.data.onboarding.datasource.OnBoardingDataSource import com.threegap.bitnagil.data.onboarding.model.dto.OnBoardingDto import com.threegap.bitnagil.data.onboarding.model.request.GetOnBoardingRecommendRoutinesRequest import com.threegap.bitnagil.data.onboarding.model.request.RegisterOnBoardingRecommendRoutinesRequest +import com.threegap.bitnagil.data.routine.datasource.RoutineLocalDataSource import com.threegap.bitnagil.domain.onboarding.model.OnBoarding import com.threegap.bitnagil.domain.onboarding.model.OnBoardingAbstract import com.threegap.bitnagil.domain.onboarding.model.OnBoardingRecommendRoutine -import com.threegap.bitnagil.domain.onboarding.model.OnBoardingRecommendRoutineEvent import com.threegap.bitnagil.domain.onboarding.repository.OnBoardingRepository -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow import javax.inject.Inject class OnBoardingRepositoryImpl @Inject constructor( private val onBoardingDataSource: OnBoardingDataSource, + private val routineLocalDataSource: RoutineLocalDataSource, ) : OnBoardingRepository { override suspend fun getOnBoardingList(): List { val onBoardingDtos = onBoardingDataSource.getOnBoardingList() @@ -59,7 +56,7 @@ class OnBoardingRepositoryImpl @Inject constructor( return onBoardingDataSource.registerRecommendRoutineList(selectedRecommendRoutineIds = request.recommendedRoutineIds).also { if (it.isSuccess) { - _onBoardingRecommendRoutineEventFlow.emit(OnBoardingRecommendRoutineEvent.AddRoutines(selectedRecommendRoutineIds)) + routineLocalDataSource.clearCache() } } } @@ -73,10 +70,4 @@ class OnBoardingRepositoryImpl @Inject constructor( ) } } - - private val _onBoardingRecommendRoutineEventFlow = MutableSharedFlow( - extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - override suspend fun getOnBoardingRecommendRoutineEventFlow(): Flow = _onBoardingRecommendRoutineEventFlow.asSharedFlow() } diff --git a/data/src/main/java/com/threegap/bitnagil/data/routine/datasource/RoutineLocalDataSource.kt b/data/src/main/java/com/threegap/bitnagil/data/routine/datasource/RoutineLocalDataSource.kt new file mode 100644 index 00000000..bf18a3fe --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/routine/datasource/RoutineLocalDataSource.kt @@ -0,0 +1,14 @@ +package com.threegap.bitnagil.data.routine.datasource + +import com.threegap.bitnagil.domain.routine.model.RoutineCompletionInfo +import com.threegap.bitnagil.domain.routine.model.RoutineSchedule +import kotlinx.coroutines.flow.StateFlow + +interface RoutineLocalDataSource { + val routineSchedule: StateFlow + val lastFetchRange: Pair? + fun saveSchedule(schedule: RoutineSchedule, startDate: String, endDate: String) + fun getCompletionInfo(dateKey: String, routineId: String): RoutineCompletionInfo? + fun applyOptimisticToggle(dateKey: String, routineId: String, completionInfo: RoutineCompletionInfo) + fun clearCache() +} diff --git a/data/src/main/java/com/threegap/bitnagil/data/routine/datasourceImpl/RoutineLocalDataSourceImpl.kt b/data/src/main/java/com/threegap/bitnagil/data/routine/datasourceImpl/RoutineLocalDataSourceImpl.kt new file mode 100644 index 00000000..8bc15fd1 --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/routine/datasourceImpl/RoutineLocalDataSourceImpl.kt @@ -0,0 +1,57 @@ +package com.threegap.bitnagil.data.routine.datasourceImpl + +import com.threegap.bitnagil.data.routine.datasource.RoutineLocalDataSource +import com.threegap.bitnagil.domain.routine.model.RoutineCompletionInfo +import com.threegap.bitnagil.domain.routine.model.RoutineSchedule +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +class RoutineLocalDataSourceImpl @Inject constructor() : RoutineLocalDataSource { + private val _routineSchedule = MutableStateFlow(null) + override val routineSchedule: StateFlow = _routineSchedule.asStateFlow() + + override var lastFetchRange: Pair? = null + private set + + override fun saveSchedule(schedule: RoutineSchedule, startDate: String, endDate: String) { + lastFetchRange = startDate to endDate + _routineSchedule.update { schedule } + } + + override fun getCompletionInfo(dateKey: String, routineId: String): RoutineCompletionInfo? { + val routine = _routineSchedule.value?.dailyRoutines?.get(dateKey)?.routines?.find { it.id == routineId } + return routine?.let { RoutineCompletionInfo(routineId, it.isCompleted, it.subRoutineCompletionStates) } + } + + override fun applyOptimisticToggle(dateKey: String, routineId: String, completionInfo: RoutineCompletionInfo) { + _routineSchedule.update { current -> + current ?: return@update current + val dailyRoutines = current.dailyRoutines[dateKey] ?: return@update current + val updatedRoutines = dailyRoutines.routines.map { routine -> + if (routine.id == routineId) { + routine.copy( + isCompleted = completionInfo.routineCompleteYn, + subRoutineCompletionStates = completionInfo.subRoutineCompleteYn, + ) + } else { + routine + } + } + val updatedDailyRoutines = dailyRoutines.copy( + routines = updatedRoutines, + isAllCompleted = updatedRoutines.all { it.isCompleted }, + ) + + current.copy( + dailyRoutines = current.dailyRoutines + (dateKey to updatedDailyRoutines), + ) + } + } + + override fun clearCache() { + _routineSchedule.update { null } + } +} diff --git a/data/src/main/java/com/threegap/bitnagil/data/routine/repositoryImpl/RoutineRepositoryImpl.kt b/data/src/main/java/com/threegap/bitnagil/data/routine/repositoryImpl/RoutineRepositoryImpl.kt index ec7a6e9d..0bafc376 100644 --- a/data/src/main/java/com/threegap/bitnagil/data/routine/repositoryImpl/RoutineRepositoryImpl.kt +++ b/data/src/main/java/com/threegap/bitnagil/data/routine/repositoryImpl/RoutineRepositoryImpl.kt @@ -1,57 +1,174 @@ package com.threegap.bitnagil.data.routine.repositoryImpl +import android.util.Log +import com.threegap.bitnagil.data.di.IoDispatcher +import com.threegap.bitnagil.data.routine.datasource.RoutineLocalDataSource import com.threegap.bitnagil.data.routine.datasource.RoutineRemoteDataSource import com.threegap.bitnagil.data.routine.model.request.toDto import com.threegap.bitnagil.data.routine.model.response.toDomain import com.threegap.bitnagil.domain.routine.model.Routine +import com.threegap.bitnagil.domain.routine.model.RoutineCompletionInfo import com.threegap.bitnagil.domain.routine.model.RoutineCompletionInfos import com.threegap.bitnagil.domain.routine.model.RoutineEditInfo import com.threegap.bitnagil.domain.routine.model.RoutineRegisterInfo import com.threegap.bitnagil.domain.routine.model.RoutineSchedule -import com.threegap.bitnagil.domain.routine.model.WriteRoutineEvent import com.threegap.bitnagil.domain.routine.repository.RoutineRepository +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import javax.inject.Inject +import javax.inject.Singleton +@Singleton class RoutineRepositoryImpl @Inject constructor( private val routineRemoteDataSource: RoutineRemoteDataSource, + private val routineLocalDataSource: RoutineLocalDataSource, + @param:IoDispatcher private val dispatcher: CoroutineDispatcher, ) : RoutineRepository { - override suspend fun fetchWeeklyRoutines(startDate: String, endDate: String): Result = + + private val repositoryScope = CoroutineScope(SupervisorJob() + dispatcher) + private val mutex = Mutex() + private val pendingChangesByDate = mutableMapOf>() + private val originalStatesByDate = mutableMapOf>() + private val _syncError = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + override val syncError: SharedFlow = _syncError.asSharedFlow() + private val syncTrigger = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + init { + @OptIn(FlowPreview::class) + repositoryScope.launch { + syncTrigger + .debounce(500L) + .collect { flushAllPendingChanges() } + } + } + + override fun observeWeeklyRoutines(startDate: String, endDate: String): Flow = flow { + if (routineLocalDataSource.lastFetchRange != (startDate to endDate)) { + routineLocalDataSource.clearCache() + fetchAndSave(startDate, endDate).getOrThrow() + } + emitAll( + routineLocalDataSource.routineSchedule + .onEach { if (it == null) fetchAndSave(startDate, endDate).getOrThrow() } + .filterNotNull(), + ) + } + + private suspend fun fetchAndSave(startDate: String, endDate: String): Result = routineRemoteDataSource.fetchWeeklyRoutines(startDate, endDate) - .map { it.toDomain() } + .onSuccess { routineLocalDataSource.saveSchedule(it.toDomain(), startDate, endDate) } + .map { } - override suspend fun syncRoutineCompletion(routineCompletionInfos: RoutineCompletionInfos): Result = - routineRemoteDataSource.syncRoutineCompletion(routineCompletionInfos.toDto()) + override suspend fun applyRoutineToggle(dateKey: String, routineId: String, completionInfo: RoutineCompletionInfo) { + mutex.withLock { + if (originalStatesByDate[dateKey]?.containsKey(routineId) != true) { + routineLocalDataSource.getCompletionInfo(dateKey, routineId)?.let { + originalStatesByDate.getOrPut(dateKey) { mutableMapOf() }[routineId] = it + } + } + pendingChangesByDate.getOrPut(dateKey) { mutableMapOf() }[routineId] = completionInfo + } + routineLocalDataSource.applyOptimisticToggle(dateKey, routineId, completionInfo) + syncTrigger.emit(Unit) + } + + private suspend fun flushAllPendingChanges() { + val snapshot: Map> + val originals: Map> + mutex.withLock { + snapshot = pendingChangesByDate.mapValues { it.value.toMap() } + originals = originalStatesByDate.mapValues { it.value.toMap() } + pendingChangesByDate.clear() + originalStatesByDate.clear() + } + + val actualChanges = snapshot.flatMap { (dateKey, pendingForDate) -> + pendingForDate.filter { (routineId, pending) -> + originals[dateKey]?.get(routineId) != pending + }.values + } + + if (actualChanges.isEmpty()) return + + val syncRequest = RoutineCompletionInfos(routineCompletionInfos = actualChanges) + val result = syncWithRetry(syncRequest) + + result.onFailure { + _syncError.emit(Unit) + val range = routineLocalDataSource.lastFetchRange ?: return + fetchAndSave(range.first, range.second) + .onFailure { rollbackError -> + Log.e("RoutineRepository", "롤백 실패: ${rollbackError.message}") + } + } + } + + private suspend fun syncWithRetry( + syncRequest: RoutineCompletionInfos, + maxRetries: Int = 2, + initialDelayMillis: Long = 200L, + ): Result { + var delayMillis = initialDelayMillis + + repeat(maxRetries + 1) { attempt -> + val result = routineRemoteDataSource.syncRoutineCompletion(syncRequest.toDto()) + + if (result.isSuccess) return result + + val isLastAttempt = attempt == maxRetries + if (isLastAttempt) return result + + delay(delayMillis) + delayMillis *= 2 + } + + return Result.failure(IllegalStateException("Unreachable")) + } + + private suspend fun refreshCache() { + val range = routineLocalDataSource.lastFetchRange ?: return + fetchAndSave(range.first, range.second) + } override suspend fun getRoutine(routineId: String): Result = routineRemoteDataSource.getRoutine(routineId).map { it.toDomain() } override suspend fun deleteRoutine(routineId: String): Result = - routineRemoteDataSource.deleteRoutine(routineId) + routineRemoteDataSource.deleteRoutine(routineId).also { + if (it.isSuccess) refreshCache() + } override suspend fun deleteRoutineForDay(routineId: String): Result = - routineRemoteDataSource.deleteRoutineForDay(routineId) - - override suspend fun registerRoutine(routineRegisterInfo: RoutineRegisterInfo): Result { - val request = routineRegisterInfo.toDto() - return routineRemoteDataSource.registerRoutine(request).also { - if (it.isSuccess) { - _writeRoutineEventFlow.emit(WriteRoutineEvent.AddRoutine) - } + routineRemoteDataSource.deleteRoutineForDay(routineId).also { + if (it.isSuccess) refreshCache() } - } - override suspend fun editRoutine(routineEditInfo: RoutineEditInfo): Result { - val request = routineEditInfo.toDto() - return routineRemoteDataSource.editRoutine(request).also { - if (it.isSuccess) { - _writeRoutineEventFlow.emit(WriteRoutineEvent.EditRoutine(routineEditInfo.id)) - } + override suspend fun registerRoutine(routineRegisterInfo: RoutineRegisterInfo): Result = + routineRemoteDataSource.registerRoutine(routineRegisterInfo.toDto()).also { + if (it.isSuccess) refreshCache() } - } - private val _writeRoutineEventFlow = MutableSharedFlow() - override suspend fun getWriteRoutineEventFlow(): Flow = _writeRoutineEventFlow.asSharedFlow() + override suspend fun editRoutine(routineEditInfo: RoutineEditInfo): Result = + routineRemoteDataSource.editRoutine(routineEditInfo.toDto()).also { + if (it.isSuccess) refreshCache() + } } diff --git a/data/src/test/java/com/threegap/bitnagil/data/routine/repositoryImpl/RoutineRepositoryImplTest.kt b/data/src/test/java/com/threegap/bitnagil/data/routine/repositoryImpl/RoutineRepositoryImplTest.kt new file mode 100644 index 00000000..2053cd43 --- /dev/null +++ b/data/src/test/java/com/threegap/bitnagil/data/routine/repositoryImpl/RoutineRepositoryImplTest.kt @@ -0,0 +1,286 @@ +package com.threegap.bitnagil.data.routine.repositoryImpl + +import com.threegap.bitnagil.data.routine.datasource.RoutineRemoteDataSource +import com.threegap.bitnagil.data.routine.datasourceImpl.RoutineLocalDataSourceImpl +import com.threegap.bitnagil.data.routine.model.request.RoutineCompletionRequest +import com.threegap.bitnagil.data.routine.model.request.RoutineEditRequest +import com.threegap.bitnagil.data.routine.model.request.RoutineRegisterRequest +import com.threegap.bitnagil.data.routine.model.response.RoutineScheduleResponse +import com.threegap.bitnagil.domain.routine.model.DailyRoutines +import com.threegap.bitnagil.domain.routine.model.DayOfWeek +import com.threegap.bitnagil.domain.routine.model.Routine +import com.threegap.bitnagil.domain.routine.model.RoutineCompletionInfo +import com.threegap.bitnagil.domain.routine.model.RoutineSchedule +import com.threegap.bitnagil.domain.routine.repository.RoutineRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.concurrent.atomic.AtomicInteger + +@OptIn(ExperimentalCoroutinesApi::class) +class RoutineRepositoryImplTest { + + private lateinit var localDataSource: RoutineLocalDataSourceImpl + private lateinit var remoteDataSource: FakeRoutineRemoteDataSource + private lateinit var repository: RoutineRepository + + @Before + fun setup() { + localDataSource = RoutineLocalDataSourceImpl() + remoteDataSource = FakeRoutineRemoteDataSource() + } + + private fun createRepository(testScheduler: kotlinx.coroutines.test.TestCoroutineScheduler): RoutineRepository { + val dispatcher = StandardTestDispatcher(testScheduler) + return RoutineRepositoryImpl(remoteDataSource, localDataSource, dispatcher) + } + + // --- observeWeeklyRoutines --- + + @Test + fun `캐시 미스 시 Remote에서 fetch 후 결과를 방출해야 한다`() = runTest { + repository = createRepository(testScheduler) + remoteDataSource.scheduleResponse = Result.success(RoutineScheduleResponse(emptyMap())) + + val result = repository.observeWeeklyRoutines("2024-01-01", "2024-01-07").first() + + assertEquals(RoutineSchedule(emptyMap()), result) + assertEquals(1, remoteDataSource.fetchCount.get()) + } + + @Test + fun `동일 주차 재구독 시 Remote를 재호출하지 않고 캐시를 반환해야 한다`() = runTest { + repository = createRepository(testScheduler) + remoteDataSource.scheduleResponse = Result.success(RoutineScheduleResponse(emptyMap())) + val startDate = "2024-01-01" + val endDate = "2024-01-07" + + repository.observeWeeklyRoutines(startDate, endDate).first() + repository.observeWeeklyRoutines(startDate, endDate).first() + + assertEquals(1, remoteDataSource.fetchCount.get()) + } + + @Test + fun `다른 주차 구독 시 캐시 초기화 후 Remote를 재호출해야 한다`() = runTest { + repository = createRepository(testScheduler) + remoteDataSource.scheduleResponse = Result.success(RoutineScheduleResponse(emptyMap())) + + repository.observeWeeklyRoutines("2024-01-01", "2024-01-07").first() + repository.observeWeeklyRoutines("2024-01-08", "2024-01-14").first() + + assertEquals(2, remoteDataSource.fetchCount.get()) + } + + // --- applyRoutineToggle --- + + @Test + fun `토글 호출 시 로컬 캐시에 즉시 optimistic update가 반영되어야 한다`() = runTest { + repository = createRepository(testScheduler) + val (dateKey, routineId) = setupCacheWithRoutine(isCompleted = false) + + repository.applyRoutineToggle( + dateKey = dateKey, + routineId = routineId, + completionInfo = RoutineCompletionInfo(routineId, routineCompleteYn = true, subRoutineCompleteYn = emptyList()), + ) + + val updatedRoutine = localDataSource.routineSchedule.value + ?.dailyRoutines?.get(dateKey)?.routines?.find { it.id == routineId } + assertTrue(updatedRoutine!!.isCompleted) + } + + @Test + fun `토글 후 debounce 경과 시 서버 sync API가 호출되어야 한다`() = runTest { + repository = createRepository(testScheduler) + runCurrent() // repositoryScope의 syncTrigger collector 시작 + val (dateKey, routineId) = setupCacheWithRoutine(isCompleted = false) + + repository.applyRoutineToggle( + dateKey = dateKey, + routineId = routineId, + completionInfo = RoutineCompletionInfo(routineId, routineCompleteYn = true, subRoutineCompleteYn = emptyList()), + ) + + assertEquals(0, remoteDataSource.syncCount.get()) + advanceTimeBy(501L) + assertEquals(1, remoteDataSource.syncCount.get()) + } + + @Test + fun `서로 다른 날짜에 토글 시 debounce 후 두 날짜 모두 sync되어야 한다`() = runTest { + repository = createRepository(testScheduler) + runCurrent() + val (dateKey1, routineId1) = setupCacheWithRoutineOnDate("2024-01-01", "routine1", isCompleted = false) + val (dateKey2, routineId2) = setupCacheWithRoutineOnDate("2024-01-02", "routine2", isCompleted = false) + + repository.applyRoutineToggle(dateKey1, routineId1, RoutineCompletionInfo(routineId1, routineCompleteYn = true, subRoutineCompleteYn = emptyList())) + repository.applyRoutineToggle(dateKey2, routineId2, RoutineCompletionInfo(routineId2, routineCompleteYn = true, subRoutineCompleteYn = emptyList())) + + advanceTimeBy(501L) + assertEquals(1, remoteDataSource.syncCount.get()) + } + + @Test + fun `A→B→A 토글 시 최종 상태가 원래와 동일하면 API를 호출하지 않아야 한다`() = runTest { + repository = createRepository(testScheduler) + runCurrent() // repositoryScope의 syncTrigger collector 시작 + val (dateKey, routineId) = setupCacheWithRoutine(isCompleted = false) + val completionInfoB = RoutineCompletionInfo(routineId, routineCompleteYn = true, subRoutineCompleteYn = emptyList()) + val completionInfoA = RoutineCompletionInfo(routineId, routineCompleteYn = false, subRoutineCompleteYn = emptyList()) + + repository.applyRoutineToggle(dateKey, routineId, completionInfoB) + repository.applyRoutineToggle(dateKey, routineId, completionInfoA) + + advanceTimeBy(501L) + assertEquals(0, remoteDataSource.syncCount.get()) + } + + @Test + fun `sync 실패 시 서버 데이터로 로컬 캐시가 rollback되어야 한다`() = runTest { + repository = createRepository(testScheduler) + runCurrent() + remoteDataSource.scheduleResponse = Result.success(RoutineScheduleResponse(emptyMap())) + remoteDataSource.syncResult = Result.failure(Exception("네트워크 오류")) + val (dateKey, routineId) = setupCacheWithRoutine(isCompleted = false) + + repository.applyRoutineToggle( + dateKey = dateKey, + routineId = routineId, + completionInfo = RoutineCompletionInfo(routineId, routineCompleteYn = true, subRoutineCompleteYn = emptyList()), + ) + + advanceTimeBy(1200L) // debounce(500) + retry delay(200 + 400) + 여유 + + assertEquals(3, remoteDataSource.syncCount.get()) // 1회 시도 + 2회 재시도 + assertEquals(1, remoteDataSource.fetchCount.get()) // 롤백 fetch 1회 + assertEquals(RoutineSchedule(emptyMap()), localDataSource.routineSchedule.value) + } + + // --- delete / edit / register --- + + @Test + fun `deleteRoutine 성공 시 캐시 무효화 후 Remote를 재호출해야 한다`() = runTest { + repository = createRepository(testScheduler) + remoteDataSource.scheduleResponse = Result.success(RoutineScheduleResponse(emptyMap())) + localDataSource.saveSchedule(RoutineSchedule(emptyMap()), "2024-01-01", "2024-01-07") + + repository.deleteRoutine("routine1") + + assertEquals(1, remoteDataSource.fetchCount.get()) + } + + @Test + fun `deleteRoutine 실패 시 Remote를 재호출하지 않아야 한다`() = runTest { + repository = createRepository(testScheduler) + remoteDataSource.deleteResult = Result.failure(Exception("삭제 실패")) + localDataSource.saveSchedule(RoutineSchedule(emptyMap()), "2024-01-01", "2024-01-07") + + repository.deleteRoutine("routine1") + + assertEquals(0, remoteDataSource.fetchCount.get()) + } + + @Test + fun `sync 1차 실패 후 재시도 성공 시 syncError가 emit되지 않아야 한다`() = runTest { + repository = createRepository(testScheduler) + runCurrent() + remoteDataSource.syncFailCount = 1 // 1번만 실패, 이후 성공 + val (dateKey, routineId) = setupCacheWithRoutine(isCompleted = false) + + var syncErrorEmitted = false + val job = launch { + repository.syncError.collect { syncErrorEmitted = true } + } + + repository.applyRoutineToggle( + dateKey = dateKey, + routineId = routineId, + completionInfo = RoutineCompletionInfo(routineId, routineCompleteYn = true, subRoutineCompleteYn = emptyList()), + ) + + advanceTimeBy(1200L) // debounce + 재시도 여유 시간 + + assertEquals(2, remoteDataSource.syncCount.get()) // 1차 실패 + 2차 성공 + assertEquals(false, syncErrorEmitted) // 에러 다이얼로그 없음 + assertEquals(0, remoteDataSource.fetchCount.get()) // 롤백 fetch 없음 + + job.cancel() + } + + // --- Helpers --- + + private fun setupCacheWithRoutine(isCompleted: Boolean): Pair = + setupCacheWithRoutineOnDate("2024-01-01", "routine1", isCompleted) + + private fun setupCacheWithRoutineOnDate(dateKey: String, routineId: String, isCompleted: Boolean): Pair { + val existing = localDataSource.routineSchedule.value?.dailyRoutines ?: emptyMap() + val newDailyRoutines = existing + mapOf( + dateKey to DailyRoutines( + routines = listOf( + Routine( + id = routineId, + name = "테스트 루틴", + repeatDays = listOf(DayOfWeek.MONDAY), + executionTime = "08:00", + startDate = dateKey, + endDate = dateKey, + routineDate = dateKey, + isCompleted = isCompleted, + isDeleted = false, + subRoutineNames = emptyList(), + subRoutineCompletionStates = emptyList(), + recommendedRoutineType = null, + ), + ), + isAllCompleted = isCompleted, + ), + ) + localDataSource.saveSchedule(RoutineSchedule(newDailyRoutines), dateKey, dateKey) + return dateKey to routineId + } + + // --- Fake Objects --- + + private class FakeRoutineRemoteDataSource : RoutineRemoteDataSource { + var scheduleResponse: Result = Result.success(RoutineScheduleResponse(emptyMap())) + var syncResult: Result = Result.success(Unit) + var deleteResult: Result = Result.success(Unit) + var syncFailCount: Int = 0 + val fetchCount = AtomicInteger(0) + val syncCount = AtomicInteger(0) + + override suspend fun fetchWeeklyRoutines(startDate: String, endDate: String): Result { + fetchCount.incrementAndGet() + return scheduleResponse + } + + override suspend fun syncRoutineCompletion(routineCompletionRequest: RoutineCompletionRequest): Result { + val attempt = syncCount.incrementAndGet() + return if (attempt <= syncFailCount) { + Result.failure(Exception("네트워크 오류")) + } else { + syncResult + } + } + + override suspend fun getRoutine(routineId: String): Result = + Result.failure(NotImplementedError()) + + override suspend fun deleteRoutine(routineId: String): Result = deleteResult + + override suspend fun deleteRoutineForDay(routineId: String): Result = Result.success(Unit) + + override suspend fun registerRoutine(request: RoutineRegisterRequest): Result = Result.success(Unit) + + override suspend fun editRoutine(request: RoutineEditRequest): Result = Result.success(Unit) + } +} diff --git a/domain/src/main/java/com/threegap/bitnagil/domain/onboarding/model/OnBoardingRecommendRoutineEvent.kt b/domain/src/main/java/com/threegap/bitnagil/domain/onboarding/model/OnBoardingRecommendRoutineEvent.kt deleted file mode 100644 index a2e98a2e..00000000 --- a/domain/src/main/java/com/threegap/bitnagil/domain/onboarding/model/OnBoardingRecommendRoutineEvent.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.threegap.bitnagil.domain.onboarding.model - -sealed interface OnBoardingRecommendRoutineEvent { - data class AddRoutines(val routineIds: List) : OnBoardingRecommendRoutineEvent -} diff --git a/domain/src/main/java/com/threegap/bitnagil/domain/onboarding/repository/OnBoardingRepository.kt b/domain/src/main/java/com/threegap/bitnagil/domain/onboarding/repository/OnBoardingRepository.kt index bb768a0c..590ce9bb 100644 --- a/domain/src/main/java/com/threegap/bitnagil/domain/onboarding/repository/OnBoardingRepository.kt +++ b/domain/src/main/java/com/threegap/bitnagil/domain/onboarding/repository/OnBoardingRepository.kt @@ -3,14 +3,11 @@ package com.threegap.bitnagil.domain.onboarding.repository import com.threegap.bitnagil.domain.onboarding.model.OnBoarding import com.threegap.bitnagil.domain.onboarding.model.OnBoardingAbstract import com.threegap.bitnagil.domain.onboarding.model.OnBoardingRecommendRoutine -import com.threegap.bitnagil.domain.onboarding.model.OnBoardingRecommendRoutineEvent -import kotlinx.coroutines.flow.Flow interface OnBoardingRepository { suspend fun getOnBoardingList(): List suspend fun getOnBoardingAbstract(selectedItemIdsWithOnBoardingId: List>>): OnBoardingAbstract suspend fun getRecommendOnBoardingRouteList(selectedItemIdsWithOnBoardingId: List>>): Result> suspend fun registerRecommendRoutineList(selectedRecommendRoutineIds: List): Result - suspend fun getOnBoardingRecommendRoutineEventFlow(): Flow suspend fun getUserOnBoarding(): Result>>> } diff --git a/domain/src/main/java/com/threegap/bitnagil/domain/onboarding/usecase/GetOnBoardingRecommendRoutineEventFlowUseCase.kt b/domain/src/main/java/com/threegap/bitnagil/domain/onboarding/usecase/GetOnBoardingRecommendRoutineEventFlowUseCase.kt deleted file mode 100644 index 6903b27b..00000000 --- a/domain/src/main/java/com/threegap/bitnagil/domain/onboarding/usecase/GetOnBoardingRecommendRoutineEventFlowUseCase.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.threegap.bitnagil.domain.onboarding.usecase - -import com.threegap.bitnagil.domain.onboarding.model.OnBoardingRecommendRoutineEvent -import com.threegap.bitnagil.domain.onboarding.repository.OnBoardingRepository -import kotlinx.coroutines.flow.Flow -import javax.inject.Inject - -class GetOnBoardingRecommendRoutineEventFlowUseCase @Inject constructor( - private val repository: OnBoardingRepository, -) { - suspend operator fun invoke(): Flow { - return repository.getOnBoardingRecommendRoutineEventFlow() - } -} diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/screen/home/model/ToggleStrategy.kt b/domain/src/main/java/com/threegap/bitnagil/domain/routine/model/ToggleStrategy.kt similarity index 67% rename from presentation/src/main/java/com/threegap/bitnagil/presentation/screen/home/model/ToggleStrategy.kt rename to domain/src/main/java/com/threegap/bitnagil/domain/routine/model/ToggleStrategy.kt index 9e8b9ad6..544bc1a3 100644 --- a/presentation/src/main/java/com/threegap/bitnagil/presentation/screen/home/model/ToggleStrategy.kt +++ b/domain/src/main/java/com/threegap/bitnagil/domain/routine/model/ToggleStrategy.kt @@ -1,4 +1,4 @@ -package com.threegap.bitnagil.presentation.screen.home.model +package com.threegap.bitnagil.domain.routine.model sealed interface ToggleStrategy { data object Main : ToggleStrategy diff --git a/domain/src/main/java/com/threegap/bitnagil/domain/routine/model/WriteRoutineEvent.kt b/domain/src/main/java/com/threegap/bitnagil/domain/routine/model/WriteRoutineEvent.kt deleted file mode 100644 index 6fd1ceaf..00000000 --- a/domain/src/main/java/com/threegap/bitnagil/domain/routine/model/WriteRoutineEvent.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.threegap.bitnagil.domain.routine.model - -sealed interface WriteRoutineEvent { - data object AddRoutine : WriteRoutineEvent - data class EditRoutine(val routineId: String) : WriteRoutineEvent -} diff --git a/domain/src/main/java/com/threegap/bitnagil/domain/routine/repository/RoutineRepository.kt b/domain/src/main/java/com/threegap/bitnagil/domain/routine/repository/RoutineRepository.kt index e91b8287..0555e38e 100644 --- a/domain/src/main/java/com/threegap/bitnagil/domain/routine/repository/RoutineRepository.kt +++ b/domain/src/main/java/com/threegap/bitnagil/domain/routine/repository/RoutineRepository.kt @@ -1,20 +1,20 @@ package com.threegap.bitnagil.domain.routine.repository import com.threegap.bitnagil.domain.routine.model.Routine -import com.threegap.bitnagil.domain.routine.model.RoutineCompletionInfos +import com.threegap.bitnagil.domain.routine.model.RoutineCompletionInfo import com.threegap.bitnagil.domain.routine.model.RoutineEditInfo import com.threegap.bitnagil.domain.routine.model.RoutineRegisterInfo import com.threegap.bitnagil.domain.routine.model.RoutineSchedule -import com.threegap.bitnagil.domain.routine.model.WriteRoutineEvent import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow interface RoutineRepository { - suspend fun fetchWeeklyRoutines(startDate: String, endDate: String): Result - suspend fun syncRoutineCompletion(routineCompletionInfos: RoutineCompletionInfos): Result + val syncError: SharedFlow + fun observeWeeklyRoutines(startDate: String, endDate: String): Flow + suspend fun applyRoutineToggle(dateKey: String, routineId: String, completionInfo: RoutineCompletionInfo) suspend fun getRoutine(routineId: String): Result suspend fun deleteRoutine(routineId: String): Result suspend fun deleteRoutineForDay(routineId: String): Result suspend fun registerRoutine(routineRegisterInfo: RoutineRegisterInfo): Result suspend fun editRoutine(routineEditInfo: RoutineEditInfo): Result - suspend fun getWriteRoutineEventFlow(): Flow } diff --git a/domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/ApplyRoutineToggleUseCase.kt b/domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/ApplyRoutineToggleUseCase.kt new file mode 100644 index 00000000..a303f8d6 --- /dev/null +++ b/domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/ApplyRoutineToggleUseCase.kt @@ -0,0 +1,44 @@ +package com.threegap.bitnagil.domain.routine.usecase + +import com.threegap.bitnagil.domain.routine.model.RoutineCompletionInfo +import com.threegap.bitnagil.domain.routine.model.ToggleStrategy +import com.threegap.bitnagil.domain.routine.repository.RoutineRepository +import javax.inject.Inject + +class ApplyRoutineToggleUseCase @Inject constructor( + private val routineRepository: RoutineRepository, + private val toggleRoutineUseCase: ToggleRoutineUseCase, +) { + suspend operator fun invoke( + dateKey: String, + routineId: String, + isCompleted: Boolean, + subRoutineCompletionStates: List, + strategy: ToggleStrategy, + ) { + val toggledState = + when (strategy) { + is ToggleStrategy.Main -> { + toggleRoutineUseCase.toggleMainRoutine( + isCompleted = isCompleted, + subRoutineStates = subRoutineCompletionStates, + ) + } + + is ToggleStrategy.Sub -> { + toggleRoutineUseCase.toggleSubRoutine( + index = strategy.index, + subRoutineStates = subRoutineCompletionStates, + ) ?: return + } + } + + val completionInfo = RoutineCompletionInfo( + routineId = routineId, + routineCompleteYn = toggledState.isCompleted, + subRoutineCompleteYn = toggledState.subRoutinesIsCompleted, + ) + + routineRepository.applyRoutineToggle(dateKey, routineId, completionInfo) + } +} diff --git a/domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/GetWriteRoutineEventFlowUseCase.kt b/domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/ObserveRoutineSyncErrorUseCase.kt similarity index 51% rename from domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/GetWriteRoutineEventFlowUseCase.kt rename to domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/ObserveRoutineSyncErrorUseCase.kt index 46ee1552..83f9a764 100644 --- a/domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/GetWriteRoutineEventFlowUseCase.kt +++ b/domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/ObserveRoutineSyncErrorUseCase.kt @@ -1,12 +1,11 @@ package com.threegap.bitnagil.domain.routine.usecase -import com.threegap.bitnagil.domain.routine.model.WriteRoutineEvent import com.threegap.bitnagil.domain.routine.repository.RoutineRepository import kotlinx.coroutines.flow.Flow import javax.inject.Inject -class GetWriteRoutineEventFlowUseCase @Inject constructor( +class ObserveRoutineSyncErrorUseCase @Inject constructor( private val routineRepository: RoutineRepository, ) { - suspend operator fun invoke(): Flow = routineRepository.getWriteRoutineEventFlow() + operator fun invoke(): Flow = routineRepository.syncError } diff --git a/domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/FetchWeeklyRoutinesUseCase.kt b/domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/ObserveWeeklyRoutinesUseCase.kt similarity index 53% rename from domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/FetchWeeklyRoutinesUseCase.kt rename to domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/ObserveWeeklyRoutinesUseCase.kt index 084b81e5..d8b0d029 100644 --- a/domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/FetchWeeklyRoutinesUseCase.kt +++ b/domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/ObserveWeeklyRoutinesUseCase.kt @@ -2,12 +2,12 @@ package com.threegap.bitnagil.domain.routine.usecase import com.threegap.bitnagil.domain.routine.model.RoutineSchedule import com.threegap.bitnagil.domain.routine.repository.RoutineRepository +import kotlinx.coroutines.flow.Flow import javax.inject.Inject -class FetchWeeklyRoutinesUseCase @Inject constructor( +class ObserveWeeklyRoutinesUseCase @Inject constructor( private val routineRepository: RoutineRepository, ) { - suspend operator fun invoke(startDate: String, endDate: String): Result { - return routineRepository.fetchWeeklyRoutines(startDate, endDate) - } + operator fun invoke(startDate: String, endDate: String): Flow = + routineRepository.observeWeeklyRoutines(startDate, endDate) } diff --git a/domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/RoutineCompletionUseCase.kt b/domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/RoutineCompletionUseCase.kt deleted file mode 100644 index 0acc1e02..00000000 --- a/domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/RoutineCompletionUseCase.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.threegap.bitnagil.domain.routine.usecase - -import com.threegap.bitnagil.domain.routine.model.RoutineCompletionInfos -import com.threegap.bitnagil.domain.routine.repository.RoutineRepository -import javax.inject.Inject - -class RoutineCompletionUseCase @Inject constructor( - private val routineRepository: RoutineRepository, -) { - suspend operator fun invoke(routineCompletionInfos: RoutineCompletionInfos): Result = - routineRepository.syncRoutineCompletion(routineCompletionInfos) -} diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/screen/home/HomeScreen.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/screen/home/HomeScreen.kt index 85d85187..e09fe183 100644 --- a/presentation/src/main/java/com/threegap/bitnagil/presentation/screen/home/HomeScreen.kt +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/screen/home/HomeScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.threegap.bitnagil.designsystem.BitnagilTheme +import com.threegap.bitnagil.designsystem.component.block.BitnagilConfirmDialog import com.threegap.bitnagil.designsystem.modifier.clickableWithoutRipple import com.threegap.bitnagil.presentation.screen.home.component.template.CollapsibleHeader import com.threegap.bitnagil.presentation.screen.home.component.template.EmptyRoutineView @@ -64,6 +65,15 @@ fun HomeScreenContainer( } } + if (uiState.showSyncErrorDialog) { + BitnagilConfirmDialog( + title = "루틴 완료를 저장하지 못했어요", + description = "네트워크 연결에 문제가 있었던 것 같아요.\n다시 한 번 시도해 주세요.", + confirmButtonText = "확인", + onConfirm = viewModel::dismissSyncErrorDialog, + ) + } + HomeScreen( uiState = uiState, onDateSelect = viewModel::selectDate, diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/screen/home/HomeViewModel.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/screen/home/HomeViewModel.kt index 639da290..d92798f2 100644 --- a/presentation/src/main/java/com/threegap/bitnagil/presentation/screen/home/HomeViewModel.kt +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/screen/home/HomeViewModel.kt @@ -3,28 +3,23 @@ package com.threegap.bitnagil.presentation.screen.home import android.util.Log import androidx.lifecycle.ViewModel import com.threegap.bitnagil.domain.emotion.usecase.ObserveDailyEmotionUseCase -import com.threegap.bitnagil.domain.onboarding.usecase.GetOnBoardingRecommendRoutineEventFlowUseCase -import com.threegap.bitnagil.domain.routine.model.RoutineCompletionInfo -import com.threegap.bitnagil.domain.routine.model.RoutineCompletionInfos -import com.threegap.bitnagil.domain.routine.usecase.FetchWeeklyRoutinesUseCase -import com.threegap.bitnagil.domain.routine.usecase.GetWriteRoutineEventFlowUseCase -import com.threegap.bitnagil.domain.routine.usecase.RoutineCompletionUseCase -import com.threegap.bitnagil.domain.routine.usecase.ToggleRoutineUseCase +import com.threegap.bitnagil.domain.routine.model.ToggleStrategy +import com.threegap.bitnagil.domain.routine.usecase.ApplyRoutineToggleUseCase +import com.threegap.bitnagil.domain.routine.usecase.ObserveRoutineSyncErrorUseCase +import com.threegap.bitnagil.domain.routine.usecase.ObserveWeeklyRoutinesUseCase import com.threegap.bitnagil.domain.user.usecase.ObserveUserProfileUseCase import com.threegap.bitnagil.presentation.screen.home.contract.HomeSideEffect import com.threegap.bitnagil.presentation.screen.home.contract.HomeState -import com.threegap.bitnagil.presentation.screen.home.model.ToggleStrategy import com.threegap.bitnagil.presentation.screen.home.model.toUiModel import com.threegap.bitnagil.presentation.screen.home.util.getCurrentWeekDays import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch import org.orbitmvi.orbit.Container import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.viewmodel.container @@ -33,37 +28,31 @@ import javax.inject.Inject @HiltViewModel class HomeViewModel @Inject constructor( - private val fetchWeeklyRoutinesUseCase: FetchWeeklyRoutinesUseCase, + private val observeWeeklyRoutinesUseCase: ObserveWeeklyRoutinesUseCase, private val observeUserProfileUseCase: ObserveUserProfileUseCase, private val observeDailyEmotionUseCase: ObserveDailyEmotionUseCase, - private val routineCompletionUseCase: RoutineCompletionUseCase, - private val getWriteRoutineEventFlowUseCase: GetWriteRoutineEventFlowUseCase, - private val getOnBoardingRecommendRoutineEventFlowUseCase: GetOnBoardingRecommendRoutineEventFlowUseCase, - private val toggleRoutineUseCase: ToggleRoutineUseCase, + private val applyRoutineToggleUseCase: ApplyRoutineToggleUseCase, + private val observeRoutineSyncErrorUseCase: ObserveRoutineSyncErrorUseCase, ) : ContainerHost, ViewModel() { override val container: Container = container( initialState = HomeState.INIT, buildSettings = { repeatOnSubscribedStopTimeout = 5_000L }, + onCreate = { + observeDailyEmotion() + observeUserProfile() + observeWeeklyRoutines() + observeRoutineSyncError() + }, ) - private val pendingChangesByDate = mutableMapOf>() - private val routineSyncTrigger = MutableSharedFlow(extraBufferCapacity = 64) - - init { - initialize() - observeDailyEmotion() - } - private fun observeDailyEmotion() { intent { repeatOnSubscription { observeDailyEmotionUseCase().collect { result -> result.fold( - onSuccess = { dailyEmotion -> - reduce { state.copy(dailyEmotion = dailyEmotion.toUiModel()) } - }, + onSuccess = { reduce { state.copy(dailyEmotion = it.toUiModel()) } }, onFailure = {}, ) } @@ -71,24 +60,60 @@ class HomeViewModel @Inject constructor( } } - fun selectDate(data: LocalDate) { + private fun observeUserProfile() { intent { - val previousDateKey = state.selectedDate.toString() - if (pendingChangesByDate.containsKey(previousDateKey)) { - syncRoutineChangesForDate(previousDateKey) + repeatOnSubscription { + observeUserProfileUseCase().collect { result -> + result.fold( + onSuccess = { reduce { state.copy(userNickname = it.nickname) } }, + onFailure = {}, + ) + } } + } + } - reduce { state.copy(selectedDate = data) } + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) + private fun observeWeeklyRoutines() { + intent { + repeatOnSubscription { + container.stateFlow + .map { it.currentWeeks } + .distinctUntilChanged() + .debounce(300L) + .flatMapLatest { weeks -> + observeWeeklyRoutinesUseCase(weeks.first().toString(), weeks.last().toString()) + } + .catch { e -> Log.e("HomeViewModel", "루틴 가져오기 실패: ${e.message}") } + .collect { reduce { state.copy(routineSchedule = it.toUiModel()) } } + } } } - fun getNextWeek() { + private fun observeRoutineSyncError() { intent { - val currentDateKey = state.selectedDate.toString() - if (pendingChangesByDate.containsKey(currentDateKey)) { - syncRoutineChangesForDate(currentDateKey) + repeatOnSubscription { + observeRoutineSyncErrorUseCase().collect { + reduce { state.copy(showSyncErrorDialog = true) } + } } + } + } + + fun dismissSyncErrorDialog() { + intent { + reduce { state.copy(showSyncErrorDialog = false) } + } + } + fun selectDate(date: LocalDate) { + intent { + reduce { state.copy(selectedDate = date) } + } + } + + fun getNextWeek() { + intent { val newWeek = state.selectedDate.plusWeeks(1).getCurrentWeekDays() reduce { state.copy(currentWeeks = newWeek, selectedDate = newWeek.first()) } } @@ -96,11 +121,6 @@ class HomeViewModel @Inject constructor( fun getPreviousWeek() { intent { - val currentDateKey = state.selectedDate.toString() - if (pendingChangesByDate.containsKey(currentDateKey)) { - syncRoutineChangesForDate(currentDateKey) - } - val newWeek = state.selectedDate.minusWeeks(1).getCurrentWeekDays() reduce { state.copy(currentWeeks = newWeek, selectedDate = newWeek.first()) } } @@ -108,70 +128,28 @@ class HomeViewModel @Inject constructor( fun toggleRoutine(routineId: String) { intent { - updateRoutineState(routineId, ToggleStrategy.Main) + applyToggle(routineId, ToggleStrategy.Main) } } fun toggleSubRoutine(routineId: String, subRoutineIndex: Int) { intent { - updateRoutineState(routineId, ToggleStrategy.Sub(subRoutineIndex)) + applyToggle(routineId, ToggleStrategy.Sub(subRoutineIndex)) } } - private suspend fun updateRoutineState(routineId: String, strategy: ToggleStrategy) { + private suspend fun applyToggle(routineId: String, strategy: ToggleStrategy) { subIntent { val dateKey = state.selectedDate.toString() - val dailySchedule = state.routineSchedule.dailyRoutines[dateKey] ?: return@subIntent - val routine = dailySchedule.routines.find { it.id == routineId } ?: return@subIntent - - val toggledState = when (strategy) { - is ToggleStrategy.Main -> { - toggleRoutineUseCase.toggleMainRoutine( - isCompleted = routine.isCompleted, - subRoutineStates = routine.subRoutineCompletionStates, - ) - } - - is ToggleStrategy.Sub -> { - toggleRoutineUseCase.toggleSubRoutine( - index = strategy.index, - subRoutineStates = routine.subRoutineCompletionStates, - ) - } - } ?: return@subIntent - - val updatedRoutines = dailySchedule.routines.map { routine -> - if (routine.id == routineId) { - routine.copy( - isCompleted = toggledState.isCompleted, - subRoutineCompletionStates = toggledState.subRoutinesIsCompleted, - ) - } else { - routine - } - } - - val updatedDailySchedule = dailySchedule.copy( - routines = updatedRoutines, - isAllCompleted = updatedRoutines.all { it.isCompleted }, - ) - - val newSchedule = state.routineSchedule.copy( - dailyRoutines = state.routineSchedule.dailyRoutines + (dateKey to updatedDailySchedule), - ) + val routine = state.selectedDateRoutines.find { it.id == routineId } ?: return@subIntent - reduce { state.copy(routineSchedule = newSchedule) } - - val change = RoutineCompletionInfo( + applyRoutineToggleUseCase( + dateKey = dateKey, routineId = routineId, - routineCompleteYn = toggledState.isCompleted, - subRoutineCompleteYn = toggledState.subRoutinesIsCompleted, + isCompleted = routine.isCompleted, + subRoutineCompletionStates = routine.subRoutineCompletionStates, + strategy = strategy, ) - - val dateChanges = pendingChangesByDate.getOrPut(dateKey) { mutableMapOf() } - dateChanges[routineId] = change - - routineSyncTrigger.emit(dateKey) } } @@ -199,111 +177,4 @@ class HomeViewModel @Inject constructor( postSideEffect(HomeSideEffect.NavigateToRoutineList(selectedDate)) } } - - private fun initialize() { - intent { - coroutineScope { - launch { observeUserProfile() } - launch { fetchWeeklyRoutines(state.currentWeeks) } - launch { observeWriteRoutineEvent() } - launch { observeRecommendRoutineEvent() } - launch { observeWeekChanges() } - launch { observeRoutineUpdates() } - } - } - } - - private suspend fun observeWriteRoutineEvent() { - subIntent { - getWriteRoutineEventFlowUseCase().collect { - fetchWeeklyRoutines(state.currentWeeks) - } - } - } - - private suspend fun observeRecommendRoutineEvent() { - subIntent { - getOnBoardingRecommendRoutineEventFlowUseCase().collect { - fetchWeeklyRoutines(state.currentWeeks) - } - } - } - - @OptIn(FlowPreview::class) - private suspend fun observeWeekChanges() { - subIntent { - container.stateFlow - .map { it.currentWeeks } - .distinctUntilChanged() - .drop(1) - .debounce(500L) - .collect { newWeeks -> - fetchWeeklyRoutines(newWeeks) - } - } - } - - @OptIn(FlowPreview::class) - private suspend fun observeRoutineUpdates() { - subIntent { - routineSyncTrigger - .debounce(500L) - .collect { dateKey -> - syncRoutineChangesForDate(dateKey) - } - } - } - - private fun observeUserProfile() { - intent { - repeatOnSubscription { - reduce { state.copy(loadingCount = state.loadingCount + 1) } - observeUserProfileUseCase().collect { result -> - result.fold( - onSuccess = { - reduce { state.copy(userNickname = it.nickname, loadingCount = state.loadingCount - 1) } - }, - onFailure = { - Log.e("HomeViewModel", "유저 정보 가져오기 실패: ${it.message}") - reduce { state.copy(loadingCount = state.loadingCount - 1) } - }, - ) - } - } - } - } - - private suspend fun fetchWeeklyRoutines(currentWeeks: List) { - subIntent { - reduce { state.copy(loadingCount = state.loadingCount + 1) } - val startDate = currentWeeks.first().toString() - val endDate = currentWeeks.last().toString() - fetchWeeklyRoutinesUseCase(startDate, endDate).fold( - onSuccess = { - reduce { state.copy(routineSchedule = it.toUiModel(), loadingCount = state.loadingCount - 1) } - }, - onFailure = { - Log.e("HomeViewModel", "루틴 가져오기 실패: ${it.message}") - reduce { state.copy(loadingCount = state.loadingCount - 1) } - }, - ) - } - } - - private fun syncRoutineChangesForDate(dateKey: String) { - intent { - val dateChanges = pendingChangesByDate.remove(dateKey) - if (dateChanges.isNullOrEmpty()) return@intent - - val changesToSync = dateChanges.values.toList() - val syncRequest = RoutineCompletionInfos(routineCompletionInfos = changesToSync) - - routineCompletionUseCase(syncRequest).fold( - onSuccess = {}, - onFailure = { error -> - fetchWeeklyRoutines(state.currentWeeks) - }, - ) - } - } } diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/screen/home/contract/HomeState.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/screen/home/contract/HomeState.kt index e96ba7b7..78a5f9e0 100644 --- a/presentation/src/main/java/com/threegap/bitnagil/presentation/screen/home/contract/HomeState.kt +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/screen/home/contract/HomeState.kt @@ -7,27 +7,24 @@ import com.threegap.bitnagil.presentation.screen.home.util.getCurrentWeekDays import java.time.LocalDate data class HomeState( - val loadingCount: Int, val userNickname: String, val dailyEmotion: DailyEmotionUiModel, val selectedDate: LocalDate, val currentWeeks: List, val routineSchedule: RoutineScheduleUiModel, + val showSyncErrorDialog: Boolean, ) { - val isLoading: Boolean - get() = loadingCount > 0 - val selectedDateRoutines: List get() = routineSchedule.dailyRoutines[selectedDate.toString()]?.routines ?: emptyList() companion object { val INIT = HomeState( - loadingCount = 0, userNickname = "", dailyEmotion = DailyEmotionUiModel.INIT, selectedDate = LocalDate.now(), currentWeeks = LocalDate.now().getCurrentWeekDays(), routineSchedule = RoutineScheduleUiModel.INIT, + showSyncErrorDialog = false, ) } } diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/screen/routinelist/RoutineListViewModel.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/screen/routinelist/RoutineListViewModel.kt index bf90fd1b..2c317419 100644 --- a/presentation/src/main/java/com/threegap/bitnagil/presentation/screen/routinelist/RoutineListViewModel.kt +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/screen/routinelist/RoutineListViewModel.kt @@ -3,18 +3,15 @@ package com.threegap.bitnagil.presentation.screen.routinelist import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import com.threegap.bitnagil.domain.routine.usecase.DeleteRoutineForDayUseCase import com.threegap.bitnagil.domain.routine.usecase.DeleteRoutineUseCase -import com.threegap.bitnagil.domain.routine.usecase.FetchWeeklyRoutinesUseCase -import com.threegap.bitnagil.domain.routine.usecase.GetWriteRoutineEventFlowUseCase +import com.threegap.bitnagil.domain.routine.usecase.ObserveWeeklyRoutinesUseCase import com.threegap.bitnagil.presentation.screen.home.util.getCurrentWeekDays import com.threegap.bitnagil.presentation.screen.routinelist.contract.RoutineListSideEffect import com.threegap.bitnagil.presentation.screen.routinelist.contract.RoutineListState import com.threegap.bitnagil.presentation.screen.routinelist.model.RoutineUiModel import com.threegap.bitnagil.presentation.screen.routinelist.model.toUiModel import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.launch import org.orbitmvi.orbit.Container import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.viewmodel.container @@ -24,13 +21,20 @@ import javax.inject.Inject @HiltViewModel class RoutineListViewModel @Inject constructor( savedStateHandle: SavedStateHandle, - private val fetchWeeklyRoutinesUseCase: FetchWeeklyRoutinesUseCase, + private val observeWeeklyRoutinesUseCase: ObserveWeeklyRoutinesUseCase, private val deleteRoutineUseCase: DeleteRoutineUseCase, private val deleteRoutineForDayUseCase: DeleteRoutineForDayUseCase, - private val getWriteRoutineEventFlowUseCase: GetWriteRoutineEventFlowUseCase, ) : ContainerHost, ViewModel() { - override val container: Container = container(initialState = RoutineListState.INIT) + override val container: Container = + container( + initialState = RoutineListState.INIT, + buildSettings = { repeatOnSubscribedStopTimeout = 5_000L }, + onCreate = { + updateDate(selectedDate) + observeWeeklyRoutines() + } + ) private val selectedDate = savedStateHandle.get("selectedDate") ?.takeIf { it.isNotBlank() } @@ -39,12 +43,6 @@ class RoutineListViewModel @Inject constructor( } ?: LocalDate.now() - init { - updateDate(selectedDate) - fetchRoutines() - observeRoutineChanges() - } - fun updateDate(selectedDate: LocalDate) { intent { reduce { state.copy(selectedDate = selectedDate) } @@ -75,29 +73,16 @@ class RoutineListViewModel @Inject constructor( } } - private fun observeRoutineChanges() { - viewModelScope.launch { - getWriteRoutineEventFlowUseCase().collect { - fetchRoutines() - } - } - } - - private fun fetchRoutines() { + private fun observeWeeklyRoutines() { intent { - reduce { state.copy(isLoading = true) } - val currentWeek = state.selectedDate.getCurrentWeekDays() - val startDate = currentWeek.first().toString() - val endDate = currentWeek.last().toString() - fetchWeeklyRoutinesUseCase(startDate, endDate).fold( - onSuccess = { routineSchedule -> - reduce { state.copy(isLoading = false, routines = routineSchedule.toUiModel()) } - }, - onFailure = { - Log.e("RoutineListViewModel", "루틴 가져오기 실패: ${it.message}") - reduce { state.copy(isLoading = false) } - }, - ) + repeatOnSubscription { + reduce { state.copy(isLoading = true) } + val weekDays = state.selectedDate.getCurrentWeekDays() + observeWeeklyRoutinesUseCase(weekDays.first().toString(), weekDays.last().toString()) + .collect { schedule -> + reduce { state.copy(isLoading = false, routines = schedule.toUiModel()) } + } + } } } @@ -107,8 +92,7 @@ class RoutineListViewModel @Inject constructor( reduce { state.copy(isLoading = true) } deleteRoutineUseCase(selectedRoutine.routineId).fold( onSuccess = { - fetchRoutines() - reduce { state.copy(deleteConfirmBottomSheetVisible = false) } + reduce { state.copy(isLoading = false, deleteConfirmBottomSheetVisible = false) } postSideEffect(RoutineListSideEffect.ShowToast("삭제가 완료되었습니다.")) }, onFailure = { @@ -121,12 +105,11 @@ class RoutineListViewModel @Inject constructor( fun deleteRoutineForToday() { intent { - reduce { state.copy(isLoading = true) } val selectedRoutine = state.selectedRoutine ?: return@intent + reduce { state.copy(isLoading = true) } deleteRoutineForDayUseCase(selectedRoutine.routineId).fold( onSuccess = { - fetchRoutines() - reduce { state.copy(deleteConfirmBottomSheetVisible = false) } + reduce { state.copy(isLoading = false, deleteConfirmBottomSheetVisible = false) } postSideEffect(RoutineListSideEffect.ShowToast("삭제가 완료되었습니다.")) }, onFailure = { diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/screen/withdrawal/component/WithdrawalConfirmDialog.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/screen/withdrawal/component/WithdrawalConfirmDialog.kt index c5339341..ce0ef92c 100644 --- a/presentation/src/main/java/com/threegap/bitnagil/presentation/screen/withdrawal/component/WithdrawalConfirmDialog.kt +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/screen/withdrawal/component/WithdrawalConfirmDialog.kt @@ -1,69 +1,24 @@ package com.threegap.bitnagil.presentation.screen.withdrawal.component -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.DialogProperties -import com.threegap.bitnagil.designsystem.BitnagilTheme -import com.threegap.bitnagil.designsystem.modifier.clickableWithoutRipple +import com.threegap.bitnagil.designsystem.component.block.BitnagilConfirmDialog -@OptIn(ExperimentalMaterial3Api::class) @Composable fun WithdrawalConfirmDialog( onConfirm: () -> Unit, modifier: Modifier = Modifier, ) { - BasicAlertDialog( - onDismissRequest = {}, + BitnagilConfirmDialog( + title = "탈퇴가 완료되었어요", + description = "이용해 주셔서 감사합니다. 언제든 다시\n돌아오실 수 있어요:)", + confirmButtonText = "확인", + onConfirm = onConfirm, modifier = modifier, - properties = DialogProperties( - dismissOnBackPress = false, - dismissOnClickOutside = false, - ), - ) { - Column( - modifier = Modifier - .background( - color = BitnagilTheme.colors.white, - shape = RoundedCornerShape(12.dp), - ) - .padding(vertical = 20.dp, horizontal = 24.dp), - ) { - Text( - text = "탈퇴가 완료되었어요", - color = BitnagilTheme.colors.coolGray10, - style = BitnagilTheme.typography.subtitle1SemiBold, - modifier = Modifier.padding(bottom = 6.dp), - ) - - Text( - text = "이용해 주셔서 감사합니다. 언제든 다시\n돌아오실 수 있어요:)", - color = BitnagilTheme.colors.coolGray40, - style = BitnagilTheme.typography.body2Medium, - modifier = Modifier.padding(bottom = 18.dp), - ) - - Text( - text = "확인", - color = BitnagilTheme.colors.orange500, - style = BitnagilTheme.typography.body2Medium, - textAlign = TextAlign.End, - modifier = Modifier - .fillMaxWidth() - .clickableWithoutRipple { onConfirm() }, - ) - } - } + dismissOnBackPress = false, + dismissOnClickOutside = false, + ) } @Preview