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