Files
Android-app/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt
wtclaude 94a5c26d6c feat(nav): honor the shard's public nav order, labels and hiding (M12 phase 5)
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>
2026-08-08 07:07:10 -05:00

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
}
}