/* * SPDX-License-Identifier: GPL-3.0-or-later */ package com.runicgateway.app.ui import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ExitToApp import androidx.compose.material.icons.filled.Menu import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.NavigationDrawerItemColors import androidx.compose.material3.NavigationDrawerItemDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.runicgateway.app.R import com.runicgateway.app.core.auth.Session import com.runicgateway.app.core.web.WebHandoff import com.runicgateway.app.data.api.dto.BrandDto import com.runicgateway.app.data.appearance.SiteAppearance import com.runicgateway.app.data.repository.Capability import com.runicgateway.app.ui.auth.AccountScreen import com.runicgateway.app.ui.auth.LoginScreen import com.runicgateway.app.ui.auth.RecoveryCodesScreen import com.runicgateway.app.ui.auth.TrustedDevicesScreen import com.runicgateway.app.ui.auth.roleLabelRes import com.runicgateway.app.ui.components.BrandLogo import com.runicgateway.app.ui.contact.ContactScreen import com.runicgateway.app.ui.events.EventScreen import com.runicgateway.app.ui.events.EventSeriesScreen import com.runicgateway.app.ui.events.EventsScreen import com.runicgateway.app.ui.events.MyEventsScreen import com.runicgateway.app.ui.home.HomeScreen import com.runicgateway.app.ui.navigation.APP_MENU import com.runicgateway.app.ui.navigation.NavNode import com.runicgateway.app.ui.navigation.Routes import com.runicgateway.app.ui.navigation.buildNavTree import com.runicgateway.app.ui.navigation.isEntryVisible import com.runicgateway.app.ui.navigation.pruneNav import com.runicgateway.app.ui.news.NewsScreen import com.runicgateway.app.ui.news.PostScreen import com.runicgateway.app.ui.admin.AdminContentScreen import com.runicgateway.app.ui.admin.AdminDashboardScreen import com.runicgateway.app.ui.admin.AdminModerationScreen import com.runicgateway.app.ui.admin.AdminSupportScreen import com.runicgateway.app.ui.notifications.InboxBadgeViewModel import com.runicgateway.app.ui.notifications.InboxScreen import com.runicgateway.app.ui.notifications.NotificationSettingsScreen import com.runicgateway.app.ui.page.PageScreen import com.runicgateway.app.ui.player.CharacterSheetScreen import com.runicgateway.app.ui.player.CharactersScreen import com.runicgateway.app.ui.player.MyHousesScreen import com.runicgateway.app.ui.player.VendorsScreen import com.runicgateway.app.ui.session.SessionViewModel import com.runicgateway.app.ui.shard.AtlasCreatureScreen import com.runicgateway.app.ui.shard.AtlasScreen import com.runicgateway.app.ui.shard.ChampsScreen import com.runicgateway.app.ui.shard.GovernorsScreen import com.runicgateway.app.ui.shard.GuildsScreen import com.runicgateway.app.ui.shard.HousesScreen import com.runicgateway.app.ui.shard.LeaderboardsScreen import com.runicgateway.app.ui.shard.MarketScreen import com.runicgateway.app.ui.shard.MarketVendorScreen import com.runicgateway.app.ui.shard.RulesScreen import com.runicgateway.app.ui.shard.ShardBoard import com.runicgateway.app.ui.rust.RustBadgeViewModel import com.runicgateway.app.ui.rust.RustAccountScreen import com.runicgateway.app.ui.rust.RustServerScreen import com.runicgateway.app.ui.rust.RustServersScreen import com.runicgateway.app.ui.shard.ShardScreen import com.runicgateway.app.ui.theme.LocalShardStructure import com.runicgateway.app.ui.wiki.WikiPageScreen import com.runicgateway.app.ui.wiki.WikiScreen import kotlinx.coroutines.launch /** Destinations that show the drawer (hamburger); others show a back arrow. */ private val TOP_LEVEL_ROUTES = setOf( Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT, // Protocol 3.0 content screens are drawer destinations, so the drawer gesture works // on them too (M11). Routes.SHARD_RULES, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.ATLAS, Routes.NOTIFICATIONS, // Events (M13): the calendar and the history are drawer rows, so the drawer // gesture works on them. The event page and an arc are detail screens and are // deliberately absent — a back gesture there means "back", not "open the menu". Routes.EVENTS, Routes.MY_EVENTS, // The Rust server list is a drawer row (M14); one server's page is a detail // screen and is deliberately absent — a back gesture there means "back". Routes.RUST, Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES, // The player's own Rust account (M15) — a drawer row like the three above it. Routes.PLAYER_RUST, Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT, ) /** * The main app shell once a shard site is configured (PLAN.md §5): one shared, * declarative, access-level navigation drawer whose entries are filtered by the * current session, plus the Sign in / Sign out toggle and the Settings → Server * switch. The signed-in role is re-validated against the backend on every resume * (§4.3), so a server-side demotion drops menu access promptly. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun RunicApp( appearance: SiteAppearance, onChangeServer: () -> Unit, modifier: Modifier = Modifier, deepLinkStream: String? = null, deepLinkRef: String? = null, onDeepLinkConsumed: () -> Unit = {}, sessionViewModel: SessionViewModel = hiltViewModel(), inboxBadgeViewModel: InboxBadgeViewModel = hiltViewModel(), rustBadgeViewModel: RustBadgeViewModel = hiltViewModel(), ) { val brand = appearance.brand val navController = rememberNavController() val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() val session by sessionViewModel.session.collectAsStateWithLifecycle() // What this shard publishes, independently of who the caller is (§5, M11). val shardFeatures by sessionViewModel.shardFeatures.collectAsStateWithLifecycle() // What this BACKEND serves at all, independently of both (§5, M13). A different // question from the line above and gated separately — see `isEntryVisible`. val capabilities by sessionViewModel.capabilities.collectAsStateWithLifecycle() // Re-validate the cached role each time the app returns to the foreground (§4.3), // and re-read the two drawer counts with it: a tickle that arrived while the app // was away is exactly what brings someone back to it, and a live player count is // only live if it is re-read when somebody looks. LifecycleResumeEffect(capabilities) { sessionViewModel.revalidate() inboxBadgeViewModel.refresh() // The Rust count is a LIVE number, so it is re-read on the same clock the // unread badge is: coming back to the app is exactly when a stale one // would be noticed. Keyed on the capability answer as well as on resume, // because the very first resume happens before this host has said whether // the module is there — and asking then would either make a request on a // site that has no Rust, or never make one at all. capabilities?.let { rustBadgeViewModel.refresh(Capability.RUST in it) } onPauseOrDispose { } } val unread by inboxBadgeViewModel.unread.collectAsStateWithLifecycle() // How many people are on the Rust fleet, for the drawer row's badge — the // phone's answer to D15's footer count (M14). Refreshed on resume, never on a // timer: a badge is a glance, not a feed. Gated here rather than inside the // view model because this is the only place that knows whether the module is // installed at all, and a host that has not answered yet asks nothing. val rustOnline by rustBadgeViewModel.online.collectAsStateWithLifecycle() // The badge follows the session, so signing out clears it rather than leaving // the previous account's count on the drawer. LaunchedEffect(session) { inboxBadgeViewModel.refresh() } // A tapped push notification deep-links to its stream's screen (§11, item 7). LaunchedEffect(deepLinkStream, deepLinkRef) { val stream = deepLinkStream ?: return@LaunchedEffect // Both halves of the tickle: a `notification:` ref means there is an inbox // row waiting, and that is where the tap goes (ENGAGEMENT.md phase 8). navController.navigate(Routes.forTickle(stream, deepLinkRef)) { popUpTo(Routes.HOME) { saveState = true } launchSingleTop = true } onDeepLinkConsumed() } val backStackEntry by navController.currentBackStackEntryAsState() // A destination's route is its NavHost *pattern*, so News reports // "news?category={category}" (§6.2). Compare on the part before the query. val currentRoute = backStackEntry?.destination?.route?.substringBefore('?') val isTopLevel = currentRoute in TOP_LEVEL_ROUTES // The admin's nav overrides, then the gates — never the other way round. An // override is presentation only: it may relabel, reorder, group and hide, so // `pruneNav` still decides what this caller may see and remains the boundary // (§6.1, AC-3). With no stored row the merge returns APP_MENU itself. val nav = pruneNav(buildNavTree(APP_MENU, appearance.navPublic)) { isEntryVisible(it, session, shardFeatures, capabilities) } val context = LocalContext.current // An added link's path is site-relative; a hand-off needs it absolute against // the configured base URL, which is exactly what the asset resolver does (§6.3). val resolveUrl = LocalAssetResolver.current val openNode: (NavNode) -> Unit = { node -> scope.launch { drawerState.close() } when (node) { is NavNode.Item -> navController.navigateTopLevel(node.entry.route) // A link the app resolved opens like any other drawer row, detail screen // or not: one rule, and back-press lands on Home as it does from every // row. One it could not resolve goes to the browser, absolute against // the site's base URL (§6.3). is NavNode.Link -> node.route ?.let { navController.navigateTopLevel(it) } ?: resolveUrl(node.path)?.let { WebHandoff.open(context, it) } // Section headers aren't clickable — the group is always open (§6.3). is NavNode.Section -> Unit } } ModalNavigationDrawer( drawerState = drawerState, gesturesEnabled = isTopLevel, drawerContent = { ModalDrawerSheet(drawerContainerColor = MaterialTheme.colorScheme.surfaceVariant) { val drawerItemColors = NavigationDrawerItemDefaults.colors( selectedContainerColor = MaterialTheme.colorScheme.secondaryContainer, selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer, unselectedTextColor = MaterialTheme.colorScheme.onSurface, ) // Scroll the drawer: a signed-in session adds Account, Notifications, and // the player groups, and the full list overflows a phone's drawer height — // without this the lower entries (Notifications included) are clipped and // unreachable. See RunicGateway M10. Column(Modifier.verticalScroll(rememberScrollState())) { Spacer(Modifier.height(12.dp)) // The instance's logo above its name (§5.6). Decorative — the name // is the very next line — and absent on an instance that uploaded // none, in which case the header is exactly what it was before M12. BrandLogo( logo = brand?.logo, height = 32.dp, modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 4.dp), ) Text( text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp), ) HorizontalDivider() Spacer(Modifier.height(8.dp)) nav.forEach { node -> if (node is NavNode.Section) { // A group the admin created: its label as a header, its rows // beneath it. Always open — a drawer is already a vertical // list, so the website's dropdown does not translate (§6.3). Text( text = node.label, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding( start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp, ), ) node.items.forEach { child -> NavRow( node = child, currentRoute = currentRoute, colors = drawerItemColors, indented = true, unread = unread, rustOnline = rustOnline, ) { openNode(child) } } } else { NavRow( node = node, currentRoute = currentRoute, colors = drawerItemColors, unread = unread, rustOnline = rustOnline, ) { openNode(node) } } } HorizontalDivider(Modifier.padding(vertical = 8.dp)) // Sign in / Sign out toggles on the session (§5). val signInLabel = if (session is Session.SignedIn) { R.string.menu_sign_out } else { R.string.menu_sign_in } NavigationDrawerItem( label = { Text(stringResource(signInLabel)) }, selected = false, onClick = { scope.launch { drawerState.close() } if (session is Session.SignedIn) { sessionViewModel.signOut() navController.navigateTopLevel(Routes.HOME) } else { navController.navigate(Routes.LOGIN) } }, colors = drawerItemColors, shape = LocalShardStructure.current.pill, modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding), ) NavigationDrawerItem( label = { Text(stringResource(R.string.menu_change_server)) }, selected = false, onClick = { scope.launch { drawerState.close() } onChangeServer() }, colors = drawerItemColors, shape = LocalShardStructure.current.pill, modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding), ) } } }, ) { Scaffold( modifier = modifier, topBar = { TopAppBar( colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surfaceVariant, titleContentColor = MaterialTheme.colorScheme.onSurface, navigationIconContentColor = MaterialTheme.colorScheme.onSurface, actionIconContentColor = MaterialTheme.colorScheme.onSurface, ), title = { val name = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name) // The logo stands in for the title here, so unlike the drawer's // it is named for a screen reader — and it falls back to the // text when the instance has no logo or the load fails (§5.6). BrandLogo( logo = brand?.logo, height = 24.dp, contentDescription = name, ) { Text( text = name.uppercase(), style = MaterialTheme.typography.titleSmall .copy(letterSpacing = 1.2.sp), maxLines = 1, overflow = TextOverflow.Ellipsis, ) } }, navigationIcon = { if (isTopLevel) { IconButton(onClick = { scope.launch { drawerState.open() } }) { Icon(Icons.Filled.Menu, stringResource(R.string.nav_open_menu)) } } else { IconButton(onClick = { navController.popBackStack() }) { Icon( Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.action_back), ) } } }, ) }, ) { innerPadding -> RunicNavHost( navController = navController, brand = brand, session = session, onSignOut = { sessionViewModel.signOut() }, onSignOutEverywhere = { sessionViewModel.signOut(allDevices = true) }, modifier = Modifier.padding(innerPadding), ) } } } /** * One drawer row: a coded entry, or an admin's added link (§6.3). * * A link that the app can open natively is deliberately indistinguishable from a * coded row — that is the point of resolving it. One that hands off to the browser * carries a trailing icon, so leaving the app is never a surprise. */ @Composable private fun NavRow( node: NavNode, currentRoute: String?, colors: NavigationDrawerItemColors, indented: Boolean = false, unread: Int = 0, rustOnline: Int = 0, onClick: () -> Unit, ) { val route = when (node) { is NavNode.Item -> node.entry.route is NavNode.Link -> node.route is NavNode.Section -> null } val label = when (node) { // An admin's label wins over the bundled one, and is the same string in // every locale — see MenuEntry.label. is NavNode.Item -> node.entry.label ?: stringResource(node.entry.labelRes) is NavNode.Link -> node.label is NavNode.Section -> return } val handsOff = node is NavNode.Link && node.route == null // The unread count rides on whichever row leads to the inbox — including an // admin's own nav override pointing at it, since the badge belongs to the // destination, not to the bundled entry. val showsUnread = !handsOff && unread > 0 && route == Routes.NOTIFICATIONS // The live player count rides on whichever row leads to the Rust list, for the // same reason the unread count rides on whichever leads to the inbox — the // number belongs to the destination, not to the bundled entry, so an admin's // own nav override pointing there carries it too. // // **Zero renders nothing**, rather than a `0`: an empty fleet is not a // notification, and a badge that read `0` on a site whose servers are simply // quiet would be worse than no badge at all. val showsRustOnline = !handsOff && rustOnline > 0 && route == Routes.RUST NavigationDrawerItem( label = { Text(label) }, selected = route != null && currentRoute == route.substringBefore('?'), onClick = onClick, badge = when { handsOff -> { { Icon( Icons.AutoMirrored.Filled.ExitToApp, contentDescription = stringResource(R.string.nav_opens_in_browser), modifier = Modifier.size(18.dp), ) } } showsUnread -> { { // Named for a screen reader: "7" beside "Notifications" reads as // a count to a sighted user and as a bare number to everyone else. val spoken = stringResource(R.string.inbox_unread_count, unread) Text( text = unread.toString(), style = MaterialTheme.typography.labelLarge, modifier = Modifier.semantics { contentDescription = spoken }, ) } } showsRustOnline -> { { // Named for a screen reader: "42" beside "Rust servers" reads // as a count to a sighted user and as a bare number to // everyone else. val spoken = stringResource(R.string.rust_online_badge, rustOnline) Text( text = rustOnline.toString(), style = MaterialTheme.typography.labelLarge, modifier = Modifier.semantics { contentDescription = spoken }, ) } } else -> null }, colors = colors, // Like Card's elevation, NavigationDrawerItem takes its shape as a default // argument (CircleShape) rather than from the theme, so --radius-pill has to // be handed to it at every call site or the selected row stays fully round // while every other radius follows the shard (phase 8's AC-5 walk). shape = LocalShardStructure.current.pill, modifier = Modifier .padding(NavigationDrawerItemDefaults.ItemPadding) .padding(start = if (indented) 16.dp else 0.dp), ) } @Composable private fun RunicNavHost( navController: NavHostController, brand: BrandDto?, session: Session, onSignOut: () -> Unit, onSignOutEverywhere: () -> Unit, modifier: Modifier = Modifier, ) { NavHost( navController = navController, startDestination = Routes.HOME, modifier = modifier, ) { composable(Routes.HOME) { HomeScreen(brand = brand) } // The category is optional: navigating to plain Routes.NEWS matches this // pattern with no argument and opens the default tab, which is every route // into the screen except an admin's nav override or added link (§6.2). composable( route = Routes.NEWS_ROUTE, arguments = listOf( navArgument(Routes.Args.CATEGORY) { type = NavType.StringType nullable = true defaultValue = null }, ), ) { NewsScreen(onOpenPost = { category, idOrSlug -> navController.navigate(Routes.post(category, idOrSlug)) }) } composable( route = Routes.POST, arguments = listOf( navArgument(Routes.Args.CATEGORY) { type = NavType.StringType }, navArgument(Routes.Args.ID_OR_SLUG) { type = NavType.StringType }, ), ) { PostScreen() } // Events (M13). CORE's routes, so these screens are reachable on a backend // with no game module at all — which is why they sit above the shard block // rather than inside it. composable(Routes.EVENTS) { EventsScreen(onOpenEvent = { slug -> navController.navigate(Routes.event(slug)) }) } // The app's one route with a query argument. `run` is optional and nullable: // navigating to Routes.event(slug) with no run matches this pattern with no // argument, which is every route in except an announcement's link. composable( route = Routes.EVENT_ROUTE, arguments = listOf( navArgument(Routes.Args.SLUG) { type = NavType.StringType }, navArgument(Routes.Args.RUN) { type = NavType.StringType nullable = true defaultValue = null }, ), ) { EventScreen( onOpenSeries = { slug -> navController.navigate(Routes.eventSeries(slug)) }, onOpenRun = { slug, runId -> navController.navigate(Routes.event(slug, runId.toString())) }, ) } composable( route = Routes.EVENT_SERIES, arguments = listOf(navArgument(Routes.Args.SLUG) { type = NavType.StringType }), ) { EventSeriesScreen(onOpenEvent = { slug -> navController.navigate(Routes.event(slug)) }) } composable(Routes.MY_EVENTS) { // Signed out, this route is not in the drawer — but a saved back-stack // entry can still be restored onto it, so the shell says where to go // rather than letting the screen ask the server and render a 401. when (session) { is Session.SignedIn -> MyEventsScreen(onOpenRun = { slug, runId -> navController.navigate(Routes.event(slug, runId.toString())) }) Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) } } } composable(Routes.SHARD) { ShardScreen(onOpenBoard = { board -> navController.navigate( when (board) { ShardBoard.CHAMPS -> Routes.SHARD_CHAMPS ShardBoard.GUILDS -> Routes.SHARD_GUILDS ShardBoard.GOVERNORS -> Routes.SHARD_GOVERNORS ShardBoard.HOUSES -> Routes.SHARD_HOUSES }, ) }) } composable(Routes.SHARD_CHAMPS) { ChampsScreen() } composable(Routes.SHARD_GUILDS) { GuildsScreen() } composable(Routes.SHARD_GOVERNORS) { GovernorsScreen() } composable(Routes.SHARD_HOUSES) { HousesScreen() } // Protocol 3.0 shard content (M11). Each screen self-reports "not published // here" from its own 404/403, so a deep link to a gated feature still lands on // an honest answer even though the menu hides the entry. composable(Routes.SHARD_RULES) { RulesScreen() } composable(Routes.SHARD_LEADERBOARDS) { LeaderboardsScreen(brand = brand) } composable(Routes.SHARD_MARKET) { MarketScreen(onOpenVendor = { serial -> navController.navigate(Routes.marketVendor(serial)) }) } composable( route = Routes.SHARD_MARKET_VENDOR, arguments = listOf(navArgument(Routes.Args.SERIAL) { type = NavType.StringType }), ) { entry -> MarketVendorScreen(serial = entry.arguments?.getString(Routes.Args.SERIAL).orEmpty()) } composable(Routes.ATLAS) { AtlasScreen(onOpenCreature = { slug -> navController.navigate(Routes.atlasCreature(slug)) }) } composable( route = Routes.ATLAS_CREATURE, arguments = listOf(navArgument(Routes.Args.SLUG) { type = NavType.StringType }), ) { entry -> AtlasCreatureScreen(slug = entry.arguments?.getString(Routes.Args.SLUG).orEmpty()) } // The Rust module's two screens (M14). Not under `shard/`: a different game, // a different shape — a fleet with a list above it rather than one place. composable(Routes.RUST) { RustServersScreen(onOpenServer = { id -> navController.navigate(Routes.rustServer(id)) }) } composable( route = Routes.RUST_SERVER, arguments = listOf( navArgument(Routes.Args.SERVER_ID) { type = NavType.StringType }, navArgument(Routes.Args.TAB) { type = NavType.StringType nullable = true defaultValue = null }, ), ) { RustServerScreen( onBack = { navController.navigateTopLevel(Routes.RUST) }, // D123: a site-event marker opens the app's own event page on its run. onOpenEvent = { slug, runId -> navController.navigate(Routes.event(slug, runId)) }, ) } composable(Routes.WIKI) { WikiScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) }) } composable( route = Routes.WIKI_PAGE, arguments = listOf(navArgument(Routes.Args.SLUG) { type = NavType.StringType }), ) { WikiPageScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) }) } composable( route = Routes.PAGE, arguments = listOf(navArgument(Routes.Args.SLUG) { type = NavType.StringType }), ) { PageScreen() } composable(Routes.CONTACT) { ContactScreen() } composable(Routes.LOGIN) { // Leave the login screen as soon as the session is established — whether by // password or the SSO bridge. Keying off the shared session (not just the // login VM's local flag) makes this robust to the deep-link/recomposition // timing of the Custom-Tab return, which the LoginScreen callback alone can miss. if (session is Session.SignedIn) { LaunchedEffect(Unit) { navController.popBackStack(Routes.LOGIN, inclusive = true) } } else { LoginScreen(onSignedIn = { navController.popBackStack() }) } } composable(Routes.ACCOUNT) { // Only meaningful while signed in; a sign-out (here or from the drawer) // sends the user home rather than leaving a stale identity on screen. when (val s = session) { is Session.SignedIn -> AccountScreen( username = s.user.username, roleLabel = stringResource(roleLabelRes(s.user.role)), onSignOut = onSignOut, onSignOutEverywhere = onSignOutEverywhere, onOpenTrustedDevices = { navController.navigate(Routes.ACCOUNT_TRUSTED_DEVICES) }, onOpenRecoveryCodes = { navController.navigate(Routes.ACCOUNT_RECOVERY_CODES) }, ) Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) } } } composable(Routes.ACCOUNT_TRUSTED_DEVICES) { // Signed-in only; a drop (sign-out/demotion) sends the user home (§4.3). when (session) { is Session.SignedIn -> TrustedDevicesScreen() Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) } } } composable(Routes.ACCOUNT_RECOVERY_CODES) { when (session) { is Session.SignedIn -> RecoveryCodesScreen() Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) } } } composable(Routes.NOTIFICATIONS) { // Signed-in only; a sign-out (or demotion) sends the user home rather than // leaving another account's items up. The backend gates every call // regardless, and the inbox routes are role-agnostic (§5) — staff have an // inbox for the same reason players do, which on the web took a second // mount to be true. when (session) { is Session.SignedIn -> InboxScreen( onOpenSettings = { navController.navigate(Routes.NOTIFICATIONS_SETTINGS) }, // A notification whose link the app can render opens in the app. // `navigate`, not `navigateTopLevel`: the inbox is where the // reader came from and back should return there. onOpenRoute = { route -> navController.navigate(route) }, ) Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) } } } composable(Routes.NOTIFICATIONS_SETTINGS) { when (session) { is Session.SignedIn -> NotificationSettingsScreen() Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) } } } // ── Player game data (§6.3) — reached from the player-only menu groups. // The server enforces the player gate on every call; these screens simply // render 401/403/503 as clean states (§7). composable(Routes.PLAYER_CHARACTERS) { PlayerGate(session, navController) { CharactersScreen(onOpenChar = { serial -> navController.navigate(Routes.playerChar(serial)) }) } } composable( route = Routes.PLAYER_CHAR, arguments = listOf(navArgument(Routes.Args.SERIAL) { type = NavType.StringType }), ) { CharacterSheetScreen() } composable(Routes.PLAYER_VENDORS) { PlayerGate(session, navController) { VendorsScreen() } } composable(Routes.PLAYER_HOUSES) { PlayerGate(session, navController) { MyHousesScreen() } } // The player's own Rust account (M15). Behind the same gate as the three // above: the route is `requireAuth` server-side, and the gate is here so a // signed-out reader is sent home rather than left on a screen that 401s. composable(Routes.PLAYER_RUST) { PlayerGate(session, navController) { RustAccountScreen() } } // ── Staff operations (§1, §6.4, M10) — reached from the staff menu section. // The backend re-checks role on every /admin/… call; these gates only mirror // the menu's visibility so a signed-out/demoted user isn't left on a stale screen. composable(Routes.ADMIN_DASHBOARD) { StaffGate(session, navController) { AdminDashboardScreen(isAdmin = (session as? Session.SignedIn)?.user?.isAdmin == true) } } composable(Routes.ADMIN_CONTENT) { StaffGate(session, navController) { AdminContentScreen() } } composable(Routes.ADMIN_MODERATION) { StaffGate(session, navController, require = { it.isModerator }) { AdminModerationScreen() } } composable(Routes.ADMIN_SUPPORT) { StaffGate(session, navController, require = { it.isModerator }) { AdminSupportScreen() } } } } /** * A UX guard for the player-only groups: while signed in, render [content]; if the * session drops (sign-out, or a server-side demotion caught on resume, §4.3), send * the user home instead of leaving a stale player screen up. The backend remains * the authority — this only mirrors the menu's visibility rule. */ @Composable private fun PlayerGate( session: Session, navController: NavHostController, content: @Composable () -> Unit, ) { when (session) { is Session.SignedIn -> content() Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) } } } /** * The staff-operations analogue of [PlayerGate] (§1, M10): render [content] only for * a signed-in staff account; a signed-out/demoted session (caught on resume, §4.3) is * sent home rather than left on a stale admin screen. The backend is the authority — * every `/admin/…` call re-checks role — so this only mirrors the menu's visibility. */ @Composable private fun StaffGate( session: Session, navController: NavHostController, require: (com.runicgateway.app.core.auth.SessionUser) -> Boolean = { it.isStaff }, content: @Composable () -> Unit, ) { val ok = (session as? Session.SignedIn)?.user?.let(require) == true if (ok) content() else LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) } } /** Navigate to a top-level menu destination: single instance, reset to it. */ private fun NavHostController.navigateTopLevel(route: String) { navigate(route) { popUpTo(Routes.HOME) { saveState = true } launchSingleTop = true restoreState = true } }