feat(android): refactor settings into a hub with category subpages (#2218)

Replace the single-column settings screen with a category directory
whose rows come from a declarative catalog (SettingsHubCatalog: one
entry = route + title + summary + icon + visibility lambda), so adding
a category later (Display, Chat, Voice, Machines) is a list element,
not layout surgery. Mirrors the web settings IA at the category level.

- General subpage (settings/general) inherits the uncategorized
  sections: Appearance (theme + Material You), Language, About
  (versions + hub health). Zero behavior change per item.
- The owner-only Insights section dissolves into hub-level rows:
  Usage/Storage render only for the owner (same ns=default gate) and
  route to their existing dashboards, matching the web's owner-only
  nav categories (SettingsNav OWNER_ONLY_CATEGORIES).
- Flat settings/... route pattern with per-hub ViewModelHolder, same
  as the existing usage/storage precedents; back from a subpage lands
  on the hub. The hub's holder passes probeHealth=false so opening the
  hub itself fires no /health request; the General holder keeps it.
- Previews: hub owner/non-owner gating + General body. JVM tests pin
  catalog order, the owner gate, route uniqueness, and row summaries.
- Strings: settings_category_general(+_summary) in en/ru/zh; relocate
  three zh keys (chat_tool_resolved, chat_plan_implement,
  chat_plan_continue) that had drifted to the file tail and broke the
  three-language key order (KEYSYNC) — pure moves, values untouched.


via [HAPI](https://hapi.run)

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
This commit is contained in:
admin
2026-10-28 15:20:53 +08:00
committed by GitHub
co-authored by Claude Code HAPI
parent 7c715843bb
commit 64f464190f
10 changed files with 415 additions and 42 deletions
@@ -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<SettingsViewModelHolder>(
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<SettingsViewModelHolder>(
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() {
@@ -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<SettingsHubEntry> = 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<SettingsHubEntry> =
entries.filter { entry -> entry.isVisible(state) }
}
@@ -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<SettingsHubEntry>,
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),
)
}
@@ -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 = {},
)
}
}
}
@@ -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
@@ -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<ThemeSettings> = 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) {
@@ -108,6 +108,8 @@
<!-- Settings scaffold (B-M4e) -->
<string name="settings_title">Настройки</string>
<string name="settings_category_general">Основные</string>
<string name="settings_category_general_summary">Внешний вид, язык и о приложении</string>
<string name="settings_back">Назад</string>
<string name="settings_cancel">Отмена</string>
<string name="settings_retry">Повторить</string>
@@ -115,6 +115,8 @@
<!-- Settings scaffold (B-M4e) -->
<string name="settings_title">设置</string>
<string name="settings_category_general">通用</string>
<string name="settings_category_general_summary">外观、语言与关于</string>
<string name="settings_back">返回</string>
<string name="settings_cancel">取消</string>
<string name="settings_retry">重试</string>
@@ -338,6 +340,7 @@
<string name="chat_tool_approved_allow_all_edits">✓ 已批准:允许所有编辑</string>
<string name="chat_tool_denied">✕ 已拒绝</string>
<string name="chat_tool_aborted">✕ 已中止</string>
<string name="chat_tool_resolved">已处理</string>
<string name="chat_tool_canceled">— 已取消</string>
<string name="chat_tool_status_pending">等待中</string>
<string name="chat_tool_status_error">错误</string>
@@ -417,6 +420,8 @@
<string name="tool_todo_list">任务清单</string>
<string name="tool_plan">计划</string>
<string name="tool_plan_proposal">计划提案</string>
<string name="chat_plan_implement">执行计划</string>
<string name="chat_plan_continue">继续规划</string>
<string name="tool_skill">技能</string>
<string name="tool_skill_named">技能:%1$s</string>
<string name="tool_reasoning">思考</string>
@@ -596,7 +601,4 @@
<string name="sessions_time_filter_recent_activity">近期活动</string>
<string name="privacy_policy">隐私政策</string>
<string name="privacy_open_failed">没有可打开此链接的应用。</string>
<string name="chat_tool_resolved">已处理</string>
<string name="chat_plan_implement">执行计划</string>
<string name="chat_plan_continue">继续规划</string>
</resources>
@@ -108,6 +108,8 @@
<!-- Settings scaffold (B-M4e) -->
<string name="settings_title">Settings</string>
<string name="settings_category_general">General</string>
<string name="settings_category_general_summary">Appearance, language, and about</string>
<string name="settings_back">Back</string>
<string name="settings_cancel">Cancel</string>
<string name="settings_retry">Retry</string>
@@ -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")
}
}
}