264 lines
12 KiB
Kotlin
264 lines
12 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.MaterialTheme
|
|
import androidx.compose.material3.Surface
|
|
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
|
|
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
|
|
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(
|
|
settings: ServerSettings,
|
|
feed: UsageFeed,
|
|
session: SessionSummary,
|
|
onDismiss: () -> Unit,
|
|
) {
|
|
var signingIn by remember { mutableStateOf(false) }
|
|
val now = rememberUsageNow()
|
|
// 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(),
|
|
) {
|
|
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
|
|
// a provider can report several billing pools, 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())) {
|
|
val state =
|
|
when (val snapshots = feed.snapshots) {
|
|
is LoadState.Loading -> LoadState.Loading
|
|
is LoadState.Error -> snapshots
|
|
is LoadState.Loaded ->
|
|
LoadState.Loaded(
|
|
usageSnapshotsFor(
|
|
snapshots.value,
|
|
session.machine,
|
|
session.usageProvider,
|
|
)
|
|
)
|
|
}
|
|
UsageBody(state, now, onSignIn = { signingIn = true })
|
|
}
|
|
TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) {
|
|
Text("Close")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (signingIn) {
|
|
ProviderLoginDialog(
|
|
settings = settings,
|
|
machineId = session.machine,
|
|
machineName = session.machineName,
|
|
provider = session.provider,
|
|
onDismiss = { signingIn = false },
|
|
onSignedIn = {
|
|
signingIn = false
|
|
feed.refresh()
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
/** 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>>,
|
|
now: OffsetDateTime,
|
|
onSignIn: () -> Unit,
|
|
) {
|
|
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: this provider has no paid quota, so
|
|
// there is genuinely nothing to report and saying so is the answer.
|
|
Text(
|
|
"This session's provider has no usage limits.",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
} else {
|
|
// No card around each pool. 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 pool 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(
|
|
usageSectionTitle(snapshot),
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
SnapshotState(snapshot, onSignIn)
|
|
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, now)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun usageSectionTitle(snapshot: UsageSnapshot): String {
|
|
val machine = snapshot.machineName.ifEmpty { snapshot.machine }
|
|
val provider = snapshot.provider
|
|
val pool =
|
|
if (provider == "codex" && snapshot.limitId != "codex") {
|
|
when (snapshot.limitName) {
|
|
"gpt-reserve" -> "Luna Reserve"
|
|
null -> snapshot.limitId
|
|
else -> snapshot.limitName
|
|
}
|
|
} else null
|
|
return listOfNotNull(machine, provider, pool).joinToString(" · ")
|
|
}
|
|
|
|
/**
|
|
* 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. It still offers the direct sign-in action;
|
|
* unreachable and provider failures are the states coloured as faults.
|
|
*/
|
|
@Composable
|
|
private fun SnapshotState(snapshot: UsageSnapshot, onSignIn: () -> Unit) {
|
|
when (snapshot.state) {
|
|
"ok" -> {}
|
|
"notLoggedIn",
|
|
"loginRequired" -> {
|
|
Text(
|
|
snapshot.detail ?: "No Claude account on this machine.",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
TextButton(onClick = onSignIn) { Text("Sign in") }
|
|
}
|
|
"authenticating" ->
|
|
Text(
|
|
"Claude sign-in is in progress.",
|
|
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, now: OffsetDateTime) {
|
|
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))
|
|
UsageProgressIndicator(window, now, Modifier.fillMaxWidth())
|
|
resetLine(window, now)?.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, now: OffsetDateTime): String? =
|
|
when (val end = windowEnd(window.resetsAt, now)) {
|
|
WindowEnd.NotRunning -> null
|
|
WindowEnd.Unreadable -> "reset time unreadable"
|
|
is WindowEnd.Ends ->
|
|
if (end.until.isNegative) "resets soon" else "resets in ${formatSpan(end.until)}"
|
|
}
|