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