`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
140 lines
5.4 KiB
Kotlin
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"
|
|
}
|