151 lines
6.7 KiB
Kotlin
151 lines
6.7 KiB
Kotlin
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)
|
|
}
|
|
}
|
|
}
|