diff --git a/android/app/src/main/kotlin/app/hapi/companion/Navigation.kt b/android/app/src/main/kotlin/app/hapi/companion/Navigation.kt index 22659ea8..1199c15b 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/Navigation.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/Navigation.kt @@ -57,7 +57,8 @@ import app.hapi.companion.feature.scratchlist.ScratchlistMedia import app.hapi.companion.feature.scratchlist.ScratchlistScreen import app.hapi.companion.feature.scratchlist.ScratchlistViewModel import app.hapi.companion.feature.sessions.SessionListViewModel -import app.hapi.companion.feature.settings.SettingsScreen +import app.hapi.companion.feature.settings.SettingsGeneralScreen +import app.hapi.companion.feature.settings.SettingsHubScreen import app.hapi.companion.feature.settings.SettingsViewModel import app.hapi.companion.feature.settings.StorageScreen import app.hapi.companion.feature.settings.StorageViewModel @@ -132,8 +133,13 @@ object Routes { const val PAIRING_SCAN = "pairing/scan" const val PAIRING_MANUAL = "pairing/manual" - /** Settings scaffold + owner-only dashboards (B-M4e). */ + /** + * Settings hub (category directory) + flat subpage routes (B-M4e → P2 + * hub IA). Usage/Storage are owner-only dashboards reached straight from + * the hub rows; General holds the uncategorized items. + */ const val SETTINGS = "settings" + const val SETTINGS_GENERAL = "settings/general" const val SETTINGS_USAGE = "settings/usage" const val SETTINGS_STORAGE = "settings/storage" } @@ -245,12 +251,23 @@ fun HapiNavigation() { val hubGraph = activeHubGraph ?: return@composable val holder = viewModel( key = "settings:${hubGraph.hubUrl}", + factory = viewModelFactory { SettingsViewModelHolder(graph, hubGraph, probeHealth = false) }, + ) + SettingsHubScreen( + viewModel = holder.viewModel, + onOpenCategory = { route -> navController.navigate(route) }, + onBack = { navController.popBackStack() }, + ) + } + + composable(Routes.SETTINGS_GENERAL) { + val hubGraph = activeHubGraph ?: return@composable + val holder = viewModel( + key = "settingsGeneral:${hubGraph.hubUrl}", factory = viewModelFactory { SettingsViewModelHolder(graph, hubGraph) }, ) - SettingsScreen( + SettingsGeneralScreen( viewModel = holder.viewModel, - onOpenUsage = { navController.navigate(Routes.SETTINGS_USAGE) }, - onOpenStorage = { navController.navigate(Routes.SETTINGS_STORAGE) }, onBack = { navController.popBackStack() }, ) } @@ -672,7 +689,12 @@ private class FileViewerViewModelHolder( * Shells for the settings graph (B-M4e). Settings/usage/storage are hub-scoped * (keys include the hub origin): a hub switch swaps in fresh state. */ -private class SettingsViewModelHolder(graph: AppGraph, hubGraph: HubGraph) : ViewModel() { +private class SettingsViewModelHolder( + graph: AppGraph, + hubGraph: HubGraph, + /** The hub directory needs only the owner gate; skip its About `/health` probe. */ + probeHealth: Boolean = true, +) : ViewModel() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) val viewModel = SettingsViewModel( @@ -688,6 +710,7 @@ private class SettingsViewModelHolder(graph: AppGraph, hubGraph: HubGraph) : Vie }, fetchHealth = { hubGraph.session.api.health() }, scope = scope, + probeHealth = probeHealth, ) override fun onCleared() { diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsHubCatalog.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsHubCatalog.kt new file mode 100644 index 00000000..77d0dbb2 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsHubCatalog.kt @@ -0,0 +1,73 @@ +package app.hapi.companion.feature.settings + +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.DateRange +import androidx.compose.material.icons.filled.Settings +import androidx.compose.ui.graphics.vector.ImageVector +import app.hapi.companion.R +import app.hapi.companion.Routes + +/** + * Inputs to hub-row visibility gates. Today only the owner gate + * (`ns === 'default'`, the web twin of `OWNER_ONLY_CATEGORIES` in + * `SettingsNav.tsx`); extend this class — not the entry shape — when a future + * category needs more context (e.g. a feature flag). + */ +data class SettingsHubState(val isOwner: Boolean) + +/** + * One settings-hub row: a category destination. Rows render in list order as + * icon + title + summary cards; `route` is a navigation route handled by the + * hub screen's `onOpenCategory`. + * + * Adding a category (Display, Chat, Voice, Machines, a fork-only feature …) + * is a single list element in [SettingsHubCatalog.entries] — no hub layout + * surgery. Keep entries with no content on the current branch OUT of the + * catalog rather than gating them to `false`: an empty subpage route is worse + * than an absent row. + */ +class SettingsHubEntry( + val route: String, + @StringRes val titleRes: Int, + @StringRes val descriptionRes: Int? = null, + val icon: ImageVector, + val isVisible: (SettingsHubState) -> Boolean = { true }, +) + +/** + * Declarative category list behind the settings hub. Mirrors the web's + * `web/src/routes/settings/categories.ts` where the branch has content: + * general (appearance/language/about) plus the owner-only usage and storage + * dashboards. Display/Chat/Voice/Machines join here in later slices. + */ +object SettingsHubCatalog { + + val entries: List = listOf( + SettingsHubEntry( + route = Routes.SETTINGS_GENERAL, + titleRes = R.string.settings_category_general, + descriptionRes = R.string.settings_category_general_summary, + icon = Icons.Default.Settings, + ), + SettingsHubEntry( + route = Routes.SETTINGS_USAGE, + titleRes = R.string.settings_usage, + descriptionRes = R.string.settings_usage_summary, + icon = Icons.Default.DateRange, + isVisible = { state -> state.isOwner }, + ), + SettingsHubEntry( + route = Routes.SETTINGS_STORAGE, + titleRes = R.string.settings_storage, + descriptionRes = R.string.settings_storage_summary, + icon = Icons.AutoMirrored.Filled.List, + isVisible = { state -> state.isOwner }, + ), + ) + + /** Rows to render for [state], in catalog order. */ + fun visibleEntries(state: SettingsHubState): List = + entries.filter { entry -> entry.isVisible(state) } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsHubScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsHubScreen.kt new file mode 100644 index 00000000..dbe6c9fb --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsHubScreen.kt @@ -0,0 +1,148 @@ +package app.hapi.companion.feature.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.automirrored.filled.KeyboardArrowRight +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.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.hapi.companion.R + +/** + * Settings hub (P2 IA): the category directory that replaced the former + * single-column settings screen. Rows come from [SettingsHubCatalog] — the + * owner-only Usage/Storage rows hide for non-owners exactly like the old + * Insights section did (and like the web's owner-only nav categories). + * Subpages are flat `settings/...` routes; back from a subpage lands here. + */ +@Composable +fun SettingsHubScreen( + viewModel: SettingsViewModel, + onOpenCategory: (String) -> Unit, + onBack: () -> Unit, +) { + val isOwner by viewModel.isOwner.collectAsState() + SettingsHubContent( + entries = SettingsHubCatalog.visibleEntries(SettingsHubState(isOwner = isOwner)), + onOpenCategory = onOpenCategory, + onBack = onBack, + ) +} + +/** Entries-injected body of [SettingsHubScreen] — previewable without a VM. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun SettingsHubContent( + entries: List, + onOpenCategory: (String) -> Unit, + onBack: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.settings_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.settings_back), + ) + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth(), + ) { + Column { + entries.forEachIndexed { index, entry -> + if (index > 0) { + SettingsHubDivider() + } + SettingsHubRow(entry = entry, onClick = { onOpenCategory(entry.route) }) + } + } + } + } + } +} + +/** M3-list-item-style row: leading icon, title + summary, trailing chevron. */ +@Composable +private fun SettingsHubRow(entry: SettingsHubEntry, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + entry.icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Column( + modifier = Modifier + .weight(1f) + .padding(start = 16.dp), + ) { + Text( + text = stringResource(entry.titleRes), + style = MaterialTheme.typography.bodyLarge, + ) + entry.descriptionRes?.let { descriptionRes -> + Text( + text = stringResource(descriptionRes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + } + } + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun SettingsHubDivider() { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsPreviews.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsPreviews.kt index 566be975..7f609e1f 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsPreviews.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsPreviews.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import app.hapi.companion.ui.theme.HapiTheme +import app.hapi.protocol.wire.HubHealthResponse import app.hapi.protocol.wire.SqliteStorageUsageResponse import app.hapi.protocol.wire.UsageSummaryBucket import app.hapi.protocol.wire.UsageSummaryRange @@ -17,7 +18,8 @@ import java.time.LocalDate /** * Compile-checked previews doubling as visual regressions for the B-M4e * dashboards: stat tiles, the daily bar chart, the ranked bar list, and the - * storage donut — light, dark, and OLED. + * storage donut — light, dark, and OLED — plus the P2 settings hub (owner and + * non-owner gating) and the General subpage it drills into. */ private fun bucket( @@ -141,3 +143,53 @@ private fun StorageCardOledPreview() { Surface { StorageUsageCard(usage = SAMPLE_STORAGE_USAGE) } } } + +@Preview(name = "Settings hub · owner", showBackground = true) +@Composable +private fun SettingsHubOwnerPreview() { + HapiTheme(darkTheme = false, dynamicColor = false) { + Surface { + SettingsHubContent( + entries = SettingsHubCatalog.visibleEntries(SettingsHubState(isOwner = true)), + onOpenCategory = {}, + onBack = {}, + ) + } + } +} + +@Preview(name = "Settings hub · non-owner", showBackground = true) +@Composable +private fun SettingsHubNonOwnerPreview() { + HapiTheme(darkTheme = false, dynamicColor = false) { + Surface { + SettingsHubContent( + entries = SettingsHubCatalog.visibleEntries(SettingsHubState(isOwner = false)), + onOpenCategory = {}, + onBack = {}, + ) + } + } +} + +@Preview(name = "Settings general", showBackground = true, heightDp = 1200) +@Composable +private fun SettingsGeneralPreview() { + HapiTheme(darkTheme = false, dynamicColor = false) { + Surface { + SettingsGeneralContent( + theme = ThemeSettings(mode = ThemeMode.DARK, dynamicColor = false), + language = AppLanguage.ENGLISH, + hubUrl = "https://hub.example", + hubInfo = HubInfoState.Loaded( + HubHealthResponse(status = "ok", protocolVersion = 1), + ), + onSetThemeMode = {}, + onSetDynamicColor = {}, + onSetLanguage = {}, + onRetryHubInfo = {}, + onBack = {}, + ) + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsScreen.kt index 7a4e7a4d..375e4b94 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsScreen.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsScreen.kt @@ -44,30 +44,55 @@ import app.hapi.companion.R import app.hapi.protocol.wire.SUPPORTED_PROTOCOL_VERSION /** - * Settings home (B-M4e scaffold): Appearance (theme mode + Material You), - * Language (persist-only until M5), the owner-only Usage/Storage entries, and - * About (app/protocol versions + hub health). + * General subpage of the settings hub (P2 IA): the catch-all category for + * everything without a dedicated category on this branch — Appearance (theme + * mode + Material You), Language, and About (app/protocol versions + hub + * health). The former owner-only Usage/Storage rows moved hub-level (see + * [SettingsHubCatalog]); their owner gate is unchanged. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable -fun SettingsScreen( +fun SettingsGeneralScreen( viewModel: SettingsViewModel, - onOpenUsage: () -> Unit, - onOpenStorage: () -> Unit, onBack: () -> Unit, ) { val theme by viewModel.themeSettings.collectAsState() val language by viewModel.language.collectAsState() - val isOwner by viewModel.isOwner.collectAsState() val hubInfo by viewModel.hubInfo.collectAsState() + SettingsGeneralContent( + theme = theme, + language = language, + hubUrl = viewModel.hubUrl, + hubInfo = hubInfo, + onSetThemeMode = viewModel::setThemeMode, + onSetDynamicColor = viewModel::setDynamicColor, + onSetLanguage = viewModel::setLanguage, + onRetryHubInfo = viewModel::retryHubInfo, + onBack = onBack, + ) +} + +/** State-injected body of [SettingsGeneralScreen] — previewable without a VM. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun SettingsGeneralContent( + theme: ThemeSettings, + language: AppLanguage, + hubUrl: String, + hubInfo: HubInfoState, + onSetThemeMode: (ThemeMode) -> Unit, + onSetDynamicColor: (Boolean) -> Unit, + onSetLanguage: (AppLanguage) -> Unit, + onRetryHubInfo: () -> Unit, + onBack: () -> Unit, +) { var showThemeDialog by rememberSaveable { mutableStateOf(false) } var showLanguageDialog by rememberSaveable { mutableStateOf(false) } Scaffold( topBar = { TopAppBar( - title = { Text(stringResource(R.string.settings_title)) }, + title = { Text(stringResource(R.string.settings_category_general)) }, navigationIcon = { IconButton(onClick = onBack) { Icon( @@ -103,7 +128,7 @@ fun SettingsScreen( ), checked = theme.dynamicColor && theme.mode != ThemeMode.OLED, enabled = theme.mode != ThemeMode.OLED, - onCheckedChange = viewModel::setDynamicColor, + onCheckedChange = onSetDynamicColor, ) } } @@ -116,22 +141,6 @@ fun SettingsScreen( ) } - if (isOwner) { - SettingsSection(title = stringResource(R.string.settings_section_insights)) { - SettingsRow( - label = stringResource(R.string.settings_usage), - description = stringResource(R.string.settings_usage_summary), - onClick = onOpenUsage, - ) - SettingsDivider() - SettingsRow( - label = stringResource(R.string.settings_storage), - description = stringResource(R.string.settings_storage_summary), - onClick = onOpenStorage, - ) - } - } - SettingsSection(title = stringResource(R.string.settings_section_about)) { SettingsRow( label = stringResource(R.string.settings_app_version), @@ -145,7 +154,7 @@ fun SettingsScreen( SettingsDivider() SettingsRow( label = stringResource(R.string.settings_hub), - value = viewModel.hubUrl, + value = hubUrl, description = when (val info = hubInfo) { is HubInfoState.Loading -> stringResource(R.string.settings_hub_checking) is HubInfoState.Loaded -> stringResource( @@ -155,7 +164,7 @@ fun SettingsScreen( ) is HubInfoState.Failed -> stringResource(R.string.settings_hub_unreachable) }, - onClick = if (hubInfo is HubInfoState.Failed) viewModel::retryHubInfo else null, + onClick = if (hubInfo is HubInfoState.Failed) onRetryHubInfo else null, ) SettingsDivider() app.hapi.companion.ui.components.PrivacyPolicyLink() @@ -168,7 +177,7 @@ fun SettingsScreen( selected = theme.mode, onSelect = { mode -> showThemeDialog = false - viewModel.setThemeMode(mode) + onSetThemeMode(mode) }, onDismiss = { showThemeDialog = false }, ) @@ -179,7 +188,7 @@ fun SettingsScreen( selected = language, onSelect = { choice -> showLanguageDialog = false - viewModel.setLanguage(choice) + onSetLanguage(choice) // Apply immediately (B-M5a): appcompat recreates the activity // with the new locale and, thanks to autoStoreLocales in the // manifest, re-applies it on every cold start. The DataStore diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsViewModel.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsViewModel.kt index 10555b97..a103eb05 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsViewModel.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsViewModel.kt @@ -33,7 +33,9 @@ sealed interface HubInfoState { /** * Settings-home state: appearance + language prefs (DataStore-backed), the * owner gate, and the About hub probe. Plain constructor for JVM tests; - * Navigation hosts it behind a per-hub lifecycle holder. + * Navigation hosts it behind a per-hub lifecycle holder. The hub directory + * passes [probeHealth] = false — it needs only the owner gate, so opening the + * hub itself fires no `/health` request (the General subpage's holder does). */ class SettingsViewModel( private val themePrefs: ThemePrefs, @@ -44,6 +46,8 @@ class SettingsViewModel( private val currentJwt: suspend () -> String?, private val fetchHealth: suspend () -> HubHealthResponse, private val scope: CoroutineScope, + /** Kick off the About hub probe eagerly (default) or skip it. */ + probeHealth: Boolean = true, ) { val themeSettings: StateFlow = themePrefs.settings @@ -63,7 +67,9 @@ class SettingsViewModel( init { scope.launch { mutableIsOwner.value = isOwnerNamespace(runCatching { currentJwt() }.getOrNull()) } - scope.launch { loadHubInfo() } + if (probeHealth) { + scope.launch { loadHubInfo() } + } } fun setThemeMode(mode: ThemeMode) { diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 809d51aa..50d896b4 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -108,6 +108,8 @@ Настройки + Основные + Внешний вид, язык и о приложении Назад Отмена Повторить diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index e64e2eb5..edfb9064 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -115,6 +115,8 @@ 设置 + 通用 + 外观、语言与关于 返回 取消 重试 @@ -338,6 +340,7 @@ ✓ 已批准:允许所有编辑 ✕ 已拒绝 ✕ 已中止 + 已处理 — 已取消 等待中 错误 @@ -417,6 +420,8 @@ 任务清单 计划 计划提案 + 执行计划 + 继续规划 技能 技能:%1$s 思考 @@ -596,7 +601,4 @@ 近期活动 隐私政策 没有可打开此链接的应用。 - 已处理 - 执行计划 - 继续规划 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 54f1ec91..5402e5c6 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -108,6 +108,8 @@ Settings + General + Appearance, language, and about Back Cancel Retry diff --git a/android/app/src/test/kotlin/app/hapi/companion/feature/settings/SettingsHubCatalogTest.kt b/android/app/src/test/kotlin/app/hapi/companion/feature/settings/SettingsHubCatalogTest.kt new file mode 100644 index 00000000..ddf66ba9 --- /dev/null +++ b/android/app/src/test/kotlin/app/hapi/companion/feature/settings/SettingsHubCatalogTest.kt @@ -0,0 +1,56 @@ +package app.hapi.companion.feature.settings + +import app.hapi.companion.Routes +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * The settings hub's category list is data: gating and ordering live here, so + * later slices (Display/Chat/Voice/Machines) extend the catalog and these + * tests keep the owner contract honest. + */ +class SettingsHubCatalogTest { + + @Test + fun `owners see general usage and storage in catalog order`() { + val routes = SettingsHubCatalog.visibleEntries(SettingsHubState(isOwner = true)) + .map { it.route } + + assertEquals( + listOf(Routes.SETTINGS_GENERAL, Routes.SETTINGS_USAGE, Routes.SETTINGS_STORAGE), + routes, + ) + } + + @Test + fun `non-owners see only the ungated general category`() { + val routes = SettingsHubCatalog.visibleEntries(SettingsHubState(isOwner = false)) + .map { it.route } + + assertEquals(listOf(Routes.SETTINGS_GENERAL), routes) + } + + @Test + fun `general stays visible regardless of ownership`() { + val general = SettingsHubCatalog.entries.first { it.route == Routes.SETTINGS_GENERAL } + + assertTrue(general.isVisible(SettingsHubState(isOwner = true))) + assertTrue(general.isVisible(SettingsHubState(isOwner = false))) + } + + @Test + fun `catalog routes are unique so hub rows never shadow each other`() { + val routes = SettingsHubCatalog.entries.map { it.route } + + assertEquals(routes.size, routes.toSet().size) + } + + @Test + fun `every hub row carries a summary`() { + SettingsHubCatalog.entries.forEach { entry -> + assertNotNull(entry.descriptionRes, "${entry.route} needs a hub summary") + } + } +}