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>) { 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)}" }