From 2105cf861c81741035313429fe0d8ef8c6f0b1fa Mon Sep 17 00:00:00 2001 From: whitlocktech Date: Fri, 22 May 2026 23:44:57 -0500 Subject: [PATCH] Fix intimacy add-encounter form and Health Trends loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intimacy form: move AddIntimacyForm from an inline LazyColumn card (where Cancel/Add buttons land below the fold and keyboard) to a ModalBottomSheet that is always fully visible and slides above the keyboard. Also fix a race-condition in addIntimacyLog where `_uiState.value = state.copy(isDirty=true)` used a stale snapshot and could overwrite a concurrent Room Flow update that had already refreshed intimacyLogs — replaced with the atomic `_uiState.update {}`. Health Trends: replace `onEach {...}.launchIn()` with `collectLatest {}` inside a try/catch so any exception during setup or DB query resolves isLoading rather than leaving the screen stuck on an infinite spinner. collectLatest also cancels in-flight computations when the user switches the time range, preventing stale results. Removed unused CycleRepository / CyclePredictionEngine imports. Update deprecated TrendsRange.values() → TrendsRange.entries in the screen. Co-Authored-By: Claude Opus 4.7 --- .../hsdiary/ui/daydetail/DayDetailScreen.kt | 271 +++++++++++------- .../ui/daydetail/DayDetailViewModel.kt | 60 +++- .../hsdiary/ui/trends/HealthTrendsScreen.kt | 2 +- .../ui/trends/HealthTrendsViewModel.kt | 27 +- 4 files changed, 234 insertions(+), 126 deletions(-) diff --git a/app/src/main/java/com/hsdiary/ui/daydetail/DayDetailScreen.kt b/app/src/main/java/com/hsdiary/ui/daydetail/DayDetailScreen.kt index a300113..db98e27 100644 --- a/app/src/main/java/com/hsdiary/ui/daydetail/DayDetailScreen.kt +++ b/app/src/main/java/com/hsdiary/ui/daydetail/DayDetailScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.hsdiary.data.db.entity.ConditionDefinitionEntity import com.hsdiary.data.db.entity.IntimacyLogEntity +import com.hsdiary.data.model.ConditionCategory import com.hsdiary.data.model.CyclePhase import java.time.LocalDate import java.time.format.DateTimeFormatter @@ -115,8 +116,8 @@ fun DayDetailScreen( state.currentPhase == CyclePhase.FERTILE_WINDOW || state.currentPhase == CyclePhase.OVULATION ), - onAdd = { pType, pName, time, protected -> - viewModel.addIntimacyLog(pType, pName, time, protected) + onAdd = { pType, pName, time, isProtected -> + viewModel.addIntimacyLog(pType, pName, time, isProtected) }, onDelete = viewModel::deleteIntimacyLog ) @@ -125,22 +126,45 @@ fun DayDetailScreen( } } +private enum class CycleDayUiState { + NO_DATA, // State 1: no data for this day → show Started (untapped) + START_CONFIRMED, // State 2: this day is cycle_start → show Started (confirmed ✓) + MID_OPEN, // State 3: mid-period, no end yet → show Ended (untapped) + END_CONFIRMED, // State 4: this day is cycle_end → show Ended (confirmed ✓) + BETWEEN_CYCLES, // State 5: post-period non-menstruation phase → hide both + PREDICTED // State 6: MENSTRUATION_PREDICTED → show Started (untapped) +} + +private fun cycleDayUiState(state: DayDetailUiState): CycleDayUiState = when { + state.isPeriodStart -> CycleDayUiState.START_CONFIRMED + state.isPeriodEnd -> CycleDayUiState.END_CONFIRMED + state.hasActiveCycle && state.cycleEndDate == null -> CycleDayUiState.MID_OPEN + state.hasActiveCycle -> CycleDayUiState.BETWEEN_CYCLES + state.currentPhase == CyclePhase.MENSTRUATION_PREDICTED -> CycleDayUiState.PREDICTED + state.currentPhase == CyclePhase.NO_DATA -> CycleDayUiState.NO_DATA + else -> CycleDayUiState.BETWEEN_CYCLES +} + @Composable private fun CycleSection( state: DayDetailUiState, onTogglePeriodStart: () -> Unit, onTogglePeriodEnd: () -> Unit ) { + val cycleState = cycleDayUiState(state) + Column(modifier = Modifier.padding(16.dp)) { - // Phase / cycle day header + // Phase / cycle-day header val phaseLabel = when (state.currentPhase) { - CyclePhase.MENSTRUATION_CONFIRMED -> "Menstruation" - CyclePhase.MENSTRUATION_PREDICTED -> "Predicted: Menstruation" - CyclePhase.FERTILE_WINDOW, CyclePhase.FERTILE_WINDOW_PREDICTED -> "Fertile Window" - CyclePhase.OVULATION, CyclePhase.OVULATION_PREDICTED -> "Ovulation" - CyclePhase.LUTEAL -> "Luteal Phase" - CyclePhase.FOLLICULAR -> "Follicular Phase" - else -> null + CyclePhase.MENSTRUATION_CONFIRMED -> "Menstruation" + CyclePhase.MENSTRUATION_PREDICTED -> "Predicted: Menstruation" + CyclePhase.FERTILE_WINDOW, + CyclePhase.FERTILE_WINDOW_PREDICTED -> "Fertile Window" + CyclePhase.OVULATION, + CyclePhase.OVULATION_PREDICTED -> "Ovulation" + CyclePhase.LUTEAL -> "Luteal Phase" + CyclePhase.FOLLICULAR -> "Follicular Phase" + else -> null } if (state.cycleDay > 0 && phaseLabel != null) { Text( @@ -151,7 +175,7 @@ private fun CycleSection( Spacer(Modifier.height(4.dp)) } - // Period day-of-period badge + // Period day badge — visible whenever a cycle record spans this date if (state.hasActiveCycle && state.periodDayNumber > 0) { Text( "Period day ${state.periodDayNumber}", @@ -164,37 +188,49 @@ private fun CycleSection( Text("Period", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) Spacer(Modifier.height(12.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - FilterChip( - selected = state.isPeriodStart, - onClick = onTogglePeriodStart, - label = { Text("Period started") }, - leadingIcon = { - Icon( - if (state.isPeriodStart || state.hasActiveCycle) Icons.Default.Check else Icons.Default.PlayArrow, - contentDescription = null, - modifier = Modifier.size(16.dp) + val showStarted = cycleState == CycleDayUiState.NO_DATA || + cycleState == CycleDayUiState.START_CONFIRMED || + cycleState == CycleDayUiState.PREDICTED + val showEnded = cycleState == CycleDayUiState.MID_OPEN || + cycleState == CycleDayUiState.END_CONFIRMED + + if (showStarted || showEnded) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + if (showStarted) { + FilterChip( + selected = cycleState == CycleDayUiState.START_CONFIRMED, + onClick = onTogglePeriodStart, + label = { Text("Period started") }, + leadingIcon = { + Icon( + if (cycleState == CycleDayUiState.START_CONFIRMED) Icons.Default.Check + else Icons.Default.PlayArrow, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } ) } - ) - FilterChip( - selected = state.isPeriodEnd, - onClick = onTogglePeriodEnd, - enabled = state.isPeriodEnd || - (state.hasActiveCycle && state.cycleStartDate != null && state.date > state.cycleStartDate), - label = { Text("Period ended") }, - leadingIcon = { - Icon( - if (state.isPeriodEnd) Icons.Default.Check else Icons.Default.Stop, - contentDescription = null, - modifier = Modifier.size(16.dp) + if (showEnded) { + FilterChip( + selected = cycleState == CycleDayUiState.END_CONFIRMED, + onClick = onTogglePeriodEnd, + label = { Text("Period ended") }, + leadingIcon = { + Icon( + if (cycleState == CycleDayUiState.END_CONFIRMED) Icons.Default.Check + else Icons.Default.Stop, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } ) } - ) + } } - // Predicted-period nudge - if (state.currentPhase == CyclePhase.MENSTRUATION_PREDICTED && !state.isPeriodStart) { + // Predicted-period nudge (State 6 only) + if (cycleState == CycleDayUiState.PREDICTED) { Spacer(Modifier.height(8.dp)) OutlinedCard(modifier = Modifier.fillMaxWidth()) { Row( @@ -202,8 +238,10 @@ private fun CycleSection( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Icon(Icons.Default.Info, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp)) - Text("Period predicted today — tap \"Period started\" to confirm", style = MaterialTheme.typography.bodySmall) + Icon(Icons.Default.Info, contentDescription = null, + tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp)) + Text("Period predicted today — tap \"Period started\" to confirm", + style = MaterialTheme.typography.bodySmall) } } } @@ -228,8 +266,8 @@ private fun ConditionsSection( Spacer(Modifier.height(12.dp)) grouped.forEach { (category, items) -> - val categoryLabel = category.replace("_", " ").lowercase() - .replaceFirstChar { it.uppercase() } + val categoryLabel = ConditionCategory.entries.find { it.name == category }?.displayName + ?: category.replace("_", " ").lowercase().replaceFirstChar { it.uppercase() } val isExpanded = category in expandedCategories val hasSelected = items.any { selectedConditions.containsKey(it.conditionKey) } @@ -341,6 +379,7 @@ private fun NotesSection(notes: String, onNotesChange: (String) -> Unit) { } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun IntimacySection( logs: List, @@ -382,28 +421,34 @@ private fun IntimacySection( ) } - if (showAddForm) { - AddIntimacyForm( - profiles = profiles, - activeProfileId = activeProfileId, - onConfirm = { pType, pName, time, protected -> - onAdd(pType, pName, time, protected) - showAddForm = false - }, - onCancel = { showAddForm = false } - ) - } else { - TextButton( - onClick = { showAddForm = true }, - modifier = Modifier.padding(top = 4.dp) - ) { - Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(4.dp)) - Text("Add encounter") - } + TextButton( + onClick = { showAddForm = true }, + modifier = Modifier.padding(top = 4.dp) + ) { + Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text("Add encounter") } } } + + // Bottom sheet keeps the form above the keyboard and always fully visible + if (showAddForm) { + ModalBottomSheet( + onDismissRequest = { showAddForm = false }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + AddIntimacyForm( + profiles = profiles, + activeProfileId = activeProfileId, + onConfirm = { pType, pName, time, isProtected -> + onAdd(pType, pName, time, isProtected) + showAddForm = false + }, + onCancel = { showAddForm = false } + ) + } + } } @Composable @@ -446,67 +491,79 @@ private fun AddIntimacyForm( var participantType by remember { mutableStateOf("OTHER") } var participantName by remember { mutableStateOf("") } var timeOfDay by remember { mutableStateOf("") } - var protected by remember { mutableStateOf(true) } + var isProtected by remember { mutableStateOf(true) } val otherProfile = profiles.firstOrNull { it.id != activeProfileId } - Card(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text("New encounter", style = MaterialTheme.typography.titleSmall) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text("New encounter", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - // Participant selector - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - if (otherProfile != null) { - FilterChip( - selected = participantType == "PARTNER", - onClick = { participantType = "PARTNER"; participantName = otherProfile.name }, - label = { Text(otherProfile.name) } - ) - } + // Participant selector + Text("With", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (otherProfile != null) { FilterChip( - selected = participantType == "OTHER", - onClick = { participantType = "OTHER"; participantName = "" }, - label = { Text("Other") } - ) - } - if (participantType == "OTHER") { - OutlinedTextField( - value = participantName, - onValueChange = { if (it.length <= 32) participantName = it }, - label = { Text("Name (optional)") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() + selected = participantType == "PARTNER", + onClick = { participantType = "PARTNER"; participantName = otherProfile.name }, + label = { Text(otherProfile.name) } ) } + FilterChip( + selected = participantType == "OTHER", + onClick = { participantType = "OTHER"; participantName = "" }, + label = { Text(if (otherProfile == null) "Add name (optional)" else "Other") } + ) + } + if (participantType == "OTHER") { OutlinedTextField( - value = timeOfDay, - onValueChange = { timeOfDay = it }, - label = { Text("Time (optional, HH:MM)") }, + value = participantName, + onValueChange = { if (it.length <= 32) participantName = it }, + label = { Text("Name (optional)") }, singleLine = true, modifier = Modifier.fillMaxWidth() ) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { + } + OutlinedTextField( + value = timeOfDay, + onValueChange = { timeOfDay = it }, + label = { Text("Time (optional, e.g. 21:30)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { Text("Protected", style = MaterialTheme.typography.bodyMedium) - Switch(checked = protected, onCheckedChange = { protected = it }) - } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("Cancel") } - Button( - onClick = { - onConfirm( - participantType, - participantName.ifBlank { null }, - timeOfDay.ifBlank { null }, - protected - ) - }, - modifier = Modifier.weight(1f) - ) { Text("Add") } + Text( + if (isProtected) "Contraception used" else "No contraception", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } + Switch(checked = isProtected, onCheckedChange = { isProtected = it }) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("Cancel") } + Button( + onClick = { + onConfirm( + participantType, + participantName.ifBlank { null }, + timeOfDay.ifBlank { null }, + isProtected + ) + }, + modifier = Modifier.weight(1f) + ) { Text("Save encounter") } } } } diff --git a/app/src/main/java/com/hsdiary/ui/daydetail/DayDetailViewModel.kt b/app/src/main/java/com/hsdiary/ui/daydetail/DayDetailViewModel.kt index aaae3c7..98e766f 100644 --- a/app/src/main/java/com/hsdiary/ui/daydetail/DayDetailViewModel.kt +++ b/app/src/main/java/com/hsdiary/ui/daydetail/DayDetailViewModel.kt @@ -33,6 +33,7 @@ data class DayDetailUiState( val periodDayNumber: Int = 0, // day-of-period for this date (1 = first day) val hasActiveCycle: Boolean = false, // a cycle record spans this date (start logged, regardless of which day) val cycleStartDate: LocalDate? = null, + val cycleEndDate: LocalDate? = null, // confirmed end of the spanning cycle, null = still open val notes: String = "", val conditions: Map = emptyMap(), val definitions: List = emptyList(), @@ -93,6 +94,7 @@ class DayDetailViewModel @Inject constructor( val cycleRecord = if (isFemale) cycleRepository.getRecordContainingDate(activeProfile.id, dateStr) else null val hasActiveCycle = cycleRecord != null val cycleStartDate = cycleRecord?.let { LocalDate.parse(it.cycleStart) } + val cycleEndDate = cycleRecord?.cycleEnd?.let { LocalDate.parse(it) } val periodDayNumber = if (cycleRecord != null) { (date.toEpochDay() - LocalDate.parse(cycleRecord.cycleStart).toEpochDay()).toInt() + 1 } else 0 @@ -108,6 +110,7 @@ class DayDetailViewModel @Inject constructor( periodDayNumber = periodDayNumber, hasActiveCycle = hasActiveCycle, cycleStartDate = cycleStartDate, + cycleEndDate = cycleEndDate, notes = dayLog.notes ?: "", conditions = conditions, definitions = defsToShow, @@ -121,6 +124,42 @@ class DayDetailViewModel @Inject constructor( intimacyRepository.getLogsForDay(dateStr, activeProfile.id) .onEach { logs -> _uiState.value = _uiState.value.copy(intimacyLogs = logs) } .launchIn(viewModelScope) + + if (isFemale) { + cycleRepository.getCycleRecords(activeProfile.id) + .onEach { records -> + val updatedIsPeriodStart = records.any { it.cycleStart == dateStr } + val updatedIsPeriodEnd = records.any { it.cycleEnd == dateStr } + val updatedRecord = records.firstOrNull { r -> + r.cycleStart <= dateStr && (r.cycleEnd == null || r.cycleEnd >= dateStr) + } + val updatedHasActive = updatedRecord != null + val updatedCycleStartDate = updatedRecord?.let { LocalDate.parse(it.cycleStart) } + val updatedCycleEndDate = updatedRecord?.cycleEnd?.let { LocalDate.parse(it) } + val updatedPeriodDayNum = updatedRecord?.let { + (date.toEpochDay() - LocalDate.parse(it.cycleStart).toEpochDay()).toInt() + 1 + } ?: 0 + val updatedPrediction = predictionEngine.buildPrediction( + records, activeProfile.cycleLengthDefault, LocalDate.now() + ) + val updatedPhase = updatedPrediction.phaseMap[date] ?: CyclePhase.NO_DATA + val updatedCycleDay = if (updatedPrediction.currentCycleStartDate != null) { + maxOf(1, (date.toEpochDay() - updatedPrediction.currentCycleStartDate.toEpochDay()).toInt() + 1) + } else 0 + _uiState.value = _uiState.value.copy( + isPeriodStart = updatedIsPeriodStart, + isPeriodEnd = updatedIsPeriodEnd, + hasActiveCycle = updatedHasActive, + cycleStartDate = updatedCycleStartDate, + cycleEndDate = updatedCycleEndDate, + periodDayNumber = updatedPeriodDayNum, + prediction = updatedPrediction, + currentPhase = updatedPhase, + cycleDay = updatedCycleDay + ) + } + .launchIn(viewModelScope) + } } fun togglePeriodStart() { @@ -141,6 +180,7 @@ class DayDetailViewModel @Inject constructor( periodDayNumber = 0, hasActiveCycle = false, cycleStartDate = null, + cycleEndDate = null, isDirty = true ) } else { @@ -157,6 +197,7 @@ class DayDetailViewModel @Inject constructor( periodDayNumber = 1, hasActiveCycle = true, cycleStartDate = date, + cycleEndDate = null, isDirty = true ) } @@ -170,24 +211,22 @@ class DayDetailViewModel @Inject constructor( val dayLog = state.dayLog ?: return@launch if (state.isPeriodEnd) { - // Remove end date from the cycle record cycleRepository.removePeriodEnd(profileId, dateStr) - _uiState.value = state.copy(isPeriodEnd = false, isDirty = true) + _uiState.value = state.copy(isPeriodEnd = false, cycleEndDate = null, isDirty = true) } else { - // Mark as end: close the current open cycle record at this date cycleRepository.endCurrentCycle(profileId, dateStr) - // Also mark this day as a period day if not already if (!state.periodActive) { val updatedLog = dayLog.copy(periodActive = true) dayLogRepository.upsertDayLog(updatedLog) _uiState.value = state.copy( isPeriodEnd = true, + cycleEndDate = date, periodActive = true, dayLog = updatedLog, isDirty = true ) } else { - _uiState.value = state.copy(isPeriodEnd = true, isDirty = true) + _uiState.value = state.copy(isPeriodEnd = true, cycleEndDate = date, isDirty = true) } } } @@ -232,10 +271,9 @@ class DayDetailViewModel @Inject constructor( } } - fun addIntimacyLog(participantType: String, participantName: String?, timeOfDay: String?, protected: Boolean) { + fun addIntimacyLog(participantType: String, participantName: String?, timeOfDay: String?, isProtected: Boolean) { viewModelScope.launch { - val state = _uiState.value - val profileId = state.activeProfile?.id ?: return@launch + val profileId = _uiState.value.activeProfile?.id ?: return@launch intimacyRepository.insertLog( IntimacyLogEntity( date = dateStr, @@ -243,11 +281,13 @@ class DayDetailViewModel @Inject constructor( participantType = participantType, participantName = participantName, timeOfDay = timeOfDay, - protected = protected, + protected = isProtected, shared = participantType != "OTHER" ) ) - _uiState.value = state.copy(isDirty = true) + // Use update{} so we never overwrite a concurrent Flow emission that already + // refreshed intimacyLogs; only the isDirty flag changes here. + _uiState.update { it.copy(isDirty = true) } } } diff --git a/app/src/main/java/com/hsdiary/ui/trends/HealthTrendsScreen.kt b/app/src/main/java/com/hsdiary/ui/trends/HealthTrendsScreen.kt index ef556cb..4e19c88 100644 --- a/app/src/main/java/com/hsdiary/ui/trends/HealthTrendsScreen.kt +++ b/app/src/main/java/com/hsdiary/ui/trends/HealthTrendsScreen.kt @@ -55,7 +55,7 @@ fun HealthTrendsScreen( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp) ) { - TrendsRange.values().forEach { range -> + TrendsRange.entries.forEach { range -> FilterChip( selected = state.range == range, onClick = { viewModel.setRange(range) }, diff --git a/app/src/main/java/com/hsdiary/ui/trends/HealthTrendsViewModel.kt b/app/src/main/java/com/hsdiary/ui/trends/HealthTrendsViewModel.kt index cef476a..a7368ad 100644 --- a/app/src/main/java/com/hsdiary/ui/trends/HealthTrendsViewModel.kt +++ b/app/src/main/java/com/hsdiary/ui/trends/HealthTrendsViewModel.kt @@ -8,10 +8,8 @@ import com.hsdiary.data.db.entity.DayLogEntity import com.hsdiary.data.db.entity.ProfileEntity import com.hsdiary.data.model.ProfileType import com.hsdiary.data.preferences.UserPreferences -import com.hsdiary.data.repository.CycleRepository import com.hsdiary.data.repository.DayLogRepository import com.hsdiary.data.repository.ProfileRepository -import com.hsdiary.domain.CyclePredictionEngine import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -50,25 +48,39 @@ class HealthTrendsViewModel @Inject constructor( val uiState: StateFlow = _uiState.asStateFlow() init { - viewModelScope.launch { load() } + viewModelScope.launch { + try { + load() + } catch (e: Exception) { + // Surface the error state rather than leaving the screen on an infinite spinner + _uiState.value = HealthTrendsUiState(isLoading = false) + } + } } private suspend fun load() { val activeId = userPreferences.activeProfileId.first() val profiles = profileRepository.getAllProfilesOnce() - val profile = profiles.find { it.id == activeId } ?: profiles.firstOrNull() ?: return + val profile = profiles.find { it.id == activeId } ?: profiles.firstOrNull() + if (profile == null) { + _uiState.value = HealthTrendsUiState(isLoading = false) + return + } + val isFemale = profile.profileType == ProfileType.FEMALE.name val allDefs = dayLogRepository.getAllDefinitions() .filter { it.profileVisibility == "ALL" || (isFemale && it.profileVisibility == "FEMALE_ONLY") } + // collectLatest cancels any in-flight computation whenever the range or selection + // changes, so the UI never shows stale results when the user rapidly switches tabs. _range.combine(_selectedCondition) { range, sel -> range to sel } - .onEach { (range, sel) -> + .collectLatest { (range, sel) -> val today = LocalDate.now() val startDate = when (range) { - TrendsRange.DAYS_30 -> today.minusDays(29) + TrendsRange.DAYS_30 -> today.minusDays(29) TrendsRange.MONTHS_3 -> today.minusMonths(3) TrendsRange.MONTHS_6 -> today.minusMonths(6) - TrendsRange.ALL -> today.minusYears(5) + TrendsRange.ALL -> today.minusYears(5) } val entries = dayLogRepository.getConditionsInRange( @@ -101,7 +113,6 @@ class HealthTrendsViewModel @Inject constructor( isFemale = isFemale ) } - .launchIn(viewModelScope) } private fun buildWeeklyData(