OXYGENHUB

The widget's foot: a white mark and title, counts of unread, reviews and assigned issues that open their screens, and the phone's runner job; Assigned to you on the dashboard (closes #31) aa627197ec18

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 730ae5f0857e
When
· 2026-11-01 14:55 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
aa627197ec1885059640366c5bdc96ed9be411b7d0710a0a3e156801d69dd968
Depends on
0a2858c0dad3 0b152017dee3 144cbf7bf2b5 1dfa631436b6 41e2e862abce 4aee5293461d 57c0824300db 5ae4af48ddd2 62429abe5869 6823c243eaac 6e2af63ae616 797eca1a3e64 8440d51a2275 b610bc6eeab7 d1f635be9442 dfbc3e972872 e0e4a1c48a77 e2df27a81e5d ff65ea456ff1
Depended on by
cccf06468345 fb420bbdb058 943c54cf6a56 263c6cad5e51 4a6d7ccd0958
In bookmarks
main
In tags
v0.1.3 v0.1.4 v0.1.5 v0.1.6 v0.1.7 v0.1.8 v0.1.9
Edits
63 atoms, 4 tree operations, 15 files

Browse the tree at this change

added app/src/main/res/drawable/widget_inbox.xml +9 −0
1 +
<?xml version="1.0" encoding="utf-8"?>
2 +
<!-- An envelope, in the hub's 16-unit line icons: the widget's unread count (#31). -->
3 +
<vector xmlns:android="http://schemas.android.com/apk/res/android"
4 +
    android:width="16dp" android:height="16dp" android:viewportWidth="16" android:viewportHeight="16">
5 +
    <path android:strokeColor="#FFFFFFFF" android:strokeWidth="1.4" android:strokeLineCap="round" android:strokeLineJoin="round"
6 +
        android:pathData="M3.5,3.5H12.5A1.5,1.5 0,0 1,14 5V11A1.5,1.5 0,0 1,12.5 12.5H3.5A1.5,1.5 0,0 1,2 11V5A1.5,1.5 0,0 1,3.5 3.5Z" />
7 +
    <path android:strokeColor="#FFFFFFFF" android:strokeWidth="1.4" android:strokeLineCap="round" android:strokeLineJoin="round"
8 +
        android:pathData="M2.6,4.6L8,8.8L13.4,4.6" />
9 +
</vector>
added app/src/main/res/drawable/widget_issue.xml +9 −0
1 +
<?xml version="1.0" encoding="utf-8"?>
2 +
<!-- An open issue (a ring with its dot), in the hub's 16-unit line icons: the widget's assigned count (#31). -->
3 +
<vector xmlns:android="http://schemas.android.com/apk/res/android"
4 +
    android:width="16dp" android:height="16dp" android:viewportWidth="16" android:viewportHeight="16">
5 +
    <path android:strokeColor="#FFFFFFFF" android:strokeWidth="1.4"
6 +
        android:pathData="M2,8a6,6 0,1 0,12 0a6,6 0,1 0,-12 0" />
7 +
    <path android:fillColor="#FFFFFFFF"
8 +
        android:pathData="M6.6,8a1.4,1.4 0,1 0,2.8 0a1.4,1.4 0,1 0,-2.8 0" />
9 +
</vector>
added app/src/main/res/drawable/widget_proposal.xml +9 −0
1 +
<?xml version="1.0" encoding="utf-8"?>
2 +
<!-- A proposal (two lines joining), in the hub's 16-unit line icons: the widget's reviews count (#31). -->
3 +
<vector xmlns:android="http://schemas.android.com/apk/res/android"
4 +
    android:width="16dp" android:height="16dp" android:viewportWidth="16" android:viewportHeight="16">
5 +
    <path android:strokeColor="#FFFFFFFF" android:strokeWidth="1.4" android:strokeLineCap="round" android:strokeLineJoin="round"
6 +
        android:pathData="M3,3.5a1.5,1.5 0,1 0,3 0a1.5,1.5 0,1 0,-3 0M3,12.5a1.5,1.5 0,1 0,3 0a1.5,1.5 0,1 0,-3 0M10,12.5a1.5,1.5 0,1 0,3 0a1.5,1.5 0,1 0,-3 0" />
7 +
    <path android:strokeColor="#FFFFFFFF" android:strokeWidth="1.4" android:strokeLineCap="round" android:strokeLineJoin="round"
8 +
        android:pathData="M4.5,5V11M11.5,11V7.5A3,3 0,0 0,8.5 4.5H7M8.5,3L7,4.5L8.5,6" />
9 +
</vector>
added app/src/main/res/drawable/widget_runner.xml +9 −0
1 +
<?xml version="1.0" encoding="utf-8"?>
2 +
<!-- A phone running something, in the hub's 16-unit line icons: the job the phone runs (#31). -->
3 +
<vector xmlns:android="http://schemas.android.com/apk/res/android"
4 +
    android:width="16dp" android:height="16dp" android:viewportWidth="16" android:viewportHeight="16">
5 +
    <path android:strokeColor="#FFFFFFFF" android:strokeWidth="1.4" android:strokeLineCap="round" android:strokeLineJoin="round"
6 +
        android:pathData="M5.5,1.5H10.5A1.5,1.5 0,0 1,12 3V13A1.5,1.5 0,0 1,10.5 14.5H5.5A1.5,1.5 0,0 1,4 13V3A1.5,1.5 0,0 1,5.5 1.5Z" />
7 +
    <path android:fillColor="#FFFFFFFF"
8 +
        android:pathData="M7,5.6V10.4L10.4,8Z" />
9 +
</vector>
modified CHANGELOG.md +4 −0
1 1
# Changelog
2 2
3 +
## Unreleased
4 +
5 +
- **The widget's foot (#31):** the OxygenHub mark and title are white on the dark widget, and a row of counts runs along the bottom: an envelope with your unread messages, proposals waiting for your review, and issues assigned to you (from OxygenHub's `GET /user/issues`, oxygenhub#359; a hub without it leaves that count out), each dimmed at zero and opening its screen (the one proposal or issue when there is just one), then, while the phone serves as an OxygenFlow runner, the job it runs, redrawn as that changes. The dashboard gains **Assigned to you**, the same issues, each opening.
6 +
3 7
## 0.1.2 (2026-11-01)
4 8
5 9
- **A flashier widget (#30):** the home screen widget now has the OxygenHub mark beside its title and Your sky behind its runs, drawn by the same scene as the dashboard's backdrop, settled into a still picture at the widget's size (a widget cannot animate) and redrawn from your own activity at each refresh, with a veil so the text stays readable in light and dark. With nothing of yours to draw, it keeps the plain surface.
…
39 unchanged lines
modified app/src/main/java/io/oxygenhub/android/MainActivity.kt +8 −3
…
18 unchanged lines
19 19
import io.oxygenhub.android.ui.signin.SignInViewModel
20 20
import io.oxygenhub.android.ui.theme.OxygenHubTheme
21 21
import io.oxygenhub.android.widget.HubWidget
22 +
import io.oxygenhub.android.widget.WidgetTaps
22 23
import kotlinx.coroutines.launch
23 24
import uniffi.rust_core.oxygenGreeting
24 25
25 26
class MainActivity : ComponentActivity() {
26 27
    /** A proposal a notification asked to open (#20), until it is shown. */
27 -
    private val opening = mutableStateOf<ProposalRoute?>(null)
28 +
    /** A screen to open, as a notification or the widget asks: a route of the app's navigation. */
29 +
    private val opening = mutableStateOf<Any?>(null)
28 30
29 31
    override fun onCreate(savedInstanceState: Bundle?) {
30 32
        super.onCreate(savedInstanceState)
31 -
        if (savedInstanceState == null) opening.value = proposalIn(intent)
33 +
        if (savedInstanceState == null) opening.value = routeIn(intent)
32 34
        enableEdgeToEdge()
33 35
        val container = (application as OxygenHubApp).container
34 36
        val sessions = container.sessions
…
31 unchanged lines
66 68
67 69
    override fun onNewIntent(intent: Intent) {
68 70
        super.onNewIntent(intent)
69 -
        proposalIn(intent)?.let { opening.value = it }
71 +
        routeIn(intent)?.let { opening.value = it }
70 72
    }
71 73
74 +
    /** A land alert's proposal, or the screen a tap on the widget's counts names (#31). */
75 +
    private fun routeIn(intent: Intent?): Any? = proposalIn(intent) ?: WidgetTaps.route(intent)
76 +
72 77
    private fun proposalIn(intent: Intent?): ProposalRoute? {
73 78
        val repository = intent?.getStringExtra(LandAlerts.EXTRA_REPOSITORY) ?: return null
74 79
        val number = intent.getLongExtra(LandAlerts.EXTRA_NUMBER, 0)
…
4 unchanged lines
modified app/src/main/java/io/oxygenhub/android/api/HubClient.kt +5 −0
…
53 unchanged lines
54 54
     */
55 55
    suspend fun userProposals(landable: Boolean = false): UserProposals
56 56
57 +
    /** The open issues assigned to the caller, across repositories (#31, oxygenhub#359); a hub before it answers 404. */
58 +
    suspend fun userIssues(): List<IssueBrief>
59 +
57 60
    /** Your sky's data, the dashboard backdrop's (#23); a hub before it answers 404. */
58 61
    suspend fun userSky(): UserSky
59 62
…
313 unchanged lines
373 376
374 377
    override suspend fun userProposals(landable: Boolean): UserProposals =
375 378
        get(url("user", "proposals") { if (landable) addQueryParameter("landable", "true") }, UserProposals.serializer())
379 +
380 +
    override suspend fun userIssues(): List<IssueBrief> = get(url("user", "issues"), ListSerializer(IssueBrief.serializer()))
376 381
377 382
    override suspend fun proposals(owner: String, name: String, state: ProposalState): List<Proposal> =
378 383
        get(url("repos", owner, name, "proposals") { addQueryParameter("state", state.query) }, ListSerializer(Proposal.serializer()))
…
123 unchanged lines
modified app/src/main/java/io/oxygenhub/android/api/Models.kt +11 −0
…
326 unchanged lines
327 327
    }
328 328
}
329 329
330 +
/** An issue in a list across repositories (`GET /user/issues`, oxygenhub#359). */
331 +
@Serializable
332 +
data class IssueBrief(
333 +
    /** `owner/name`. */
334 +
    val repository: String,
335 +
    val number: Long,
336 +
    val title: String = "",
337 +
    @SerialName("updated_ms") val updatedMs: Long = 0,
338 +
    val url: String = "",
339 +
)
340 +
330 341
/** A proposal in a list across repositories. */
331 342
@Serializable
332 343
data class ProposalBrief(
…
211 unchanged lines
modified app/src/main/java/io/oxygenhub/android/runner/RunnerService.kt +10 −0
…
16 unchanged lines
17 17
import android.os.Build
18 18
import android.os.IBinder
19 19
import android.os.PowerManager
20 +
import androidx.glance.appwidget.updateAll
20 21
import io.oxygenhub.android.MainActivity
21 22
import io.oxygenhub.android.R
23 +
import io.oxygenhub.android.widget.HubWidget
24 +
import kotlinx.coroutines.runBlocking
22 25
import uniffi.rust_core.runnerRunning
23 26
import uniffi.rust_core.runnerServe
24 27
import uniffi.rust_core.runnerStop
…
113 unchanged lines
138 141
                val now = runnerRunning()
139 142
                if (now != shown) {
140 143
                    PhoneRunner.setJobs(now)
144 +
                    refreshWidget()
141 145
                    if (PhoneRunner.state.value == PhoneRunner.State.Running) notify(PhoneRunner.jobsLine(now))
142 146
                    shown = now
143 147
                }
144 148
                Thread.sleep(3_000)
145 149
            }
146 150
            PhoneRunner.setJobs(emptyList())
151 +
            refreshWidget()
147 152
        }.apply {
148 153
            name = "oxygenflow-runner-jobs"
149 154
            isDaemon = true
…
1 unchanged lines
151 156
        }
152 157
    }
153 158
159 +
    /** The widget shows what the runner runs (#31): redrawn on this thread when that changes. */
160 +
    private fun refreshWidget() {
161 +
        runCatching { runBlocking { HubWidget().updateAll(this@RunnerService) } }
162 +
    }
163 +
154 164
    /** Serving ended: wait for the charger and Wi-Fi again while the owner wants it, else stop. */
155 165
    @Synchronized
156 166
    private fun served(failed: String?) {
…
73 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/dashboard/DashboardScreen.kt +30 −1
…
16 unchanged lines
17 17
import androidx.compose.material.icons.outlined.EditNote
18 18
import androidx.compose.material.icons.outlined.Inbox
19 19
import androidx.compose.material.icons.outlined.MarkEmailUnread
20 +
import androidx.compose.material.icons.outlined.RadioButtonUnchecked
20 21
import androidx.compose.material.icons.outlined.Search
21 22
import androidx.compose.material.icons.outlined.ViewKanban
22 23
import androidx.compose.material3.HorizontalDivider
…
14 unchanged lines
37 38
import androidx.compose.ui.unit.dp
38 39
import androidx.lifecycle.compose.collectAsStateWithLifecycle
39 40
import io.oxygenhub.android.api.Inbox
41 +
import io.oxygenhub.android.api.IssueBrief
40 42
import io.oxygenhub.android.api.ProposalBrief
41 43
import io.oxygenhub.android.api.Repository
42 44
import io.oxygenhub.android.ui.backdrop.Backdrop
…
19 unchanged lines
62 64
    onAllRepositories: () -> Unit,
63 65
    onInbox: () -> Unit,
64 66
    onOpenProposal: (ProposalBrief) -> Unit = {},
67 +
    onOpenIssue: (IssueBrief) -> Unit = {},
65 68
    onSearch: (() -> Unit)? = null,
66 69
    /** Projects, when the bar has no room for it (a phone, #14). */
67 70
    onProjects: (() -> Unit)? = null,
…
14 unchanged lines
82 85
        LoadContent(state, onRefresh = { vm.refresh(); onRefreshInbox() }) { d ->
83 86
            // Your sky behind it, as on the hub's dashboard (#23): a finger draws it near and names a repository.
84 87
            Backdrop(key = d.sky, make = { still -> skyScene(d.sky, still) }, modifier = Modifier.fillMaxSize()) {
85 -
                DashboardContent(d, inbox, onOpenRepository, onAllRepositories, onInbox, onOpenProposal)
88 +
                DashboardContent(d, inbox, onOpenRepository, onAllRepositories, onInbox, onOpenProposal, onOpenIssue)
86 89
            }
87 90
        }
88 91
    }
…
7 unchanged lines
96 99
    onAllRepositories: () -> Unit,
97 100
    onInbox: () -> Unit,
98 101
    onOpenProposal: (ProposalBrief) -> Unit = {},
102 +
    onOpenIssue: (IssueBrief) -> Unit = {},
99 103
) {
100 104
    BoxWithConstraints {
101 105
        // Side by side once there is room (a tablet, or a foldable open).
…
13 unchanged lines
115 119
                Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
116 120
                    Column(Modifier.weight(2f), verticalArrangement = Arrangement.spacedBy(16.dp)) {
117 121
                        Proposals(d, onOpenProposal)
122 +
                        Assigned(d, onOpenIssue)
118 123
                        Recent(d, onOpenRepository, onAllRepositories)
119 124
                    }
120 125
                    Column(Modifier.weight(1f)) { Unread(inbox, onInbox) }
…
2 unchanged lines
123 128
                Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
124 129
                    Unread(inbox, onInbox)
125 130
                    Proposals(d, onOpenProposal)
131 +
                    Assigned(d, onOpenIssue)
126 132
                    Recent(d, onOpenRepository, onAllRepositories)
127 133
                }
128 134
            }
…
11 unchanged lines
140 146
    }
141 147
}
142 148
149 +
/** Open issues assigned to the account (#31), as the hub's dashboard lists them; each opens. */
143 150
@Composable
151 +
private fun Assigned(d: Dashboard, onOpen: (IssueBrief) -> Unit) {
152 +
    val list = d.assigned?.takeIf { it.isNotEmpty() } ?: return
153 +
    Panel(Modifier.fillMaxWidth(), colors = panelColors(alpha = 0.9f)) {
154 +
        Text(
155 +
            "Assigned to you · ${list.size}",
156 +
            style = MaterialTheme.typography.titleMedium,
157 +
            modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 4.dp),
158 +
        )
159 +
        list.forEachIndexed { i, b ->
160 +
            if (i > 0) HorizontalDivider()
161 +
            ListItem(
162 +
                modifier = Modifier.clickable { onOpen(b) },
163 +
                colors = ListItemDefaults.colors(containerColor = Color.Transparent),
164 +
                leadingContent = { Icon(Icons.Outlined.RadioButtonUnchecked, contentDescription = "Open", tint = LocalStatusColors.current.ok) },
165 +
                headlineContent = { Text(b.title, maxLines = 2, overflow = TextOverflow.Ellipsis) },
166 +
                supportingContent = { Text("${b.repository} #${b.number} · ${relativeTime(b.updatedMs)}", maxLines = 1, overflow = TextOverflow.Ellipsis) },
167 +
            )
168 +
        }
169 +
    }
170 +
}
171 +
172 +
@Composable
144 173
private fun ProposalCard(title: String, list: List<ProposalBrief>, onOpen: (ProposalBrief) -> Unit) {
145 174
    Panel(Modifier.fillMaxWidth(), colors = panelColors(alpha = 0.9f)) {
146 175
        Text(
…
79 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/dashboard/DashboardViewModel.kt +14 −0
…
2 unchanged lines
3 3
import androidx.lifecycle.ViewModel
4 4
import io.oxygenhub.android.api.Avatar
5 5
import io.oxygenhub.android.api.HubApi
6 +
import io.oxygenhub.android.api.IssueBrief
6 7
import io.oxygenhub.android.api.Repository
7 8
import io.oxygenhub.android.api.User
8 9
import io.oxygenhub.android.api.UserSky
…
21 unchanged lines
30 31
    val username: String = "",
31 32
    /** Proposals to review and the account's own (#18); null when the hub cannot say. */
32 33
    val proposals: UserProposals? = null,
34 +
    /** Open issues assigned to the account (#31); null when the hub cannot say. */
35 +
    val assigned: List<IssueBrief>? = null,
33 36
    /** The newest-updated repositories: the user's own, or everyone's when they own none. */
34 37
    val recent: List<Repository>,
35 38
    val recentAreYours: Boolean,
…
41 unchanged lines
77 80
                null
78 81
            }
79 82
        }
83 +
        // As are the issues assigned to it (a hub before oxygenhub#359 answers 404).
84 +
        val assigned = async {
85 +
            try {
86 +
                api.userIssues()
87 +
            } catch (e: CancellationException) {
88 +
                throw e
89 +
            } catch (_: Exception) {
90 +
                null
91 +
            }
92 +
        }
80 93
        // As is the sky behind it all.
81 94
        val sky = async {
82 95
            try {
…
17 unchanged lines
100 113
            avatar = avatar.await(),
101 114
            username = username,
102 115
            proposals = proposals.await(),
116 +
            assigned = assigned.await(),
103 117
            recent = pool.sortedByDescending { it.updatedMs }.take(RECENT),
104 118
            recentAreYours = mine.isNotEmpty(),
105 119
            repositoryCount = mine.size,
…
17 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/nav/AppNavigation.kt +4 −3
…
186 unchanged lines
187 187
    start: TopLevel = TopLevel.Dashboard,
188 188
    /** A repository to show beside the list from the start (`owner/name`). */
189 189
    selectedRepository: String? = null,
190 -
    /** A proposal to open, as a notification asks (#20); [onOpened] once it is. */
191 -
    open: ProposalRoute? = null,
190 +
    /** A screen to open, as a notification (#20) or the widget (#31) asks; [onOpened] once it is. */
191 +
    open: Any? = null,
192 192
    onOpened: () -> Unit = {},
193 193
) {
194 194
    val nav = rememberNavController()
…
50 unchanged lines
245 245
                    onAllRepositories = { nav.goTo(TopLevel.Repositories) },
246 246
                    onInbox = { nav.goTo(TopLevel.Inbox) },
247 247
                    onOpenProposal = { nav.navigate(ProposalRoute(it.owner, it.name, it.number)) },
248 +
                    onOpenIssue = { nav.navigate(IssueRoute(it.repository.substringBefore('/'), it.repository.substringAfter('/'), it.number)) },
248 249
                    onSearch = { nav.navigate(SearchRoute) },
249 250
                    onProjects = if (roomy) null else { { nav.navigate(ProjectsRoute) } },
250 251
                )
…
155 unchanged lines
406 407
        }
407 408
        LaunchedEffect(open) {
408 409
            open?.let {
409 -
                nav.navigate(it)
410 +
                nav.navigate(it) { launchSingleTop = true }
410 411
                onOpened()
411 412
            }
412 413
        }
…
14 unchanged lines
modified app/src/main/java/io/oxygenhub/android/widget/HubWidget.kt +143 −14
1 1
package io.oxygenhub.android.widget
2 2
3 3
import android.content.Context
4 +
import android.content.Intent
4 5
import android.content.res.Configuration
5 6
import androidx.compose.runtime.Composable
6 7
import androidx.compose.ui.graphics.Color
7 8
import androidx.compose.ui.unit.dp
8 9
import androidx.compose.ui.unit.sp
10 +
import androidx.glance.ColorFilter
9 11
import androidx.glance.GlanceId
10 12
import androidx.glance.GlanceModifier
11 13
import androidx.glance.Image
…
1 unchanged lines
13 15
import androidx.glance.LocalContext
14 16
import androidx.glance.LocalSize
15 17
import androidx.glance.GlanceTheme
18 +
import androidx.glance.action.Action
19 +
import androidx.glance.action.ActionParameters
20 +
import androidx.glance.action.actionParametersOf
16 21
import androidx.glance.action.actionStartActivity
17 22
import androidx.glance.action.clickable
18 23
import androidx.glance.appwidget.GlanceAppWidget
…
23 unchanged lines
42 47
import io.oxygenhub.android.OxygenHubApp
43 48
import io.oxygenhub.android.R
44 49
import io.oxygenhub.android.api.HubApi
50 +
import io.oxygenhub.android.api.IssueBrief
51 +
import io.oxygenhub.android.api.ProposalBrief
45 52
import io.oxygenhub.android.api.UserFlowRun
46 53
import io.oxygenhub.android.api.UserSky
54 +
import io.oxygenhub.android.runner.PhoneRunner
55 +
import io.oxygenhub.android.ui.nav.AccountRoute
56 +
import io.oxygenhub.android.ui.nav.DashboardRoute
57 +
import io.oxygenhub.android.ui.nav.InboxRoute
58 +
import io.oxygenhub.android.ui.nav.IssueRoute
59 +
import io.oxygenhub.android.ui.nav.ProposalRoute
47 60
import io.oxygenhub.android.ui.theme.DarkBad
48 61
import io.oxygenhub.android.ui.theme.DarkColorScheme
49 62
import io.oxygenhub.android.ui.theme.DarkOk
…
14 unchanged lines
64 77
    val reviews: Int? = null,
65 78
    /** Your sky, drawn behind the content; null keeps the plain surface. */
66 79
    val sky: UserSky? = null,
80 +
    /** The one proposal waiting for review, when there is just one: a tap opens it (#31). */
81 +
    val onlyReview: ProposalBrief? = null,
82 +
    /** Open issues assigned to you (#31); null when the hub cannot say. */
83 +
    val assigned: Int? = null,
84 +
    /** The one assigned issue, when there is just one: a tap opens it. */
85 +
    val onlyAssigned: IssueBrief? = null,
86 +
    /** What the phone runs as an OxygenFlow runner while it serves (#31); null when it is not serving. */
87 +
    val runnerJobs: List<String>? = null,
67 88
) {
68 89
    companion object {
69 -
        /** Each part on its own: one the hub cannot give is left out, not the whole widget. */
70 -
        suspend fun load(api: HubApi?): WidgetData = coroutineScope {
90 +
        /**
91 +
         * Each part on its own: one the hub cannot give is left out, not the
92 +
         * whole widget. [runnerJobs] is what this phone's runner runs, null
93 +
         * when it is not serving.
94 +
         */
95 +
        suspend fun load(api: HubApi?, runnerJobs: List<String>? = null): WidgetData = coroutineScope {
71 96
            if (api == null) return@coroutineScope WidgetData(signedIn = false)
72 97
            suspend fun <T> quietly(f: suspend () -> T): T? = try {
73 98
                f()
…
4 unchanged lines
78 103
            }
79 104
            val runs = async { quietly { api.userFlowRuns() } }
80 105
            val unread = async { quietly { api.inbox(limit = 1).unread } }
81 -
            val reviews = async { quietly { api.userProposals().review.size } }
106 +
            val reviews = async { quietly { api.userProposals().review } }
107 +
            val assigned = async { quietly { api.userIssues() } }
82 108
            val sky = async { quietly { api.userSky() } }
83 -
            WidgetData(true, runs.await().orEmpty().take(MAX_RUNS), unread.await(), reviews.await(), sky.await())
109 +
            val review = reviews.await()
110 +
            val issues = assigned.await()
111 +
            WidgetData(
112 +
                signedIn = true,
113 +
                runs = runs.await().orEmpty().take(MAX_RUNS),
114 +
                unread = unread.await(),
115 +
                reviews = review?.size,
116 +
                sky = sky.await(),
117 +
                onlyReview = review?.singleOrNull(),
118 +
                assigned = issues?.size,
119 +
                onlyAssigned = issues?.singleOrNull(),
120 +
                runnerJobs = runnerJobs,
121 +
            )
84 122
        }
85 123
124 +
        /** What this phone's runner runs, for [load]: null unless it is serving. */
125 +
        fun runnerJobs(): List<String>? = when (PhoneRunner.state.value) {
126 +
            PhoneRunner.State.Running, is PhoneRunner.State.Draining, is PhoneRunner.State.Leaving -> PhoneRunner.jobs.value
127 +
            else -> null
128 +
        }
129 +
86 130
        const val MAX_RUNS = 3
87 131
    }
88 132
}
…
1 unchanged lines
90 134
/**
91 135
 * A home screen widget (#28): your latest runs, unread notifications and
92 136
 * reviews waiting; a tap opens the app. The OxygenHub mark heads it and Your
93 -
 * sky is drawn behind it, at the widget's own size (#30).
137 +
 * sky is drawn behind it, at the widget's own size (#30); a row of counts
138 +
 * and the phone's runner job runs along its foot (#31).
94 139
 */
95 140
class HubWidget : GlanceAppWidget() {
96 141
    /** Composed for each size the launcher shows it at, so the sky fits exactly. */
…
1 unchanged lines
98 143
99 144
    override suspend fun provideGlance(context: Context, id: GlanceId) {
100 145
        val api = (context.applicationContext as OxygenHubApp).container.sessions.api()
101 -
        val data = WidgetData.load(api)
146 +
        val data = WidgetData.load(api, WidgetData.runnerJobs())
102 147
        provideContent {
103 148
            GlanceTheme(colors = ColorProviders(light = LightColorScheme, dark = DarkColorScheme)) { Content(data) }
104 149
        }
…
7 unchanged lines
112 157
private val ok = ColorProvider(LightOk, DarkOk)
113 158
private val bad = ColorProvider(LightBad, DarkBad)
114 159
private val warn = ColorProvider(LightWarn, DarkWarn)
160 +
161 +
/**
162 +
 * Where a tap on the widget's counts goes (#31): the extras it puts on the
163 +
 * app's intent, and the screen they name.
164 +
 */
165 +
object WidgetTaps {
166 +
    const val OPEN = "io.oxygenhub.android.widget.open"
167 +
    const val REPOSITORY = "io.oxygenhub.android.widget.repository"
168 +
    const val NUMBER = "io.oxygenhub.android.widget.number"
169 +
170 +
    /** The screen an intent's extras name, or null for none (the app opens as it would). */
171 +
    fun route(open: String?, repository: String?, number: Long): Any? {
172 +
        val owner = repository?.substringBefore('/', "")?.takeIf { it.isNotEmpty() }
173 +
        val name = repository?.substringAfter('/', "")?.takeIf { it.isNotEmpty() }
174 +
        return when (open) {
175 +
            "inbox" -> InboxRoute
176 +
            "dashboard" -> DashboardRoute
177 +
            "account" -> AccountRoute
178 +
            "proposal" -> if (owner != null && name != null && number > 0) ProposalRoute(owner, name, number) else DashboardRoute
179 +
            "issue" -> if (owner != null && name != null && number > 0) IssueRoute(owner, name, number) else DashboardRoute
180 +
            else -> null
181 +
        }
182 +
    }
183 +
184 +
    fun route(intent: Intent?): Any? =
185 +
        intent?.let { route(it.getStringExtra(OPEN), it.getStringExtra(REPOSITORY), it.getLongExtra(NUMBER, 0)) }
186 +
187 +
    internal fun action(open: String, repository: String? = null, number: Long = 0) = actionStartActivity<MainActivity>(
188 +
        actionParametersOf(
189 +
            ActionParameters.Key<String>(OPEN) to open,
190 +
            ActionParameters.Key<String>(REPOSITORY) to (repository ?: ""),
191 +
            ActionParameters.Key<Long>(NUMBER) to number,
192 +
        ),
193 +
    )
194 +
}
115 195
196 +
/** The mark and the title: white on the dark widget, the text colour on the light one (#31). */
197 +
private val headline = ColorProvider(LightColorScheme.onSurface, Color.White)
198 +
116 199
@Composable
117 200
private fun Content(d: WidgetData) {
118 -
    val title = TextStyle(color = GlanceTheme.colors.primary, fontSize = 15.sp, fontWeight = FontWeight.Bold)
201 +
    val title = TextStyle(color = headline, fontSize = 15.sp, fontWeight = FontWeight.Bold)
119 202
    val body = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 13.sp)
120 203
    val mute = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp)
121 204
    val context = LocalContext.current
…
25 unchanged lines
147 230
148 231
@Composable
149 232
private fun Body(d: WidgetData, title: TextStyle, body: TextStyle, mute: TextStyle) {
150 -
    Column {
233 +
    Column(GlanceModifier.fillMaxSize()) {
151 234
        Row(verticalAlignment = Alignment.CenterVertically) {
152 -
            Image(ImageProvider(R.drawable.mark), contentDescription = null, modifier = GlanceModifier.size(width = 28.dp, height = 14.dp))
235 +
            Image(
236 +
                ImageProvider(R.drawable.mark),
237 +
                contentDescription = null,
238 +
                colorFilter = ColorFilter.tint(headline),
239 +
                modifier = GlanceModifier.size(width = 28.dp, height = 14.dp),
240 +
            )
153 241
            Spacer(GlanceModifier.width(6.dp))
154 242
            Text("OxygenHub", style = title)
155 243
        }
…
10 unchanged lines
166 254
                Text("${r.repository} #${r.run.number}", style = body, maxLines = 1, modifier = GlanceModifier.defaultWeight())
167 255
                Text(r.run.status, style = mute)
168 256
            }
257 +
        }
258 +
        Spacer(GlanceModifier.defaultWeight())
259 +
        Counts(d)
260 +
    }
261 +
}
262 +
263 +
/**
264 +
 * The row along the bottom (#31): unread messages, proposals to review and
265 +
 * issues assigned, each an icon and its number (dimmed at zero, left out
266 +
 * when the hub cannot say), then what the phone runs while it serves as a
267 +
 * runner. Each opens its screen in the app.
268 +
 */
269 +
@Composable
270 +
private fun Counts(d: WidgetData) {
271 +
    Row(GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
272 +
        d.unread?.let { Count(R.drawable.widget_inbox, "unread", it, WidgetTaps.action("inbox")) }
273 +
        d.reviews?.let { n ->
274 +
            val r = d.onlyReview
275 +
            Count(R.drawable.widget_proposal, "to review", n.toLong(), if (r != null) WidgetTaps.action("proposal", r.repository, r.number) else WidgetTaps.action("dashboard"))
276 +
        }
277 +
        d.assigned?.let { n ->
278 +
            val i = d.onlyAssigned
279 +
            Count(R.drawable.widget_issue, "assigned", n.toLong(), if (i != null) WidgetTaps.action("issue", i.repository, i.number) else WidgetTaps.action("dashboard"))
169 280
        }
170 -
        Spacer(GlanceModifier.height(6.dp))
171 -
        Row {
172 -
            d.unread?.let { Text(if (it == 0L) "Inbox clear" else "$it unread", style = mute) }
173 -
            if (d.unread != null && d.reviews != null) Text("  ·  ", style = mute)
174 -
            d.reviews?.let { Text(if (it == 0) "no reviews waiting" else "$it to review", style = mute) }
281 +
        d.runnerJobs?.let { jobs ->
282 +
            Row(GlanceModifier.defaultWeight().clickable(WidgetTaps.action("account")), verticalAlignment = Alignment.CenterVertically) {
283 +
                Image(ImageProvider(R.drawable.widget_runner), contentDescription = "This phone runs", colorFilter = ColorFilter.tint(ok), modifier = GlanceModifier.size(14.dp))
284 +
                Spacer(GlanceModifier.width(4.dp))
285 +
                Text(runnerLine(jobs), style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 12.sp), maxLines = 1)
286 +
            }
175 287
        }
288 +
    }
289 +
}
290 +
291 +
/** What the runner part of the row says: the job, how many, or that it waits. */
292 +
internal fun runnerLine(jobs: List<String>): String = when (jobs.size) {
293 +
    0 -> "Waiting for jobs"
294 +
    1 -> jobs[0]
295 +
    else -> "${jobs.size} jobs"
296 +
}
297 +
298 +
@Composable
299 +
private fun Count(icon: Int, what: String, n: Long, open: Action) {
300 +
    val colour = if (n == 0L) GlanceTheme.colors.onSurfaceVariant else headline
301 +
    Row(GlanceModifier.padding(end = 14.dp).clickable(open), verticalAlignment = Alignment.CenterVertically) {
302 +
        Image(ImageProvider(icon), contentDescription = "$n $what", colorFilter = ColorFilter.tint(colour), modifier = GlanceModifier.size(14.dp))
303 +
        Spacer(GlanceModifier.width(4.dp))
304 +
        Text("$n", style = TextStyle(color = colour, fontSize = 13.sp, fontWeight = FontWeight.Bold))
176 305
    }
177 306
}
178 307
…
6 unchanged lines
modified app/src/test/java/io/oxygenhub/android/FakeHubApi.kt +4 −0
…
41 unchanged lines
42 42
            review = listOf(io.oxygenhub.android.api.ProposalBrief("andymccall/oxygenhub", 300, "Show a column's archived cards", "ada")),
43 43
        )
44 44
    }
45 +
    var userIssues: () -> List<io.oxygenhub.android.api.IssueBrief> = {
46 +
        listOf(io.oxygenhub.android.api.IssueBrief("andymccall/oxygenhub", 359, "GET /user/issues"))
47 +
    }
45 48
    var autoLand: (Long, Boolean) -> Proposal = { n, on -> proposal(n).copy(autoLand = if (on) "andymccall" else null) }
46 49
47 50
    val calls = mutableListOf<String>()
…
143 unchanged lines
191 194
    override suspend fun userFlowRuns(before: Long?) = userFlowRuns.invoke().also { calls += "user flow runs" }
192 195
    override suspend fun repoSky(owner: String, name: String) =
193 196
        userSky.invoke().copy(repos = listOf("$owner/$name")).also { calls += "sky $owner/$name" }
197 +
    override suspend fun userIssues() = userIssues.invoke().also { calls += "user issues" }
194 198
    override suspend fun userProposals(landable: Boolean) =
195 199
        userProposals.invoke().also { calls += if (landable) "user proposals, landable" else "user proposals" }
196 200
    override suspend fun proposals(owner: String, name: String, state: ProposalState): List<Proposal> =
…
25 unchanged lines
modified app/src/test/java/io/oxygenhub/android/ui/WidgetDataTest.kt +46 −0
…
4 unchanged lines
5 5
import io.oxygenhub.android.api.HubException
6 6
import io.oxygenhub.android.api.UserFlowRun
7 7
import io.oxygenhub.android.api.UserSky
8 +
import io.oxygenhub.android.api.IssueBrief
9 +
import io.oxygenhub.android.ui.nav.AccountRoute
10 +
import io.oxygenhub.android.ui.nav.DashboardRoute
11 +
import io.oxygenhub.android.ui.nav.InboxRoute
12 +
import io.oxygenhub.android.ui.nav.IssueRoute
13 +
import io.oxygenhub.android.ui.nav.ProposalRoute
8 14
import io.oxygenhub.android.widget.SkyBitmap
9 15
import io.oxygenhub.android.widget.WidgetData
16 +
import io.oxygenhub.android.widget.WidgetTaps
17 +
import io.oxygenhub.android.widget.runnerLine
10 18
import kotlinx.coroutines.test.runTest
11 19
import org.junit.Assert.assertEquals
12 20
import org.junit.Assert.assertFalse
…
53 unchanged lines
66 74
        assertNotEquals(light, dark)
67 75
        assertTrue("every channel is a fraction", (light + dark).all { it in 0f..1f })
68 76
    }
77 +
78 +
    @Test
79 +
    fun theCountsRowSaysWhatIsAssignedAndOneOfAKindOpensItself() = runTest {
80 +
        val api = FakeHubApi()
81 +
        val d = WidgetData.load(api)
82 +
        assertEquals(1, d.assigned)
83 +
        assertEquals(359L, d.onlyAssigned?.number)
84 +
        assertEquals(300L, d.onlyReview?.number)
85 +
        api.userIssues = { listOf(IssueBrief("andy/a", 1), IssueBrief("andy/a", 2)) }
86 +
        val two = WidgetData.load(api)
87 +
        assertEquals(2, two.assigned)
88 +
        assertNull("two open the dashboard, not one of them", two.onlyAssigned)
89 +
        api.userIssues = { throw HubException.Http(404, "no such API route") }
90 +
        val old = WidgetData.load(api)
91 +
        assertNull("a hub before /user/issues: the count is left out", old.assigned)
92 +
        assertEquals(1, old.reviews)
93 +
    }
94 +
95 +
    @Test
96 +
    fun theRunnerShowsOnlyWhileThePhoneServes() = runTest {
97 +
        val api = FakeHubApi()
98 +
        assertNull(WidgetData.load(api).runnerJobs)
99 +
        assertEquals(listOf("andy/a #7 test"), WidgetData.load(api, listOf("andy/a #7 test")).runnerJobs)
100 +
        assertEquals("Waiting for jobs", runnerLine(emptyList()))
101 +
        assertEquals("andy/a #7 test", runnerLine(listOf("andy/a #7 test")))
102 +
        assertEquals("2 jobs", runnerLine(listOf("andy/a #7 test", "andy/b #2 build")))
103 +
    }
104 +
105 +
    @Test
106 +
    fun aTapOnACountOpensItsScreen() {
107 +
        assertEquals(InboxRoute, WidgetTaps.route("inbox", "", 0))
108 +
        assertEquals(DashboardRoute, WidgetTaps.route("dashboard", "", 0))
109 +
        assertEquals(AccountRoute, WidgetTaps.route("account", "", 0))
110 +
        assertEquals(ProposalRoute("andy", "a", 3), WidgetTaps.route("proposal", "andy/a", 3))
111 +
        assertEquals(IssueRoute("andy", "a", 4), WidgetTaps.route("issue", "andy/a", 4))
112 +
        assertEquals("a broken reference falls back to the dashboard", DashboardRoute, WidgetTaps.route("issue", "nonsense", 4))
113 +
        assertNull("not from the widget", WidgetTaps.route(null, null, 0))
114 +
    }
69 115
}