OXYGENHUB

Screenshots from Compose previews, and what rendering them showed (refs #2) 0a2858c0dad3

Compose Preview Screenshot Testing renders the app with layoutlib, no
emulator: sign-in, the dashboard on a phone, repositories and issues side
by side on an open foldable and a tablet, the inbox dark, from a sample hub
(scripts/screenshots.sh, then copied to docs/screenshots). The plugin's
own reference folder is ignored: the greeting and relative times move
with the clock, so it is no baseline.

Rendering showed selected chips and rows in Material's lavender (the
hub's schemes now set the secondary container), the device code in the
muted colour, and list rows painting over their cards (now transparent).
The signed-in app can start at a destination with a repository selected.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key f4ec0f2e7863
When
· 2026-10-24 22:32 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
0a2858c0dad3080a6f62d02070da227f0c6b25b86ea11e199f2d047f5d656a03
Depends on
144cbf7bf2b5 206d05a042e7 57c0824300db 5ae4af48ddd2 9481bfa7c5d0 e0e4a1c48a77 f821ee741b9f
Depended on by
aa627197ec18 821035db5a1c dce8ebd2482a bfa2eff05d35 8a657957d5a0 98dbd1220929 ff65ea456ff1 4aee5293461d d1f635be9442 0381bd8af55e 3f3508bd5f16
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
31 atoms, 7 tree operations, 14 files

Browse the tree at this change

added app/src/screenshotTest/kotlin/io/oxygenhub/android/Screenshots.kt +128 −0
1 +
package io.oxygenhub.android
2 +
3 +
import android.content.res.Configuration
4 +
import androidx.compose.runtime.Composable
5 +
import androidx.compose.runtime.remember
6 +
import androidx.compose.ui.tooling.preview.Preview
7 +
import com.android.tools.screenshot.PreviewTest
8 +
import io.oxygenhub.android.api.DeviceCode
9 +
import io.oxygenhub.android.api.HubApi
10 +
import io.oxygenhub.android.api.Inbox
11 +
import io.oxygenhub.android.api.Issue
12 +
import io.oxygenhub.android.api.IssueState
13 +
import io.oxygenhub.android.api.Label
14 +
import io.oxygenhub.android.api.Notification
15 +
import io.oxygenhub.android.api.Repository
16 +
import io.oxygenhub.android.api.User
17 +
import io.oxygenhub.android.api.Visibility
18 +
import io.oxygenhub.android.auth.Session
19 +
import io.oxygenhub.android.ui.nav.SignedInApp
20 +
import io.oxygenhub.android.ui.nav.TopLevel
21 +
import io.oxygenhub.android.ui.signin.SignInContent
22 +
import io.oxygenhub.android.ui.signin.SignInState
23 +
import io.oxygenhub.android.ui.theme.OxygenHubTheme
24 +
import kotlinx.coroutines.Dispatchers
25 +
import kotlinx.coroutines.test.setMain
26 +
27 +
// The README's screenshots, rendered by layoutlib with no emulator:
28 +
//   ./gradlew :app:updateDebugScreenshotTest
29 +
// writes them under app/src/screenshotTestDebug/reference; scripts/screenshots.sh
30 +
// copies them to docs/screenshots. The data is made up.
31 +
32 +
private const val PHONE = "spec:width=411dp,height=891dp"
33 +
private const val FOLDABLE = "spec:width=791dp,height=820dp"
34 +
private const val TABLET = "spec:width=1280dp,height=800dp,dpi=240"
35 +
36 +
private val hour = 3_600_000L
37 +
private val day = 24 * hour
38 +
39 +
/** A hub in memory, for the pictures. */
40 +
private class SampleHub : HubApi {
41 +
    private val now = System.currentTimeMillis()
42 +
43 +
    override suspend fun user() = User("ada", "Ada Lovelace")
44 +
45 +
    override suspend fun repositories() = listOf(
46 +
        Repository("ada", "engine", "The analytical engine's notes, in Oxygen", visibility = Visibility.Public, updatedMs = now - 2 * hour),
47 +
        Repository("ada", "bernoulli", "Computing Bernoulli numbers, note G", visibility = Visibility.Private, updatedMs = now - 1 * day),
48 +
        Repository("ada", "punch-cards", "Card decks for the Jacquard loom", updatedMs = now - 3 * day),
49 +
        Repository("babbage", "difference-engine", "Engine No. 2", visibility = Visibility.Internal, updatedMs = now - 5 * hour),
50 +
        Repository("ada", "letters", "Correspondence, sealed and signed", updatedMs = now - 12 * day),
51 +
    )
52 +
53 +
    override suspend fun issues(owner: String, name: String, state: IssueState) = listOf(
54 +
        Issue(
55 +
            42, "Note G: the loop that computes B7", author = "ada", pinned = true, comments = 4,
56 +
            labels = listOf(Label("feature", "#0064e0")), createdMs = now - 3 * hour,
57 +
        ),
58 +
        Issue(41, "The mill overflows on forty-digit carries", author = "babbage", comments = 2, labels = listOf(Label("bug", "#c62d47")), createdMs = now - 1 * day),
59 +
        Issue(39, "Describe the store's capacity in the README", author = "ada", labels = listOf(Label("docs", "#00b8d4")), createdMs = now - 4 * day),
60 +
        Issue(37, "Punch-card reader skips every hundredth card", author = "menabrea", comments = 7, createdMs = now - 9 * day),
61 +
    ).filter { state == IssueState.All || it.state == state.query }
62 +
63 +
    override suspend fun inbox(limit: Int) = Inbox(
64 +
        unread = 3,
65 +
        items = listOf(
66 +
            Notification(5, now - 20 * 60_000L, "proposal.review_requested", "Carry look-ahead for the mill", actor = "babbage"),
67 +
            Notification(4, now - 2 * hour, "flow.failed", "engine main: tests failed"),
68 +
            Notification(3, now - 5 * hour, "issue.comment", "Note G: the loop that computes B7", actor = "menabrea"),
69 +
            Notification(2, now - 1 * day, "proposal.landed", "Store of a thousand numbers", actor = "ada", read = true),
70 +
            Notification(1, now - 2 * day, "issue.assigned", "The mill overflows on forty-digit carries", actor = "babbage", read = true),
71 +
        ),
72 +
    )
73 +
74 +
    override suspend fun markRead(id: Long) {}
75 +
    override suspend fun markAllRead() {}
76 +
    override suspend fun revokeToken() {}
77 +
}
78 +
79 +
private val session = Session("https://oxygenhub.io", "ada", "", listOf("repo:read", "user:read"))
80 +
81 +
@Composable
82 +
private fun App(start: TopLevel, selected: String? = null, dark: Boolean = false) {
83 +
    // layoutlib never runs the main looper, so the view models' first load
84 +
    // would never land: run it in place (the sample hub never suspends).
85 +
    remember { Dispatchers.setMain(Dispatchers.Unconfined) }
86 +
    OxygenHubTheme(darkTheme = dark, dynamicColor = false) {
87 +
        SignedInApp(session, SampleHub(), { "Hello, ada! Oxygen Native Core is active." }, {}, start, selected)
88 +
    }
89 +
}
90 +
91 +
@PreviewTest
92 +
@Preview(name = "sign-in", device = PHONE, showSystemUi = false)
93 +
@Composable
94 +
fun SignInPhone() {
95 +
    OxygenHubTheme(dynamicColor = false) {
96 +
        SignInContent("https://oxygenhub.io", SignInState.Idle(), null, {}, {}, {})
97 +
    }
98 +
}
99 +
100 +
@PreviewTest
101 +
@Preview(name = "sign-in-code", device = PHONE, uiMode = Configuration.UI_MODE_NIGHT_YES)
102 +
@Composable
103 +
fun SignInCodeDark() {
104 +
    val code = DeviceCode("", "BCDF-GHJK", "https://oxygenhub.io/device", "https://oxygenhub.io/device?code=BCDF-GHJK")
105 +
    OxygenHubTheme(darkTheme = true, dynamicColor = false) {
106 +
        SignInContent("https://oxygenhub.io", SignInState.Approving(code), null, {}, {}, {})
107 +
    }
108 +
}
109 +
110 +
@PreviewTest
111 +
@Preview(name = "dashboard-phone", device = PHONE)
112 +
@Composable
113 +
fun DashboardPhone() = App(TopLevel.Dashboard)
114 +
115 +
@PreviewTest
116 +
@Preview(name = "repositories-foldable", device = FOLDABLE)
117 +
@Composable
118 +
fun RepositoriesFoldable() = App(TopLevel.Repositories, selected = "ada/engine")
119 +
120 +
@PreviewTest
121 +
@Preview(name = "repositories-tablet-dark", device = TABLET, uiMode = Configuration.UI_MODE_NIGHT_YES)
122 +
@Composable
123 +
fun RepositoriesTabletDark() = App(TopLevel.Repositories, selected = "ada/engine", dark = true)
124 +
125 +
@PreviewTest
126 +
@Preview(name = "inbox-phone-dark", device = PHONE, uiMode = Configuration.UI_MODE_NIGHT_YES)
127 +
@Composable
128 +
fun InboxPhoneDark() = App(TopLevel.Inbox, dark = true)
added scripts/screenshots.sh +14 −0
1 +
#!/usr/bin/env bash
2 +
# Renders the README's screenshots from the previews in app/src/screenshotTest
3 +
# (layoutlib: no emulator or device) and copies them to docs/screenshots.
4 +
set -euo pipefail
5 +
cd "$(dirname "$0")/.."
6 +
./gradlew --max-workers=2 :app:updateDebugScreenshotTest
7 +
src=app/src/screenshotTestDebug/reference/io/oxygenhub/android/ScreenshotsKt
8 +
mkdir -p docs/screenshots
9 +
for f in "$src"/*.png; do
10 +
    # SignInPhone_sign-in_0eb2062e_0.png -> sign-in.png
11 +
    name=$(basename "$f" | sed -E 's/^[^_]+_(.+)_[0-9a-f]+_[0-9]+\.png$/\1/')
12 +
    cp "$f" "docs/screenshots/$name.png"
13 +
done
14 +
ls docs/screenshots
modified .oxyignore +4 −0
…
18 unchanged lines
19 19
/.idea/navEditor.xml
20 20
/.idea/assetWizardSettings.xml
21 21
.DS_Store
22 +
23 +
# Compose screenshot references: the README copies live in docs/screenshots
24 +
# (scripts/screenshots.sh); these change with the clock, so are not kept.
25 +
/app/src/screenshotTestDebug/
modified app/build.gradle.kts +7 −0
…
4 unchanged lines
5 5
    alias(libs.plugins.android.application)
6 6
    alias(libs.plugins.kotlin.compose)
7 7
    alias(libs.plugins.kotlin.serialization)
8 +
    alias(libs.plugins.screenshot)
8 9
}
9 10
10 11
// The Rust core (rust-core/, over oxy-core) is built for the app's ABIs by
…
74 unchanged lines
85 86
    buildFeatures {
86 87
        compose = true
87 88
    }
89 +
    // README screenshots, rendered from previews without an emulator:
90 +
    // ./gradlew :app:updateDebugScreenshotTest (src/screenshotTest).
91 +
    experimentalProperties["android.experimental.enableScreenshotTest"] = true
88 92
    sourceSets["main"].jniLibs.directories.add(rustJniLibs.get().asFile.path)
89 93
    sourceSets["main"].kotlin.directories.add(rustBindings.get().asFile.path)
90 94
}
…
26 unchanged lines
117 121
    androidTestImplementation(libs.androidx.compose.ui.test.junit4)
118 122
    androidTestImplementation(libs.androidx.espresso.core)
119 123
    androidTestImplementation(libs.androidx.junit)
124 +
    screenshotTestImplementation(libs.screenshot.validation.api)
125 +
    screenshotTestImplementation(libs.androidx.compose.ui.tooling)
126 +
    screenshotTestImplementation(libs.kotlinx.coroutines.test)
120 127
    debugImplementation(libs.androidx.compose.ui.test.manifest)
121 128
    debugImplementation(libs.androidx.compose.ui.tooling)
122 129
}
modified app/src/main/java/io/oxygenhub/android/ui/dashboard/DashboardScreen.kt +3 −0
…
17 unchanged lines
18 18
import androidx.compose.material3.HorizontalDivider
19 19
import androidx.compose.material3.Icon
20 20
import androidx.compose.material3.ListItem
21 +
import androidx.compose.material3.ListItemDefaults
21 22
import androidx.compose.material3.MaterialTheme
22 23
import androidx.compose.material3.Text
23 24
import androidx.compose.material3.TextButton
24 25
import androidx.compose.runtime.Composable
25 26
import androidx.compose.runtime.getValue
26 27
import androidx.compose.ui.Modifier
28 +
import androidx.compose.ui.graphics.Color
27 29
import androidx.compose.ui.text.font.FontWeight
28 30
import androidx.compose.ui.unit.dp
29 31
import androidx.lifecycle.compose.collectAsStateWithLifecycle
…
59 unchanged lines
89 91
private fun Unread(inbox: Load<Inbox>, onInbox: () -> Unit) {
90 92
    Card(Modifier.fillMaxWidth().clickable(onClick = onInbox)) {
91 93
        ListItem(
94 +
            colors = ListItemDefaults.colors(containerColor = Color.Transparent),
92 95
            leadingContent = {
93 96
                Icon(if ((inbox.valueOrNull?.unread ?: 0) > 0) Icons.Outlined.MarkEmailUnread else Icons.Outlined.Inbox, contentDescription = null)
94 97
            },
…
44 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/nav/AppNavigation.kt +5 −2
…
79 unchanged lines
80 80
    api: HubApi,
81 81
    nativeCore: () -> String,
82 82
    onSignOut: suspend () -> Unit,
83 +
    start: TopLevel = TopLevel.Dashboard,
84 +
    /** A repository to show beside the list from the start (`owner/name`). */
85 +
    selectedRepository: String? = null,
83 86
) {
84 87
    val nav = rememberNavController()
85 88
    // One inbox for the badge, the dashboard's count and the inbox screen.
…
25 unchanged lines
111 114
            }
112 115
        },
113 116
    ) {
114 -
        NavHost(nav, startDestination = DashboardRoute) {
117 +
        NavHost(nav, startDestination = start.route) {
115 118
            composable<DashboardRoute> {
116 119
                DashboardScreen(
117 120
                    vm = rememberViewModel { DashboardViewModel(api, session) },
…
6 unchanged lines
124 127
                )
125 128
            }
126 129
            composable<RepositoriesRoute> {
127 -
                RepositoriesScreen(rememberViewModel { RepositoriesViewModel(api, session.username) }) { slug, onBack ->
130 +
                RepositoriesScreen(rememberViewModel { RepositoriesViewModel(api, session.username) }, selectedRepository) { slug, onBack ->
128 131
                    val (owner, name) = slug.split('/', limit = 2)
129 132
                    IssuesScreen(rememberViewModel(key = "issues:$slug") { IssuesViewModel(api, owner, name) }, onBack)
130 133
                }
…
19 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/repos/RepositoriesScreen.kt +9 −1
…
21 unchanged lines
22 22
import androidx.compose.material3.adaptive.layout.AnimatedPane
23 23
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
24 24
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
25 +
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldDestinationItem
25 26
import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
26 27
import androidx.compose.material3.adaptive.navigation.NavigableListDetailPaneScaffold
27 28
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
…
20 unchanged lines
48 49
@Composable
49 50
fun RepositoriesScreen(
50 51
    vm: RepositoriesViewModel,
52 +
    initialSelection: String? = null,
51 53
    detail: @Composable (slug: String, onBack: (() -> Unit)?) -> Unit,
52 54
) {
53 55
    val info = currentWindowAdaptiveInfo()
54 56
    val base = calculatePaneScaffoldDirective(info)
55 57
    val twoPanes = info.windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND)
56 58
    val directive = if (twoPanes && base.maxHorizontalPartitions < 2) base.copy(maxHorizontalPartitions = 2) else base
57 -
    val navigator = rememberListDetailPaneScaffoldNavigator<String>(scaffoldDirective = directive)
59 +
    val navigator = rememberListDetailPaneScaffoldNavigator(
60 +
        scaffoldDirective = directive,
61 +
        initialDestinationHistory = listOfNotNull(
62 +
            ThreePaneScaffoldDestinationItem<String>(ListDetailPaneScaffoldRole.List),
63 +
            initialSelection?.let { ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.Detail, it) },
64 +
        ),
65 +
    )
58 66
    val scope = rememberCoroutineScope()
59 67
    val selected = navigator.currentDestination?.contentKey
60 68
    val detailShownAlone = navigator.scaffoldValue[ListDetailPaneScaffoldRole.List] == PaneAdaptedValue.Hidden
…
65 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/repos/RepositoryRow.kt +3 −1
…
16 unchanged lines
17 17
import androidx.compose.runtime.Composable
18 18
import androidx.compose.ui.Alignment
19 19
import androidx.compose.ui.Modifier
20 +
import androidx.compose.ui.graphics.Color
20 21
import androidx.compose.ui.text.style.TextOverflow
21 22
import androidx.compose.ui.unit.dp
22 23
import io.oxygenhub.android.api.Repository
…
7 unchanged lines
30 31
        colors = if (selected) {
31 32
            ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.secondaryContainer)
32 33
        } else {
33 -
            ListItemDefaults.colors()
34 +
            // Transparent: the row takes the colour of what holds it (a card, a pane).
35 +
            ListItemDefaults.colors(containerColor = Color.Transparent)
34 36
        },
35 37
        leadingContent = { Icon(Icons.Outlined.Book, contentDescription = null) },
36 38
        headlineContent = { Text(repo.slug, maxLines = 1, overflow = TextOverflow.Ellipsis) },
…
18 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/signin/SignInScreen.kt +1 −0
…
144 unchanged lines
145 145
    ) {
146 146
        Text(
147 147
            code.userCode,
148 +
            color = MaterialTheme.colorScheme.onSurface,
148 149
            fontFamily = FontFamily.Monospace,
149 150
            fontSize = 34.sp,
150 151
            fontWeight = FontWeight.Bold,
…
29 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/theme/Color.kt +4 −0
…
18 unchanged lines
19 19
val DarkBad = Color(0xFFFF5E7A)
20 20
val DarkWarn = Color(0xFFB48CFF)
21 21
22 +
// Selected chips and rows: the accent, quietly.
23 +
val DarkSelected = Color(0xFF123A66)
24 +
22 25
// Light
23 26
val LightBackground = Color(0xFFF4F7FC)
24 27
val LightSurface = Color(0xFFFFFFFF)
…
7 unchanged lines
32 35
val LightOk = Color(0xFF1A7850)
33 36
val LightBad = Color(0xFFC62D47)
34 37
val LightWarn = Color(0xFF6B3FD1)
38 +
val LightSelected = Color(0xFFD7E5FB)
modified app/src/main/java/io/oxygenhub/android/ui/theme/Theme.kt +4 −0
…
19 unchanged lines
20 20
    onPrimaryContainer = Color.White,
21 21
    secondary = DarkAccent2,
22 22
    onSecondary = Color.White,
23 +
    secondaryContainer = DarkSelected,
24 +
    onSecondaryContainer = DarkInk,
23 25
    tertiary = DarkWarn,
24 26
    background = DarkBackground,
25 27
    onBackground = DarkInk,
…
18 unchanged lines
44 46
    onPrimaryContainer = Color.White,
45 47
    secondary = LightAccent2,
46 48
    onSecondary = Color.White,
49 +
    secondaryContainer = LightSelected,
50 +
    onSecondaryContainer = LightInk,
47 51
    tertiary = LightWarn,
48 52
    background = LightBackground,
49 53
    onBackground = LightInk,
…
41 unchanged lines
modified build.gradle.kts +1 −0
…
2 unchanged lines
3 3
    alias(libs.plugins.android.application) apply false
4 4
    alias(libs.plugins.kotlin.compose) apply false
5 5
    alias(libs.plugins.kotlin.serialization) apply false
6 +
    alias(libs.plugins.screenshot) apply false
6 7
}
modified gradle.properties +2 −0
…
16 unchanged lines
17 17
org.gradle.configuration-cache=true
18 18
# Kotlin code style for this project: "official" or "obsolete":
19 19
kotlin.code.style=official
20 +
# Compose Preview Screenshot Testing (the README's screenshots).
21 +
android.experimental.enableScreenshotTest=true
modified gradle/libs.versions.toml +3 −0
…
13 unchanged lines
14 14
coroutines = "1.10.2"
15 15
okhttp = "4.12.0"
16 16
jna = "5.17.0"
17 +
screenshot = "0.0.1-alpha16"
17 18
18 19
[libraries]
19 20
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
…
23 unchanged lines
43 44
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
44 45
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
45 46
jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" }
47 +
screenshot-validation-api = { group = "com.android.tools.screenshot", name = "screenshot-validation-api", version.ref = "screenshot" }
46 48
okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" }
47 49
48 50
[plugins]
49 51
android-application = { id = "com.android.application", version.ref = "agp" }
50 52
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
51 53
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
54 +
screenshot = { id = "com.android.compose.screenshot", version.ref = "screenshot" }