diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt index 5765f9c..b47b26a 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt @@ -26,7 +26,12 @@ private sealed class Screen { data object Spawn : Screen() - data object Usage : Screen() + /** + * Reached from a session rather than from the list, because usage belongs to the provider + * running that session and not to the app. It carries the session back with it so Back returns + * where it came from -- see [Screen.Session]. + */ + data class Usage(val from: SessionSummary) : Screen() data object Models : Screen() @@ -97,7 +102,6 @@ fun AppRoot(settingsVersion: Int) { reloadToken = reloadToken, onOpen = { screen = Screen.Session(it) }, onSpawn = { screen = Screen.Spawn }, - onUsage = { screen = Screen.Usage }, onModels = { screen = Screen.Models }, onSetups = { screen = Screen.Setups }, onSettings = { screen = Screen.Settings }, @@ -107,6 +111,7 @@ fun AppRoot(settingsVersion: Int) { settings = current, summary = here.summary, onBack = goToList, + onUsage = { screen = Screen.Usage(here.summary) }, ) is Screen.Spawn -> SpawnScreen( @@ -117,7 +122,13 @@ fun AppRoot(settingsVersion: Int) { }, onBack = goToList, ) - is Screen.Usage -> UsageScreen(settings = current, onBack = goToList) + is Screen.Usage -> + UsageScreen( + settings = current, + // Back to the session it was opened from, not to the list: this is a step down + // from that session, so stepping back is the one thing Back can mean here. + onBack = { screen = Screen.Session(here.from) }, + ) is Screen.Models -> ModelsScreen(settings = current, onBack = goToList) is Screen.Setups -> SetupsScreen(settings = current, onBack = goToList) is Screen.Settings -> diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt index 898acfa..c577548 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt @@ -51,7 +51,7 @@ class MainActivity : ComponentActivity() { handleEnrollment(intent) setContent { - MaterialTheme { + MaterialTheme(colorScheme = AiAppColors) { Surface(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize().statusBarsPadding().imePadding()) { AppRoot(settingsVersion) 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 92b3e57..5d63643 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt @@ -50,7 +50,6 @@ fun SessionListScreen( reloadToken: Int, onOpen: (SessionSummary) -> Unit, onSpawn: () -> Unit, - onUsage: () -> Unit, onModels: () -> Unit, onSetups: () -> Unit, onSettings: () -> Unit, @@ -99,7 +98,6 @@ fun SessionListScreen( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { - TextButton(onClick = onUsage) { Text("Usage") } TextButton(onClick = onModels) { Text("Models") } TextButton(onClick = onSetups) { Text("Setups") } TextButton(onClick = onSettings) { Text("Settings") } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 1edf39e..cad9841 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -130,7 +130,12 @@ private fun updateTool( } @Composable -fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () -> Unit) { +fun SessionScreen( + settings: ServerSettings, + summary: SessionSummary, + onBack: () -> Unit, + onUsage: () -> Unit, +) { val scope = rememberCoroutineScope() var items by remember { mutableStateOf(listOf()) } var status by remember { mutableStateOf(summary.status) } @@ -252,6 +257,15 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () ) } StatusText(status) + // Beside the provider it reports on, which is the line directly to its left. + // + // Its real home is this provider's settings, which do not exist yet; until they do, + // the session is the only place the provider is already named, so it is the only + // place the button can sit without inventing a scope for itself. What it shows is + // the paid service's own numbers, so a session on a provider with no such service + // gets an honest "unavailable" rather than a hidden button -- a control that comes + // and goes makes its absence the signal, and absence cannot say why. + TextButton(onClick = onUsage) { Text("Usage") } } (streamError ?: actionError)?.let { message -> diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt new file mode 100644 index 0000000..e5a0090 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt @@ -0,0 +1,100 @@ +package com.example.aiapp + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +/** + * Catppuccin Mocha, as published in `catppuccin/palette`. + * + * Named rather than used as literals at the point of need, so the mapping below reads as the + * decision it is -- "a card is Surface 0" -- and so a value can be checked against the upstream + * palette without reading the layout that uses it. + */ +private object Mocha { + val Rosewater = Color(0xFFF5E0DC) + val Mauve = Color(0xFFCBA6F7) + val Red = Color(0xFFF38BA8) + val Peach = Color(0xFFFAB387) + val Yellow = Color(0xFFF9E2AF) + val Green = Color(0xFFA6E3A1) + val Teal = Color(0xFF94E2D5) + val Sky = Color(0xFF89DCEB) + val Blue = Color(0xFF89B4FA) + val Lavender = Color(0xFFB4BEFE) + val Text = Color(0xFFCDD6F4) + val Subtext0 = Color(0xFFA6ADC8) + val Overlay0 = Color(0xFF6C7086) + val Surface2 = Color(0xFF585B70) + val Surface1 = Color(0xFF45475A) + val Surface0 = Color(0xFF313244) + val Base = Color(0xFF1E1E2E) + val Mantle = Color(0xFF181825) + val Crust = Color(0xFF11111B) +} + +/** + * The app's colour scheme: Catppuccin Mocha mapped onto Material's roles. + * + * Copied from dev-updater rather than shared, which is a deliberate line: wg-app-link is the *link* + * -- the tunnel, the pinned CA, enrollment -- and a palette is not that. The two apps looking alike + * is a preference, not a contract, and the moment one wants a different accent the shared version + * becomes a thing to fight rather than a thing to use. + * + * The mapping that matters is the surface ladder. Mocha names its darks in order -- Crust, Mantle, + * Base, Surface 0, Surface 1 -- and Material asks for the same thing under different names, so the + * page is Base, a component's outlined card stays Base beside it, and a project's card is Surface + * 0: one visible step up, which is the whole of what the nesting has to say. + * + * Accents on this palette are light, so anything filled with one takes Crust for its text rather + * than the near-white the roles default to. + */ +val AiAppColors = + darkColorScheme( + primary = Mocha.Mauve, + onPrimary = Mocha.Crust, + primaryContainer = Mocha.Surface1, + onPrimaryContainer = Mocha.Mauve, + secondary = Mocha.Lavender, + onSecondary = Mocha.Crust, + secondaryContainer = Mocha.Surface1, + onSecondaryContainer = Mocha.Lavender, + tertiary = Mocha.Rosewater, + onTertiary = Mocha.Crust, + background = Mocha.Base, + onBackground = Mocha.Text, + surface = Mocha.Base, + onSurface = Mocha.Text, + surfaceVariant = Mocha.Surface0, + onSurfaceVariant = Mocha.Subtext0, + surfaceContainerLowest = Mocha.Crust, + surfaceContainerLow = Mocha.Mantle, + surfaceContainer = Mocha.Base, + surfaceContainerHigh = Mocha.Surface0, + surfaceContainerHighest = Mocha.Surface0, + inverseSurface = Mocha.Text, + inverseOnSurface = Mocha.Base, + inversePrimary = Mocha.Mauve, + outline = Mocha.Overlay0, + outlineVariant = Mocha.Surface2, + error = Mocha.Red, + onError = Mocha.Crust, + errorContainer = Mocha.Surface1, + onErrorContainer = Mocha.Red, + scrim = Mocha.Crust, + ) + +/** "There is something here": a session that is running. */ +val runningColor: Color + @Composable get() = Mocha.Green + +/** + * "This went wrong on its own": a session that fell over. + * + * The scheme's error colour, and deliberately not "the same red as a destructive button" even + * though it is the same red. They are the same red for different reasons, and a state is not an + * action -- nothing here is a button. + */ +val failedColor: Color + @Composable get() = MaterialTheme.colorScheme.error 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 de5df77..18b9dea 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageScreen.kt @@ -56,6 +56,11 @@ fun UsageScreen(settings: ServerSettings, onBack: () -> Unit) { Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + // Deliberately not subtitled with the provider this was opened from. These numbers + // are the *account's*, reported by whichever paid service answered -- naming the + // session's provider here made an echo session's screen read "echo" above a card + // reading "claude", which is a claim about echo that nothing measured. Each card + // names the service it came from, which is the true scope. Text( "Usage", style = MaterialTheme.typography.headlineSmall,