From 56882d5fa3e778f7505563355b55e92e29d6ef13 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 28 Aug 2026 03:21:42 -0400 Subject: [PATCH] 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`, 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 Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw --- .../kotlin/com/example/aiapp/LoadState.kt | 32 +++++++++++++++++++ .../com/example/aiapp/SessionListScreen.kt | 26 ++++++--------- .../kotlin/com/example/aiapp/UsageScreen.kt | 20 ++++-------- 3 files changed, 49 insertions(+), 29 deletions(-) create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/LoadState.kt diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/LoadState.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/LoadState.kt new file mode 100644 index 0000000..cb9dfb3 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/LoadState.kt @@ -0,0 +1,32 @@ +package com.example.aiapp + +/** + * What a screen knows about something it had to fetch: still finding out, + * got it, or couldn't. + * + * Three states rather than a value alongside a nullable error, because + * "we couldn't find out" must not share a representation with "there is + * nothing" -- a failed fetch would otherwise render as an empty list, + * which is the one wrong answer that looks like a right one. + * + * [Loading] and [Error] carry no payload, so they are `LoadState` + * and this is covariant in [T]: one `LoadState.Loading` serves every + * screen rather than each needing its own. + */ +sealed class LoadState { + data object Loading : LoadState() + + data class Loaded(val value: T) : LoadState() + + data class Error(val message: String) : LoadState() + + companion object { + /** + * The failure a fetch produces. Api.kt writes its messages to be + * read on this screen, so this passes one through rather than + * replacing it; the fallback covers only a throwable with no + * message at all, which [ApiException] never is. + */ + fun failed(e: ApiException): Error = Error(e.message ?: "Unknown error") + } +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt index aca25ad..47b5f05 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt @@ -40,12 +40,6 @@ import kotlinx.coroutines.withContext private val AWAITING_COLOR = Color(0xFFB26A00) private val RUNNING_COLOR = Color(0xFF2E7D32) -private sealed class ListState { - data object Loading : ListState() - data class Loaded(val sessions: List) : ListState() - data class Error(val message: String) : ListState() -} - /** * The session list -- the app's root screen. Sessions awaiting an answer * sort to the top: that's the "your turn" inbox. @@ -60,16 +54,16 @@ fun SessionListScreen( onSettings: () -> Unit, ) { val scope = rememberCoroutineScope() - var listState by remember { mutableStateOf(ListState.Loading) } + var listState by remember { mutableStateOf>>(LoadState.Loading) } var confirmingDelete by remember { mutableStateOf(null) } fun refresh() { - listState = ListState.Loading + listState = LoadState.Loading scope.launch { listState = try { - withContext(Dispatchers.IO) { ListState.Loaded(fetchSessions(settings)) } + withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) } } catch (e: ApiException) { - ListState.Error(e.message ?: "Unknown error") + LoadState.failed(e) } } } @@ -91,13 +85,13 @@ fun SessionListScreen( Spacer(Modifier.height(16.dp)) when (val state = listState) { - is ListState.Loading -> CircularProgressIndicator() - is ListState.Error -> Text( + is LoadState.Loading -> CircularProgressIndicator() + is LoadState.Error -> Text( "Couldn't reach the server: ${state.message}", color = MaterialTheme.colorScheme.error, ) - is ListState.Loaded -> { - if (state.sessions.isEmpty()) { + is LoadState.Loaded -> { + if (state.value.isEmpty()) { Text( "No sessions. Tap + to spawn one.", style = MaterialTheme.typography.bodyMedium, @@ -106,7 +100,7 @@ fun SessionListScreen( } // Awaiting-answer first (the point of the screen), then // most recently active. - val ordered = state.sessions.sortedWith( + val ordered = state.value.sortedWith( compareByDescending { it.status == "awaitingInput" } .thenByDescending { it.lastActivity }, ) @@ -143,7 +137,7 @@ fun SessionListScreen( withContext(Dispatchers.IO) { deleteSession(settings, session.id) } refresh() } catch (e: ApiException) { - listState = ListState.Error(e.message ?: "Delete failed") + listState = LoadState.Error(e.message ?: "Delete failed") } } }) { Text("Delete") } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageScreen.kt index 7042e62..860729e 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageScreen.kt @@ -35,25 +35,19 @@ import java.time.OffsetDateTime private val WARN_COLOR = Color(0xFFB26A00) private val OVER_COLOR = Color(0xFFB3261E) -private sealed class UsageState { - data object Loading : UsageState() - data class Loaded(val snapshots: List) : UsageState() - data class Error(val message: String) : UsageState() -} - /** 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(UsageState.Loading) } + var state by remember { mutableStateOf>>(LoadState.Loading) } fun refresh() { - state = UsageState.Loading + state = LoadState.Loading scope.launch { state = try { - withContext(Dispatchers.IO) { UsageState.Loaded(fetchUsage(settings)) } + withContext(Dispatchers.IO) { LoadState.Loaded(fetchUsage(settings)) } } catch (e: ApiException) { - UsageState.Error(e.message ?: "Unknown error") + LoadState.failed(e) } } } @@ -72,9 +66,9 @@ fun UsageScreen(settings: ServerSettings, onBack: () -> Unit) { Spacer(Modifier.height(16.dp)) when (val current = state) { - is UsageState.Loading -> CircularProgressIndicator() - is UsageState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error) - is UsageState.Loaded -> current.snapshots.forEach { snapshot -> + 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)