diff --git a/.dev-updater.ron b/.dev-updater.ron index 5e24338..30df5a5 100644 --- a/.dev-updater.ron +++ b/.dev-updater.ron @@ -10,6 +10,13 @@ // A built APK wins: it is the authority on what will actually install. label: "AI Sessions", +// Where this project's own state lives, said rather than guessed: it is +// what the Uninstall dialog offers to delete, and a plausible-looking +// path it worked out would read as "this component keeps nothing here" +// when it was wrong. `Ron` rather than `Script` because the answer is +// three constants -- there is nothing here worth spawning a process for. +resources: Ron("resources.ron"), + // The two halves this checkout produces: the server a phone talks to, and // the app that talks to it. They are built in parallel -- this list is the // set, not a sequence, so nothing here should be read as an order. diff --git a/AGENTS.md b/AGENTS.md index 9b75d4d..a45b4bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,6 +81,13 @@ repo is in PLAN.md's "Backend layout" section. APK (built in `app/`), built in parallel. The project it serves is the repository, not either half of it, which is why this sits at the root rather than in `app/`. + It points at `resources.ron` beside it, which says this project keeps its + state as `ai-app` — so the Uninstall dialog offers `~/.local/share/ai-app` + and `~/.config/ai-app` instead of saying it cannot tell. That file is + *ours*, not Dev Updater's: it ignores keys it doesn't know, so anything + else worth keeping in one place belongs there too. Note what deleting the + config directory takes with it — the CA under `certs`, which is the + one-way door described below. `Managed` means Dev Updater supervises `ai-server` with its own built-in service implementation rather than a script kept here. ai-app had such a script until 2026-08-28 and it was the generic case exactly — no diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt new file mode 100644 index 0000000..b0e7bce --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt @@ -0,0 +1,37 @@ +package com.example.aiapp + +import java.time.Duration +import java.time.OffsetDateTime + +/** + * How long is left in a usage window. + * + * Shared by the session bar and the usage screen: the arithmetic is the same in both and only the + * sentence around it differs, so this returns the span on its own and leaves the wording to the + * caller. + */ + +/** "1d 4h", "3h 12m", "12m" -- the span alone, with no leading or trailing words. */ +fun formatSpan(until: Duration): String = + when { + until.toHours() >= 24 -> "${until.toDays()}d ${until.toHours() % 24}h" + until.toHours() > 0 -> "${until.toHours()}h ${until.toMinutes() % 60}m" + else -> "${until.toMinutes()}m" + } + +/** + * Time from [now] until [resetsAt], or null when that timestamp cannot be read. + * + * Null rather than a zero duration, because a string this app failed to parse is not a window that + * has just run out: a caller given zero for both would tell the reader to refresh on the strength + * of something nobody measured. + * + * [now] is a parameter rather than read here so a caller can drive it from state and have the + * countdown recompute on its own schedule. + */ +fun remainingUntil(resetsAt: String, now: OffsetDateTime): Duration? = + try { + Duration.between(now, OffsetDateTime.parse(resetsAt)) + } catch (_: Exception) { + null + } 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 ff566aa..fe75331 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt @@ -3,7 +3,6 @@ package com.example.aiapp import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -16,6 +15,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import java.time.Duration +import java.time.OffsetDateTime import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext @@ -66,6 +67,11 @@ private const val REFRESH_MS = 60_000L @Composable fun SessionUsageBar(settings: ServerSettings, setup: String, modifier: Modifier = Modifier) { var usage by remember(setup) { mutableStateOf(FiveHourUsage.Waiting) } + // The countdown moves even when the numbers do not, so it is driven by a clock this loop + // advances 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 ticks when the quota + // happens to move would sit at a stale figure for hours. + var now by remember { mutableStateOf(OffsetDateTime.now()) } LaunchedEffect(setup) { while (true) { @@ -76,6 +82,7 @@ fun SessionUsageBar(settings: ServerSettings, setup: String, modifier: Modifier } catch (e: ApiException) { FiveHourUsage.Unavailable(e.message ?: "couldn't reach the backend") } + now = OffsetDateTime.now() delay(REFRESH_MS) } } @@ -109,19 +116,42 @@ fun SessionUsageBar(settings: ServerSettings, setup: String, modifier: Modifier is FiveHourUsage.Known -> { LinearProgressIndicator( progress = { (state.percent / 100.0).toFloat().coerceIn(0f, 1f) }, + color = usageColor, modifier = Modifier.weight(1f), ) Text( - " ${state.percent.toInt()}% of 5h", + fiveHourLabel(state, now), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.width(84.dp), + modifier = Modifier.padding(start = 8.dp), ) } } } } +/** + * "42% -- 2h 15m left": how much is gone, then how long what is left has to last. + * + * The percentage on its own does not answer the question it gets asked, which is whether to start + * something now; 80% with twenty minutes to go and 80% with four hours to go are opposite answers. + * + * The window's end has its own missing case, kept apart from the rest: a snapshot can arrive with + * no reset time, and saying "refresh soon" there would put a recommendation on the screen that + * nothing measured. The percentage is still known, so it is still shown. + */ +private fun fiveHourLabel(state: FiveHourUsage.Known, now: OffsetDateTime): String { + val percent = "${state.percent.toInt()}%" + val until = state.resetsAt?.let { remainingUntil(it, now) } + return when { + until == null -> "$percent \u00b7 reset time unknown" + // Under a minute, including past the end: the number would round to "0m left", which reads + // as a measurement rather than as the window having run out. + until < Duration.ofMinutes(1) -> "$percent \u00b7 refresh soon" + else -> "$percent \u00b7 ${formatSpan(until)} left" + } +} + /** * The five-hour window for one machine, out of every machine's snapshot. * diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt index 452c444..e59a2c7 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt @@ -129,6 +129,17 @@ val awaitingColor: Color val warningColor: Color @Composable get() = Mocha.Yellow +/** + * How much of a quota window is gone, on the bar under a session's header. + * + * Blue because that bar reports a quantity rather than a verdict: it sits on a screen the reader + * opened to do something else, and the scheme's primary made it the loudest thing there. The usage + * screen is where the same number turns [warningColor] and then [overLimitColor] -- somebody + * looking at that screen came to be told where the limits are. + */ +val usageColor: Color + @Composable get() = Mocha.Blue + /** * Code: a fenced block, an inline span, a tool's input. * diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageScreen.kt index 860199c..682f89c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageScreen.kt @@ -25,7 +25,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import java.time.Duration import java.time.OffsetDateTime import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -180,15 +179,7 @@ private fun WindowBar(window: UsageWindow) { } /** "in 3h 12m" -- close enough for deciding whether to start a big task. */ -private fun formatReset(resetsAt: String): String = - try { - val until = Duration.between(OffsetDateTime.now(), OffsetDateTime.parse(resetsAt)) - when { - until.isNegative -> "soon" - until.toHours() >= 24 -> "in ${until.toDays()}d ${until.toHours() % 24}h" - until.toHours() > 0 -> "in ${until.toHours()}h ${until.toMinutes() % 60}m" - else -> "in ${until.toMinutes()}m" - } - } catch (_: Exception) { - "at $resetsAt" - } +private fun formatReset(resetsAt: String): String { + val until = remainingUntil(resetsAt, OffsetDateTime.now()) ?: return "at $resetsAt" + return if (until.isNegative) "soon" else "in ${formatSpan(until)}" +} diff --git a/resources.ron b/resources.ron new file mode 100644 index 0000000..21a10c2 --- /dev/null +++ b/resources.ron @@ -0,0 +1,27 @@ +// This project's own resources: where ai-app keeps what it accumulates +// and what somebody configured. +// +// Structured as the body of the struct, with no outer parentheses, which +// is the house rule every RON file here follows -- `wg_app_link::format` +// is the only thing that knows it, so both this project's code and Dev +// Updater read the file the same way. +// +// Dev Updater reads the keys it understands (`name`, `data`, `config`) +// and ignores the rest, so anything else this project needs to keep in +// one place belongs here too. + +// What this project calls itself for the purpose of keeping state, and +// where `~/.local/share/ai-app` and `~/.config/ai-app` come from. Not the +// crate name -- that is `ai-server`, which answers to the binary it +// produces -- and not the label, which is "AI Sessions" because that is +// what a person reads on a card. +// +// No `data` or `config` key: both are the conventional XDG places, and +// writing them out would be a second copy of what the server already +// derives from this same name. +// +// Worth knowing before using the Uninstall dialog's config toggle: +// `$XDG_CONFIG_HOME/ai-app/certs` is in there, and the CA is the one-way +// door -- deleting it strands every phone that has this server's app +// installed until the app is rebuilt against a new CA and reinstalled. +name: "ai-app",