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