From faea8a40694204db8780f7f954552d2a8dc00a04 Mon Sep 17 00:00:00 2001 From: wjdrjs00 Date: Thu, 26 Mar 2026 16:37:46 +0900 Subject: [PATCH 01/17] =?UTF-8?q?Refactor:=20Home=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=20Type-safe=20navigation=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/HomeBottomNavigationBar.kt | 34 ++++++--------- .../bitnagil/navigation/home/HomeNavHost.kt | 13 ++++-- .../bitnagil/navigation/home/HomeNavigator.kt | 25 ++++++++--- .../bitnagil/navigation/home/HomeRoute.kt | 42 +++++++++---------- 4 files changed, 62 insertions(+), 52 deletions(-) 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), +) From 822a734fa73737a26072dbf531d7e991d512b870 Mon Sep 17 00:00:00 2001 From: wjdrjs00 Date: Fri, 27 Mar 2026 02:21:56 +0900 Subject: [PATCH 02/17] =?UTF-8?q?Refactor:=20RoutineLocalDataSource=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20DI=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bitnagil/di/data/DataSourceModule.kt | 8 ++- .../datasource/RoutineLocalDataSource.kt | 14 +++++ .../RoutineLocalDataSourceImpl.kt | 57 +++++++++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 data/src/main/java/com/threegap/bitnagil/data/routine/datasource/RoutineLocalDataSource.kt create mode 100644 data/src/main/java/com/threegap/bitnagil/data/routine/datasourceImpl/RoutineLocalDataSourceImpl.kt 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/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 } + } +} From ca58d928787be9b4e56e4ce0b335c1dc7f3bd1f2 Mon Sep 17 00:00:00 2001 From: wjdrjs00 Date: Fri, 27 Mar 2026 02:22:17 +0900 Subject: [PATCH 03/17] =?UTF-8?q?Refactor:=20RoutineRepository=EB=A5=BC=20?= =?UTF-8?q?Flow=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EB=B0=8F=20Optimistic=20Update=20=EC=B1=85?= =?UTF-8?q?=EC=9E=84=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repositoryImpl/RoutineRepositoryImpl.kt | 118 ++++++++++++++---- .../domain/routine/model/WriteRoutineEvent.kt | 6 - .../routine/repository/RoutineRepository.kt | 8 +- .../usecase/FetchWeeklyRoutinesUseCase.kt | 13 -- .../GetWriteRoutineEventFlowUseCase.kt | 12 -- .../usecase/RoutineCompletionUseCase.kt | 12 -- 6 files changed, 96 insertions(+), 73 deletions(-) delete mode 100644 domain/src/main/java/com/threegap/bitnagil/domain/routine/model/WriteRoutineEvent.kt delete mode 100644 domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/FetchWeeklyRoutinesUseCase.kt delete mode 100644 domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/GetWriteRoutineEventFlowUseCase.kt delete mode 100644 domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/RoutineCompletionUseCase.kt 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..b365ec22 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,125 @@ package com.threegap.bitnagil.data.routine.repositoryImpl +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.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow -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 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 pendingChangesByDate = mutableMapOf>() + private val originalStatesByDate = mutableMapOf>() + private val syncTrigger = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_LATEST, + ) + + init { + @OptIn(FlowPreview::class) + repositoryScope.launch { + syncTrigger + .debounce(500L) + .collect { dateKey -> flushPendingChanges(dateKey) } + } + } + + override fun observeWeeklyRoutines(startDate: String, endDate: String): Flow = flow { + if (routineLocalDataSource.lastFetchRange != (startDate to endDate)) { + routineLocalDataSource.clearCache() + fetchAndSave(startDate, endDate) + } + emitAll( + routineLocalDataSource.routineSchedule + .onEach { if (it == null) fetchAndSave(startDate, endDate) } + .filterNotNull(), + ) + } + + private suspend fun fetchAndSave(startDate: String, endDate: String) { routineRemoteDataSource.fetchWeeklyRoutines(startDate, endDate) - .map { it.toDomain() } + .onSuccess { routineLocalDataSource.saveSchedule(it.toDomain(), startDate, endDate) } + .onFailure { throw it } + } + + override suspend fun applyRoutineToggle(dateKey: String, routineId: String, completionInfo: RoutineCompletionInfo) { + if (originalStatesByDate[dateKey]?.containsKey(routineId) != true) { + routineLocalDataSource.getCompletionInfo(dateKey, routineId)?.let { + originalStatesByDate.getOrPut(dateKey) { mutableMapOf() }[routineId] = it + } + } + routineLocalDataSource.applyOptimisticToggle(dateKey, routineId, completionInfo) + pendingChangesByDate.getOrPut(dateKey) { mutableMapOf() }[routineId] = completionInfo + syncTrigger.emit(dateKey) + } - override suspend fun syncRoutineCompletion(routineCompletionInfos: RoutineCompletionInfos): Result = - routineRemoteDataSource.syncRoutineCompletion(routineCompletionInfos.toDto()) + private suspend fun flushPendingChanges(dateKey: String) { + val snapshot = pendingChangesByDate.remove(dateKey) + val originals = originalStatesByDate.remove(dateKey) + val actualChanges = snapshot?.filter { (routineId, pending) -> originals?.get(routineId) != pending } + if (actualChanges.isNullOrEmpty()) return + + val syncRequest = RoutineCompletionInfos(routineCompletionInfos = actualChanges.values.toList()) + routineRemoteDataSource.syncRoutineCompletion(syncRequest.toDto()) + .onFailure { + val range = routineLocalDataSource.lastFetchRange ?: return + fetchAndSave(range.first, range.second) + } + } + + 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/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..37caff6b 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,18 @@ 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 interface RoutineRepository { - suspend fun fetchWeeklyRoutines(startDate: String, endDate: String): Result - suspend fun syncRoutineCompletion(routineCompletionInfos: RoutineCompletionInfos): Result + 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/FetchWeeklyRoutinesUseCase.kt b/domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/FetchWeeklyRoutinesUseCase.kt deleted file mode 100644 index 084b81e5..00000000 --- a/domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/FetchWeeklyRoutinesUseCase.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.threegap.bitnagil.domain.routine.usecase - -import com.threegap.bitnagil.domain.routine.model.RoutineSchedule -import com.threegap.bitnagil.domain.routine.repository.RoutineRepository -import javax.inject.Inject - -class FetchWeeklyRoutinesUseCase @Inject constructor( - private val routineRepository: RoutineRepository, -) { - suspend operator fun invoke(startDate: String, endDate: String): Result { - return routineRepository.fetchWeeklyRoutines(startDate, endDate) - } -} 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/GetWriteRoutineEventFlowUseCase.kt deleted file mode 100644 index 46ee1552..00000000 --- a/domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/GetWriteRoutineEventFlowUseCase.kt +++ /dev/null @@ -1,12 +0,0 @@ -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( - private val routineRepository: RoutineRepository, -) { - suspend operator fun invoke(): Flow = routineRepository.getWriteRoutineEventFlow() -} 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) -} From efae7ac43be28a497fd984c20a11b088508f98ae Mon Sep 17 00:00:00 2001 From: wjdrjs00 Date: Fri, 27 Mar 2026 02:22:26 +0900 Subject: [PATCH 04/17] =?UTF-8?q?Refactor:=20ToggleStrategy=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=8F=99=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A3=A8=ED=8B=B4=20UseCase=20=EC=9E=AC=EA=B5=AC?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/routine}/model/ToggleStrategy.kt | 2 +- .../usecase/ApplyRoutineToggleUseCase.kt | 44 +++++++++++++++++++ .../usecase/ObserveWeeklyRoutinesUseCase.kt | 13 ++++++ 3 files changed, 58 insertions(+), 1 deletion(-) rename {presentation/src/main/java/com/threegap/bitnagil/presentation/screen/home => domain/src/main/java/com/threegap/bitnagil/domain/routine}/model/ToggleStrategy.kt (67%) create mode 100644 domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/ApplyRoutineToggleUseCase.kt create mode 100644 domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/ObserveWeeklyRoutinesUseCase.kt 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/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/ObserveWeeklyRoutinesUseCase.kt b/domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/ObserveWeeklyRoutinesUseCase.kt new file mode 100644 index 00000000..d8b0d029 --- /dev/null +++ b/domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/ObserveWeeklyRoutinesUseCase.kt @@ -0,0 +1,13 @@ +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 ObserveWeeklyRoutinesUseCase @Inject constructor( + private val routineRepository: RoutineRepository, +) { + operator fun invoke(startDate: String, endDate: String): Flow = + routineRepository.observeWeeklyRoutines(startDate, endDate) +} From a0ce38bca6aa73983dc4cab7b04382d2757e5f68 Mon Sep 17 00:00:00 2001 From: wjdrjs00 Date: Fri, 27 Mar 2026 02:22:31 +0900 Subject: [PATCH 05/17] =?UTF-8?q?Refactor:=20HomeViewModel=20=EB=8B=A8?= =?UTF-8?q?=EC=88=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/screen/home/HomeViewModel.kt | 254 ++++-------------- .../screen/home/contract/HomeState.kt | 5 - 2 files changed, 53 insertions(+), 206 deletions(-) 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..7fb2af26 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,22 @@ 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.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 +27,29 @@ 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, ) : ContainerHost, ViewModel() { override val container: Container = container( initialState = HomeState.INIT, buildSettings = { repeatOnSubscribedStopTimeout = 5_000L }, + onCreate = { + observeDailyEmotion() + observeUserProfile() + observeWeeklyRoutines() + }, ) - 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 +57,44 @@ 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) } } } - fun getNextWeek() { + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) + private fun observeWeeklyRoutines() { intent { - val currentDateKey = state.selectedDate.toString() - if (pendingChangesByDate.containsKey(currentDateKey)) { - syncRoutineChangesForDate(currentDateKey) + 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 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 +102,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 +109,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 routine = state.selectedDateRoutines.find { it.id == routineId } ?: return@subIntent - val updatedDailySchedule = dailySchedule.copy( - routines = updatedRoutines, - isAllCompleted = updatedRoutines.all { it.isCompleted }, - ) - - val newSchedule = state.routineSchedule.copy( - dailyRoutines = state.routineSchedule.dailyRoutines + (dateKey to updatedDailySchedule), - ) - - 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 +158,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..6f672851 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,22 +7,17 @@ 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 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(), From 1ddcf38469ff0979432a26cf630f303572f45fc5 Mon Sep 17 00:00:00 2001 From: wjdrjs00 Date: Fri, 27 Mar 2026 02:22:35 +0900 Subject: [PATCH 06/17] =?UTF-8?q?Refactor:=20RoutineListViewModel=20?= =?UTF-8?q?=EB=8B=A8=EC=88=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routinelist/RoutineListViewModel.kt | 46 +++++-------------- 1 file changed, 12 insertions(+), 34 deletions(-) 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..f16de3b1 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,10 +21,9 @@ 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) @@ -41,8 +37,7 @@ class RoutineListViewModel @Inject constructor( init { updateDate(selectedDate) - fetchRoutines() - observeRoutineChanges() + observeWeeklyRoutines() } fun updateDate(selectedDate: LocalDate) { @@ -75,29 +70,14 @@ 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) } - }, - ) + val weekDays = state.selectedDate.getCurrentWeekDays() + observeWeeklyRoutinesUseCase(weekDays.first().toString(), weekDays.last().toString()) + .collect { schedule -> + reduce { state.copy(isLoading = false, routines = schedule.toUiModel()) } + } } } @@ -107,8 +87,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 +100,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 = { From ad48939ef9a557dd5d1565c178b5084cdc5b43d8 Mon Sep 17 00:00:00 2001 From: wjdrjs00 Date: Fri, 27 Mar 2026 02:22:40 +0900 Subject: [PATCH 07/17] =?UTF-8?q?Refactor:=20OnBoarding=20=EB=A3=A8?= =?UTF-8?q?=ED=8B=B4=20=EC=BA=90=EC=8B=9C=20=EB=AC=B4=ED=9A=A8=ED=99=94=20?= =?UTF-8?q?data=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EC=9C=84=EC=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repositoryImpl/OnBoardingRepositoryImpl.kt | 15 +++------------ .../model/OnBoardingRecommendRoutineEvent.kt | 5 ----- .../onboarding/repository/OnBoardingRepository.kt | 3 --- ...tOnBoardingRecommendRoutineEventFlowUseCase.kt | 14 -------------- 4 files changed, 3 insertions(+), 34 deletions(-) delete mode 100644 domain/src/main/java/com/threegap/bitnagil/domain/onboarding/model/OnBoardingRecommendRoutineEvent.kt delete mode 100644 domain/src/main/java/com/threegap/bitnagil/domain/onboarding/usecase/GetOnBoardingRecommendRoutineEventFlowUseCase.kt 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/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() - } -} From 3b674a66baa5ab29e31b4019e51ac23c444c4c96 Mon Sep 17 00:00:00 2001 From: wjdrjs00 Date: Fri, 27 Mar 2026 02:22:53 +0900 Subject: [PATCH 08/17] =?UTF-8?q?Test:=20RoutineRepositoryImpl=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bitnagil/di/data/CoroutineModule.kt | 18 ++ .../bitnagil/data/di/CoroutineQualifier.kt | 7 + .../RoutineRepositoryImplTest.kt | 238 ++++++++++++++++++ 3 files changed, 263 insertions(+) create mode 100644 app/src/main/java/com/threegap/bitnagil/di/data/CoroutineModule.kt create mode 100644 data/src/main/java/com/threegap/bitnagil/data/di/CoroutineQualifier.kt create mode 100644 data/src/test/java/com/threegap/bitnagil/data/routine/repositoryImpl/RoutineRepositoryImplTest.kt 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/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/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..5acc4bde --- /dev/null +++ b/data/src/test/java/com/threegap/bitnagil/data/routine/repositoryImpl/RoutineRepositoryImplTest.kt @@ -0,0 +1,238 @@ +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.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 `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() // repositoryScope의 syncTrigger collector 시작 + 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(501L) + + // sync 실패 후 fetchAndSave 호출 → 서버 값(emptyMap)으로 덮어씀 + assertEquals(1, remoteDataSource.fetchCount.get()) + 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()) + } + + // --- Helpers --- + + private fun setupCacheWithRoutine(isCompleted: Boolean): Pair { + val dateKey = "2024-01-01" + val routineId = "routine1" + val schedule = RoutineSchedule( + dailyRoutines = 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(schedule, 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) + 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 { + syncCount.incrementAndGet() + return 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) + } +} From d4abc0e9724e9f2e990fea8fa8d6078989abfd5e Mon Sep 17 00:00:00 2001 From: wjdrjs00 Date: Fri, 27 Mar 2026 02:40:41 +0900 Subject: [PATCH 09/17] =?UTF-8?q?Refactor:=20syncTrigger=EC=9D=98=20Buffer?= =?UTF-8?q?Overflow=20=EC=A0=84=EB=9E=B5=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/routine/repositoryImpl/RoutineRepositoryImpl.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b365ec22..7a069f6e 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 @@ -40,7 +40,7 @@ class RoutineRepositoryImpl @Inject constructor( private val originalStatesByDate = mutableMapOf>() private val syncTrigger = MutableSharedFlow( extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_LATEST, + onBufferOverflow = BufferOverflow.DROP_OLDEST, ) init { From 836b342578a7d2654bc28ee2483aad88b9a9e05c Mon Sep 17 00:00:00 2001 From: wjdrjs00 Date: Fri, 27 Mar 2026 12:01:30 +0900 Subject: [PATCH 10/17] =?UTF-8?q?Refactor:=20=ED=8E=9C=EB=94=A9=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=82=AC=ED=95=AD=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EB=B0=8F=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=EC=A0=9C?= =?UTF-8?q?=EC=96=B4=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repositoryImpl/RoutineRepositoryImpl.kt | 52 ++++++++++------ .../RoutineRepositoryImplTest.kt | 60 ++++++++++++------- 2 files changed, 71 insertions(+), 41 deletions(-) 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 7a069f6e..e14ea1cd 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 @@ -25,6 +25,8 @@ 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 @@ -36,9 +38,10 @@ class RoutineRepositoryImpl @Inject constructor( ) : RoutineRepository { private val repositoryScope = CoroutineScope(SupervisorJob() + dispatcher) + private val mutex = Mutex() private val pendingChangesByDate = mutableMapOf>() private val originalStatesByDate = mutableMapOf>() - private val syncTrigger = MutableSharedFlow( + private val syncTrigger = MutableSharedFlow( extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST, ) @@ -48,7 +51,7 @@ class RoutineRepositoryImpl @Inject constructor( repositoryScope.launch { syncTrigger .debounce(500L) - .collect { dateKey -> flushPendingChanges(dateKey) } + .collect { flushAllPendingChanges() } } } @@ -71,28 +74,41 @@ class RoutineRepositoryImpl @Inject constructor( } override suspend fun applyRoutineToggle(dateKey: String, routineId: String, completionInfo: RoutineCompletionInfo) { - if (originalStatesByDate[dateKey]?.containsKey(routineId) != true) { - routineLocalDataSource.getCompletionInfo(dateKey, routineId)?.let { - originalStatesByDate.getOrPut(dateKey) { mutableMapOf() }[routineId] = it + 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) - pendingChangesByDate.getOrPut(dateKey) { mutableMapOf() }[routineId] = completionInfo - syncTrigger.emit(dateKey) + syncTrigger.emit(Unit) } - private suspend fun flushPendingChanges(dateKey: String) { - val snapshot = pendingChangesByDate.remove(dateKey) - val originals = originalStatesByDate.remove(dateKey) - val actualChanges = snapshot?.filter { (routineId, pending) -> originals?.get(routineId) != pending } - if (actualChanges.isNullOrEmpty()) return - - val syncRequest = RoutineCompletionInfos(routineCompletionInfos = actualChanges.values.toList()) - routineRemoteDataSource.syncRoutineCompletion(syncRequest.toDto()) - .onFailure { - val range = routineLocalDataSource.lastFetchRange ?: return - fetchAndSave(range.first, range.second) + 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() + } + + for ((dateKey, pendingForDate) in snapshot) { + val actualChanges = pendingForDate.filter { (routineId, pending) -> + originals[dateKey]?.get(routineId) != pending } + if (actualChanges.isEmpty()) continue + + val syncRequest = RoutineCompletionInfos(routineCompletionInfos = actualChanges.values.toList()) + routineRemoteDataSource.syncRoutineCompletion(syncRequest.toDto()) + .onFailure { + val range = routineLocalDataSource.lastFetchRange ?: return@onFailure + fetchAndSave(range.first, range.second) + } + } } private suspend fun 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 index 5acc4bde..e81be790 100644 --- 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 @@ -114,6 +114,20 @@ class RoutineRepositoryImplTest { 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(2, remoteDataSource.syncCount.get()) + } + @Test fun `A→B→A 토글 시 최종 상태가 원래와 동일하면 API를 호출하지 않아야 한다`() = runTest { repository = createRepository(testScheduler) @@ -175,33 +189,33 @@ class RoutineRepositoryImplTest { // --- Helpers --- - private fun setupCacheWithRoutine(isCompleted: Boolean): Pair { - val dateKey = "2024-01-01" - val routineId = "routine1" - val schedule = RoutineSchedule( - dailyRoutines = 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, - ), + 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, ), + isAllCompleted = isCompleted, ), ) - localDataSource.saveSchedule(schedule, dateKey, dateKey) + localDataSource.saveSchedule(RoutineSchedule(newDailyRoutines), dateKey, dateKey) return dateKey to routineId } From baa457256d12c74374344664bb8f4206639c5159 Mon Sep 17 00:00:00 2001 From: wjdrjs00 Date: Fri, 27 Mar 2026 13:49:01 +0900 Subject: [PATCH 11/17] =?UTF-8?q?Refactor:=20syncError=20=EB=85=B8?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=8F=20ObserveRoutineSyncErrorUseCase=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routine/repositoryImpl/RoutineRepositoryImpl.kt | 5 +++++ .../domain/routine/repository/RoutineRepository.kt | 2 ++ .../routine/usecase/ObserveRoutineSyncErrorUseCase.kt | 11 +++++++++++ 3 files changed, 18 insertions(+) create mode 100644 domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/ObserveRoutineSyncErrorUseCase.kt 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 e14ea1cd..301cf54b 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 @@ -19,6 +19,8 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.BufferOverflow 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 @@ -41,6 +43,8 @@ class RoutineRepositoryImpl @Inject constructor( 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, @@ -105,6 +109,7 @@ class RoutineRepositoryImpl @Inject constructor( val syncRequest = RoutineCompletionInfos(routineCompletionInfos = actualChanges.values.toList()) routineRemoteDataSource.syncRoutineCompletion(syncRequest.toDto()) .onFailure { + _syncError.emit(Unit) val range = routineLocalDataSource.lastFetchRange ?: return@onFailure fetchAndSave(range.first, range.second) } 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 37caff6b..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 @@ -6,8 +6,10 @@ 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 kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow interface RoutineRepository { + 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 diff --git a/domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/ObserveRoutineSyncErrorUseCase.kt b/domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/ObserveRoutineSyncErrorUseCase.kt new file mode 100644 index 00000000..83f9a764 --- /dev/null +++ b/domain/src/main/java/com/threegap/bitnagil/domain/routine/usecase/ObserveRoutineSyncErrorUseCase.kt @@ -0,0 +1,11 @@ +package com.threegap.bitnagil.domain.routine.usecase + +import com.threegap.bitnagil.domain.routine.repository.RoutineRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class ObserveRoutineSyncErrorUseCase @Inject constructor( + private val routineRepository: RoutineRepository, +) { + operator fun invoke(): Flow = routineRepository.syncError +} From 4ec0a5b8d60c03aba65a50f81ce4f6871414955b Mon Sep 17 00:00:00 2001 From: wjdrjs00 Date: Fri, 27 Mar 2026 13:49:25 +0900 Subject: [PATCH 12/17] =?UTF-8?q?Refactor:=20BitnagilConfirmDialog=20?= =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B8=EC=8B=9C=EC=8A=A4=ED=85=9C=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../component/block/BitnagilConfirmDialog.kt | 85 +++++++++++++++++++ .../component/WithdrawalConfirmDialog.kt | 63 ++------------ 2 files changed, 94 insertions(+), 54 deletions(-) create mode 100644 core/designsystem/src/main/java/com/threegap/bitnagil/designsystem/component/block/BitnagilConfirmDialog.kt 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/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 From e7a6b02604152a9744d0a381152cf7db8d4dfe5d Mon Sep 17 00:00:00 2001 From: wjdrjs00 Date: Fri, 27 Mar 2026 13:49:44 +0900 Subject: [PATCH 13/17] =?UTF-8?q?Refactor:=20=EB=A1=A4=EB=B0=B1=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EB=8B=A4=EC=9D=B4=EC=96=BC=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20HomeState=EB=A1=9C=20=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/screen/home/HomeScreen.kt | 10 ++++++++++ .../presentation/screen/home/HomeViewModel.kt | 19 +++++++++++++++++++ .../screen/home/contract/HomeState.kt | 2 ++ 3 files changed, 31 insertions(+) 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 7fb2af26..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 @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import com.threegap.bitnagil.domain.emotion.usecase.ObserveDailyEmotionUseCase 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 @@ -31,6 +32,7 @@ class HomeViewModel @Inject constructor( private val observeUserProfileUseCase: ObserveUserProfileUseCase, private val observeDailyEmotionUseCase: ObserveDailyEmotionUseCase, private val applyRoutineToggleUseCase: ApplyRoutineToggleUseCase, + private val observeRoutineSyncErrorUseCase: ObserveRoutineSyncErrorUseCase, ) : ContainerHost, ViewModel() { override val container: Container = @@ -41,6 +43,7 @@ class HomeViewModel @Inject constructor( observeDailyEmotion() observeUserProfile() observeWeeklyRoutines() + observeRoutineSyncError() }, ) @@ -87,6 +90,22 @@ class HomeViewModel @Inject constructor( } } + private fun observeRoutineSyncError() { + intent { + 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) } 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 6f672851..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 @@ -12,6 +12,7 @@ data class HomeState( val selectedDate: LocalDate, val currentWeeks: List, val routineSchedule: RoutineScheduleUiModel, + val showSyncErrorDialog: Boolean, ) { val selectedDateRoutines: List get() = routineSchedule.dailyRoutines[selectedDate.toString()]?.routines ?: emptyList() @@ -23,6 +24,7 @@ data class HomeState( selectedDate = LocalDate.now(), currentWeeks = LocalDate.now().getCurrentWeekDays(), routineSchedule = RoutineScheduleUiModel.INIT, + showSyncErrorDialog = false, ) } } From 9e0fcaf7a0cd4ff1fa9cb129c3985e4daba907cb Mon Sep 17 00:00:00 2001 From: wjdrjs00 Date: Sun, 29 Mar 2026 18:40:03 +0900 Subject: [PATCH 14/17] =?UTF-8?q?Refactor:=20=EB=8F=99=EA=B8=B0=ED=99=94?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EB=B0=B0=EC=B9=98=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 날짜별로 반복 호출하던 syncRoutineCompletion API를 단일 호출로 통합하여 동기화 로직 개선 --- .../repositoryImpl/RoutineRepositoryImpl.kt | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) 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 301cf54b..d1c0f384 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 @@ -100,20 +100,21 @@ class RoutineRepositoryImpl @Inject constructor( originalStatesByDate.clear() } - for ((dateKey, pendingForDate) in snapshot) { - val actualChanges = pendingForDate.filter { (routineId, pending) -> + val actualChanges = snapshot.flatMap { (dateKey, pendingForDate) -> + pendingForDate.filter { (routineId, pending) -> originals[dateKey]?.get(routineId) != pending - } - if (actualChanges.isEmpty()) continue - - val syncRequest = RoutineCompletionInfos(routineCompletionInfos = actualChanges.values.toList()) - routineRemoteDataSource.syncRoutineCompletion(syncRequest.toDto()) - .onFailure { - _syncError.emit(Unit) - val range = routineLocalDataSource.lastFetchRange ?: return@onFailure - fetchAndSave(range.first, range.second) - } + }.values } + + if (actualChanges.isEmpty()) return + + val syncRequest = RoutineCompletionInfos(routineCompletionInfos = actualChanges) + routineRemoteDataSource.syncRoutineCompletion(syncRequest.toDto()) + .onFailure { + _syncError.emit(Unit) + val range = routineLocalDataSource.lastFetchRange ?: return@onFailure + fetchAndSave(range.first, range.second) + } } private suspend fun refreshCache() { From 7362cdf3df784ebb06c27baaa2f89e780e866237 Mon Sep 17 00:00:00 2001 From: wjdrjs00 Date: Sun, 29 Mar 2026 19:18:12 +0900 Subject: [PATCH 15/17] =?UTF-8?q?Refactor:=20fetch=20=EB=B0=8F=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EC=97=90=EB=9F=AC=20=ED=95=B8=EB=93=A4?= =?UTF-8?q?=EB=A7=81=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fetchAndSave의 반환 타입을 Result으로 변경하고 호출부에서 예외 처리(getOrThrow) 하도록 수정 --- .../repositoryImpl/RoutineRepositoryImpl.kt | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) 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 d1c0f384..22bf4eb6 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,5 +1,6 @@ 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 @@ -62,20 +63,19 @@ class RoutineRepositoryImpl @Inject constructor( override fun observeWeeklyRoutines(startDate: String, endDate: String): Flow = flow { if (routineLocalDataSource.lastFetchRange != (startDate to endDate)) { routineLocalDataSource.clearCache() - fetchAndSave(startDate, endDate) + fetchAndSave(startDate, endDate).getOrThrow() } emitAll( routineLocalDataSource.routineSchedule - .onEach { if (it == null) fetchAndSave(startDate, endDate) } + .onEach { if (it == null) fetchAndSave(startDate, endDate).getOrThrow() } .filterNotNull(), ) } - private suspend fun fetchAndSave(startDate: String, endDate: String) { + private suspend fun fetchAndSave(startDate: String, endDate: String): Result = routineRemoteDataSource.fetchWeeklyRoutines(startDate, endDate) .onSuccess { routineLocalDataSource.saveSchedule(it.toDomain(), startDate, endDate) } - .onFailure { throw it } - } + .map { } override suspend fun applyRoutineToggle(dateKey: String, routineId: String, completionInfo: RoutineCompletionInfo) { mutex.withLock { @@ -110,10 +110,13 @@ class RoutineRepositoryImpl @Inject constructor( val syncRequest = RoutineCompletionInfos(routineCompletionInfos = actualChanges) routineRemoteDataSource.syncRoutineCompletion(syncRequest.toDto()) - .onFailure { + .onFailure { error -> _syncError.emit(Unit) val range = routineLocalDataSource.lastFetchRange ?: return@onFailure fetchAndSave(range.first, range.second) + .onFailure { rollbackError -> + Log.e("RoutineRepository", "롤백 실패: ${rollbackError.message}") + } } } From 09eb7bbabd89203b087b7f64fecf09bcfaa23111 Mon Sep 17 00:00:00 2001 From: wjdrjs00 Date: Sun, 29 Mar 2026 19:24:40 +0900 Subject: [PATCH 16/17] =?UTF-8?q?Refactor:=20=EB=A3=A8=ED=8B=B4=20?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94=20=EC=9A=94=EC=B2=AD=20=EC=8B=9C=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=9C=EB=8F=84(Retry)=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repositoryImpl/RoutineRepositoryImpl.kt | 42 +++++++++++++++---- 1 file changed, 33 insertions(+), 9 deletions(-) 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 22bf4eb6..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 @@ -18,6 +18,7 @@ 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 @@ -109,15 +110,38 @@ class RoutineRepositoryImpl @Inject constructor( if (actualChanges.isEmpty()) return val syncRequest = RoutineCompletionInfos(routineCompletionInfos = actualChanges) - routineRemoteDataSource.syncRoutineCompletion(syncRequest.toDto()) - .onFailure { error -> - _syncError.emit(Unit) - val range = routineLocalDataSource.lastFetchRange ?: return@onFailure - fetchAndSave(range.first, range.second) - .onFailure { rollbackError -> - Log.e("RoutineRepository", "롤백 실패: ${rollbackError.message}") - } - } + 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() { From 0edc72f392ec64bea40d08fd21fc266717b6f3cb Mon Sep 17 00:00:00 2001 From: wjdrjs00 Date: Sun, 29 Mar 2026 19:25:02 +0900 Subject: [PATCH 17/17] =?UTF-8?q?Test:=20RoutineRepositoryImpl=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EB=B0=8F=20?= =?UTF-8?q?=EB=94=94=EB=B0=94=EC=9A=B4=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RoutineRepositoryImplTest.kt | 48 ++++++++++++++++--- 1 file changed, 41 insertions(+), 7 deletions(-) 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 index e81be790..2053cd43 100644 --- 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 @@ -14,6 +14,7 @@ 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 @@ -125,7 +126,7 @@ class RoutineRepositoryImplTest { repository.applyRoutineToggle(dateKey2, routineId2, RoutineCompletionInfo(routineId2, routineCompleteYn = true, subRoutineCompleteYn = emptyList())) advanceTimeBy(501L) - assertEquals(2, remoteDataSource.syncCount.get()) + assertEquals(1, remoteDataSource.syncCount.get()) } @Test @@ -146,7 +147,7 @@ class RoutineRepositoryImplTest { @Test fun `sync 실패 시 서버 데이터로 로컬 캐시가 rollback되어야 한다`() = runTest { repository = createRepository(testScheduler) - runCurrent() // repositoryScope의 syncTrigger collector 시작 + runCurrent() remoteDataSource.scheduleResponse = Result.success(RoutineScheduleResponse(emptyMap())) remoteDataSource.syncResult = Result.failure(Exception("네트워크 오류")) val (dateKey, routineId) = setupCacheWithRoutine(isCompleted = false) @@ -156,10 +157,11 @@ class RoutineRepositoryImplTest { routineId = routineId, completionInfo = RoutineCompletionInfo(routineId, routineCompleteYn = true, subRoutineCompleteYn = emptyList()), ) - advanceTimeBy(501L) - // sync 실패 후 fetchAndSave 호출 → 서버 값(emptyMap)으로 덮어씀 - assertEquals(1, remoteDataSource.fetchCount.get()) + 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) } @@ -187,6 +189,33 @@ class RoutineRepositoryImplTest { 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 = @@ -225,6 +254,7 @@ class RoutineRepositoryImplTest { 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) @@ -234,8 +264,12 @@ class RoutineRepositoryImplTest { } override suspend fun syncRoutineCompletion(routineCompletionRequest: RoutineCompletionRequest): Result { - syncCount.incrementAndGet() - return syncResult + val attempt = syncCount.incrementAndGet() + return if (attempt <= syncFailCount) { + Result.failure(Exception("네트워크 오류")) + } else { + syncResult + } } override suspend fun getRoutine(routineId: String): Result =