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 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<IntimacyLogEntity>,
@@ -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") }
}
}
}

View File

@@ -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<String, Int> = emptyMap(),
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 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) }
}
}

View File

@@ -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) },

View File

@@ -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<HealthTrendsUiState> = _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(