Show a repository's packages on the phone, with how to install them

b52a354 · Gabriel Morell · 2026-09-14 05:32

41 files +1295 -36
Message
{commit_body(@commit)}

Files changed

modified android/app/src/main/java/org/neiam/gitgud/app/MainActivity.kt
+24 −0
@@ -24,6 +24,7 @@ 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
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,6 +144,9 @@ fun GitGudApp(
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,6 +219,26 @@ fun GitGudApp(
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,6 +168,31 @@ interface GitGudApi {
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,6 +327,103 @@ data class ReviewResponse(val review: Review)
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
@@ -0,0 +1,169 @@
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
@@ -0,0 +1,310 @@
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