OXYGENHUB
# 6823c243eaac oxygenhub-android / app / src / main / java / io / oxygenhub / android / ui / nav / AppNavigation.kt
21.7 KiB · last changed Flow, at a glance; New repository with an assistant to start it (closes #26, closes #27, refs andymccall/oxygenhub#347) 7 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 proposal to open, as a notification asks (#20); [onOpened] once it is. */
191
    open: ProposalRoute? = 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
                    onSearch = { nav.navigate(SearchRoute) },
249
                    onProjects = if (roomy) null else { { nav.navigate(ProjectsRoute) } },
250
                )
251
            }
252
            composable<SearchRoute> {
253
                SearchScreen(rememberViewModel { SearchViewModel(api) }, onBack = { nav.popBackStack() }) { h ->
254
                    nav.navigate(if (h.kind == "proposal") ProposalRoute(h.owner, h.name, h.number) else IssueRoute(h.owner, h.name, h.number))
255
                }
256
            }
257
            composable<RepositoriesRoute> {
258
                RepositoriesScreen(
259
                    rememberViewModel { RepositoriesViewModel(api, session.username) },
260
                    selectedRepository,
261
                    onNew = { nav.navigate(NewRepositoryRoute) },
262
                ) { slug, onBack ->
263
                    val (owner, name) = slug.split('/', limit = 2)
264
                    RepositoryScreen(
265
                        rememberViewModel(key = "code:$slug") { CodeViewModel(api, owner, name) },
266
                        rememberViewModel(key = "issues:$slug") { IssuesViewModel(api, owner, name) },
267
                        rememberViewModel(key = "proposals:$slug") { ProposalsViewModel(api, owner, name) },
268
                        rememberViewModel(key = "runs:$slug") { RunsViewModel(api, owner, name) },
269
                        rememberViewModel(key = "releases:$slug") { ReleasesViewModel(api, owner, name) },
270
                        onBack,
271
                        onOpenProposal = { nav.navigate(ProposalRoute(owner, name, it.number)) },
272
                        onOpenFile = { ref, path -> nav.navigate(FileRoute(owner, name, ref, path)) },
273
                        onOpenHistory = { ref -> nav.navigate(HistoryRoute(owner, name, ref)) },
274
                        onOpenIssue = { nav.navigate(IssueRoute(owner, name, it)) },
275
                        onNewIssue = if (session.canWrite) { { nav.navigate(NewIssueRoute(owner, name)) } } else null,
276
                        onOpenRun = { nav.navigate(RunRoute(owner, name, it)) },
277
                        onOpenOwner = { nav.navigate(OwnerRoute(owner)) },
278
                        loadSky = { repoSkyOrNull(api, owner, name) },
279
                    )
280
                }
281
            }
282
            composable<IssuesRoute> { e ->
283
                val r = e.toRoute<IssuesRoute>()
284
                RepositoryScreen(
285
                    rememberViewModel { CodeViewModel(api, r.owner, r.name) },
286
                    rememberViewModel { IssuesViewModel(api, r.owner, r.name) },
287
                    rememberViewModel { ProposalsViewModel(api, r.owner, r.name) },
288
                    rememberViewModel { RunsViewModel(api, r.owner, r.name) },
289
                    rememberViewModel { ReleasesViewModel(api, r.owner, r.name) },
290
                    onBack = { nav.popBackStack() },
291
                    onOpenProposal = { nav.navigate(ProposalRoute(r.owner, r.name, it.number)) },
292
                    onOpenFile = { ref, path -> nav.navigate(FileRoute(r.owner, r.name, ref, path)) },
293
                    onOpenHistory = { ref -> nav.navigate(HistoryRoute(r.owner, r.name, ref)) },
294
                    onOpenIssue = { nav.navigate(IssueRoute(r.owner, r.name, it)) },
295
                    onNewIssue = if (session.canWrite) { { nav.navigate(NewIssueRoute(r.owner, r.name)) } } else null,
296
                    onOpenRun = { nav.navigate(RunRoute(r.owner, r.name, it)) },
297
                    onOpenOwner = { nav.navigate(OwnerRoute(r.owner)) },
298
                    loadSky = { repoSkyOrNull(api, r.owner, r.name) },
299
                )
300
            }
301
            composable<OwnerRoute> { e ->
302
                val r = e.toRoute<OwnerRoute>()
303
                OwnerScreen(
304
                    rememberViewModel { OwnerViewModel(api, r.name) },
305
                    api,
306
                    onBack = { nav.popBackStack() },
307
                    onOpenOwner = { nav.navigate(OwnerRoute(it)) },
308
                    onOpenRepository = { nav.navigate(IssuesRoute(it.owner, it.name)) },
309
                    onOpenProject = { nav.navigate(BoardRoute(it.owner, it.number)) },
310
                )
311
            }
312
            composable<ProjectsRoute> {
313
                ProjectsScreen(rememberViewModel { ProjectsViewModel(api, session.username) }) { p -> nav.navigate(BoardRoute(p.owner, p.number)) }
314
            }
315
            composable<BoardRoute> { e ->
316
                val r = e.toRoute<BoardRoute>()
317
                BoardScreen(
318
                    rememberViewModel { BoardViewModel(api, r.owner, r.number) },
319
                    canWrite = session.canWrite,
320
                    onBack = { nav.popBackStack() },
321
                    onOpenCard = { card ->
322
                        card.target?.let { (owner, name, n) ->
323
                            nav.navigate(if (card.kind == "proposal") ProposalRoute(owner, name, n) else IssueRoute(owner, name, n))
324
                        }
325
                    },
326
                )
327
            }
328
            composable<RunRoute> { e ->
329
                val r = e.toRoute<RunRoute>()
330
                RunScreen(
331
                    rememberViewModel { RunViewModel(api, r.owner, r.name, r.number) },
332
                    canWrite = session.canWrite,
333
                    onBack = { nav.popBackStack() },
334
                    onOpenJob = { j -> nav.navigate(JobLogRoute(r.owner, r.name, r.number, j.name)) },
335
                )
336
            }
337
            composable<JobLogRoute> { e ->
338
                val r = e.toRoute<JobLogRoute>()
339
                JobLogScreen(rememberViewModel { JobLogViewModel(api, r.owner, r.name, r.number, r.job) }, onBack = { nav.popBackStack() })
340
            }
341
            composable<IssueRoute> { e ->
342
                val r = e.toRoute<IssueRoute>()
343
                IssueScreen(rememberViewModel { IssueViewModel(api, r.owner, r.name, r.number) }, canWrite = session.canWrite, onBack = { nav.popBackStack() }, me = session.username)
344
            }
345
            composable<FlowRoute> {
346
                FlowMonitorScreen(rememberViewModel { FlowMonitorViewModel(api) }) { owner, name, number -> nav.navigate(RunRoute(owner, name, number)) }
347
            }
348
            composable<NewRepositoryRoute> {
349
                NewRepositoryScreen(
350
                    rememberViewModel { NewRepositoryViewModel(api, session.username) },
351
                    canCreate = session.canCreate,
352
                    onBack = { nav.popBackStack() },
353
                    onOpen = { r -> nav.navigate(IssuesRoute(r.owner, r.name)) { popUpTo<NewRepositoryRoute> { inclusive = true } } },
354
                )
355
            }
356
            composable<NewIssueRoute> { e ->
357
                val r = e.toRoute<NewIssueRoute>()
358
                NewIssueScreen(
359
                    rememberViewModel { NewIssueViewModel(api, r.owner, r.name) },
360
                    onBack = { nav.popBackStack() },
361
                    onOpened = { n -> nav.navigate(IssueRoute(r.owner, r.name, n)) { popUpTo<NewIssueRoute> { inclusive = true } } },
362
                )
363
            }
364
            composable<FileRoute> { e ->
365
                val r = e.toRoute<FileRoute>()
366
                FileScreen(
367
                    rememberViewModel { FileViewModel(api, r.owner, r.name, r.ref, r.path) },
368
                    onBack = { nav.popBackStack() },
369
                    onOpenHistory = { nav.navigate(HistoryRoute(r.owner, r.name, r.ref, r.path)) },
370
                )
371
            }
372
            composable<HistoryRoute> { e ->
373
                val r = e.toRoute<HistoryRoute>()
374
                HistoryScreen(
375
                    rememberViewModel { HistoryViewModel(api, r.owner, r.name, r.ref, r.path) },
376
                    onBack = { nav.popBackStack() },
377
                    onOpenChange = { nav.navigate(ChangeRoute(r.owner, r.name, it.hash)) },
378
                )
379
            }
380
            composable<ChangeRoute> { e ->
381
                val r = e.toRoute<ChangeRoute>()
382
                ChangeScreen(
383
                    rememberViewModel { ChangeViewModel(api, r.owner, r.name, r.hash) },
384
                    onBack = { nav.popBackStack() },
385
                    onOpenFile = { path -> nav.navigate(FileRoute(r.owner, r.name, r.hash, path)) },
386
                )
387
            }
388
            composable<ProposalRoute> { e ->
389
                val r = e.toRoute<ProposalRoute>()
390
                val scope = rememberCoroutineScope()
391
                ProposalScreen(
392
                    rememberViewModel { ProposalViewModel(api, r.owner, r.name, r.number) },
393
                    canWrite = session.canWrite,
394
                    onBack = { nav.popBackStack() },
395
                    // Signing out revokes this token; the new sign-in asks for repo:write.
396
                    onSignInAgain = { scope.launch { onSignOut() } },
397
                    onOpenFiles = { nav.navigate(ProposalFilesRoute(r.owner, r.name, r.number)) },
398
                )
399
            }
400
            composable<ProposalFilesRoute> { e ->
401
                val r = e.toRoute<ProposalFilesRoute>()
402
                ProposalFilesScreen(rememberViewModel { ProposalFilesViewModel(api, r.owner, r.name, r.number) }, onBack = { nav.popBackStack() })
403
            }
404
            composable<InboxRoute> { InboxScreen(inbox, onOpen = openLink) }
405
            composable<AccountRoute> { AccountScreen(session, rememberAvatar(api, session.username), api, nativeCore, onSignOut) }
406
        }
407
        LaunchedEffect(open) {
408
            open?.let {
409
                nav.navigate(it)
410
                onOpened()
411
            }
412
        }
413
    }
414
}
415
416
/** To a top-level destination, keeping one copy of each and its state, as the bar's own taps do. */
417
private fun NavHostController.goTo(top: TopLevel) {
418
    navigate(top.route) {
419
        popUpTo(graph.findStartDestination().id) { saveState = true }
420
        launchSingleTop = true
421
        restoreState = true
422
    }
423
}
424
425
/** How long a screen takes to come in (#23). */
426
private const val NAV_MS = 300