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 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,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
|
@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.LUTEAL -> "Luteal Phase"
|
CyclePhase.OVULATION,
|
||||||
CyclePhase.FOLLICULAR -> "Follicular Phase"
|
CyclePhase.OVULATION_PREDICTED -> "Ovulation"
|
||||||
else -> null
|
CyclePhase.LUTEAL -> "Luteal Phase"
|
||||||
|
CyclePhase.FOLLICULAR -> "Follicular Phase"
|
||||||
|
else -> null
|
||||||
}
|
}
|
||||||
if (state.cycleDay > 0 && phaseLabel != null) {
|
if (state.cycleDay > 0 && phaseLabel != null) {
|
||||||
Text(
|
Text(
|
||||||
@@ -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))
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
val showStarted = cycleState == CycleDayUiState.NO_DATA ||
|
||||||
FilterChip(
|
cycleState == CycleDayUiState.START_CONFIRMED ||
|
||||||
selected = state.isPeriodStart,
|
cycleState == CycleDayUiState.PREDICTED
|
||||||
onClick = onTogglePeriodStart,
|
val showEnded = cycleState == CycleDayUiState.MID_OPEN ||
|
||||||
label = { Text("Period started") },
|
cycleState == CycleDayUiState.END_CONFIRMED
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
if (showStarted || showEnded) {
|
||||||
if (state.isPeriodStart || state.hasActiveCycle) Icons.Default.Check else Icons.Default.PlayArrow,
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
contentDescription = null,
|
if (showStarted) {
|
||||||
modifier = Modifier.size(16.dp)
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
if (showEnded) {
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = state.isPeriodEnd,
|
selected = cycleState == CycleDayUiState.END_CONFIRMED,
|
||||||
onClick = onTogglePeriodEnd,
|
onClick = onTogglePeriodEnd,
|
||||||
enabled = state.isPeriodEnd ||
|
label = { Text("Period ended") },
|
||||||
(state.hasActiveCycle && state.cycleStartDate != null && state.date > state.cycleStartDate),
|
leadingIcon = {
|
||||||
label = { Text("Period ended") },
|
Icon(
|
||||||
leadingIcon = {
|
if (cycleState == CycleDayUiState.END_CONFIRMED) Icons.Default.Check
|
||||||
Icon(
|
else Icons.Default.Stop,
|
||||||
if (state.isPeriodEnd) 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,28 +421,34 @@ private fun IntimacySection(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showAddForm) {
|
TextButton(
|
||||||
AddIntimacyForm(
|
onClick = { showAddForm = true },
|
||||||
profiles = profiles,
|
modifier = Modifier.padding(top = 4.dp)
|
||||||
activeProfileId = activeProfileId,
|
) {
|
||||||
onConfirm = { pType, pName, time, protected ->
|
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
onAdd(pType, pName, time, protected)
|
Spacer(Modifier.width(4.dp))
|
||||||
showAddForm = false
|
Text("Add encounter")
|
||||||
},
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
@Composable
|
||||||
@@ -446,67 +491,79 @@ 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
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Text("With", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
if (otherProfile != null) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
FilterChip(
|
if (otherProfile != null) {
|
||||||
selected = participantType == "PARTNER",
|
|
||||||
onClick = { participantType = "PARTNER"; participantName = otherProfile.name },
|
|
||||||
label = { Text(otherProfile.name) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = participantType == "OTHER",
|
selected = participantType == "PARTNER",
|
||||||
onClick = { participantType = "OTHER"; participantName = "" },
|
onClick = { participantType = "PARTNER"; participantName = otherProfile.name },
|
||||||
label = { Text("Other") }
|
label = { Text(otherProfile.name) }
|
||||||
)
|
|
||||||
}
|
|
||||||
if (participantType == "OTHER") {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = participantName,
|
|
||||||
onValueChange = { if (it.length <= 32) participantName = it },
|
|
||||||
label = { Text("Name (optional)") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
FilterChip(
|
||||||
|
selected = participantType == "OTHER",
|
||||||
|
onClick = { participantType = "OTHER"; participantName = "" },
|
||||||
|
label = { Text(if (otherProfile == null) "Add name (optional)" else "Other") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (participantType == "OTHER") {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = timeOfDay,
|
value = participantName,
|
||||||
onValueChange = { timeOfDay = it },
|
onValueChange = { if (it.length <= 32) participantName = it },
|
||||||
label = { Text("Time (optional, HH:MM)") },
|
label = { Text("Name (optional)") },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
Row(
|
}
|
||||||
modifier = Modifier.fillMaxWidth(),
|
OutlinedTextField(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
value = timeOfDay,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
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)
|
Text("Protected", style = MaterialTheme.typography.bodyMedium)
|
||||||
Switch(checked = protected, onCheckedChange = { protected = it })
|
Text(
|
||||||
}
|
if (isProtected) "Contraception used" else "No contraception",
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
style = MaterialTheme.typography.bodySmall,
|
||||||
OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("Cancel") }
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
Button(
|
)
|
||||||
onClick = {
|
|
||||||
onConfirm(
|
|
||||||
participantType,
|
|
||||||
participantName.ifBlank { null },
|
|
||||||
timeOfDay.ifBlank { null },
|
|
||||||
protected
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
) { Text("Add") }
|
|
||||||
}
|
}
|
||||||
|
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 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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) },
|
||||||
|
|||||||
@@ -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,25 +48,39 @@ 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)
|
||||||
TrendsRange.MONTHS_3 -> today.minusMonths(3)
|
TrendsRange.MONTHS_3 -> today.minusMonths(3)
|
||||||
TrendsRange.MONTHS_6 -> today.minusMonths(6)
|
TrendsRange.MONTHS_6 -> today.minusMonths(6)
|
||||||
TrendsRange.ALL -> today.minusYears(5)
|
TrendsRange.ALL -> today.minusYears(5)
|
||||||
}
|
}
|
||||||
|
|
||||||
val entries = dayLogRepository.getConditionsInRange(
|
val entries = dayLogRepository.getConditionsInRange(
|
||||||
@@ -101,7 +113,6 @@ class HealthTrendsViewModel @Inject constructor(
|
|||||||
isFemale = isFemale
|
isFemale = isFemale
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.launchIn(viewModelScope)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildWeeklyData(
|
private fun buildWeeklyData(
|
||||||
|
|||||||
Reference in New Issue
Block a user