Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -44,7 +44,7 @@ fun CompoundPicker(
pinned: Set<String> = 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = {},
Expand All @@ -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") } },
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <reified T> jsonSaver(): Saver<T, String> = Saver(
save = { Json.encodeToString(it) },
restore = { Json.decodeFromString<T>(it) },
)

/** Wrap null so clearing an existing value restores null instead of running its initializer again. */
internal fun <T> nullableSaver(): Saver<T?, Any> = listSaver<T?, T?>(
save = { listOf(it) }, restore = { it.single() },
)

internal inline fun <reified V> stateMapSaver(): Saver<SnapshotStateMap<String, V>, String> = Saver(
save = { Json.encodeToString(it.toMap()) },
restore = { Json.decodeFromString<Map<String, V>>(it).toList().toMutableStateMap() },
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -92,17 +97,17 @@ fun SymptomSheet(
existing: JournalEntry.Symptoms? = null,
onDelete: (() -> Unit)? = null,
) {
val selected = remember { mutableStateMapOf<String, Boolean>().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<Boolean>()) { mutableStateMapOf<String, Boolean>().apply { existing?.symptoms?.forEach { put(it, true) } } }
var mood by rememberSaveable(stateSaver = nullableSaver<Int>()) { mutableStateOf(existing?.mood) }
var hair by rememberSaveable(stateSaver = nullableSaver<Int>()) { mutableStateOf(existing?.hairShedding) }
var note by rememberSaveable { mutableStateOf(existing?.note ?: "") }
var time by rememberSaveable(stateSaver = nullableSaver<Instant>()) { 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),
Expand Down Expand Up @@ -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)
}
}
})
}

/**
Expand All @@ -172,12 +177,12 @@ fun BloodworkSheet(
measured: Set<String> = 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 }
Expand All @@ -188,15 +193,17 @@ 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<String>()) {
mutableStateMapOf<String, String>().apply {
BloodMarkers.all.forEach { m -> shown(m.key, defaultUnits).takeIf { it.isNotEmpty() }?.let { put(m.key, it) } }
unlisted.forEach { put(it.marker, it.printedNumber()) }
}
}
// 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<String>() }
val touched = rememberSaveable(saver = listSaver<SnapshotStateSet<String>, String>(
save = { it.toList() }, restore = { mutableStateSetOf(*it.toTypedArray()) },
)) { mutableStateSetOf<String>() }
// 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 }
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

Expand All @@ -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<Editing?>(null) }
var editing by rememberSaveable(stateSaver = jsonSaver<Editing?>()) { mutableStateOf<Editing?>(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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)) {
Expand Down Expand Up @@ -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<Instant>()) { 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),
Expand All @@ -113,7 +115,7 @@ fun BloodPressureSheet(
}
if (existing != null && onDelete != null) DeleteEntryButton(onDelete)
}
}
})
}

@OptIn(ExperimentalMaterial3Api::class)
Expand All @@ -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<Instant>()) { 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),
Expand All @@ -148,7 +150,7 @@ fun NoteSheet(
}
if (existing != null && onDelete != null) DeleteEntryButton(onDelete)
}
}
})
}

@Composable
Expand Down
Loading
Loading