OXYGENHUB
# 31e4f3755627 oxygenhub-android / app / src / main / java / io / oxygenhub / android / ui / nav / AppNavigation.kt
13.1 KiB · last changed Issues on the phone: read, comment, close and reopen, labels, and new issues (refs #4) 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.outlined.AccountCircle
9
import androidx.compose.material.icons.outlined.Book
10
import androidx.compose.material.icons.outlined.Dashboard
11
import androidx.compose.material.icons.outlined.Inbox
12
import androidx.compose.material3.Badge
13
import androidx.compose.material3.BadgedBox
14
import androidx.compose.material3.Icon
15
import androidx.compose.material3.Text
16
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
17
import androidx.compose.runtime.Composable
18
import androidx.compose.runtime.LaunchedEffect
19
import androidx.compose.runtime.getValue
20
import androidx.compose.runtime.rememberCoroutineScope
21
import androidx.compose.ui.graphics.vector.ImageVector
22
import androidx.lifecycle.ViewModel
23
import androidx.lifecycle.compose.collectAsStateWithLifecycle
24
import androidx.lifecycle.viewmodel.compose.viewModel
25
import androidx.lifecycle.viewmodel.initializer
26
import androidx.lifecycle.viewmodel.viewModelFactory
27
import androidx.navigation.NavDestination.Companion.hasRoute
28
import androidx.navigation.NavDestination.Companion.hierarchy
29
import androidx.navigation.NavGraph.Companion.findStartDestination
30
import androidx.navigation.NavHostController
31
import androidx.navigation.compose.NavHost
32
import androidx.navigation.compose.composable
33
import androidx.navigation.compose.currentBackStackEntryAsState
34
import androidx.navigation.compose.rememberNavController
35
import androidx.navigation.toRoute
36
import io.oxygenhub.android.api.HubApi
37
import io.oxygenhub.android.auth.Session
38
import io.oxygenhub.android.ui.account.AccountScreen
39
import io.oxygenhub.android.ui.code.ChangeScreen
40
import io.oxygenhub.android.ui.code.ChangeViewModel
41
import io.oxygenhub.android.ui.code.CodeViewModel
42
import io.oxygenhub.android.ui.code.FileScreen
43
import io.oxygenhub.android.ui.code.FileViewModel
44
import io.oxygenhub.android.ui.code.HistoryScreen
45
import io.oxygenhub.android.ui.code.HistoryViewModel
46
import io.oxygenhub.android.ui.common.rememberAvatar
47
import io.oxygenhub.android.ui.dashboard.DashboardScreen
48
import io.oxygenhub.android.ui.dashboard.DashboardViewModel
49
import io.oxygenhub.android.ui.inbox.InboxScreen
50
import io.oxygenhub.android.ui.inbox.InboxViewModel
51
import io.oxygenhub.android.ui.issues.IssueScreen
52
import io.oxygenhub.android.ui.issues.IssueViewModel
53
import io.oxygenhub.android.ui.issues.IssuesViewModel
54
import io.oxygenhub.android.ui.issues.NewIssueScreen
55
import io.oxygenhub.android.ui.issues.NewIssueViewModel
56
import io.oxygenhub.android.ui.proposals.ProposalScreen
57
import io.oxygenhub.android.ui.proposals.ProposalViewModel
58
import io.oxygenhub.android.ui.proposals.ProposalsViewModel
59
import io.oxygenhub.android.ui.repos.RepositoriesScreen
60
import io.oxygenhub.android.ui.repos.RepositoriesViewModel
61
import io.oxygenhub.android.ui.repos.RepositoryScreen
62
import kotlin.reflect.KClass
63
import kotlinx.coroutines.launch
64
import kotlinx.serialization.Serializable
65
66
// The app's routes. Top-level ones are in the navigation bar (or rail, or
67
// drawer, as the window allows); the rest are pushed over them. Browse
68
// (oxygenhub-android#3) adds a repository's overview, code and history
69
// under the repository's route.
70
71
@Serializable data object DashboardRoute
72
73
@Serializable data object RepositoriesRoute
74
75
@Serializable data object InboxRoute
76
77
@Serializable data object AccountRoute
78
79
/** A repository's issues, opened from outside the repositories list (the dashboard). */
80
@Serializable data class IssuesRoute(val owner: String, val name: String)
81
82
/** One proposal, pushed over the repository it belongs to. */
83
@Serializable data class ProposalRoute(val owner: String, val name: String, val number: Long)
84
85
/** A file at a reference, a history (of a file when [path] is given), and a change (#3). */
86
@Serializable data class FileRoute(val owner: String, val name: String, val ref: String, val path: String)
87
88
@Serializable data class HistoryRoute(val owner: String, val name: String, val ref: String, val path: String? = null)
89
90
@Serializable data class ChangeRoute(val owner: String, val name: String, val hash: String)
91
92
/** An issue, and a new one (#4). */
93
@Serializable data class IssueRoute(val owner: String, val name: String, val number: Long)
94
95
@Serializable data class NewIssueRoute(val owner: String, val name: String)
96
97
enum class TopLevel(val label: String, val icon: ImageVector, val selectedIcon: ImageVector, val route: Any, val routeClass: KClass<*>) {
98
    Dashboard("Dashboard", Icons.Outlined.Dashboard, Icons.Filled.Dashboard, DashboardRoute, DashboardRoute::class),
99
    Repositories("Repositories", Icons.Outlined.Book, Icons.Filled.Book, RepositoriesRoute, RepositoriesRoute::class),
100
    Inbox("Inbox", Icons.Outlined.Inbox, Icons.Filled.Inbox, InboxRoute, InboxRoute::class),
101
    Account("Account", Icons.Outlined.AccountCircle, Icons.Filled.AccountCircle, AccountRoute, AccountRoute::class),
102
}
103
104
/** A view model made by [make], kept by the screen's back stack entry (or [key] within it). */
105
@Composable
106
inline fun <reified VM : ViewModel> rememberViewModel(key: String? = null, crossinline make: () -> VM): VM =
107
    viewModel(key = key, factory = viewModelFactory { initializer { make() } })
108
109
/** The signed-in app: navigation around the dashboard, repositories, inbox and account. */
110
@Composable
111
fun SignedInApp(
112
    session: Session,
113
    api: HubApi,
114
    nativeCore: () -> String,
115
    onSignOut: suspend () -> Unit,
116
    start: TopLevel = TopLevel.Dashboard,
117
    /** A repository to show beside the list from the start (`owner/name`). */
118
    selectedRepository: String? = null,
119
    /** A proposal to open, as a notification asks (#20); [onOpened] once it is. */
120
    open: ProposalRoute? = null,
121
    onOpened: () -> Unit = {},
122
) {
123
    val nav = rememberNavController()
124
    // One inbox for the badge, the dashboard's count and the inbox screen.
125
    val inbox = rememberViewModel(key = "inbox:${session.hub}:${session.username}") { InboxViewModel(api) }
126
    val inboxState by inbox.state.collectAsStateWithLifecycle()
127
    val unread = inboxState.valueOrNull?.unread ?: 0
128
    val entry by nav.currentBackStackEntryAsState()
129
    val destination = entry?.destination
130
131
    NavigationSuiteScaffold(
132
        navigationSuiteItems = {
133
            TopLevel.entries.forEach { top ->
134
                val selected = destination?.hierarchy?.any { it.hasRoute(top.routeClass) } == true
135
                item(
136
                    selected = selected,
137
                    onClick = { nav.goTo(top) },
138
                    icon = {
139
                        val icon = if (selected) top.selectedIcon else top.icon
140
                        if (top == TopLevel.Inbox && unread > 0) {
141
                            BadgedBox(badge = { Badge { Text(if (unread > 99) "99+" else unread.toString()) } }) {
142
                                Icon(icon, contentDescription = "${top.label}, $unread unread")
143
                            }
144
                        } else {
145
                            Icon(icon, contentDescription = top.label)
146
                        }
147
                    },
148
                    label = { Text(top.label) },
149
                )
150
            }
151
        },
152
    ) {
153
        NavHost(nav, startDestination = start.route) {
154
            composable<DashboardRoute> {
155
                DashboardScreen(
156
                    vm = rememberViewModel { DashboardViewModel(api, session) },
157
                    hub = session.hub,
158
                    inbox = inboxState,
159
                    onRefreshInbox = inbox::refresh,
160
                    onOpenRepository = { nav.navigate(IssuesRoute(it.owner, it.name)) },
161
                    onAllRepositories = { nav.goTo(TopLevel.Repositories) },
162
                    onInbox = { nav.goTo(TopLevel.Inbox) },
163
                    onOpenProposal = { nav.navigate(ProposalRoute(it.owner, it.name, it.number)) },
164
                )
165
            }
166
            composable<RepositoriesRoute> {
167
                RepositoriesScreen(rememberViewModel { RepositoriesViewModel(api, session.username) }, selectedRepository) { slug, onBack ->
168
                    val (owner, name) = slug.split('/', limit = 2)
169
                    RepositoryScreen(
170
                        rememberViewModel(key = "code:$slug") { CodeViewModel(api, owner, name) },
171
                        rememberViewModel(key = "issues:$slug") { IssuesViewModel(api, owner, name) },
172
                        rememberViewModel(key = "proposals:$slug") { ProposalsViewModel(api, owner, name) },
173
                        onBack,
174
                        onOpenProposal = { nav.navigate(ProposalRoute(owner, name, it.number)) },
175
                        onOpenFile = { ref, path -> nav.navigate(FileRoute(owner, name, ref, path)) },
176
                        onOpenHistory = { ref -> nav.navigate(HistoryRoute(owner, name, ref)) },
177
                        onOpenIssue = { nav.navigate(IssueRoute(owner, name, it)) },
178
                        onNewIssue = if (session.canWrite) { { nav.navigate(NewIssueRoute(owner, name)) } } else null,
179
                    )
180
                }
181
            }
182
            composable<IssuesRoute> { e ->
183
                val r = e.toRoute<IssuesRoute>()
184
                RepositoryScreen(
185
                    rememberViewModel { CodeViewModel(api, r.owner, r.name) },
186
                    rememberViewModel { IssuesViewModel(api, r.owner, r.name) },
187
                    rememberViewModel { ProposalsViewModel(api, r.owner, r.name) },
188
                    onBack = { nav.popBackStack() },
189
                    onOpenProposal = { nav.navigate(ProposalRoute(r.owner, r.name, it.number)) },
190
                    onOpenFile = { ref, path -> nav.navigate(FileRoute(r.owner, r.name, ref, path)) },
191
                    onOpenHistory = { ref -> nav.navigate(HistoryRoute(r.owner, r.name, ref)) },
192
                    onOpenIssue = { nav.navigate(IssueRoute(r.owner, r.name, it)) },
193
                    onNewIssue = if (session.canWrite) { { nav.navigate(NewIssueRoute(r.owner, r.name)) } } else null,
194
                )
195
            }
196
            composable<IssueRoute> { e ->
197
                val r = e.toRoute<IssueRoute>()
198
                IssueScreen(rememberViewModel { IssueViewModel(api, r.owner, r.name, r.number) }, canWrite = session.canWrite, onBack = { nav.popBackStack() })
199
            }
200
            composable<NewIssueRoute> { e ->
201
                val r = e.toRoute<NewIssueRoute>()
202
                NewIssueScreen(
203
                    rememberViewModel { NewIssueViewModel(api, r.owner, r.name) },
204
                    onBack = { nav.popBackStack() },
205
                    onOpened = { n -> nav.navigate(IssueRoute(r.owner, r.name, n)) { popUpTo<NewIssueRoute> { inclusive = true } } },
206
                )
207
            }
208
            composable<FileRoute> { e ->
209
                val r = e.toRoute<FileRoute>()
210
                FileScreen(
211
                    rememberViewModel { FileViewModel(api, r.owner, r.name, r.ref, r.path) },
212
                    onBack = { nav.popBackStack() },
213
                    onOpenHistory = { nav.navigate(HistoryRoute(r.owner, r.name, r.ref, r.path)) },
214
                )
215
            }
216
            composable<HistoryRoute> { e ->
217
                val r = e.toRoute<HistoryRoute>()
218
                HistoryScreen(
219
                    rememberViewModel { HistoryViewModel(api, r.owner, r.name, r.ref, r.path) },
220
                    onBack = { nav.popBackStack() },
221
                    onOpenChange = { nav.navigate(ChangeRoute(r.owner, r.name, it.hash)) },
222
                )
223
            }
224
            composable<ChangeRoute> { e ->
225
                val r = e.toRoute<ChangeRoute>()
226
                ChangeScreen(
227
                    rememberViewModel { ChangeViewModel(api, r.owner, r.name, r.hash) },
228
                    onBack = { nav.popBackStack() },
229
                    onOpenFile = { path -> nav.navigate(FileRoute(r.owner, r.name, r.hash, path)) },
230
                )
231
            }
232
            composable<ProposalRoute> { e ->
233
                val r = e.toRoute<ProposalRoute>()
234
                val scope = rememberCoroutineScope()
235
                ProposalScreen(
236
                    rememberViewModel { ProposalViewModel(api, r.owner, r.name, r.number) },
237
                    canWrite = session.canWrite,
238
                    onBack = { nav.popBackStack() },
239
                    // Signing out revokes this token; the new sign-in asks for repo:write.
240
                    onSignInAgain = { scope.launch { onSignOut() } },
241
                )
242
            }
243
            composable<InboxRoute> { InboxScreen(inbox) }
244
            composable<AccountRoute> { AccountScreen(session, rememberAvatar(api, session.username), api, nativeCore, onSignOut) }
245
        }
246
        LaunchedEffect(open) {
247
            open?.let {
248
                nav.navigate(it)
249
                onOpened()
250
            }
251
        }
252
    }
253
}
254
255
/** To a top-level destination, keeping one copy of each and its state, as the bar's own taps do. */
256
private fun NavHostController.goTo(top: TopLevel) {
257
    navigate(top.route) {
258
        popUpTo(graph.findStartDestination().id) { saveState = true }
259
        launchSingleTop = true
260
        restoreState = true
261
    }
262
}