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:
@@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user