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)