Merge branch 'main' of git.arirex.me:iris/ai-app
This commit is contained in:
commit
7190eac6f3
6 files changed
+296
-11
No files matched your search
@@ -78,6 +78,11 @@ repo is in PLAN.md's "Backend layout" section.
|
|||||||
the REST + SSE clients; `Events.kt` the event model mirror;
|
the REST + SSE clients; `Events.kt` the event model mirror;
|
||||||
`ServerConfig.kt` settings + Keystore-sealed token; screens in
|
`ServerConfig.kt` settings + Keystore-sealed token; screens in
|
||||||
`SessionListScreen/SessionScreen/SpawnScreen/SettingsScreen`.
|
`SessionListScreen/SessionScreen/SpawnScreen/SettingsScreen`.
|
||||||
|
`Notifications.kt` is the foreground service holding the notification
|
||||||
|
stream and the one place that decides where a notification is said --
|
||||||
|
nothing for the session on screen, a `SessionAlerts` banner while the app
|
||||||
|
is up, Android's drawer otherwise, never two of them. See PLAN.md's
|
||||||
|
"Notifications: two places, never both".
|
||||||
**Icons are Nerd Fonts glyphs from a committed subset**, not vector assets
|
**Icons are Nerd Fonts glyphs from a committed subset**, not vector assets
|
||||||
and not ordinary Unicode — `NerdIcons.kt` declares each codepoint and
|
and not ordinary Unicode — `NerdIcons.kt` declares each codepoint and
|
||||||
`app/build-icon-font.sh` subsets the font. The two lists have to agree: a
|
`app/build-icon-font.sh` subsets the font. The two lists have to agree: a
|
||||||
|
|||||||
@@ -647,6 +647,37 @@ connectivity/lifecycle. The app keeps no persistent transcript store — the
|
|||||||
backend's transcript is the source of truth; the app caches only for the
|
backend's transcript is the source of truth; the app caches only for the
|
||||||
screen it's showing.
|
screen it's showing.
|
||||||
|
|
||||||
|
### Notifications: two places, never both (decided 2026-08-30)
|
||||||
|
|
||||||
|
The backend's `GET /notifications` is one SSE stream of attention-wanting
|
||||||
|
moments, and the app decides where each one is said. Three outcomes, in one
|
||||||
|
place (`NotificationService.show`):
|
||||||
|
|
||||||
|
- **Nothing at all** if the session is the one on screen. The transcript in
|
||||||
|
front of the reader is already saying it.
|
||||||
|
- **A banner over the app** if the app is up — `SessionAlerts`, queued, one
|
||||||
|
per session replacing that session's own, dismissable by a push in either
|
||||||
|
direction, and otherwise retiring itself when the bar across its foot runs
|
||||||
|
out. Tapping one opens the session, through the same path a tapped
|
||||||
|
notification uses.
|
||||||
|
- **A row in Android's drawer** otherwise, which is what the foreground
|
||||||
|
service exists for.
|
||||||
|
|
||||||
|
Never two of them for one moment. A notification that has already been shown
|
||||||
|
in the app is not something to also find in the shade afterwards, and a
|
||||||
|
drawer that fills up behind an app that showed you each one is a drawer
|
||||||
|
nobody reads.
|
||||||
|
|
||||||
|
Which of the three applies is answered without a flag anybody has to keep
|
||||||
|
level: the session on screen is registered by the one composable that draws
|
||||||
|
one, and "the app is up" *is* the banner queue being collected, since it
|
||||||
|
collects only while it is on screen.
|
||||||
|
|
||||||
|
The alternative considered and rejected was giving the app its own
|
||||||
|
connection to `/notifications` while it is in front. That is a second stream
|
||||||
|
per device saying the same thing, and it puts the "which of these two shows
|
||||||
|
it" decision in two processes' worth of code instead of one function.
|
||||||
|
|
||||||
### Deferred polish
|
### Deferred polish
|
||||||
|
|
||||||
Noticed and deliberately not fixed yet, so they are not re-found from
|
Noticed and deliberately not fixed yet, so they are not re-found from
|
||||||
|
|||||||
@@ -212,4 +212,11 @@ fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) {
|
|||||||
onBack = goToMain,
|
onBack = goToMain,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Last, so it draws over the screen above rather than under it: these are stacked in the Box
|
||||||
|
// the activity puts around this, and that Box paints in the order it was given. A session
|
||||||
|
// wanting attention is not a fact about the page somebody happens to be on, so it is not the
|
||||||
|
// page's job to leave room for it. Tapping one is the same act as tapping a notification, so
|
||||||
|
// it goes through the same `open`, failure dialog included.
|
||||||
|
SessionAlerts(onOpen = { request -> scope.launch { open(request) } })
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,9 @@ import java.io.IOException
|
|||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -139,6 +142,10 @@ class NotificationService : Service() {
|
|||||||
// them is already saying it, and a sound over the top of it would be this app announcing
|
// them is already saying it, and a sound over the top of it would be this app announcing
|
||||||
// what the screen is showing.
|
// what the screen is showing.
|
||||||
if (isOnScreen(notification.sessionId)) return
|
if (isOnScreen(notification.sessionId)) return
|
||||||
|
// The app is up: it says this itself, as a banner over whatever screen they are on. See
|
||||||
|
// [forTheScreen]. Never both -- one thing happened, and a drawer filling up behind an
|
||||||
|
// app that already showed you each one is a drawer nobody reads.
|
||||||
|
if (handOver(notification)) return
|
||||||
val manager = NotificationManagerCompat.from(this)
|
val manager = NotificationManagerCompat.from(this)
|
||||||
// Two different noes, and both are answers rather than faults: the runtime permission
|
// Two different noes, and both are answers rather than faults: the runtime permission
|
||||||
// refused, and notifications switched off for the app in Android's own settings. Neither
|
// refused, and notifications switched off for the app in Android's own settings. Neither
|
||||||
@@ -160,15 +167,7 @@ class NotificationService : Service() {
|
|||||||
val built =
|
val built =
|
||||||
NotificationCompat.Builder(this, ALERT_CHANNEL)
|
NotificationCompat.Builder(this, ALERT_CHANNEL)
|
||||||
.setContentTitle(notification.title)
|
.setContentTitle(notification.title)
|
||||||
.setContentText(
|
.setContentText(attentionLine(notification.kind))
|
||||||
when (notification.kind) {
|
|
||||||
// What the reader has to do, not what the session
|
|
||||||
// did: "awaitingInput" is the wire's word and says
|
|
||||||
// nothing to somebody reading a lock screen.
|
|
||||||
"awaitingInput" -> "Waiting for you"
|
|
||||||
else -> "Finished"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
||||||
.setContentIntent(open)
|
.setContentIntent(open)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
@@ -262,6 +261,23 @@ class NotificationService : Service() {
|
|||||||
|
|
||||||
private fun isOnScreen(sessionId: String) = onScreen == sessionId
|
private fun isOnScreen(sessionId: String) = onScreen == sessionId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The way a notification reaches the app instead of Android's drawer.
|
||||||
|
*
|
||||||
|
* Whether there is an app to reach is the subscriber count rather than a flag of its own:
|
||||||
|
* [SessionAlerts] collects this exactly while it is on screen, so there is nothing that
|
||||||
|
* could be left saying the app is up after it has gone. `tryEmit` neither suspends nor
|
||||||
|
* blocks the thread reading the stream, and the buffer is there so a handful of sessions
|
||||||
|
* finishing together all land rather than the last one winning.
|
||||||
|
*/
|
||||||
|
private val toApp = MutableSharedFlow<SessionNotification>(extraBufferCapacity = 8)
|
||||||
|
|
||||||
|
/** Everything meant for the screen rather than the drawer; see [toApp]. */
|
||||||
|
val forTheScreen: SharedFlow<SessionNotification> = toApp.asSharedFlow()
|
||||||
|
|
||||||
|
private fun handOver(notification: SessionNotification) =
|
||||||
|
toApp.subscriptionCount.value > 0 && toApp.tryEmit(notification)
|
||||||
|
|
||||||
/** Somebody is looking at [sessionId]; nothing is posted about it until they stop. */
|
/** Somebody is looking at [sessionId]; nothing is posted about it until they stop. */
|
||||||
fun showing(context: Context, sessionId: String) {
|
fun showing(context: Context, sessionId: String) {
|
||||||
onScreen = sessionId
|
onScreen = sessionId
|
||||||
@@ -319,6 +335,20 @@ data class SessionNotification(
|
|||||||
val at: Double,
|
val at: Double,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a notification asks of the reader, in the words they see.
|
||||||
|
*
|
||||||
|
* What they have to do, not what the session did: "awaitingInput" is the wire's word and says
|
||||||
|
* nothing to somebody reading a lock screen. One function because the same fact is now shown in two
|
||||||
|
* places -- Android's drawer and the app's own banner -- and two mappings of one word drift. The
|
||||||
|
* banner colours the line as well, which is its own decision and stays with the drawing.
|
||||||
|
*/
|
||||||
|
fun attentionLine(kind: String): String =
|
||||||
|
when (kind) {
|
||||||
|
"awaitingInput" -> "Waiting for you"
|
||||||
|
else -> "Finished"
|
||||||
|
}
|
||||||
|
|
||||||
fun parseNotification(json: String): SessionNotification {
|
fun parseNotification(json: String): SessionNotification {
|
||||||
val body = JSONObject(json)
|
val body = JSONObject(json)
|
||||||
return SessionNotification(
|
return SessionNotification(
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
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 of them 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 -- they are already here, and what a tap on the notification would have done is what a tap
|
||||||
|
* on this does. So while these are on screen the stream is delivered here instead, which is
|
||||||
|
* arranged by the collection below and nothing else; see `NotificationService.forTheScreen`.
|
||||||
|
*
|
||||||
|
* A banner can go three ways, and each is somebody deciding something different: tapped, which
|
||||||
|
* opens the session; pushed off either side; or left alone, in which case it goes by itself when
|
||||||
|
* the bar across its foot 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, and for the same reason: 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, so the timer that was going to retire it has been
|
||||||
|
// standing still the whole time.
|
||||||
|
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. What is drawn
|
||||||
|
* is therefore the thing that decides, which is the only arrangement where a bar that has emptied
|
||||||
|
* cannot be sitting under a banner that is still there.
|
||||||
|
*/
|
||||||
|
@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 that happens to be in the way. The
|
||||||
|
// border is the one cue, and the elevation beside it is the platform's shadow rather
|
||||||
|
// than a second tint -- Material draws no tonal overlay over a container stated here.
|
||||||
|
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 -- the reason `progressColor` exists. 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, so
|
||||||
|
* this is a duration the reader can watch rather than one they have to learn.
|
||||||
|
*/
|
||||||
|
private const val ALERT_LIFE_MS = 6_000
|
||||||
@@ -1686,6 +1686,16 @@ private fun QuestionRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long after a menu closes a press on its own button still counts as the press that closed it.
|
||||||
|
*
|
||||||
|
* Sized to one tap, because one tap is all it has to span -- [PickerButton] explains the pair of
|
||||||
|
* events it separates. Deliberately not the platform's long-press timeout, which is the longest a
|
||||||
|
* tap can legally be: half a second of ignoring the button would start swallowing a deliberate
|
||||||
|
* reopen, and a press held that long to close a menu is not worth protecting at that price.
|
||||||
|
*/
|
||||||
|
private const val ONE_TAP_MS = 250L
|
||||||
|
|
||||||
/** The modes the CLI accepts, in the order they give up asking. */
|
/** The modes the CLI accepts, in the order they give up asking. */
|
||||||
private val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
|
private val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
|
||||||
|
|
||||||
@@ -1698,8 +1708,21 @@ private val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPe
|
|||||||
@Composable
|
@Composable
|
||||||
private fun PickerButton(current: String, options: List<String>, onPick: (String) -> Unit) {
|
private fun PickerButton(current: String, options: List<String>, onPick: (String) -> Unit) {
|
||||||
var open by remember { mutableStateOf(false) }
|
var open by remember { mutableStateOf(false) }
|
||||||
|
// When an outside touch last closed the menu.
|
||||||
|
//
|
||||||
|
// Pressing this button while its own menu is open is such a touch. The menu is deliberately
|
||||||
|
// not focusable (see below), which means the press that dismisses it is also delivered to the
|
||||||
|
// window underneath -- and what it lands on there is this button. The dismissal arrives with
|
||||||
|
// the press and the click with the release, measured 3ms apart on the emulator, so a button
|
||||||
|
// that simply opened on every click would reopen what the same finger had just closed, and
|
||||||
|
// the menu could only be put away by tapping somewhere else. So the moment is remembered, and
|
||||||
|
// a click that follows it within one tap is read as the second half of that tap rather than
|
||||||
|
// as a new one.
|
||||||
|
var closedAt by remember { mutableLongStateOf(0L) }
|
||||||
Box {
|
Box {
|
||||||
TextButton(onClick = { open = true }) {
|
TextButton(
|
||||||
|
onClick = { if (SystemClock.uptimeMillis() - closedAt > ONE_TAP_MS) open = true }
|
||||||
|
) {
|
||||||
// One line, truncated rather than wrapped: this sits in a row
|
// One line, truncated rather than wrapped: this sits in a row
|
||||||
// whose height is the buttons beside it, and a second line
|
// whose height is the buttons beside it, and a second line
|
||||||
// would move them.
|
// would move them.
|
||||||
@@ -1731,7 +1754,10 @@ private fun PickerButton(current: String, options: List<String>, onPick: (String
|
|||||||
// this menu takes no parameter for.
|
// this menu takes no parameter for.
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
expanded = open,
|
expanded = open,
|
||||||
onDismissRequest = { open = false },
|
onDismissRequest = {
|
||||||
|
open = false
|
||||||
|
closedAt = SystemClock.uptimeMillis()
|
||||||
|
},
|
||||||
properties = PopupProperties(focusable = false, clippingEnabled = false),
|
properties = PopupProperties(focusable = false, clippingEnabled = false),
|
||||||
) {
|
) {
|
||||||
options.forEach { option ->
|
options.forEach { option ->
|
||||||
|
|||||||
Reference in new issue
Block a user