Files
ai-app/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt
T
irisandClaude Opus 5 32b2a4871a Read a timeout in units, share one usage answer, and let a tilde mean home
The composer's settings row is outlined bubbles opening round menus, and
the message box is a TextFieldValue so anything put into it without being
typed -- a draft, a share, a slash command -- leaves the cursor at the end.

A tap that puts a text selection away no longer also collapses the card the
text was drawn in: every open and close on the session screen goes through
one guard that spends such a press on the selection.

The usage bar and the usage dialog were two polls of one measurement and
disagreed for up to a minute at a time; they are one feed now, and the
countdown rounds up to the minute in the one place both read.

A working directory typed as ~/repos/ai-app was four literal characters on
the local transport and as an argument on both, so the existence check
refused every home-relative path. It is checked by entering the directory
now, expanded for a local spawn the way the remote shell expands it, and
stored short so the phone draws what somebody would write.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:56:36 -04:00

216 lines
10 KiB
Kotlin

package com.example.aiapp
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import java.time.OffsetDateTime
/**
* Window bars for the account's rate limits, with reset times.
*
* A dialog rather than a screen. Usage is something you check *against* what you were reading --
* "can I start this" is asked with the transcript still on screen -- and pushing a whole screen for
* it took the session away to answer a question about the session. It also has no navigation of its
* own: there is nothing here to open, so the only thing its Back could ever have meant was "put
* this away", which is what dismissing does. The system back gesture dismisses it, since a `Dialog`
* handles that itself.
*/
@Composable
fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) {
// A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the
// gaps between its title, its content and its 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 taller than a bar. Everything else here is what AlertDialog would have
// drawn -- the same container colour, the same corner -- so nothing about it looks foreign.
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Column(Modifier.padding(horizontal = 24.dp, vertical = 16.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
// Deliberately not subtitled with the provider this was opened from. These
// numbers belong to an account on a particular machine, reported by whichever
// paid service answered there -- naming the session's provider here made an
// echo session's screen read "echo" above a line reading "claude", which is a
// claim about echo that nothing measured. Each machine names itself and the
// service it came from, which is the true scope.
Text(
"Usage",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f),
)
// A spinner in the button's place while the answer is on its way, since the
// numbers under it stay put during a refresh -- without it, pressing refresh
// over an unchanged read-out looks like a button that does nothing.
if (feed.refreshing) {
GlyphSpinner("Refreshing usage")
} else {
GlyphButton(REFRESH_GLYPH, "Refresh usage", feed.refresh)
}
}
Spacer(Modifier.height(8.dp))
// Scrolls rather than being trimmed: a machine can report any number of windows
// and there can be any number of machines, and a dialog is the one place where
// running out of room is silent. `fill = false` so a short read-out keeps a short
// dialog instead of stretching to the window.
Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) {
UsageBody(feed.snapshots)
}
TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) {
Text("Close")
}
}
}
}
}
/** What came back, or why nothing did. Split out so the dialog above reads as its own shape. */
@Composable
private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
Column {
when (val current = state) {
is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded ->
if (current.value.isEmpty()) {
// Not an error and not a blank screen: no machine offers a paid service,
// so there is genuinely nothing to report and saying so is the answer.
Text(
"No machine here runs anything with usage limits.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
// No card around each machine. A card is a step up the surface ladder, and
// inside a dialog -- itself a raised surface -- the step barely renders while
// costing 16dp of padding on every side. What separates one machine from the
// next is the line naming it, which is enough for a list this short.
current.value.forEachIndexed { index, snapshot ->
if (index > 0) {
Spacer(Modifier.height(20.dp))
}
// Machine and service on one line: which account these numbers belong to
// is decided by both together, and stacked as a heading over a subtitle
// they read as a section of their own rather than as the label they are.
// Small and quiet, because the numbers below are what somebody opened
// this to see.
Text(
"${snapshot.setupName.ifEmpty { snapshot.setup }} · ${snapshot.provider}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
SnapshotState(snapshot)
snapshot.windows.forEachIndexed { windowIndex, window ->
// Between the bars, not after the last one: a trailing gap here is
// what put a band of empty dialog above the Close button.
if (windowIndex > 0) {
Spacer(Modifier.height(12.dp))
}
WindowBar(window)
}
}
}
}
}
}
/**
* Anything other than numbers: why this machine has none.
*
* The distinction the old single message could not draw. A machine nobody has logged in on is
* working exactly as somebody set it up, so it reads as a plain statement -- marking it would be
* the interface nagging about a decision already made, and would dilute the marks that do mean
* something. Only the two faults are coloured as faults.
*/
@Composable
private fun SnapshotState(snapshot: UsageSnapshot) {
when (snapshot.state) {
"ok" -> {}
"notLoggedIn" ->
Text(
"No Claude account on this machine.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Reached but refused, versus never reached at all: different things to go and do,
// so they say different things rather than sharing one "unavailable".
"failed" ->
Text(
snapshot.detail ?: "Couldn't read the limits from this machine.",
style = MaterialTheme.typography.bodyMedium,
color = failedColor,
)
else ->
Text(
snapshot.detail ?: "Couldn't reach this machine.",
style = MaterialTheme.typography.bodyMedium,
color = failedColor,
)
}
}
@Composable
private fun WindowBar(window: UsageWindow) {
Column {
Row(modifier = Modifier.fillMaxWidth()) {
Text(
window.label + if (window.active) " (active)" else "",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
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 {
Spacer(Modifier.height(2.dp))
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/**
* "resets in 3h 12m" -- close enough for deciding whether to start a big task -- or nothing.
*
* Null for a window that is not running, which is the case this row has always drawn as nothing and
* is right to: there is no end to report. What it used to get wrong is the other missing case, a
* timestamp that arrived and could not be read: that was printed raw, so a parse failure appeared
* as an ISO string in the middle of a sentence written for a person. Both cases 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())) {
WindowEnd.NotRunning -> null
WindowEnd.Unreadable -> "reset time unreadable"
is WindowEnd.Ends ->
if (end.until.isNegative) "resets soon" else "resets in ${formatSpan(end.until)}"
}