Say how long is left, not that the window is five hours
The bar read "31% of 5h", which is the one thing about the window a reader already knows. What decides whether to start something now is how long what is left has to last: 80% with twenty minutes to go and 80% with four hours to go are opposite answers, and the second number was a screen away on the usage screen. It now reads "31% - 2h 36m left", and the countdown is driven by a clock the refresh loop advances rather than computed at draw time. A percentage that comes back unchanged is an equal value, so Compose skips the recomposition -- a "left" recomputed only when the quota happens to move would have sat at a stale figure for hours while looking live. A window can arrive with no reset time, so that keeps its own wording: "reset time unknown" rather than "refresh soon", which would be a recommendation nothing measured. Under a minute, including past the end, is "refresh soon" -- "0m left" reads as a measurement. The span arithmetic was already on the usage screen, so it moves into `ResetCountdown.kt` and both callers supply their own sentence. That screen still reads "resets in 2h 37m" and "resets in 5d 21h", checked on the emulator alongside the bar it was not part of changing. The fill is blue rather than the scheme's primary: the bar sits under every session header, on a screen somebody opened to do something else, and it reports a quantity rather than a verdict. The usage screen is still where the same number turns yellow and then red, for a reader who went there to be told where the limits are. Also declares this project's resources for Dev Updater, whose declaration schema changed in d27b5a3: `resources.ron` says ai-app keeps its state as `ai-app`, so the Uninstall dialog offers the real directories instead of saying it cannot tell where they are. Only the name, because both XDG places are the conventional ones. What that dialog's config toggle would delete includes the CA under `certs`, which strands every phone running an APK pinned to it -- noted where somebody would be standing when it matters.
This commit is contained in:
1 parent
894180de77
commit
a50d72960c
7 files changed
+125
-15
No files matched your search
@@ -10,6 +10,13 @@
|
|||||||
// A built APK wins: it is the authority on what will actually install.
|
// A built APK wins: it is the authority on what will actually install.
|
||||||
label: "AI Sessions",
|
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 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
|
// 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.
|
// set, not a sequence, so nothing here should be read as an order.
|
||||||
|
|||||||
@@ -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
|
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
|
repository, not either half of it, which is why this sits at the root
|
||||||
rather than in `app/`.
|
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
|
`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
|
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
|
script until 2026-08-28 and it was the generic case exactly — no
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ package com.example.aiapp
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -16,6 +15,8 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.OffsetDateTime
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -66,6 +67,11 @@ private const val REFRESH_MS = 60_000L
|
|||||||
@Composable
|
@Composable
|
||||||
fun SessionUsageBar(settings: ServerSettings, setup: String, modifier: Modifier = Modifier) {
|
fun SessionUsageBar(settings: ServerSettings, setup: String, modifier: Modifier = Modifier) {
|
||||||
var usage by remember(setup) { mutableStateOf<FiveHourUsage>(FiveHourUsage.Waiting) }
|
var usage by remember(setup) { mutableStateOf<FiveHourUsage>(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) {
|
LaunchedEffect(setup) {
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -76,6 +82,7 @@ fun SessionUsageBar(settings: ServerSettings, setup: String, modifier: Modifier
|
|||||||
} catch (e: ApiException) {
|
} catch (e: ApiException) {
|
||||||
FiveHourUsage.Unavailable(e.message ?: "couldn't reach the backend")
|
FiveHourUsage.Unavailable(e.message ?: "couldn't reach the backend")
|
||||||
}
|
}
|
||||||
|
now = OffsetDateTime.now()
|
||||||
delay(REFRESH_MS)
|
delay(REFRESH_MS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,19 +116,42 @@ fun SessionUsageBar(settings: ServerSettings, setup: String, modifier: Modifier
|
|||||||
is FiveHourUsage.Known -> {
|
is FiveHourUsage.Known -> {
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
progress = { (state.percent / 100.0).toFloat().coerceIn(0f, 1f) },
|
progress = { (state.percent / 100.0).toFloat().coerceIn(0f, 1f) },
|
||||||
|
color = usageColor,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
" ${state.percent.toInt()}% of 5h",
|
fiveHourLabel(state, now),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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.
|
* The five-hour window for one machine, out of every machine's snapshot.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -129,6 +129,17 @@ val awaitingColor: Color
|
|||||||
val warningColor: Color
|
val warningColor: Color
|
||||||
@Composable get() = Mocha.Yellow
|
@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.
|
* Code: a fenced block, an inline span, a tool's input.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import java.time.Duration
|
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
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. */
|
/** "in 3h 12m" -- close enough for deciding whether to start a big task. */
|
||||||
private fun formatReset(resetsAt: String): String =
|
private fun formatReset(resetsAt: String): String {
|
||||||
try {
|
val until = remainingUntil(resetsAt, OffsetDateTime.now()) ?: return "at $resetsAt"
|
||||||
val until = Duration.between(OffsetDateTime.now(), OffsetDateTime.parse(resetsAt))
|
return if (until.isNegative) "soon" else "in ${formatSpan(until)}"
|
||||||
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"
|
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
Reference in new issue
Block a user