Fix intimacy add-encounter form and Health Trends loading

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 23:44:57 -05:00
parent cd276ed44c
commit 2105cf861c
4 changed files with 234 additions and 126 deletions

View File

@@ -18,6 +18,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.hsdiary.data.db.entity.ConditionDefinitionEntity import com.hsdiary.data.db.entity.ConditionDefinitionEntity
import com.hsdiary.data.db.entity.IntimacyLogEntity import com.hsdiary.data.db.entity.IntimacyLogEntity
import com.hsdiary.data.model.ConditionCategory
import com.hsdiary.data.model.CyclePhase import com.hsdiary.data.model.CyclePhase
import java.time.LocalDate import java.time.LocalDate
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@@ -115,8 +116,8 @@ fun DayDetailScreen(
state.currentPhase == CyclePhase.FERTILE_WINDOW || state.currentPhase == CyclePhase.FERTILE_WINDOW ||
state.currentPhase == CyclePhase.OVULATION state.currentPhase == CyclePhase.OVULATION
), ),
onAdd = { pType, pName, time, protected -> onAdd = { pType, pName, time, isProtected ->
viewModel.addIntimacyLog(pType, pName, time, protected) viewModel.addIntimacyLog(pType, pName, time, isProtected)
}, },
onDelete = viewModel::deleteIntimacyLog onDelete = viewModel::deleteIntimacyLog
) )
@@ -125,19 +126,42 @@ 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 @Composable
private fun CycleSection( private fun CycleSection(
state: DayDetailUiState, state: DayDetailUiState,
onTogglePeriodStart: () -> Unit, onTogglePeriodStart: () -> Unit,
onTogglePeriodEnd: () -> Unit onTogglePeriodEnd: () -> Unit
) { ) {
val cycleState = cycleDayUiState(state)
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
// Phase / cycle day header // Phase / cycle-day header
val phaseLabel = when (state.currentPhase) { val phaseLabel = when (state.currentPhase) {
CyclePhase.MENSTRUATION_CONFIRMED -> "Menstruation" CyclePhase.MENSTRUATION_CONFIRMED -> "Menstruation"
CyclePhase.MENSTRUATION_PREDICTED -> "Predicted: Menstruation" CyclePhase.MENSTRUATION_PREDICTED -> "Predicted: Menstruation"
CyclePhase.FERTILE_WINDOW, CyclePhase.FERTILE_WINDOW_PREDICTED -> "Fertile Window" CyclePhase.FERTILE_WINDOW,
CyclePhase.OVULATION, CyclePhase.OVULATION_PREDICTED -> "Ovulation" CyclePhase.FERTILE_WINDOW_PREDICTED -> "Fertile Window"
CyclePhase.OVULATION,
CyclePhase.OVULATION_PREDICTED -> "Ovulation"
CyclePhase.LUTEAL -> "Luteal Phase" CyclePhase.LUTEAL -> "Luteal Phase"
CyclePhase.FOLLICULAR -> "Follicular Phase" CyclePhase.FOLLICULAR -> "Follicular Phase"
else -> null else -> null
@@ -151,7 +175,7 @@ private fun CycleSection(
Spacer(Modifier.height(4.dp)) 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) { if (state.hasActiveCycle && state.periodDayNumber > 0) {
Text( Text(
"Period day ${state.periodDayNumber}", "Period day ${state.periodDayNumber}",
@@ -164,37 +188,49 @@ private fun CycleSection(
Text("Period", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) Text("Period", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.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)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
if (showStarted) {
FilterChip( FilterChip(
selected = state.isPeriodStart, selected = cycleState == CycleDayUiState.START_CONFIRMED,
onClick = onTogglePeriodStart, onClick = onTogglePeriodStart,
label = { Text("Period started") }, label = { Text("Period started") },
leadingIcon = { leadingIcon = {
Icon( Icon(
if (state.isPeriodStart || state.hasActiveCycle) Icons.Default.Check else Icons.Default.PlayArrow, if (cycleState == CycleDayUiState.START_CONFIRMED) Icons.Default.Check
else Icons.Default.PlayArrow,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
) )
}
if (showEnded) {
FilterChip( FilterChip(
selected = state.isPeriodEnd, selected = cycleState == CycleDayUiState.END_CONFIRMED,
onClick = onTogglePeriodEnd, onClick = onTogglePeriodEnd,
enabled = state.isPeriodEnd ||
(state.hasActiveCycle && state.cycleStartDate != null && state.date > state.cycleStartDate),
label = { Text("Period ended") }, label = { Text("Period ended") },
leadingIcon = { leadingIcon = {
Icon( Icon(
if (state.isPeriodEnd) Icons.Default.Check else Icons.Default.Stop, if (cycleState == CycleDayUiState.END_CONFIRMED) Icons.Default.Check
else Icons.Default.Stop,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
) )
} }
}
}
// Predicted-period nudge // Predicted-period nudge (State 6 only)
if (state.currentPhase == CyclePhase.MENSTRUATION_PREDICTED && !state.isPeriodStart) { if (cycleState == CycleDayUiState.PREDICTED) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
OutlinedCard(modifier = Modifier.fillMaxWidth()) { OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Row( Row(
@@ -202,8 +238,10 @@ private fun CycleSection(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Icon(Icons.Default.Info, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp)) Icon(Icons.Default.Info, contentDescription = null,
Text("Period predicted today — tap \"Period started\" to confirm", style = MaterialTheme.typography.bodySmall) 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)) Spacer(Modifier.height(12.dp))
grouped.forEach { (category, items) -> grouped.forEach { (category, items) ->
val categoryLabel = category.replace("_", " ").lowercase() val categoryLabel = ConditionCategory.entries.find { it.name == category }?.displayName
.replaceFirstChar { it.uppercase() } ?: category.replace("_", " ").lowercase().replaceFirstChar { it.uppercase() }
val isExpanded = category in expandedCategories val isExpanded = category in expandedCategories
val hasSelected = items.any { selectedConditions.containsKey(it.conditionKey) } val hasSelected = items.any { selectedConditions.containsKey(it.conditionKey) }
@@ -341,6 +379,7 @@ private fun NotesSection(notes: String, onNotesChange: (String) -> Unit) {
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun IntimacySection( private fun IntimacySection(
logs: List<IntimacyLogEntity>, logs: List<IntimacyLogEntity>,
@@ -382,17 +421,6 @@ 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( TextButton(
onClick = { showAddForm = true }, onClick = { showAddForm = true },
modifier = Modifier.padding(top = 4.dp) modifier = Modifier.padding(top = 4.dp)
@@ -403,6 +431,23 @@ private fun IntimacySection(
} }
} }
} }
// 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 }
)
}
} }
} }
@@ -446,15 +491,21 @@ private fun AddIntimacyForm(
var participantType by remember { mutableStateOf("OTHER") } var participantType by remember { mutableStateOf("OTHER") }
var participantName by remember { mutableStateOf("") } var participantName by remember { mutableStateOf("") }
var timeOfDay 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 } val otherProfile = profiles.firstOrNull { it.id != activeProfileId }
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) { Column(
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { modifier = Modifier
Text("New encounter", style = MaterialTheme.typography.titleSmall) .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 // Participant selector
Text("With", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (otherProfile != null) { if (otherProfile != null) {
FilterChip( FilterChip(
@@ -466,7 +517,7 @@ private fun AddIntimacyForm(
FilterChip( FilterChip(
selected = participantType == "OTHER", selected = participantType == "OTHER",
onClick = { participantType = "OTHER"; participantName = "" }, onClick = { participantType = "OTHER"; participantName = "" },
label = { Text("Other") } label = { Text(if (otherProfile == null) "Add name (optional)" else "Other") }
) )
} }
if (participantType == "OTHER") { if (participantType == "OTHER") {
@@ -481,7 +532,7 @@ private fun AddIntimacyForm(
OutlinedTextField( OutlinedTextField(
value = timeOfDay, value = timeOfDay,
onValueChange = { timeOfDay = it }, onValueChange = { timeOfDay = it },
label = { Text("Time (optional, HH:MM)") }, label = { Text("Time (optional, e.g. 21:30)") },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
@@ -490,10 +541,17 @@ private fun AddIntimacyForm(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Column {
Text("Protected", style = MaterialTheme.typography.bodyMedium) Text("Protected", style = MaterialTheme.typography.bodyMedium)
Switch(checked = protected, onCheckedChange = { protected = it }) Text(
if (isProtected) "Contraception used" else "No contraception",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Switch(checked = isProtected, onCheckedChange = { isProtected = it })
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("Cancel") } OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("Cancel") }
Button( Button(
onClick = { onClick = {
@@ -501,12 +559,11 @@ private fun AddIntimacyForm(
participantType, participantType,
participantName.ifBlank { null }, participantName.ifBlank { null },
timeOfDay.ifBlank { null }, timeOfDay.ifBlank { null },
protected isProtected
) )
}, },
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { Text("Add") } ) { Text("Save encounter") }
}
} }
} }
} }

View File

@@ -33,6 +33,7 @@ data class DayDetailUiState(
val periodDayNumber: Int = 0, // day-of-period for this date (1 = first day) 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 hasActiveCycle: Boolean = false, // a cycle record spans this date (start logged, regardless of which day)
val cycleStartDate: LocalDate? = null, val cycleStartDate: LocalDate? = null,
val cycleEndDate: LocalDate? = null, // confirmed end of the spanning cycle, null = still open
val notes: String = "", val notes: String = "",
val conditions: Map<String, Int> = emptyMap(), val conditions: Map<String, Int> = emptyMap(),
val definitions: List<ConditionDefinitionEntity> = emptyList(), val definitions: List<ConditionDefinitionEntity> = emptyList(),
@@ -93,6 +94,7 @@ class DayDetailViewModel @Inject constructor(
val cycleRecord = if (isFemale) cycleRepository.getRecordContainingDate(activeProfile.id, dateStr) else null val cycleRecord = if (isFemale) cycleRepository.getRecordContainingDate(activeProfile.id, dateStr) else null
val hasActiveCycle = cycleRecord != null val hasActiveCycle = cycleRecord != null
val cycleStartDate = cycleRecord?.let { LocalDate.parse(it.cycleStart) } val cycleStartDate = cycleRecord?.let { LocalDate.parse(it.cycleStart) }
val cycleEndDate = cycleRecord?.cycleEnd?.let { LocalDate.parse(it) }
val periodDayNumber = if (cycleRecord != null) { val periodDayNumber = if (cycleRecord != null) {
(date.toEpochDay() - LocalDate.parse(cycleRecord.cycleStart).toEpochDay()).toInt() + 1 (date.toEpochDay() - LocalDate.parse(cycleRecord.cycleStart).toEpochDay()).toInt() + 1
} else 0 } else 0
@@ -108,6 +110,7 @@ class DayDetailViewModel @Inject constructor(
periodDayNumber = periodDayNumber, periodDayNumber = periodDayNumber,
hasActiveCycle = hasActiveCycle, hasActiveCycle = hasActiveCycle,
cycleStartDate = cycleStartDate, cycleStartDate = cycleStartDate,
cycleEndDate = cycleEndDate,
notes = dayLog.notes ?: "", notes = dayLog.notes ?: "",
conditions = conditions, conditions = conditions,
definitions = defsToShow, definitions = defsToShow,
@@ -121,6 +124,42 @@ class DayDetailViewModel @Inject constructor(
intimacyRepository.getLogsForDay(dateStr, activeProfile.id) intimacyRepository.getLogsForDay(dateStr, activeProfile.id)
.onEach { logs -> _uiState.value = _uiState.value.copy(intimacyLogs = logs) } .onEach { logs -> _uiState.value = _uiState.value.copy(intimacyLogs = logs) }
.launchIn(viewModelScope) .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() { fun togglePeriodStart() {
@@ -141,6 +180,7 @@ class DayDetailViewModel @Inject constructor(
periodDayNumber = 0, periodDayNumber = 0,
hasActiveCycle = false, hasActiveCycle = false,
cycleStartDate = null, cycleStartDate = null,
cycleEndDate = null,
isDirty = true isDirty = true
) )
} else { } else {
@@ -157,6 +197,7 @@ class DayDetailViewModel @Inject constructor(
periodDayNumber = 1, periodDayNumber = 1,
hasActiveCycle = true, hasActiveCycle = true,
cycleStartDate = date, cycleStartDate = date,
cycleEndDate = null,
isDirty = true isDirty = true
) )
} }
@@ -170,24 +211,22 @@ class DayDetailViewModel @Inject constructor(
val dayLog = state.dayLog ?: return@launch val dayLog = state.dayLog ?: return@launch
if (state.isPeriodEnd) { if (state.isPeriodEnd) {
// Remove end date from the cycle record
cycleRepository.removePeriodEnd(profileId, dateStr) cycleRepository.removePeriodEnd(profileId, dateStr)
_uiState.value = state.copy(isPeriodEnd = false, isDirty = true) _uiState.value = state.copy(isPeriodEnd = false, cycleEndDate = null, isDirty = true)
} else { } else {
// Mark as end: close the current open cycle record at this date
cycleRepository.endCurrentCycle(profileId, dateStr) cycleRepository.endCurrentCycle(profileId, dateStr)
// Also mark this day as a period day if not already
if (!state.periodActive) { if (!state.periodActive) {
val updatedLog = dayLog.copy(periodActive = true) val updatedLog = dayLog.copy(periodActive = true)
dayLogRepository.upsertDayLog(updatedLog) dayLogRepository.upsertDayLog(updatedLog)
_uiState.value = state.copy( _uiState.value = state.copy(
isPeriodEnd = true, isPeriodEnd = true,
cycleEndDate = date,
periodActive = true, periodActive = true,
dayLog = updatedLog, dayLog = updatedLog,
isDirty = true isDirty = true
) )
} else { } 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 { viewModelScope.launch {
val state = _uiState.value val profileId = _uiState.value.activeProfile?.id ?: return@launch
val profileId = state.activeProfile?.id ?: return@launch
intimacyRepository.insertLog( intimacyRepository.insertLog(
IntimacyLogEntity( IntimacyLogEntity(
date = dateStr, date = dateStr,
@@ -243,11 +281,13 @@ class DayDetailViewModel @Inject constructor(
participantType = participantType, participantType = participantType,
participantName = participantName, participantName = participantName,
timeOfDay = timeOfDay, timeOfDay = timeOfDay,
protected = protected, protected = isProtected,
shared = participantType != "OTHER" 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) }
} }
} }

View File

@@ -55,7 +55,7 @@ fun HealthTrendsScreen(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp) horizontalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
TrendsRange.values().forEach { range -> TrendsRange.entries.forEach { range ->
FilterChip( FilterChip(
selected = state.range == range, selected = state.range == range,
onClick = { viewModel.setRange(range) }, onClick = { viewModel.setRange(range) },

View File

@@ -8,10 +8,8 @@ import com.hsdiary.data.db.entity.DayLogEntity
import com.hsdiary.data.db.entity.ProfileEntity import com.hsdiary.data.db.entity.ProfileEntity
import com.hsdiary.data.model.ProfileType import com.hsdiary.data.model.ProfileType
import com.hsdiary.data.preferences.UserPreferences import com.hsdiary.data.preferences.UserPreferences
import com.hsdiary.data.repository.CycleRepository
import com.hsdiary.data.repository.DayLogRepository import com.hsdiary.data.repository.DayLogRepository
import com.hsdiary.data.repository.ProfileRepository import com.hsdiary.data.repository.ProfileRepository
import com.hsdiary.domain.CyclePredictionEngine
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -50,19 +48,33 @@ class HealthTrendsViewModel @Inject constructor(
val uiState: StateFlow<HealthTrendsUiState> = _uiState.asStateFlow() val uiState: StateFlow<HealthTrendsUiState> = _uiState.asStateFlow()
init { 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() { private suspend fun load() {
val activeId = userPreferences.activeProfileId.first() val activeId = userPreferences.activeProfileId.first()
val profiles = profileRepository.getAllProfilesOnce() 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 isFemale = profile.profileType == ProfileType.FEMALE.name
val allDefs = dayLogRepository.getAllDefinitions() val allDefs = dayLogRepository.getAllDefinitions()
.filter { it.profileVisibility == "ALL" || (isFemale && it.profileVisibility == "FEMALE_ONLY") } .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 } _range.combine(_selectedCondition) { range, sel -> range to sel }
.onEach { (range, sel) -> .collectLatest { (range, sel) ->
val today = LocalDate.now() val today = LocalDate.now()
val startDate = when (range) { val startDate = when (range) {
TrendsRange.DAYS_30 -> today.minusDays(29) TrendsRange.DAYS_30 -> today.minusDays(29)
@@ -101,7 +113,6 @@ class HealthTrendsViewModel @Inject constructor(
isFemale = isFemale isFemale = isFemale
) )
} }
.launchIn(viewModelScope)
} }
private fun buildWeeklyData( private fun buildWeeklyData(