Files
ai-app/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt
T
iris ff39ef5cf9 Say it in icons, and put the whole backend behind four tabs
Six things Bryan asked for, which turned out to be one change: the app had
no icon set, so every one of them was blocked on having somewhere for icons
to come from.

That somewhere is dev-updater's arrangement, ported: a Nerd Fonts subset
committed as an asset, drawn as text. `Gear.kt`'s hand-drawn canvas gear
argued against icon fonts because a system font may not have the glyph and
whoever gets the empty box is never the person who wrote it. The objection
is right about *relying* on a system font and the answer is to ship the
glyph, so the file is gone and its reasoning is restated in `NerdIcons.kt`
rather than deleted -- otherwise the next reader re-derives it. `md-cog` and
`md-refresh` are dev-updater's own codepoints, because a cog means the same
thing in both apps.

The root screen's four words under the title are now four tabs, and the two
that act on the whole screen -- settings and refresh -- moved up onto the
title row as glyphs. That row's old comment recorded that a fifth word would
have had nowhere to go; tabs also say something the words did not, which is
that sessions, import, models and setups are four views of one backend
rather than four errands. Refresh feeds whichever tab is showing. Import,
models and setups lose their headings and their Back buttons, since the tab
row is now both.

Usage is a dialog. It is checked *against* what you were reading -- "can I
start this" is asked with the transcript still on screen -- and it had no
navigation of its own, so the only thing its Back could mean was "put this
away". The button that opens it is a chart glyph coloured by the worst of
the machine's windows, so the row says whether the limits are worth opening
before anybody opens them.

One `quotaColor` now colours every bar that measures a quota: blue, yellow
at 75%, red at 95%. The session bar escalates where it used to sit blue at
every level, and the dialog's thresholds moved out of it. A download keeps
plain blue at every value -- it has no limit to approach, and colouring it
like one would say the opposite of what is happening. States that are not
measurements take the ordinary control colour, since blue is the low end of
this scale and would read as "checked, and fine" about a machine nobody
could reach.

Send and stop are the filled paper plane and the filled square. Send keeps
the word "Queue" while a turn is in flight, because that is what pressing it
then does, and an icon that does two things while looking identical would
promise something immediate and do something that waits.

Looked at on the emulator: all six glyphs render, the tabs and the system
back gesture between them, the dialog over a live session, and the bar
bands at 82% and 97% forced through a scratch build, since this account is
at 72/31/5 and would only ever have shown blue.
2026-08-29 22:27:54 -04:00

203 lines
8.5 KiB
Kotlin

package com.example.aiapp
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import com.example.wgapplink.EnrollmentScanActivity
import com.google.zxing.client.android.Intents
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanIntentResult
import com.journeyapps.barcodescanner.ScanOptions
/**
* Server address and token. The normal path is the "Scan QR code" button below, which decodes the
* server's terminal QR itself; these fields are the fallback for typing the same three values by
* hand. [onBack] is null on first run, when there is nothing to go back to.
*/
@Composable
fun SettingsScreen(
existing: ServerSettings?,
onSaved: (ServerSettings) -> Unit,
onBack: (() -> Unit)?,
) {
val context = LocalContext.current
var host by remember { mutableStateOf(existing?.host ?: "10.66.0.1") }
var port by remember { mutableStateOf((existing?.port ?: 8443).toString()) }
// Never pre-filled from the stored token: this screen shouldn't be a
// way to read the credential back off the device.
var token by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
val scanLauncher =
rememberLauncherForActivityResult(ScanContract()) { result: ScanIntentResult ->
// Null contents means the user backed out of the scanner -- not an
// error, so nothing to report.
val contents = result.contents ?: return@rememberLauncherForActivityResult
val settings = parseEnrollmentUri(contents.toUri())
if (settings == null) {
error = "Not a valid enrollment code"
} else {
saveServerSettings(context, settings)
onSaved(settings)
}
}
val requestCamera =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) {
scanLauncher.launch(enrollmentScanOptions())
} else {
error =
"Scanning needs the camera. Grant it in the system settings, " +
"or type the host, port and token in below."
}
}
Column(Modifier.fillMaxSize().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
// Leading, where a back arrow points at what it returns to. Trailing it would put a
// left-pointing arrow at the right edge, aimed across the title it sits beside.
//
// Absent rather than disabled on first run, which is the one place this app lets a
// control come and go: there is no screen underneath yet, so a Back here would not be
// a capability being withheld but a promise it could not keep.
if (onBack != null) {
GlyphButton(BACK_GLYPH, "Back", onBack)
Spacer(Modifier.width(8.dp))
}
Text(
"Server",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f),
)
}
Spacer(Modifier.height(8.dp))
Text(
"The easy way: run ai-server on the backend and scan the QR it prints. " +
"Or type the same values here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
OutlinedButton(
onClick = {
// Hold the camera permission before the scanner starts.
// Letting its activity ask on our behalf is what the
// library does by default, and it opens the camera without
// waiting for the answer: the first-ever scan comes up as
// a live preview with "Sorry, the Android camera
// encountered a problem" over it, and works on the second
// try. Nothing is wrong with the camera, so nothing should
// say there is.
if (
context.checkSelfPermission(Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
) {
scanLauncher.launch(enrollmentScanOptions())
} else {
requestCamera.launch(Manifest.permission.CAMERA)
}
},
modifier = Modifier.fillMaxWidth(),
) {
Text("Scan QR code")
}
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = host,
onValueChange = { host = it },
label = { Text("Host") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = port,
onValueChange = { port = it },
label = { Text("Port") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = token,
onValueChange = { token = it },
label = { Text(if (existing != null) "Token (unchanged if left blank)" else "Token") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(24.dp))
error?.let {
Text(it, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
}
Button(
onClick = {
val portNumber = port.trim().toIntOrNull()
val effectiveToken = token.trim().ifEmpty { existing?.token ?: "" }
when {
host.isBlank() -> error = "Host is required"
portNumber == null || portNumber !in 1..65535 -> error = "Port must be 1-65535"
effectiveToken.isEmpty() ->
error = "Token is required -- scan the server's QR or paste it"
else -> {
val settings = ServerSettings(host.trim(), portNumber, effectiveToken)
saveServerSettings(context, settings)
onSaved(settings)
}
}
}
) {
Text("Save")
}
}
}
/**
* How the enrollment QR is scanned, in one place because two callers reach it -- straight from the
* button when the camera permission is already held, and from the permission result when it has
* just been granted.
*
* MIXED_SCAN is the load-bearing part: ZXing otherwise looks only for a dark code on a light
* ground, and ai-server's QR is block characters in the terminal's foreground colour, so on a
* dark-themed terminal it comes out as a photographic negative the scanner silently never matches.
* Which way round it renders is the terminal's business, not something this app should depend on.
* The mixed decoder alternates normal and inverted frames, costing half the frame rate at each
* polarity and nothing else.
*/
private fun enrollmentScanOptions(): ScanOptions =
ScanOptions()
.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
.setCaptureActivity(EnrollmentScanActivity::class.java)
// Follow the phone, not the library's landscape pin.
.setOrientationLocked(false)
.addExtra(Intents.Scan.SCAN_TYPE, Intents.Scan.MIXED_SCAN)