Merge remote-tracking branch 'origin/main'

This commit is contained in:
iris committed 2026-08-29 22:29:18 -04:00
commit bb191eec21
17 files changed
+627 -310

No files matched your search

@@ -16,37 +16,27 @@ import androidx.compose.ui.unit.dp
import com.example.wgapplink.localNetworkAllowed
/**
* One `when` rather than a navigation library: four screens, with the list as the root and the back
* button the only other way between them.
* One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root
* and the back button the only other way between them.
*
* Import, models and setups are not here any more. They are tabs inside [MainScreen] -- four views
* of the same backend, none of them a step down from another -- and what is left in this `when` is
* only what genuinely is a step down: one session, spawning one, and settings.
*/
private sealed class Screen {
data object SessionList : Screen()
data object Main : Screen()
data class Session(val summary: SessionSummary) : Screen()
data object Spawn : Screen()
/** Continuing a session the machine already had, rather than starting an empty one. */
data object Import : Screen()
/**
* Reached from a session rather than from the list, because usage belongs to the provider
* running that session and not to the app. It carries the session back with it so Back returns
* where it came from -- see [Screen.Session].
*/
data class Usage(val from: SessionSummary) : Screen()
/**
* What can be changed about one session. Carries the session back with it for the same reason
* [Screen.Usage] does, and carries it *out* renamed, so the session behind it shows the new
* name without waiting for a list refresh.
* What can be changed about one session. Carries the session back with it so Back returns where
* it came from, and carries it *out* renamed, so the session behind it shows the new name
* without waiting for a list refresh.
*/
data class SessionSettings(val from: SessionSummary) : Screen()
data object Models : Screen()
data object Setups : Screen()
data object Settings : Screen()
}
@@ -58,7 +48,7 @@ private sealed class Screen {
fun AppRoot(settingsVersion: Int) {
val context = LocalContext.current
var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) }
var screen by remember { mutableStateOf<Screen>(Screen.SessionList) }
var screen by remember { mutableStateOf<Screen>(Screen.Main) }
// Bumped whenever another screen changes something the list shows, so
// returning to it refetches instead of showing a stale list.
var reloadToken by remember { mutableIntStateOf(0) }
@@ -86,7 +76,7 @@ fun AppRoot(settingsVersion: Int) {
existing = null,
onSaved = { saved ->
settings = saved
screen = Screen.SessionList
screen = Screen.Main
},
onBack = null,
)
@@ -97,32 +87,32 @@ fun AppRoot(settingsVersion: Int) {
// reached by the system back gesture or a screen's own Back button.
// Every leaf screen can have changed something the list shows, so it
// always refetches.
val goToList = {
val goToMain = {
reloadToken++
screen = Screen.SessionList
screen = Screen.Main
}
if (screen !is Screen.SessionList) {
BackHandler(onBack = goToList)
if (screen !is Screen.Main) {
BackHandler(onBack = goToMain)
}
when (val here = screen) {
is Screen.SessionList ->
SessionListScreen(
is Screen.Main ->
MainScreen(
settings = current,
reloadToken = reloadToken,
onOpen = { screen = Screen.Session(it) },
onSpawn = { screen = Screen.Spawn },
onImport = { screen = Screen.Import },
onModels = { screen = Screen.Models },
onSetups = { screen = Screen.Setups },
onImported = { imported ->
reloadToken++
screen = Screen.Session(imported)
},
onSettings = { screen = Screen.Settings },
)
is Screen.Session ->
SessionScreen(
settings = current,
summary = here.summary,
onBack = goToList,
onUsage = { screen = Screen.Usage(here.summary) },
onBack = goToMain,
onSettings = { screen = Screen.SessionSettings(here.summary) },
)
is Screen.Spawn ->
@@ -132,23 +122,7 @@ fun AppRoot(settingsVersion: Int) {
reloadToken++
screen = Screen.Session(spawned)
},
onBack = goToList,
)
is Screen.Import ->
ImportScreen(
settings = current,
onImported = { imported ->
reloadToken++
screen = Screen.Session(imported)
},
onBack = goToList,
)
is Screen.Usage ->
UsageScreen(
settings = current,
// Back to the session it was opened from, not to the list: this is a step down
// from that session, so stepping back is the one thing Back can mean here.
onBack = { screen = Screen.Session(here.from) },
onBack = goToMain,
)
is Screen.SessionSettings ->
SessionSettingsScreen(
@@ -162,16 +136,14 @@ fun AppRoot(settingsVersion: Int) {
},
onBack = { screen = Screen.Session(here.from) },
)
is Screen.Models -> ModelsScreen(settings = current, onBack = goToList)
is Screen.Setups -> SetupsScreen(settings = current, onBack = goToList)
is Screen.Settings ->
SettingsScreen(
existing = current,
onSaved = { saved ->
settings = saved
goToList()
goToMain()
},
onBack = goToList,
onBack = goToMain,
)
}
}
@@ -1,59 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
/**
* A gear: settings for the thing it sits beside.
*
* Drawn rather than set in a font, for the reason [Chevron] gives -- an icon glyph is one a system
* font may not have, and whoever gets the empty box instead is never the person who wrote it. The
* app has no icon set otherwise, and one dependency for one gear is a poor trade.
*
* A thick ring with eight blunt teeth cut into its edge. The proportions are the whole of whether
* this reads as a gear: drawn first with thin teeth standing clear of a thin hub, it was a sun --
* unmistakably, and only once it was looked at on a screen. What separates the two shapes is that a
* gear's teeth are as heavy as its body and barely longer than they are wide, and that its centre
* is a hole rather than a dot.
*
* It draws no label, so every caller owes it a `contentDescription`: that is all assistive
* technology has, and it is also the answer to "what was that button for" six months from now.
*/
@Composable
fun Gear(modifier: Modifier = Modifier, colour: Color = MaterialTheme.colorScheme.primary) {
Canvas(modifier.size(20.dp)) {
val centre = Offset(size.width / 2, size.height / 2)
val tooth = 4.dp.toPx()
// The teeth end at the edge, so the body has to leave room for half a tooth's width
// where they meet the ring -- otherwise the widest part of the drawing is clipped.
val tip = size.minDimension / 2
val body = tip - tooth * 0.62f
drawCircle(colour, radius = body, centre, style = Stroke(3.dp.toPx()))
repeat(TEETH) { index ->
val angle = 2 * PI * index / TEETH
val direction = Offset(cos(angle).toFloat(), sin(angle).toFloat())
drawLine(
colour,
centre + direction * (body - 1.dp.toPx()),
centre + direction * tip,
strokeWidth = tooth,
// Square-ended, because a rounded tooth on a shape this small rounds away
// most of the tooth.
cap = StrokeCap.Butt,
)
}
}
}
private const val TEETH = 8
@@ -39,11 +39,7 @@ import kotlinx.coroutines.withContext
* screen into a file reader.
*/
@Composable
fun ImportScreen(
settings: ServerSettings,
onImported: (SessionSummary) -> Unit,
onBack: () -> Unit,
) {
fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (SessionSummary) -> Unit) {
val scope = rememberCoroutineScope()
var setups by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
var chosen by remember { mutableStateOf<Setup?>(null) }
@@ -73,7 +69,7 @@ fun ImportScreen(
}
}
LaunchedEffect(Unit) {
LaunchedEffect(reloadToken) {
setups =
try {
val found = withContext(Dispatchers.IO) { fetchSetups(settings) }
@@ -88,14 +84,8 @@ fun ImportScreen(
}
Column(Modifier.fillMaxSize().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
"Import a session",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onBack) { Text("Back") }
}
// No heading: the tab that selected this one already says "Import". The sentence below
// stays, because it says what importing *does*, which the tab label cannot.
Text(
"Sessions Claude Code already has on the machine. Importing continues one where it " +
"left off; the transcript here shows its recent history.",
@@ -0,0 +1,110 @@
package com.example.aiapp
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* The app's root: one title, and four views of the backend behind it.
*
* These were four screens reached by four words in a row under the title, and the row was already
* full -- the comment it replaced recorded that a fifth would have to go somewhere else. Tabs say
* the same thing in less space and say one more thing besides: that these are places to be rather
* than errands to run. Sessions, the machine's importable history, the models on it and the
* machines themselves are all *the same backend*, looked at four ways, and none of them is a step
* down from another. Settings still is a step down, which is why it stays a pushed screen and keeps
* its own Back.
*/
private enum class MainTab(val label: String) {
Sessions("Sessions"),
Import("Import"),
Models("Models"),
Setups("Setups"),
}
@Composable
fun MainScreen(
settings: ServerSettings,
reloadToken: Int,
onOpen: (SessionSummary) -> Unit,
onSpawn: () -> Unit,
onImported: (SessionSummary) -> Unit,
onSettings: () -> Unit,
) {
var tab by remember { mutableStateOf(MainTab.Sessions) }
var refreshToken by remember { mutableIntStateOf(0) }
// A tab the app put over the list has to step back to it rather than fall through to the
// system default, which closes the app -- that reads as a crash to somebody who only meant to
// get back to their sessions. Nested inside AppRoot's handler, so it wins while it is enabled.
BackHandler(enabled = tab != MainTab.Sessions) { tab = MainTab.Sessions }
Column(Modifier.fillMaxSize()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 16.dp),
) {
Text(
"AI Sessions",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f),
)
// Glyphs rather than the words they replaced: neither ever changes, both are read
// faster than they are spelled, and together they take the width that let the title
// keep its own line. They sit on the title's row because they act on the whole
// screen -- everything below this row is one tab's business, and a control belongs
// with the thing it acts on.
Row(horizontalArrangement = Arrangement.spacedBy(GLYPH_BUTTON_GAP)) {
GlyphButton(REFRESH_GLYPH, "Refresh", { refreshToken++ })
GlyphButton(SETTINGS_GLYPH, "Settings", onSettings)
}
}
// Primary rather than the plain TabRow, which is deprecated in favour of the two that
// say where they sit: these are the app's top-level destinations.
PrimaryTabRow(selectedTabIndex = tab.ordinal) {
MainTab.entries.forEach { entry ->
Tab(
selected = tab == entry,
onClick = { tab = entry },
text = { Text(entry.label) },
)
}
}
// Refreshing means "ask again about what I am looking at", so the button feeds the tab
// that is showing. The token from above means something else already changed what these
// show; the two are the same instruction to the tab below, so they are summed rather than
// tracked apart -- either one moving moves the sum, which is all a tab watches.
val token = reloadToken + refreshToken
when (tab) {
MainTab.Sessions ->
SessionListScreen(
settings = settings,
reloadToken = token,
onOpen = onOpen,
onSpawn = onSpawn,
)
MainTab.Import ->
ImportScreen(settings = settings, reloadToken = token, onImported = onImported)
MainTab.Models -> ModelsScreen(settings = settings, reloadToken = token)
MainTab.Setups -> SetupsScreen(settings = settings, reloadToken = token)
}
}
}
@@ -40,7 +40,7 @@ import kotlinx.coroutines.withContext
* going when this screen closes.
*/
@Composable
fun ModelsScreen(settings: ServerSettings, onBack: () -> Unit) {
fun ModelsScreen(settings: ServerSettings, reloadToken: Int) {
val scope = rememberCoroutineScope()
var state by remember { mutableStateOf<LoadState<Models>>(LoadState.Loading) }
var query by remember { mutableStateOf("") }
@@ -61,7 +61,10 @@ fun ModelsScreen(settings: ServerSettings, onBack: () -> Unit) {
// Polled rather than pushed: a download belongs to the machine, not to
// any session, so it has no event stream of its own. Slow enough not
// to matter, frequent enough that a bar moves.
LaunchedEffect(Unit) {
// Keyed on the token as well, so the header's Refresh restarts the loop with a read now
// rather than leaving the reader watching for up to a second and a half to see whether
// anything happened.
LaunchedEffect(reloadToken) {
while (true) {
reload()
delay(1500)
@@ -69,16 +72,6 @@ fun ModelsScreen(settings: ServerSettings, onBack: () -> Unit) {
}
Column(Modifier.fillMaxSize().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
"Models",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onBack) { Text("Back") }
}
Spacer(Modifier.height(8.dp))
actionError?.let {
Text(it, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
@@ -270,6 +263,10 @@ private fun DownloadCard(download: Download, onCancel: () -> Unit) {
if (download.total != null && download.total > 0) {
LinearProgressIndicator(
progress = { download.done.toFloat() / download.total.toFloat() },
// Blue at every value, unlike a quota bar: a download nearing its end is
// nearing success, and colouring it like a limit being approached would say
// the opposite of what is happening.
color = progressColor,
modifier = Modifier.fillMaxWidth(),
)
Text(
@@ -277,7 +274,7 @@ private fun DownloadCard(download: Download, onCancel: () -> Unit) {
style = MaterialTheme.typography.bodySmall,
)
} else {
LinearProgressIndicator(Modifier.fillMaxWidth())
LinearProgressIndicator(color = progressColor, modifier = Modifier.fillMaxWidth())
Text(
"${gigabytes(download.done)} so far, total size unknown",
style = MaterialTheme.typography.bodySmall,
@@ -0,0 +1,134 @@
package com.example.aiapp
import androidx.compose.foundation.layout.size
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* The icons the app draws, as glyphs in a Nerd Fonts subset rather than as vector assets.
*
* Drawing them as *text* is what makes them cheap: an icon beside a line of text wants that line's
* size, colour and baseline, and a `Text` gets all three for free where an `Icon` needs each one
* set and kept in step by hand.
*
* This replaced a hand-drawn canvas gear, whose doc comment argued against icon fonts on the
* grounds that a system font may not have the glyph and whoever gets the empty box instead is never
* the person who wrote it. That objection is about *relying* on a system font, and it is exactly
* right: the answer is not to avoid glyphs but to ship them. The font here is
* `app/build-icon-font.sh`'s output -- five glyphs, 1.4 KB, subset out of the 3 MB symbols font and
* committed -- so the codepoints below are resolved by an asset in the APK and cannot come back as
* tofu. Adding one means adding its codepoint in *both* places; a codepoint here that the script
* did not subset is a glyph that silently isn't there.
*
* The same arrangement as dev-updater, down to the cog and the refresh arrow being the same two
* Material Design codepoints. Those two must not drift: an icon that means "settings" in one app
* and something else in the other is the failure this is worth preventing. The script is copied
* rather than shared because most of what looks like duplication is the `GLYPHS` list, which has
* to differ -- the point of subsetting is to ship only the codepoints one app draws. All Material Design
* bar one, so they read as one family; the exception is noted where it is declared.
*/
val NerdIcons = FontFamily(Font(R.font.nerd_icons))
/** Nerd Fonts puts these in plane 15, so each is a surrogate pair. */
private fun glyph(codePoint: Int) = String(Character.toChars(codePoint))
/** `md-cog` -- settings for the thing it sits beside. */
val SETTINGS_GLYPH = glyph(0xF0493)
/** `md-refresh` -- ask the server again for whatever is on screen. */
val REFRESH_GLYPH = glyph(0xF0450)
/** `md-send` -- the filled paper plane: submit what is in the composer. */
val SEND_GLYPH = glyph(0xF048A)
/** `md-stop` -- a filled square: interrupt the turn that is running. */
val STOP_GLYPH = glyph(0xF04DB)
/** `md-arrow_left` -- back one level, to whatever this was opened from. */
val BACK_GLYPH = glyph(0xF004D)
/**
* `fa-line_chart` -- how much of the account's rate limits is gone.
*
* Font Awesome's rather than Material's, which is the one break in the family above: it was asked
* for by name, and Material's chart glyphs are a bare line where this one has its axes, which is
* what makes it read as a measurement rather than as a trend.
*/
val USAGE_GLYPH = glyph(0xF201)
/**
* The size of a glyph button's box, which is the size of the glyph itself.
*
* Not the 48dp of a default `IconButton`: that box centres its drawing inside itself, so aligning
* the box against a title aligns nothing a reader can see, and the pressed-state ripple lands on
* the box rather than on the mark. A box that *is* the glyph aligns like any other content and
* takes its ripple with it. The cost is the touch target -- `minimumInteractiveComponentSize` is
* applied inside the caller's modifier, so a size set here wins over it.
*/
private val GLYPH_BUTTON_SIZE = 28.dp
/**
* Restores the separation the 48dp boxes used to provide, now that the boxes are the size of what
* they draw: two of these plus the gap come to the same 48dp centre-to-centre spacing.
*/
val GLYPH_BUTTON_GAP = 48.dp - GLYPH_BUTTON_SIZE
/**
* A glyph you can press: the icon equivalent of a `TextButton`.
*
* Its own composable so that every icon button in the app is one size and one colour without each
* caller saying so, and so the [label] none of them displays is still there for a screen reader --
* which is all assistive technology has to go on, and also the answer to "what was that button for"
* six months from now.
*
* [enabled] is passed through rather than left to callers hiding the button: a control that comes
* and goes makes its own absence the signal, and absence cannot say whether there was nothing to do
* or nobody checked.
*/
@Composable
fun GlyphButton(
glyph: String,
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
colour: Color = MaterialTheme.colorScheme.primary,
) {
IconButton(
onClick = onClick,
enabled = enabled,
modifier = modifier.size(GLYPH_BUTTON_SIZE).semantics { contentDescription = label },
) {
Glyph(glyph, colour = if (enabled) colour else MaterialTheme.colorScheme.outline)
}
}
/**
* One icon, drawn as text.
*
* Callers that are already inside something pressable use this; [GlyphButton] is the one that adds
* the press. Either way the caller owes it a description, since neither draws a word.
*/
@Composable
fun Glyph(
glyph: String,
modifier: Modifier = Modifier,
colour: Color = MaterialTheme.colorScheme.primary,
size: TextUnit = GLYPH_SIZE,
) {
Text(glyph, fontFamily = NerdIcons, fontSize = size, color = colour, modifier = modifier)
}
/** The size an icon draws at beside a line of text. */
private val GLYPH_SIZE = 20.sp
@@ -39,8 +39,11 @@ import kotlinx.coroutines.withContext
// as in dev-updater.
/**
* The session list -- the app's root screen. Sessions awaiting an answer sort to the top: that's
* the "your turn" inbox.
* The sessions tab: sessions awaiting an answer sort to the top, which is the "your turn" inbox.
*
* No title and no Back of its own -- [MainScreen] owns the header and the tab that names this one.
* What stays here is the button that adds a session, because that acts on this list and nothing
* else.
*/
@Composable
fun SessionListScreen(
@@ -48,10 +51,6 @@ fun SessionListScreen(
reloadToken: Int,
onOpen: (SessionSummary) -> Unit,
onSpawn: () -> Unit,
onImport: () -> Unit,
onModels: () -> Unit,
onSetups: () -> Unit,
onSettings: () -> Unit,
) {
val scope = rememberCoroutineScope()
var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) }
@@ -92,25 +91,6 @@ fun SessionListScreen(
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize().padding(16.dp)) {
// Title and actions on separate rows. Sharing one row worked
// with three actions and broke with the fourth: the title took
// whatever was left and wrapped "AI Sessions" onto three
// lines. Giving the actions their own row means adding a fifth
// costs nothing, and the title is never the thing that gives.
Text("AI Sessions", style = MaterialTheme.typography.headlineSmall)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
TextButton(onClick = onImport) { Text("Import") }
TextButton(onClick = onModels) { Text("Models") }
TextButton(onClick = onSetups) { Text("Setups") }
TextButton(onClick = onSettings) { Text("Settings") }
Spacer(Modifier.weight(1f))
TextButton(onClick = { refresh() }) { Text("Refresh") }
}
Spacer(Modifier.height(8.dp))
when (val state = listState) {
is LoadState.Loading -> CircularProgressIndicator()
// The message as Api.kt wrote it, with nothing added: it is
@@ -27,8 +27,8 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
@@ -316,7 +316,6 @@ fun SessionScreen(
settings: ServerSettings,
summary: SessionSummary,
onBack: () -> Unit,
onUsage: () -> Unit,
onSettings: () -> Unit,
) {
val scope = rememberCoroutineScope()
@@ -737,12 +736,18 @@ fun SessionScreen(
}
}
// One poll for this machine's limits, read by the two things that show them: the bar under
// the header, and the colour of the button that opens the dialog.
val usage = rememberSessionUsage(settings, summary.setup)
var usageOpen by remember { mutableStateOf(false) }
Column(Modifier.fillMaxSize()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
) {
TextButton(onClick = onBack) { Text("Back") }
GlyphButton(BACK_GLYPH, "Back", onBack)
Spacer(Modifier.width(8.dp))
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleMedium)
Text(
@@ -766,22 +771,29 @@ fun SessionScreen(
// the paid service's own numbers, so a session on a provider with no such service
// gets an honest "unavailable" rather than a hidden button -- a control that comes
// and goes makes its absence the signal, and absence cannot say why.
TextButton(onClick = onUsage) { Text("Usage") }
// A step down from this session, so it sits at the end of the session's own row.
// The name is the whole of what it holds today, which is why it is a gear and not a
// word: there will be more, and a bar of words has nowhere to put it.
IconButton(
onClick = onSettings,
modifier = Modifier.semantics { contentDescription = "Session settings" },
) {
Gear()
// Coloured by the worst window behind it, so the row says whether the limits are
// worth opening before anybody opens them. Blue at every ordinary level and only
// yellow or red near a limit -- and the theme's plain control colour whenever there
// is no measurement, since blue is the low end of the scale here and would read as
// "checked, and fine" about a machine nobody could reach.
Row(horizontalArrangement = Arrangement.spacedBy(GLYPH_BUTTON_GAP)) {
GlyphButton(
USAGE_GLYPH,
"Usage",
{ usageOpen = true },
colour = usageGlyphColour(usage),
)
// A step down from this session, so it sits at the end of the session's own row.
// The name is the whole of what it holds today, which is why it is a cog and not
// a word: there will be more, and a bar of words has nowhere to put it.
GlyphButton(SETTINGS_GLYPH, "Session settings", onSettings)
}
}
// Under the header, above everything the session itself says: it is a fact about the
// machine rather than a turn in the conversation, and it is the number that decides
// whether to keep going -- which was a screen away from where that gets decided.
SessionUsageBar(settings = settings, setup = summary.setup)
SessionUsageBar(usage)
(streamError ?: actionError)?.let { message ->
Text(
@@ -1052,21 +1064,46 @@ fun SessionScreen(
}
if (running) {
OutlinedButton(onClick = { act { interruptSession(settings, summary.id) } }) {
Text("Stop")
// A filled square, which is what stop has looked like since tape decks.
// Outlined beside the filled Send, so the pair still reads as one primary
// action and one secondary -- the glyphs changed, the weighting did not.
Glyph(
STOP_GLYPH,
colour = LocalContentColor.current,
modifier = Modifier.semantics { contentDescription = "Stop" },
)
}
Spacer(Modifier.width(8.dp))
}
// "Queue" while a turn is in flight, because that is what
// sending then does: the message is injected at the next
// tool boundary rather than starting a turn of its own.
// Naming it Send there would promise something immediate
// and describe something that waits.
Button(onClick = { send() }) { Text(if (running) "Queue" else "Send") }
// The paper plane alone when it means send. While a turn is in flight it keeps
// the word "Queue" beside it, because that is what sending then does -- the
// message is injected at the next tool boundary rather than starting a turn of
// its own -- and an icon that does two things while looking identical would
// promise something immediate and do something that waits. The word is also the
// button's accessible name, which is all a screen reader gets either way.
Button(onClick = { send() }) {
Glyph(
SEND_GLYPH,
colour = LocalContentColor.current,
modifier = Modifier.semantics { contentDescription = sendLabel(running) },
)
if (running) {
Spacer(Modifier.width(8.dp))
Text(sendLabel(running))
}
}
}
}
}
if (usageOpen) {
UsageDialog(settings = settings, onDismiss = { usageOpen = false })
}
}
/** What pressing Send does right now, said the same way to the eye and to a screen reader. */
private fun sendLabel(running: Boolean) = if (running) "Queue" else "Send"
/**
* An inline transcript image, fetched (authenticated, pinned) from the session's files route. The
* bitmap is remembered per ref, so scrolling doesn't refetch.
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
@@ -15,7 +16,6 @@ import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -77,12 +77,13 @@ fun SessionSettingsScreen(
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
GlyphButton(BACK_GLYPH, "Back", onBack)
Spacer(Modifier.width(8.dp))
Text(
"Session settings",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onBack) { Text("Back") }
}
Spacer(Modifier.height(16.dp))
@@ -14,6 +14,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import java.time.Duration
import java.time.OffsetDateTime
@@ -21,12 +22,13 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
/** How much of the five-hour window is gone, or why that isn't known. */
sealed class FiveHourUsage {
/** What one machine's rate limits came back as, or why they didn't. */
sealed class SessionUsage {
/** Nothing has come back yet. Distinct from every answer, including an empty one. */
data object Waiting : FiveHourUsage()
data object Waiting : SessionUsage()
data class Known(val percent: Double, val resetsAt: String?) : FiveHourUsage()
/** Every window the machine reported, in the order it reported them. */
data class Known(val windows: List<UsageWindow>) : SessionUsage()
/**
* This machine meters nothing, so there is no window to show.
@@ -37,7 +39,7 @@ sealed class FiveHourUsage {
* snapshot for it -- and reading that silence as "couldn't find out" is exactly the mistake of
* answering with the nearest available word. Drawn as nothing, because there is nothing.
*/
data object NotMetered : FiveHourUsage()
data object NotMetered : SessionUsage()
/**
* The question could not be answered, and why.
@@ -46,18 +48,66 @@ sealed class FiveHourUsage {
* never share an appearance: a bar sitting at zero because a machine is unreachable reads as
* plenty of headroom, which is the opposite of the truth.
*/
data class Unavailable(val why: String) : FiveHourUsage()
data class Unavailable(val why: String) : SessionUsage()
}
/** How often to ask again. The backend caches, so this re-reads its cache rather than the API. */
private const val REFRESH_MS = 60_000L
/**
* One machine's rate limits, polled.
*
* Hoisted out of [SessionUsageBar] because two things on a session's screen show this same answer
* -- the bar, and the colour of the button that opens the usage dialog. Fetching it twice would
* cost two round trips to say one thing, and the two copies would disagree for up to a minute at a
* time, which is the interface contradicting itself about a number somebody is deciding on.
*/
@Composable
fun rememberSessionUsage(settings: ServerSettings, setup: String): SessionUsage {
var usage by remember(setup) { mutableStateOf<SessionUsage>(SessionUsage.Waiting) }
LaunchedEffect(setup) {
while (true) {
usage =
try {
usageFor(withContext(Dispatchers.IO) { fetchUsage(settings) }, setup)
} catch (e: ApiException) {
SessionUsage.Unavailable(e.message ?: "couldn't reach the backend")
}
delay(REFRESH_MS)
}
}
return usage
}
/**
* The colour for a control that reports on [usage] as a whole: the worst window's.
*
* Worst rather than the five-hour one, because the button it colours opens *all* of them, and a
* blue icon over a weekly quota at 97% would be the interface answering a question nobody asked.
* Taken over however many windows came back rather than the three Claude sends today -- the backend
* deliberately passes windows it does not recognise straight through, so a fourth one is a thing
* that happens rather than a thing to notice later.
*
* Every state that is not a measurement takes the ordinary control colour instead. That is the
* point where colour stops being able to help: blue is the low end of a scale here, so colouring an
* unknown blue would say "measured, and fine" about a machine nobody could reach. The dialog behind
* the button is where those say, in words, which one they are.
*/
@Composable
fun usageGlyphColour(usage: SessionUsage): Color =
when (usage) {
is SessionUsage.Known ->
usage.windows.maxOfOrNull { it.percent }?.let { quotaColor(it) }
?: MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.primary
}
/**
* The five-hour window for the machine this session runs on, under the session's own header.
*
* Here rather than only on the usage screen because it is the number that decides whether to keep
* Here rather than only in the usage dialog because it is the number that decides whether to keep
* going, and it was a screen away from the place that decision gets made. It reports on this
* session's machine alone -- the usage screen is still where every machine is compared.
* session's machine alone -- the dialog is still where every machine is compared.
*
* What it shows is the paid service's own metering, fetched from the machine that holds the
* account. It is never derived from what this app has watched go past: the transcript's token
@@ -65,31 +115,22 @@ private const val REFRESH_MS = 60_000L
* out of them would be a guess wearing a measurement's clothes.
*/
@Composable
fun SessionUsageBar(settings: ServerSettings, setup: String, modifier: Modifier = Modifier) {
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.
fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
// The countdown moves even when the numbers do not, so it is driven by a clock of its own
// 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 ticked when the quota
// happened to move would sit at a stale figure for hours.
var now by remember { mutableStateOf(OffsetDateTime.now()) }
LaunchedEffect(setup) {
LaunchedEffect(Unit) {
while (true) {
usage =
try {
val snapshots = withContext(Dispatchers.IO) { fetchUsage(settings) }
fiveHourFor(snapshots, setup)
} catch (e: ApiException) {
FiveHourUsage.Unavailable(e.message ?: "couldn't reach the backend")
}
now = OffsetDateTime.now()
delay(REFRESH_MS)
now = OffsetDateTime.now()
}
}
// Nothing at all for a machine that meters nothing: a row saying "unknown" there would
// report a problem about a setup somebody chose, on every screen, forever.
if (usage is FiveHourUsage.NotMetered) {
if (usage is SessionUsage.NotMetered) {
return
}
@@ -97,39 +138,47 @@ fun SessionUsageBar(settings: ServerSettings, setup: String, modifier: Modifier
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 2.dp),
) {
// Words, not a colour and not an empty bar: every one of these is a different kind of
// answer from "this much is used", and only words carry a difference in kind.
when (val state = usage) {
FiveHourUsage.NotMetered -> Unit
// Words, not a colour and not an empty bar: "couldn't check" is a different kind of
// answer from "this much is used", and only words carry a difference in kind.
is FiveHourUsage.Unavailable ->
Text(
"5-hour usage unknown -- ${state.why}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
FiveHourUsage.Waiting ->
Text(
"5-hour usage: checking",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
is FiveHourUsage.Known -> {
LinearProgressIndicator(
progress = { (state.percent / 100.0).toFloat().coerceIn(0f, 1f) },
color = usageColor,
modifier = Modifier.weight(1f),
)
Text(
fiveHourLabel(state, now),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp),
)
SessionUsage.NotMetered -> Unit
is SessionUsage.Unavailable -> UsageNote("5-hour usage unknown -- ${state.why}")
SessionUsage.Waiting -> UsageNote("5-hour usage: checking")
is SessionUsage.Known -> {
val window = state.windows.firstOrNull { it.kind == "session" }
if (window == null) {
UsageNote("5-hour usage unknown -- no five-hour window reported")
} else {
LinearProgressIndicator(
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
// The same step at the same percentages as the dialog's bars: this is the
// same measurement, and a reader who learned the colour there has to be
// able to read it here without checking which screen they are on.
color = quotaColor(window.percent),
modifier = Modifier.weight(1f),
)
Text(
fiveHourLabel(window, now),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp),
)
}
}
}
}
}
/** Anything this row says instead of drawing a bar, so all of them look the same. */
@Composable
private fun UsageNote(text: String) {
Text(
text,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
/**
* "42% -- 2h 15m left": how much is gone, then how long what is left has to last.
*
@@ -140,43 +189,32 @@ fun SessionUsageBar(settings: ServerSettings, setup: String, modifier: Modifier
* 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) }
private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
val percent = "${window.percent.toInt()}%"
val until = window.resetsAt?.let { remainingUntil(it, now) }
return when {
until == null -> "$percent \u00b7 reset time unknown"
until == null -> "$percent · 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"
until < Duration.ofMinutes(1) -> "$percent · refresh soon"
else -> "$percent · ${formatSpan(until)} left"
}
}
/**
* The five-hour window for one machine, out of every machine's snapshot.
* One machine's snapshot, out of every machine's.
*
* Selects the window by `kind`, which is the API's own word, rather than by the label beside it --
* the label is written to be read and would stop matching the day its wording changes, silently
* leaving the bar with nothing to show.
*
* Every way of having *failed* to get a number is [FiveHourUsage.Unavailable] with the reason in
* it: a machine nobody logged into, one that could not be reached, a snapshot that came back
* without the window. None of them may look like zero, and none of them may look like
* [FiveHourUsage.NotMetered], which is the machine having no quota rather than the question going
* unanswered.
* Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it:
* a machine nobody logged into, one that could not be reached, a snapshot that came back empty.
* None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the
* machine having no quota rather than the question going unanswered.
*/
fun fiveHourFor(snapshots: List<UsageSnapshot>, setup: String): FiveHourUsage {
val mine = snapshots.firstOrNull { it.setup == setup }
fun usageFor(snapshots: List<UsageSnapshot>, setup: String): SessionUsage {
// No snapshot at all means the backend never asked, which it only does for a machine with
// nothing metered on it. That is a different answer from having asked and failed.
if (mine == null) {
return FiveHourUsage.NotMetered
}
val mine = snapshots.firstOrNull { it.setup == setup } ?: return SessionUsage.NotMetered
if (mine.state != "ok") {
return FiveHourUsage.Unavailable(mine.detail ?: mine.state)
return SessionUsage.Unavailable(mine.detail ?: mine.state)
}
val window =
mine.windows.firstOrNull { it.kind == "session" }
?: return FiveHourUsage.Unavailable("no five-hour window reported")
return FiveHourUsage.Known(window.percent, window.resetsAt)
return SessionUsage.Known(mine.windows)
}
@@ -11,12 +11,12 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -79,14 +79,21 @@ fun SettingsScreen(
Column(Modifier.fillMaxSize().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
// Leading, where a back arrow points at what it returns to. Trailing it would put a
// left-pointing arrow at the right edge, aimed across the title it sits beside.
//
// Absent rather than disabled on first run, which is the one place this app lets a
// control come and go: there is no screen underneath yet, so a Back here would not be
// a capability being withheld but a promise it could not keep.
if (onBack != null) {
GlyphButton(BACK_GLYPH, "Back", onBack)
Spacer(Modifier.width(8.dp))
}
Text(
"Server",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f),
)
if (onBack != null) {
TextButton(onClick = onBack) { Text("Back") }
}
}
Spacer(Modifier.height(8.dp))
Text(
@@ -38,7 +38,7 @@ import kotlinx.coroutines.withContext
* which is what keeps the enrolled token from being able to introduce commands.
*/
@Composable
fun SetupsScreen(settings: ServerSettings, onBack: () -> Unit) {
fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
val scope = rememberCoroutineScope()
var state by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
var adding by remember { mutableStateOf(false) }
@@ -56,14 +56,13 @@ fun SetupsScreen(settings: ServerSettings, onBack: () -> Unit) {
}
}
LaunchedEffect(Unit) { reload() }
LaunchedEffect(reloadToken) { reload() }
Column(Modifier.fillMaxSize().padding(16.dp)) {
Text("Setups", style = MaterialTheme.typography.headlineSmall)
// The heading and Back are the tab row's now; adding a machine is this tab's own work
// and stays with the list it adds to.
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
TextButton(onClick = { adding = true }) { Text("Add machine") }
Spacer(Modifier.weight(1f))
TextButton(onClick = onBack) { Text("Back") }
}
Spacer(Modifier.height(8.dp))
@@ -130,16 +130,43 @@ val warningColor: Color
@Composable get() = Mocha.Yellow
/**
* How much of a quota window is gone, on the bar under a session's header.
* The fill of a progress bar that is only reporting how far along something is.
*
* 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.
* Blue because a bar like this reports a quantity rather than a verdict, and the scheme's primary
* made it the loudest thing on a screen the reader opened to do something else. A download, or a
* compaction, has no limit to be near: it finishes. Only a bar measuring a *quota* escalates, and
* that one is [quotaColor].
*/
val usageColor: Color
val progressColor: Color
@Composable get() = Mocha.Blue
/**
* The fill of a bar measuring how much of a quota is gone: blue, then yellow, then red.
*
* One function rather than the same `when` written beside each bar, because the whole point of
* colouring by consequence is that the reader learns the step once -- two bars showing the same 80%
* in different colours teaches nothing except that the colour cannot be trusted. It reads as a
* difference in degree, which is all colour can carry: the states that differ in *kind* from this
* -- a window nobody could read, a machine that meters nothing -- are said in words elsewhere,
* because a reader has no way to tell those from an ordinary low number by colour alone.
*
* [percent] is the API's own 0-100 rather than a fraction, so callers pass what the server sent
* without each converting it first and one of them getting it wrong by a factor of a hundred.
*/
@Composable
fun quotaColor(percent: Double): Color =
when {
percent >= OVER_LIMIT_PERCENT -> overLimitColor
percent >= WARNING_PERCENT -> warningColor
else -> progressColor
}
/** Close enough to the limit to be worth seeing before starting something big. */
private const val WARNING_PERCENT = 75.0
/** Close enough that the next turn may be the one that is refused. */
private const val OVER_LIMIT_PERCENT = 95.0
/**
* Code: a fenced block, an inline span, a tool's input.
*
@@ -3,12 +3,12 @@ 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.fillMaxSize
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.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
@@ -30,9 +30,18 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** Window bars for the account's rate limits, with reset times. */
/**
* 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 UsageScreen(settings: ServerSettings, onBack: () -> Unit) {
fun UsageDialog(settings: ServerSettings, onDismiss: () -> Unit) {
val scope = rememberCoroutineScope()
var state by remember { mutableStateOf<LoadState<List<UsageSnapshot>>>(LoadState.Loading) }
@@ -49,24 +58,35 @@ fun UsageScreen(settings: ServerSettings, onBack: () -> Unit) {
}
LaunchedEffect(Unit) { refresh() }
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(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 card reading "claude", which is a claim about echo
// that nothing measured. Each card names the machine and the service it came from,
// which is the true scope.
Text(
"Usage",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onBack) { Text("Back") }
TextButton(onClick = { refresh() }) { Text("Refresh") }
}
Spacer(Modifier.height(16.dp))
AlertDialog(
onDismissRequest = onDismiss,
title = {
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 card reading "claude", which is a claim
// about echo that nothing measured. Each card names the machine and the service
// it came from, which is the true scope.
Text("Usage", modifier = Modifier.weight(1f))
GlyphButton(REFRESH_GLYPH, "Refresh usage", { refresh() })
}
},
// Scrolls here 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.
text = { Column(Modifier.verticalScroll(rememberScrollState())) { UsageBody(state) } },
confirmButton = { TextButton(onClick = onDismiss) { 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)
@@ -146,12 +166,6 @@ private fun SnapshotState(snapshot: UsageSnapshot) {
@Composable
private fun WindowBar(window: UsageWindow) {
val color =
when {
window.percent >= 95 -> overLimitColor
window.percent >= 75 -> warningColor
else -> MaterialTheme.colorScheme.primary
}
Column {
Row(modifier = Modifier.fillMaxWidth()) {
Text(
@@ -164,7 +178,7 @@ private fun WindowBar(window: UsageWindow) {
Spacer(Modifier.height(4.dp))
LinearProgressIndicator(
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
color = color,
color = quotaColor(window.percent),
modifier = Modifier.fillMaxWidth(),
)
window.resetsAt?.let {