Android: a home-screen CI widget, after the Plasma one

351bed2 · Gabriel Morell · 2026-09-26 22:31

37 files +1511 -21
Message
{commit_body(@commit)}

Files changed

modified android/README.md
+8 −0
@@ -16,6 +16,7 @@ B612-mono shell, so they feel like one product family.
16 16 | **Issues** | List by state, read with server-rendered markdown, comment, quote-reply, close and reopen |
17 17 | **Pull requests** | List by state (`merged` is its own state), read description + reviews + discussion + line comments, comment, approve / request changes, merge — with the repo's merge blockers spelled out |
18 18 | **Wiki** | Page index and rendered pages (read-only) |
19 +| **Home-screen widget** | The Plasma widget's strip on a phone: the logo and one status circle per watched repository, or one per organization folded into a single circle. Stretch it taller and the recent runs list underneath — tap a circle to filter them, the logo for every run, a run to open it. Polls every 15 minutes, every 30 s while a run is in flight |
19 20
20 21 ## Stack
21 22
@@ -64,6 +65,13 @@ android/
64 65 StatusChip.kt # CI status + issue/PR state vocabulary
65 66 Common.kt # AppBarTitle, empty states, relative times
66 67 theme/ # 9 palettes ported from assets/css/app.css
68 + widget/
69 + CiWidgetProvider.kt # launcher events + circle / refresh taps
70 + CiWidgetRenderer.kt # RemoteViews from the cached snapshot
71 + CiWidgetWorker.kt # the poll, and CiWidgetUpdater's scheduling
72 + CiWidgetModel.kt # watched → circles → repos, as in the plasmoid
73 + CiWidgetConfigActivity.kt # what a widget watches
74 + WidgetStore.kt # per-widget config + shared snapshot
67 75 res/font/ # B612 regular + bold
68 76 ```
69 77
modified android/app/src/main/AndroidManifest.xml
+34 −0
@@ -49,6 +49,40 @@
49 49 <category android:name="android.intent.category.BROWSABLE"/>
50 50 <data android:scheme="gitgud" android:host="run"/>
51 51 </intent-filter>
52 +
53 + <!-- A circle on the CI widget: the repository it stands for. -->
54 + <intent-filter android:autoVerify="false">
55 + <action android:name="android.intent.action.VIEW"/>
56 + <category android:name="android.intent.category.DEFAULT"/>
57 + <category android:name="android.intent.category.BROWSABLE"/>
58 + <data android:scheme="gitgud" android:host="repo"/>
59 + </intent-filter>
60 + </activity>
61 +
62 + <!-- The home-screen CI widget, after the Plasma one. -->
63 + <receiver
64 + android:name=".widget.CiWidgetProvider"
65 + android:label="@string/widget_ci_name"
66 + android:exported="false">
67 + <intent-filter>
68 + <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
69 + </intent-filter>
70 + <meta-data
71 + android:name="android.appwidget.provider"
72 + android:resource="@xml/ci_widget_info"/>
73 + </receiver>
74 +
75 + <!-- Its own task, so choosing what a widget watches doesn't drag
76 + the app's back stack along with it. -->
77 + <activity
78 + android:name=".widget.CiWidgetConfigActivity"
79 + android:exported="true"
80 + android:excludeFromRecents="true"
81 + android:taskAffinity=""
82 + android:theme="@style/Theme.GitGud">
83 + <intent-filter>
84 + <action android:name="android.appwidget.action.APPWIDGET_CONFIGURE"/>
85 + </intent-filter>
52 86 </activity>
53 87 </application>
54 88 </manifest>
modified android/app/src/main/java/org/neiam/gitgud/app/MainActivity.kt
+42 −21
@@ -48,12 +48,16 @@ import org.neiam.gitgud.app.ui.theme.appThemeForKey
48 48 * forge renders on Settings → API tokens.
49 49 */
50 50 class MainActivity : ComponentActivity() {
51 + // Mutable because the activity is singleTask: a notification or
52 + // widget tap while the app is already open arrives in onNewIntent.
53 + private var link by mutableStateOf<DeepLink?>(null)
54 +
51 55 override fun onCreate(savedInstanceState: Bundle?) {
52 56 super.onCreate(savedInstanceState)
53 57 val tokens = TokenStore(this)
54 58 val prefs = PrefsStore(this)
55 59 val initialPair = parseIntent(intent)
56 − val initialRun = parseRunLink(intent)
60 + link = parseLink(intent)
57 61
58 62 setContent {
59 63 // Nothing stored means follow the OS, the same default as the
@@ -73,7 +77,7 @@ class MainActivity : ComponentActivity() {
73 77 tokens = tokens,
74 78 prefs = prefs,
75 79 initialPair = initialPair,
76 − initialRunRoute = initialRun,
80 + link = link,
77 81 currentTheme = theme,
78 82 currentThemeKey = themeKey,
79 83 onPickTheme = { picked ->
@@ -86,6 +90,12 @@ class MainActivity : ComponentActivity() {
86 90 }
87 91 }
88 92
93 + override fun onNewIntent(intent: Intent) {
94 + super.onNewIntent(intent)
95 + setIntent(intent)
96 + parseLink(intent)?.let { link = it }
97 + }
98 +
89 99 private fun parseIntent(intent: Intent?): PairPayload? {
90 100 if (intent?.action != Intent.ACTION_VIEW) return null
91 101 return PairPayload.parse(intent.data?.toString())
@@ -93,46 +103,57 @@ class MainActivity : ComponentActivity() {
93 103
94 104 /**
95 105 * `gitgud://run/<owner>/<name>/<number>` — where a "run finished"
96 − * notification points. Returns the route to open, or null for
97 − * anything else, including a malformed one: a notification tap
98 − * should land somewhere sensible rather than crash.
106 + * notification and a run on the CI widget point — and
107 + * `gitgud://repo/<owner>/<name>`, a circle on the widget. Returns
108 + * the route to open, or null for anything else, including a
109 + * malformed one: a tap should land somewhere sensible rather than
110 + * crash.
99 111 */
100 − private fun parseRunLink(intent: Intent?): String? {
112 + private fun parseLink(intent: Intent?): DeepLink? {
101 113 if (intent?.action != Intent.ACTION_VIEW) return null
102 114 val uri = intent.data ?: return null
103 115 if (!uri.scheme.equals("gitgud", ignoreCase = true)) return null
104 − if (!uri.host.equals("run", ignoreCase = true)) return null
105 116
106 117 val segments = uri.pathSegments.orEmpty()
107 − if (segments.size != 3) return null
118 + val owner = segments.getOrNull(0)?.takeIf { it.isNotBlank() } ?: return null
119 + val name = segments.getOrNull(1)?.takeIf { it.isNotBlank() } ?: return null
108 120
109 − val (owner, name) = segments[0] to segments[1]
110 − val number = segments[2].toIntOrNull() ?: return null
111 − if (owner.isBlank() || name.isBlank() || number <= 0) return null
112 −
113 − return "repo/$owner/$name/run/$number"
121 + return when {
122 + uri.host.equals("run", ignoreCase = true) && segments.size == 3 -> {
123 + val number = segments[2].toIntOrNull()?.takeIf { it > 0 } ?: return null
124 + DeepLink("repo/$owner/$name/run/$number")
125 + }
126 + uri.host.equals("repo", ignoreCase = true) && segments.size == 2 ->
127 + DeepLink("repo/$owner/$name")
128 + else -> null
129 + }
114 130 }
115 131 }
116 132
133 +/**
134 + * A route to open on arrival. A class rather than the bare string so
135 + * that tapping the same run twice is two arrivals, not one.
136 + */
137 +class DeepLink(val route: String)
138 +
117 139 @Composable
118 140 fun GitGudApp(
119 141 tokens: TokenStore,
120 142 prefs: PrefsStore,
121 143 initialPair: PairPayload?,
122 − initialRunRoute: String? = null,
144 + link: DeepLink? = null,
123 145 currentTheme: AppTheme,
124 146 currentThemeKey: String,
125 147 onPickTheme: (String) -> Unit,
126 148 ) {
127 149 val nav = rememberNavController()
128 150
129 − // A notification tap opens the run it is about, once the graph is
130 − // up. Not a start destination: Back should still reach the repo
131 − // list rather than leaving the app.
132 − LaunchedEffect(initialRunRoute) {
133 − if (initialRunRoute != null && tokens.load() != null) {
134 − nav.navigate("repos")
135 − nav.navigate(initialRunRoute)
151 + // A notification or widget tap opens what it is about, once the
152 + // graph is up. Not a start destination: Back should still reach the
153 + // repo list rather than leaving the app.
154 + LaunchedEffect(link) {
155 + if (link != null && tokens.load() != null) {
156 + nav.navigate(link.route) { popUpTo("repos") }
136 157 }
137 158 }
138 159
added android/app/src/main/java/org/neiam/gitgud/app/widget/CiWidgetConfigActivity.kt
+366 −0
@@ -0,0 +1,366 @@
1 +package org.neiam.gitgud.app.widget
2 +
3 +import android.app.Activity
4 +import android.appwidget.AppWidgetManager
5 +import android.content.Intent
6 +import android.os.Bundle
7 +import androidx.activity.ComponentActivity
8 +import androidx.activity.compose.setContent
9 +import androidx.compose.foundation.layout.Arrangement
10 +import androidx.compose.foundation.layout.Column
11 +import androidx.compose.foundation.layout.PaddingValues
12 +import androidx.compose.foundation.layout.Row
13 +import androidx.compose.foundation.layout.Spacer
14 +import androidx.compose.foundation.layout.fillMaxSize
15 +import androidx.compose.foundation.layout.fillMaxWidth
16 +import androidx.compose.foundation.layout.padding
17 +import androidx.compose.foundation.layout.width
18 +import androidx.compose.foundation.lazy.LazyColumn
19 +import androidx.compose.foundation.lazy.items
20 +import androidx.compose.foundation.lazy.itemsIndexed
21 +import androidx.compose.material.icons.Icons
22 +import androidx.compose.material.icons.filled.Close
23 +import androidx.compose.material.icons.filled.KeyboardArrowDown
24 +import androidx.compose.material.icons.filled.KeyboardArrowUp
25 +import androidx.compose.material3.Button
26 +import androidx.compose.material3.ExperimentalMaterial3Api
27 +import androidx.compose.material3.HorizontalDivider
28 +import androidx.compose.material3.Icon
29 +import androidx.compose.material3.IconButton
30 +import androidx.compose.material3.MaterialTheme
31 +import androidx.compose.material3.OutlinedTextField
32 +import androidx.compose.material3.Scaffold
33 +import androidx.compose.material3.Surface
34 +import androidx.compose.material3.Switch
35 +import androidx.compose.material3.Text
36 +import androidx.compose.material3.TextButton
37 +import androidx.compose.material3.TopAppBar
38 +import androidx.compose.runtime.Composable
39 +import androidx.compose.runtime.LaunchedEffect
40 +import androidx.compose.runtime.getValue
41 +import androidx.compose.runtime.mutableStateListOf
42 +import androidx.compose.runtime.mutableStateOf
43 +import androidx.compose.runtime.remember
44 +import androidx.compose.runtime.setValue
45 +import androidx.compose.ui.Alignment
46 +import androidx.compose.ui.Modifier
47 +import androidx.compose.ui.text.style.TextOverflow
48 +import androidx.compose.ui.unit.dp
49 +import org.neiam.gitgud.app.MainActivity
50 +import org.neiam.gitgud.app.data.GitGudClient
51 +import org.neiam.gitgud.app.data.PrefsStore
52 +import org.neiam.gitgud.app.data.ReposResponse
53 +import org.neiam.gitgud.app.data.TokenStore
54 +import org.neiam.gitgud.app.ui.AppBarTitle
55 +import org.neiam.gitgud.app.ui.CenterSpinner
56 +import org.neiam.gitgud.app.ui.CenterText
57 +import org.neiam.gitgud.app.ui.StatusDot
58 +import org.neiam.gitgud.app.ui.theme.GitGudTheme
59 +import org.neiam.gitgud.app.ui.theme.SYSTEM_THEME_KEY
60 +import org.neiam.gitgud.app.ui.theme.appThemeForKey
61 +
62 +/**
63 + * Choosing what a CI widget watches — the plasmoid's "Watching" settings.
64 + *
65 + * Opened by the launcher when the widget is placed (and must answer
66 + * RESULT_OK or the launcher removes it again), from the widget's gear,
67 + * and from the launcher's own "reconfigure" on Android 12+.
68 + *
69 + * The widget uses the app's pairing rather than a token of its own: one
70 + * phone, one forge, one token to revoke.
71 + */
72 +class CiWidgetConfigActivity : ComponentActivity() {
73 + override fun onCreate(savedInstanceState: Bundle?) {
74 + super.onCreate(savedInstanceState)
75 +
76 + val widgetId = intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
77 + ?: AppWidgetManager.INVALID_APPWIDGET_ID
78 + if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
79 + finish()
80 + return
81 + }
82 + val result = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId)
83 + setResult(Activity.RESULT_CANCELED, result)
84 +
85 + val store = WidgetStore(this)
86 + val tokens = TokenStore(this)
87 + val themeKey = PrefsStore(this).themeKey ?: SYSTEM_THEME_KEY
88 +
89 + setContent {
90 + GitGudTheme(theme = appThemeForKey(themeKey)) {
91 + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
92 + WidgetConfigScreen(
93 + initial = store.config(widgetId) ?: WidgetConfig(),
94 + tokens = tokens,
95 + onSave = { config ->
96 + store.saveConfig(widgetId, config)
97 + CiWidgetRenderer.render(this, AppWidgetManager.getInstance(this), widgetId)
98 + CiWidgetUpdater.schedulePeriodic(this)
99 + CiWidgetUpdater.refreshNow(this)
100 + setResult(Activity.RESULT_OK, result)
101 + finish()
102 + },
103 + onCancel = { finish() },
104 + onPair = {
105 + startActivity(Intent(this, MainActivity::class.java))
106 + finish()
107 + },
108 + )
109 + }
110 + }
111 + }
112 + }
113 +}
114 +
115 +@OptIn(ExperimentalMaterial3Api::class)
116 +@Composable
117 +private fun WidgetConfigScreen(
118 + initial: WidgetConfig,
119 + tokens: TokenStore,
120 + onSave: (WidgetConfig) -> Unit,
121 + onCancel: () -> Unit,
122 + onPair: () -> Unit,
123 +) {
124 + val creds = remember { tokens.load() }
125 + val watched = remember { mutableStateListOf<Watched>().apply { addAll(initial.watched) } }
126 + var hideIdle by remember { mutableStateOf(initial.hideIdle) }
127 + var filter by remember { mutableStateOf("") }
128 + var loaded by remember { mutableStateOf<ReposResponse?>(null) }
129 + var error by remember { mutableStateOf<String?>(null) }
130 +
131 + LaunchedEffect(creds) {
132 + if (creds == null) return@LaunchedEffect
133 + try {
134 + loaded = GitGudClient.authenticated(creds).repos()
135 + } catch (e: Exception) {
136 + error = GitGudClient.describe(e)
137 + }
138 + }
139 +
140 + fun watching(repo: String) = watched.any { it is Watched.Repo && it.fullName == repo }
141 + fun watchingAll(handle: String) = watched.any { it is Watched.Namespace && it.handle == handle }
142 + fun move(from: Int, to: Int) {
143 + if (to in watched.indices) watched.add(to, watched.removeAt(from))
144 + }
145 +
146 + Scaffold(
147 + topBar = {
148 + TopAppBar(
149 + title = { AppBarTitle("CI widget") },
150 + navigationIcon = {
151 + IconButton(onClick = onCancel) { Icon(Icons.Default.Close, contentDescription = "Cancel") }
152 + },
153 + actions = {
154 + TextButton(
155 + onClick = { onSave(WidgetConfig(watched.toList(), hideIdle)) },
156 + enabled = creds != null,
157 + modifier = Modifier.padding(end = 4.dp),
158 + ) { Text("Save") }
159 + },
160 + )
161 + },
162 + ) { padding ->
163 + if (creds == null) {
164 + Column(
165 + modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp),
166 + verticalArrangement = Arrangement.Center,
167 + horizontalAlignment = Alignment.CenterHorizontally,
168 + ) {
169 + Text(
170 + "The widget uses the app's connection. Pair GitGud with your forge first, then add the widget again.",
171 + style = MaterialTheme.typography.bodyMedium,
172 + )
173 + Spacer(Modifier.padding(8.dp))
174 + Button(onClick = onPair) { Text("Open GitGud") }
175 + }
176 + return@Scaffold
177 + }
178 +
179 + LazyColumn(
180 + modifier = Modifier.fillMaxSize().padding(padding),
181 + contentPadding = PaddingValues(bottom = 24.dp),
182 + ) {
183 + item { SectionHeader("Watching") }
184 + if (watched.isEmpty()) {
185 + item {
186 + Text(
187 + "Nothing yet. Watch a whole user or organization, or add repositories one by one, below.",
188 + style = MaterialTheme.typography.bodySmall,
189 + color = MaterialTheme.colorScheme.onSurfaceVariant,
190 + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
191 + )
192 + }
193 + }
194 + itemsIndexed(watched, key = { _, w -> w.toString() }) { index, entry ->
195 + WatchedRow(
196 + entry = entry,
197 + canMoveUp = index > 0,
198 + canMoveDown = index < watched.lastIndex,
199 + onMoveUp = { move(index, index - 1) },
200 + onMoveDown = { move(index, index + 1) },
201 + onRemove = { watched.removeAt(index) },
202 + onGrouped = { grouped ->
203 + if (entry is Watched.Namespace) watched[index] = entry.copy(grouped = grouped)
204 + },
205 + )
206 + }
207 +
208 + item {
209 + Row(
210 + verticalAlignment = Alignment.CenterVertically,
211 + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
212 + ) {
213 + Text(
214 + "Hide repositories that have never run CI",
215 + style = MaterialTheme.typography.bodyMedium,
216 + modifier = Modifier.weight(1f),
217 + )
218 + Switch(checked = hideIdle, onCheckedChange = { hideIdle = it })
219 + }
220 + HorizontalDivider()
221 + }
222 +
223 + item {
224 + SectionHeader("Add")
225 + Row(
226 + verticalAlignment = Alignment.CenterVertically,
227 + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
228 + ) {
229 + OutlinedTextField(
230 + value = filter,
231 + onValueChange = { filter = it },
232 + label = { Text("owner/repo or owner") },
233 + singleLine = true,
234 + modifier = Modifier.weight(1f),
235 + )
236 + Spacer(Modifier.width(8.dp))
237 + // A typed entry, for something the list doesn't show
238 + // yet or couldn't load.
239 + val typed = filter.trim().trim('/')
240 + TextButton(
241 + enabled = typed.isNotEmpty() &&
242 + (if ('/' in typed) !watching(typed) else !watchingAll(typed)),
243 + onClick = {
244 + watched.add(if ('/' in typed) Watched.Repo(typed) else Watched.Namespace(typed))
245 + filter = ""
246 + },
247 + ) { Text("Add") }
248 + }
249 + }
250 +
251 + val response = loaded
252 + when {
253 + error != null -> item { CenterText(error ?: "", Modifier.padding(16.dp)) }
254 + response == null -> item { CenterSpinner(Modifier.padding(24.dp)) }
255 + else -> {
256 + val needle = filter.trim().lowercase()
257 + val byOwner = response.repositories
258 + .filter { it.full_name != null && !it.archived }
259 + .filter { needle.isEmpty() || it.full_name!!.lowercase().contains(needle) }
260 + .groupBy { it.owner ?: it.full_name!!.substringBefore('/') }
261 + val owners = (response.namespaces.map { it.handle } + byOwner.keys).distinct()
262 + .filter { needle.isEmpty() || it.lowercase().contains(needle) || byOwner.containsKey(it) }
263 +
264 + for (owner in owners) {
265 + item(key = "ns:$owner") {
266 + val kind = response.namespaces.firstOrNull { it.handle == owner }?.kind
267 + Row(
268 + verticalAlignment = Alignment.CenterVertically,
269 + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 12.dp),
270 + ) {
271 + Column(Modifier.weight(1f)) {
272 + Text(owner, style = MaterialTheme.typography.titleSmall)
273 + if (kind != null) {
274 + Text(
275 + if (kind == "org") "organization" else kind,
276 + style = MaterialTheme.typography.labelSmall,
277 + color = MaterialTheme.colorScheme.onSurfaceVariant,
278 + )
279 + }
280 + }
281 + TextButton(
282 + enabled = !watchingAll(owner),
283 + onClick = { watched.add(Watched.Namespace(owner)) },
284 + ) { Text("Watch all") }
285 + }
286 + }
287 + items(byOwner[owner].orEmpty().sortedBy { it.name.lowercase() }, key = { "repo:${it.full_name}" }) { repo ->
288 + val fullName = repo.full_name!!
289 + Row(
290 + verticalAlignment = Alignment.CenterVertically,
291 + modifier = Modifier.fillMaxWidth().padding(start = 28.dp, end = 8.dp),
292 + ) {
293 + StatusDot(repo.ci_status)
294 + Spacer(Modifier.width(10.dp))
295 + Text(
296 + repo.name,
297 + style = MaterialTheme.typography.bodyMedium,
298 + maxLines = 1,
299 + overflow = TextOverflow.Ellipsis,
300 + modifier = Modifier.weight(1f),
301 + )
302 + TextButton(
303 + enabled = !watching(fullName) && !watchingAll(owner),
304 + onClick = { watched.add(Watched.Repo(fullName)) },
305 + ) { Text("Add") }
306 + }
307 + }
308 + }
309 + }
310 + }
311 + }
312 + }
313 +}
314 +
315 +@Composable
316 +private fun SectionHeader(text: String) {
317 + Text(
318 + text,
319 + style = MaterialTheme.typography.labelLarge,
320 + color = MaterialTheme.colorScheme.primary,
321 + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp),
322 + )
323 +}
324 +
325 +@Composable
326 +private fun WatchedRow(
327 + entry: Watched,
328 + canMoveUp: Boolean,
329 + canMoveDown: Boolean,
330 + onMoveUp: () -> Unit,
331 + onMoveDown: () -> Unit,
332 + onRemove: () -> Unit,
333 + onGrouped: (Boolean) -> Unit,
334 +) {
335 + Row(
336 + verticalAlignment = Alignment.CenterVertically,
337 + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp),
338 + ) {
339 + Column(Modifier.weight(1f)) {
340 + when (entry) {
341 + is Watched.Repo -> Text(entry.fullName, style = MaterialTheme.typography.bodyMedium)
342 + is Watched.Namespace -> {
343 + Text("${entry.handle}/*", style = MaterialTheme.typography.bodyMedium)
344 + Row(verticalAlignment = Alignment.CenterVertically) {
345 + Text(
346 + "One circle",
347 + style = MaterialTheme.typography.labelSmall,
348 + color = MaterialTheme.colorScheme.onSurfaceVariant,
349 + )
350 + Spacer(Modifier.width(8.dp))
351 + Switch(checked = entry.grouped, onCheckedChange = onGrouped)
352 + }
353 + }
354 + }
355 + }
356 + IconButton(onClick = onMoveUp, enabled = canMoveUp) {
357 + Icon(Icons.Default.KeyboardArrowUp, contentDescription = "Move up")
358 + }
359 + IconButton(onClick = onMoveDown, enabled = canMoveDown) {
360 + Icon(Icons.Default.KeyboardArrowDown, contentDescription = "Move down")
361 + }
362 + IconButton(onClick = onRemove) {
363 + Icon(Icons.Default.Close, contentDescription = "Remove")
364 + }
365 + }
366 +}
added android/app/src/main/java/org/neiam/gitgud/app/widget/CiWidgetModel.kt
+98 −0
@@ -0,0 +1,98 @@
1 +package org.neiam.gitgud.app.widget
2 +
3 +import org.neiam.gitgud.app.data.Run
4 +import org.neiam.gitgud.app.ui.effectiveStatus
5 +
6 +/** One dot on the strip: a repository, or a namespace folded into one. */
7 +data class Circle(
8 + val key: String,
9 + val label: String,
10 + val repos: List<String>,
11 + val status: String,
12 +) {
13 + val grouped: Boolean get() = key.startsWith("ns:")
14 +}
15 +
16 +enum class StatusCategory { SUCCESS, FAILURE, RUNNING, QUEUED, DIM }
17 +
18 +/**
19 + * Watched entries → circles → repos, and the status vocabulary. The same
20 + * rules as the Plasma widget's main.qml and Status.js, so a desktop and
21 + * a phone watching the same things show the same dots.
22 + */
23 +object CiWidgetModel {
24 + private val ACTIVE = setOf("queued", "running", "pending", "blocked")
25 +
26 + fun isActive(status: String?) = status in ACTIVE
27 +
28 + fun category(status: String?): StatusCategory = when (status) {
29 + "success" -> StatusCategory.SUCCESS
30 + "failure", "cancelled", "timed_out" -> StatusCategory.FAILURE
31 + "running" -> StatusCategory.RUNNING
32 + "queued", "pending", "blocked" -> StatusCategory.QUEUED
33 + else -> StatusCategory.DIM
34 + }
35 +
36 + // Worst-first for folding several repos into one circle: a run in
37 + // flight is the most interesting thing, then a broken build.
38 + private fun priority(status: String?) = when (category(status)) {
39 + StatusCategory.RUNNING -> 5
40 + StatusCategory.QUEUED -> 4
41 + StatusCategory.FAILURE -> 3
42 + StatusCategory.SUCCESS -> 2
43 + StatusCategory.DIM -> 1
44 + }
45 +
46 + fun aggregate(statuses: List<String>): String =
47 + statuses.maxByOrNull { priority(it) } ?: "unknown"
48 +
49 + fun runStatus(run: Run): String = effectiveStatus(run.status, run.conclusion) ?: "unknown"
50 +
51 + fun repoStatus(fullName: String, snapshot: Snapshot): String =
52 + snapshot.runs[fullName]?.firstOrNull()?.let { runStatus(it) }
53 + ?: snapshot.repos[fullName]?.ci_status
54 + ?: "unknown"
55 +
56 + /** In the order the user listed them, before any are hidden as idle. */
57 + private fun resolve(watched: List<Watched>, snapshot: Snapshot): List<Pair<String, Pair<String, List<String>>>> {
58 + val out = LinkedHashMap<String, Pair<String, List<String>>>()
59 + for (w in watched) {
60 + when (w) {
61 + is Watched.Repo -> out.putIfAbsent("repo:${w.fullName}", w.fullName to listOf(w.fullName))
62 + is Watched.Namespace -> {
63 + val members = snapshot.repos
64 + .filter { (_, row) -> row.owner == w.handle && !row.archived }
65 + .keys.sorted()
66 + if (w.grouped) {
67 + out.putIfAbsent("ns:${w.handle}", w.handle to members)
68 + } else {
69 + members.forEach { out.putIfAbsent("repo:$it", it to listOf(it)) }
70 + }
71 + }
72 + }
73 + }
74 + return out.entries.map { it.key to it.value }
75 + }
76 +
77 + fun circles(config: WidgetConfig, snapshot: Snapshot): List<Circle> =
78 + resolve(config.watched, snapshot).mapNotNull { (key, value) ->
79 + val (label, repos) = value
80 + val status = aggregate(repos.map { repoStatus(it, snapshot) })
81 + if (config.hideIdle && status == "unknown") return@mapNotNull null
82 + Circle(
83 + key = key,
84 + label = if (key.startsWith("ns:")) "$label (${repos.size} repos)" else label,
85 + repos = repos,
86 + status = status,
87 + )
88 + }
89 +
90 + fun watchedRepos(config: WidgetConfig, snapshot: Snapshot): List<String> =
91 + resolve(config.watched, snapshot).flatMap { it.second.second }.distinct()
92 +
93 + /** Newest first across [repos]. */
94 + fun recentRuns(repos: List<String>, snapshot: Snapshot, limit: Int): List<Pair<String, Run>> =
95 + repos.flatMap { repo -> snapshot.runs[repo].orEmpty().map { repo to it } }
96 + .sortedByDescending { it.second.inserted_at.orEmpty() }
97 + .take(limit)
98 +}
added android/app/src/main/java/org/neiam/gitgud/app/widget/CiWidgetProvider.kt
+64 −0

Click to load diff…

added android/app/src/main/java/org/neiam/gitgud/app/widget/CiWidgetRenderer.kt
+305 −0

Click to load diff…

added android/app/src/main/java/org/neiam/gitgud/app/widget/CiWidgetWorker.kt
+187 −0

Click to load diff…

added android/app/src/main/java/org/neiam/gitgud/app/widget/WidgetStore.kt
+102 −0

Click to load diff…

added android/app/src/main/res/drawable/ic_widget_refresh.xml
+10 −0

Click to load diff…

added android/app/src/main/res/drawable/ic_widget_settings.xml
+10 −0

Click to load diff…

added android/app/src/main/res/drawable/widget_dot.xml
+4 −0

Click to load diff…

added android/app/src/main/res/drawable/widget_highlight.xml
+6 −0

Click to load diff…

added android/app/src/main/res/drawable/widget_ring.xml
+6 −0

Click to load diff…

added android/app/src/main/res/drawable/widget_ring_thick.xml
+6 −0

Click to load diff…

added android/app/src/main/res/drawable/widget_shape.xml
+7 −0

Click to load diff…

added android/app/src/main/res/layout/widget_ci.xml
+136 −0

Click to load diff…

added android/app/src/main/res/layout/widget_ci_circle.xml
+36 −0

Click to load diff…

added android/app/src/main/res/layout/widget_ci_more.xml
+11 −0

Click to load diff…

added android/app/src/main/res/layout/widget_ci_run.xml
+50 −0

Click to load diff…

modified android/app/src/main/res/values/strings.xml
+6 −0

Click to load diff…

added android/app/src/main/res/xml/ci_widget_info.xml
+17 −0

Click to load diff…

Parents: e7443bd