From 13b03d764a11273a614373d70cb20acb390f1aaf Mon Sep 17 00:00:00 2001 From: Markel15 Date: Thu, 22 Oct 2026 22:25:38 +0200 Subject: [PATCH] fix: refresh habits screen state when returning after midnight Symptom ------- Opening the Habits tab before midnight (e.g. at 23:30), leaving the app in the background without killing the process, and returning after midnight (e.g. at 00:20) left the screen in a stale / hybrid state: * Header progress (completedToday / totalHabits) and card completion states still reflected the previous day. * In NumericHabitCard, the selection dot and value counter stayed anchored on yesterday even if the card recomposed, and tapping +/- could apply yesterday's base todayValue to the new date. * In HabitCard (boolean), the new day could remain treated as a future (disabled) cell in the weekly calendar or fail to scroll across a Sunday -> Monday rollover. Root cause ---------- There was no reactive source of truth for "today" across the Habits feature; LocalDate.now() was called independently in multiple places: 1. HabitViewModel.init called getHabitsWithStatus() only once. Its default parameter (date: LocalDate = LocalDate.now()) was evaluated at ViewModel creation time and stayed captured inside the UseCase flow for the entire lifetime of the ViewModel. Returning from the background without a DB emission never re-ran the combine block, and even on a DB emission the UseCase still computed isCompletedToday, todayValue and streaks against the captured date from the previous day. 2. NumericHabitCard keyed weekStart and selectedDate only on remember(weekOffset). Because weekOffset stayed 0 across midnight, selectedDate never reset to the new day. 3. HabitScreen passed LocalDate.now() directly to incrementNumericHabit / decrementNumericHabit while reading currentValue = habitWithStatus.todayValue (computed for the stale date). 4. HabitCard read LocalDate.now() directly during composition and only seeded rememberWeekCalendarState's firstVisibleWeekDate on initial composition. Fix --- Make `today: LocalDate` part of HabitUiState.Success and drive it from a lifecycle-aware StateFlow in HabitViewModel: * HabitUiState.kt: add `today: LocalDate` to HabitUiState.Success. * HabitViewModel.kt: implement DefaultLifecycleObserver and hold a `_today = MutableStateFlow(LocalDate.now())` updated in onResume via refreshTodayIfNeeded(). Switch the UseCase subscription with `_today.flatMapLatest { today -> getHabitsWithStatus(today).map { today to it } }` so `today` and `habits` always emit together without an intermediate frame where the date has advanced but the statuses have not. * HabitScreen.kt: register the ViewModel as a lifecycle observer via DisposableEffect and pass `state.today` down to HabitCard, NumericHabitCard and the today increment/decrement callbacks. * NumericHabitCard.kt: accept `today` as a parameter and include it as a key in `remember(today)` / `remember(today, weekOffset)` for weekOffset, weekStart and selectedDate. * HabitCard.kt: accept `today` as a parameter and scroll the WeekCalendar to `today` inside a `LaunchedEffect(today)` when the date advances. * HabitViewModelTest.kt: add unit test covering the midnight rollover via refreshTodayIfNeeded(). Behavior note ------------- Because `_today` is a MutableStateFlow, resuming the app on the same calendar day is a no-op (structural equality check prevents any emission or DB re-computation). Re-computation only happens when the date has actually changed while the app was in the background. --- .../flowstate/feature/habits/HabitScreen.kt | 14 +++++-- .../flowstate/feature/habits/HabitUiState.kt | 2 + .../feature/habits/HabitViewModel.kt | 35 +++++++++++++---- .../feature/habits/components/HabitCard.kt | 7 +++- .../habits/components/NumericHabitCard.kt | 8 ++-- .../feature/habits/HabitViewModelTest.kt | 39 +++++++++++++++++++ 6 files changed, 90 insertions(+), 15 deletions(-) diff --git a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/HabitScreen.kt b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/HabitScreen.kt index 9868b15e..4774ecf8 100644 --- a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/HabitScreen.kt +++ b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/HabitScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.markel.flowstate.core.domain.HabitType import com.markel.flowstate.feature.habits.components.AddHabitSheet @@ -30,7 +31,6 @@ import com.markel.flowstate.feature.habits.components.HabitFabMenu import com.markel.flowstate.feature.habits.components.NumericHabitCard import com.markel.flowstate.feature.habits.details.components.HabitHeader import com.markel.flowstate.feature.habits.details.components.MotivationalMessage -import java.time.LocalDate import com.markel.flowstate.core.designsystem.R as DesignR import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState @@ -41,6 +41,12 @@ fun HabitScreen( viewModel: HabitViewModel = hiltViewModel(), onNavigateToDetail: (habitId: Int) -> Unit, ) { + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner, viewModel) { + lifecycleOwner.lifecycle.addObserver(viewModel) + onDispose { lifecycleOwner.lifecycle.removeObserver(viewModel) } + } + val uiState by viewModel.uiState.collectAsStateWithLifecycle() Box( @@ -121,6 +127,7 @@ fun HabitScreen( habitWithStatus = habitWithStatus, weekEntries = state.weekEntriesByHabit[habitWithStatus.habit.id] ?: emptySet(), + today = state.today, onToggleDay = { date -> viewModel.toggleBooleanHabitOnDate( habitWithStatus.habit.id, @@ -154,10 +161,11 @@ fun HabitScreen( habitWithStatus = habitWithStatus, allEntries = state.numericEntriesByHabit[habitWithStatus.habit.id] ?: emptyList(), + today = state.today, onIncrementToday = { viewModel.incrementNumericHabit( habitId = habitWithStatus.habit.id, - date = LocalDate.now(), + date = state.today, currentValue = habitWithStatus.todayValue, step = habitWithStatus.habit.step ) @@ -165,7 +173,7 @@ fun HabitScreen( onDecrementToday = { viewModel.decrementNumericHabit( habitId = habitWithStatus.habit.id, - date = LocalDate.now(), + date = state.today, currentValue = habitWithStatus.todayValue, step = habitWithStatus.habit.step ) diff --git a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/HabitUiState.kt b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/HabitUiState.kt index d263f95b..e29fd175 100644 --- a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/HabitUiState.kt +++ b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/HabitUiState.kt @@ -2,6 +2,7 @@ package com.markel.flowstate.feature.habits import com.markel.flowstate.core.domain.HabitNumericEntry import com.markel.flowstate.core.domain.HabitWithStatus +import java.time.LocalDate sealed interface HabitUiState { data object Loading : HabitUiState @@ -9,6 +10,7 @@ sealed interface HabitUiState { val habits: List, val weekEntriesByHabit: Map>, val numericEntriesByHabit: Map>, + val today: LocalDate = LocalDate.now(), val showAddDialog: Boolean = false, val completedToday: Int = 0, val totalHabits: Int = 0, diff --git a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/HabitViewModel.kt b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/HabitViewModel.kt index 014e9439..d40fd32b 100644 --- a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/HabitViewModel.kt +++ b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/HabitViewModel.kt @@ -1,5 +1,7 @@ package com.markel.flowstate.feature.habits +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.markel.flowstate.core.domain.Habit @@ -19,17 +21,21 @@ import com.markel.flowstate.core.domain.usecase.habits.ToggleHabitEntryUseCase import com.markel.flowstate.core.domain.usecase.habits.UpdateHabitUseCase import com.markel.flowstate.core.domain.usecase.habits.UpdateHabitsOrderUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.time.DayOfWeek import java.time.LocalDate import javax.inject.Inject +@OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class HabitViewModel @Inject constructor( private val getHabitsWithStatus: GetHabitsWithStatusUseCase, @@ -44,44 +50,59 @@ class HabitViewModel @Inject constructor( private val decrementNumericValue: DecrementNumericValueUseCase, private val deleteNumericEntry: DeleteNumericEntryUseCase, private val updateHabitsOrder: UpdateHabitsOrderUseCase -) : ViewModel() { +) : ViewModel(), DefaultLifecycleObserver { + private val _today = MutableStateFlow(LocalDate.now()) private val _showAddDialog = MutableStateFlow(false) private val _uiState = MutableStateFlow(HabitUiState.Loading) val uiState = _uiState.asStateFlow() - init{ + init { viewModelScope.launch { combine( - getHabitsWithStatus(), + _today.flatMapLatest { today -> + getHabitsWithStatus(today).map { habits -> today to habits } + }, getAllBooleanEntries(), getAllNumericEntries(), _showAddDialog - ) { habits, allBooleanEntries, allNumericEntries, showDialog -> + ) { (today, habits), allBooleanEntries, allNumericEntries, showDialog -> val weekEntriesByHabit = allBooleanEntries .groupBy({ it.habitId }, { it.epochDay }) .mapValues { it.value.toSet() } val numericEntriesByHabit = allNumericEntries.groupBy { it.habitId } - val today = LocalDate.now() val habitsDueToday = habits.filter { it.habit.isScheduledFor(today) } HabitUiState.Success( habits = habits, weekEntriesByHabit = weekEntriesByHabit, numericEntriesByHabit = numericEntriesByHabit, + today = today, showAddDialog = showDialog, completedToday = habitsDueToday.count { it.isCompletedToday }, totalHabits = habitsDueToday.size, motivationalMessageIndex = today.dayOfYear % 7 ) - }.collect {newState -> + }.collect { newState -> _uiState.value = newState } - } } + override fun onResume(owner: LifecycleOwner) { + refreshTodayIfNeeded() + } + + /** + * Re-evaluates the current date when returning to the foreground. + * Because [_today] is a [MutableStateFlow], assigning the same [LocalDate] + * is a no-op and only triggers a re-computation when midnight has passed. + */ + fun refreshTodayIfNeeded(now: LocalDate = LocalDate.now()) { + _today.value = now + } + // ================================== // OPERATIONS FOR BOOLEAN HABITS // ================================== diff --git a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/HabitCard.kt b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/HabitCard.kt index 93941d9b..adc73584 100644 --- a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/HabitCard.kt +++ b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/HabitCard.kt @@ -45,11 +45,11 @@ fun HabitCard( colorArgb: Int, scheduledDays: Set ) -> Unit, + today: LocalDate = LocalDate.now(), onNavigateToDetail: (() -> Unit)? = null ) { val habit = habitWithStatus.habit val habitColor = Color(habit.colorArgb) - val today = LocalDate.now() val isScheduledToday = habit.isScheduledFor(today) val isCompletedToday = isScheduledToday && today.toEpochDay() in weekEntries @@ -62,6 +62,11 @@ fun HabitCard( firstDayOfWeek = DayOfWeek.MONDAY ) + // For the edge case of leaving the app in the background a Sunday and returning a Monday, scroll to the new week + LaunchedEffect(today) { + weekState.animateScrollToWeek(today) + } + val surfaceColor = MaterialTheme.colorScheme.surfaceContainer val cardBg by animateColorAsState( targetValue = if (isCompletedToday) diff --git a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/NumericHabitCard.kt b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/NumericHabitCard.kt index db816c63..9c9ec364 100644 --- a/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/NumericHabitCard.kt +++ b/feature/habits/src/main/java/com/markel/flowstate/feature/habits/components/NumericHabitCard.kt @@ -57,14 +57,14 @@ fun NumericHabitCard( step: Float?, scheduledDays: Set ) -> Unit, + today: LocalDate = LocalDate.now(), onNavigateToDetail: (() -> Unit)? = null ) { val habit = habitWithStatus.habit val habitColor = Color(habit.colorArgb) - val today = LocalDate.now() val scope = rememberCoroutineScope() - var weekOffset by remember { mutableIntStateOf(0) } // O if actual week, -1 if is last week - val weekStart = remember(weekOffset) { + var weekOffset by remember(today) { mutableIntStateOf(0) } // 0 if actual week, -1 if is last week + val weekStart = remember(today, weekOffset) { today.with(DayOfWeek.MONDAY).plusWeeks(weekOffset.toLong()) } @@ -86,7 +86,7 @@ fun NumericHabitCard( } // selectedDate resets to today (if is this week) or monday when changing week view - var selectedDate by remember(weekOffset) { + var selectedDate by remember(today, weekOffset) { mutableStateOf(if (weekOffset == 0) today else weekStart) } diff --git a/feature/habits/src/test/java/com/markel/flowstate/feature/habits/HabitViewModelTest.kt b/feature/habits/src/test/java/com/markel/flowstate/feature/habits/HabitViewModelTest.kt index 6f8bbb88..31062c6c 100644 --- a/feature/habits/src/test/java/com/markel/flowstate/feature/habits/HabitViewModelTest.kt +++ b/feature/habits/src/test/java/com/markel/flowstate/feature/habits/HabitViewModelTest.kt @@ -581,4 +581,43 @@ class HabitViewModelTest { // THEN coVerify { deleteNumericEntry(4, date) } } + + @Test + fun refreshTodayIfNeeded_whenDayChanges_recomputesStatusForNewDay() = runTest { + // GIVEN - Habit completed on day 1 (23:30), not completed on day 2 (00:20) + val today = LocalDate.now() + val tomorrow = today.plusDays(1) + val testHabit = habit(id = 1) + + coEvery { getHabitsWithStatus(today) } returns flowOf( + listOf(habitWithStatus(testHabit, completedToday = true)) + ) + coEvery { getHabitsWithStatus(tomorrow) } returns flowOf( + listOf(habitWithStatus(testHabit, completedToday = false)) + ) + coEvery { getAllBooleanEntries() } returns flowOf( + listOf(entry(habitId = 1, epochDay = today.toEpochDay())) + ) + + viewModel = buildViewModel() + + viewModel.uiState.test { + val initialState = awaitItem().let { + if (it is HabitUiState.Loading) awaitItem() else it + } as HabitUiState.Success + + assertEquals(today, initialState.today) + assertEquals(1, initialState.completedToday) + assertTrue(initialState.habits.first().isCompletedToday) + + // WHEN - User returns to the app after midnight (00:20) + viewModel.refreshTodayIfNeeded(tomorrow) + + // THEN - State updates to tomorrow and resets today's completion status + val updatedState = awaitItem() as HabitUiState.Success + assertEquals(tomorrow, updatedState.today) + assertEquals(0, updatedState.completedToday) + assertFalse(updatedState.habits.first().isCompletedToday) + } + } } \ No newline at end of file