neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Android: a home-screen CI widget, after the Plasma one
351bed2 · Gabriel Morell · 2026-09-26 22:31
Message
{commit_body(@commit)}
Files changed
modified
android/README.md
+8
−0
| 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 | 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 | 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 | 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 | 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 | 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 | 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
| 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
| 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