Your avatar on the dashboard and the Account screen (closes #13) db00632a61d6
The hub's /avatar/{name}: an uploaded PNG or JPEG drawn as it is, or the
hub's identicon read from its SVG (hue and cells) and drawn on a Compose
Canvas, so no SVG library; the initial while there is neither. Fetched
without the token, kept for the run (Avatars), refreshed with the
dashboard; a failed fetch keeps the last one. HubApi.avatar, unit tests
against an identicon oxygenhub.io served, the client and the dashboard
view model. Changelog for #12 and #13, README.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 62e60420377a
- When
- · 2026-10-25 11:03 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
db00632a61d620e27d07021cba8b0363b333500c8ca16cf69e5a12763b80da87- Depends on
- 1416009b06af 144cbf7bf2b5 3f3508bd5f16 57c0824300db 5ae4af48ddd2
- Depended on by
- 29a375612f92 263c6cad5e51 840c75813f86 1dfa631436b6 40ab4df121e0 aa21eb5e0e5b e0004d61958c 62429abe5869 98dbd1220929 ff65ea456ff1 4aee5293461d d1f635be9442 e2df27a81e5d 0381bd8af55e
- In bookmarks
- main
- In tags
- v0.1.0 v0.1.1 v0.1.2 v0.1.3 v0.1.4 v0.1.5 v0.1.6 v0.1.7 v0.1.8 v0.1.9
- Edits
- 34 atoms, 4 tree operations, 14 files
Browse the tree at this change
added app/src/main/java/io/oxygenhub/android/api/Avatar.kt +42 −0
| 1 | + | package io.oxygenhub.android.api |
|
| 2 | + | ||
| 3 | + | /** |
|
| 4 | + | * An account's picture as the hub serves it at `/avatar/{name}`: the |
|
| 5 | + | * picture its owner uploaded, or else the identicon the hub draws from the |
|
| 6 | + | * name. The identicon comes as SVG, which Android cannot draw by itself, so |
|
| 7 | + | * it is read into its hue and cells and drawn natively. |
|
| 8 | + | */ |
|
| 9 | + | sealed interface Avatar { |
|
| 10 | + | /** An uploaded PNG or JPEG, as served. */ |
|
| 11 | + | class Picture(val bytes: ByteArray) : Avatar |
|
| 12 | + | ||
| 13 | + | /** |
|
| 14 | + | * The hub's identicon: on a 7 by 7 field of the hue at 45% saturation and |
|
| 15 | + | * 92% lightness, the [cells] (x, y, each 1 to 5) at 65% and 45%. |
|
| 16 | + | */ |
|
| 17 | + | data class Identicon(val hue: Int, val cells: Set<Pair<Int, Int>>) : Avatar |
|
| 18 | + | ||
| 19 | + | companion object { |
|
| 20 | + | private val FIELD = Regex("""<rect width="7" height="7" fill="hsl\((\d{1,3}),45%,92%\)"/>""") |
|
| 21 | + | private val CELL = Regex("""<rect x="([1-5])" y="([1-5])" width="1" height="1"/>""") |
|
| 22 | + | ||
| 23 | + | /** |
|
| 24 | + | * The answer to `/avatar/{name}` by its media type; null for what this |
|
| 25 | + | * app cannot draw (a system account's own mark, or anything unexpected), |
|
| 26 | + | * which then shows as the name's initial. |
|
| 27 | + | */ |
|
| 28 | + | fun parse(contentType: String?, bytes: ByteArray): Avatar? = |
|
| 29 | + | when (contentType?.substringBefore(';')?.trim()?.lowercase()) { |
|
| 30 | + | "image/png", "image/jpeg" -> if (bytes.isEmpty()) null else Picture(bytes) |
|
| 31 | + | "image/svg+xml" -> identicon(bytes.decodeToString()) |
|
| 32 | + | else -> null |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | /** The identicon in the hub's SVG, or null when the SVG is not one. */ |
|
| 36 | + | fun identicon(svg: String): Identicon? { |
|
| 37 | + | val hue = FIELD.find(svg)?.groupValues?.get(1)?.toIntOrNull()?.takeIf { it < 360 } ?: return null |
|
| 38 | + | val cells = CELL.findAll(svg).map { it.groupValues[1].toInt() to it.groupValues[2].toInt() }.toSet() |
|
| 39 | + | return Identicon(hue, cells) |
|
| 40 | + | } |
|
| 41 | + | } |
|
| 42 | + | } |
added app/src/main/java/io/oxygenhub/android/ui/common/AvatarImage.kt +103 −0
| 1 | + | package io.oxygenhub.android.ui.common |
|
| 2 | + | ||
| 3 | + | import android.graphics.BitmapFactory |
|
| 4 | + | import androidx.compose.foundation.Canvas |
|
| 5 | + | import androidx.compose.foundation.Image |
|
| 6 | + | import androidx.compose.foundation.layout.Box |
|
| 7 | + | import androidx.compose.foundation.layout.fillMaxSize |
|
| 8 | + | import androidx.compose.foundation.layout.size |
|
| 9 | + | import androidx.compose.foundation.shape.CircleShape |
|
| 10 | + | import androidx.compose.material3.MaterialTheme |
|
| 11 | + | import androidx.compose.material3.Surface |
|
| 12 | + | import androidx.compose.material3.Text |
|
| 13 | + | import androidx.compose.runtime.Composable |
|
| 14 | + | import androidx.compose.runtime.getValue |
|
| 15 | + | import androidx.compose.runtime.produceState |
|
| 16 | + | import androidx.compose.runtime.remember |
|
| 17 | + | import androidx.compose.ui.Alignment |
|
| 18 | + | import androidx.compose.ui.Modifier |
|
| 19 | + | import androidx.compose.ui.draw.clip |
|
| 20 | + | import androidx.compose.ui.geometry.Offset |
|
| 21 | + | import androidx.compose.ui.geometry.Size |
|
| 22 | + | import androidx.compose.ui.graphics.Color |
|
| 23 | + | import androidx.compose.ui.graphics.asImageBitmap |
|
| 24 | + | import androidx.compose.ui.layout.ContentScale |
|
| 25 | + | import androidx.compose.ui.semantics.contentDescription |
|
| 26 | + | import androidx.compose.ui.semantics.semantics |
|
| 27 | + | import androidx.compose.ui.unit.Dp |
|
| 28 | + | import androidx.compose.ui.unit.dp |
|
| 29 | + | import io.oxygenhub.android.api.Avatar |
|
| 30 | + | import io.oxygenhub.android.api.HubApi |
|
| 31 | + | import kotlinx.coroutines.CancellationException |
|
| 32 | + | import java.util.concurrent.ConcurrentHashMap |
|
| 33 | + | ||
| 34 | + | /** Avatars already fetched this run, by account name: each screen shows the last one at once. */ |
|
| 35 | + | object Avatars { |
|
| 36 | + | private val known = ConcurrentHashMap<String, Avatar>() |
|
| 37 | + | ||
| 38 | + | fun cached(name: String): Avatar? = known[name.lowercase()] |
|
| 39 | + | ||
| 40 | + | /** Fetch `name`'s avatar again; on a failure the one fetched before, if any. */ |
|
| 41 | + | suspend fun load(api: HubApi, name: String): Avatar? { |
|
| 42 | + | val fresh = try { |
|
| 43 | + | api.avatar(name) |
|
| 44 | + | } catch (e: CancellationException) { |
|
| 45 | + | throw e |
|
| 46 | + | } catch (_: Exception) { |
|
| 47 | + | return cached(name) |
|
| 48 | + | } |
|
| 49 | + | if (fresh == null) known.remove(name.lowercase()) else known[name.lowercase()] = fresh |
|
| 50 | + | return fresh |
|
| 51 | + | } |
|
| 52 | + | } |
|
| 53 | + | ||
| 54 | + | /** `name`'s avatar: the cached one first, then the hub's. */ |
|
| 55 | + | @Composable |
|
| 56 | + | fun rememberAvatar(api: HubApi, name: String): Avatar? { |
|
| 57 | + | val avatar by produceState(Avatars.cached(name), api, name) { value = Avatars.load(api, name) } |
|
| 58 | + | return avatar |
|
| 59 | + | } |
|
| 60 | + | ||
| 61 | + | /** |
|
| 62 | + | * An account's avatar in a circle: its picture, the hub's identicon drawn |
|
| 63 | + | * cell by cell, or its initial while there is neither. |
|
| 64 | + | */ |
|
| 65 | + | @Composable |
|
| 66 | + | fun AvatarImage(avatar: Avatar?, name: String, modifier: Modifier = Modifier, size: Dp = 40.dp) { |
|
| 67 | + | val described = modifier.size(size).clip(CircleShape).semantics { contentDescription = "$name's avatar" } |
|
| 68 | + | when (avatar) { |
|
| 69 | + | is Avatar.Picture -> { |
|
| 70 | + | val bitmap = remember(avatar) { |
|
| 71 | + | BitmapFactory.decodeByteArray(avatar.bytes, 0, avatar.bytes.size)?.asImageBitmap() |
|
| 72 | + | } |
|
| 73 | + | if (bitmap == null) { |
|
| 74 | + | Initial(name, size, described) |
|
| 75 | + | } else { |
|
| 76 | + | Image(bitmap, contentDescription = null, contentScale = ContentScale.Crop, modifier = described) |
|
| 77 | + | } |
|
| 78 | + | } |
|
| 79 | + | is Avatar.Identicon -> Canvas(described) { |
|
| 80 | + | // The hub's 7 by 7 field, so the pattern has the same margin. |
|
| 81 | + | val cell = this.size.width / 7f |
|
| 82 | + | drawRect(Color.hsl(avatar.hue.toFloat(), 0.45f, 0.92f)) |
|
| 83 | + | val ink = Color.hsl(avatar.hue.toFloat(), 0.65f, 0.45f) |
|
| 84 | + | for ((x, y) in avatar.cells) { |
|
| 85 | + | drawRect(ink, topLeft = Offset(x * cell, y * cell), size = Size(cell, cell)) |
|
| 86 | + | } |
|
| 87 | + | } |
|
| 88 | + | null -> Initial(name, size, described) |
|
| 89 | + | } |
|
| 90 | + | } |
|
| 91 | + | ||
| 92 | + | @Composable |
|
| 93 | + | private fun Initial(name: String, size: Dp, modifier: Modifier) { |
|
| 94 | + | Surface(modifier, color = MaterialTheme.colorScheme.primaryContainer) { |
|
| 95 | + | Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { |
|
| 96 | + | Text( |
|
| 97 | + | name.take(1).uppercase(), |
|
| 98 | + | style = if (size >= 56.dp) MaterialTheme.typography.headlineMedium else MaterialTheme.typography.titleMedium, |
|
| 99 | + | color = MaterialTheme.colorScheme.onPrimaryContainer, |
|
| 100 | + | ) |
|
| 101 | + | } |
|
| 102 | + | } |
|
| 103 | + | } |
added app/src/test/java/io/oxygenhub/android/api/AvatarTest.kt +37 −0
| 1 | + | package io.oxygenhub.android.api |
|
| 2 | + | ||
| 3 | + | import io.oxygenhub.android.Fixtures |
|
| 4 | + | import org.junit.Assert.assertEquals |
|
| 5 | + | import org.junit.Assert.assertNull |
|
| 6 | + | import org.junit.Assert.assertTrue |
|
| 7 | + | import org.junit.Test |
|
| 8 | + | ||
| 9 | + | class AvatarTest { |
|
| 10 | + | @Test |
|
| 11 | + | fun theHubsIdenticonIsReadIntoItsHueAndCells() { |
|
| 12 | + | // As oxygenhub.io served /avatar/bob (2026-10-25). |
|
| 13 | + | val a = Avatar.parse("image/svg+xml", Fixtures.text("identicon-bob.svg").toByteArray()) as Avatar.Identicon |
|
| 14 | + | assertEquals(196, a.hue) |
|
| 15 | + | assertEquals(16, a.cells.size) |
|
| 16 | + | assertTrue((3 to 2) in a.cells && (1 to 5) in a.cells && (5 to 5) in a.cells) |
|
| 17 | + | // Every cell has its mirror image, as the hub draws them. |
|
| 18 | + | for ((x, y) in a.cells) assertTrue("($x,$y) has no mirror", (6 - x to y) in a.cells) |
|
| 19 | + | } |
|
| 20 | + | ||
| 21 | + | @Test |
|
| 22 | + | fun anUploadedPictureIsKeptAsItCame() { |
|
| 23 | + | val png = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47) |
|
| 24 | + | val a = Avatar.parse("image/png", png) as Avatar.Picture |
|
| 25 | + | assertTrue(a.bytes.contentEquals(png)) |
|
| 26 | + | assertTrue(Avatar.parse("image/jpeg; charset=binary", png) is Avatar.Picture) |
|
| 27 | + | } |
|
| 28 | + | ||
| 29 | + | @Test |
|
| 30 | + | fun whatTheAppCannotDrawIsNoAvatar() { |
|
| 31 | + | // A system account's own mark is SVG, but not the identicon. |
|
| 32 | + | assertNull(Avatar.parse("image/svg+xml", "<svg><circle r=\"3\"/></svg>".toByteArray())) |
|
| 33 | + | assertNull(Avatar.parse("text/html", "<html>".toByteArray())) |
|
| 34 | + | assertNull(Avatar.parse(null, ByteArray(3))) |
|
| 35 | + | assertNull(Avatar.parse("image/png", ByteArray(0))) |
|
| 36 | + | } |
|
| 37 | + | } |
added app/src/test/resources/fixtures/identicon-bob.svg +1 −0
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 7 7" shape-rendering="crispEdges"><rect width="7" height="7" fill="hsl(196,45%,92%)"/><g fill="hsl(196,65%,45%)"><rect x="1" y="1" width="1" height="1"/><rect x="5" y="1" width="1" height="1"/><rect x="2" y="2" width="1" height="1"/><rect x="4" y="2" width="1" height="1"/><rect x="3" y="2" width="1" height="1"/><rect x="1" y="3" width="1" height="1"/><rect x="5" y="3" width="1" height="1"/><rect x="2" y="3" width="1" height="1"/><rect x="4" y="3" width="1" height="1"/><rect x="3" y="3" width="1" height="1"/><rect x="1" y="4" width="1" height="1"/><rect x="5" y="4" width="1" height="1"/><rect x="1" y="5" width="1" height="1"/><rect x="5" y="5" width="1" height="1"/><rect x="2" y="5" width="1" height="1"/><rect x="4" y="5" width="1" height="1"/></g></svg> |
modified CHANGELOG.md +2 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **Your avatar (#13):** the dashboard's greeting and the Account screen show the signed-in account's avatar from the hub's `/avatar/{name}`: an uploaded picture as it is, or the hub's identicon, read from its SVG (hue and cells) and drawn natively, so no SVG library; the initial while there is neither, or the hub cannot be reached. Fetched without the token (it is public), kept for the run, and refreshed with the dashboard. |
|
| 6 | + | - **The keyboard no longer comes up on its own on Repositories (#12):** opening Repositories gave the filter focus and brought the keyboard up on every visit (tapping the navigation bar gives the app's view focus, which Compose hands to the screen's first focusable). The filter now takes focus only when it is touched, or reached from a keyboard. Checked on a Pixel 10 Pro Fold. |
|
| 5 | 7 | - **Sign in, the hub's API, navigation and foldable layouts (#2):** sign in with the hub's device flow, as `oh auth login` does (a code to approve on the hub's `/device` page, with the password and second factor there, never in the app), against oxygenhub.io or any hub; the token sealed with a Keystore key, kept out of backups, and revoked on the hub when signing out. A hand-written Kotlin client for the hub's API: the user, repositories, a repository's issues, the inbox and marking it read. A dashboard (greeting, recent repositories, unread count), repositories with their issues side by side from a medium width up (a foldable open, a tablet), and the inbox with a badge; a navigation bar on a phone and a rail when wider; light and dark, in dynamic colour or the hub's own palette; loading, empty and error states with pull to refresh. The placeholder login (username and password) and tabs are gone. Unit tests for the client (against fixtures from hub-api's types) and the view models; README screenshots rendered from Compose previews without an emulator. |
|
| 6 | 8 | - **The project set up properly (#1):** the application id and package are `io.oxygenhub.android` (permanent), minSdk 31 (Android 12), the Gradle and build caches no longer tracked, `rust-core/` part of the repository (it was a nested, empty Git repository, so it had never been committed), and the Rust core built for arm64-v8a and x86_64 and its Kotlin bindings generated by the build itself (`buildRustCore`, `generateRustBindings`) instead of being committed. The pipeline has no jobs until OxygenFlow has an Android image (oxygenflow#19). |
modified README.md +3 −1
| … | 29 unchanged lines |
||
| 30 | 30 | device token is sealed with a key in the Android Keystore (StrongBox where |
|
| 31 | 31 | the phone has one), never written in the clear, logged or backed up. |
|
| 32 | 32 | Signing out (Account) revokes it on the hub. |
|
| 33 | - | * 🏠 **Dashboard:** a greeting, your newest repositories, the unread count. |
|
| 33 | + | * 🏠 **Dashboard:** a greeting with your avatar, your newest repositories, |
|
| 34 | + | the unread count. Your avatar is the picture you uploaded to the hub, or |
|
| 35 | + | the hub's identicon for you (#13). |
|
| 34 | 36 | * 📚 **Repositories:** yours or all you can see, filtered as you type; a |
|
| 35 | 37 | repository's **issues** (open, closed, all; pinned first). An issue opens on |
|
| 36 | 38 | the hub's web page for now (#4 brings it into the app). |
|
| … | 70 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/api/HubClient.kt +24 −0
| … | 34 unchanged lines |
||
| 35 | 35 | ||
| 36 | 36 | /** Revokes the token this client signs in with (`DELETE /user/token`). */ |
|
| 37 | 37 | suspend fun revokeToken() |
|
| 38 | + | ||
| 39 | + | /** An account's picture (`/avatar/{name}`, public); null when the hub has none this app can draw. */ |
|
| 40 | + | suspend fun avatar(name: String): Avatar? |
|
| 38 | 41 | } |
|
| 39 | 42 | ||
| 40 | 43 | /** |
|
| … | 8 unchanged lines |
||
| 49 | 52 | private val onUnauthorized: () -> Unit = {}, |
|
| 50 | 53 | ) : HubApi { |
|
| 51 | 54 | private val base: HttpUrl = "${hub.trimEnd('/')}/api/v1/".toHttpUrl() |
|
| 55 | + | private val root: HttpUrl = "${hub.trimEnd('/')}/".toHttpUrl() |
|
| 52 | 56 | ||
| 53 | 57 | override suspend fun user(): User = get("user", User.serializer()) |
|
| 54 | 58 | ||
| … | 15 unchanged lines |
||
| 70 | 74 | ||
| 71 | 75 | override suspend fun revokeToken() { |
|
| 72 | 76 | send("DELETE", url("user", "token"), notifyUnauthorized = false) |
|
| 77 | + | } |
|
| 78 | + | ||
| 79 | + | override suspend fun avatar(name: String): Avatar? { |
|
| 80 | + | val url = root.newBuilder().addPathSegment("avatar").addPathSegment(name).build() |
|
| 81 | + | // A public picture: no token goes with it. |
|
| 82 | + | val response = try { |
|
| 83 | + | http.newCall(Request.Builder().url(url).get().build()).await() |
|
| 84 | + | } catch (e: IOException) { |
|
| 85 | + | throw HubException.Network(e) |
|
| 86 | + | } |
|
| 87 | + | response.use { r -> |
|
| 88 | + | if (r.code == 404) return null |
|
| 89 | + | val bytes = try { |
|
| 90 | + | r.body?.bytes() ?: ByteArray(0) |
|
| 91 | + | } catch (e: IOException) { |
|
| 92 | + | throw HubException.Network(e) |
|
| 93 | + | } |
|
| 94 | + | if (!r.isSuccessful) throw HubException.Http(r.code, errorDetail(bytes.decodeToString())) |
|
| 95 | + | return Avatar.parse(r.header("Content-Type"), bytes) |
|
| 96 | + | } |
|
| 73 | 97 | } |
|
| 74 | 98 | ||
| 75 | 99 | // The bearer token is never part of a string anything prints. |
|
| … | 52 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/ui/account/AccountScreen.kt +4 −12
| … | 9 unchanged lines |
||
| 10 | 10 | import androidx.compose.foundation.layout.size |
|
| 11 | 11 | import androidx.compose.foundation.layout.widthIn |
|
| 12 | 12 | import androidx.compose.foundation.rememberScrollState |
|
| 13 | - | import androidx.compose.foundation.shape.CircleShape |
|
| 14 | 13 | import androidx.compose.foundation.verticalScroll |
|
| 15 | 14 | import androidx.compose.material.icons.Icons |
|
| 16 | 15 | import androidx.compose.material.icons.automirrored.outlined.Logout |
|
| … | 7 unchanged lines |
||
| 24 | 23 | import androidx.compose.material3.ListItem |
|
| 25 | 24 | import androidx.compose.material3.MaterialTheme |
|
| 26 | 25 | import androidx.compose.material3.OutlinedButton |
|
| 27 | - | import androidx.compose.material3.Surface |
|
| 28 | 26 | import androidx.compose.material3.Text |
|
| 29 | 27 | import androidx.compose.material3.TextButton |
|
| 30 | 28 | import androidx.compose.runtime.Composable |
|
| … | 7 unchanged lines |
||
| 38 | 36 | import androidx.compose.ui.text.font.FontWeight |
|
| 39 | 37 | import androidx.compose.ui.unit.dp |
|
| 40 | 38 | import io.oxygenhub.android.auth.Session |
|
| 39 | + | import io.oxygenhub.android.api.Avatar |
|
| 41 | 40 | import io.oxygenhub.android.ui.common.AppScreen |
|
| 41 | + | import io.oxygenhub.android.ui.common.AvatarImage |
|
| 42 | 42 | import kotlinx.coroutines.launch |
|
| 43 | 43 | ||
| 44 | 44 | /** Who is signed in, to which hub, with what; and signing out. */ |
|
| 45 | 45 | @Composable |
|
| 46 | - | fun AccountScreen(session: Session, nativeCore: () -> String, onSignOut: suspend () -> Unit) { |
|
| 46 | + | fun AccountScreen(session: Session, avatar: Avatar?, nativeCore: () -> String, onSignOut: suspend () -> Unit) { |
|
| 47 | 47 | var confirming by remember { mutableStateOf(false) } |
|
| 48 | 48 | var signingOut by remember { mutableStateOf(false) } |
|
| 49 | 49 | val scope = rememberCoroutineScope() |
|
| … | 6 unchanged lines |
||
| 56 | 56 | verticalArrangement = Arrangement.spacedBy(16.dp), |
|
| 57 | 57 | ) { |
|
| 58 | 58 | Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { |
|
| 59 | - | Surface(Modifier.size(72.dp), shape = CircleShape, color = MaterialTheme.colorScheme.primaryContainer) { |
|
| 60 | - | Box(contentAlignment = Alignment.Center) { |
|
| 61 | - | Text( |
|
| 62 | - | session.username.take(1).uppercase(), |
|
| 63 | - | style = MaterialTheme.typography.headlineMedium, |
|
| 64 | - | color = MaterialTheme.colorScheme.onPrimaryContainer, |
|
| 65 | - | ) |
|
| 66 | - | } |
|
| 67 | - | } |
|
| 59 | + | AvatarImage(avatar, session.username, size = 72.dp) |
|
| 68 | 60 | Spacer(Modifier.height(8.dp)) |
|
| 69 | 61 | Text(session.username, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) |
|
| 70 | 62 | } |
|
| … | 52 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/ui/dashboard/DashboardScreen.kt +6 −1
| … | 23 unchanged lines |
||
| 24 | 24 | import androidx.compose.material3.TextButton |
|
| 25 | 25 | import androidx.compose.runtime.Composable |
|
| 26 | 26 | import androidx.compose.runtime.getValue |
|
| 27 | + | import androidx.compose.ui.Alignment |
|
| 27 | 28 | import androidx.compose.ui.Modifier |
|
| 28 | 29 | import androidx.compose.ui.graphics.Color |
|
| 29 | 30 | import androidx.compose.ui.text.font.FontWeight |
|
| … | 2 unchanged lines |
||
| 32 | 33 | import io.oxygenhub.android.api.Inbox |
|
| 33 | 34 | import io.oxygenhub.android.api.Repository |
|
| 34 | 35 | import io.oxygenhub.android.ui.common.AppScreen |
|
| 36 | + | import io.oxygenhub.android.ui.common.AvatarImage |
|
| 35 | 37 | import io.oxygenhub.android.ui.common.Load |
|
| 36 | 38 | import io.oxygenhub.android.ui.common.LoadContent |
|
| 37 | 39 | import io.oxygenhub.android.ui.repos.RepositoryRow |
|
| … | 33 unchanged lines |
||
| 71 | 73 | .verticalScroll(rememberScrollState()) |
|
| 72 | 74 | .padding(16.dp), |
|
| 73 | 75 | ) { |
|
| 74 | - | Text(d.greeting, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) |
|
| 76 | + | Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { |
|
| 77 | + | AvatarImage(d.avatar, d.username, size = 48.dp) |
|
| 78 | + | Text(d.greeting, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) |
|
| 79 | + | } |
|
| 75 | 80 | Spacer(Modifier.height(16.dp)) |
|
| 76 | 81 | if (wide) { |
|
| 77 | 82 | Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { |
|
| … | 64 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/ui/dashboard/DashboardViewModel.kt +10 −0
| 1 | 1 | package io.oxygenhub.android.ui.dashboard |
|
| 2 | 2 | ||
| 3 | 3 | import androidx.lifecycle.ViewModel |
|
| 4 | + | import io.oxygenhub.android.api.Avatar |
|
| 4 | 5 | import io.oxygenhub.android.api.HubApi |
|
| 5 | 6 | import io.oxygenhub.android.api.Repository |
|
| 6 | 7 | import io.oxygenhub.android.api.User |
|
| 7 | 8 | import io.oxygenhub.android.auth.Session |
|
| 9 | + | import io.oxygenhub.android.ui.common.Avatars |
|
| 8 | 10 | import io.oxygenhub.android.ui.common.Load |
|
| 9 | 11 | import io.oxygenhub.android.ui.common.load |
|
| 10 | 12 | import kotlinx.coroutines.CancellationException |
|
| … | 9 unchanged lines |
||
| 20 | 22 | val greeting: String, |
|
| 21 | 23 | /** The account, or null when the token may not read it (no `user:read`). */ |
|
| 22 | 24 | val user: User?, |
|
| 25 | + | /** The account's avatar (#13), or null while the hub has none this app can draw. */ |
|
| 26 | + | val avatar: Avatar? = null, |
|
| 27 | + | /** Whose avatar: the signed-in account's name. */ |
|
| 28 | + | val username: String = "", |
|
| 23 | 29 | /** The newest-updated repositories: the user's own, or everyone's when they own none. */ |
|
| 24 | 30 | val recent: List<Repository>, |
|
| 25 | 31 | val recentAreYours: Boolean, |
|
| … | 29 unchanged lines |
||
| 55 | 61 | null |
|
| 56 | 62 | } |
|
| 57 | 63 | } |
|
| 64 | + | // As is the avatar: its failure leaves the last one, or the initial. |
|
| 65 | + | val avatar = async { Avatars.load(api, session.username) } |
|
| 58 | 66 | val repos = api.repositories() |
|
| 59 | 67 | val me = user.await() |
|
| 60 | 68 | val username = me?.username ?: session.username |
|
| … | 2 unchanged lines |
||
| 63 | 71 | Dashboard( |
|
| 64 | 72 | greeting = greeting(hourOfDay(), me?.shownName ?: session.username), |
|
| 65 | 73 | user = me, |
|
| 74 | + | avatar = avatar.await(), |
|
| 75 | + | username = username, |
|
| 66 | 76 | recent = pool.sortedByDescending { it.updatedMs }.take(RECENT), |
|
| 67 | 77 | recentAreYours = mine.isNotEmpty(), |
|
| 68 | 78 | repositoryCount = mine.size, |
|
| … | 16 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/ui/nav/AppNavigation.kt +2 −1
| … | 33 unchanged lines |
||
| 34 | 34 | import io.oxygenhub.android.api.HubApi |
|
| 35 | 35 | import io.oxygenhub.android.auth.Session |
|
| 36 | 36 | import io.oxygenhub.android.ui.account.AccountScreen |
|
| 37 | + | import io.oxygenhub.android.ui.common.rememberAvatar |
|
| 37 | 38 | import io.oxygenhub.android.ui.dashboard.DashboardScreen |
|
| 38 | 39 | import io.oxygenhub.android.ui.dashboard.DashboardViewModel |
|
| 39 | 40 | import io.oxygenhub.android.ui.inbox.InboxScreen |
|
| … | 97 unchanged lines |
||
| 137 | 138 | IssuesScreen(rememberViewModel { IssuesViewModel(api, r.owner, r.name) }, onBack = { nav.popBackStack() }) |
|
| 138 | 139 | } |
|
| 139 | 140 | composable<InboxRoute> { InboxScreen(inbox) } |
|
| 140 | - | composable<AccountRoute> { AccountScreen(session, nativeCore, onSignOut) } |
|
| 141 | + | composable<AccountRoute> { AccountScreen(session, rememberAvatar(api, session.username), nativeCore, onSignOut) } |
|
| 141 | 142 | } |
|
| 142 | 143 | } |
|
| 143 | 144 | } |
|
| … | 9 unchanged lines |
modified app/src/test/java/io/oxygenhub/android/FakeHubApi.kt +3 −0
| 1 | 1 | package io.oxygenhub.android |
|
| 2 | 2 | ||
| 3 | + | import io.oxygenhub.android.api.Avatar |
|
| 3 | 4 | import io.oxygenhub.android.api.HubApi |
|
| 4 | 5 | import io.oxygenhub.android.api.Inbox |
|
| 5 | 6 | import io.oxygenhub.android.api.Issue |
|
| … | 12 unchanged lines |
||
| 18 | 19 | var markRead: (Long) -> Unit = {} |
|
| 19 | 20 | var markAllRead: () -> Unit = {} |
|
| 20 | 21 | var revoke: () -> Unit = {} |
|
| 22 | + | var avatar: (String) -> Avatar? = { null } |
|
| 21 | 23 | ||
| 22 | 24 | val calls = mutableListOf<String>() |
|
| 23 | 25 | ||
| … | 10 unchanged lines |
||
| 34 | 36 | calls += "read all" |
|
| 35 | 37 | markAllRead.invoke() |
|
| 36 | 38 | } |
|
| 39 | + | override suspend fun avatar(name: String): Avatar? = avatar.invoke(name).also { calls += "avatar $name" } |
|
| 37 | 40 | override suspend fun revokeToken() { |
|
| 38 | 41 | calls += "revoke" |
|
| 39 | 42 | revoke.invoke() |
|
| … | 2 unchanged lines |
modified app/src/test/java/io/oxygenhub/android/api/HubClientTest.kt +14 −0
| … | 7 unchanged lines |
||
| 8 | 8 | import okhttp3.mockwebserver.SocketPolicy |
|
| 9 | 9 | import org.junit.After |
|
| 10 | 10 | import org.junit.Assert.assertEquals |
|
| 11 | + | import org.junit.Assert.assertNull |
|
| 11 | 12 | import org.junit.Assert.assertFalse |
|
| 12 | 13 | import org.junit.Assert.assertTrue |
|
| 13 | 14 | import org.junit.Assert.fail |
|
| … | 36 unchanged lines |
||
| 50 | 51 | } |
|
| 51 | 52 | ||
| 52 | 53 | @Test |
|
| 54 | + | fun anAvatarIsFetchedWithoutTheToken() = runTest { |
|
| 55 | + | server.enqueue(MockResponse().setHeader("Content-Type", "image/svg+xml").setBody(Fixtures.text("identicon-bob.svg"))) |
|
| 56 | + | val a = client().avatar("bob") |
|
| 57 | + | assertEquals(196, (a as Avatar.Identicon).hue) |
|
| 58 | + | val req = server.takeRequest() |
|
| 59 | + | assertEquals("/avatar/bob", req.path) |
|
| 60 | + | assertNull(req.getHeader("Authorization")) |
|
| 61 | + | server.enqueue(MockResponse().setResponseCode(404)) |
|
| 62 | + | assertNull(client().avatar("nobody")) |
|
| 63 | + | assertEquals(0, unauthorized) |
|
| 64 | + | } |
|
| 65 | + | ||
| 66 | + | @Test |
|
| 53 | 67 | fun repositories() = runTest { |
|
| 54 | 68 | server.enqueue(json("repos.json")) |
|
| 55 | 69 | assertEquals(3, client().repositories().size) |
|
| … | 114 unchanged lines |
modified app/src/test/java/io/oxygenhub/android/ui/DashboardViewModelTest.kt +14 −0
| … | 2 unchanged lines |
||
| 3 | 3 | import io.oxygenhub.android.FakeHubApi |
|
| 4 | 4 | import io.oxygenhub.android.Fixtures |
|
| 5 | 5 | import io.oxygenhub.android.MainDispatcherRule |
|
| 6 | + | import io.oxygenhub.android.api.Avatar |
|
| 6 | 7 | import io.oxygenhub.android.api.HubException |
|
| 7 | 8 | import io.oxygenhub.android.auth.Session |
|
| 8 | 9 | import io.oxygenhub.android.ui.common.Load |
|
| … | 24 unchanged lines |
||
| 33 | 34 | } |
|
| 34 | 35 | ||
| 35 | 36 | @Test |
|
| 37 | + | fun showsTheAccountsAvatarAndKeepsItWhenTheHubFails() { |
|
| 38 | + | val mine = Avatar.Identicon(196, setOf(3 to 3)) |
|
| 39 | + | api.avatar = { if (it == "andymccall") mine else null } |
|
| 40 | + | val vm = DashboardViewModel(api, session) { 9 } |
|
| 41 | + | assertEquals(mine, ready(vm).avatar) |
|
| 42 | + | assertEquals("andymccall", ready(vm).username) |
|
| 43 | + | // A failed fetch keeps the one before; the page still loads. |
|
| 44 | + | api.avatar = { throw HubException.Http(500, "down") } |
|
| 45 | + | vm.refresh() |
|
| 46 | + | assertEquals(mine, ready(vm).avatar) |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | @Test |
|
| 36 | 50 | fun withNoRepositoriesOfTheirOwnShowsTheHubs() { |
|
| 37 | 51 | val d = ready(DashboardViewModel(api, session.copy(username = "newcomer")) { 14 }.also { |
|
| 38 | 52 | api.user = { Fixtures.user.copy(username = "newcomer", displayName = "") } |
|
| … | 33 unchanged lines |