Pull to ask for a board, swipe to say where you are

0335fca · Gabriel Morell · 2026-09-03 19:56

11 files +101 -1
Message
{commit_body(@commit)}

Files changed

modified clients/android/app/src/main/java/io/neiam/apolloscrib/ui/BoardScreen.kt
+101 −1
@@ -1,5 +1,6 @@
1 1 package io.neiam.apolloscrib.ui
2 2
3 +import androidx.compose.foundation.gestures.detectHorizontalDragGestures
3 4 import androidx.compose.foundation.layout.Arrangement
4 5 import androidx.compose.foundation.layout.PaddingValues
5 6 import androidx.compose.foundation.layout.Column
@@ -13,27 +14,38 @@ import androidx.compose.foundation.layout.width
13 14 import androidx.compose.foundation.lazy.LazyColumn
14 15 import androidx.compose.foundation.lazy.items
15 16 import androidx.compose.material3.Card
17 +import androidx.compose.material3.ExperimentalMaterial3Api
16 18 import androidx.compose.material3.Icon
17 19 import androidx.compose.material3.MaterialTheme
18 20 import androidx.compose.material3.Text
19 21 import androidx.compose.material3.TextButton
22 +import androidx.compose.material3.pulltorefresh.PullToRefreshBox
20 23 import androidx.compose.runtime.Composable
24 +import androidx.compose.runtime.LaunchedEffect
21 25 import androidx.compose.runtime.collectAsState
22 26 import androidx.compose.runtime.getValue
27 +import androidx.compose.runtime.mutableStateOf
23 28 import androidx.compose.runtime.remember
29 +import androidx.compose.runtime.setValue
30 +import androidx.compose.runtime.snapshotFlow
24 31 import androidx.compose.ui.Alignment
25 32 import androidx.compose.ui.Modifier
26 33 import androidx.compose.ui.graphics.Color
34 +import androidx.compose.ui.input.pointer.pointerInput
35 +import androidx.compose.ui.platform.LocalContext
27 36 import androidx.compose.ui.res.painterResource
28 37 import androidx.compose.ui.text.style.TextOverflow
29 38 import androidx.compose.ui.unit.Dp
30 39 import androidx.compose.ui.unit.dp
40 +import io.neiam.apolloscrib.data.Settings
31 41 import io.neiam.apolloscrib.data.VisionStore
32 42 import io.neiam.apolloscrib.mqtt.AnkyraClient
33 43 import io.neiam.apolloscrib.mqtt.AnkyraService
34 44 import io.neiam.apolloscrib.targets.Preview
35 45 import io.neiam.apolloscrib.targets.Targets
36 46 import io.neiam.apolloscrib.ui.theme.LocalAppTheme
47 +import kotlinx.coroutines.flow.first
48 +import kotlinx.coroutines.withTimeoutOrNull
37 49 import java.text.DateFormat
38 50 import java.util.Date
39 51
@@ -45,6 +57,7 @@ import java.util.Date
45 57 * the same [Preview]s the Smartspace cards are built from, so what is on this
46 58 * screen is what a Target added for that query will say.
47 59 */
60 +@OptIn(ExperimentalMaterial3Api::class)
48 61 @Composable
49 62 fun BoardScreen(
50 63 modifier: Modifier = Modifier,
@@ -62,7 +75,74 @@ fun BoardScreen(
62 75 val lastUpdated = remember(boards) { store.lastUpdated() }
63 76 val stale = remember(boards) { store.isStale() }
64 77
65 − Column(modifier = modifier.fillMaxSize()) {
78 + val context = LocalContext.current
79 +
80 + // A pull asks for a board; the wait ends when one lands. There is no
81 + // acknowledgement to wait for -- a Pythiae answers on its own tick and the
82 + // request is debounced at the other end -- so a new payload is the only
83 + // honest signal that it worked, and a timeout covers nothing coming back.
84 + var refreshing by remember { mutableStateOf(false) }
85 +
86 + LaunchedEffect(refreshing) {
87 + if (!refreshing) return@LaunchedEffect
88 +
89 + val before = VisionStore.boards.value
90 + withTimeoutOrNull(8_000) {
91 + snapshotFlow { VisionStore.boards.value }.first { it != before }
92 + }
93 + refreshing = false
94 + }
95 +
96 + // What the last side-swipe did. Cleared after a moment: it is what
97 + // happened when the gesture was made, not the state of anything.
98 + var swiped by remember { mutableStateOf("") }
99 +
100 + LaunchedEffect(swiped) {
101 + if (swiped.isNotEmpty()) {
102 + kotlinx.coroutines.delay(3_000)
103 + swiped = ""
104 + }
105 + }
106 +
107 + // The launcher's feed page owns horizontal drags -- that is how the panel
108 + // is dismissed -- so this gesture belongs to the app alone. Read-only is
109 + // also the wrong place to be publishing anything from.
110 + val sideSwipeModifier = if (onEditConnection == null) {
111 + Modifier
112 + } else {
113 + Modifier.pointerInput(Unit) {
114 + var travelled = 0f
115 +
116 + detectHorizontalDragGestures(
117 + onDragStart = { travelled = 0f },
118 + onDragEnd = {
119 + // A quarter of the width, so a lazy thumb on a card does
120 + // not publish a position by accident.
121 + if (kotlin.math.abs(travelled) > size.width / 4f) {
122 + val settings = Settings(context)
123 +
124 + // Opt-in means opt-in: a gesture is not consent, and a
125 + // wall-mounted client should not start taking fixes
126 + // because somebody brushed the screen.
127 + if (!settings.publishLocation) {
128 + swiped = "Location reporting is off"
129 + } else {
130 + swiped = "Publishing position..."
131 + AnkyraService.reportLocationNow(context) { sent ->
132 + // False covers both no fix and no running
133 + // service, and this cannot tell them apart --
134 + // so it says the thing that is true of both
135 + // rather than guessing at the reason.
136 + swiped = if (sent) "Position sent" else "Could not send a position"
137 + }
138 + }
139 + }
140 + }
141 + ) { _, amount -> travelled += amount }
142 + }
143 + }
144 +
145 + Column(modifier = modifier.fillMaxSize().then(sideSwipeModifier)) {
66 146 // Outside the list rather than its first row: what the connection is
67 147 // doing should not scroll away, since it is the thing that tells you
68 148 // whether the board under it is worth reading.
@@ -74,6 +154,25 @@ fun BoardScreen(
74 154 modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp)
75 155 )
76 156
157 + // Only when there is something to say: an empty line still takes one,
158 + // and the board would shift under the reader every time it cleared.
159 + if (swiped.isNotEmpty()) {
160 + Text(
161 + text = swiped,
162 + style = MaterialTheme.typography.bodySmall,
163 + color = LocalAppTheme.current.dim,
164 + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 4.dp)
165 + )
166 + }
167 +
168 + PullToRefreshBox(
169 + isRefreshing = refreshing,
170 + onRefresh = {
171 + refreshing = true
172 + AnkyraService.requestBoard(context)
173 + },
174 + modifier = Modifier.fillMaxSize()
175 + ) {
77 176 LazyColumn(
78 177 modifier = Modifier.fillMaxSize(),
79 178 contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp),
@@ -124,6 +223,7 @@ fun BoardScreen(
124 223 }
125 224 }
126 225 }
226 + }
127 227 }
128 228 }
129 229

Parents: 4118534