Files
ai-app/app/androidApp/src/main/kotlin/com/example/aiapp/UsageScreen.kt
T
irisandClaude Opus 5 56882d5fa3 One load state for the two screens that had the same one twice
`ListState` and `UsageState` were the same three cases -- Loading, Loaded,
Error -- differing only in what Loaded carried, which is the shape rule 17
asks to parameterize rather than copy. They are now one `LoadState<T>`,
covariant so a single `LoadState.Loading` serves both.

Worth keeping as a type rather than a value beside a nullable error: it is
what stops "we couldn't find out" from sharing a representation with
"there is nothing", so a failed fetch cannot render as an empty list.

`LoadState.failed(e)` also collects the `e.message ?: "Unknown error"` both
screens were spelling out, so there is one answer to what an ApiException
looks like on screen instead of one per caller.

No behaviour change. Verified on the emulator against a real ai-server, not
just compiled: the list empty, the list with two sessions, usage with three
real windows, and both screens' error state with the server stopped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-28 03:21:42 -04:00

140 lines
5.4 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.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.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.Duration
import java.time.OffsetDateTime
private val WARN_COLOR = Color(0xFFB26A00)
private val OVER_COLOR = Color(0xFFB3261E)
/** Window bars for the account's rate limits, with reset times. */
@Composable
fun UsageScreen(settings: ServerSettings, onBack: () -> Unit) {
val scope = rememberCoroutineScope()
var state by remember { mutableStateOf<LoadState<List<UsageSnapshot>>>(LoadState.Loading) }
fun refresh() {
state = LoadState.Loading
scope.launch {
state = try {
withContext(Dispatchers.IO) { LoadState.Loaded(fetchUsage(settings)) }
} catch (e: ApiException) {
LoadState.failed(e)
}
}
}
LaunchedEffect(Unit) { refresh() }
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
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))
when (val current = state) {
is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded -> current.value.forEach { snapshot ->
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(snapshot.provider, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
if (!snapshot.available) {
Text(
snapshot.error ?: "Unavailable",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
}
snapshot.windows.forEach { window ->
WindowBar(window)
Spacer(Modifier.height(12.dp))
}
}
}
Spacer(Modifier.height(12.dp))
}
}
}
}
@Composable
private fun WindowBar(window: UsageWindow) {
val color = when {
window.percent >= 95 -> OVER_COLOR
window.percent >= 75 -> WARN_COLOR
else -> MaterialTheme.colorScheme.primary
}
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 = color,
modifier = Modifier.fillMaxWidth(),
)
window.resetsAt?.let {
Spacer(Modifier.height(2.dp))
Text(
"resets ${formatReset(it)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/** "in 3h 12m" -- close enough for deciding whether to start a big task. */
private fun formatReset(resetsAt: String): String = try {
val until = Duration.between(OffsetDateTime.now(), OffsetDateTime.parse(resetsAt))
when {
until.isNegative -> "soon"
until.toHours() >= 24 -> "in ${until.toDays()}d ${until.toHours() % 24}h"
until.toHours() > 0 -> "in ${until.toHours()}h ${until.toMinutes() % 60}m"
else -> "in ${until.toMinutes()}m"
}
} catch (_: Exception) {
"at $resetsAt"
}