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