The same pass the server had, on the Kotlin side: comments restating what the code says are gone, and the ones recording a measurement, a constraint or an incident are kept but cut to a few lines each. 6540 comment lines to 5674, and 920 lines off the app. Two doc comments had drifted onto the item above the one they describe -- `contextAfter`'s onto `sessionWorking` in Events.kt, and `UsageMonitor`'s equivalent on the server was fixed in the previous commit. Each is back on its own item, which is the only non-comment line this diff moves. The comments are reflowed to the column limit at their own indentation: several were written wide, and ktfmt re-wrapped them into lines holding a single orphan word. `/tmp` script, not kept -- ktfmt is idempotent over the result, which is the check. Left alone deliberately: this codebase's remaining comment density is high because the comments carry things the code cannot say -- what a null means, what a number was measured against, which bug a guard exists for. Of the 238 one-line doc comments in the app, five were pure restatement of the name and were removed; the rest each say something the signature does not. ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest pass; cargo test (127), clippy --all-targets and fmt still clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
177 lines
8.5 KiB
Kotlin
177 lines
8.5 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import androidx.compose.animation.core.Animatable
|
|
import androidx.compose.animation.core.LinearEasing
|
|
import androidx.compose.animation.core.tween
|
|
import androidx.compose.foundation.BorderStroke
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.material3.Card
|
|
import androidx.compose.material3.CardDefaults
|
|
import androidx.compose.material3.LinearProgressIndicator
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.SwipeToDismissBox
|
|
import androidx.compose.material3.SwipeToDismissBoxValue
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.key
|
|
import androidx.compose.runtime.mutableIntStateOf
|
|
import androidx.compose.runtime.mutableStateListOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.text.style.TextOverflow
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.lifecycle.Lifecycle
|
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
|
import androidx.lifecycle.repeatOnLifecycle
|
|
|
|
/**
|
|
* A session wanting attention, said over the app rather than through Android's drawer.
|
|
*
|
|
* Two places can carry the same fact and only one is right at a time. A row in the shade is for
|
|
* somebody looking at something else: it makes a sound, it waits however long it has to, and acting
|
|
* on it means leaving whatever they were doing. Somebody with this app open needs none of that. So
|
|
* while these are on screen the stream is delivered here instead, which is arranged by the
|
|
* collection below and nothing else.
|
|
*
|
|
* A banner can go three ways, each somebody deciding something different: tapped, which opens the
|
|
* session; pushed off either side; or left alone, in which case it goes when the bar runs out.
|
|
*/
|
|
@Composable
|
|
fun SessionAlerts(onOpen: (SessionOpenRequest) -> Unit, modifier: Modifier = Modifier) {
|
|
val queue = remember { mutableStateListOf<SessionAlert>() }
|
|
// What tells two notifications about one session apart, and what a replaced banner gets a new
|
|
// one of so its timer starts again rather than inheriting the remains of the last one's.
|
|
var arrivals by remember { mutableIntStateOf(0) }
|
|
val lifecycleOwner = LocalLifecycleOwner.current
|
|
LaunchedEffect(lifecycleOwner) {
|
|
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
|
try {
|
|
NotificationService.forTheScreen.collect { notification ->
|
|
arrivals++
|
|
val alert = SessionAlert(notification, arrivals)
|
|
// One banner per session, replacing that session's own -- the same rule the
|
|
// drawer follows: a session that finished and then asked a question is one
|
|
// thing to know about, the question. It keeps its place in the queue rather
|
|
// than moving to the end, because the reader may already be reaching for it.
|
|
val already = queue.indexOfFirst {
|
|
it.notification.sessionId == notification.sessionId
|
|
}
|
|
if (already >= 0) queue[already] = alert else queue.add(alert)
|
|
}
|
|
} finally {
|
|
// Leaving the app hands the job back to the drawer, so nothing arriving while it is
|
|
// away is lost. What would be lost is the truth of what is already up: these say a
|
|
// session wants somebody *now*, and one still sitting here on a return several
|
|
// minutes later is a claim nobody checked. Frozen, too -- Compose stops the clock
|
|
// with the window.
|
|
queue.clear()
|
|
}
|
|
}
|
|
}
|
|
// Oldest at the top, so a new one appears below the ones already being read instead of shoving
|
|
// them down the screen mid-reach.
|
|
Column(modifier.fillMaxWidth().padding(8.dp)) {
|
|
queue.forEach { alert ->
|
|
key(alert.arrival) {
|
|
AlertBanner(
|
|
alert = alert,
|
|
onOpen = {
|
|
queue.remove(alert)
|
|
onOpen(SessionOpenRequest(alert.notification.sessionId, alert.arrival))
|
|
},
|
|
onGone = { queue.remove(alert) },
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** One notification queued for the screen, with the arrival that tells it from its predecessor. */
|
|
private data class SessionAlert(val notification: SessionNotification, val arrival: Int)
|
|
|
|
/**
|
|
* One banner: what wants attention, and how long this has left to say so.
|
|
*
|
|
* The bar and the going away are one value rather than a bar beside a timer, because two of them
|
|
* would be two accounts of the same countdown and only one can be the one that fires.
|
|
*/
|
|
@Composable
|
|
private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> Unit) {
|
|
val swipe = rememberSwipeToDismissBoxState()
|
|
val life = remember { Animatable(1f) }
|
|
LaunchedEffect(Unit) {
|
|
life.animateTo(0f, animationSpec = tween(ALERT_LIFE_MS, easing = LinearEasing))
|
|
onGone()
|
|
}
|
|
// Settled is "still where it started"; anything else is a push that carried far enough for the
|
|
// gesture to commit, which the platform decides rather than this screen.
|
|
LaunchedEffect(swipe.currentValue) {
|
|
if (swipe.currentValue != SwipeToDismissBoxValue.Settled) onGone()
|
|
}
|
|
SwipeToDismissBox(
|
|
state = swipe,
|
|
// Nothing behind it. Pushing one of these away means the same thing whichever way it went,
|
|
// so a coloured ground with an icon would be drawing a distinction that isn't there.
|
|
backgroundContent = {},
|
|
modifier = Modifier.padding(bottom = 8.dp),
|
|
) {
|
|
Card(
|
|
onClick = onOpen,
|
|
colors =
|
|
CardDefaults.cardColors(
|
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
|
),
|
|
// Outlined, because the step it needs to make is not one this palette can make with a
|
|
// tint: the card under a banner on the session list is the same surface, so a banner
|
|
// relying on colour alone reads as one more row in the way. The border is the one cue.
|
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
|
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
|
|
) {
|
|
Column(Modifier.padding(start = 12.dp, end = 12.dp, top = 12.dp, bottom = 10.dp)) {
|
|
Text(
|
|
alert.notification.title,
|
|
style = MaterialTheme.typography.titleSmall,
|
|
// One line, cut at the tail: a session is identified by the start of its name,
|
|
// and a banner that grew with the name would move the one below it.
|
|
maxLines = 1,
|
|
overflow = TextOverflow.Ellipsis,
|
|
)
|
|
Text(
|
|
attentionLine(alert.notification.kind),
|
|
style = MaterialTheme.typography.labelLarge,
|
|
// The list's own colour for a session waiting on a person, so the banner and
|
|
// the row behind it are saying one thing rather than two.
|
|
color =
|
|
if (alert.notification.kind == "awaitingInput") awaitingColor
|
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
LinearProgressIndicator(
|
|
progress = { life.value },
|
|
// Blue because it is reporting how much of something is left rather than passing
|
|
// judgement on it. Stated beside the track, which is the card's own colour so that
|
|
// the spent part reads as empty rather than as a second bar.
|
|
color = progressColor,
|
|
trackColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
drawStopIndicator = {},
|
|
gapSize = 0.dp,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* How long a banner stays if nobody touches it.
|
|
*
|
|
* Long enough to read a session name and a line, short enough that a stack of them clears itself
|
|
* while somebody is still on the screen that produced them. The bar makes the number visible.
|
|
*/
|
|
private const val ALERT_LIFE_MS = 6_000
|