Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -154,18 +161,19 @@ 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
)
},
onDecrementToday = {
viewModel.decrementNumericHabit(
habitId = habitWithStatus.habit.id,
date = LocalDate.now(),
date = state.today,
currentValue = habitWithStatus.todayValue,
step = habitWithStatus.habit.step
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ 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
data class Success(
val habits: List<HabitWithStatus>,
val weekEntriesByHabit: Map<Int, Set<Long>>,
val numericEntriesByHabit: Map<Int, List<HabitNumericEntry>>,
val today: LocalDate = LocalDate.now(),
val showAddDialog: Boolean = false,
val completedToday: Int = 0,
val totalHabits: Int = 0,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand All @@ -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>(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
// ==================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,11 @@ fun HabitCard(
colorArgb: Int,
scheduledDays: Set<DayOfWeek>
) -> 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

Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,14 @@ fun NumericHabitCard(
step: Float?,
scheduledDays: Set<DayOfWeek>
) -> 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())
}

Expand All @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Loading