OXYGENHUB
# 3f3508bd5f16 oxygenhub-android / app / src / main / java / io / oxygenhub / android / ui / nav / AppNavigation.kt
7.1 KiB · last changed Screenshots from Compose previews, and what rendering them showed (refs #2) 11 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.dashboard.DashboardScreen
38
import io.oxygenhub.android.ui.dashboard.DashboardViewModel
39
import io.oxygenhub.android.ui.inbox.InboxScreen
40
import io.oxygenhub.android.ui.inbox.InboxViewModel
41
import io.oxygenhub.android.ui.issues.IssuesScreen
42
import io.oxygenhub.android.ui.issues.IssuesViewModel
43
import io.oxygenhub.android.ui.repos.RepositoriesScreen
44
import io.oxygenhub.android.ui.repos.RepositoriesViewModel
45
import kotlinx.serialization.Serializable
46
import kotlin.reflect.KClass
47
48
// The app's routes. Top-level ones are in the navigation bar (or rail, or
49
// drawer, as the window allows); the rest are pushed over them. Browse
50
// (oxygenhub-android#3) adds a repository's overview, code and history
51
// under the repository's route.
52
53
@Serializable data object DashboardRoute
54
55
@Serializable data object RepositoriesRoute
56
57
@Serializable data object InboxRoute
58
59
@Serializable data object AccountRoute
60
61
/** A repository's issues, opened from outside the repositories list (the dashboard). */
62
@Serializable data class IssuesRoute(val owner: String, val name: String)
63
64
enum class TopLevel(val label: String, val icon: ImageVector, val selectedIcon: ImageVector, val route: Any, val routeClass: KClass<*>) {
65
    Dashboard("Dashboard", Icons.Outlined.Dashboard, Icons.Filled.Dashboard, DashboardRoute, DashboardRoute::class),
66
    Repositories("Repositories", Icons.Outlined.Book, Icons.Filled.Book, RepositoriesRoute, RepositoriesRoute::class),
67
    Inbox("Inbox", Icons.Outlined.Inbox, Icons.Filled.Inbox, InboxRoute, InboxRoute::class),
68
    Account("Account", Icons.Outlined.AccountCircle, Icons.Filled.AccountCircle, AccountRoute, AccountRoute::class),
69
}
70
71
/** A view model made by [make], kept by the screen's back stack entry (or [key] within it). */
72
@Composable
73
inline fun <reified VM : ViewModel> rememberViewModel(key: String? = null, crossinline make: () -> VM): VM =
74
    viewModel(key = key, factory = viewModelFactory { initializer { make() } })
75
76
/** The signed-in app: navigation around the dashboard, repositories, inbox and account. */
77
@Composable
78
fun SignedInApp(
79
    session: Session,
80
    api: HubApi,
81
    nativeCore: () -> String,
82
    onSignOut: suspend () -> Unit,
83
    start: TopLevel = TopLevel.Dashboard,
84
    /** A repository to show beside the list from the start (`owner/name`). */
85
    selectedRepository: String? = null,
86
) {
87
    val nav = rememberNavController()
88
    // One inbox for the badge, the dashboard's count and the inbox screen.
89
    val inbox = rememberViewModel(key = "inbox:${session.hub}:${session.username}") { InboxViewModel(api) }
90
    val inboxState by inbox.state.collectAsStateWithLifecycle()
91
    val unread = inboxState.valueOrNull?.unread ?: 0
92
    val entry by nav.currentBackStackEntryAsState()
93
    val destination = entry?.destination
94
95
    NavigationSuiteScaffold(
96
        navigationSuiteItems = {
97
            TopLevel.entries.forEach { top ->
98
                val selected = destination?.hierarchy?.any { it.hasRoute(top.routeClass) } == true
99
                item(
100
                    selected = selected,
101
                    onClick = { nav.goTo(top) },
102
                    icon = {
103
                        val icon = if (selected) top.selectedIcon else top.icon
104
                        if (top == TopLevel.Inbox && unread > 0) {
105
                            BadgedBox(badge = { Badge { Text(if (unread > 99) "99+" else unread.toString()) } }) {
106
                                Icon(icon, contentDescription = "${top.label}, $unread unread")
107
                            }
108
                        } else {
109
                            Icon(icon, contentDescription = top.label)
110
                        }
111
                    },
112
                    label = { Text(top.label) },
113
                )
114
            }
115
        },
116
    ) {
117
        NavHost(nav, startDestination = start.route) {
118
            composable<DashboardRoute> {
119
                DashboardScreen(
120
                    vm = rememberViewModel { DashboardViewModel(api, session) },
121
                    hub = session.hub,
122
                    inbox = inboxState,
123
                    onRefreshInbox = inbox::refresh,
124
                    onOpenRepository = { nav.navigate(IssuesRoute(it.owner, it.name)) },
125
                    onAllRepositories = { nav.goTo(TopLevel.Repositories) },
126
                    onInbox = { nav.goTo(TopLevel.Inbox) },
127
                )
128
            }
129
            composable<RepositoriesRoute> {
130
                RepositoriesScreen(rememberViewModel { RepositoriesViewModel(api, session.username) }, selectedRepository) { slug, onBack ->
131
                    val (owner, name) = slug.split('/', limit = 2)
132
                    IssuesScreen(rememberViewModel(key = "issues:$slug") { IssuesViewModel(api, owner, name) }, onBack)
133
                }
134
            }
135
            composable<IssuesRoute> { e ->
136
                val r = e.toRoute<IssuesRoute>()
137
                IssuesScreen(rememberViewModel { IssuesViewModel(api, r.owner, r.name) }, onBack = { nav.popBackStack() })
138
            }
139
            composable<InboxRoute> { InboxScreen(inbox) }
140
            composable<AccountRoute> { AccountScreen(session, nativeCore, onSignOut) }
141
        }
142
    }
143
}
144
145
/** To a top-level destination, keeping one copy of each and its state, as the bar's own taps do. */
146
private fun NavHostController.goTo(top: TopLevel) {
147
    navigate(top.route) {
148
        popUpTo(graph.findStartDestination().id) { saveState = true }
149
        launchSingleTop = true
150
        restoreState = true
151
    }
152
}