neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Show a repository's packages on the phone, with how to install them
b52a354 · Gabriel Morell · 2026-09-14 05:32
Message
{commit_body(@commit)}
Files changed
modified
android/app/src/main/java/org/neiam/gitgud/app/MainActivity.kt
+24
−0
| 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 |
| 27 | +import org.neiam.gitgud.app.ui.PackageScreen | |
| 27 | 28 | import org.neiam.gitgud.app.ui.PullScreen |
| 28 | 29 | import org.neiam.gitgud.app.ui.RepoListScreen |
| 29 | 30 | import org.neiam.gitgud.app.ui.RepoScreen |
| 143 | 144 | onOpenIssue = { number -> nav.navigate("repo/$owner/$name/issue/$number") }, |
| 144 | 145 | onOpenPull = { number -> nav.navigate("repo/$owner/$name/pull/$number") }, |
| 145 | 146 | onOpenWikiPage = { slug -> nav.navigate("repo/$owner/$name/wiki/$slug") }, |
| 147 | + onOpenPackage = { format, pkg -> | |
| 148 | + nav.navigate("repo/$owner/$name/package/$format/$pkg") | |
| 149 | + }, | |
| 146 | 150 | ) |
| 147 | 151 | } |
| 148 | 152 |
| 215 | 219 | ) |
| 216 | 220 | } |
| 217 | 221 | |
| 222 | + composable( | |
| 223 | + route = "repo/{owner}/{name}/package/{format}/{pkg}", | |
| 224 | + arguments = repoArgs() + listOf( | |
| 225 | + navArgument("format") { type = NavType.StringType }, | |
| 226 | + navArgument("pkg") { type = NavType.StringType }, | |
| 227 | + ), | |
| 228 | + ) { entry -> | |
| 229 | + val (owner, name) = entry.repoPath() ?: return@composable | |
| 230 | + val format = entry.arguments?.getString("format") ?: return@composable | |
| 231 | + val pkg = entry.arguments?.getString("pkg") ?: return@composable | |
| 232 | + PackageScreen( | |
| 233 | + owner = owner, | |
| 234 | + name = name, | |
| 235 | + format = format, | |
| 236 | + pkg = pkg, | |
| 237 | + tokens = tokens, | |
| 238 | + onBack = { nav.popBackStack() }, | |
| 239 | + ) | |
| 240 | + } | |
| 241 | + | |
| 218 | 242 | composable( |
| 219 | 243 | route = "repo/{owner}/{name}/wiki/{slug}", |
| 220 | 244 | arguments = repoArgs() + listOf(navArgument("slug") { type = NavType.StringType }), |
modified
android/app/src/main/java/org/neiam/gitgud/app/data/GitGudClient.kt
+25
−0
| 168 | 168 | @Path("name") name: String, |
| 169 | 169 | @Path("slug") slug: String, |
| 170 | 170 | ): WikiPageResponse |
| 171 | + | |
| 172 | + // --- packages --- | |
| 173 | + | |
| 174 | + @GET("api/mobile/v1/repos/{owner}/{name}/packages") | |
| 175 | + suspend fun packages( | |
| 176 | + @Path("owner") owner: String, | |
| 177 | + @Path("name") name: String, | |
| 178 | + ): PackagesResponse | |
| 179 | + | |
| 180 | + @GET("api/mobile/v1/repos/{owner}/{name}/packages/{format}/{pkg}") | |
| 181 | + suspend fun packageDetail( | |
| 182 | + @Path("owner") owner: String, | |
| 183 | + @Path("name") name: String, | |
| 184 | + @Path("format") format: String, | |
| 185 | + @Path("pkg") pkg: String, | |
| 186 | + ): PackageDetail | |
| 187 | + | |
| 188 | + // Its own path rather than a sub-path of the package, because a | |
| 189 | + // package may legitimately be named "usage". | |
| 190 | + @GET("api/mobile/v1/repos/{owner}/{name}/package-usage/{format}") | |
| 191 | + suspend fun packageUsage( | |
| 192 | + @Path("owner") owner: String, | |
| 193 | + @Path("name") name: String, | |
| 194 | + @Path("format") format: String, | |
| 195 | + ): PackageUsage | |
| 171 | 196 | } |
| 172 | 197 | |
| 173 | 198 | object GitGudClient { |
modified
android/app/src/main/java/org/neiam/gitgud/app/data/Models.kt
+97
−0
| 327 | 327 | @Serializable |
| 328 | 328 | data class RunResponse(val run: Run, val jobs: List<Job> = emptyList()) |
| 329 | 329 | |
| 330 | +// --- packages --- | |
| 331 | + | |
| 332 | +/** | |
| 333 | + * The packages tab, in one response. | |
| 334 | + * | |
| 335 | + * `label` and `summary` come from the server rather than a table here: | |
| 336 | + * a new format lands server-side and this client shows it correctly | |
| 337 | + * without being rebuilt, which is the opposite of how a hardcoded | |
| 338 | + * format list ages. | |
| 339 | + */ | |
| 340 | +@Serializable | |
| 341 | +data class PackagesResponse( | |
| 342 | + val formats: List<PackageFormat> = emptyList(), | |
| 343 | + /** Every format the forge can serve, so an empty tab can still offer instructions. */ | |
| 344 | + val available_formats: List<String> = emptyList(), | |
| 345 | +) | |
| 346 | + | |
| 347 | +@Serializable | |
| 348 | +data class PackageFormat( | |
| 349 | + val format: String, | |
| 350 | + val label: String, | |
| 351 | + val summary: String? = null, | |
| 352 | + val packages: List<PackageSummary> = emptyList(), | |
| 353 | +) | |
| 354 | + | |
| 355 | +@Serializable | |
| 356 | +data class PackageSummary( | |
| 357 | + val name: String, | |
| 358 | + val format: String, | |
| 359 | + val kind: String? = null, | |
| 360 | + val latest_version: String? = null, | |
| 361 | + /** The line you'd paste to install it, or null for a format with none. */ | |
| 362 | + val install_line: String? = null, | |
| 363 | + val updated_at: String? = null, | |
| 364 | +) | |
| 365 | + | |
| 366 | +@Serializable | |
| 367 | +data class PackageDetail( | |
| 368 | + val name: String, | |
| 369 | + val format: String, | |
| 370 | + val kind: String? = null, | |
| 371 | + val latest_version: String? = null, | |
| 372 | + val versions: List<PackageVersion> = emptyList(), | |
| 373 | +) | |
| 374 | + | |
| 375 | +@Serializable | |
| 376 | +data class PackageVersion( | |
| 377 | + val version: String, | |
| 378 | + val size: Long = 0, | |
| 379 | + val digest: String? = null, | |
| 380 | + val yanked: Boolean = false, | |
| 381 | + val yanked_reason: String? = null, | |
| 382 | + val pushed_at: String? = null, | |
| 383 | + val install_line: String? = null, | |
| 384 | + val files: List<PackageFileInfo> = emptyList(), | |
| 385 | +) | |
| 386 | + | |
| 387 | +@Serializable | |
| 388 | +data class PackageFileInfo( | |
| 389 | + val filename: String, | |
| 390 | + val size: Long = 0, | |
| 391 | + val content_type: String? = null, | |
| 392 | + val sha256: String? = null, | |
| 393 | +) | |
| 394 | + | |
| 395 | +/** | |
| 396 | + * Setup instructions for one format. | |
| 397 | + * | |
| 398 | + * Rendered verbatim. Every URL in here is one only the server can | |
| 399 | + * know — its own address, the namespace a repository belongs to, the | |
| 400 | + * key path — so the client formats these blocks and never builds one. | |
| 401 | + */ | |
| 402 | +@Serializable | |
| 403 | +data class PackageUsage( | |
| 404 | + val format: String, | |
| 405 | + val label: String, | |
| 406 | + val summary: String? = null, | |
| 407 | + val repository_url: String? = null, | |
| 408 | + val namespace_url: String? = null, | |
| 409 | + val key_url: String? = null, | |
| 410 | + val sections: List<UsageSection> = emptyList(), | |
| 411 | +) | |
| 412 | + | |
| 413 | +@Serializable | |
| 414 | +data class UsageSection( | |
| 415 | + val key: String, | |
| 416 | + val title: String, | |
| 417 | + val steps: List<UsageStep> = emptyList(), | |
| 418 | +) | |
| 419 | + | |
| 420 | +@Serializable | |
| 421 | +data class UsageStep( | |
| 422 | + val label: String, | |
| 423 | + val language: String? = null, | |
| 424 | + val code: String, | |
| 425 | +) | |
| 426 | + | |
| 330 | 427 | /** The server's error shape — `{"message": "..."}` on every 4xx. */ |
| 331 | 428 | @Serializable |
| 332 | 429 | data class ApiError(val message: String? = null) |
added
android/app/src/main/java/org/neiam/gitgud/app/ui/PackageScreen.kt
+169
−0
| 1 | +package org.neiam.gitgud.app.ui | |
| 2 | + | |
| 3 | +import androidx.compose.foundation.clickable | |
| 4 | +import androidx.compose.foundation.layout.Arrangement | |
| 5 | +import androidx.compose.foundation.layout.Column | |
| 6 | +import androidx.compose.foundation.layout.PaddingValues | |
| 7 | +import androidx.compose.foundation.layout.fillMaxSize | |
| 8 | +import androidx.compose.foundation.layout.fillMaxWidth | |
| 9 | +import androidx.compose.foundation.layout.padding | |
| 10 | +import androidx.compose.foundation.lazy.LazyColumn | |
| 11 | +import androidx.compose.foundation.lazy.items | |
| 12 | +import androidx.compose.material.icons.Icons | |
| 13 | +import androidx.compose.material.icons.automirrored.filled.ArrowBack | |
| 14 | +import androidx.compose.material.icons.filled.ExpandLess | |
| 15 | +import androidx.compose.material.icons.filled.ExpandMore | |
| 16 | +import androidx.compose.material3.Card | |
| 17 | +import androidx.compose.material3.ExperimentalMaterial3Api | |
| 18 | +import androidx.compose.material3.Icon | |
| 19 | +import androidx.compose.material3.IconButton | |
| 20 | +import androidx.compose.material3.ListItem | |
| 21 | +import androidx.compose.material3.MaterialTheme | |
| 22 | +import androidx.compose.material3.Scaffold | |
| 23 | +import androidx.compose.material3.Text | |
| 24 | +import androidx.compose.material3.TopAppBar | |
| 25 | +import androidx.compose.runtime.Composable | |
| 26 | +import androidx.compose.runtime.LaunchedEffect | |
| 27 | +import androidx.compose.runtime.getValue | |
| 28 | +import androidx.compose.runtime.mutableStateOf | |
| 29 | +import androidx.compose.runtime.remember | |
| 30 | +import androidx.compose.runtime.setValue | |
| 31 | +import androidx.compose.ui.Modifier | |
| 32 | +import androidx.compose.ui.unit.dp | |
| 33 | +import org.neiam.gitgud.app.data.GitGudClient | |
| 34 | +import org.neiam.gitgud.app.data.PackageDetail | |
| 35 | +import org.neiam.gitgud.app.data.TokenStore | |
| 36 | + | |
| 37 | +/** | |
| 38 | + * One package's versions. | |
| 39 | + * | |
| 40 | + * Every version carries its own install line, not just the newest: the | |
| 41 | + * reason to open this screen at all is usually to install something | |
| 42 | + * that isn't the newest thing. | |
| 43 | + */ | |
| 44 | +@OptIn(ExperimentalMaterial3Api::class) | |
| 45 | +@Composable | |
| 46 | +fun PackageScreen( | |
| 47 | + owner: String, | |
| 48 | + name: String, | |
| 49 | + format: String, | |
| 50 | + pkg: String, | |
| 51 | + tokens: TokenStore, | |
| 52 | + onBack: () -> Unit, | |
| 53 | +) { | |
| 54 | + val creds = remember { tokens.load() } | |
| 55 | + var detail by remember { mutableStateOf<PackageDetail?>(null) } | |
| 56 | + var error by remember { mutableStateOf<String?>(null) } | |
| 57 | + var open by remember { mutableStateOf<String?>(null) } | |
| 58 | + | |
| 59 | + LaunchedEffect(owner, name, format, pkg) { | |
| 60 | + if (creds == null) { | |
| 61 | + error = "Not paired." | |
| 62 | + return@LaunchedEffect | |
| 63 | + } | |
| 64 | + try { | |
| 65 | + detail = GitGudClient.authenticated(creds).packageDetail(owner, name, format, pkg) | |
| 66 | + error = null | |
| 67 | + } catch (e: Exception) { | |
| 68 | + error = GitGudClient.describe(e) | |
| 69 | + } | |
| 70 | + } | |
| 71 | + | |
| 72 | + Scaffold( | |
| 73 | + topBar = { | |
| 74 | + TopAppBar( | |
| 75 | + title = { AppBarTitle(pkg) }, | |
| 76 | + navigationIcon = { | |
| 77 | + IconButton(onClick = onBack) { | |
| 78 | + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") | |
| 79 | + } | |
| 80 | + }, | |
| 81 | + ) | |
| 82 | + }, | |
| 83 | + ) { padding -> | |
| 84 | + when { | |
| 85 | + error != null -> CenterText("Couldn't load $pkg: $error", Modifier.padding(padding)) | |
| 86 | + detail == null -> CenterSpinner(Modifier.padding(padding)) | |
| 87 | + detail!!.versions.isEmpty() -> | |
| 88 | + CenterText("No versions published.", Modifier.padding(padding)) | |
| 89 | + else -> LazyColumn( | |
| 90 | + modifier = Modifier.fillMaxSize().padding(padding), | |
| 91 | + contentPadding = PaddingValues(12.dp), | |
| 92 | + verticalArrangement = Arrangement.spacedBy(8.dp), | |
| 93 | + ) { | |
| 94 | + items(detail!!.versions, key = { it.version }) { version -> | |
| 95 | + val expanded = open == version.version | |
| 96 | + | |
| 97 | + Card(modifier = Modifier.fillMaxWidth()) { | |
| 98 | + Column { | |
| 99 | + ListItem( | |
| 100 | + modifier = Modifier.clickable { | |
| 101 | + open = if (expanded) null else version.version | |
| 102 | + }, | |
| 103 | + headlineContent = { | |
| 104 | + Text( | |
| 105 | + version.version + | |
| 106 | + if (version.yanked) " (yanked)" else "", | |
| 107 | + style = MaterialTheme.typography.titleSmall, | |
| 108 | + ) | |
| 109 | + }, | |
| 110 | + supportingContent = { | |
| 111 | + val parts = listOfNotNull( | |
| 112 | + formatBytes(version.size).takeIf { version.size > 0 }, | |
| 113 | + relativeTime(version.pushed_at).takeIf { it.isNotEmpty() }, | |
| 114 | + version.yanked_reason, | |
| 115 | + ) | |
| 116 | + Text(parts.joinToString(" · ")) | |
| 117 | + }, | |
| 118 | + trailingContent = { | |
| 119 | + if (version.files.isEmpty()) return@ListItem | |
| 120 | + Icon( | |
| 121 | + if (expanded) Icons.Filled.ExpandLess | |
| 122 | + else Icons.Filled.ExpandMore, | |
| 123 | + contentDescription = if (expanded) "Hide files" | |
| 124 | + else "Show files", | |
| 125 | + ) | |
| 126 | + }, | |
| 127 | + ) | |
| 128 | + | |
| 129 | + version.install_line?.let { CodeLine(it) } | |
| 130 | + | |
| 131 | + if (expanded) { | |
| 132 | + Column( | |
| 133 | + modifier = Modifier | |
| 134 | + .padding(horizontal = 16.dp) | |
| 135 | + .padding(top = 8.dp, bottom = 12.dp), | |
| 136 | + verticalArrangement = Arrangement.spacedBy(4.dp), | |
| 137 | + ) { | |
| 138 | + version.files.forEach { file -> | |
| 139 | + Text( | |
| 140 | + file.filename, | |
| 141 | + style = MaterialTheme.typography.bodySmall, | |
| 142 | + ) | |
| 143 | + Text( | |
| 144 | + listOfNotNull( | |
| 145 | + formatBytes(file.size), | |
| 146 | + file.sha256?.take(12), | |
| 147 | + ).joinToString(" · "), | |
| 148 | + style = MaterialTheme.typography.bodySmall, | |
| 149 | + color = MaterialTheme.colorScheme.onSurfaceVariant, | |
| 150 | + ) | |
| 151 | + } | |
| 152 | + } | |
| 153 | + } | |
| 154 | + } | |
| 155 | + } | |
| 156 | + } | |
| 157 | + } | |
| 158 | + } | |
| 159 | + } | |
| 160 | +} | |
| 161 | + | |
| 162 | +/** Sizes as a human reads them; a package listing is scanned, not audited. */ | |
| 163 | +fun formatBytes(bytes: Long): String = when { | |
| 164 | + bytes <= 0 -> "0 B" | |
| 165 | + bytes < 1024 -> "$bytes B" | |
| 166 | + bytes < 1024 * 1024 -> "${bytes / 1024} KB" | |
| 167 | + bytes < 1024L * 1024 * 1024 -> String.format("%.1f MB", bytes / 1024.0 / 1024.0) | |
| 168 | + else -> String.format("%.1f GB", bytes / 1024.0 / 1024.0 / 1024.0) | |
| 169 | +} |
added
android/app/src/main/java/org/neiam/gitgud/app/ui/PackagesTab.kt
+310
−0
| 1 | +package org.neiam.gitgud.app.ui | |
| 2 | + | |
| 3 | +import android.content.ClipData | |
| 4 | +import android.content.ClipboardManager | |
| 5 | +import android.content.Context | |
| 6 | +import android.widget.Toast | |
| 7 | +import androidx.compose.foundation.background | |
| 8 | +import androidx.compose.foundation.clickable | |
| 9 | +import androidx.compose.foundation.horizontalScroll | |
| 10 | +import androidx.compose.foundation.layout.Arrangement | |
| 11 | +import androidx.compose.foundation.layout.Column | |
| 12 | +import androidx.compose.foundation.layout.PaddingValues | |
| 13 | +import androidx.compose.foundation.layout.Row | |
| 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.lazy.LazyColumn | |
| 18 | +import androidx.compose.foundation.lazy.items | |
| 19 | +import androidx.compose.foundation.rememberScrollState | |
| 20 | +import androidx.compose.foundation.shape.RoundedCornerShape | |
| 21 | +import androidx.compose.material.icons.Icons | |
| 22 | +import androidx.compose.material.icons.filled.ContentCopy | |
| 23 | +import androidx.compose.material.icons.filled.ExpandLess | |
| 24 | +import androidx.compose.material.icons.filled.ExpandMore | |
| 25 | +import androidx.compose.material.icons.filled.Inventory2 | |
| 26 | +import androidx.compose.material3.Card | |
| 27 | +import androidx.compose.material3.Icon | |
| 28 | +import androidx.compose.material3.IconButton | |
| 29 | +import androidx.compose.material3.ListItem | |
| 30 | +import androidx.compose.material3.MaterialTheme | |
| 31 | +import androidx.compose.material3.Text | |
| 32 | +import androidx.compose.runtime.Composable | |
| 33 | +import androidx.compose.runtime.LaunchedEffect | |
| 34 | +import androidx.compose.runtime.getValue | |
| 35 | +import androidx.compose.runtime.mutableStateMapOf | |
| 36 | +import androidx.compose.runtime.mutableStateOf | |
| 37 | +import androidx.compose.runtime.remember | |
| 38 | +import androidx.compose.runtime.rememberCoroutineScope | |
| 39 | +import androidx.compose.runtime.setValue | |
| 40 | +import androidx.compose.ui.Alignment | |
| 41 | +import androidx.compose.ui.Modifier | |
| 42 | +import androidx.compose.ui.draw.clip | |
| 43 | +import androidx.compose.ui.platform.LocalContext | |
| 44 | +import androidx.compose.ui.unit.dp | |
| 45 | +import androidx.compose.ui.unit.sp | |
| 46 | +import kotlinx.coroutines.launch | |
| 47 | +import org.neiam.gitgud.app.data.GitGudClient | |
| 48 | +import org.neiam.gitgud.app.data.PackageFormat | |
| 49 | +import org.neiam.gitgud.app.data.PackageUsage | |
| 50 | +import org.neiam.gitgud.app.data.PackagesResponse | |
| 51 | +import org.neiam.gitgud.app.data.TokenStore | |
| 52 | +import org.neiam.gitgud.app.ui.theme.B612 | |
| 53 | +import org.neiam.gitgud.app.ui.theme.LocalAppTheme | |
| 54 | + | |
| 55 | +/** | |
| 56 | + * What this repository publishes, and how to install it. | |
| 57 | + * | |
| 58 | + * Read-only, matching the API: publishing is a CI job's business, and | |
| 59 | + * a phone holding a credential that could push to a registry is a | |
| 60 | + * worse trade than the convenience is worth. | |
| 61 | + * | |
| 62 | + * The instructions are fetched per format when opened rather than | |
| 63 | + * bundled with the list. They are several hundred lines of shell across | |
| 64 | + * all formats, and most readers open one. | |
| 65 | + */ | |
| 66 | +@Composable | |
| 67 | +fun PackagesTab( | |
| 68 | + owner: String, | |
| 69 | + name: String, | |
| 70 | + creds: TokenStore.Credentials, | |
| 71 | + onOpenPackage: (format: String, name: String) -> Unit, | |
| 72 | +) { | |
| 73 | + var packages by remember { mutableStateOf<PackagesResponse?>(null) } | |
| 74 | + var error by remember { mutableStateOf<String?>(null) } | |
| 75 | + | |
| 76 | + // Per format: null = never opened, present = fetched. Kept here so | |
| 77 | + // collapsing and reopening doesn't refetch. | |
| 78 | + val usage = remember { mutableStateMapOf<String, PackageUsage>() } | |
| 79 | + var expanded by remember { mutableStateOf<String?>(null) } | |
| 80 | + val scope = rememberCoroutineScope() | |
| 81 | + | |
| 82 | + LaunchedEffect(owner, name) { | |
| 83 | + try { | |
| 84 | + packages = GitGudClient.authenticated(creds).packages(owner, name) | |
| 85 | + error = null | |
| 86 | + } catch (e: Exception) { | |
| 87 | + error = GitGudClient.describe(e) | |
| 88 | + } | |
| 89 | + } | |
| 90 | + | |
| 91 | + // Every phone paired before this tab existed carries a token | |
| 92 | + // without `packages:read`, so the common first experience of this | |
| 93 | + // screen is a 403. Saying what to do about it beats echoing the | |
| 94 | + // server's sentence at someone who never chose a scope by hand. | |
| 95 | + val needsRepair = error?.contains("packages:read") == true | |
| 96 | + | |
| 97 | + fun toggle(format: String) { | |
| 98 | + if (expanded == format) { | |
| 99 | + expanded = null | |
| 100 | + return | |
| 101 | + } | |
| 102 | + expanded = format | |
| 103 | + if (usage.containsKey(format)) return | |
| 104 | + scope.launch { | |
| 105 | + runCatching { GitGudClient.authenticated(creds).packageUsage(owner, name, format) } | |
| 106 | + .onSuccess { usage[format] = it } | |
| 107 | + } | |
| 108 | + } | |
| 109 | + | |
| 110 | + when { | |
| 111 | + needsRepair -> CenterText( | |
| 112 | + "This device was paired before packages existed. " + | |
| 113 | + "Re-pair it from the tokens page to see them.", | |
| 114 | + ) | |
| 115 | + error != null -> CenterText("Couldn't load packages: $error") | |
| 116 | + packages == null -> CenterSpinner() | |
| 117 | + else -> { | |
| 118 | + val response = packages!! | |
| 119 | + | |
| 120 | + LazyColumn( | |
| 121 | + modifier = Modifier.fillMaxSize(), | |
| 122 | + contentPadding = PaddingValues(12.dp), | |
| 123 | + verticalArrangement = Arrangement.spacedBy(8.dp), | |
| 124 | + ) { | |
| 125 | + if (response.formats.isEmpty()) { | |
| 126 | + // Not an error state. A repository that has never | |
| 127 | + // published anything is the normal case, and the | |
| 128 | + // useful thing to show is how to start. | |
| 129 | + item { | |
| 130 | + Text( | |
| 131 | + "Nothing published yet. Pick a format to see how to publish to it.", | |
| 132 | + style = MaterialTheme.typography.bodyMedium, | |
| 133 | + color = MaterialTheme.colorScheme.onSurfaceVariant, | |
| 134 | + modifier = Modifier.padding(bottom = 4.dp), | |
| 135 | + ) | |
| 136 | + } | |
| 137 | + items(response.available_formats, key = { it }) { format -> | |
| 138 | + FormatCard( | |
| 139 | + title = format, | |
| 140 | + summary = usage[format]?.summary, | |
| 141 | + open = expanded == format, | |
| 142 | + onToggle = { toggle(format) }, | |
| 143 | + usage = usage[format], | |
| 144 | + ) | |
| 145 | + } | |
| 146 | + } else { | |
| 147 | + items(response.formats, key = { it.format }) { section -> | |
| 148 | + PackageSection( | |
| 149 | + section = section, | |
| 150 | + open = expanded == section.format, | |
| 151 | + onToggle = { toggle(section.format) }, | |
| 152 | + usage = usage[section.format], | |
| 153 | + onOpenPackage = onOpenPackage, | |
| 154 | + ) | |
| 155 | + } | |
| 156 | + } | |
| 157 | + } | |
| 158 | + } | |
| 159 | + } | |
| 160 | +} | |
| 161 | + | |
| 162 | +@Composable | |
| 163 | +private fun PackageSection( | |
| 164 | + section: PackageFormat, | |
| 165 | + open: Boolean, | |
| 166 | + onToggle: () -> Unit, | |
| 167 | + usage: PackageUsage?, | |
| 168 | + onOpenPackage: (format: String, name: String) -> Unit, | |
| 169 | +) { | |
| 170 | + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { | |
| 171 | + FormatCard( | |
| 172 | + title = section.label, | |
| 173 | + summary = section.summary, | |
| 174 | + open = open, | |
| 175 | + onToggle = onToggle, | |
| 176 | + usage = usage, | |
| 177 | + ) | |
| 178 | + | |
| 179 | + section.packages.forEach { pkg -> | |
| 180 | + Card(modifier = Modifier.fillMaxWidth()) { | |
| 181 | + Column { | |
| 182 | + ListItem( | |
| 183 | + modifier = Modifier.clickable { | |
| 184 | + onOpenPackage(section.format, pkg.name) | |
| 185 | + }, | |
| 186 | + headlineContent = { Text(pkg.name) }, | |
| 187 | + supportingContent = { | |
| 188 | + val version = pkg.latest_version ?: "no versions" | |
| 189 | + Text("$version · ${relativeTime(pkg.updated_at)}") | |
| 190 | + }, | |
| 191 | + leadingContent = { | |
| 192 | + Icon(Icons.Filled.Inventory2, contentDescription = null) | |
| 193 | + }, | |
| 194 | + ) | |
| 195 | + // The line you came here for, right under the name. | |
| 196 | + pkg.install_line?.let { CodeLine(it) } | |
| 197 | + } | |
| 198 | + } | |
| 199 | + } | |
| 200 | + } | |
| 201 | +} | |
| 202 | + | |
| 203 | +/** A format's heading, with its instructions folded underneath. */ | |
| 204 | +@Composable | |
| 205 | +private fun FormatCard( | |
| 206 | + title: String, | |
| 207 | + summary: String?, | |
| 208 | + open: Boolean, | |
| 209 | + onToggle: () -> Unit, | |
| 210 | + usage: PackageUsage?, | |
| 211 | +) { | |
| 212 | + Card(modifier = Modifier.fillMaxWidth()) { | |
| 213 | + Column { | |
| 214 | + ListItem( | |
| 215 | + modifier = Modifier.clickable(onClick = onToggle), | |
| 216 | + headlineContent = { | |
| 217 | + Text(title, style = MaterialTheme.typography.titleSmall) | |
| 218 | + }, | |
| 219 | + supportingContent = summary?.let { { Text(it) } }, | |
| 220 | + trailingContent = { | |
| 221 | + IconButton(onClick = onToggle) { | |
| 222 | + Icon( | |
| 223 | + if (open) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, | |
| 224 | + contentDescription = if (open) "Hide instructions" else "How to install", | |
| 225 | + ) | |
| 226 | + } | |
| 227 | + }, | |
| 228 | + ) | |
| 229 | + | |
| 230 | + if (!open) return@Column | |
| 231 | + | |
| 232 | + if (usage == null) { | |
| 233 | + CenterSpinner(Modifier.padding(16.dp)) | |
| 234 | + return@Column | |
| 235 | + } | |
| 236 | + | |
| 237 | + Column( | |
| 238 | + modifier = Modifier.padding(horizontal = 12.dp).padding(bottom = 12.dp), | |
| 239 | + verticalArrangement = Arrangement.spacedBy(10.dp), | |
| 240 | + ) { | |
| 241 | + usage.sections.forEach { block -> | |
| 242 | + Text( | |
| 243 | + block.title, | |
| 244 | + style = MaterialTheme.typography.labelLarge, | |
| 245 | + color = MaterialTheme.colorScheme.secondary, | |
| 246 | + ) | |
| 247 | + block.steps.forEach { step -> | |
| 248 | + Text( | |
| 249 | + step.label, | |
| 250 | + style = MaterialTheme.typography.bodySmall, | |
| 251 | + color = MaterialTheme.colorScheme.onSurfaceVariant, | |
| 252 | + ) | |
| 253 | + CodeLine(step.code) | |
| 254 | + } | |
| 255 | + } | |
| 256 | + } | |
| 257 | + } | |
| 258 | + } | |
| 259 | +} | |
| 260 | + | |
| 261 | +/** | |
| 262 | + * A block of shell or config, scrollable sideways and copyable. | |
| 263 | + * | |
| 264 | + * `softWrap = false` on purpose: these are commands, and a wrapped | |
| 265 | + * command read off a phone is a command typed wrong. The copy button | |
| 266 | + * is the intended path. | |
| 267 | + */ | |
| 268 | +@Composable | |
| 269 | +fun CodeLine(code: String) { | |
| 270 | + val context = LocalContext.current | |
| 271 | + val scroll = rememberScrollState() | |
| 272 | + | |
| 273 | + Row( | |
| 274 | + verticalAlignment = Alignment.CenterVertically, | |
| 275 | + modifier = Modifier | |
| 276 | + .fillMaxWidth() | |
| 277 | + .padding(horizontal = 4.dp) | |
| 278 | + .clip(RoundedCornerShape(6.dp)) | |
| 279 | + .background(LocalAppTheme.current.cardBg), | |
| 280 | + ) { | |
| 281 | + Text( | |
| 282 | + code, | |
| 283 | + fontFamily = B612, | |
| 284 | + fontSize = 11.sp, | |
| 285 | + softWrap = false, | |
| 286 | + modifier = Modifier | |
| 287 | + .weight(1f) | |
| 288 | + .horizontalScroll(scroll) | |
| 289 | + .padding(horizontal = 10.dp, vertical = 8.dp), | |
| 290 | + ) | |
| 291 | + IconButton(onClick = { copy(context, code) }) { | |
| 292 | + Icon( | |
| 293 | + Icons.Filled.ContentCopy, | |
| 294 | + contentDescription = "Copy", | |
| 295 | + modifier = Modifier.padding(2.dp), | |
| 296 | + ) | |
| 297 | + } | |
| 298 | + } | |
| 299 | +} | |
| 300 | + | |
| 301 | +private fun copy(context: Context, text: String) { | |
| 302 | + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager | |
| 303 | + ?: return | |
| 304 | + clipboard.setPrimaryClip(ClipData.newPlainText("command", text)) | |
| 305 | + // Android 13+ shows its own copy confirmation; a second toast on | |
| 306 | + // those versions would be two popups for one tap. | |
| 307 | + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) { | |
| 308 | + Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show() | |
| 309 | + } | |
| 310 | +} |
modified
android/app/src/main/java/org/neiam/gitgud/app/ui/RepoScreen.kt
+13
−3
Click to load diff…
modified
docs/mobile-api.md
+39
−0
Click to load diff…
modified
lib/git_gud/accounts/personal_access_token.ex
+8
−3
Click to load diff…
modified
lib/git_gud/packages/usage.ex
+45
−0
Click to load diff…
modified
lib/git_gud_web/controllers/api/mobile/json.ex
+125
−0
Click to load diff…
added
lib/git_gud_web/controllers/api/mobile/package_controller.ex
+120
−0
Click to load diff…
modified
lib/git_gud_web/live/repo_live/packages.ex
+4
−30
Click to load diff…
modified
lib/git_gud_web/router.ex
+7
−0
Click to load diff…
added
test/git_gud_web/controllers/api/mobile/mobile_packages_test.exs
+309
−0
Click to load diff…
Parents: ec2cfdc