A notification arriving while the app was open was shown as a banner and nowhere else, so a moment that happened while the phone was face-up on a desk left nothing behind at all -- the banner is seconds long and reaches only somebody already looking. The two are not two versions of one thing: a banner interrupts and a row records. Both go up now, and the banner having done the interrupting is what makes the row a silent one (`setSilent`), so one moment is worth a noise once. What keeps the drawer from filling up is the other end rather than suppression, and already was: opening a session clears whatever is posted about it, whichever way the reader got there. Checked with ktfmtFormat, compileDebugKotlin, testDebugUnitTest and lintDebug, and on the emulator against the sandbox, reading the posted record out of dumpsys: app on the session list gives a banner and flags=AUTO_CANCEL|SILENT; app backgrounded gives flags=AUTO_CANCEL; opening the session leaves nothing posted about it in either case.
177 lines
8.6 KiB
Kotlin
177 lines
8.6 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 as well as in Android's drawer.
|
|
*
|
|
* Two places carry the same fact and they are doing different jobs: a row in the shade waits
|
|
* however long it has to, which makes it the record, and a banner is read now or not at all, which
|
|
* makes it the interruption. So somebody with the app open gets both -- this, and a silent row
|
|
* behind it that is still there when they go looking and goes by itself when they open the session.
|
|
* Whether the app is open at all is this collection 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
|