Files
ai-app/app/androidApp/src/main/kotlin/com/example/aiapp/SessionAlerts.kt
T
irisandClaude Opus 5 edc39c7371 Thin the app's comments
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>
2026-09-04 16:20:16 -04:00

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