neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Pickable repo sorting, and collapsed CI log sections
001cc6a · Gabriel Morell · 2026-09-12 16:04
Message
{commit_body(@commit)}
Files changed
modified
android/app/src/main/java/org/neiam/gitgud/app/MainActivity.kt
+7
−4
@@ -20,7 +20,7 @@ import androidx.navigation.compose.rememberNavController
| 20 | 20 | import androidx.navigation.navArgument |
| 21 | 21 | import org.neiam.gitgud.app.auth.PairScreen |
| 22 | 22 | import org.neiam.gitgud.app.data.PairPayload |
| 23 | −import org.neiam.gitgud.app.data.ThemeStore | |
| 23 | +import org.neiam.gitgud.app.data.PrefsStore | |
| 24 | 24 | import org.neiam.gitgud.app.data.TokenStore |
| 25 | 25 | import org.neiam.gitgud.app.ui.IssueScreen |
| 26 | 26 | import org.neiam.gitgud.app.ui.LogScreen |
@@ -49,12 +49,12 @@ class MainActivity : ComponentActivity() {
| 49 | 49 | override fun onCreate(savedInstanceState: Bundle?) { |
| 50 | 50 | super.onCreate(savedInstanceState) |
| 51 | 51 | val tokens = TokenStore(this) |
| 52 | − val themeStore = ThemeStore(this) | |
| 52 | + val prefs = PrefsStore(this) | |
| 53 | 53 | val initialPair = parseIntent(intent) |
| 54 | 54 | |
| 55 | 55 | setContent { |
| 56 | 56 | var theme by remember { |
| 57 | − mutableStateOf(themeStore.themeKey?.let(::appThemeByKey) ?: ALL_THEMES.first()) | |
| 57 | + mutableStateOf(prefs.themeKey?.let(::appThemeByKey) ?: ALL_THEMES.first()) | |
| 58 | 58 | } |
| 59 | 59 | GitGudTheme(theme = theme) { |
| 60 | 60 | Surface( |
@@ -63,11 +63,12 @@ class MainActivity : ComponentActivity() {
| 63 | 63 | ) { |
| 64 | 64 | GitGudApp( |
| 65 | 65 | tokens = tokens, |
| 66 | + prefs = prefs, | |
| 66 | 67 | initialPair = initialPair, |
| 67 | 68 | currentTheme = theme, |
| 68 | 69 | onPickTheme = { picked -> |
| 69 | 70 | theme = picked |
| 70 | − themeStore.themeKey = picked.key | |
| 71 | + prefs.themeKey = picked.key | |
| 71 | 72 | }, |
| 72 | 73 | ) |
| 73 | 74 | } |
@@ -84,6 +85,7 @@ class MainActivity : ComponentActivity() {
| 84 | 85 | @Composable |
| 85 | 86 | fun GitGudApp( |
| 86 | 87 | tokens: TokenStore, |
| 88 | + prefs: PrefsStore, | |
| 87 | 89 | initialPair: PairPayload?, |
| 88 | 90 | currentTheme: AppTheme, |
| 89 | 91 | onPickTheme: (AppTheme) -> Unit, |
@@ -108,6 +110,7 @@ fun GitGudApp(
| 108 | 110 | composable("repos") { |
| 109 | 111 | RepoListScreen( |
| 110 | 112 | tokens = tokens, |
| 113 | + prefs = prefs, | |
| 111 | 114 | currentTheme = currentTheme, |
| 112 | 115 | onPickTheme = onPickTheme, |
| 113 | 116 | onOpenRepo = { owner, name -> nav.navigate("repo/$owner/$name") }, |
added
android/app/src/main/java/org/neiam/gitgud/app/data/PrefsStore.kt
+51
−0
@@ -0,0 +1,51 @@
| 1 | +package org.neiam.gitgud.app.data | |
| 2 | + | |
| 3 | +import android.content.Context | |
| 4 | +import androidx.security.crypto.EncryptedSharedPreferences | |
| 5 | +import androidx.security.crypto.MasterKey | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * Display preferences: the chosen theme and the repo list's sort order. | |
| 9 | + * | |
| 10 | + * A separate EncryptedSharedPreferences file from credentials, so | |
| 11 | + * signing out doesn't reset how the app looks — re-pairing a device | |
| 12 | + * shouldn't cost you the palette and ordering you'd settled on. | |
| 13 | + * | |
| 14 | + * Both are stored as their stable string keys rather than ordinals, so | |
| 15 | + * reordering `ALL_THEMES` or `RepoSort` doesn't silently change what a | |
| 16 | + * device had chosen. An unrecognized key falls back to the default | |
| 17 | + * rather than failing. | |
| 18 | + */ | |
| 19 | +class PrefsStore(context: Context) { | |
| 20 | + private val prefs by lazy { | |
| 21 | + val masterKey = MasterKey.Builder(context) | |
| 22 | + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) | |
| 23 | + .build() | |
| 24 | + EncryptedSharedPreferences.create( | |
| 25 | + context, | |
| 26 | + "gitgud_prefs", | |
| 27 | + masterKey, | |
| 28 | + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, | |
| 29 | + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, | |
| 30 | + ) | |
| 31 | + } | |
| 32 | + | |
| 33 | + var themeKey: String? | |
| 34 | + get() = prefs.getString(KEY_THEME, null) | |
| 35 | + set(value) = put(KEY_THEME, value) | |
| 36 | + | |
| 37 | + var repoSortKey: String? | |
| 38 | + get() = prefs.getString(KEY_REPO_SORT, null) | |
| 39 | + set(value) = put(KEY_REPO_SORT, value) | |
| 40 | + | |
| 41 | + private fun put(key: String, value: String?) { | |
| 42 | + prefs.edit().also { e -> | |
| 43 | + if (value == null) e.remove(key) else e.putString(key, value) | |
| 44 | + }.apply() | |
| 45 | + } | |
| 46 | + | |
| 47 | + companion object { | |
| 48 | + private const val KEY_THEME = "theme_key" | |
| 49 | + private const val KEY_REPO_SORT = "repo_sort" | |
| 50 | + } | |
| 51 | +} |
added
android/app/src/main/java/org/neiam/gitgud/app/data/RepoSort.kt
+74
−0
@@ -0,0 +1,74 @@
| 1 | +package org.neiam.gitgud.app.data | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * How the repo list is ordered. | |
| 5 | + * | |
| 6 | + * Sorting happens on the client because `/repos` returns everything the | |
| 7 | + * token can reach in one response — there's no pagination to respect, | |
| 8 | + * so re-ordering is instant and costs no round-trip. | |
| 9 | + * | |
| 10 | + * `Pushed` is the default: the list's job is "what have I been working | |
| 11 | + * on", and that's what recency answers. | |
| 12 | + */ | |
| 13 | +enum class RepoSort(val key: String, val label: String) { | |
| 14 | + Pushed("pushed", "Recently pushed"), | |
| 15 | + Name("name", "Name"), | |
| 16 | + Ci("ci", "CI status"); | |
| 17 | + | |
| 18 | + fun sort(repos: List<Repository>): List<Repository> = when (this) { | |
| 19 | + Pushed -> repos.sortedWith(byPushed) | |
| 20 | + Name -> repos.sortedWith(byName) | |
| 21 | + Ci -> repos.sortedWith(byCi) | |
| 22 | + } | |
| 23 | + | |
| 24 | + companion object { | |
| 25 | + val DEFAULT = Pushed | |
| 26 | + | |
| 27 | + fun byKey(key: String?): RepoSort = | |
| 28 | + entries.firstOrNull { it.key == key } ?: DEFAULT | |
| 29 | + } | |
| 30 | +} | |
| 31 | + | |
| 32 | +private val byName = compareBy<Repository> { (it.full_name ?: it.name).lowercase() } | |
| 33 | + | |
| 34 | +/** | |
| 35 | + * Newest push first, never-pushed repos last. | |
| 36 | + * | |
| 37 | + * Timestamps are compared as strings: they're ISO-8601 UTC emitted by | |
| 38 | + * one server with one formatter, so lexicographic order *is* | |
| 39 | + * chronological order, and there's no parse that can fail halfway | |
| 40 | + * through sorting a list. | |
| 41 | + * | |
| 42 | + * An empty repo sorts to the bottom rather than the top, which is what | |
| 43 | + * a null would do on a naive descending sort — it has no work on it to | |
| 44 | + * be recent about. | |
| 45 | + */ | |
| 46 | +private val byPushed = Comparator<Repository> { a, b -> | |
| 47 | + val x = a.pushed_at | |
| 48 | + val y = b.pushed_at | |
| 49 | + when { | |
| 50 | + x == y -> 0 | |
| 51 | + x == null -> 1 | |
| 52 | + y == null -> -1 | |
| 53 | + else -> y.compareTo(x) | |
| 54 | + } | |
| 55 | +}.then(byName) | |
| 56 | + | |
| 57 | +/** | |
| 58 | + * Worst news first. | |
| 59 | + * | |
| 60 | + * Ordered by what wants a human, not by alphabet or severity in the | |
| 61 | + * abstract: a failure is why you opened the app, something running is | |
| 62 | + * worth watching, something queued is merely pending, and a cancelled | |
| 63 | + * run was usually cancelled on purpose. Green and never-built sink. | |
| 64 | + */ | |
| 65 | +private val byCi = compareBy<Repository> { ciRank(it.ci_status) }.then(byPushed) | |
| 66 | + | |
| 67 | +private fun ciRank(status: String?): Int = when (status) { | |
| 68 | + "failure", "timed_out" -> 0 | |
| 69 | + "running" -> 1 | |
| 70 | + "queued", "blocked", "pending" -> 2 | |
| 71 | + "cancelled" -> 3 | |
| 72 | + "success" -> 4 | |
| 73 | + else -> 5 | |
| 74 | +} |
deleted
android/app/src/main/java/org/neiam/gitgud/app/data/ThemeStore.kt
+0
−35
@@ -1,35 +0,0 @@
| 1 | −package org.neiam.gitgud.app.data | |
| 2 | − | |
| 3 | −import android.content.Context | |
| 4 | −import androidx.security.crypto.EncryptedSharedPreferences | |
| 5 | −import androidx.security.crypto.MasterKey | |
| 6 | − | |
| 7 | −/** | |
| 8 | − * Persists the chosen theme key (an entry in `ALL_THEMES`). Stored in a | |
| 9 | − * separate EncryptedSharedPreferences file from credentials so a token | |
| 10 | − * reset doesn't clobber the theme. | |
| 11 | − */ | |
| 12 | −class ThemeStore(context: Context) { | |
| 13 | − private val prefs by lazy { | |
| 14 | − val masterKey = MasterKey.Builder(context) | |
| 15 | − .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) | |
| 16 | − .build() | |
| 17 | − EncryptedSharedPreferences.create( | |
| 18 | − context, | |
| 19 | − "gitgud_prefs", | |
| 20 | − masterKey, | |
| 21 | − EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, | |
| 22 | − EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, | |
| 23 | − ) | |
| 24 | − } | |
| 25 | − | |
| 26 | − var themeKey: String? | |
| 27 | − get() = prefs.getString(KEY_THEME, null) | |
| 28 | − set(value) { | |
| 29 | − prefs.edit().also { e -> | |
| 30 | − if (value == null) e.remove(KEY_THEME) else e.putString(KEY_THEME, value) | |
| 31 | − }.apply() | |
| 32 | − } | |
| 33 | − | |
| 34 | − companion object { private const val KEY_THEME = "theme_key" } | |
| 35 | −} |
modified
android/app/src/main/java/org/neiam/gitgud/app/ui/LogScreen.kt
+106
−29
@@ -1,11 +1,15 @@
| 1 | 1 | package org.neiam.gitgud.app.ui |
| 2 | 2 | |
| 3 | +import androidx.compose.foundation.background | |
| 4 | +import androidx.compose.foundation.clickable | |
| 3 | 5 | import androidx.compose.foundation.horizontalScroll |
| 6 | +import androidx.compose.foundation.layout.Box | |
| 4 | 7 | import androidx.compose.foundation.layout.Column |
| 5 | 8 | import androidx.compose.foundation.layout.PaddingValues |
| 6 | 9 | import androidx.compose.foundation.layout.Row |
| 7 | 10 | import androidx.compose.foundation.layout.Spacer |
| 8 | 11 | import androidx.compose.foundation.layout.fillMaxSize |
| 12 | +import androidx.compose.foundation.layout.size | |
| 9 | 13 | import androidx.compose.foundation.layout.fillMaxWidth |
| 10 | 14 | import androidx.compose.foundation.layout.padding |
| 11 | 15 | import androidx.compose.foundation.layout.width |
@@ -14,7 +18,11 @@ import androidx.compose.foundation.lazy.items
| 14 | 18 | import androidx.compose.foundation.rememberScrollState |
| 15 | 19 | import androidx.compose.material.icons.Icons |
| 16 | 20 | import androidx.compose.material.icons.automirrored.filled.ArrowBack |
| 21 | +import androidx.compose.material.icons.filled.ExpandLess | |
| 22 | +import androidx.compose.material.icons.filled.ExpandMore | |
| 17 | 23 | import androidx.compose.material.icons.filled.Refresh |
| 24 | +import androidx.compose.material.icons.filled.UnfoldLess | |
| 25 | +import androidx.compose.material.icons.filled.UnfoldMore | |
| 18 | 26 | import androidx.compose.material3.ExperimentalMaterial3Api |
| 19 | 27 | import androidx.compose.material3.HorizontalDivider |
| 20 | 28 | import androidx.compose.material3.Icon |
@@ -32,17 +40,26 @@ import androidx.compose.runtime.remember
| 32 | 40 | import androidx.compose.runtime.setValue |
| 33 | 41 | import androidx.compose.ui.Alignment |
| 34 | 42 | import androidx.compose.ui.Modifier |
| 43 | +import androidx.compose.ui.text.font.FontWeight | |
| 35 | 44 | import androidx.compose.ui.text.style.TextOverflow |
| 36 | 45 | import androidx.compose.ui.unit.dp |
| 37 | 46 | import androidx.compose.ui.unit.sp |
| 38 | 47 | import kotlinx.coroutines.delay |
| 39 | 48 | import org.neiam.gitgud.app.data.GitGudClient |
| 40 | 49 | import org.neiam.gitgud.app.data.LogsResponse |
| 50 | +import org.neiam.gitgud.app.data.Step | |
| 41 | 51 | import org.neiam.gitgud.app.data.TokenStore |
| 42 | 52 | import org.neiam.gitgud.app.ui.theme.B612 |
| 53 | +import org.neiam.gitgud.app.ui.theme.LocalAppTheme | |
| 43 | 54 | |
| 44 | 55 | /** |
| 45 | − * A job's logs, grouped by step. | |
| 56 | + * A job's logs, grouped by collapsible steps. | |
| 57 | + * | |
| 58 | + * Sections start collapsed. A green ten-step build is several thousand | |
| 59 | + * lines of noise you didn't come for, and the step list itself is the | |
| 60 | + * useful view — it says what ran and what broke, in one screen, without | |
| 61 | + * scrolling. Failed steps are the exception and open themselves: that's | |
| 62 | + * the step you opened the log to read. | |
| 46 | 63 | * |
| 47 | 64 | * Lines don't wrap — they scroll sideways. Build output is written for |
| 48 | 65 | * an 80-column terminal, and wrapping a stack trace across a phone's |
@@ -68,6 +85,12 @@ fun LogScreen(
| 68 | 85 | var reloadKey by remember { mutableStateOf(0) } |
| 69 | 86 | val hScroll = rememberScrollState() |
| 70 | 87 | |
| 88 | + // Only what the reader has explicitly toggled. Everything else | |
| 89 | + // follows `defaultExpanded`, so a step that fails on a later poll | |
| 90 | + // opens on its own rather than staying shut because it happened to | |
| 91 | + // be passing when the screen first loaded. | |
| 92 | + var overrides by remember(jobId) { mutableStateOf<Map<Long, Boolean>>(emptyMap()) } | |
| 93 | + | |
| 71 | 94 | LaunchedEffect(jobId, reloadKey) { |
| 72 | 95 | if (creds == null) { |
| 73 | 96 | error = "Not paired." |
@@ -97,6 +120,20 @@ fun LogScreen(
| 97 | 120 | } |
| 98 | 121 | }, |
| 99 | 122 | actions = { |
| 123 | + val steps = logs?.steps.orEmpty().map { it.step } | |
| 124 | + if (steps.isNotEmpty()) { | |
| 125 | + val allOpen = steps.all { overrides[it.id] ?: defaultExpanded(it) } | |
| 126 | + IconButton( | |
| 127 | + onClick = { | |
| 128 | + overrides = steps.associate { it.id to !allOpen } | |
| 129 | + }, | |
| 130 | + ) { | |
| 131 | + Icon( | |
| 132 | + if (allOpen) Icons.Default.UnfoldLess else Icons.Default.UnfoldMore, | |
| 133 | + contentDescription = if (allOpen) "Collapse all" else "Expand all", | |
| 134 | + ) | |
| 135 | + } | |
| 136 | + } | |
| 100 | 137 | IconButton(onClick = { reloadKey++ }) { |
| 101 | 138 | Icon(Icons.Default.Refresh, contentDescription = "Refresh") |
| 102 | 139 | } |
@@ -131,28 +168,42 @@ fun LogScreen(
| 131 | 168 | } |
| 132 | 169 | |
| 133 | 170 | l.steps.forEach { group -> |
| 171 | + val open = overrides[group.step.id] ?: defaultExpanded(group.step) | |
| 172 | + | |
| 134 | 173 | item(key = "head-${group.step.id}") { |
| 135 | 174 | StepHeader( |
| 136 | − name = group.step.name ?: "step ${group.step.number}", | |
| 137 | − status = effectiveStatus(group.step.status, group.step.conclusion), | |
| 138 | − duration = durationBetween( | |
| 139 | − group.step.started_at, | |
| 140 | − group.step.completed_at, | |
| 141 | − ), | |
| 175 | + step = group.step, | |
| 176 | + lineCount = group.lines.size, | |
| 177 | + expanded = open, | |
| 178 | + onToggle = { | |
| 179 | + overrides = overrides + (group.step.id to !open) | |
| 180 | + }, | |
| 142 | 181 | ) |
| 143 | 182 | } |
| 183 | + | |
| 184 | + if (!open) return@forEach | |
| 185 | + | |
| 144 | 186 | items(group.lines, key = { "${group.step.id}-${it.seq}" }) { line -> |
| 145 | − Text( | |
| 146 | − line.body, | |
| 147 | − fontFamily = B612, | |
| 148 | − fontSize = 11.sp, | |
| 149 | − softWrap = false, | |
| 150 | − maxLines = 1, | |
| 151 | − overflow = TextOverflow.Clip, | |
| 187 | + // The background sits on the Box, outside the | |
| 188 | + // scroll, so it fills the viewport rather than | |
| 189 | + // stopping at the end of a short line. | |
| 190 | + Box( | |
| 152 | 191 | modifier = Modifier |
| 153 | − .horizontalScroll(hScroll) | |
| 154 | − .padding(horizontal = 10.dp, vertical = 1.dp), | |
| 155 | − ) | |
| 192 | + .fillMaxWidth() | |
| 193 | + .background(LocalAppTheme.current.cardBg), | |
| 194 | + ) { | |
| 195 | + Text( | |
| 196 | + line.body, | |
| 197 | + fontFamily = B612, | |
| 198 | + fontSize = 11.sp, | |
| 199 | + softWrap = false, | |
| 200 | + maxLines = 1, | |
| 201 | + overflow = TextOverflow.Clip, | |
| 202 | + modifier = Modifier | |
| 203 | + .horizontalScroll(hScroll) | |
| 204 | + .padding(horizontal = 10.dp, vertical = 1.dp), | |
| 205 | + ) | |
| 206 | + } | |
| 156 | 207 | } |
| 157 | 208 | } |
| 158 | 209 | } |
@@ -162,32 +213,58 @@ fun LogScreen(
| 162 | 213 | } |
| 163 | 214 | } |
| 164 | 215 | |
| 216 | +/** | |
| 217 | + * A step opens by default only when it failed. `cancelled` doesn't | |
| 218 | + * count — a cancelled step was usually cancelled on purpose, and its | |
| 219 | + * output is the build stopping rather than a reason. | |
| 220 | + */ | |
| 221 | +private fun defaultExpanded(step: Step): Boolean = | |
| 222 | + effectiveStatus(step.status, step.conclusion) in setOf("failure", "timed_out") | |
| 223 | + | |
| 165 | 224 | @Composable |
| 166 | −private fun StepHeader(name: String, status: String?, duration: String) { | |
| 167 | − Column { | |
| 225 | +private fun StepHeader( | |
| 226 | + step: Step, | |
| 227 | + lineCount: Int, | |
| 228 | + expanded: Boolean, | |
| 229 | + onToggle: () -> Unit, | |
| 230 | +) { | |
| 231 | + Column(modifier = Modifier.background(MaterialTheme.colorScheme.surface)) { | |
| 168 | 232 | HorizontalDivider() |
| 169 | 233 | Row( |
| 170 | 234 | verticalAlignment = Alignment.CenterVertically, |
| 171 | 235 | modifier = Modifier |
| 172 | 236 | .fillMaxWidth() |
| 173 | − .padding(horizontal = 10.dp, vertical = 6.dp), | |
| 237 | + .clickable(onClick = onToggle) | |
| 238 | + .padding(horizontal = 10.dp, vertical = 8.dp), | |
| 174 | 239 | ) { |
| 175 | − StatusDot(status) | |
| 240 | + StatusDot(effectiveStatus(step.status, step.conclusion)) | |
| 176 | 241 | Spacer(Modifier.width(8.dp)) |
| 177 | 242 | Text( |
| 178 | − name, | |
| 243 | + step.name ?: "step ${step.number}", | |
| 179 | 244 | style = MaterialTheme.typography.labelMedium, |
| 245 | + fontWeight = FontWeight.Bold, | |
| 180 | 246 | modifier = Modifier.weight(1f), |
| 181 | 247 | maxLines = 1, |
| 182 | 248 | overflow = TextOverflow.Ellipsis, |
| 183 | 249 | ) |
| 184 | − if (duration.isNotBlank()) { | |
| 185 | − Text( | |
| 186 | − duration, | |
| 187 | − style = MaterialTheme.typography.labelSmall, | |
| 188 | − color = MaterialTheme.colorScheme.onSurfaceVariant, | |
| 189 | − ) | |
| 190 | − } | |
| 250 | + | |
| 251 | + // A collapsed section still has to say what it's hiding, | |
| 252 | + // or it reads as an empty step rather than a closed one. | |
| 253 | + Text( | |
| 254 | + listOfNotNull( | |
| 255 | + durationBetween(step.started_at, step.completed_at).takeIf { it.isNotBlank() }, | |
| 256 | + "$lineCount ${if (lineCount == 1) "line" else "lines"}", | |
| 257 | + ).joinToString(" · "), | |
| 258 | + style = MaterialTheme.typography.labelSmall, | |
| 259 | + color = MaterialTheme.colorScheme.onSurfaceVariant, | |
| 260 | + ) | |
| 261 | + Spacer(Modifier.width(4.dp)) | |
| 262 | + Icon( | |
| 263 | + if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, | |
| 264 | + contentDescription = if (expanded) "Collapse" else "Expand", | |
| 265 | + modifier = Modifier.size(16.dp), | |
| 266 | + tint = MaterialTheme.colorScheme.onSurfaceVariant, | |
| 267 | + ) | |
| 191 | 268 | } |
| 192 | 269 | } |
| 193 | 270 | } |
modified
android/app/src/main/java/org/neiam/gitgud/app/ui/RepoListScreen.kt
+62
−2
@@ -21,6 +21,7 @@ import androidx.compose.material.icons.automirrored.filled.Logout
| 21 | 21 | import androidx.compose.material.icons.filled.Lock |
| 22 | 22 | import androidx.compose.material.icons.filled.MoreVert |
| 23 | 23 | import androidx.compose.material.icons.filled.Refresh |
| 24 | +import androidx.compose.material.icons.automirrored.filled.Sort | |
| 24 | 25 | import androidx.compose.material3.AlertDialog |
| 25 | 26 | import androidx.compose.material3.Card |
| 26 | 27 | import androidx.compose.material3.DropdownMenu |
@@ -51,6 +52,8 @@ import androidx.compose.ui.unit.dp
| 51 | 52 | import kotlinx.coroutines.launch |
| 52 | 53 | import org.neiam.gitgud.app.data.GitGudClient |
| 53 | 54 | import org.neiam.gitgud.app.data.Namespace |
| 55 | +import org.neiam.gitgud.app.data.PrefsStore | |
| 56 | +import org.neiam.gitgud.app.data.RepoSort | |
| 54 | 57 | import org.neiam.gitgud.app.data.Repository |
| 55 | 58 | import org.neiam.gitgud.app.data.ReposResponse |
| 56 | 59 | import org.neiam.gitgud.app.data.TokenStore |
@@ -71,6 +74,7 @@ import org.neiam.gitgud.app.ui.theme.AppTheme
| 71 | 74 | @Composable |
| 72 | 75 | fun RepoListScreen( |
| 73 | 76 | tokens: TokenStore, |
| 77 | + prefs: PrefsStore, | |
| 74 | 78 | currentTheme: AppTheme, |
| 75 | 79 | onPickTheme: (AppTheme) -> Unit, |
| 76 | 80 | onOpenRepo: (owner: String, name: String) -> Unit, |
@@ -81,6 +85,8 @@ fun RepoListScreen(
| 81 | 85 | var selectedNamespace by remember { mutableStateOf<String?>(null) } |
| 82 | 86 | var menuOpen by remember { mutableStateOf(false) } |
| 83 | 87 | var themePickerOpen by remember { mutableStateOf(false) } |
| 88 | + var sortPickerOpen by remember { mutableStateOf(false) } | |
| 89 | + var sort by remember { mutableStateOf(RepoSort.byKey(prefs.repoSortKey)) } | |
| 84 | 90 | var reloadKey by remember { mutableStateOf(0) } |
| 85 | 91 | |
| 86 | 92 | LaunchedEffect(reloadKey) { |
@@ -108,6 +114,14 @@ fun RepoListScreen(
| 108 | 114 | Icon(Icons.Default.MoreVert, contentDescription = "Menu") |
| 109 | 115 | } |
| 110 | 116 | DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { |
| 117 | + DropdownMenuItem( | |
| 118 | + text = { Text("Sort: ${sort.label}") }, | |
| 119 | + leadingIcon = { Icon(Icons.AutoMirrored.Filled.Sort, contentDescription = null) }, | |
| 120 | + onClick = { | |
| 121 | + menuOpen = false | |
| 122 | + sortPickerOpen = true | |
| 123 | + }, | |
| 124 | + ) | |
| 111 | 125 | DropdownMenuItem( |
| 112 | 126 | text = { Text("Theme: ${currentTheme.name}") }, |
| 113 | 127 | onClick = { |
@@ -134,8 +148,12 @@ fun RepoListScreen(
| 134 | 148 | ReposState.Loading -> CenterSpinner(Modifier.padding(padding)) |
| 135 | 149 | is ReposState.Error -> CenterText("Couldn't load repositories: ${s.message}", Modifier.padding(padding)) |
| 136 | 150 | is ReposState.Loaded -> { |
| 137 | − val repos = s.data.repositories.filter { | |
| 138 | − selectedNamespace == null || it.owner == selectedNamespace | |
| 151 | + val repos = remember(s.data, selectedNamespace, sort) { | |
| 152 | + sort.sort( | |
| 153 | + s.data.repositories.filter { | |
| 154 | + selectedNamespace == null || it.owner == selectedNamespace | |
| 155 | + }, | |
| 156 | + ) | |
| 139 | 157 | } |
| 140 | 158 | |
| 141 | 159 | Column(modifier = Modifier.fillMaxSize().padding(padding)) { |
@@ -164,6 +182,18 @@ fun RepoListScreen(
| 164 | 182 | } |
| 165 | 183 | } |
| 166 | 184 | |
| 185 | + if (sortPickerOpen) { | |
| 186 | + SortPickerDialog( | |
| 187 | + current = sort, | |
| 188 | + onDismiss = { sortPickerOpen = false }, | |
| 189 | + onPick = { picked -> | |
| 190 | + sortPickerOpen = false | |
| 191 | + sort = picked | |
| 192 | + prefs.repoSortKey = picked.key | |
| 193 | + }, | |
| 194 | + ) | |
| 195 | + } | |
| 196 | + | |
| 167 | 197 | if (themePickerOpen) { |
| 168 | 198 | ThemePickerDialog( |
| 169 | 199 | current = currentTheme, |
@@ -272,6 +302,36 @@ private fun RepoRow(repo: Repository, onClick: () -> Unit) {
| 272 | 302 | } |
| 273 | 303 | } |
| 274 | 304 | |
| 305 | +@Composable | |
| 306 | +private fun SortPickerDialog( | |
| 307 | + current: RepoSort, | |
| 308 | + onDismiss: () -> Unit, | |
| 309 | + onPick: (RepoSort) -> Unit, | |
| 310 | +) { | |
| 311 | + AlertDialog( | |
| 312 | + onDismissRequest = onDismiss, | |
| 313 | + title = { Text("Sort repositories by") }, | |
| 314 | + text = { | |
| 315 | + Column { | |
| 316 | + RepoSort.entries.forEach { option -> | |
| 317 | + Row( | |
| 318 | + verticalAlignment = Alignment.CenterVertically, | |
| 319 | + modifier = Modifier | |
| 320 | + .fillMaxWidth() | |
| 321 | + .clickable { onPick(option) } | |
| 322 | + .padding(vertical = 4.dp), | |
| 323 | + ) { | |
| 324 | + RadioButton(selected = option == current, onClick = { onPick(option) }) | |
| 325 | + Spacer(Modifier.width(8.dp)) | |
| 326 | + Text(option.label) | |
| 327 | + } | |
| 328 | + } | |
| 329 | + } | |
| 330 | + }, | |
| 331 | + confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } }, | |
| 332 | + ) | |
| 333 | +} | |
| 334 | + | |
| 275 | 335 | @Composable |
| 276 | 336 | private fun ThemePickerDialog( |
| 277 | 337 | current: AppTheme, |
Parents: 504e101