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, so the only thing its Back could ever have meant was "put this away". */ @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, 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 // taller than a bar. 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 -- naming the session's // provider here made an echo session's screen read "echo" above a line reading // "claude". Each machine names itself and the service it came from. 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. 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 on every side. What separates one machine from the next is the // line naming it. 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. 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. 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: there is no end to report. What this used to get wrong is * the other missing case, a timestamp that arrived and could not be read -- printed raw, so a parse * 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())) { WindowEnd.NotRunning -> null WindowEnd.Unreadable -> "reset time unreadable" is WindowEnd.Ends -> if (end.until.isNegative) "resets soon" else "resets in ${formatSpan(end.until)}" }