Skip to content

Commit afecd8f

Browse files
committed
Feat: 홈 화면 ViewModel 구현
- 주간 루틴 조회 기능 구현 - 루틴 완료 상태 토글 기능 구현 - 루틴 삭제 기능 구현 (낙관적 업데이트 적용) - 루틴 정렬 기능 상태 관리 - 변경된 루틴 상태를 일정 시간 후 서버와 동기화하는 로직 구현
1 parent c6f8640 commit afecd8f

1 file changed

Lines changed: 398 additions & 0 deletions

File tree

  • presentation/src/main/java/com/threegap/bitnagil/presentation/home
Lines changed: 398 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,398 @@
1+
package com.threegap.bitnagil.presentation.home
2+
3+
import android.util.Log
4+
import androidx.lifecycle.SavedStateHandle
5+
import androidx.lifecycle.viewModelScope
6+
import com.threegap.bitnagil.domain.routine.model.RoutineCompletion
7+
import com.threegap.bitnagil.domain.routine.model.RoutineCompletionInfo
8+
import com.threegap.bitnagil.domain.routine.usecase.DeleteRoutineUseCase
9+
import com.threegap.bitnagil.domain.routine.usecase.FetchWeeklyRoutinesUseCase
10+
import com.threegap.bitnagil.domain.routine.usecase.RoutineCompletionUseCase
11+
import com.threegap.bitnagil.presentation.common.mviviewmodel.MviViewModel
12+
import com.threegap.bitnagil.presentation.home.model.HomeIntent
13+
import com.threegap.bitnagil.presentation.home.model.HomeSideEffect
14+
import com.threegap.bitnagil.presentation.home.model.HomeState
15+
import com.threegap.bitnagil.presentation.home.model.RoutineSortType
16+
import com.threegap.bitnagil.presentation.home.model.RoutineUiModel
17+
import com.threegap.bitnagil.presentation.home.model.RoutinesUiModel
18+
import com.threegap.bitnagil.presentation.home.model.toUiModel
19+
import com.threegap.bitnagil.presentation.home.util.getCurrentWeekDays
20+
import dagger.hilt.android.lifecycle.HiltViewModel
21+
import kotlinx.coroutines.FlowPreview
22+
import kotlinx.coroutines.flow.MutableSharedFlow
23+
import kotlinx.coroutines.flow.debounce
24+
import kotlinx.coroutines.flow.distinctUntilChanged
25+
import kotlinx.coroutines.flow.drop
26+
import kotlinx.coroutines.flow.map
27+
import kotlinx.coroutines.launch
28+
import org.orbitmvi.orbit.syntax.simple.SimpleSyntax
29+
import java.time.LocalDate
30+
import javax.inject.Inject
31+
32+
@HiltViewModel
33+
class HomeViewModel @Inject constructor(
34+
savedStateHandle: SavedStateHandle,
35+
private val fetchWeeklyRoutinesUseCase: FetchWeeklyRoutinesUseCase,
36+
private val routineCompletionUseCase: RoutineCompletionUseCase,
37+
private val deleteRoutineUseCase: DeleteRoutineUseCase,
38+
) : MviViewModel<HomeState, HomeSideEffect, HomeIntent>(
39+
initState = HomeState(),
40+
savedStateHandle = savedStateHandle,
41+
) {
42+
private val pendingChangesByDate = mutableMapOf<String, MutableList<RoutineCompletionInfo>>()
43+
private val backupStatesByDate = mutableMapOf<String, RoutinesUiModel>()
44+
private val routineSyncTrigger = MutableSharedFlow<LocalDate>()
45+
46+
init {
47+
observeWeekChanges()
48+
observeRoutineUpdates()
49+
observeSideEffects()
50+
fetchWeeklyRoutines(container.stateFlow.value.currentWeeks)
51+
}
52+
53+
override suspend fun SimpleSyntax<HomeState, HomeSideEffect>.reduceState(
54+
intent: HomeIntent,
55+
state: HomeState,
56+
): HomeState? {
57+
val newState = when (intent) {
58+
is HomeIntent.UpdateLoading -> {
59+
state.copy(isLoading = intent.isLoading)
60+
}
61+
62+
is HomeIntent.LoadWeeklyRoutines -> {
63+
state.copy(routines = intent.routines)
64+
}
65+
66+
is HomeIntent.OnDateSelect -> {
67+
state.copy(selectedDate = intent.date)
68+
}
69+
70+
is HomeIntent.OnNextWeekClick -> {
71+
val newWeek = state.selectedDate.plusWeeks(1).getCurrentWeekDays()
72+
state.copy(
73+
currentWeeks = newWeek,
74+
selectedDate = newWeek.first(),
75+
)
76+
}
77+
78+
is HomeIntent.OnPreviousWeekClick -> {
79+
val newWeek = state.selectedDate.minusWeeks(1).getCurrentWeekDays()
80+
state.copy(
81+
currentWeeks = newWeek,
82+
selectedDate = newWeek.first(),
83+
)
84+
}
85+
86+
is HomeIntent.OnRoutineCompletionToggle -> {
87+
val updatedState = updateMainRoutine(state, intent.routineId, intent.isCompleted)
88+
sendSideEffect(HomeSideEffect.ProcessRoutineToggle(state))
89+
updatedState
90+
}
91+
92+
is HomeIntent.OnSubRoutineCompletionToggle -> {
93+
val updatedState = updateSubRoutine(state, intent.routineId, intent.subRoutineId, intent.isCompleted)
94+
sendSideEffect(HomeSideEffect.ProcessRoutineToggle(state))
95+
updatedState
96+
}
97+
98+
is HomeIntent.OnSortTypeChange -> {
99+
val newSortType = if (state.currentSortType == intent.sortType) {
100+
RoutineSortType.TIME_ORDER
101+
} else {
102+
intent.sortType
103+
}
104+
state.copy(
105+
currentSortType = newSortType,
106+
routineSortBottomSheetVisible = false,
107+
)
108+
}
109+
110+
is HomeIntent.DeleteRoutineOptimistically -> {
111+
val updatedRoutinesByDate = state.routines.routinesByDate.mapValues { (_, routineList) ->
112+
routineList.filterNot { it.routineId == intent.routineId }
113+
}
114+
115+
state.copy(
116+
routines = RoutinesUiModel(routinesByDate = updatedRoutinesByDate),
117+
showDeleteConfirmDialog = false,
118+
deletingRoutine = null,
119+
)
120+
}
121+
122+
is HomeIntent.RestoreRoutinesAfterDeleteFailure -> {
123+
state.copy(routines = intent.backupRoutines)
124+
}
125+
126+
is HomeIntent.ConfirmRoutineDeletion -> null
127+
128+
is HomeIntent.ShowRoutineSortBottomSheet -> {
129+
state.copy(routineSortBottomSheetVisible = true)
130+
}
131+
132+
is HomeIntent.HideRoutineSortBottomSheet -> {
133+
state.copy(routineSortBottomSheetVisible = false)
134+
}
135+
136+
is HomeIntent.ShowRoutineDetailsBottomSheet -> {
137+
state.copy(
138+
routineDetailsBottomSheetVisible = true,
139+
selectedRoutine = intent.routine,
140+
)
141+
}
142+
143+
is HomeIntent.HideRoutineDetailsBottomSheet -> {
144+
state.copy(
145+
routineDetailsBottomSheetVisible = false,
146+
selectedRoutine = null,
147+
)
148+
}
149+
150+
is HomeIntent.ShowDeleteConfirmDialog -> {
151+
state.copy(
152+
showDeleteConfirmDialog = true,
153+
deletingRoutine = intent.routine,
154+
)
155+
}
156+
157+
is HomeIntent.HideDeleteConfirmDialog -> {
158+
state.copy(
159+
showDeleteConfirmDialog = false,
160+
deletingRoutine = null,
161+
)
162+
}
163+
}
164+
return newState
165+
}
166+
167+
@OptIn(FlowPreview::class)
168+
private fun observeWeekChanges() {
169+
viewModelScope.launch {
170+
container.stateFlow
171+
.map { it.currentWeeks }
172+
.distinctUntilChanged()
173+
.drop(1)
174+
.debounce(500L)
175+
.collect { newWeeks ->
176+
fetchWeeklyRoutines(newWeeks)
177+
}
178+
}
179+
}
180+
181+
private fun observeSideEffects() {
182+
viewModelScope.launch {
183+
sideEffectFlow.collect { sideEffect ->
184+
when (sideEffect) {
185+
is HomeSideEffect.ProcessRoutineToggle -> {
186+
handleRoutineToggle(sideEffect.originalState)
187+
}
188+
}
189+
}
190+
}
191+
}
192+
193+
@OptIn(FlowPreview::class)
194+
private fun observeRoutineUpdates() {
195+
viewModelScope.launch {
196+
routineSyncTrigger
197+
.debounce(2000L)
198+
.collect { date ->
199+
syncRoutineChangesForDate(date)
200+
}
201+
}
202+
}
203+
204+
private fun fetchWeeklyRoutines(currentWeeks: List<LocalDate>) {
205+
sendIntent(HomeIntent.UpdateLoading(true))
206+
val startDate = currentWeeks.first().toString()
207+
val endDate = currentWeeks.last().toString()
208+
viewModelScope.launch {
209+
fetchWeeklyRoutinesUseCase(startDate, endDate).fold(
210+
onSuccess = { routines ->
211+
val routinesUiModel = routines.toUiModel()
212+
sendIntent(HomeIntent.LoadWeeklyRoutines(routinesUiModel))
213+
sendIntent(HomeIntent.UpdateLoading(false))
214+
},
215+
onFailure = { error ->
216+
Log.e("HomeViewModel", "루틴 가져오기 실패: ${error.message}")
217+
sendIntent(HomeIntent.UpdateLoading(false))
218+
},
219+
)
220+
}
221+
}
222+
223+
private fun handleRoutineToggle(originalState: HomeState) {
224+
val selectedDate = originalState.selectedDate
225+
val dateKey = selectedDate.toString()
226+
227+
if (!backupStatesByDate.containsKey(dateKey)) {
228+
backupStatesByDate[dateKey] = originalState.routines
229+
}
230+
231+
val currentState = container.stateFlow.value
232+
val originalRoutines = backupStatesByDate[dateKey] ?: originalState.routines
233+
val changes = calculateStateChanges(originalRoutines, currentState.routines, selectedDate)
234+
235+
if (changes.isNotEmpty()) {
236+
pendingChangesByDate[dateKey] = changes.toMutableList()
237+
viewModelScope.launch {
238+
routineSyncTrigger.emit(selectedDate)
239+
}
240+
} else {
241+
pendingChangesByDate.remove(dateKey)
242+
}
243+
}
244+
245+
private fun updateMainRoutine(
246+
state: HomeState,
247+
routineId: String,
248+
isCompleted: Boolean,
249+
): HomeState {
250+
return updateRoutinesForDate(state) { routinesForDate ->
251+
val routineIndex = routinesForDate.indexOfFirst { it.routineId == routineId }
252+
if (routineIndex == -1) return@updateRoutinesForDate false
253+
254+
val updatedRoutine = routinesForDate[routineIndex].copy(
255+
isCompleted = isCompleted,
256+
subRoutines = routinesForDate[routineIndex].subRoutines.map { subRoutine ->
257+
subRoutine.copy(isCompleted = isCompleted)
258+
},
259+
)
260+
261+
routinesForDate[routineIndex] = updatedRoutine
262+
true
263+
}
264+
}
265+
266+
private fun updateSubRoutine(
267+
state: HomeState,
268+
routineId: String,
269+
subRoutineId: String,
270+
isCompleted: Boolean,
271+
): HomeState {
272+
return updateRoutinesForDate(state) { routinesForDate ->
273+
val routineIndex = routinesForDate.indexOfFirst { it.routineId == routineId }
274+
if (routineIndex == -1) return@updateRoutinesForDate false
275+
276+
val routine = routinesForDate[routineIndex]
277+
val updatedSubRoutines = routine.subRoutines.map { subRoutine ->
278+
if (subRoutine.subRoutineId == subRoutineId) {
279+
subRoutine.copy(isCompleted = isCompleted)
280+
} else {
281+
subRoutine
282+
}
283+
}
284+
285+
val routineCompleted = if (isCompleted) updatedSubRoutines.all { it.isCompleted } else false
286+
287+
val updatedRoutine = routine.copy(
288+
subRoutines = updatedSubRoutines,
289+
isCompleted = routineCompleted,
290+
)
291+
292+
routinesForDate[routineIndex] = updatedRoutine
293+
true
294+
}
295+
}
296+
297+
private fun updateRoutinesForDate(
298+
state: HomeState,
299+
updateLogic: (MutableList<RoutineUiModel>) -> Boolean
300+
): HomeState {
301+
val dateKey = state.selectedDate.toString()
302+
val routinesForDate = state.routines.routinesByDate[dateKey]?.toMutableList() ?: return state
303+
304+
if (!updateLogic(routinesForDate)) return state
305+
306+
val updatedRoutinesByDate = state.routines.routinesByDate.toMutableMap()
307+
updatedRoutinesByDate[dateKey] = routinesForDate
308+
309+
return state.copy(routines = RoutinesUiModel(updatedRoutinesByDate))
310+
}
311+
312+
private fun calculateStateChanges(
313+
originalRoutines: RoutinesUiModel,
314+
updatedRoutines: RoutinesUiModel,
315+
date: LocalDate,
316+
): List<RoutineCompletionInfo> {
317+
val dateKey = date.toString()
318+
val originalRoutineList = originalRoutines.routinesByDate[dateKey] ?: emptyList()
319+
val updatedRoutineList = updatedRoutines.routinesByDate[dateKey] ?: emptyList()
320+
321+
return buildList {
322+
updatedRoutineList.forEach { updatedRoutine ->
323+
val originalRoutine = originalRoutineList.find { it.routineId == updatedRoutine.routineId }
324+
325+
if (originalRoutine?.isCompleted != updatedRoutine.isCompleted) {
326+
add(
327+
RoutineCompletionInfo(
328+
routineType = updatedRoutine.routineType,
329+
routineId = updatedRoutine.routineId,
330+
historySeq = updatedRoutine.historySeq,
331+
isCompleted = updatedRoutine.isCompleted,
332+
),
333+
)
334+
}
335+
336+
updatedRoutine.subRoutines.forEach { updatedSubRoutine ->
337+
val originalSubRoutine = originalRoutine?.subRoutines
338+
?.find { it.subRoutineId == updatedSubRoutine.subRoutineId }
339+
340+
if (originalSubRoutine?.isCompleted != updatedSubRoutine.isCompleted) {
341+
add(
342+
RoutineCompletionInfo(
343+
routineType = updatedSubRoutine.routineType,
344+
routineId = updatedSubRoutine.subRoutineId,
345+
historySeq = updatedSubRoutine.historySeq,
346+
isCompleted = updatedSubRoutine.isCompleted,
347+
),
348+
)
349+
}
350+
}
351+
}
352+
}
353+
}
354+
355+
private suspend fun syncRoutineChangesForDate(date: LocalDate) {
356+
val dateKey = date.toString()
357+
val unsyncedChanges = pendingChangesByDate[dateKey] ?: return
358+
359+
if (unsyncedChanges.isEmpty()) return
360+
361+
val syncRequest = RoutineCompletion(
362+
performedDate = dateKey,
363+
routineCompletions = unsyncedChanges.toList(),
364+
)
365+
366+
routineCompletionUseCase(syncRequest).fold(
367+
onSuccess = {
368+
pendingChangesByDate.remove(dateKey)
369+
backupStatesByDate.remove(dateKey)
370+
},
371+
onFailure = { error ->
372+
Log.e("HomeViewModel", "루틴 동기화 실패: ${error.message}")
373+
val backupState = backupStatesByDate[dateKey] ?: return
374+
sendIntent(HomeIntent.LoadWeeklyRoutines(backupState))
375+
pendingChangesByDate.remove(dateKey)
376+
backupStatesByDate.remove(dateKey)
377+
sendIntent(HomeIntent.UpdateLoading(false))
378+
},
379+
)
380+
}
381+
382+
fun deleteRoutine(routineId: String) {
383+
val currentRoutines = container.stateFlow.value.routines
384+
sendIntent(HomeIntent.DeleteRoutineOptimistically(routineId))
385+
386+
viewModelScope.launch {
387+
deleteRoutineUseCase(routineId).fold(
388+
onSuccess = {
389+
sendIntent(HomeIntent.ConfirmRoutineDeletion(routineId))
390+
},
391+
onFailure = { error ->
392+
Log.e("HomeViewModel", "루틴 삭제 실패: ${error.message}")
393+
sendIntent(HomeIntent.RestoreRoutinesAfterDeleteFailure(currentRoutines))
394+
},
395+
)
396+
}
397+
}
398+
}

0 commit comments

Comments
 (0)