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