OXYGENHUB
# 114865a07877 oxygenhub-android / app / src / main / java / io / oxygenhub / android / ui / nav / AppNavigation.kt
8.8 KiB · last changed The phone as an OxygenFlow runner, first part (refs #8) 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.getValue
19
import androidx.compose.ui.graphics.vector.ImageVector
20
import androidx.lifecycle.ViewModel
21
import androidx.lifecycle.compose.collectAsStateWithLifecycle
22
import androidx.lifecycle.viewmodel.compose.viewModel
23
import androidx.lifecycle.viewmodel.initializer
24
import androidx.lifecycle.viewmodel.viewModelFactory
25
import androidx.navigation.NavDestination.Companion.hasRoute
26
import androidx.navigation.NavDestination.Companion.hierarchy
27
import androidx.navigation.NavGraph.Companion.findStartDestination
28
import androidx.navigation.NavHostController
29
import androidx.navigation.compose.NavHost
30
import androidx.navigation.compose.composable
31
import androidx.navigation.compose.currentBackStackEntryAsState
32
import androidx.navigation.compose.rememberNavController
33
import androidx.navigation.toRoute
34
import io.oxygenhub.android.api.HubApi
35
import io.oxygenhub.android.auth.Session
36
import io.oxygenhub.android.ui.account.AccountScreen
37
import io.oxygenhub.android.ui.common.rememberAvatar
38
import androidx.compose.runtime.rememberCoroutineScope
39
import io.oxygenhub.android.ui.proposals.ProposalScreen
40
import io.oxygenhub.android.ui.proposals.ProposalViewModel
41
import io.oxygenhub.android.ui.proposals.ProposalsViewModel
42
import io.oxygenhub.android.ui.repos.RepositoryScreen
43
import kotlinx.coroutines.launch
44
import io.oxygenhub.android.ui.dashboard.DashboardScreen
45
import io.oxygenhub.android.ui.dashboard.DashboardViewModel
46
import io.oxygenhub.android.ui.inbox.InboxScreen
47
import io.oxygenhub.android.ui.inbox.InboxViewModel
48
import io.oxygenhub.android.ui.issues.IssuesViewModel
49
import io.oxygenhub.android.ui.repos.RepositoriesScreen
50
import io.oxygenhub.android.ui.repos.RepositoriesViewModel
51
import kotlinx.serialization.Serializable
52
import kotlin.reflect.KClass
53
54
// The app's routes. Top-level ones are in the navigation bar (or rail, or
55
// drawer, as the window allows); the rest are pushed over them. Browse
56
// (oxygenhub-android#3) adds a repository's overview, code and history
57
// under the repository's route.
58
59
@Serializable data object DashboardRoute
60
61
@Serializable data object RepositoriesRoute
62
63
@Serializable data object InboxRoute
64
65
@Serializable data object AccountRoute
66
67
/** A repository's issues, opened from outside the repositories list (the dashboard). */
68
@Serializable data class IssuesRoute(val owner: String, val name: String)
69
70
/** One proposal, pushed over the repository it belongs to. */
71
@Serializable data class ProposalRoute(val owner: String, val name: String, val number: Long)
72
73
enum class TopLevel(val label: String, val icon: ImageVector, val selectedIcon: ImageVector, val route: Any, val routeClass: KClass<*>) {
74
    Dashboard("Dashboard", Icons.Outlined.Dashboard, Icons.Filled.Dashboard, DashboardRoute, DashboardRoute::class),
75
    Repositories("Repositories", Icons.Outlined.Book, Icons.Filled.Book, RepositoriesRoute, RepositoriesRoute::class),
76
    Inbox("Inbox", Icons.Outlined.Inbox, Icons.Filled.Inbox, InboxRoute, InboxRoute::class),
77
    Account("Account", Icons.Outlined.AccountCircle, Icons.Filled.AccountCircle, AccountRoute, AccountRoute::class),
78
}
79
80
/** A view model made by [make], kept by the screen's back stack entry (or [key] within it). */
81
@Composable
82
inline fun <reified VM : ViewModel> rememberViewModel(key: String? = null, crossinline make: () -> VM): VM =
83
    viewModel(key = key, factory = viewModelFactory { initializer { make() } })
84
85
/** The signed-in app: navigation around the dashboard, repositories, inbox and account. */
86
@Composable
87
fun SignedInApp(
88
    session: Session,
89
    api: HubApi,
90
    nativeCore: () -> String,
91
    onSignOut: suspend () -> Unit,
92
    start: TopLevel = TopLevel.Dashboard,
93
    /** A repository to show beside the list from the start (`owner/name`). */
94
    selectedRepository: String? = null,
95
) {
96
    val nav = rememberNavController()
97
    // One inbox for the badge, the dashboard's count and the inbox screen.
98
    val inbox = rememberViewModel(key = "inbox:${session.hub}:${session.username}") { InboxViewModel(api) }
99
    val inboxState by inbox.state.collectAsStateWithLifecycle()
100
    val unread = inboxState.valueOrNull?.unread ?: 0
101
    val entry by nav.currentBackStackEntryAsState()
102
    val destination = entry?.destination
103
104
    NavigationSuiteScaffold(
105
        navigationSuiteItems = {
106
            TopLevel.entries.forEach { top ->
107
                val selected = destination?.hierarchy?.any { it.hasRoute(top.routeClass) } == true
108
                item(
109
                    selected = selected,
110
                    onClick = { nav.goTo(top) },
111
                    icon = {
112
                        val icon = if (selected) top.selectedIcon else top.icon
113
                        if (top == TopLevel.Inbox && unread > 0) {
114
                            BadgedBox(badge = { Badge { Text(if (unread > 99) "99+" else unread.toString()) } }) {
115
                                Icon(icon, contentDescription = "${top.label}, $unread unread")
116
                            }
117
                        } else {
118
                            Icon(icon, contentDescription = top.label)
119
                        }
120
                    },
121
                    label = { Text(top.label) },
122
                )
123
            }
124
        },
125
    ) {
126
        NavHost(nav, startDestination = start.route) {
127
            composable<DashboardRoute> {
128
                DashboardScreen(
129
                    vm = rememberViewModel { DashboardViewModel(api, session) },
130
                    hub = session.hub,
131
                    inbox = inboxState,
132
                    onRefreshInbox = inbox::refresh,
133
                    onOpenRepository = { nav.navigate(IssuesRoute(it.owner, it.name)) },
134
                    onAllRepositories = { nav.goTo(TopLevel.Repositories) },
135
                    onInbox = { nav.goTo(TopLevel.Inbox) },
136
                    onOpenProposal = { nav.navigate(ProposalRoute(it.owner, it.name, it.number)) },
137
                )
138
            }
139
            composable<RepositoriesRoute> {
140
                RepositoriesScreen(rememberViewModel { RepositoriesViewModel(api, session.username) }, selectedRepository) { slug, onBack ->
141
                    val (owner, name) = slug.split('/', limit = 2)
142
                    RepositoryScreen(
143
                        rememberViewModel(key = "issues:$slug") { IssuesViewModel(api, owner, name) },
144
                        rememberViewModel(key = "proposals:$slug") { ProposalsViewModel(api, owner, name) },
145
                        onBack,
146
                        onOpenProposal = { nav.navigate(ProposalRoute(owner, name, it.number)) },
147
                    )
148
                }
149
            }
150
            composable<IssuesRoute> { e ->
151
                val r = e.toRoute<IssuesRoute>()
152
                RepositoryScreen(
153
                    rememberViewModel { IssuesViewModel(api, r.owner, r.name) },
154
                    rememberViewModel { ProposalsViewModel(api, r.owner, r.name) },
155
                    onBack = { nav.popBackStack() },
156
                    onOpenProposal = { nav.navigate(ProposalRoute(r.owner, r.name, it.number)) },
157
                )
158
            }
159
            composable<ProposalRoute> { e ->
160
                val r = e.toRoute<ProposalRoute>()
161
                val scope = rememberCoroutineScope()
162
                ProposalScreen(
163
                    rememberViewModel { ProposalViewModel(api, r.owner, r.name, r.number) },
164
                    canWrite = session.canWrite,
165
                    onBack = { nav.popBackStack() },
166
                    // Signing out revokes this token; the new sign-in asks for repo:write.
167
                    onSignInAgain = { scope.launch { onSignOut() } },
168
                )
169
            }
170
            composable<InboxRoute> { InboxScreen(inbox) }
171
            composable<AccountRoute> { AccountScreen(session, rememberAvatar(api, session.username), api, nativeCore, onSignOut) }
172
        }
173
    }
174
}
175
176
/** To a top-level destination, keeping one copy of each and its state, as the bar's own taps do. */
177
private fun NavHostController.goTo(top: TopLevel) {
178
    navigate(top.route) {
179
        popUpTo(graph.findStartDestination().id) { saveState = true }
180
        launchSingleTop = true
181
        restoreState = true
182
    }
183
}