mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-10 02:01:40 +00:00
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:
co-authored by
Claude Code
HAPI
parent
7c715843bb
commit
64f464190f
@@ -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),
|
||||
)
|
||||
}
|
||||
+53
-1
@@ -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
|
||||
|
||||
+8
-2
@@ -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>
|
||||
|
||||
+56
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user