package com.example.aiapp import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.repeatOnLifecycle /** * The app's root: one title, and four views of the backend behind it. * * These were four screens reached by four words in a row under the title, and the row was already * full. Tabs say the same thing in less space and say one more thing besides: that these are places * to be rather than errands to run. Sessions, the machine's importable history, the models on it * and the machines themselves are all *the same backend*, looked at four ways, and none is a step * down from another. Settings still is, which is why it stays a pushed screen with its own Back. */ private enum class MainTab(val label: String) { Sessions("Sessions"), Import("Import"), Models("Models"), Machines("Machines"), } @Composable fun MainScreen( settings: ServerSettings, reloadToken: Int, /** What another app shared in and no session has taken yet; see [ShareRequest]. */ share: ShareRequest? = null, onOpen: (SessionSummary) -> Unit, onSpawn: () -> Unit, onImported: (SessionSummary) -> Unit, onSettings: () -> Unit, ) { var tab by remember { mutableStateOf(MainTab.Sessions) } var refreshToken by remember { mutableIntStateOf(0) } // Coming back to the app asks again, on whichever tab is showing. // // What these four draw is a snapshot of a backend they are not connected to, so it is only as // fresh as the last answer -- and a *failed* answer is the one that outstays its welcome. A // phone that was away while the tunnel was down came back to "Couldn't reach the server" // sitting at the top of a list the server would now answer for perfectly well. A stale failure // is worse than a stale list: it is a claim about right now. // // Through the same token the Refresh button uses, so this is one instruction the tabs already // understand. Not on the first entry: the tab composing already asks. val lifecycleOwner = LocalLifecycleOwner.current LaunchedEffect(lifecycleOwner) { var opening = true lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { if (!opening) refreshToken++ opening = false } } // A tab the app put over the list has to step back to it rather than fall through to the system // default, which closes the app. Nested inside AppRoot's handler, so it wins while enabled. BackHandler(enabled = tab != MainTab.Sessions) { tab = MainTab.Sessions } Column(Modifier.fillMaxSize()) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 16.dp), ) { Text( "AI Sessions", style = MaterialTheme.typography.headlineSmall, modifier = Modifier.weight(1f), ) // Glyphs rather than the words they replaced: neither ever changes, both are read // faster than they are spelled, and together they take the width that let the title // keep its own line. They sit on the title's row because they act on the whole screen. // // Flush against each other: a glyph button carries its own padding, so two side by side // already have two rings between their marks. Row { GlyphButton(REFRESH_GLYPH, "Refresh", { refreshToken++ }) GlyphButton(SETTINGS_GLYPH, "Settings", onSettings) } } // What is waiting to be attached, and what to do about it. Said here because the list below // is where the choice is made, and a share that arrived with nothing on screen saying so // would read as a tap that did nothing. share?.let { Text( it.summary() + " -- open the session it belongs in.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp) .background( MaterialTheme.colorScheme.primaryContainer, MaterialTheme.shapes.small, ) .padding(12.dp), ) } // Primary rather than the plain TabRow, which is deprecated in favour of the two that say // where they sit: these are the app's top-level destinations. PrimaryTabRow(selectedTabIndex = tab.ordinal) { MainTab.entries.forEach { entry -> Tab( selected = tab == entry, onClick = { tab = entry }, text = { Text(entry.label) }, ) } } // Refreshing means "ask again about what I am looking at", so the button feeds the tab that // is showing. The token from above means something else already changed what these show; // the two are the same instruction, so they are summed rather than tracked apart. val token = reloadToken + refreshToken when (tab) { MainTab.Sessions -> SessionListScreen( settings = settings, reloadToken = token, onOpen = onOpen, onSpawn = onSpawn, ) MainTab.Import -> ImportScreen(settings = settings, reloadToken = token, onImported = onImported) MainTab.Models -> ModelsScreen(settings = settings, reloadToken = token) MainTab.Machines -> MachinesScreen(settings = settings, reloadToken = token) } } }