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