Skip to content
Merged
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 @@ -36,15 +36,24 @@ 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<String, () -> 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(
onDismissRequest = onDismiss,
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") }
}
},
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -122,7 +129,9 @@ fun TodayScreen(onOpenSettings: () -> Unit, onOpenPlan: () -> Unit, onImportBloo
val upkeepFailed by vm.upkeepFailed.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<Sheet?>(null) }
Expand All @@ -141,6 +150,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(
Expand All @@ -158,6 +169,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)
}
Expand Down Expand Up @@ -277,6 +289,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)
Expand Down Expand Up @@ -385,6 +404,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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -309,21 +311,40 @@ 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<ChosenTime?>(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<LocalTime?> = 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) {
if (day.isToday) return logGroup(group)
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? {
Expand Down Expand Up @@ -532,21 +553,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. */
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ProtocolTrackerApp>().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() }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
Loading