neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Android: the CI wall stops flickering between Loading and its cards
4241ea4 · gmorell · 2026-11-03 19:46
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