neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Packages: say what each one is, and show it
a625083 · Gabriel Morell · 2026-09-29 18:30
Message
{commit_body(@commit)}
Files changed
modified
android/app/src/main/java/org/neiam/gitgud/app/data/GitGudClient.kt
+10
−0
| 4 | 4 | import kotlinx.serialization.json.Json |
| 5 | 5 | import okhttp3.MediaType.Companion.toMediaType |
| 6 | 6 | import okhttp3.OkHttpClient |
| 7 | +import okhttp3.ResponseBody | |
| 7 | 8 | import okhttp3.logging.HttpLoggingInterceptor |
| 8 | 9 | import retrofit2.HttpException |
| 9 | 10 | import retrofit2.Retrofit |
| 12 | 13 | import retrofit2.http.POST |
| 13 | 14 | import retrofit2.http.Path |
| 14 | 15 | import retrofit2.http.Query |
| 16 | +import retrofit2.http.Url | |
| 15 | 17 | import java.util.concurrent.TimeUnit |
| 16 | 18 | |
| 17 | 19 | /** |
| 185 | 187 | @Path("pkg") pkg: String, |
| 186 | 188 | ): PackageDetail |
| 187 | 189 | |
| 190 | + /** | |
| 191 | + * Image bytes at a path the API handed back (a package's `icon_url`). | |
| 192 | + * Relative to the base URL, so a forge served under a path prefix | |
| 193 | + * still resolves. | |
| 194 | + */ | |
| 195 | + @GET | |
| 196 | + suspend fun bytes(@Url path: String): ResponseBody | |
| 197 | + | |
| 188 | 198 | // Its own path rather than a sub-path of the package, because a |
| 189 | 199 | // package may legitimately be named "usage". |
| 190 | 200 | @GET("api/mobile/v1/repos/{owner}/{name}/package-usage/{format}") |
modified
android/app/src/main/java/org/neiam/gitgud/app/data/Models.kt
+10
−0
| 355 | 355 | @Serializable |
| 356 | 356 | data class PackageSummary( |
| 357 | 357 | val name: String, |
| 358 | + /** What a person calls it — an app's label, else the name. */ | |
| 359 | + val title: String? = null, | |
| 360 | + /** One line on what it is, or null when nothing says. */ | |
| 361 | + val summary: String? = null, | |
| 362 | + /** API-relative path to its icon, or null for a package with none. */ | |
| 363 | + val icon_url: String? = null, | |
| 358 | 364 | val format: String, |
| 359 | 365 | val kind: String? = null, |
| 360 | 366 | val latest_version: String? = null, |
| 366 | 372 | @Serializable |
| 367 | 373 | data class PackageDetail( |
| 368 | 374 | val name: String, |
| 375 | + val title: String? = null, | |
| 376 | + val summary: String? = null, | |
| 377 | + val description: String? = null, | |
| 378 | + val icon_url: String? = null, | |
| 369 | 379 | val format: String, |
| 370 | 380 | val kind: String? = null, |
| 371 | 381 | val latest_version: String? = null, |
added
android/app/src/main/java/org/neiam/gitgud/app/ui/PackageIcon.kt
+71
−0
| 1 | +package org.neiam.gitgud.app.ui | |
| 2 | + | |
| 3 | +import android.graphics.BitmapFactory | |
| 4 | +import androidx.compose.foundation.Image | |
| 5 | +import androidx.compose.foundation.layout.size | |
| 6 | +import androidx.compose.foundation.shape.RoundedCornerShape | |
| 7 | +import androidx.compose.material.icons.Icons | |
| 8 | +import androidx.compose.material.icons.filled.Inventory2 | |
| 9 | +import androidx.compose.material3.Icon | |
| 10 | +import androidx.compose.runtime.Composable | |
| 11 | +import androidx.compose.runtime.LaunchedEffect | |
| 12 | +import androidx.compose.runtime.getValue | |
| 13 | +import androidx.compose.runtime.mutableStateOf | |
| 14 | +import androidx.compose.runtime.remember | |
| 15 | +import androidx.compose.runtime.setValue | |
| 16 | +import androidx.compose.ui.Modifier | |
| 17 | +import androidx.compose.ui.draw.clip | |
| 18 | +import androidx.compose.ui.graphics.ImageBitmap | |
| 19 | +import androidx.compose.ui.graphics.asImageBitmap | |
| 20 | +import androidx.compose.ui.layout.ContentScale | |
| 21 | +import androidx.compose.ui.unit.Dp | |
| 22 | +import androidx.compose.ui.unit.dp | |
| 23 | +import kotlinx.coroutines.Dispatchers | |
| 24 | +import kotlinx.coroutines.withContext | |
| 25 | +import org.neiam.gitgud.app.data.GitGudClient | |
| 26 | +import org.neiam.gitgud.app.data.TokenStore | |
| 27 | + | |
| 28 | +/** | |
| 29 | + * Decoded icons, by path, for the life of the process. A package list | |
| 30 | + * redraws on every scroll and a launcher icon is a few KB; fetching it | |
| 31 | + * once is the whole of the caching this needs. | |
| 32 | + */ | |
| 33 | +private object IconCache { | |
| 34 | + val bitmaps = HashMap<String, ImageBitmap>() | |
| 35 | +} | |
| 36 | + | |
| 37 | +/** | |
| 38 | + * A package's icon, fetched through the API with the paired token — | |
| 39 | + * or the generic package glyph when it has none or the fetch fails. | |
| 40 | + * No image library for one small picture per row. | |
| 41 | + */ | |
| 42 | +@Composable | |
| 43 | +fun PackageIcon( | |
| 44 | + iconUrl: String?, | |
| 45 | + creds: TokenStore.Credentials?, | |
| 46 | + size: Dp = 40.dp, | |
| 47 | +) { | |
| 48 | + var bitmap by remember(iconUrl) { mutableStateOf(iconUrl?.let { IconCache.bitmaps[it] }) } | |
| 49 | + | |
| 50 | + LaunchedEffect(iconUrl) { | |
| 51 | + if (iconUrl == null || creds == null || bitmap != null) return@LaunchedEffect | |
| 52 | + bitmap = runCatching { | |
| 53 | + withContext(Dispatchers.IO) { | |
| 54 | + val bytes = GitGudClient.authenticated(creds) | |
| 55 | + .bytes(iconUrl.removePrefix("/")) | |
| 56 | + .use { it.bytes() } | |
| 57 | + BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap() | |
| 58 | + } | |
| 59 | + }.getOrNull()?.also { IconCache.bitmaps[iconUrl] = it } | |
| 60 | + } | |
| 61 | + | |
| 62 | + val shape = RoundedCornerShape(size / 4) | |
| 63 | + bitmap?.let { | |
| 64 | + Image( | |
| 65 | + bitmap = it, | |
| 66 | + contentDescription = null, | |
| 67 | + contentScale = ContentScale.Crop, | |
| 68 | + modifier = Modifier.size(size).clip(shape), | |
| 69 | + ) | |
| 70 | + } ?: Icon(Icons.Filled.Inventory2, contentDescription = null) | |
| 71 | +} |
modified
android/app/src/main/java/org/neiam/gitgud/app/ui/PackageScreen.kt
+31
−1
| 4 | 4 | import androidx.compose.foundation.layout.Arrangement |
| 5 | 5 | import androidx.compose.foundation.layout.Column |
| 6 | 6 | import androidx.compose.foundation.layout.PaddingValues |
| 7 | +import androidx.compose.foundation.layout.Row | |
| 7 | 8 | import androidx.compose.foundation.layout.fillMaxSize |
| 8 | 9 | import androidx.compose.foundation.layout.fillMaxWidth |
| 9 | 10 | import androidx.compose.foundation.layout.padding |
| 72 | 73 | Scaffold( |
| 73 | 74 | topBar = { |
| 74 | 75 | TopAppBar( |
| 75 | − title = { AppBarTitle(pkg) }, | |
| 76 | + title = { AppBarTitle(detail?.title ?: pkg) }, | |
| 76 | 77 | navigationIcon = { |
| 77 | 78 | IconButton(onClick = onBack) { |
| 78 | 79 | Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") |
| 91 | 92 | contentPadding = PaddingValues(12.dp), |
| 92 | 93 | verticalArrangement = Arrangement.spacedBy(8.dp), |
| 93 | 94 | ) { |
| 95 | + item(key = "about") { PackageHeader(detail!!, creds) } | |
| 94 | 96 | items(detail!!.versions, key = { it.version }) { version -> |
| 95 | 97 | val expanded = open == version.version |
| 96 | 98 |
| 167 | 169 | bytes < 1024L * 1024 * 1024 -> String.format("%.1f MB", bytes / 1024.0 / 1024.0) |
| 168 | 170 | else -> String.format("%.1f GB", bytes / 1024.0 / 1024.0 / 1024.0) |
| 169 | 171 | } |
| 172 | + | |
| 173 | +/** | |
| 174 | + * What the package is, above its versions: icon, the name a person | |
| 175 | + * calls it, and whatever the publisher wrote about it. | |
| 176 | + */ | |
| 177 | +@Composable | |
| 178 | +private fun PackageHeader(detail: PackageDetail, creds: TokenStore.Credentials?) { | |
| 179 | + Row( | |
| 180 | + horizontalArrangement = Arrangement.spacedBy(12.dp), | |
| 181 | + modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), | |
| 182 | + ) { | |
| 183 | + PackageIcon(detail.icon_url, creds, size = 56.dp) | |
| 184 | + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { | |
| 185 | + Text(detail.title ?: detail.name, style = MaterialTheme.typography.titleMedium) | |
| 186 | + if (detail.title != null && detail.title != detail.name) { | |
| 187 | + Text(detail.name, style = MaterialTheme.typography.labelSmall) | |
| 188 | + } | |
| 189 | + detail.summary?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } | |
| 190 | + detail.description?.takeIf { it != detail.summary }?.let { | |
| 191 | + Text( | |
| 192 | + it, | |
| 193 | + style = MaterialTheme.typography.bodySmall, | |
| 194 | + modifier = Modifier.padding(top = 4.dp), | |
| 195 | + ) | |
| 196 | + } | |
| 197 | + } | |
| 198 | + } | |
| 199 | +} |
modified
android/app/src/main/java/org/neiam/gitgud/app/ui/PackagesTab.kt
+16
−6
| 22 | 22 | import androidx.compose.material.icons.filled.ContentCopy |
| 23 | 23 | import androidx.compose.material.icons.filled.ExpandLess |
| 24 | 24 | import androidx.compose.material.icons.filled.ExpandMore |
| 25 | −import androidx.compose.material.icons.filled.Inventory2 | |
| 26 | 25 | import androidx.compose.material3.Card |
| 27 | 26 | import androidx.compose.material3.Icon |
| 28 | 27 | import androidx.compose.material3.IconButton |
| 151 | 150 | onToggle = { toggle(section.format) }, |
| 152 | 151 | usage = usage[section.format], |
| 153 | 152 | onOpenPackage = onOpenPackage, |
| 153 | + creds = creds, | |
| 154 | 154 | ) |
| 155 | 155 | } |
| 156 | 156 | } |
| 166 | 166 | onToggle: () -> Unit, |
| 167 | 167 | usage: PackageUsage?, |
| 168 | 168 | onOpenPackage: (format: String, name: String) -> Unit, |
| 169 | + creds: TokenStore.Credentials, | |
| 169 | 170 | ) { |
| 170 | 171 | Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { |
| 171 | 172 | FormatCard( |
| 177 | 178 | ) |
| 178 | 179 | |
| 179 | 180 | section.packages.forEach { pkg -> |
| 181 | + // An app's label heads the row; its package id sits above | |
| 182 | + // it, small, where `org.example.app` is still findable. | |
| 183 | + val overline: (@Composable () -> Unit)? = | |
| 184 | + if (pkg.title != null && pkg.title != pkg.name) { | |
| 185 | + { Text(pkg.name, maxLines = 1) } | |
| 186 | + } else null | |
| 187 | + | |
| 180 | 188 | Card(modifier = Modifier.fillMaxWidth()) { |
| 181 | 189 | Column { |
| 182 | 190 | ListItem( |
| 183 | 191 | modifier = Modifier.clickable { |
| 184 | 192 | onOpenPackage(section.format, pkg.name) |
| 185 | 193 | }, |
| 186 | − headlineContent = { Text(pkg.name) }, | |
| 194 | + headlineContent = { Text(pkg.title ?: pkg.name) }, | |
| 195 | + overlineContent = overline, | |
| 187 | 196 | supportingContent = { |
| 188 | 197 | val version = pkg.latest_version ?: "no versions" |
| 189 | − Text("$version · ${relativeTime(pkg.updated_at)}") | |
| 190 | − }, | |
| 191 | − leadingContent = { | |
| 192 | − Icon(Icons.Filled.Inventory2, contentDescription = null) | |
| 198 | + Column { | |
| 199 | + pkg.summary?.let { Text(it, maxLines = 2) } | |
| 200 | + Text("$version · ${relativeTime(pkg.updated_at)}") | |
| 201 | + } | |
| 193 | 202 | }, |
| 203 | + leadingContent = { PackageIcon(pkg.icon_url, creds) }, | |
| 194 | 204 | ) |
| 195 | 205 | // The line you came here for, right under the name. |
| 196 | 206 | pkg.install_line?.let { CodeLine(it) } |
modified
docs/package-registries.md
+9
−0
Click to load diff…
modified
lib/git_gud/packages.ex
+80
−3
Click to load diff…
added
lib/git_gud/packages/about.ex
+125
−0
Click to load diff…
modified
lib/git_gud/packages/format.ex
+37
−0
Click to load diff…
modified
lib/git_gud/packages/formats/fdroid.ex
+78
−22
Click to load diff…
modified
lib/git_gud/packages/package.ex
+51
−0
Click to load diff…
modified
lib/git_gud_web/controllers/api/mobile/json.ex
+27
−2
Click to load diff…
modified
lib/git_gud_web/controllers/api/mobile/package_controller.ex
+25
−0
Click to load diff…
added
lib/git_gud_web/controllers/package_icon_controller.ex
+54
−0
Click to load diff…
modified
lib/git_gud_web/live/org_live/packages.ex
+293
−67
Click to load diff…
modified
lib/git_gud_web/live/org_live/show.ex
+10
−0
Click to load diff…
modified
lib/git_gud_web/live/repo_live/packages.ex
+159
−3
Click to load diff…
modified
lib/git_gud_web/router.ex
+4
−0
Click to load diff…
added
priv/repo/migrations/20260929174843_add_package_descriptions.exs
+14
−0
Click to load diff…
added
test/git_gud/packages/about_test.exs
+120
−0
Click to load diff…
modified
test/git_gud/packages/fdroid_format_test.exs
+75
−0
Click to load diff…
modified
test/git_gud_web/controllers/api/mobile/mobile_packages_test.exs
+44
−0
Click to load diff…
modified
test/git_gud_web/live/org_packages_catalog_test.exs
+10
−5
Click to load diff…
added
test/git_gud_web/live/package_about_ui_test.exs
+182
−0
Click to load diff…
modified
test/git_gud_web/live/package_retention_ui_test.exs
+1
−1
Click to load diff…
Parents: c09893f