Android: the CI wall stops flickering between Loading and its cards

4241ea4 · gmorell · 2026-11-03 19:46

14 files +58 -11
Message
{commit_body(@commit)}

Files changed

modified android/app/src/main/AndroidManifest.xml
+7 −1
@@ -28,7 +28,13 @@
28 28 android:name=".MainActivity"
29 29 android:exported="true"
30 30 android:launchMode="singleTask"
31 − android:theme="@style/Theme.GitGud">
31 + android:theme="@style/Theme.GitGud"
32 + android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation">
33 + <!-- Compose redraws for these itself. Without them, the CI
34 + wall hiding the system bars resizes the window, which on
35 + Android 14 and older rebuilds the activity, which shows the
36 + bars again: a loop that flickers the wall between
37 + "Loading…" and its cards. -->
32 38 <intent-filter>
33 39 <action android:name="android.intent.action.MAIN"/>
34 40 <category android:name="android.intent.category.LAUNCHER"/>
modified android/app/src/main/java/org/neiam/gitgud/app/ui/wall/CiWallScreen.kt
+15 −10
@@ -82,6 +82,7 @@ import androidx.core.view.WindowInsetsControllerCompat
82 82 import androidx.lifecycle.Lifecycle
83 83 import androidx.lifecycle.LifecycleOwner
84 84 import androidx.lifecycle.repeatOnLifecycle
85 +import androidx.lifecycle.viewmodel.compose.viewModel
85 86 import kotlinx.coroutines.CancellationException
86 87 import kotlinx.coroutines.delay
87 88 import org.neiam.gitgud.app.data.GitGudClient
@@ -124,9 +125,10 @@ fun CiWallScreen(
124 125 val theme = LocalAppTheme.current
125 126 var scope by remember { mutableStateOf(prefs.wallScope) }
126 127 var newestFirst by remember { mutableStateOf(prefs.wallNewestFirst) }
127 − var cards by remember { mutableStateOf<List<WallCard>?>(null) }
128 − var error by remember { mutableStateOf<String?>(null) }
129 − var orgs by remember { mutableStateOf<List<Namespace>>(emptyList()) }
128 + val wall: WallViewModel = viewModel()
129 + val cards = wall.cards
130 + val error = wall.error
131 + val orgs = wall.orgs
130 132 var now by remember { mutableStateOf(Instant.now()) }
131 133
132 134 val api = remember(tokens) { tokens.load()?.let { GitGudClient.authenticated(it) } }
@@ -145,22 +147,24 @@ fun CiWallScreen(
145 147
146 148 LaunchedEffect(scope, api) {
147 149 if (api == null) {
148 − error = "Not paired."
150 + wall.error = "Not paired."
149 151 return@LaunchedEffect
150 152 }
151 − cards = null
153 + // Back to "Loading…" only for another scope: the last answer
154 + // stays up while the next one is on its way.
155 + wall.showing(scope)
152 156 // Only while it's on screen: a TV on standby shouldn't keep
153 157 // asking the forge every few seconds.
154 158 val poll: suspend () -> Unit = {
155 159 while (true) {
156 160 try {
157 − val wall = scope?.let { api.orgCiWall(it, lines) } ?: api.ciWall(lines)
158 − cards = wall.jobs
159 − error = null
161 + val answer = scope?.let { api.orgCiWall(it, lines) } ?: api.ciWall(lines)
162 + wall.cards = answer.jobs
163 + wall.error = null
160 164 } catch (e: CancellationException) {
161 165 throw e
162 166 } catch (e: Exception) {
163 − error = GitGudClient.describe(e)
167 + wall.error = GitGudClient.describe(e)
164 168 }
165 169 delay(POLL_MILLIS)
166 170 }
@@ -170,7 +174,8 @@ fun CiWallScreen(
170 174
171 175 // The organizations to choose between; a failure just leaves "Mine".
172 176 LaunchedEffect(api) {
173 − orgs = runCatching { api?.repos()?.namespaces.orEmpty().filter { it.isOrg } }
177 + if (wall.orgs.isNotEmpty()) return@LaunchedEffect
178 + wall.orgs = runCatching { api?.repos()?.namespaces.orEmpty().filter { it.isOrg } }
174 179 .getOrDefault(emptyList())
175 180 }
176 181
added android/app/src/main/java/org/neiam/gitgud/app/ui/wall/WallViewModel.kt
+36 −0
@@ -0,0 +1,36 @@
1 +package org.neiam.gitgud.app.ui.wall
2 +
3 +import androidx.compose.runtime.getValue
4 +import androidx.compose.runtime.mutableStateOf
5 +import androidx.compose.runtime.setValue
6 +import androidx.lifecycle.ViewModel
7 +import org.neiam.gitgud.app.data.Namespace
8 +import org.neiam.gitgud.app.data.WallCard
9 +
10 +/**
11 + * What the wall last heard, kept across anything that recreates the
12 + * screen, so a wall never drops back to "Loading…" just because the
13 + * activity was rebuilt — only when what it shows changes.
14 + */
15 +class WallViewModel : ViewModel() {
16 + var cards by mutableStateOf<List<WallCard>?>(null)
17 + var error by mutableStateOf<String?>(null)
18 + var orgs by mutableStateOf<List<Namespace>>(emptyList())
19 +
20 + /** The scope `cards` are for; a sentinel until the first poll. */
21 + var cardsScope: String? = UNSET
22 + private set
23 +
24 + /** Forget the cards when the wall switches to another scope. */
25 + fun showing(scope: String?) {
26 + if (cardsScope != scope) {
27 + cards = null
28 + error = null
29 + cardsScope = scope
30 + }
31 + }
32 +
33 + private companion object {
34 + const val UNSET = "\u0000"
35 + }
36 +}

Parents: b2aad05