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