OXYGENHUB
# aa627197ec18 oxygenhub-android / app / src / main / java / io / oxygenhub / android / ui / nav / AppNavigation.kt
21.9 KiB · last changed 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) 3 days ago source blame history raw
1
package io.oxygenhub.android.ui.nav
2
3
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection
4
import androidx.compose.animation.core.tween
5
import androidx.compose.animation.fadeIn
6
import androidx.compose.animation.fadeOut
7
import androidx.compose.material.icons.Icons
8
import androidx.compose.material.icons.filled.AccountCircle
9
import androidx.compose.material.icons.filled.Book
10
import androidx.compose.material.icons.filled.Dashboard
11
import androidx.compose.material.icons.filled.Inbox
12
import androidx.compose.material.icons.filled.RocketLaunch
13
import androidx.compose.material.icons.filled.Search
14
import androidx.compose.material.icons.filled.ViewKanban
15
import androidx.compose.material.icons.outlined.AccountCircle
16
import androidx.compose.material.icons.outlined.Book
17
import androidx.compose.material.icons.outlined.Dashboard
18
import androidx.compose.material.icons.outlined.Inbox
19
import androidx.compose.material.icons.outlined.RocketLaunch
20
import androidx.compose.material.icons.outlined.Search
21
import androidx.compose.material.icons.outlined.ViewKanban
22
import androidx.compose.material3.Badge
23
import androidx.compose.material3.BadgedBox
24
import androidx.compose.material3.Icon
25
import androidx.compose.material3.Text
26
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
27
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
28
import androidx.compose.runtime.Composable
29
import androidx.compose.runtime.LaunchedEffect
30
import androidx.compose.runtime.getValue
31
import androidx.compose.runtime.rememberCoroutineScope
32
import androidx.compose.ui.graphics.vector.ImageVector
33
import androidx.compose.ui.platform.LocalUriHandler
34
import androidx.lifecycle.ViewModel
35
import androidx.lifecycle.compose.collectAsStateWithLifecycle
36
import androidx.lifecycle.viewmodel.compose.viewModel
37
import androidx.lifecycle.viewmodel.initializer
38
import androidx.lifecycle.viewmodel.viewModelFactory
39
import androidx.navigation.NavDestination.Companion.hasRoute
40
import androidx.navigation.NavDestination.Companion.hierarchy
41
import androidx.navigation.NavGraph.Companion.findStartDestination
42
import androidx.navigation.NavHostController
43
import androidx.navigation.compose.NavHost
44
import androidx.navigation.compose.composable
45
import androidx.navigation.compose.currentBackStackEntryAsState
46
import androidx.navigation.compose.rememberNavController
47
import androidx.navigation.toRoute
48
import androidx.window.core.layout.WindowSizeClass
49
import io.oxygenhub.android.api.HubApi
50
import io.oxygenhub.android.auth.Session
51
import io.oxygenhub.android.ui.account.AccountScreen
52
import io.oxygenhub.android.ui.backdrop.repoSkyOrNull
53
import io.oxygenhub.android.ui.code.ChangeScreen
54
import io.oxygenhub.android.ui.code.ChangeViewModel
55
import io.oxygenhub.android.ui.code.CodeViewModel
56
import io.oxygenhub.android.ui.code.FileScreen
57
import io.oxygenhub.android.ui.code.FileViewModel
58
import io.oxygenhub.android.ui.code.HistoryScreen
59
import io.oxygenhub.android.ui.code.HistoryViewModel
60
import io.oxygenhub.android.ui.common.rememberAvatar
61
import io.oxygenhub.android.ui.dashboard.DashboardScreen
62
import io.oxygenhub.android.ui.dashboard.DashboardViewModel
63
import io.oxygenhub.android.ui.flow.FlowMonitorScreen
64
import io.oxygenhub.android.ui.flow.FlowMonitorViewModel
65
import io.oxygenhub.android.ui.flow.JobLogScreen
66
import io.oxygenhub.android.ui.flow.JobLogViewModel
67
import io.oxygenhub.android.ui.flow.RunScreen
68
import io.oxygenhub.android.ui.flow.RunViewModel
69
import io.oxygenhub.android.ui.flow.RunsViewModel
70
import io.oxygenhub.android.ui.inbox.InboxScreen
71
import io.oxygenhub.android.ui.inbox.InboxViewModel
72
import io.oxygenhub.android.ui.issues.IssueScreen
73
import io.oxygenhub.android.ui.issues.IssueViewModel
74
import io.oxygenhub.android.ui.issues.IssuesViewModel
75
import io.oxygenhub.android.ui.issues.NewIssueScreen
76
import io.oxygenhub.android.ui.issues.NewIssueViewModel
77
import io.oxygenhub.android.ui.owners.OwnerScreen
78
import io.oxygenhub.android.ui.owners.OwnerViewModel
79
import io.oxygenhub.android.ui.projects.BoardScreen
80
import io.oxygenhub.android.ui.projects.BoardViewModel
81
import io.oxygenhub.android.ui.projects.ProjectsScreen
82
import io.oxygenhub.android.ui.projects.ProjectsViewModel
83
import io.oxygenhub.android.ui.proposals.ProposalFilesScreen
84
import io.oxygenhub.android.ui.proposals.ProposalFilesViewModel
85
import io.oxygenhub.android.ui.proposals.ProposalScreen
86
import io.oxygenhub.android.ui.proposals.ProposalViewModel
87
import io.oxygenhub.android.ui.proposals.ProposalsViewModel
88
import io.oxygenhub.android.ui.releases.ReleasesViewModel
89
import io.oxygenhub.android.ui.releases.SearchScreen
90
import io.oxygenhub.android.ui.releases.SearchViewModel
91
import io.oxygenhub.android.ui.repos.NewRepositoryScreen
92
import io.oxygenhub.android.ui.repos.NewRepositoryViewModel
93
import io.oxygenhub.android.ui.repos.RepositoriesScreen
94
import io.oxygenhub.android.ui.repos.RepositoriesViewModel
95
import io.oxygenhub.android.ui.repos.RepositoryScreen
96
import kotlin.reflect.KClass
97
import kotlinx.coroutines.launch
98
import kotlinx.serialization.Serializable
99
100
// The app's routes. Top-level ones are in the navigation bar (or rail, or
101
// drawer, as the window allows); the rest are pushed over them. Browse
102
// (oxygenhub-android#3) adds a repository's overview, code and history
103
// under the repository's route.
104
105
@Serializable data object DashboardRoute
106
107
@Serializable data object RepositoriesRoute
108
109
@Serializable data object NewRepositoryRoute
110
111
@Serializable data object FlowRoute
112
113
@Serializable data object InboxRoute
114
115
@Serializable data object ProjectsRoute
116
117
@Serializable data object SearchRoute
118
119
/** A project board. */
120
@Serializable data class BoardRoute(val owner: String, val number: Long)
121
122
@Serializable data object AccountRoute
123
124
/** A repository's issues, opened from outside the repositories list (the dashboard). */
125
@Serializable data class IssuesRoute(val owner: String, val name: String)
126
127
/** One proposal, pushed over the repository it belongs to. */
128
@Serializable data class ProposalRoute(val owner: String, val name: String, val number: Long)
129
130
/** A proposal's files with their diffs (#4). */
131
@Serializable data class ProposalFilesRoute(val owner: String, val name: String, val number: Long)
132
133
/** A file at a reference, a history (of a file when [path] is given), and a change (#3). */
134
@Serializable data class FileRoute(val owner: String, val name: String, val ref: String, val path: String)
135
136
@Serializable data class HistoryRoute(val owner: String, val name: String, val ref: String, val path: String? = null)
137
138
@Serializable data class ChangeRoute(val owner: String, val name: String, val hash: String)
139
140
/** An issue, and a new one (#4). */
141
@Serializable data class IssueRoute(val owner: String, val name: String, val number: Long)
142
143
@Serializable data class NewIssueRoute(val owner: String, val name: String)
144
145
/** An account's or organisation's page. */
146
@Serializable data class OwnerRoute(val name: String)
147
148
/** A Flow run, and a job's log (#5). */
149
@Serializable data class RunRoute(val owner: String, val name: String, val number: Long)
150
151
@Serializable data class JobLogRoute(val owner: String, val name: String, val number: Long, val job: String)
152
153
/**
154
 * Where the bar or rail goes. [wide] ones show only when there is room (the
155
 * unfolded Fold, a tablet: #14); on a phone the bar keeps its four and they
156
 * are reached from the dashboard.
157
 */
158
enum class TopLevel(
159
    val label: String,
160
    val icon: ImageVector,
161
    val selectedIcon: ImageVector,
162
    val route: Any,
163
    val routeClass: KClass<*>,
164
    val wide: Boolean = false,
165
) {
166
    Dashboard("Dashboard", Icons.Outlined.Dashboard, Icons.Filled.Dashboard, DashboardRoute, DashboardRoute::class),
167
    Repositories("Repositories", Icons.Outlined.Book, Icons.Filled.Book, RepositoriesRoute, RepositoriesRoute::class),
168
    Flow("Flow", Icons.Outlined.RocketLaunch, Icons.Filled.RocketLaunch, FlowRoute, FlowRoute::class),
169
    Projects("Projects", Icons.Outlined.ViewKanban, Icons.Filled.ViewKanban, ProjectsRoute, ProjectsRoute::class, wide = true),
170
    Search("Search", Icons.Outlined.Search, Icons.Filled.Search, SearchRoute, SearchRoute::class, wide = true),
171
    Inbox("Inbox", Icons.Outlined.Inbox, Icons.Filled.Inbox, InboxRoute, InboxRoute::class),
172
    Account("Account", Icons.Outlined.AccountCircle, Icons.Filled.AccountCircle, AccountRoute, AccountRoute::class),
173
}
174
175
/** A view model made by [make], kept by the screen's back stack entry (or [key] within it). */
176
@Composable
177
inline fun <reified VM : ViewModel> rememberViewModel(key: String? = null, crossinline make: () -> VM): VM =
178
    viewModel(key = key, factory = viewModelFactory { initializer { make() } })
179
180
/** The signed-in app: navigation around the dashboard, repositories, inbox and account. */
181
@Composable
182
fun SignedInApp(
183
    session: Session,
184
    api: HubApi,
185
    nativeCore: () -> String,
186
    onSignOut: suspend () -> Unit,
187
    start: TopLevel = TopLevel.Dashboard,
188
    /** A repository to show beside the list from the start (`owner/name`). */
189
    selectedRepository: String? = null,
190
    /** A screen to open, as a notification (#20) or the widget (#31) asks; [onOpened] once it is. */
191
    open: Any? = null,
192
    onOpened: () -> Unit = {},
193
) {
194
    val nav = rememberNavController()
195
    // One inbox for the badge, the dashboard's count and the inbox screen.
196
    val inbox = rememberViewModel(key = "inbox:${session.hub}:${session.username}") { InboxViewModel(api) }
197
    val inboxState by inbox.state.collectAsStateWithLifecycle()
198
    val unread = inboxState.valueOrNull?.unread ?: 0
199
    val entry by nav.currentBackStackEntryAsState()
200
    // A hub page opens on its own screen when the app has one, else in the browser.
201
    val uri = LocalUriHandler.current
202
    val openLink: (String) -> Unit = { url -> routeFor(url, session.hub)?.let { nav.navigate(it) } ?: uri.openUri(url) }
203
    val destination = entry?.destination
204
205
    val roomy = currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND)
206
    NavigationSuiteScaffold(
207
        navigationSuiteItems = {
208
            TopLevel.entries.filter { roomy || !it.wide }.forEach { top ->
209
                val selected = destination?.hierarchy?.any { it.hasRoute(top.routeClass) } == true
210
                item(
211
                    selected = selected,
212
                    onClick = { nav.goTo(top) },
213
                    icon = {
214
                        val icon = if (selected) top.selectedIcon else top.icon
215
                        if (top == TopLevel.Inbox && unread > 0) {
216
                            BadgedBox(badge = { Badge { Text(if (unread > 99) "99+" else unread.toString()) } }) {
217
                                Icon(icon, contentDescription = "${top.label}, $unread unread")
218
                            }
219
                        } else {
220
                            Icon(icon, contentDescription = top.label)
221
                        }
222
                    },
223
                    label = { Text(top.label) },
224
                )
225
            }
226
        },
227
    ) {
228
        NavHost(
229
            nav,
230
            startDestination = start.route,
231
            // A screen opened comes in a little from the side it leads to as it fades in, and
232
            // goes back the way it came (#23): where you are going reads in the motion.
233
            enterTransition = { slideIntoContainer(SlideDirection.Start, tween(NAV_MS), initialOffset = { it / 8 }) + fadeIn(tween(NAV_MS)) },
234
            exitTransition = { fadeOut(tween(NAV_MS * 2 / 3)) },
235
            popEnterTransition = { fadeIn(tween(NAV_MS)) },
236
            popExitTransition = { slideOutOfContainer(SlideDirection.End, tween(NAV_MS), targetOffset = { it / 8 }) + fadeOut(tween(NAV_MS * 2 / 3)) },
237
        ) {
238
            composable<DashboardRoute> {
239
                DashboardScreen(
240
                    vm = rememberViewModel { DashboardViewModel(api, session) },
241
                    hub = session.hub,
242
                    inbox = inboxState,
243
                    onRefreshInbox = inbox::refresh,
244
                    onOpenRepository = { nav.navigate(IssuesRoute(it.owner, it.name)) },
245
                    onAllRepositories = { nav.goTo(TopLevel.Repositories) },
246
                    onInbox = { nav.goTo(TopLevel.Inbox) },
247
                    onOpenProposal = { nav.navigate(ProposalRoute(it.owner, it.name, it.number)) },
248
                    onOpenIssue = { nav.navigate(IssueRoute(it.repository.substringBefore('/'), it.repository.substringAfter('/'), it.number)) },
249
                    onSearch = { nav.navigate(SearchRoute) },
250
                    onProjects = if (roomy) null else { { nav.navigate(ProjectsRoute) } },
251
                )
252
            }
253
            composable<SearchRoute> {
254
                SearchScreen(rememberViewModel { SearchViewModel(api) }, onBack = { nav.popBackStack() }) { h ->
255
                    nav.navigate(if (h.kind == "proposal") ProposalRoute(h.owner, h.name, h.number) else IssueRoute(h.owner, h.name, h.number))
256
                }
257
            }
258
            composable<RepositoriesRoute> {
259
                RepositoriesScreen(
260
                    rememberViewModel { RepositoriesViewModel(api, session.username) },
261
                    selectedRepository,
262
                    onNew = { nav.navigate(NewRepositoryRoute) },
263
                ) { slug, onBack ->
264
                    val (owner, name) = slug.split('/', limit = 2)
265
                    RepositoryScreen(
266
                        rememberViewModel(key = "code:$slug") { CodeViewModel(api, owner, name) },
267
                        rememberViewModel(key = "issues:$slug") { IssuesViewModel(api, owner, name) },
268
                        rememberViewModel(key = "proposals:$slug") { ProposalsViewModel(api, owner, name) },
269
                        rememberViewModel(key = "runs:$slug") { RunsViewModel(api, owner, name) },
270
                        rememberViewModel(key = "releases:$slug") { ReleasesViewModel(api, owner, name) },
271
                        onBack,
272
                        onOpenProposal = { nav.navigate(ProposalRoute(owner, name, it.number)) },
273
                        onOpenFile = { ref, path -> nav.navigate(FileRoute(owner, name, ref, path)) },
274
                        onOpenHistory = { ref -> nav.navigate(HistoryRoute(owner, name, ref)) },
275
                        onOpenIssue = { nav.navigate(IssueRoute(owner, name, it)) },
276
                        onNewIssue = if (session.canWrite) { { nav.navigate(NewIssueRoute(owner, name)) } } else null,
277
                        onOpenRun = { nav.navigate(RunRoute(owner, name, it)) },
278
                        onOpenOwner = { nav.navigate(OwnerRoute(owner)) },
279
                        loadSky = { repoSkyOrNull(api, owner, name) },
280
                    )
281
                }
282
            }
283
            composable<IssuesRoute> { e ->
284
                val r = e.toRoute<IssuesRoute>()
285
                RepositoryScreen(
286
                    rememberViewModel { CodeViewModel(api, r.owner, r.name) },
287
                    rememberViewModel { IssuesViewModel(api, r.owner, r.name) },
288
                    rememberViewModel { ProposalsViewModel(api, r.owner, r.name) },
289
                    rememberViewModel { RunsViewModel(api, r.owner, r.name) },
290
                    rememberViewModel { ReleasesViewModel(api, r.owner, r.name) },
291
                    onBack = { nav.popBackStack() },
292
                    onOpenProposal = { nav.navigate(ProposalRoute(r.owner, r.name, it.number)) },
293
                    onOpenFile = { ref, path -> nav.navigate(FileRoute(r.owner, r.name, ref, path)) },
294
                    onOpenHistory = { ref -> nav.navigate(HistoryRoute(r.owner, r.name, ref)) },
295
                    onOpenIssue = { nav.navigate(IssueRoute(r.owner, r.name, it)) },
296
                    onNewIssue = if (session.canWrite) { { nav.navigate(NewIssueRoute(r.owner, r.name)) } } else null,
297
                    onOpenRun = { nav.navigate(RunRoute(r.owner, r.name, it)) },
298
                    onOpenOwner = { nav.navigate(OwnerRoute(r.owner)) },
299
                    loadSky = { repoSkyOrNull(api, r.owner, r.name) },
300
                )
301
            }
302
            composable<OwnerRoute> { e ->
303
                val r = e.toRoute<OwnerRoute>()
304
                OwnerScreen(
305
                    rememberViewModel { OwnerViewModel(api, r.name) },
306
                    api,
307
                    onBack = { nav.popBackStack() },
308
                    onOpenOwner = { nav.navigate(OwnerRoute(it)) },
309
                    onOpenRepository = { nav.navigate(IssuesRoute(it.owner, it.name)) },
310
                    onOpenProject = { nav.navigate(BoardRoute(it.owner, it.number)) },
311
                )
312
            }
313
            composable<ProjectsRoute> {
314
                ProjectsScreen(rememberViewModel { ProjectsViewModel(api, session.username) }) { p -> nav.navigate(BoardRoute(p.owner, p.number)) }
315
            }
316
            composable<BoardRoute> { e ->
317
                val r = e.toRoute<BoardRoute>()
318
                BoardScreen(
319
                    rememberViewModel { BoardViewModel(api, r.owner, r.number) },
320
                    canWrite = session.canWrite,
321
                    onBack = { nav.popBackStack() },
322
                    onOpenCard = { card ->
323
                        card.target?.let { (owner, name, n) ->
324
                            nav.navigate(if (card.kind == "proposal") ProposalRoute(owner, name, n) else IssueRoute(owner, name, n))
325
                        }
326
                    },
327
                )
328
            }
329
            composable<RunRoute> { e ->
330
                val r = e.toRoute<RunRoute>()
331
                RunScreen(
332
                    rememberViewModel { RunViewModel(api, r.owner, r.name, r.number) },
333
                    canWrite = session.canWrite,
334
                    onBack = { nav.popBackStack() },
335
                    onOpenJob = { j -> nav.navigate(JobLogRoute(r.owner, r.name, r.number, j.name)) },
336
                )
337
            }
338
            composable<JobLogRoute> { e ->
339
                val r = e.toRoute<JobLogRoute>()
340
                JobLogScreen(rememberViewModel { JobLogViewModel(api, r.owner, r.name, r.number, r.job) }, onBack = { nav.popBackStack() })
341
            }
342
            composable<IssueRoute> { e ->
343
                val r = e.toRoute<IssueRoute>()
344
                IssueScreen(rememberViewModel { IssueViewModel(api, r.owner, r.name, r.number) }, canWrite = session.canWrite, onBack = { nav.popBackStack() }, me = session.username)
345
            }
346
            composable<FlowRoute> {
347
                FlowMonitorScreen(rememberViewModel { FlowMonitorViewModel(api) }) { owner, name, number -> nav.navigate(RunRoute(owner, name, number)) }
348
            }
349
            composable<NewRepositoryRoute> {
350
                NewRepositoryScreen(
351
                    rememberViewModel { NewRepositoryViewModel(api, session.username) },
352
                    canCreate = session.canCreate,
353
                    onBack = { nav.popBackStack() },
354
                    onOpen = { r -> nav.navigate(IssuesRoute(r.owner, r.name)) { popUpTo<NewRepositoryRoute> { inclusive = true } } },
355
                )
356
            }
357
            composable<NewIssueRoute> { e ->
358
                val r = e.toRoute<NewIssueRoute>()
359
                NewIssueScreen(
360
                    rememberViewModel { NewIssueViewModel(api, r.owner, r.name) },
361
                    onBack = { nav.popBackStack() },
362
                    onOpened = { n -> nav.navigate(IssueRoute(r.owner, r.name, n)) { popUpTo<NewIssueRoute> { inclusive = true } } },
363
                )
364
            }
365
            composable<FileRoute> { e ->
366
                val r = e.toRoute<FileRoute>()
367
                FileScreen(
368
                    rememberViewModel { FileViewModel(api, r.owner, r.name, r.ref, r.path) },
369
                    onBack = { nav.popBackStack() },
370
                    onOpenHistory = { nav.navigate(HistoryRoute(r.owner, r.name, r.ref, r.path)) },
371
                )
372
            }
373
            composable<HistoryRoute> { e ->
374
                val r = e.toRoute<HistoryRoute>()
375
                HistoryScreen(
376
                    rememberViewModel { HistoryViewModel(api, r.owner, r.name, r.ref, r.path) },
377
                    onBack = { nav.popBackStack() },
378
                    onOpenChange = { nav.navigate(ChangeRoute(r.owner, r.name, it.hash)) },
379
                )
380
            }
381
            composable<ChangeRoute> { e ->
382
                val r = e.toRoute<ChangeRoute>()
383
                ChangeScreen(
384
                    rememberViewModel { ChangeViewModel(api, r.owner, r.name, r.hash) },
385
                    onBack = { nav.popBackStack() },
386
                    onOpenFile = { path -> nav.navigate(FileRoute(r.owner, r.name, r.hash, path)) },
387
                )
388
            }
389
            composable<ProposalRoute> { e ->
390
                val r = e.toRoute<ProposalRoute>()
391
                val scope = rememberCoroutineScope()
392
                ProposalScreen(
393
                    rememberViewModel { ProposalViewModel(api, r.owner, r.name, r.number) },
394
                    canWrite = session.canWrite,
395
                    onBack = { nav.popBackStack() },
396
                    // Signing out revokes this token; the new sign-in asks for repo:write.
397
                    onSignInAgain = { scope.launch { onSignOut() } },
398
                    onOpenFiles = { nav.navigate(ProposalFilesRoute(r.owner, r.name, r.number)) },
399
                )
400
            }
401
            composable<ProposalFilesRoute> { e ->
402
                val r = e.toRoute<ProposalFilesRoute>()
403
                ProposalFilesScreen(rememberViewModel { ProposalFilesViewModel(api, r.owner, r.name, r.number) }, onBack = { nav.popBackStack() })
404
            }
405
            composable<InboxRoute> { InboxScreen(inbox, onOpen = openLink) }
406
            composable<AccountRoute> { AccountScreen(session, rememberAvatar(api, session.username), api, nativeCore, onSignOut) }
407
        }
408
        LaunchedEffect(open) {
409
            open?.let {
410
                nav.navigate(it) { launchSingleTop = true }
411
                onOpened()
412
            }
413
        }
414
    }
415
}
416
417
/** To a top-level destination, keeping one copy of each and its state, as the bar's own taps do. */
418
private fun NavHostController.goTo(top: TopLevel) {
419
    navigate(top.route) {
420
        popUpTo(graph.findStartDestination().id) { saveState = true }
421
        launchSingleTop = true
422
        restoreState = true
423
    }
424
}
425
426
/** How long a screen takes to come in (#23). */
427
private const val NAV_MS = 300