The drawer has been the app's coded `APP_MENU` in coded order since M1. Phase 5 lets an admin's `nav_public` row relabel, reorder and hide its public rows, which is the first time anything in the app's navigation comes from the shard. The public nav is keyed by **website** paths, so this needs a translation table, and it is the one new piece of cross-repo coupling the milestone introduces. It lives in a single file with the website's own `NAV` array quoted beside it — `NavPaths.kt` — so the coupling is visible and reviewable in one place instead of spread across the drawer's call sites. The `feature` values are deliberately not mirrored: `APP_MENU` stays the app's own source of truth for gating, and a second copy of a security-relevant value that drifts silently is worth more than it costs. Nine of the sixteen website rows have a drawer row. The other seven map to a screen the app reaches another way — three news categories are tabs on one News screen, and champs / guilds / governors / houses sit behind the Shard hub because that is the better shape on a phone — and an override for one of them is **ignored**, which is §6.1's rule that a nav override may never introduce navigation. The hub is a design decision, not an accident to correct. The mapping still exists for all sixteen because phase 6's added links resolve an admin-authored path against the same table, and there a category tab or a hub board is a perfectly good destination: the admin asked for it by path. The merge is a port of the website's `applyNavOverrides`, narrowed to what a drawer can express — `label`, `order`, `hidden`, and nothing else. It runs **before** `visibleEntries`, so the two gates from M10/M11 still decide what this caller may see and remain the actual boundary: an override that relabels the Market row, moves it to the front and says `hidden: false` still shows nothing to a caller whose shard does not publish the market. Hiding is subtractive, never additive. One thing the design did not settle and the sort turns on: an untouched row's implicit key has to be its index in the **website's** nav, not the app's. A stored `order` is a position in that list, so a key taken from the app's shorter list would put explicit and implicit keys on two incomparable number lines and scramble a partially-overridden nav. Both tie-breaks are the web's — an explicit order beats a coincidental index, and two explicit orders keep code order. `Routes.news(category)` and an optional NavHost argument ship here as the table's route builder; phase 6 is their first caller. Navigating to plain `Routes.NEWS` matches the new pattern with no argument and opens the default tab, so the drawer and the push deep-link are unaffected — but `destination.route` is now a pattern with a query, so the top-level and selected-row checks compare on the part before it. Two questions went to the org lead before any code. The three news-category paths get a mapped route but no drawer row of their own, on the same rule as the hub four. And an admin **may** hide Home, mirroring the website, where `/` is hideable too: Home stays the NavHost's start destination and stays reachable by back-press, and the app does not invent a policy the site doesn't have. 442 unit tests green (410 + 32), `lintDebug` and `assembleDebug` clean. The strongest of them is AC-1's: with no stored row the merge returns `APP_MENU` itself — identity, not equality — so an instance whose admin never touched the nav provably gets the drawer the app shipped with. Co-Authored-By: Claude <noreply@anthropic.com>
540 lines
24 KiB
Kotlin
540 lines
24 KiB
Kotlin
/*
|
|
* 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.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.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.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.res.stringResource
|
|
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.data.api.dto.BrandDto
|
|
import com.runicgateway.app.data.appearance.SiteAppearance
|
|
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.home.HomeScreen
|
|
import com.runicgateway.app.ui.navigation.APP_MENU
|
|
import com.runicgateway.app.ui.navigation.Routes
|
|
import com.runicgateway.app.ui.navigation.applyNavOverrides
|
|
import com.runicgateway.app.ui.navigation.visibleEntries
|
|
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.NotificationsScreen
|
|
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.shard.ShardScreen
|
|
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,
|
|
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
|
|
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,
|
|
onDeepLinkConsumed: () -> Unit = {},
|
|
sessionViewModel: SessionViewModel = 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()
|
|
|
|
// Re-validate the cached role each time the app returns to the foreground (§4.3).
|
|
LifecycleResumeEffect(Unit) {
|
|
sessionViewModel.revalidate()
|
|
onPauseOrDispose { }
|
|
}
|
|
|
|
// A tapped push notification deep-links to its stream's screen (§11, item 7).
|
|
LaunchedEffect(deepLinkStream) {
|
|
val stream = deepLinkStream ?: return@LaunchedEffect
|
|
navController.navigate(Routes.forStream(stream)) {
|
|
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 and hide, so
|
|
// `visibleEntries` 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 entries = visibleEntries(
|
|
applyNavOverrides(APP_MENU, appearance.navPublic),
|
|
session,
|
|
shardFeatures,
|
|
)
|
|
|
|
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))
|
|
entries.forEach { entry ->
|
|
NavigationDrawerItem(
|
|
// An admin's label wins over the bundled one, and is the
|
|
// same string in every locale — see MenuEntry.label.
|
|
label = { Text(entry.label ?: stringResource(entry.labelRes)) },
|
|
selected = currentRoute == entry.route,
|
|
onClick = {
|
|
scope.launch { drawerState.close() }
|
|
navController.navigateTopLevel(entry.route)
|
|
},
|
|
colors = drawerItemColors,
|
|
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
|
)
|
|
}
|
|
|
|
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,
|
|
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
|
)
|
|
NavigationDrawerItem(
|
|
label = { Text(stringResource(R.string.menu_change_server)) },
|
|
selected = false,
|
|
onClick = {
|
|
scope.launch { drawerState.close() }
|
|
onChangeServer()
|
|
},
|
|
colors = drawerItemColors,
|
|
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),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@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()
|
|
}
|
|
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())
|
|
}
|
|
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 stale settings up. The backend gates every call regardless (§5).
|
|
when (session) {
|
|
is Session.SignedIn -> NotificationsScreen()
|
|
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() }
|
|
}
|
|
|
|
// ── 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
|
|
}
|
|
}
|