From d1f9462a79ce4195ffb769d74184ccca34edd5c6 Mon Sep 17 00:00:00 2001 From: ApolloF <98666317+ApolloF@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:52:08 +0100 Subject: [PATCH] Sheets: keep open sheets and their drafts across rotation Bottom sheets lost everything on a configuration change: the open target (which sheet, which dose or entry) and the typed fields were plain remember state, so a rotation dismissed the sheet and dropped the draft. - Today, Journal and the item editor keep their open sheet in rememberSaveable; sheet targets, LogTarget and Occurrence are @Serializable and stored through a JSON saver (StateSavers.kt). - Sheet content runs under its own SaveableStateHolder (rememberSaveableSheetContent), so fields inside ModalBottomSheet restore too. - Drafts in the dose, note, blood pressure, symptom and bloodwork sheets, the compound picker's search, the date/time pickers and the site row use rememberSaveable; nullable values use a list saver so a cleared mood, hair or time stays cleared instead of re-running the initializer. - An explicit site choice is kept apart from the loaded suggestion, so history arriving after a restore no longer overrides it. SheetRestorationTest covers each sheet with StateRestorationTester. --- .../ui/components/CompoundPicker.kt | 4 +- .../protocoltracker/ui/components/Pickers.kt | 6 +- .../ui/components/StateSavers.kt | 35 ++ .../protocoltracker/ui/health/HealthSheets.kt | 39 +- .../ui/journal/JournalScreen.kt | 13 +- .../ui/plan/ItemEditorScreen.kt | 8 +- .../protocoltracker/ui/today/JournalSheets.kt | 30 +- .../protocoltracker/ui/today/LogDoseSheet.kt | 31 +- .../protocoltracker/ui/today/SiteRow.kt | 4 +- .../protocoltracker/ui/today/TodayScreen.kt | 13 +- .../ui/today/TodayViewModel.kt | 5 + .../ui/SheetRestorationTest.kt | 454 ++++++++++++++++++ .../domain/schedule/Occurrences.kt | 10 +- 13 files changed, 592 insertions(+), 60 deletions(-) create mode 100644 app/src/main/kotlin/com/apollof/protocoltracker/ui/components/StateSavers.kt create mode 100644 app/src/test/kotlin/com/apollof/protocoltracker/ui/SheetRestorationTest.kt diff --git a/app/src/main/kotlin/com/apollof/protocoltracker/ui/components/CompoundPicker.kt b/app/src/main/kotlin/com/apollof/protocoltracker/ui/components/CompoundPicker.kt index 47f80bf..b33875b 100644 --- a/app/src/main/kotlin/com/apollof/protocoltracker/ui/components/CompoundPicker.kt +++ b/app/src/main/kotlin/com/apollof/protocoltracker/ui/components/CompoundPicker.kt @@ -17,7 +17,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -44,7 +44,7 @@ fun CompoundPicker( pinned: Set = emptySet(), ) { val c = Tracker.colors - var query by remember { mutableStateOf("") } + var query by rememberSaveable { mutableStateOf("") } val shown = compounds.filter { !it.archived } .filter { q -> query.isBlank() || listOf(q.displayName, q.group).any { it.contains(query.trim(), ignoreCase = true) } } .sortedWith(compoundOrder) diff --git a/app/src/main/kotlin/com/apollof/protocoltracker/ui/components/Pickers.kt b/app/src/main/kotlin/com/apollof/protocoltracker/ui/components/Pickers.kt index 82854ed..e16968f 100644 --- a/app/src/main/kotlin/com/apollof/protocoltracker/ui/components/Pickers.kt +++ b/app/src/main/kotlin/com/apollof/protocoltracker/ui/components/Pickers.kt @@ -21,7 +21,7 @@ import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.layout.Layout @@ -76,7 +76,7 @@ fun DatePickDialog(initial: LocalDate, onDismiss: () -> Unit, onConfirm: (LocalD /** Read-only field that opens a date picker; optional dates can be cleared. */ @Composable fun DateField(label: String, value: LocalDate?, onChange: (LocalDate?) -> Unit, modifier: Modifier = Modifier, optional: Boolean = false) { - var open by remember { mutableStateOf(false) } + var open by rememberSaveable { mutableStateOf(false) } OutlinedTextField( value = value?.format(Formats.date) ?: "", onValueChange = {}, @@ -96,7 +96,7 @@ fun DateField(label: String, value: LocalDate?, onChange: (LocalDate?) -> Unit, @Composable fun TimeField(label: String, value: LocalTime, onChange: (LocalTime) -> Unit, modifier: Modifier = Modifier) { - var open by remember { mutableStateOf(false) } + var open by rememberSaveable { mutableStateOf(false) } OutlinedTextField( value = value.format(Formats.time), onValueChange = {}, readOnly = true, label = { Text(label) }, trailingIcon = { IconButton(onClick = { open = true }) { Icon(Icons.Outlined.Schedule, "Choose $label") } }, diff --git a/app/src/main/kotlin/com/apollof/protocoltracker/ui/components/StateSavers.kt b/app/src/main/kotlin/com/apollof/protocoltracker/ui/components/StateSavers.kt new file mode 100644 index 0000000..d56b889 --- /dev/null +++ b/app/src/main/kotlin/com/apollof/protocoltracker/ui/components/StateSavers.kt @@ -0,0 +1,35 @@ +package com.apollof.protocoltracker.ui.components + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.compose.runtime.toMutableStateMap +import kotlinx.serialization.json.Json + +/** Keep a sheet's dialog content in its caller's saved state, together with the open-sheet target. */ +@Composable +internal fun rememberSaveableSheetContent( + content: @Composable ColumnScope.() -> Unit, +): @Composable ColumnScope.() -> Unit { + val holder = rememberSaveableStateHolder() + return { holder.SaveableStateProvider("content") { content() } } +} + +/** Keep serializable sheet targets as Bundle-compatible strings, including their edit snapshots. */ +internal inline fun jsonSaver(): Saver = Saver( + save = { Json.encodeToString(it) }, + restore = { Json.decodeFromString(it) }, +) + +/** Wrap null so clearing an existing value restores null instead of running its initializer again. */ +internal fun nullableSaver(): Saver = listSaver( + save = { listOf(it) }, restore = { it.single() }, +) + +internal inline fun stateMapSaver(): Saver, String> = Saver( + save = { Json.encodeToString(it.toMap()) }, + restore = { Json.decodeFromString>(it).toList().toMutableStateMap() }, +) diff --git a/app/src/main/kotlin/com/apollof/protocoltracker/ui/health/HealthSheets.kt b/app/src/main/kotlin/com/apollof/protocoltracker/ui/health/HealthSheets.kt index ccaa4aa..c607821 100644 --- a/app/src/main/kotlin/com/apollof/protocoltracker/ui/health/HealthSheets.kt +++ b/app/src/main/kotlin/com/apollof/protocoltracker/ui/health/HealthSheets.kt @@ -32,6 +32,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateSetOf +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.snapshots.SnapshotStateSet import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.saveable.rememberSaveable @@ -55,6 +57,9 @@ import com.apollof.protocoltracker.domain.model.printedValue import com.apollof.protocoltracker.domain.model.upFrontMarkers import com.apollof.protocoltracker.domain.pk.LabUnits import com.apollof.protocoltracker.domain.units.formatNumber +import com.apollof.protocoltracker.ui.components.rememberSaveableSheetContent +import com.apollof.protocoltracker.ui.components.nullableSaver +import com.apollof.protocoltracker.ui.components.stateMapSaver import com.apollof.protocoltracker.ui.components.DateField import com.apollof.protocoltracker.ui.components.DeleteEntryButton import com.apollof.protocoltracker.ui.components.FieldRow @@ -92,17 +97,17 @@ fun SymptomSheet( existing: JournalEntry.Symptoms? = null, onDelete: (() -> Unit)? = null, ) { - val selected = remember { mutableStateMapOf().apply { existing?.symptoms?.forEach { put(it, true) } } } - var mood by remember { mutableStateOf(existing?.mood) } - var hair by remember { mutableStateOf(existing?.hairShedding) } - var note by remember { mutableStateOf(existing?.note ?: "") } - var time by remember { mutableStateOf(existing?.at) } + val selected = rememberSaveable(saver = stateMapSaver()) { mutableStateMapOf().apply { existing?.symptoms?.forEach { put(it, true) } } } + var mood by rememberSaveable(stateSaver = nullableSaver()) { mutableStateOf(existing?.mood) } + var hair by rememberSaveable(stateSaver = nullableSaver()) { mutableStateOf(existing?.hairShedding) } + var note by rememberSaveable { mutableStateOf(existing?.note ?: "") } + var time by rememberSaveable(stateSaver = nullableSaver()) { mutableStateOf(existing?.at) } val c = Tracker.colors val keys = SymptomCatalog.all.map { it.key }.filter { selected[it] == true } + existing?.symptoms.orEmpty().filter { SymptomCatalog.find(it) == null } val canSave = keys.isNotEmpty() || mood != null || hair != null || note.isNotBlank() - ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), containerColor = c.surface) { + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), containerColor = c.surface, content = rememberSaveableSheetContent { Column( Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp).padding(bottom = 16.dp).imePadding().navigationBarsPadding(), verticalArrangement = Arrangement.spacedBy(16.dp), @@ -151,7 +156,7 @@ fun SymptomSheet( if (!canSave) Text("Choose a symptom or add a note.", style = TrackerType.caption, color = c.muted) if (existing != null && onDelete != null) DeleteEntryButton(onDelete) } - } + }) } /** @@ -172,12 +177,12 @@ fun BloodworkSheet( measured: Set = emptySet(), onDelete: (() -> Unit)? = null, ) { - var units by remember { mutableStateOf(defaultUnits) } + var units by rememberSaveable { mutableStateOf(defaultUnits) } val start = (existing?.at ?: now).atZone(zone) - var date by remember { mutableStateOf(start.toLocalDate()) } - var time by remember { mutableStateOf(LocalTime.of(start.hour, start.minute)) } - var lab by remember { mutableStateOf(existing?.lab ?: "") } - var note by remember { mutableStateOf(existing?.note ?: "") } + var date by rememberSaveable { mutableStateOf(start.toLocalDate()) } + var time by rememberSaveable { mutableStateOf(LocalTime.of(start.hour, start.minute)) } + var lab by rememberSaveable { mutableStateOf(existing?.lab ?: "") } + var note by rememberSaveable { mutableStateOf(existing?.note ?: "") } val original = existing?.results.orEmpty() // Results with no known marker, in saved order, listed under "Other tests" (import doc §7). val unlisted = original.filter { BloodMarkers.find(it.marker) == null } @@ -188,7 +193,7 @@ fun BloodworkSheet( return formatNumber(m.fromStored(r.value, u), 3) } // Raw text per marker, in the units currently selected (unlisted results: as printed, never converted). - val texts = remember { + val texts = rememberSaveable(saver = stateMapSaver()) { mutableStateMapOf().apply { BloodMarkers.all.forEach { m -> shown(m.key, defaultUnits).takeIf { it.isNotEmpty() }?.let { put(m.key, it) } } unlisted.forEach { put(it.marker, it.printedNumber()) } @@ -196,7 +201,9 @@ fun BloodworkSheet( } // Fields the user typed a different value into. Only these are rebuilt on save; every other result, including its // lab range, qualifier and unlisted results, is kept exactly as saved (import doc §10.7). - val touched = remember { mutableStateSetOf() } + val touched = rememberSaveable(saver = listSaver, String>( + save = { it.toList() }, restore = { mutableStateSetOf(*it.toTypedArray()) }, + )) { mutableStateSetOf() } // Also every field that holds a value when the history arrives, so nothing typed folds away. val upFront = remember(measured) { upFrontMarkers(measured + original.map { it.marker } + texts.filterValues { it.isNotEmpty() }.keys).mapTo(HashSet()) { it.key } @@ -226,7 +233,7 @@ fun BloodworkSheet( units = next } - ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), containerColor = c.surface) { + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), containerColor = c.surface, content = rememberSaveableSheetContent { Column( Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp).padding(bottom = 16.dp).imePadding().navigationBarsPadding(), verticalArrangement = Arrangement.spacedBy(16.dp), @@ -318,7 +325,7 @@ fun BloodworkSheet( if (implausible.isNotEmpty()) Text("Fix the marked results to save.", style = TrackerType.caption, color = c.muted) if (existing != null && onDelete != null) DeleteEntryButton(onDelete) } - } + }) } /** Caption under a result field: its range, then how a censored value was reported; null when neither. */ diff --git a/app/src/main/kotlin/com/apollof/protocoltracker/ui/journal/JournalScreen.kt b/app/src/main/kotlin/com/apollof/protocoltracker/ui/journal/JournalScreen.kt index 99786db..11d4c3f 100644 --- a/app/src/main/kotlin/com/apollof/protocoltracker/ui/journal/JournalScreen.kt +++ b/app/src/main/kotlin/com/apollof/protocoltracker/ui/journal/JournalScreen.kt @@ -83,6 +83,7 @@ import com.apollof.protocoltracker.ui.theme.NumericStyle import com.apollof.protocoltracker.ui.theme.Spacing import com.apollof.protocoltracker.ui.theme.Tracker import com.apollof.protocoltracker.ui.theme.TrackerType +import com.apollof.protocoltracker.ui.components.jsonSaver import com.apollof.protocoltracker.ui.today.LogMenuSheet import com.apollof.protocoltracker.ui.today.LogTarget import com.apollof.protocoltracker.ui.today.LogDoseSheet @@ -91,14 +92,22 @@ import com.apollof.protocoltracker.ui.today.JournalLine import com.apollof.protocoltracker.ui.today.NoteSheet import java.time.ZoneId import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +@Serializable private sealed interface Editing { + @Serializable data class Dose(val log: DoseLog) : Editing + @Serializable data class Bp(val entry: JournalEntry.BloodPressure?) : Editing + @Serializable data class Note(val entry: JournalEntry.Note?) : Editing + @Serializable data class Symptoms(val entry: JournalEntry.Symptoms?) : Editing + @Serializable data class Bloodwork(val entry: JournalEntry.Bloodwork?) : Editing /** A new extra dose from the Log menu. */ + @Serializable data object Extra : Editing } @@ -113,9 +122,9 @@ fun JournalScreen(onOpenSettings: () -> Unit, onImportBloodwork: (() -> Unit)? = val doseEditor by vm.doseEditor.collectAsStateWithLifecycle() val snackbar = remember { SnackbarHostState() } val scope = rememberCoroutineScope() - var editing by remember { mutableStateOf(null) } + var editing by rememberSaveable(stateSaver = jsonSaver()) { mutableStateOf(null) } var adherenceOpen by rememberSaveable { mutableStateOf(false) } - var logMenu by remember { mutableStateOf(false) } + var logMenu by rememberSaveable { mutableStateOf(false) } val c = Tracker.colors val motion = Motions.current diff --git a/app/src/main/kotlin/com/apollof/protocoltracker/ui/plan/ItemEditorScreen.kt b/app/src/main/kotlin/com/apollof/protocoltracker/ui/plan/ItemEditorScreen.kt index 2912fd0..caf9a0d 100644 --- a/app/src/main/kotlin/com/apollof/protocoltracker/ui/plan/ItemEditorScreen.kt +++ b/app/src/main/kotlin/com/apollof/protocoltracker/ui/plan/ItemEditorScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -67,6 +68,7 @@ import com.apollof.protocoltracker.domain.model.DoseBasis import com.apollof.protocoltracker.domain.model.DoseUnit import com.apollof.protocoltracker.domain.model.Route import com.apollof.protocoltracker.ui.appViewModel +import com.apollof.protocoltracker.ui.components.rememberSaveableSheetContent import com.apollof.protocoltracker.ui.components.CategoryTag import com.apollof.protocoltracker.ui.components.CompoundName import com.apollof.protocoltracker.ui.components.categoryLabels @@ -105,7 +107,7 @@ fun ItemEditorScreen(itemId: String?, phaseId: String?, onDone: () -> Unit, onNe val preview = remember(d, compounds, loaded, slotTimes) { vm.preview(d, compounds, slotTimes) } val compound = compounds.firstOrNull { it.id == d.compoundId } var confirmDelete by remember { mutableStateOf(false) } - var picking by remember { mutableStateOf(itemId == null && d.compoundId == null) } + var picking by rememberSaveable { mutableStateOf(itemId == null && d.compoundId == null) } var addingTime by remember { mutableStateOf(false) } var tried by remember { mutableStateOf(false) } val c = Tracker.colors @@ -311,11 +313,11 @@ fun ItemEditorScreen(itemId: String?, phaseId: String?, onDone: () -> Unit, onNe } if (picking) { - ModalBottomSheet(onDismissRequest = { picking = false }, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), containerColor = c.surface) { + ModalBottomSheet(onDismissRequest = { picking = false }, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), containerColor = c.surface, content = rememberSaveableSheetContent { CompoundPicker(compounds, onPick = { vm.selectCompound(it); picking = false }, footer = { SecondaryButton("New compound…", { picking = false; onNewCompound() }, Modifier.fillMaxWidth(), Icons.Outlined.Add) }) - } + }) } if (addingTime) TimePickDialog(java.time.LocalTime.of(9, 0), onDismiss = { addingTime = false }, onConfirm = { t -> vm.edit { it.copy(times = (it.times + t).distinct()) }; addingTime = false diff --git a/app/src/main/kotlin/com/apollof/protocoltracker/ui/today/JournalSheets.kt b/app/src/main/kotlin/com/apollof/protocoltracker/ui/today/JournalSheets.kt index 6caa1cb..003a4b4 100644 --- a/app/src/main/kotlin/com/apollof/protocoltracker/ui/today/JournalSheets.kt +++ b/app/src/main/kotlin/com/apollof/protocoltracker/ui/today/JournalSheets.kt @@ -22,7 +22,7 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType @@ -32,6 +32,8 @@ import com.apollof.protocoltracker.domain.model.bloodPressureProblems import com.apollof.protocoltracker.domain.schedule.SlotTimes import com.apollof.protocoltracker.domain.schedule.dateOf import com.apollof.protocoltracker.domain.timeline.atOrBefore +import com.apollof.protocoltracker.ui.components.rememberSaveableSheetContent +import com.apollof.protocoltracker.ui.components.nullableSaver import com.apollof.protocoltracker.ui.components.QuickChip import com.apollof.protocoltracker.ui.components.DeleteEntryButton import com.apollof.protocoltracker.ui.components.Formats @@ -47,7 +49,7 @@ import java.time.ZoneId /** Time row shared by the journal sheets: now, or a picked time on the same day. */ @Composable internal fun TimeChoice(now: Instant, zone: ZoneId, time: Instant?, onTime: (Instant?) -> Unit) { - var picking by remember { mutableStateOf(false) } + var picking by rememberSaveable { mutableStateOf(false) } Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { SectionLabel("Time") Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { @@ -81,16 +83,16 @@ fun BloodPressureSheet( existing: JournalEntry.BloodPressure? = null, onDelete: (() -> Unit)? = null, ) { - var systolic by remember { mutableStateOf(existing?.systolic?.toString() ?: "") } - var diastolic by remember { mutableStateOf(existing?.diastolic?.toString() ?: "") } - var pulse by remember { mutableStateOf(existing?.pulse?.toString() ?: "") } - var note by remember { mutableStateOf(existing?.note ?: "") } - var time by remember { mutableStateOf(existing?.at) } - var tried by remember { mutableStateOf(false) } + var systolic by rememberSaveable { mutableStateOf(existing?.systolic?.toString() ?: "") } + var diastolic by rememberSaveable { mutableStateOf(existing?.diastolic?.toString() ?: "") } + var pulse by rememberSaveable { mutableStateOf(existing?.pulse?.toString() ?: "") } + var note by rememberSaveable { mutableStateOf(existing?.note ?: "") } + var time by rememberSaveable(stateSaver = nullableSaver()) { mutableStateOf(existing?.at) } + var tried by rememberSaveable { mutableStateOf(false) } val problems = bloodPressureProblems(systolic.toIntOrNull(), diastolic.toIntOrNull(), pulse.takeIf { it.isNotBlank() }?.toIntOrNull()) val c = Tracker.colors - ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), containerColor = c.surface) { + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), containerColor = c.surface, content = rememberSaveableSheetContent { Column( Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp).padding(bottom = 16.dp).imePadding().navigationBarsPadding(), verticalArrangement = Arrangement.spacedBy(16.dp), @@ -113,7 +115,7 @@ fun BloodPressureSheet( } if (existing != null && onDelete != null) DeleteEntryButton(onDelete) } - } + }) } @OptIn(ExperimentalMaterial3Api::class) @@ -126,10 +128,10 @@ fun NoteSheet( existing: JournalEntry.Note? = null, onDelete: (() -> Unit)? = null, ) { - var text by remember { mutableStateOf(existing?.text ?: "") } - var time by remember { mutableStateOf(existing?.at) } + var text by rememberSaveable { mutableStateOf(existing?.text ?: "") } + var time by rememberSaveable(stateSaver = nullableSaver()) { mutableStateOf(existing?.at) } val c = Tracker.colors - ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), containerColor = c.surface) { + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), containerColor = c.surface, content = rememberSaveableSheetContent { Column( Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp).padding(bottom = 16.dp).imePadding().navigationBarsPadding(), verticalArrangement = Arrangement.spacedBy(16.dp), @@ -148,7 +150,7 @@ fun NoteSheet( } if (existing != null && onDelete != null) DeleteEntryButton(onDelete) } - } + }) } @Composable diff --git a/app/src/main/kotlin/com/apollof/protocoltracker/ui/today/LogDoseSheet.kt b/app/src/main/kotlin/com/apollof/protocoltracker/ui/today/LogDoseSheet.kt index 7d57299..320ee9e 100644 --- a/app/src/main/kotlin/com/apollof/protocoltracker/ui/today/LogDoseSheet.kt +++ b/app/src/main/kotlin/com/apollof/protocoltracker/ui/today/LogDoseSheet.kt @@ -34,6 +34,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -74,6 +75,8 @@ import com.apollof.protocoltracker.domain.units.formatVolume import com.apollof.protocoltracker.domain.units.tablets import com.apollof.protocoltracker.domain.units.toBaseOrNull import com.apollof.protocoltracker.domain.units.volumeMl +import com.apollof.protocoltracker.ui.components.rememberSaveableSheetContent +import com.apollof.protocoltracker.ui.components.jsonSaver import com.apollof.protocoltracker.ui.components.CategoryTag import com.apollof.protocoltracker.ui.components.CompoundName import com.apollof.protocoltracker.ui.components.CompoundPicker @@ -131,7 +134,7 @@ fun LogDoseSheet( val today = slotTimes.dateOf(now, zone) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() - var picked by remember { mutableStateOf((target as? LogTarget.Unscheduled)?.compound) } + var picked by rememberSaveable(stateSaver = jsonSaver()) { mutableStateOf((target as? LogTarget.Unscheduled)?.compound) } // Runs an action once, after the sheet has slid away: a second tap can neither save twice nor land on // whatever appears underneath (such as the snackbar's Undo). var closing by remember { mutableStateOf(false) } @@ -141,7 +144,7 @@ fun LogDoseSheet( scope.launch { sheetState.hide() }.invokeOnCompletion { action() } } fun siteChoice(compound: Compound, editing: DoseLog?) = sites?.takeIf { compound.route == Route.INJECTION }?.invoke(compound.id, editing) - ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = Tracker.colors.surface) { + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = Tracker.colors.surface, content = rememberSaveableSheetContent { when (target) { is LogTarget.Scheduled -> DoseForm( compound = target.compound, @@ -171,7 +174,7 @@ fun LogDoseSheet( val compound = (compounds.firstOrNull { it.id == log.compoundId } ?: log.snapshot.asCompound(log.compoundId, log.site)) .copy(baseUnit = log.snapshot.baseUnit) // Skipping applies to planned doses only. - var status by remember { mutableStateOf(log.status) } + var status by rememberSaveable { mutableStateOf(log.status) } DoseForm( compound = compound, heading = "Edit dose", @@ -230,7 +233,7 @@ fun LogDoseSheet( } } } - } + }) } @Composable @@ -263,14 +266,16 @@ private fun DoseForm( // Edit mode set to Skipped: a skip has no amount, time or site of its own, so those fields go. val skipped = status == LogStatus.SKIPPED val units = unitsFor(compound.baseUnit, formulation) - var unit by remember { mutableStateOf(initialAmount?.unit ?: planned?.unit ?: if (compound.baseUnit == BaseUnit.IU) DoseUnit.IU else DoseUnit.MG) } - var text by remember { mutableStateOf(initialAmount?.let { formatNumber(it.value, 4) } ?: "") } - var time by remember { mutableStateOf(initialTime) } - var usingNow by remember { mutableStateOf(!editing && abs(initialTime.epochSecond - now.epochSecond) < 60) } - var pickTime by remember { mutableStateOf(false) } - var note by remember { mutableStateOf(initialNote) } - // Starts again when the history arrives after the sheet opened. - var chosenSite by remember(site?.initial) { mutableStateOf(site?.initial) } + var unit by rememberSaveable { mutableStateOf(initialAmount?.unit ?: planned?.unit ?: if (compound.baseUnit == BaseUnit.IU) DoseUnit.IU else DoseUnit.MG) } + var text by rememberSaveable { mutableStateOf(initialAmount?.let { formatNumber(it.value, 4) } ?: "") } + var time by rememberSaveable { mutableStateOf(initialTime) } + var usingNow by rememberSaveable { mutableStateOf(!editing && abs(initialTime.epochSecond - now.epochSecond) < 60) } + var pickTime by rememberSaveable { mutableStateOf(false) } + var note by rememberSaveable { mutableStateOf(initialNote) } + // History may arrive after restoration; keep an explicit choice while suggestions load. + var sitePicked by rememberSaveable { mutableStateOf(false) } + var pickedSite by rememberSaveable { mutableStateOf(null) } + val chosenSite = if (sitePicked) pickedSite else site?.initial val value = text.toDecimal()?.takeIf { it > 0 } // A field that still shows the plan (or the dose being edited) saves that amount, not its rounded copy. @@ -378,7 +383,7 @@ private fun DoseForm( } } - if (site != null) SiteRow(site, chosenSite, slotTimes.dateOf(now, zone), zone, slotTimes) { chosenSite = it } + if (site != null) SiteRow(site, chosenSite, slotTimes.dateOf(now, zone), zone, slotTimes) { pickedSite = it; sitePicked = true } if (status != null) Segmented(LogStatus.entries, status, { if (it == LogStatus.TAKEN) "Taken" else "Skipped" }) { onStatus(it) } diff --git a/app/src/main/kotlin/com/apollof/protocoltracker/ui/today/SiteRow.kt b/app/src/main/kotlin/com/apollof/protocoltracker/ui/today/SiteRow.kt index c9a1372..e68f22b 100644 --- a/app/src/main/kotlin/com/apollof/protocoltracker/ui/today/SiteRow.kt +++ b/app/src/main/kotlin/com/apollof/protocoltracker/ui/today/SiteRow.kt @@ -9,7 +9,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -37,7 +37,7 @@ import java.time.ZoneId @Composable internal fun SiteRow(choice: SiteChoice, selected: String?, today: LocalDate, zone: ZoneId, slotTimes: SlotTimes, onSelect: (String?) -> Unit) { val c = Tracker.colors - var expanded by remember { mutableStateOf(false) } + var expanded by rememberSaveable { mutableStateOf(false) } val shown = if (expanded) choice.all else choice.offered Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { diff --git a/app/src/main/kotlin/com/apollof/protocoltracker/ui/today/TodayScreen.kt b/app/src/main/kotlin/com/apollof/protocoltracker/ui/today/TodayScreen.kt index d6acc1e..f05944d 100644 --- a/app/src/main/kotlin/com/apollof/protocoltracker/ui/today/TodayScreen.kt +++ b/app/src/main/kotlin/com/apollof/protocoltracker/ui/today/TodayScreen.kt @@ -105,16 +105,25 @@ import com.apollof.protocoltracker.ui.theme.SectionLabelStyle import com.apollof.protocoltracker.ui.theme.Spacing import com.apollof.protocoltracker.ui.theme.Tracker import com.apollof.protocoltracker.ui.theme.TrackerType +import com.apollof.protocoltracker.ui.components.jsonSaver import java.time.LocalTime import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +@Serializable private sealed interface Sheet { + @Serializable data class Dose(val target: LogTarget) : Sheet + @Serializable data object BloodPressure : Sheet + @Serializable data object Note : Sheet + @Serializable data object Symptoms : Sheet + @Serializable data object Bloodwork : Sheet /** A journal entry from "Logged today", opened to change or delete it. */ + @Serializable data class Entry(val entry: JournalEntry) : Sheet } @@ -134,7 +143,7 @@ fun TodayScreen(onOpenSettings: () -> Unit, onOpenPlan: () -> Unit, onImportBloo var pickingTime by remember { mutableStateOf(false) } val snackbar = remember { SnackbarHostState() } val scope = rememberCoroutineScope() - var sheet by remember { mutableStateOf(null) } + var sheet by rememberSaveable(stateSaver = jsonSaver()) { mutableStateOf(null) } var weekOpen by rememberSaveable { mutableStateOf(false) } val motion = Motions.current val c = Tracker.colors @@ -153,7 +162,7 @@ fun TodayScreen(onOpenSettings: () -> Unit, onOpenPlan: () -> Unit, onImportBloo // A chosen check time is for one sitting: leaving Today (or the app) sets the clock back to now. LifecycleEventEffect(Lifecycle.Event.ON_STOP) { vm.setLogTime(null) } - var logMenu by remember { mutableStateOf(false) } + var logMenu by rememberSaveable { mutableStateOf(false) } Scaffold( containerColor = c.bg, snackbarHost = { SnackbarHost(snackbar) }, diff --git a/app/src/main/kotlin/com/apollof/protocoltracker/ui/today/TodayViewModel.kt b/app/src/main/kotlin/com/apollof/protocoltracker/ui/today/TodayViewModel.kt index d607ba8..3071a07 100644 --- a/app/src/main/kotlin/com/apollof/protocoltracker/ui/today/TodayViewModel.kt +++ b/app/src/main/kotlin/com/apollof/protocoltracker/ui/today/TodayViewModel.kt @@ -84,6 +84,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable /** Section order (injectable, oral, support, peptide), then the plan's own order. */ private val todayOrder = compareBy( @@ -186,11 +187,15 @@ data class DayUi( ) /** What the log sheet edits: a scheduled dose (pending or already logged) or a new unscheduled one. */ +@Serializable sealed interface LogTarget { /** [backfill]: opened from a past day, so the time starts at the planned time. */ + @Serializable data class Scheduled(val occurrence: Occurrence, val compound: Compound, val existing: DoseLog?, val partLabel: String, val backfill: Boolean = false) : LogTarget + @Serializable data class Unscheduled(val compound: Compound?) : LogTarget /** A logged dose, planned or extra, opened to change it or delete it. */ + @Serializable data class Edit(val log: DoseLog) : LogTarget } diff --git a/app/src/test/kotlin/com/apollof/protocoltracker/ui/SheetRestorationTest.kt b/app/src/test/kotlin/com/apollof/protocoltracker/ui/SheetRestorationTest.kt new file mode 100644 index 0000000..7e24128 --- /dev/null +++ b/app/src/test/kotlin/com/apollof/protocoltracker/ui/SheetRestorationTest.kt @@ -0,0 +1,454 @@ +package com.apollof.protocoltracker.ui + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.ComposeTimeoutException +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isRoot +import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.printToString +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.apollof.protocoltracker.ProtocolTrackerApp +import com.apollof.protocoltracker.data.TimeFormat +import com.apollof.protocoltracker.dayStartsAtMidnight +import com.apollof.protocoltracker.domain.model.Amount +import com.apollof.protocoltracker.domain.model.DaySlot +import com.apollof.protocoltracker.domain.model.DoseBasis +import com.apollof.protocoltracker.domain.model.DoseUnit +import com.apollof.protocoltracker.domain.model.Formulation +import com.apollof.protocoltracker.domain.model.HAIR_SHEDDING_LABELS +import com.apollof.protocoltracker.domain.model.InjectionSites +import com.apollof.protocoltracker.domain.model.JournalEntry +import com.apollof.protocoltracker.domain.model.MarkerResult +import com.apollof.protocoltracker.domain.model.PlanItem +import com.apollof.protocoltracker.domain.model.Schedule +import com.apollof.protocoltracker.domain.model.SiteChoice +import com.apollof.protocoltracker.domain.model.SiteWrite +import com.apollof.protocoltracker.domain.model.Timing +import com.apollof.protocoltracker.domain.pk.LabUnits +import com.apollof.protocoltracker.domain.pk.Presets +import com.apollof.protocoltracker.domain.units.DisplayFormat +import com.apollof.protocoltracker.ui.health.BloodworkInput +import com.apollof.protocoltracker.ui.health.BloodworkSheet +import com.apollof.protocoltracker.ui.health.SymptomInput +import com.apollof.protocoltracker.ui.health.SymptomSheet +import com.apollof.protocoltracker.ui.journal.JournalScreen +import com.apollof.protocoltracker.ui.plan.ItemEditorScreen +import com.apollof.protocoltracker.ui.theme.ProtocolTrackerTheme +import com.apollof.protocoltracker.ui.today.LogDoseSheet +import com.apollof.protocoltracker.ui.today.LogTarget +import com.apollof.protocoltracker.ui.today.NoteSheet +import com.apollof.protocoltracker.ui.today.TodayScreen +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +/** Restore the real screen as well as its sheet: preserving fields alone would still dismiss the draft. */ +@RunWith(AndroidJUnit4::class) +@Config(qualifiers = "w411dp-h2400dp") +class SheetRestorationTest { + @get:Rule + val compose = createComposeRule() + + private val restoration by lazy { StateRestorationTester(compose) } + private val container get() = ApplicationProvider.getApplicationContext().container + private fun shown(text: String) = compose.onAllNodesWithText(text, useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty() + private fun waitFor(text: String) { + try { + compose.waitUntil(30_000) { shown(text) } + } catch (e: ComposeTimeoutException) { + val roots = compose.onAllNodes(isRoot(), useUnmergedTree = true) + val tree = roots.fetchSemanticsNodes().indices.joinToString("\n") { roots[it].printToString() } + throw AssertionError("Waiting for $text\n$tree", e) + } + } + private fun click(text: String) = compose.onNodeWithText(text).performSemanticsAction(SemanticsActions.OnClick) + private fun field(label: String) = compose.onNode(hasSetTextAction() and hasText(label)) + private fun type(label: String, text: String) = field(label).performTextReplacement(text) + + @Before + fun setFormats(): Unit = runBlocking { + container.dayStartsAtMidnight() + container.settings.update { it.copy(timeFormat = TimeFormat.H24) } + DisplayFormat.current = DisplayFormat(use24Hour = true) + } + + @After + fun resetFormats() { DisplayFormat.current = DisplayFormat() } + + private fun openTodaySheet(name: String) { + runBlocking { container.repository.seedPresets() } + restoration.setContent { ProtocolTrackerTheme { TodayScreen(onOpenSettings = {}, onOpenPlan = {}) } } + waitFor("Log") + compose.onNodeWithText("Log", useUnmergedTree = true).performClick() + waitFor(name) + click(name) + } + + private fun restore() { + restoration.emulateSavedInstanceStateRestore() + compose.waitForIdle() + } + + private fun saveEntry(): JournalEntry { + click("Save") + compose.waitUntil(15_000) { runBlocking { container.repository.journalNow() }.isNotEmpty() } + return runBlocking { container.repository.journalNow().maxBy { it.createdAt } } + } + + @Test + fun extraDoseKeepsCompoundAmountUnitAndNote() { + openTodaySheet("Extra dose") + waitFor("Choose compound") + type("Search", "anastrozole") + waitFor("Arimidex (anastrozole)") + click("Arimidex (anastrozole)") + type("Dose", "0.375") + click(DoseUnit.MCG.label) + type("Note (optional)", "Extra after lunch") + + restore() + + waitFor("LOG EXTRA DOSE") + field("Dose").assert(hasText("375")) + field("Note (optional)").assert(hasText("Extra after lunch")) + click("Log 375 ${DoseUnit.MCG.label}") + compose.waitUntil(15_000) { runBlocking { container.repository.allLogsNow() }.isNotEmpty() } + val saved = runBlocking { container.repository.allLogsNow().maxBy { it.createdAt } } + assertEquals("preset:anastrozole", saved.compoundId) + assertEquals(Amount(375.0, DoseUnit.MCG), saved.amount) + assertEquals("Extra after lunch", saved.note) + } + + @Test + fun compoundSearchSurvivesBeforeSelection() { + openTodaySheet("Extra dose") + waitFor("Choose compound") + type("Search", "anastrozole") + restore() + waitFor("Choose compound") + field("Search").assert(hasText("anastrozole")) + } + + @Test + fun explicitSiteSurvivesRestorationAndReloadedHistory() { + val compound = Presets.byId("preset:test-cyp")!! + val chosen = InjectionSites.all[1].key + val history = mutableStateOf(SiteChoice(null, InjectionSites.all[0].key)) + var saved: SiteWrite? = null + restoration.setContent { + ProtocolTrackerTheme { + LogDoseSheet( + LogTarget.Unscheduled(compound), listOf(compound), Instant.parse("2026-10-01T12:00:00Z"), ZoneOffset.UTC, + onDismiss = {}, onSaveScheduled = { _, _, _, _, _ -> }, onSkip = { _, _ -> }, + onSaveUnscheduled = { _, _, _, _, site -> saved = site }, sites = { _, _ -> history.value }, + ) + } + } + waitFor("All sites") + click("All sites") + compose.onNodeWithContentDescription(InjectionSites.longLabel(chosen)).performSemanticsAction(SemanticsActions.OnClick) + type("Dose", "100") + compose.runOnIdle { history.value = SiteChoice(null, null) } + restore() + compose.runOnIdle { history.value = SiteChoice(null, InjectionSites.all[2].key) } + compose.onNodeWithContentDescription(InjectionSites.longLabel(chosen)).assertExists() + click("Log 100 mg") + compose.waitUntil(15_000) { saved != null } + assertEquals(SiteWrite.Set(chosen), saved) + } + + @Test + fun explicitlyClearedSiteStaysEmptyWhenHistoryReloads() { + val compound = Presets.byId("preset:test-cyp")!! + val suggested = InjectionSites.all[0].key + val history = mutableStateOf(SiteChoice(null, suggested)) + var saved: SiteWrite? = null + restoration.setContent { + ProtocolTrackerTheme { + LogDoseSheet( + LogTarget.Unscheduled(compound), listOf(compound), Instant.parse("2026-10-01T12:00:00Z"), ZoneOffset.UTC, + onDismiss = {}, onSaveScheduled = { _, _, _, _, _ -> }, onSkip = { _, _ -> }, + onSaveUnscheduled = { _, _, _, _, site -> saved = site }, sites = { _, _ -> history.value }, + ) + } + } + compose.onNodeWithContentDescription(InjectionSites.longLabel(suggested)).performSemanticsAction(SemanticsActions.OnClick) + type("Dose", "100") + restore() + compose.runOnIdle { history.value = SiteChoice(null, InjectionSites.all[2].key) } + click("Log 100 mg") + compose.waitUntil(15_000) { saved != null } + assertEquals(SiteWrite.Set(null), saved) + } + + @Test + fun scheduledDoseKeepsItsOccurrenceAndAdjustedAmount() { + runBlocking { + container.repository.seedPresets() + container.repository.saveItem( + PlanItem( + "rotation-item", null, "preset:test-cyp", Amount(100.0, DoseUnit.MG), DoseBasis.PER_DOSE, + Formulation(perMl = 200.0), Schedule.Daily(listOf(Timing.Slot(DaySlot.ANY_TIME))), + startDate = LocalDate.now(container.zone()), + ), + ) + } + restoration.setContent { ProtocolTrackerTheme { TodayScreen(onOpenSettings = {}, onOpenPlan = {}) } } + waitFor("100 mg · 0.5 mL") + click("100 mg · 0.5 mL") + waitFor("Dose") + type("Dose", "125") + restore() + field("Dose").assert(hasText("125")) + click("Log 125 mg") + compose.waitUntil(15_000) { runBlocking { container.repository.allLogsNow() }.any { it.planItemId == "rotation-item" } } + val saved = runBlocking { container.repository.allLogsNow().first { it.planItemId == "rotation-item" } } + assertEquals(Amount(125.0, DoseUnit.MG), saved.amount) + assertEquals(Amount(100.0, DoseUnit.MG), saved.plannedAmount) + } + + @Test + fun noteKeepsUnsavedTextAndDismissalStartsAFreshDraft() { + openTodaySheet("Note") + waitFor("What happened") + type("What happened", "A note before rotation") + restore() + waitFor("What happened") + field("What happened").assert(hasText("A note before rotation")) + click("Cancel") + waitFor("Log") + compose.onNodeWithText("Log", useUnmergedTree = true).performClick() + waitFor("Note") + click("Note") + waitFor("What happened") + field("What happened").assert(hasText("")) + } + + @Test + fun symptomsKeepSelectionsAndNote() { + openTodaySheet("Symptoms") + waitFor("Night sweats") + click("Night sweats") + click("7") + click(HAIR_SHEDDING_LABELS[1]) + type("Note (optional)", "Sleep interrupted") + restore() + waitFor("Night sweats") + field("Note (optional)").assert(hasText("Sleep interrupted")) + val saved = saveEntry() as JournalEntry.Symptoms + assertEquals(listOf("night_sweats"), saved.symptoms) + assertEquals(7, saved.mood) + assertEquals(2, saved.hairShedding) + assertEquals("Sleep interrupted", saved.note) + } + + @Test + fun clearedMoodHairAndPickedTimeDoNotReturnOnRestoration() { + val oldTime = Instant.parse("2026-10-01T07:30:00Z") + val now = Instant.parse("2026-10-02T12:00:00Z") + val original = JournalEntry.Symptoms("symptoms", oldTime, listOf("night_sweats"), 7, 2, createdAt = oldTime) + var saved: SymptomInput? = null + restoration.setContent { + ProtocolTrackerTheme { + SymptomSheet(now, ZoneOffset.UTC, onDismiss = {}, onSave = { saved = it }, existing = original) + } + } + click("7") + click(HAIR_SHEDDING_LABELS[1]) + compose.onNodeWithText("Now ·", substring = true).performSemanticsAction(SemanticsActions.OnClick) + restore() + click("Save") + val result = assertNotNull(saved) + assertEquals(null, result.mood) + assertEquals(null, result.hairShedding) + assertEquals(now, result.at) + assertEquals(original.symptoms, result.symptoms) + } + + @Test + fun bloodPressureKeepsAllFields() { + openTodaySheet("Blood pressure") + waitFor("Systolic") + type("Systolic", "123") + type("Diastolic", "81") + type("Pulse (optional)", "67") + type("Note (optional)", "Seated") + restore() + waitFor("Systolic") + field("Systolic").assert(hasText("123")) + field("Diastolic").assert(hasText("81")) + field("Pulse (optional)").assert(hasText("67")) + val saved = saveEntry() as JournalEntry.BloodPressure + assertEquals(123, saved.systolic) + assertEquals(81, saved.diastolic) + assertEquals(67, saved.pulse) + assertEquals("Seated", saved.note) + } + + @Test + fun bloodworkKeepsTypedMarkersUnitsAndDrawDetails() { + openTodaySheet("Bloodwork") + compose.waitUntil(15_000) { compose.onAllNodesWithText("More markers", substring = true).fetchSemanticsNodes().isNotEmpty() } + compose.onNodeWithText("More markers", substring = true).performSemanticsAction(SemanticsActions.OnClick) + click(LabUnits.SI.label) + type("Hemoglobin", "9.9") + type("Lab (optional)", "Lab A") + type("Note (optional)", "Fasted") + restore() + compose.waitUntil(15_000) { compose.onAllNodesWithText("More markers", substring = true).fetchSemanticsNodes().isNotEmpty() } + field("Hemoglobin").assert(hasText("9.9")) + val saved = saveEntry() as JournalEntry.Bloodwork + assertEquals(15.9489, saved.result("hemoglobin")!!.value, 0.00001) + assertEquals("Lab A", saved.lab) + assertEquals("Fasted", saved.note) + } + + @Test + fun journalNoteSheetAlsoKeepsItsDraft() { + restoration.setContent { ProtocolTrackerTheme { JournalScreen(onOpenSettings = {}) } } + compose.onNodeWithContentDescription("Add entry").performSemanticsAction(SemanticsActions.OnClick) + waitFor("Note") + click("Note") + waitFor("What happened") + type("What happened", "Journal draft") + restore() + waitFor("What happened") + field("What happened").assert(hasText("Journal draft")) + assertEquals("Journal draft", (saveEntry() as JournalEntry.Note).text) + } + + @Test + fun planCompoundPickerKeepsItsSearch() { + runBlocking { container.repository.seedPresets() } + restoration.setContent { + ProtocolTrackerTheme { ItemEditorScreen(null, null, onDone = {}, onNewCompound = {}) } + } + waitFor("Choose compound") + type("Search", "anastrozole") + restore() + field("Search").assert(hasText("anastrozole")) + click("Arimidex (anastrozole)") + compose.onNodeWithText("Choose compound").assertDoesNotExist() + restore() + compose.onNodeWithText("Choose compound").assertDoesNotExist() + } + + @Test + fun earlierTimeAndOpenPickerSurviveRestoration() { + DisplayFormat.current = DisplayFormat(use24Hour = true) + val now = Instant.parse("2026-10-02T00:30:00Z") + val expected = Instant.parse("2026-10-01T23:30:00Z") + var saved: Instant? = null + restoration.setContent { + ProtocolTrackerTheme { NoteSheet(now, ZoneOffset.UTC, onDismiss = {}, onSave = { _, at -> saved = at }) } + } + type("What happened", "Before midnight") + click("Earlier…") + compose.waitUntil(5_000) { + compose.onAllNodes(hasContentDescription("23", substring = true), useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty() + } + compose.onAllNodes(hasContentDescription("23", substring = true), useUnmergedTree = true)[0] + .performClick() + restore() + click("OK") + restore() + click("Save") + assertEquals(expected, saved) + } + + @Test + fun journalDoseEditKeepsItsSnapshotAndChanges() { + runBlocking { + container.repository.seedPresets() + val compound = Presets.byId("preset:test-cyp")!! + container.repository.logUnscheduled(compound, Amount(125.0, DoseUnit.MG), compound.defaultFormulation, Instant.now()) + } + val original = runBlocking { container.repository.allLogsNow().single() } + restoration.setContent { ProtocolTrackerTheme { JournalScreen(onOpenSettings = {}) } } + waitFor("125 mg · 0.63 mL") + compose.onNode(hasClickAction() and hasText("125 mg", substring = true)).performSemanticsAction(SemanticsActions.OnClick) + waitFor("EDIT DOSE") + type("Dose", "150") + type("Note (optional)", "Changed before rotation") + restore() + field("Dose").assert(hasText("150")) + click("Save") + compose.waitUntil(15_000) { + runBlocking { container.repository.allLogsNow().single().note } == "Changed before rotation" + } + val saved = runBlocking { container.repository.allLogsNow().single() } + assertEquals(original.copy(amount = Amount(150.0, DoseUnit.MG), note = "Changed before rotation"), saved) + } + + @Test + fun todayExistingNoteKeepsTheEntryBeingEdited() { + val at = Instant.now() + val original = JournalEntry.Note("existing-note", at, "Original note", at) + runBlocking { container.repository.saveJournal(original) } + restoration.setContent { ProtocolTrackerTheme { TodayScreen(onOpenSettings = {}, onOpenPlan = {}) } } + waitFor("Original note") + click("Original note") + waitFor("What happened") + type("What happened", "Edited before rotation") + restore() + field("What happened").assert(hasText("Edited before rotation")) + click("Save") + compose.waitUntil(15_000) { + runBlocking { (container.repository.journalNow().single() as JournalEntry.Note).text } == "Edited before rotation" + } + val saved = runBlocking { container.repository.journalNow().single() as JournalEntry.Note } + assertEquals(original.id, saved.id) + assertEquals(original.createdAt.toEpochMilli(), saved.createdAt.toEpochMilli()) + assertEquals(original.at.toEpochMilli(), saved.at.toEpochMilli()) + } + + @Test + fun bloodworkEditKeepsClearedAndUntouchedResultsAndDrawPickers() { + val at = Instant.parse("2026-10-01T07:30:00Z") + val kept = MarkerResult("lh", 0.3, qualifier = "<", refLow = 1.7, refHigh = 8.6) + val removed = MarkerResult("creatinine", 1.085952) + val original = JournalEntry.Bloodwork("draw", at, listOf(kept, removed), createdAt = at) + var saved: BloodworkInput? = null + restoration.setContent { + ProtocolTrackerTheme { + BloodworkSheet(at, ZoneOffset.UTC, LabUnits.CONVENTIONAL, onDismiss = {}, onSave = { saved = it }, existing = original) + } + } + type("Creatinine", "") + compose.onNodeWithContentDescription("Choose Blood draw").performSemanticsAction(SemanticsActions.OnClick) + waitFor("Thursday, October 15, 2026") + click("Thursday, October 15, 2026") + restore() + click("OK") + compose.onNodeWithContentDescription("Choose Time").performSemanticsAction(SemanticsActions.OnClick) + restore() + click("OK") + restore() + click("Save") + val result = assertNotNull(saved) + assertEquals(listOf(kept), result.results) + assertEquals(Instant.parse("2026-10-15T07:30:00Z"), result.at) + } +} diff --git a/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/schedule/Occurrences.kt b/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/schedule/Occurrences.kt index 64d10a2..db8c414 100644 --- a/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/schedule/Occurrences.kt +++ b/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/schedule/Occurrences.kt @@ -2,6 +2,8 @@ package com.apollof.protocoltracker.domain.schedule import com.apollof.protocoltracker.domain.model.Amount import com.apollof.protocoltracker.domain.model.DaySlot +import com.apollof.protocoltracker.domain.model.InstantS +import com.apollof.protocoltracker.domain.model.LocalDateS import com.apollof.protocoltracker.domain.model.Phase import com.apollof.protocoltracker.domain.model.PlanItem import com.apollof.protocoltracker.domain.model.Schedule @@ -18,6 +20,7 @@ import java.time.ZonedDateTime import java.time.temporal.ChronoUnit import kotlin.math.ceil import kotlin.math.roundToLong +import kotlinx.serialization.Serializable /** Clock times for the parts of the day, from settings. */ data class SlotTimes( @@ -34,16 +37,17 @@ data class SlotTimes( } } +@Serializable data class Occurrence( val key: String, val item: PlanItem, /** Nominal time: the exact time, or the slot's clock time. Used for curves, sorting and "scheduled" logging. */ - val at: Instant, - val localDate: LocalDate, + val at: InstantS, + val localDate: LocalDateS, /** Null for interval schedules (every X hours). */ val timing: Timing?, /** When to remind; null when the item has reminders off. */ - val remindAt: Instant?, + val remindAt: InstantS?, /** Wall-clock time fell into a DST gap and was moved forward. */ val shifted: Boolean, ) {