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