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