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