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