563 lines
27 KiB
Kotlin
563 lines
27 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.width
|
|
import androidx.compose.foundation.rememberScrollState
|
|
import androidx.compose.foundation.text.KeyboardActions
|
|
import androidx.compose.foundation.text.KeyboardOptions
|
|
import androidx.compose.foundation.verticalScroll
|
|
import androidx.compose.material3.AlertDialog
|
|
import androidx.compose.material3.CircularProgressIndicator
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.OutlinedTextField
|
|
import androidx.compose.material3.Switch
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TextButton
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.rememberCoroutineScope
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.text.input.ImeAction
|
|
import androidx.compose.ui.unit.dp
|
|
import java.time.Instant
|
|
import java.time.ZoneId
|
|
import java.time.format.DateTimeFormatter
|
|
import java.time.format.FormatStyle
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.withContext
|
|
|
|
/**
|
|
* What can be changed about one session, as opposed to about this app.
|
|
*
|
|
* Over the session rather than a step down from it: everything here is about the conversation
|
|
* behind it, and a dialog keeps that conversation on screen while it is being adjusted. It was a
|
|
* screen of its own until 2026-08-30, which put a page transition and a back stack around two
|
|
* controls and hid the thing they act on.
|
|
*
|
|
* The model and the permission mode are deliberately still on the session's own bar, because those
|
|
* are changed *while* reading a turn -- "not this model, try that one".
|
|
*
|
|
* Captions are for what a control costs rather than for what it is. A paragraph under every control
|
|
* made the dialog longer than the conversation it covers -- so Notifications has none, while Move
|
|
* and Reload do, because what those two take away is not visible from here.
|
|
*/
|
|
@Composable
|
|
fun SessionSettingsDialog(
|
|
settings: ServerSettings,
|
|
sessionId: String,
|
|
/**
|
|
* What the session is called now, as the screen behind this knows it -- see the rename below.
|
|
*/
|
|
title: String,
|
|
onRenamed: (String) -> Unit,
|
|
/**
|
|
* How hard the model thinks, as the session reports it, or null for the CLI's own default.
|
|
*
|
|
* Taken from the row this dialog was opened over rather than fetched, because unlike the
|
|
* notification switch there is nothing else that changes it: the level is this app's to set and
|
|
* the server does not resolve it into something else.
|
|
*/
|
|
effort: String?,
|
|
/** Whether a level does anything here; the row is left out entirely where it does not. */
|
|
takesEffort: Boolean,
|
|
/**
|
|
* What this phone is holding of the conversation, or null while that is being measured -- see
|
|
* the Reload row below, which is what would discard it.
|
|
*/
|
|
cachedBytes: Long?,
|
|
onReload: () -> Unit,
|
|
onDismiss: () -> Unit,
|
|
/**
|
|
* Copies what this session costs to draw. Built by the session screen, because everything it
|
|
* measures is that screen's own state.
|
|
*/
|
|
onCopyRenderReport: () -> Unit,
|
|
/**
|
|
* Runs P0's scripted scroll-and-stream benchmark and copies the extended report, or null on
|
|
* every build but `bench` -- see [BuildConfig.FIXTURE_MODE] and BenchRun.kt. Null rather than
|
|
* always-present-but-disabled: this has no meaning at all outside the bench build, and a
|
|
* control with nothing behind it on every other build is not a state worth drawing.
|
|
*/
|
|
onRunBenchmark: (() -> Unit)? = null,
|
|
) {
|
|
val scope = rememberCoroutineScope()
|
|
var name by remember(sessionId) { mutableStateOf(title) }
|
|
var level by remember(sessionId) { mutableStateOf(effort) }
|
|
var effortError by remember { mutableStateOf<String?>(null) }
|
|
var saving by remember { mutableStateOf(false) }
|
|
var error by remember { mutableStateOf<String?>(null) }
|
|
// Null until the server has been asked. The row this dialog was opened over is a snapshot of
|
|
// whenever the list was last fetched, so drawing the switch straight from it would show a
|
|
// position that may have been changed since. Until the answer arrives the switch is disabled
|
|
// and a spinner sits beside it, which is what not knowing looks like.
|
|
var notify by remember(sessionId) { mutableStateOf<Boolean?>(null) }
|
|
var notifyError by remember { mutableStateOf<String?>(null) }
|
|
// The same three-state shape the notification switch has, for the same reason: until the
|
|
// server has answered, the switch is disabled rather than showing a position nothing confirmed.
|
|
var autoResume by remember(sessionId) { mutableStateOf<Boolean?>(null) }
|
|
var resumeMessage by remember(sessionId) { mutableStateOf(DEFAULT_RESUME_MESSAGE) }
|
|
// When the server next intends to ask whether the limit has lifted, or null when nothing is
|
|
// waiting. Read once with everything else: it moves on the server's schedule, not this
|
|
// screen's, and a figure that redrew itself here would be this app re-measuring what it was
|
|
// told.
|
|
var resumeAt by remember(sessionId) { mutableStateOf<Double?>(null) }
|
|
var resumeError by remember { mutableStateOf<String?>(null) }
|
|
// Where the session works. Null until the server has been asked, for the same reason the switch
|
|
// above is. An empty answer is a session that was never given a directory, which is not the
|
|
// same as one whose directory is unknown -- the field is only enabled once one of those is
|
|
// settled.
|
|
var cwd by remember(sessionId) { mutableStateOf<String?>(null) }
|
|
var typedCwd by remember(sessionId) { mutableStateOf("") }
|
|
var cwdError by remember { mutableStateOf<String?>(null) }
|
|
var movingCwd by remember { mutableStateOf(false) }
|
|
|
|
LaunchedEffect(sessionId) {
|
|
try {
|
|
val fresh = withContext(Dispatchers.IO) { fetchSession(settings, sessionId) }
|
|
notify = fresh.notify
|
|
autoResume = fresh.autoResume
|
|
resumeMessage = fresh.autoResumeMessage
|
|
resumeAt = fresh.resumeAt
|
|
cwd = fresh.cwd.orEmpty()
|
|
typedCwd = fresh.cwd.orEmpty()
|
|
} catch (e: ApiException) {
|
|
// Left unknown rather than falling back to the stale row: the switch stays disabled,
|
|
// instead of offering a position nothing confirmed.
|
|
notifyError = e.message
|
|
notify = null
|
|
resumeError = e.message
|
|
autoResume = null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Moves the session, which ends the process that is in the old directory.
|
|
*
|
|
* Said plainly beside the field rather than confirmed in a second dialog: what it costs is a
|
|
* process, and a stopped session is a state this app already has a word and a button for.
|
|
*/
|
|
fun moveCwd() {
|
|
val chosen = typedCwd.trim()
|
|
if (movingCwd || chosen.isEmpty() || chosen == cwd) return
|
|
movingCwd = true
|
|
cwdError = null
|
|
scope.launch {
|
|
try {
|
|
withContext(Dispatchers.IO) { setSessionCwd(settings, sessionId, chosen) }
|
|
cwd = chosen
|
|
} catch (e: ApiException) {
|
|
// Where it happened: this field is the only thing on screen that knows a move was
|
|
// asked for, and the reason is usually the path itself.
|
|
cwdError = e.message
|
|
} finally {
|
|
movingCwd = false
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Chooses a thinking level, which ends the process the old level was launched with.
|
|
*
|
|
* Put back if the request is refused, for the reason the notification switch below gives: a
|
|
* control that stays where it was put after a refusal is stating something untrue.
|
|
*/
|
|
fun setEffort(chosen: String?) {
|
|
val was = level
|
|
level = chosen
|
|
effortError = null
|
|
scope.launch {
|
|
try {
|
|
withContext(Dispatchers.IO) { setSessionEffort(settings, sessionId, chosen) }
|
|
} catch (e: ApiException) {
|
|
level = was
|
|
effortError = e.message
|
|
}
|
|
}
|
|
}
|
|
|
|
// Moved optimistically so the switch answers the finger that moved it, and put back if the
|
|
// request is refused -- a switch that waits for a round trip reads as broken on a slow tunnel,
|
|
// and one that stays moved after a refusal lies.
|
|
fun setNotify(wanted: Boolean) {
|
|
val was = notify
|
|
notify = wanted
|
|
notifyError = null
|
|
scope.launch {
|
|
try {
|
|
withContext(Dispatchers.IO) { setSessionNotify(settings, sessionId, wanted) }
|
|
} catch (e: ApiException) {
|
|
notify = was
|
|
notifyError = e.message
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Turns auto-resume on or off, or changes what it would say.
|
|
*
|
|
* One request for both, because the server takes one: switching it on and typing the message
|
|
* are two halves of the same decision, and sending them separately would leave a moment where
|
|
* the session is armed with the old words.
|
|
*
|
|
* Put back if refused, like the notification switch. Turning it off also clears what was
|
|
* scheduled -- said here rather than only on the server, or the row would go on naming a time
|
|
* that no longer exists.
|
|
*/
|
|
fun setAutoResume(on: Boolean, message: String) {
|
|
val wasOn = autoResume
|
|
val wasMessage = resumeMessage
|
|
val wasAt = resumeAt
|
|
autoResume = on
|
|
resumeMessage = message
|
|
if (!on) resumeAt = null
|
|
resumeError = null
|
|
scope.launch {
|
|
try {
|
|
withContext(Dispatchers.IO) {
|
|
setSessionAutoResume(settings, sessionId, on, message)
|
|
}
|
|
} catch (e: ApiException) {
|
|
autoResume = wasOn
|
|
resumeMessage = wasMessage
|
|
resumeAt = wasAt
|
|
resumeError = e.message
|
|
}
|
|
}
|
|
}
|
|
|
|
// Nothing to do when the name has not changed, so the button says so rather than sending a
|
|
// request whose success would look exactly like the failure of having typed nothing.
|
|
val changed = name.trim().isNotEmpty() && name.trim() != title
|
|
|
|
fun save() {
|
|
if (!changed || saving) return
|
|
val chosen = name.trim()
|
|
saving = true
|
|
error = null
|
|
scope.launch {
|
|
try {
|
|
withContext(Dispatchers.IO) { renameSession(settings, sessionId, chosen) }
|
|
onRenamed(chosen)
|
|
} catch (e: ApiException) {
|
|
// Reported here, where it happened, because this dialog is the only place that
|
|
// knows a rename was attempted.
|
|
error = e.message
|
|
saving = false
|
|
}
|
|
}
|
|
}
|
|
|
|
AlertDialog(
|
|
onDismissRequest = onDismiss,
|
|
title = { Text("Session settings") },
|
|
text = {
|
|
// Scrollable, because this dialog grew past a screenful: a Material dialog constrains
|
|
// its own height and clips what does not fit, so the last control on the list is one
|
|
// large system font away from being unreachable with nothing on screen to say so.
|
|
Column(Modifier.verticalScroll(rememberScrollState())) {
|
|
OutlinedTextField(
|
|
value = name,
|
|
onValueChange = { name = it },
|
|
label = { Text("Name") },
|
|
singleLine = true,
|
|
enabled = !saving,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
// The keyboard's own action does what the button does: a one-field form where
|
|
// the return key does nothing is a form people press return at anyway.
|
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
|
keyboardActions = KeyboardActions(onDone = { save() }),
|
|
)
|
|
Spacer(Modifier.height(8.dp))
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
Glyph(BELL_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
|
|
Spacer(Modifier.width(8.dp))
|
|
Text("Notifications", modifier = Modifier.weight(1f))
|
|
if (notify == null && notifyError == null) {
|
|
CircularProgressIndicator(
|
|
modifier = Modifier.width(16.dp).height(16.dp),
|
|
strokeWidth = 2.dp,
|
|
)
|
|
Spacer(Modifier.width(8.dp))
|
|
}
|
|
Switch(
|
|
checked = notify == true,
|
|
onCheckedChange = { setNotify(it) },
|
|
enabled = notify != null,
|
|
)
|
|
}
|
|
// Beside the switch that failed, not with the rename's error: they are two requests
|
|
// and a reader has to be able to tell which one the server refused.
|
|
notifyError?.let {
|
|
Text(
|
|
it,
|
|
color = MaterialTheme.colorScheme.error,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
)
|
|
}
|
|
Spacer(Modifier.height(8.dp))
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
Text("Resume after a usage limit", modifier = Modifier.weight(1f))
|
|
if (autoResume == null && resumeError == null) {
|
|
CircularProgressIndicator(
|
|
modifier = Modifier.width(16.dp).height(16.dp),
|
|
strokeWidth = 2.dp,
|
|
)
|
|
Spacer(Modifier.width(8.dp))
|
|
}
|
|
Switch(
|
|
checked = autoResume == true,
|
|
onCheckedChange = { setAutoResume(it, resumeMessage) },
|
|
enabled = autoResume != null,
|
|
)
|
|
}
|
|
// Disabled rather than hidden while the switch is off: a field that comes and goes
|
|
// makes its own presence the signal, and a visible one teaches what the switch will
|
|
// do. Committed on the keyboard's Done rather than on every keystroke, so typing a
|
|
// sentence is one request instead of one per letter.
|
|
OutlinedTextField(
|
|
value = resumeMessage,
|
|
onValueChange = { resumeMessage = it },
|
|
label = { Text("Message to send") },
|
|
// What an empty field means, in the field: the server's own word rather than a
|
|
// session poked with nothing to read.
|
|
placeholder = { Text(DEFAULT_RESUME_MESSAGE) },
|
|
singleLine = true,
|
|
enabled = autoResume == true,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
|
keyboardActions =
|
|
KeyboardActions(onDone = { setAutoResume(true, resumeMessage) }),
|
|
)
|
|
// What it does and what it costs, in the order it happens. The last sentence is the
|
|
// one that matters: the time below is when the server will *ask*, not a promise
|
|
// about when the session speaks.
|
|
Text(
|
|
"When this session stops because the account is out of quota, the server " +
|
|
"checks the limit and sends this message once it has lifted. It checks " +
|
|
"again if the limit is still on.",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
// Only where something is actually waiting. Absent is not a state worth a row: a
|
|
// session that has not hit a limit has nothing scheduled, which the reader can see
|
|
// from the switch.
|
|
resumeAt?.let { at ->
|
|
Text(
|
|
"Waiting now -- next check ${formatCheckTime(at)}.",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
resumeError?.let {
|
|
Text(
|
|
it,
|
|
color = MaterialTheme.colorScheme.error,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
)
|
|
}
|
|
Spacer(Modifier.height(8.dp))
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
OutlinedTextField(
|
|
value = typedCwd,
|
|
onValueChange = { typedCwd = it },
|
|
label = { Text("Working directory") },
|
|
// What the field cannot say by being empty: a session that was never given
|
|
// one starts wherever its launcher does, and this names that rather than
|
|
// showing a path nobody chose.
|
|
placeholder = { Text("wherever the session was started") },
|
|
singleLine = true,
|
|
enabled = cwd != null && !movingCwd,
|
|
modifier = Modifier.weight(1f),
|
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
|
keyboardActions = KeyboardActions(onDone = { moveCwd() }),
|
|
)
|
|
TextButton(
|
|
onClick = { moveCwd() },
|
|
enabled =
|
|
cwd != null &&
|
|
!movingCwd &&
|
|
typedCwd.trim().isNotEmpty() &&
|
|
typedCwd.trim() != cwd,
|
|
) {
|
|
Text(if (movingCwd) "Moving..." else "Move")
|
|
}
|
|
}
|
|
// The whole of what pressing Move does, where it is about to be pressed. A
|
|
// directory is settled when the process is spawned, so it is ended and the next
|
|
// thing said to the session starts it in the new place.
|
|
Text(
|
|
"Moving stops the session's process. It starts again in the new directory " +
|
|
"with the next message, or with Start.",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
cwdError?.let {
|
|
Text(
|
|
it,
|
|
color = MaterialTheme.colorScheme.error,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
)
|
|
}
|
|
// Left out rather than disabled, the one place this dialog does that: a disabled
|
|
// control teaches what the thing can do, and a llama session cannot do this at all
|
|
// -- the row would be teaching something false about it.
|
|
if (takesEffort) {
|
|
Spacer(Modifier.height(8.dp))
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
Text("Thinking", modifier = Modifier.weight(1f))
|
|
PickerButton(
|
|
current = level ?: DEFAULT_EFFORT,
|
|
// The level the CLI picks for itself is in the list as well as in the
|
|
// button, so leaving a level is not a one-way trip -- the same
|
|
// correction the model picker carries.
|
|
options = listOf(DEFAULT_EFFORT) + EFFORT_LEVELS,
|
|
onPick = { chosen ->
|
|
setEffort(chosen.takeIf { it != DEFAULT_EFFORT })
|
|
},
|
|
)
|
|
}
|
|
// What it costs, said where it is about to be pressed, like Move above: the
|
|
// CLI reads the level when it launches and has no control request for
|
|
// changing one.
|
|
Text(
|
|
"Changing this stops the session's process. It starts again with the " +
|
|
"next message, or with Start.",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
effortError?.let {
|
|
Text(
|
|
it,
|
|
color = MaterialTheme.colorScheme.error,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
)
|
|
}
|
|
}
|
|
Spacer(Modifier.height(8.dp))
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
Text("Transcript", modifier = Modifier.weight(1f))
|
|
// The size is what the button discards, and the unknown state is drawn rather
|
|
// than guessed: a spinner while the directory is being measured, and words when
|
|
// there is nothing there, because "nothing cached" and "0 B" read as different
|
|
// claims.
|
|
when {
|
|
cachedBytes == null ->
|
|
CircularProgressIndicator(
|
|
modifier = Modifier.width(16.dp).height(16.dp),
|
|
strokeWidth = 2.dp,
|
|
)
|
|
else ->
|
|
Text(
|
|
humanSize(cachedBytes)?.let { "$it cached" } ?: "nothing cached",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
Spacer(Modifier.width(12.dp))
|
|
// Enabled whether or not anything is cached: "what I see disagrees with the
|
|
// machine" is a state an empty cache can be in too, and a control that comes
|
|
// and goes makes its own presence the signal.
|
|
TextButton(onClick = onReload) { Text("Reload") }
|
|
}
|
|
// Captioned, unlike the controls above it, for the same reason Move is: what it
|
|
// costs is not visible, and neither is the case it exists for.
|
|
Text(
|
|
"Reload throws away this phone's copy and fetches the transcript from the " +
|
|
"server again. Use it when what is shown here disagrees with the file " +
|
|
"on the machine.",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
error?.let {
|
|
Spacer(Modifier.height(8.dp))
|
|
Text(
|
|
it,
|
|
color = MaterialTheme.colorScheme.error,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
)
|
|
}
|
|
Spacer(Modifier.height(8.dp))
|
|
// About this session, which is what everything in here is -- and it was on the
|
|
// header until 2026-09-03, where the folder button now is. It copies rather than
|
|
// opening anything, so it says so and then says it happened: a row that looks like
|
|
// a control and gives no sign of having run is one people press twice.
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
Glyph(SPEED_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
|
|
Spacer(Modifier.width(8.dp))
|
|
Text("Render timings", modifier = Modifier.weight(1f))
|
|
TextButton(onClick = onCopyRenderReport) { Text("Copy") }
|
|
}
|
|
// Bench-build only: see [onRunBenchmark]. Named exactly "Run benchmark" because
|
|
// ui-trace and the emulator smoke run find it by that label, the same way every
|
|
// other control here is found -- see AGENTS.md's "Driving the UI".
|
|
onRunBenchmark?.let { run ->
|
|
Spacer(Modifier.height(8.dp))
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
Glyph(SPEED_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
|
|
Spacer(Modifier.width(8.dp))
|
|
Text("P0 benchmark", modifier = Modifier.weight(1f))
|
|
TextButton(onClick = run) { Text("Run benchmark") }
|
|
}
|
|
}
|
|
}
|
|
},
|
|
// Disabled rather than absent while there is nothing to save: a button that comes and goes
|
|
// makes its own presence the signal, and its absence cannot say why.
|
|
confirmButton = {
|
|
TextButton(onClick = { save() }, enabled = changed && !saving) {
|
|
Text(if (saving) "Saving..." else "Save")
|
|
}
|
|
},
|
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Close") } },
|
|
)
|
|
}
|
|
|
|
/**
|
|
* When the server will next look, as a local time.
|
|
*
|
|
* A time rather than a countdown, for the reason the transcript's own limit row gives: this screen
|
|
* reads the figure once, and a span drawn from a value nothing refreshes goes stale while somebody
|
|
* is looking at it.
|
|
*/
|
|
private fun formatCheckTime(epochSeconds: Double): String =
|
|
try {
|
|
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
|
.withZone(ZoneId.systemDefault())
|
|
.format(Instant.ofEpochSecond(epochSeconds.toLong()))
|
|
} catch (_: Exception) {
|
|
// A time that cannot be read is not a time to show: the sentence above still says a check
|
|
// is coming, which is the part the reader can act on.
|
|
"soon"
|
|
}
|