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 | } |