diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt index 9a0b4f1..ca94d23 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt @@ -15,6 +15,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import java.time.Duration @@ -159,13 +161,7 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) { // rather than recomputed at draw time: a percentage that comes back unchanged is an equal // value, Compose skips the recomposition, and a "left" that only ticked when the quota moved // would sit at a stale figure for hours. - var now by remember { mutableStateOf(OffsetDateTime.now()) } - LaunchedEffect(Unit) { - while (true) { - delay(REFRESH_MS) - now = OffsetDateTime.now() - } - } + val now = rememberUsageNow() // Nothing at all for a session that meters nothing: a row saying "unknown" there would report // a problem about a machine somebody chose, on every screen, forever. @@ -195,14 +191,7 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) { if (window == null) { UsageNote("Usage unknown -- no window duration was reported") } else { - LinearProgressIndicator( - progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) }, - // The same step at the same percentages as the dialog's bars: this is the - // same measurement, and a reader who learned the colour there has to be - // able to read it here without checking which screen they are on. - color = quotaColor(window.percent), - modifier = Modifier.weight(1f), - ) + UsageProgressIndicator(window, now, Modifier.weight(1f)) Text( usageWindowLabel(window, now), style = MaterialTheme.typography.labelSmall, @@ -215,6 +204,56 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) { } } +/** A clock shared by each usage surface, advanced independently of changes to the quota. */ +@Composable +internal fun rememberUsageNow(): OffsetDateTime { + var now by remember { mutableStateOf(OffsetDateTime.now()) } + LaunchedEffect(Unit) { + while (true) { + delay(REFRESH_MS) + now = OffsetDateTime.now() + } + } + return now +} + +/** The quota fill with a white tick showing how far the current time window has progressed. */ +@Composable +internal fun UsageProgressIndicator( + window: UsageWindow, + now: OffsetDateTime, + modifier: Modifier = Modifier, +) { + val elapsed = usageWindowElapsedFraction(window, now) + LinearProgressIndicator( + progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) }, + // The same step at the same percentages everywhere: this is the same measurement, and a + // reader who learned the colour on one surface should not have to relearn it on another. + color = quotaColor(window.percent), + modifier = + modifier.drawWithContent { + drawContent() + elapsed?.let { fraction -> + drawLine( + color = Color.White, + start = Offset(size.width * fraction, 0f), + end = Offset(size.width * fraction, size.height), + strokeWidth = 2.dp.toPx(), + ) + } + }, + ) +} + +/** Elapsed time divided by the reported window duration, or null when either value is unknown. */ +internal fun usageWindowElapsedFraction(window: UsageWindow, now: OffsetDateTime): Float? { + val durationMinutes = window.durationMinutes?.takeIf { it > 0 } ?: return null + val end = windowEnd(window.resetsAt, now) as? WindowEnd.Ends ?: return null + val remainingMinutes = + end.until.seconds.toDouble() / 60.0 + end.until.nano.toDouble() / 60_000_000_000.0 + return (1.0 - remainingMinutes / durationMinutes).coerceIn(0.0, 1.0).toFloat() +} + /** Anything this row says instead of drawing a bar, so all of them look the same. */ @Composable private fun UsageNote(text: String) { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt index affb69e..f1090fc 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -41,6 +40,7 @@ fun UsageDialog( onDismiss: () -> Unit, ) { var signingIn by remember { mutableStateOf(false) } + val now = rememberUsageNow() // A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the gaps // between its title, content and buttons at sizes meant for a sentence of prose and a decision; // this is a dense read-out, and those gaps left a band of empty dialog above Close that was @@ -88,7 +88,7 @@ fun UsageDialog( ) ) } - UsageBody(state, onSignIn = { signingIn = true }) + UsageBody(state, now, onSignIn = { signingIn = true }) } TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) { Text("Close") @@ -113,7 +113,11 @@ fun UsageDialog( /** What came back, or why nothing did. Split out so the dialog above reads as its own shape. */ @Composable -private fun UsageBody(state: LoadState>, onSignIn: () -> Unit) { +private fun UsageBody( + state: LoadState>, + now: OffsetDateTime, + onSignIn: () -> Unit, +) { Column { when (val current = state) { is LoadState.Loading -> CircularProgressIndicator() @@ -152,7 +156,7 @@ private fun UsageBody(state: LoadState>, onSignIn: () -> Uni if (windowIndex > 0) { Spacer(Modifier.height(12.dp)) } - WindowBar(window) + WindowBar(window, now) } } } @@ -219,7 +223,7 @@ private fun SnapshotState(snapshot: UsageSnapshot, onSignIn: () -> Unit) { } @Composable -private fun WindowBar(window: UsageWindow) { +private fun WindowBar(window: UsageWindow, now: OffsetDateTime) { Column { Row(modifier = Modifier.fillMaxWidth()) { Text( @@ -230,12 +234,8 @@ private fun WindowBar(window: UsageWindow) { Text("${window.percent.toInt()}%", style = MaterialTheme.typography.bodyMedium) } Spacer(Modifier.height(4.dp)) - LinearProgressIndicator( - progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) }, - color = quotaColor(window.percent), - modifier = Modifier.fillMaxWidth(), - ) - resetLine(window)?.let { + UsageProgressIndicator(window, now, Modifier.fillMaxWidth()) + resetLine(window, now)?.let { Spacer(Modifier.height(2.dp)) Text( it, @@ -254,8 +254,8 @@ private fun WindowBar(window: UsageWindow) { * failure appeared as an ISO string in a sentence written for a person. Both are named in * [WindowEnd], and the session bar words them the same way. */ -private fun resetLine(window: UsageWindow): String? = - when (val end = windowEnd(window.resetsAt, OffsetDateTime.now())) { +private fun resetLine(window: UsageWindow, now: OffsetDateTime): String? = + when (val end = windowEnd(window.resetsAt, now)) { WindowEnd.NotRunning -> null WindowEnd.Unreadable -> "reset time unreadable" is WindowEnd.Ends -> diff --git a/app/androidApp/src/test/kotlin/com/example/aiapp/SessionUsageTest.kt b/app/androidApp/src/test/kotlin/com/example/aiapp/SessionUsageTest.kt index efea96a..6249163 100644 --- a/app/androidApp/src/test/kotlin/com/example/aiapp/SessionUsageTest.kt +++ b/app/androidApp/src/test/kotlin/com/example/aiapp/SessionUsageTest.kt @@ -1,5 +1,6 @@ package com.example.aiapp +import java.time.OffsetDateTime import kotlin.test.Test import kotlin.test.assertEquals @@ -61,6 +62,52 @@ class SessionUsageTest { assertEquals(null, shortestUsageWindow(listOf(window("unknown", null)))) } + @Test + fun `the time cursor follows elapsed time through the window`() { + val now = OffsetDateTime.parse("2026-09-17T12:00:00Z") + + assertEquals( + 0.4f, + usageWindowElapsedFraction( + window("5-hour window", 300, "2026-09-17T15:00:00Z"), + now, + ), + ) + } + + @Test + fun `the time cursor clamps at the window ends`() { + val now = OffsetDateTime.parse("2026-09-17T12:00:00Z") + + assertEquals( + 0f, + usageWindowElapsedFraction( + window("5-hour window", 300, "2026-09-17T18:00:00Z"), + now, + ), + ) + assertEquals( + 1f, + usageWindowElapsedFraction( + window("5-hour window", 300, "2026-09-17T11:00:00Z"), + now, + ), + ) + } + + @Test + fun `the time cursor is absent without a usable duration and reset time`() { + val now = OffsetDateTime.parse("2026-09-17T12:00:00Z") + + assertEquals(null, usageWindowElapsedFraction(window("unknown", null), now)) + assertEquals(null, usageWindowElapsedFraction(window("not running", 300), now)) + assertEquals( + null, + usageWindowElapsedFraction(window("unreadable", 300, "not a timestamp"), now), + ) + assertEquals(null, usageWindowElapsedFraction(window("zero", 0), now)) + } + private fun snapshot( machine: String, provider: String, @@ -78,13 +125,13 @@ class SessionUsageTest { windows = emptyList(), ) - private fun window(label: String, durationMinutes: Long?) = + private fun window(label: String, durationMinutes: Long?, resetsAt: String? = null) = UsageWindow( kind = "test", label = label, percent = 12.0, durationMinutes = durationMinutes, - resetsAt = null, + resetsAt = resetsAt, active = false, ) }