From 79676c84b64de43d15c93037af8b6642a52e8d4a Mon Sep 17 00:00:00 2001 From: ApolloF Date: Sun, 11 Oct 2026 12:57:39 +0200 Subject: [PATCH] Today: check time in the header corner (draft for review) --- .../protocoltracker/ui/components/Pickers.kt | 13 +++- .../protocoltracker/ui/today/TodayScreen.kt | 38 +++++++++ .../ui/today/TodayViewModel.kt | 38 +++++++-- .../protocoltracker/ui/today/CheckTimeTest.kt | 78 +++++++++++++++++++ .../domain/schedule/LogicalDay.kt | 4 + .../domain/schedule/LogicalDayTest.kt | 11 +++ 6 files changed, 173 insertions(+), 9 deletions(-) create mode 100644 app/src/test/kotlin/com/apollof/protocoltracker/ui/today/CheckTimeTest.kt 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 ef8afdf..82854ed 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 @@ -36,7 +36,11 @@ import java.time.ZoneOffset @OptIn(ExperimentalMaterial3Api::class) @Composable -fun TimePickDialog(initial: LocalTime, onDismiss: () -> Unit, onConfirm: (LocalTime) -> Unit, title: String = "Time") { +fun TimePickDialog( + initial: LocalTime, onDismiss: () -> Unit, onConfirm: (LocalTime) -> Unit, title: String = "Time", + /** A third button ("Now") beside Cancel, when the choice can be undone. */ + reset: Pair Unit>? = null, +) { // The dial follows Settings › Units and formats (12-hour with AM/PM). val state = rememberTimePickerState(initial.hour, initial.minute, is24Hour = DisplayFormat.current.use24Hour) AlertDialog( @@ -44,7 +48,12 @@ fun TimePickDialog(initial: LocalTime, onDismiss: () -> Unit, onConfirm: (LocalT title = { Text(title) }, text = { TimePicker(state) }, confirmButton = { TextButton(onClick = { onConfirm(LocalTime.of(state.hour, state.minute)) }) { Text("OK") } }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + dismissButton = { + Row { + reset?.let { (label, action) -> TextButton(onClick = action) { Text(label) } } + TextButton(onClick = onDismiss) { Text("Cancel") } + } + }, ) } 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 7030810..11b2e1c 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 @@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement @@ -31,6 +32,7 @@ import androidx.compose.material.icons.outlined.EditNote import androidx.compose.material.icons.outlined.History import androidx.compose.material.icons.outlined.MonitorHeart import androidx.compose.material.icons.outlined.Remove +import androidx.compose.material.icons.outlined.Schedule import androidx.compose.material.icons.outlined.Sick import androidx.compose.material.icons.outlined.Vaccines import androidx.compose.material.icons.outlined.WarningAmber @@ -60,10 +62,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.apollof.protocoltracker.domain.model.DoseLog import com.apollof.protocoltracker.domain.model.HAIR_SHEDDING_LABELS @@ -89,6 +94,7 @@ import com.apollof.protocoltracker.ui.components.WeekStripCompact import com.apollof.protocoltracker.ui.components.WeekStripFull import com.apollof.protocoltracker.ui.components.SectionLabel import com.apollof.protocoltracker.ui.components.SettingsButton +import com.apollof.protocoltracker.ui.components.TimePickDialog import com.apollof.protocoltracker.ui.components.timingIcon import com.apollof.protocoltracker.ui.health.toEntry import com.apollof.protocoltracker.ui.health.BloodworkSheet @@ -99,6 +105,7 @@ 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 java.time.LocalTime import kotlinx.coroutines.launch private sealed interface Sheet { @@ -121,7 +128,9 @@ fun TodayScreen(onOpenSettings: () -> Unit, onOpenPlan: () -> Unit, onImportBloo val lastDraw by vm.lastDraw.collectAsStateWithLifecycle() val siteLogs by vm.siteLogs.collectAsStateWithLifecycle() val measuredMarkers by vm.measuredMarkers.collectAsStateWithLifecycle() + val logTime by vm.logTime.collectAsStateWithLifecycle() var pickingDay by remember { mutableStateOf(false) } + var pickingTime by remember { mutableStateOf(false) } val snackbar = remember { SnackbarHostState() } val scope = rememberCoroutineScope() var sheet by remember { mutableStateOf(null) } @@ -140,6 +149,8 @@ fun TodayScreen(onOpenSettings: () -> Unit, onOpenPlan: () -> Unit, onImportBloo } // Back from a picked day returns to today first. BackHandler(enabled = selected != null) { vm.closeDay() } + // 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) } Scaffold( @@ -157,6 +168,7 @@ fun TodayScreen(onOpenSettings: () -> Unit, onOpenPlan: () -> Unit, onImportBloo ) { item(key = "header") { ScreenHeader("Today", eyebrow = state.dateLabel) { + LogTimeChip(logTime, onClick = { pickingTime = true }) IconButton(onClick = { pickingDay = true }, modifier = Modifier.size(48.dp)) { Icon(Icons.Outlined.CalendarMonth, contentDescription = "Open another day", tint = c.ink) } @@ -274,6 +286,13 @@ fun TodayScreen(onOpenSettings: () -> Unit, onOpenPlan: () -> Unit, onImportBloo onBloodwork = { logMenu = false; sheet = Sheet.Bloodwork }, bloodworkSubtitle = lastDraw ?: "Lab results of a blood draw", ) + if (pickingTime) TimePickDialog( + initial = logTime ?: vm.now().atZone(vm.zone()).toLocalTime(), + onDismiss = { pickingTime = false }, + onConfirm = { vm.setLogTime(it); pickingTime = false }, + title = "Checks record", + reset = ("Now" to { vm.setLogTime(null); pickingTime = false }).takeIf { logTime != null }, + ) if (pickingDay) DatePickDialog(vm.today(), onDismiss = { pickingDay = false }, onConfirm = { pickingDay = false vm.openDay(it) @@ -367,6 +386,25 @@ private fun TodayGroup(group: GroupUi, vm: TodayViewModel, openDose: (LogTarget) } } +/** The clock in Today's corner: "Now", or the time every check records until it is set back to now. */ +@Composable +private fun LogTimeChip(time: LocalTime?, onClick: () -> Unit) { + val c = Tracker.colors + val shape = RoundedCornerShape(50) + val pill = if (time != null) Modifier.background(c.accentSoft, shape) else Modifier.border(1.dp, c.line, shape) + Box( + Modifier.heightIn(min = 48.dp).clickable(role = Role.Button, onClickLabel = "Change check time", onClick = onClick) + .semantics { contentDescription = "Check time" }, + contentAlignment = Alignment.Center, + ) { + Row(pill.padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Outlined.Schedule, contentDescription = null, tint = if (time != null) c.accentText else c.muted, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(time?.format(Formats.time) ?: "Now", style = TrackerType.label, color = if (time != null) c.accentText else c.ink, maxLines = 1) + } + } +} + /** The one floating action on Today: log something that is not a planned dose. */ @Composable private fun LogButton(onClick: () -> Unit) { 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 253f077..ac86208 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 @@ -29,6 +29,7 @@ import com.apollof.protocoltracker.domain.model.measuredMarkers import com.apollof.protocoltracker.domain.model.shownAt import com.apollof.protocoltracker.domain.pk.LabUnits import com.apollof.protocoltracker.domain.schedule.Agenda +import com.apollof.protocoltracker.domain.schedule.atTimeOn import com.apollof.protocoltracker.domain.schedule.AgendaEntry import com.apollof.protocoltracker.domain.schedule.AgendaStatus import com.apollof.protocoltracker.domain.schedule.AgendaWindows @@ -61,6 +62,7 @@ import com.apollof.protocoltracker.ui.health.toEntry import com.apollof.protocoltracker.ui.minuteTicker import java.time.Instant import java.time.LocalDate +import java.time.LocalTime import java.time.ZoneId import java.time.format.TextStyle import java.util.Locale @@ -306,13 +308,31 @@ class TodayViewModel(private val c: AppContainer) : ViewModel() { fun shiftDay(days: Long) { selectedDay.value?.plusDays(days)?.let(::openDay) } fun today(): LocalDate = state.value.date ?: c.clock().atZone(c.zone()).toLocalDate() - /** The check on a picked day: past days record the planned time, today follows the usual check rules. */ + private data class ChosenTime(val day: LocalDate, val time: LocalTime) + + private val chosenTime = MutableStateFlow(null) + + /** The clock in Today's corner: the time every check records; null records as usual. It holds for the day it was set on. */ + val logTime: StateFlow = combine(chosenTime, today) { chosen, today -> chosen?.takeIf { it.day == today }?.time } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + fun setLogTime(time: LocalTime?) { chosenTime.value = time?.let { ChosenTime(today(), it) } } + + /** The chosen time on [date] (a picked day or today), or null when none is set. */ + private fun chosenAt(date: LocalDate): Instant? = + chosenTime.value?.takeIf { it.day == today() }?.let { state.value.slotTimes.atTimeOn(date, it.time, c.zone()) } + + /** " at 8:00" for a message about a check that recorded the chosen time. */ + private fun atSuffix(at: Instant?): String = at?.let { " at ${Formats.time(it, c.zone())}" }.orEmpty() + + /** The check on a picked day: past days record the chosen time or else the planned time, today follows the usual check rules. */ fun checkOnDay(item: DoseItem, day: DayUi) { val occ = item.entry.occurrence if (item.entry.log != null || occ == null || day.isToday) return check(item) if (day.isFuture) return val label = item.commonName.ifBlank { item.name.replaceFirstChar { it.titlecase() } } - launchLogged("$label logged for ${day.date.format(Formats.dayShort)}") { listOf(c.doseActions.take(occ, takenAt = occ.at)) } + val at = chosenAt(day.date) + launchLogged("$label logged for ${day.date.format(Formats.dayShort)}${atSuffix(at)}") { listOf(c.doseActions.take(occ, takenAt = at ?: occ.at)) } } fun logDayGroup(group: GroupUi, day: DayUi) { @@ -320,7 +340,8 @@ class TodayViewModel(private val c: AppContainer) : ViewModel() { if (day.isFuture) return val pending = group.items.filter { it.state.open }.mapNotNull { it.entry.occurrence } if (pending.isEmpty()) return - launchLogged("${pending.size} doses logged for ${day.date.format(Formats.dayShort)}") { pending.map { c.doseActions.take(it, takenAt = it.at) } } + val at = chosenAt(day.date) + launchLogged("${pending.size} doses logged for ${day.date.format(Formats.dayShort)}${atSuffix(at)}") { pending.map { c.doseActions.take(it, takenAt = at ?: it.at) } } } fun dayTarget(item: DoseItem, day: DayUi): LogTarget? { @@ -529,21 +550,24 @@ class TodayViewModel(private val c: AppContainer) : ViewModel() { return } val occ = item.entry.occurrence ?: return - launchLogged("$label taken${item.siteSuffix}") { listOf(c.doseActions.take(occ, site = item.siteWrite)) } + val at = chosenAt(today()) + launchLogged("$label taken${atSuffix(at)}${item.siteSuffix}") { listOf(c.doseActions.take(occ, takenAt = at, site = item.siteWrite)) } } /** Log all: each pending dose with the site its row shows. */ fun logGroup(group: GroupUi) { val pending = group.items.filter { it.state.open }.mapNotNull { item -> item.entry.occurrence?.let { it to item.siteWrite } } if (pending.isEmpty()) return - launchLogged("${pending.size} doses taken") { pending.map { (occ, site) -> c.doseActions.take(occ, site = site) } } + val at = chosenAt(today()) + launchLogged("${pending.size} doses taken${atSuffix(at)}") { pending.map { (occ, site) -> c.doseActions.take(occ, takenAt = at, site = site) } } } fun logMissedAsTaken(item: DoseItem) { val occ = item.entry.occurrence ?: return + val chosen = chosenAt(today()) // A dose that restarts its interval is recorded now, so the next one is counted from today. - val takenAt = if (occ.item.schedule.followsLastDose) c.clock() else occ.at - launchLogged("${item.commonName.ifBlank { item.name }} logged${item.siteSuffix}") { listOf(c.doseActions.take(occ, takenAt = takenAt, site = item.siteWrite)) } + val takenAt = chosen ?: if (occ.item.schedule.followsLastDose) c.clock() else occ.at + launchLogged("${item.commonName.ifBlank { item.name }} logged${atSuffix(chosen)}${item.siteSuffix}") { listOf(c.doseActions.take(occ, takenAt = takenAt, site = item.siteWrite)) } } /** Logs or re-logs a scheduled dose; undo restores the previous entry when one existed. */ diff --git a/app/src/test/kotlin/com/apollof/protocoltracker/ui/today/CheckTimeTest.kt b/app/src/test/kotlin/com/apollof/protocoltracker/ui/today/CheckTimeTest.kt new file mode 100644 index 0000000..e9d7ea5 --- /dev/null +++ b/app/src/test/kotlin/com/apollof/protocoltracker/ui/today/CheckTimeTest.kt @@ -0,0 +1,78 @@ +package com.apollof.protocoltracker.ui.today + +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.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.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.apollof.protocoltracker.ProtocolTrackerApp +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.PlanItem +import com.apollof.protocoltracker.domain.model.Schedule +import com.apollof.protocoltracker.domain.model.Timing +import com.apollof.protocoltracker.ui.theme.ProtocolTrackerTheme +import com.apollof.protocoltracker.waitForData +import java.time.Instant +import java.time.LocalDate +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** The clock in Today's corner: a chosen time is what a check records, and "Now" sets it back. */ +@RunWith(AndroidJUnit4::class) +class CheckTimeTest { + @get:Rule + val compose = createComposeRule() + + private val container get() = ApplicationProvider.getApplicationContext().container + + @Before + fun seed(): Unit = runBlocking { + container.dayStartsAtMidnight() + container.repository.seedPresets() + container.repository.saveItem( + PlanItem( + "test-item", null, "preset:test-cyp", Amount(250.0, DoseUnit.MG), DoseBasis.PER_WEEK, Formulation(perMl = 250.0), + Schedule.Daily(listOf(Timing.Slot(DaySlot.ANY_TIME))), startDate = LocalDate.now(), + ), + ) + } + + private fun count(text: String) = compose.onAllNodesWithText(text, substring = true).fetchSemanticsNodes().size + + @Test + fun aChosenTimeIsWhatTheCheckRecordsAndNowSetsItBack() { + compose.setContent { ProtocolTrackerTheme { TodayScreen(onOpenSettings = {}, onOpenPlan = {}) } } + compose.waitUntil(15_000) { count("35.71 mg · 0.14 mL") > 0 } + + compose.onNode(hasContentDescription("Check time") and hasText("Now")).performClick() + compose.onNodeWithText("Checks record").assertExists() + // The dial opens at the current time; OK keeps its hour and minute. + compose.onNodeWithText("OK").performClick() + compose.waitUntil(5_000) { compose.onAllNodesWithText("Now").fetchSemanticsNodes().isEmpty() } + + compose.onNodeWithContentDescription("Mark Test C taken").performClick() + compose.waitForData(15_000) { container.repository.allLogsNow().isNotEmpty() } + val taken = runBlocking { container.repository.allLogsNow().single().takenAt } + assertEquals(0, taken.epochSecond % 60, "the chosen minute, not the current second") + assertTrue(taken <= Instant.now() && taken > Instant.now().minusSeconds(180)) + compose.waitUntil(15_000) { count("Test C taken at") > 0 } + + compose.onNode(hasContentDescription("Check time")).performClick() + compose.onNodeWithText("Now").performClick() + compose.waitUntil(5_000) { compose.onAllNodes(hasContentDescription("Check time") and hasText("Now")).fetchSemanticsNodes().isNotEmpty() } + } +} diff --git a/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/schedule/LogicalDay.kt b/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/schedule/LogicalDay.kt index 834a302..24d2e0b 100644 --- a/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/schedule/LogicalDay.kt +++ b/core/domain/src/main/kotlin/com/apollof/protocoltracker/domain/schedule/LogicalDay.kt @@ -30,6 +30,10 @@ fun SlotTimes.dateOf(at: Instant, zone: ZoneId): LocalDate = logicalDate(at, zon fun SlotTimes.dayStartOf(date: LocalDate, zone: ZoneId): Instant = logicalDayStart(date, zone, dayStart) +/** [time] on the logical day [date]: a time before the day start is the night after (1:00 with a 4:00 day start). */ +fun SlotTimes.atTimeOn(date: LocalDate, time: LocalTime, zone: ZoneId): Instant = + ZonedDateTime.of(if (time < dayStart) date.plusDays(1) else date, time, zone).toInstant() + /** True when [at] falls within the logical day [date]. */ fun SlotTimes.isOn(at: Instant, date: LocalDate, zone: ZoneId): Boolean = at >= dayStartOf(date, zone) && at < dayStartOf(date.plusDays(1), zone) diff --git a/core/domain/src/test/kotlin/com/apollof/protocoltracker/domain/schedule/LogicalDayTest.kt b/core/domain/src/test/kotlin/com/apollof/protocoltracker/domain/schedule/LogicalDayTest.kt index eeff943..b5a9046 100644 --- a/core/domain/src/test/kotlin/com/apollof/protocoltracker/domain/schedule/LogicalDayTest.kt +++ b/core/domain/src/test/kotlin/com/apollof/protocoltracker/domain/schedule/LogicalDayTest.kt @@ -93,4 +93,15 @@ class LogicalDayTest { assertEquals(taken, sheetStartTime(occ, log(LogStatus.TAKEN, taken), now, LocalDate.parse("2026-09-25"), backfill = false)) assertEquals(now, sheetStartTime(occ, log(LogStatus.SKIPPED, occ.at), now, LocalDate.parse("2026-09-25"), backfill = false)) } + + @Test + fun aChosenTimeBeforeTheDayStartIsTheNightAfter() { + val night = SlotTimes(dayStart = four) + val day = LocalDate.parse("2026-09-25") + assertEquals(at("2026-09-25", "08:30"), night.atTimeOn(day, LocalTime.of(8, 30), zone)) + assertEquals(at("2026-09-25", "04:00"), night.atTimeOn(day, four, zone)) + assertEquals(at("2026-09-26", "01:30"), night.atTimeOn(day, LocalTime.of(1, 30), zone)) + assertEquals(day, night.dateOf(night.atTimeOn(day, LocalTime.of(1, 30), zone), zone)) + assertEquals(at("2026-09-25", "01:30"), SlotTimes(dayStart = LocalTime.MIDNIGHT).atTimeOn(day, LocalTime.of(1, 30), zone)) + } }