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() } // 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