From 3e357594be49ca3e41e1f35365a39f40f4b66517 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:23:19 +0300 Subject: [PATCH] refactor(settings): drill-down navigation replacing tabbed layout (#605) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(settings): drill-down navigation replacing tabbed layout Replaces the 4-tab settings layout with a native Android-style drill-down: a single category list (SettingsScreen) where each row pushes a dedicated sub-page onto the Navigation3 back stack (SettingsConnection / Appearance / Chat / NavBar / Behavior / About). Each sub-page is its own NavKey destination registered in Navigation.kt and reuses the existing section composables. - SettingsScreen becomes the category list with icon + label + summary (Appearance row shows live theme + language, e.g. "Dark · 한국어"). - New SettingsSubPages.kt hosts the 6 full-screen sub-pages. - AboutSection promoted out of SettingsScreen into its own sub-page. - SettingsViewModel remains the single source of truth; no persistence changes. - Adds settings_summary_* strings for list subtitles. Consumes the in-app language switcher from the prior commit (AppearancePage passes appLanguage through to AppearanceSection). * [bug] fix settings sub-page back button opening drawer on first tap The ModalNavigationDrawer wraps the whole app and stays gesture-enabled on the Settings root (a DRAWER_GESTURE_SCREEN). Drilling into a sub-page (SettingsConnection/Appearance/Chat/NavBar/Behavior/About) disables gestures, but the existing LaunchedEffect only snapped the drawer closed when it was already isOpen. The drawer's scrim surface lingered during the transition and intercepted the first back-button tap, sliding the sidebar open instead of navigating back. Now snapTo(Closed) unconditionally whenever gestures are disabled — a safe no-op when already closed — so the sub-page back button works on the first tap. * [bug] fix settings sub-page back button opening drawer (gesture-surface race) The first "back" tap on a settings sub-page opened the sidebar instead of navigating back. Root cause: Settings root is a gesture-enabled drawer screen, and the sub-pages are the first destinations ever drilled down from it. When navigating in, ModalNavigationDrawer's gesture interceptor surface lingered for a frame (gesture -> non-gesture transition), so the top-left tap was consumed by the drawer instead of the Back IconButton. The previous snapTo fix only reset drawer *state*, not the gesture layer, so it didn't help. Fix: NavigationController now holds a closeDrawer hook (set by MainNavigation) and invokes it synchronously inside navigateTo() when pushing a non-primary sub-page. The drawer is dismissed in the same tap handler that triggers the navigation, before any recomposition, so the sub-page is never tappable while the gesture surface is still mounted. The LaunchedEffect(gesturesEnabled) snapTo remains as a backstop for non-navigateTo entries. * fix(#603): delay drawer gesture activation to prevent residual swipe * fix(#603): fix settings back navigation and drawer auto-open --------- Co-authored-by: hy4ri --- .../java/com/m57/hermescontrol/Navigation.kt | 238 +++++++----- .../m57/hermescontrol/NavigationController.kt | 9 + .../com/m57/hermescontrol/NavigationKeys.kt | 14 + .../com/m57/hermescontrol/ScreenRegistry.kt | 4 +- .../ui/settings/SettingsScreen.kt | 354 ++++++++---------- .../ui/settings/SettingsSubPages.kt | 239 ++++++++++++ app/src/main/res/values/strings.xml | 10 + 7 files changed, 584 insertions(+), 284 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index 25848b12..f6ecf07c 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -33,8 +33,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -42,6 +44,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider @@ -51,6 +54,13 @@ import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.theme.BottomNavDisplayMode import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.ui.settings.SettingsAboutPage +import com.m57.hermescontrol.ui.settings.SettingsAppearancePage +import com.m57.hermescontrol.ui.settings.SettingsBehaviorPage +import com.m57.hermescontrol.ui.settings.SettingsChatPage +import com.m57.hermescontrol.ui.settings.SettingsConnectionPage +import com.m57.hermescontrol.ui.settings.SettingsNavBarPage +import com.m57.hermescontrol.ui.settings.SettingsViewModel import kotlinx.coroutines.launch import com.m57.hermescontrol.ui.authlogin.AuthLoginScreen as AuthLoginScreenContent import com.m57.hermescontrol.ui.landing.LandingScreen as LandingScreenContent @@ -104,6 +114,46 @@ private fun appEntryProvider( screen.content(sessionId, openDrawer) } } + + // ── Settings drill-down sub-pages ─────────────────────────────────── + entry { + SettingsConnectionPage( + onBack = { NavigationController.goBack() }, + onLogout = { /* handled by caller via goBack fallback */ }, + viewModel = viewModel { SettingsViewModel() }, + ) + } + entry { + SettingsAppearancePage( + onBack = { NavigationController.goBack() }, + viewModel = viewModel { SettingsViewModel() }, + ) + } + entry { + SettingsChatPage( + onBack = { NavigationController.goBack() }, + viewModel = viewModel { SettingsViewModel() }, + ) + } + entry { + SettingsNavBarPage( + onBack = { NavigationController.goBack() }, + viewModel = viewModel { SettingsViewModel() }, + ) + } + entry { + SettingsBehaviorPage( + onBack = { NavigationController.goBack() }, + viewModel = viewModel { SettingsViewModel() }, + ) + } + entry { + SettingsAboutPage( + onBack = { NavigationController.goBack() }, + onLogout = { /* handled by caller via goBack fallback */ }, + viewModel = viewModel { SettingsViewModel() }, + ) + } } @Composable @@ -119,6 +169,10 @@ fun MainNavigation(sessionId: String? = null) { val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val scope = rememberCoroutineScope() + // Synchronous drawer dismiss hook used by NavigationController.navigateTo + // when drilling into a non-gesture sub-page (see NavigationController.closeDrawer). + NavigationController.closeDrawer = { scope.launch { drawerState.close() } } + val bottomNavItemsState by AuthManager.bottomNavItemsFlow.collectAsState() val bottomNavDisplayMode by AuthManager.bottomNavDisplayModeFlow.collectAsState() val bottomNavItems = resolveBottomNavItems(bottomNavItemsState) @@ -133,104 +187,118 @@ fun MainNavigation(sessionId: String? = null) { currentScreen != AuthLoginScreen && currentScreen != PairingCodeEntryScreen val gesturesEnabled = currentScreen in DRAWER_GESTURE_SCREENS + var drawerGesturesEnabled by remember { mutableStateOf(false) } + + LaunchedEffect(gesturesEnabled) { + if (gesturesEnabled) { + // Delay enabling gestures slightly to prevent residual swipe-to-open from back button taps + kotlinx.coroutines.delay(200) + drawerGesturesEnabled = true + } else { + drawerGesturesEnabled = false + } + } + val openDrawer: () -> Unit = { scope.launch { drawerState.open() } } - // B7 (Jun 30 2026, kanban t_424): close drawer if gestures are disabled to dismiss scrim - LaunchedEffect(gesturesEnabled) { - if (!gesturesEnabled && drawerState.isOpen) { + // B7 (Jun 30 2026, kanban t_424): close drawer if gestures are disabled to dismiss scrim. + // Also force-close on transition into a non-gesture screen so the drawer's scrim + // surface cannot intercept the first back-button tap (e.g. settings sub-pages drilled + // down from the gesture-enabled Settings root). snapTo(Closed) is a safe no-op when + // already closed. + LaunchedEffect(drawerGesturesEnabled) { + if (!drawerGesturesEnabled) { drawerState.snapTo(DrawerValue.Closed) } } ModalNavigationDrawer( drawerState = drawerState, - gesturesEnabled = gesturesEnabled, + gesturesEnabled = drawerGesturesEnabled, drawerContent = { - if (gesturesEnabled) { - ModalDrawerSheet( - modifier = Modifier.verticalScroll(rememberScrollState()), + ModalDrawerSheet( + modifier = Modifier.verticalScroll(rememberScrollState()), + ) { + val connectionStatus by HermesWsClient.connectionStatus.collectAsState() + val statusColor = + when (connectionStatus) { + ConnectionStatus.CONNECTED -> LocalHermesStatusColors.current.success + + ConnectionStatus.CONNECTING, + ConnectionStatus.RECONNECTING, + -> LocalHermesStatusColors.current.warning + + ConnectionStatus.DISCONNECTED, + ConnectionStatus.NO_NETWORK, + ConnectionStatus.AUTH_EXPIRED, + -> LocalHermesStatusColors.current.error + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 2.dp), ) { - val connectionStatus by HermesWsClient.connectionStatus.collectAsState() - val statusColor = - when (connectionStatus) { - ConnectionStatus.CONNECTED -> LocalHermesStatusColors.current.success - - ConnectionStatus.CONNECTING, - ConnectionStatus.RECONNECTING, - -> LocalHermesStatusColors.current.warning - - ConnectionStatus.DISCONNECTED, - ConnectionStatus.NO_NETWORK, - ConnectionStatus.AUTH_EXPIRED, - -> LocalHermesStatusColors.current.error - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 2.dp), - ) { - Text( - text = stringResource(R.string.nav_drawer_title), - style = - MaterialTheme.typography.headlineSmall.copy( - fontWeight = FontWeight.Bold, - ), - color = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.width(8.dp)) - Box( - modifier = - Modifier - .size(10.dp) - .background(color = statusColor, shape = CircleShape), - ) - } Text( - text = stringResource(R.string.nav_drawer_subtitle), - modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, end = 16.dp), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = stringResource(R.string.nav_drawer_title), + style = + MaterialTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.Bold, + ), + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Box( + modifier = + Modifier + .size(10.dp) + .background(color = statusColor, shape = CircleShape), ) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - - for (section in DrawerSection.entries) { - Text( - text = stringResource(section.titleRes).uppercase(), - modifier = - Modifier.padding( - start = 16.dp, - top = 8.dp, - bottom = 4.dp, - end = 16.dp, - ), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.SemiBold, - ) - ScreenRegistry.ALL_SCREENS - .filter { it.drawerSection == section } - .forEach { entry -> - NavigationDrawerItem( - icon = { Icon(entry.icon, contentDescription = null) }, - label = { Text(stringResource(entry.labelRes)) }, - selected = currentScreen == entry.key, - onClick = { - scope.launch { drawerState.close() } - NavigationController.navigateTo(entry.key) - }, - colors = - NavigationDrawerItemDefaults.colors( - selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, - selectedTextColor = MaterialTheme.colorScheme.onPrimaryContainer, - selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), - ) - } - } - - Spacer(modifier = Modifier.height(8.dp)) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - Spacer(modifier = Modifier.height(12.dp)) } + Text( + text = stringResource(R.string.nav_drawer_subtitle), + modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, end = 16.dp), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + + for (section in DrawerSection.entries) { + Text( + text = stringResource(section.titleRes).uppercase(), + modifier = + Modifier.padding( + start = 16.dp, + top = 8.dp, + bottom = 4.dp, + end = 16.dp, + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.SemiBold, + ) + ScreenRegistry.ALL_SCREENS + .filter { it.drawerSection == section } + .forEach { entry -> + NavigationDrawerItem( + icon = { Icon(entry.icon, contentDescription = null) }, + label = { Text(stringResource(entry.labelRes)) }, + selected = currentScreen == entry.key, + onClick = { + scope.launch { drawerState.close() } + NavigationController.navigateTo(entry.key) + }, + colors = + NavigationDrawerItemDefaults.colors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedTextColor = MaterialTheme.colorScheme.onPrimaryContainer, + selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(modifier = Modifier.height(12.dp)) } }, ) { diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt index bf691fc8..0780aa3a 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt @@ -18,6 +18,11 @@ object NavigationController { var backStack: NavBackStack? = null var pendingSessionId: String? = null + // Set by MainNavigation so navigation can synchronously dismiss the drawer + // (e.g. when drilling into a non-gesture sub-page) before a recomposition, + // preventing the drawer's gesture surface from intercepting the first tap. + var closeDrawer: (() -> Unit)? = null + // Bottom-nav primary screens — dynamic, updated by Navigation.kt via // updatePrimaryScreens() when the user customises the bottom nav bar. // Default matches the default 5 bottom-nav items. @@ -46,6 +51,10 @@ object NavigationController { if (isPrimaryScreen(key)) { stack.clear() + } else { + // Drilling into a sub-page (non-primary): dismiss the drawer + // synchronously so its gesture surface can't eat the next tap. + closeDrawer?.invoke() } stack.add(key) } diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt index 2be587a4..6a2f2d6e 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt @@ -54,3 +54,17 @@ import kotlinx.serialization.Serializable @Serializable data object ProvidersScreen : NavKey @Serializable data object AnalyticsScreen : NavKey + +// ── Settings drill-down sub-pages ────────────────────────────────────── + +@Serializable data object SettingsConnection : NavKey + +@Serializable data object SettingsAppearance : NavKey + +@Serializable data object SettingsChat : NavKey + +@Serializable data object SettingsNavBar : NavKey + +@Serializable data object SettingsBehavior : NavKey + +@Serializable data object SettingsAbout : NavKey diff --git a/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt b/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt index 0ab1e9ef..6e19dd85 100644 --- a/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt +++ b/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt @@ -208,9 +208,7 @@ object ScreenRegistry { DrawerSection.INSPECT, ) { sessionId, openDrawer -> SettingsScreenContent( - onBack = { - NavigationController.goBack() - }, + onOpenDrawer = openDrawer, onNavigateToLogin = { NavigationController.navigateTo(AuthLoginScreen) }, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt index c2eccbb0..436ffc37 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt @@ -1,62 +1,53 @@ package com.m57.hermescontrol.ui.settings -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -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.material3.Button -import androidx.compose.material3.ButtonDefaults +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.automirrored.filled.ListAlt +import androidx.compose.material.icons.filled.ChatBubbleOutline +import androidx.compose.material.icons.filled.Dashboard +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.Tune import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.PrimaryScrollableTabRow -import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel -import com.m57.hermescontrol.BuildConfig +import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.R +import com.m57.hermescontrol.SettingsAbout +import com.m57.hermescontrol.SettingsAppearance +import com.m57.hermescontrol.SettingsBehavior +import com.m57.hermescontrol.SettingsChat +import com.m57.hermescontrol.SettingsConnection +import com.m57.hermescontrol.SettingsNavBar +import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.NavIcon -import com.m57.hermescontrol.ui.settings.components.AppearanceSection -import com.m57.hermescontrol.ui.settings.components.BehaviorSection -import com.m57.hermescontrol.ui.settings.components.ChatSection -import com.m57.hermescontrol.ui.settings.components.ConnectionSection -import com.m57.hermescontrol.ui.settings.components.NavBarSection -import com.m57.hermescontrol.ui.settings.components.TestConnectionButton -import com.m57.hermescontrol.ui.settings.components.TestResultCard -enum class SettingsTab { - CONNECTION, - APPEARANCE, - BEHAVIOR, - ABOUT, -} - -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( - onBack: () -> Unit, + onOpenDrawer: (() -> Unit)? = null, onNavigateToLogin: () -> Unit = {}, onLogout: () -> Unit = {}, modifier: Modifier = Modifier, @@ -64,178 +55,153 @@ fun SettingsScreen( ) { val state by viewModel.uiState.collectAsStateWithLifecycle() - LaunchedEffect(state.navigateToLogin) { - if (state.navigateToLogin) { - onNavigateToLogin() - viewModel.clearNavigateToLogin() - } - } - - var passwordVisible by remember { mutableStateOf(false) } - var selectedTab by remember { mutableStateOf(SettingsTab.CONNECTION) } - - BackHandler(onBack = onBack) - HermesScaffold( title = { Text(stringResource(R.string.screen_settings)) }, - navigationIcon = NavIcon.Back(onBack), + navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, ) { Column( modifier = - Modifier - .fillMaxSize(), + modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp), ) { - PrimaryScrollableTabRow( - selectedTabIndex = selectedTab.ordinal, - modifier = Modifier.fillMaxWidth(), - edgePadding = 0.dp, - ) { - SettingsTab.entries.forEach { tab -> - Tab( - selected = selectedTab == tab, - onClick = { selectedTab = tab }, - text = { - Text( - when (tab) { - SettingsTab.CONNECTION -> stringResource(R.string.settings_tab_connection) - SettingsTab.APPEARANCE -> stringResource(R.string.settings_tab_appearance) - SettingsTab.BEHAVIOR -> stringResource(R.string.settings_tab_behavior) - SettingsTab.ABOUT -> stringResource(R.string.settings_tab_about) + SettingsCategoryCard( + items = + listOf( + SettingsRow( + icon = Icons.AutoMirrored.Filled.ListAlt, + label = stringResource(R.string.settings_sec_connection), + summary = + if (state.profiles.isNotEmpty()) { + stringResource( + R.string.settings_summary_profiles, + state.profiles.size, + ) + } else { + null }, - ) - }, - ) - } - } + onClick = { NavigationController.navigateTo(SettingsConnection) }, + ), + SettingsRow( + icon = Icons.Filled.Palette, + label = stringResource(R.string.settings_sec_appearance), + summary = appearanceSummary(state), + onClick = { NavigationController.navigateTo(SettingsAppearance) }, + ), + SettingsRow( + icon = Icons.Filled.ChatBubbleOutline, + label = stringResource(R.string.settings_sec_chat), + summary = + if (state.typingEffectEnabled) { + stringResource(R.string.settings_summary_typing_on) + } else { + stringResource(R.string.settings_summary_typing_off) + }, + onClick = { NavigationController.navigateTo(SettingsChat) }, + ), + SettingsRow( + icon = Icons.Filled.Dashboard, + label = stringResource(R.string.settings_sec_nav_bar), + onClick = { NavigationController.navigateTo(SettingsNavBar) }, + ), + SettingsRow( + icon = Icons.Filled.Tune, + label = stringResource(R.string.settings_sec_behavior), + summary = + if (state.autoReconnect) { + stringResource(R.string.settings_summary_on) + } else { + stringResource(R.string.settings_summary_off) + }, + onClick = { NavigationController.navigateTo(SettingsBehavior) }, + ), + SettingsRow( + icon = Icons.Filled.Info, + label = stringResource(R.string.settings_sec_about), + onClick = { NavigationController.navigateTo(SettingsAbout) }, + ), + ), + ) + } + } +} - Column( - modifier = - Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - when (selectedTab) { - SettingsTab.CONNECTION -> { - ConnectionSection( - state = state, - viewModel = viewModel, - passwordVisible = passwordVisible, - onPasswordVisibilityToggle = { passwordVisible = !passwordVisible }, +@Composable +private fun appearanceSummary(state: SettingsUiState): String { + val theme = + when (state.themePreference) { + ThemePreference.SYSTEM -> stringResource(R.string.settings_summary_theme_system) + ThemePreference.LIGHT -> stringResource(R.string.settings_summary_theme_light) + ThemePreference.DARK -> stringResource(R.string.settings_summary_theme_dark) + } + val lang = + when (state.appLanguage) { + "system" -> stringResource(R.string.language_system) + "ko" -> stringResource(R.string.language_korean) + else -> stringResource(R.string.language_english) + } + return "$theme · $lang" +} + +private data class SettingsRow( + val icon: ImageVector, + val label: String, + val summary: String? = null, + val onClick: () -> Unit, +) + +@Composable +private fun SettingsCategoryCard(items: List) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(1.dp), + ) { + Column { + items.forEachIndexed { index, row -> + ListItem( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = row.onClick), + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + leadingContent = { + Icon( + imageVector = row.icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, ) - - TestResultCard(testResult = state.testResult) - TestConnectionButton( - isTesting = state.isTesting, - onTest = viewModel::testConnection, + }, + headlineContent = { + Text( + text = row.label, + style = MaterialTheme.typography.bodyLarge, ) - - Spacer(modifier = Modifier.height(2.dp)) - - Button( - onClick = { - viewModel.logout() - onLogout() - }, - modifier = Modifier.fillMaxWidth(), - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - ), - ) { - Text(stringResource(R.string.settings_logout)) - } - } - - SettingsTab.BEHAVIOR -> { - BehaviorSection( - autoReconnect = state.autoReconnect, - onAutoReconnectChange = viewModel::onAutoReconnectChange, - ) - } - - SettingsTab.APPEARANCE -> { - AppearanceSection( - themePreference = state.themePreference, - onThemeChange = viewModel::onThemeChange, - useDynamicColors = state.useDynamicColors, - onUseDynamicColorsChange = viewModel::onUseDynamicColorsChange, - themePreset = state.themePreset, - onThemePresetChange = viewModel::onThemePresetChange, - appLanguage = state.appLanguage, - onAppLanguageChange = viewModel::onAppLanguageChange, - ) - - ChatSection( - typingEffectEnabled = state.typingEffectEnabled, - onTypingEffectEnabledChange = viewModel::onTypingEffectEnabledChange, - typingEffectDelayMs = state.typingEffectDelayMs, - onTypingEffectDelayMsChange = viewModel::onTypingEffectDelayMsChange, - ) - - NavBarSection( - bottomNavDisplayMode = state.bottomNavDisplayMode, - onBottomNavDisplayModeChange = viewModel::onBottomNavDisplayModeChange, - selectedNavItems = state.selectedNavItems, - availableNavItems = viewModel.availableNavItems, - onReorderNavItems = viewModel::reorderNavItems, - onRemoveNavItem = viewModel::removeNavItem, - onAddNavItem = viewModel::addNavItem, - ) - } - - SettingsTab.ABOUT -> { - // ── About section ───────────────────────────────────────── - SectionCard(title = stringResource(R.string.settings_sec_about)) { - Row(verticalAlignment = Alignment.CenterVertically) { + }, + supportingContent = + row.summary?.let { summary -> + { Text( - text = stringResource(R.string.settings_about_app_name), - style = - MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Bold, - ), + text = summary, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - - Spacer(modifier = Modifier.height(8.dp)) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - Spacer(modifier = Modifier.height(8.dp)) - - InfoRow( - label = stringResource(R.string.settings_about_version), - value = "v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})", - ) - InfoRow( - label = stringResource(R.string.settings_about_build), - value = - if (BuildConfig.DEBUG) { - stringResource(R.string.settings_about_debug) - } else { - stringResource(R.string.settings_about_release) - }, - ) - if (BuildConfig.GIT_SHA.isNotBlank()) { - InfoRow( - label = stringResource(R.string.settings_about_commit), - value = BuildConfig.GIT_SHA, - ) - } - - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = "https://github.com/Hy4ri/hermes-mobile", - style = - MaterialTheme.typography.bodySmall.copy( - color = MaterialTheme.colorScheme.primary, - ), - ) - - Spacer(modifier = Modifier.height(24.dp)) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - Spacer(modifier = Modifier.height(16.dp)) - } - } + }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) + if (index < items.lastIndex) { + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp, end = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant, + ) } } } @@ -247,11 +213,7 @@ internal fun InfoRow( label: String, value: String, ) { - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { Text( text = label, style = MaterialTheme.typography.bodyMedium, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt new file mode 100644 index 00000000..33e0dff4 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt @@ -0,0 +1,239 @@ +package com.m57.hermescontrol.ui.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.R +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.NavIcon +import com.m57.hermescontrol.ui.settings.components.AboutSection +import com.m57.hermescontrol.ui.settings.components.AppearanceSection +import com.m57.hermescontrol.ui.settings.components.BehaviorSection +import com.m57.hermescontrol.ui.settings.components.ChatSection +import com.m57.hermescontrol.ui.settings.components.ConnectionSection +import com.m57.hermescontrol.ui.settings.components.NavBarSection +import com.m57.hermescontrol.ui.settings.components.TestConnectionButton +import com.m57.hermescontrol.ui.settings.components.TestResultCard + +/** + * Drill-down sub-pages for Settings. Each is its own NavKey destination + * (see Navigation.kt) so the native back stack handles navigation — no + * manual routing. The SettingsViewModel stays the single source of truth. + */ + +@Composable +internal fun SettingsConnectionPage( + onBack: () -> Unit, + onLogout: () -> Unit, + viewModel: SettingsViewModel = viewModel { SettingsViewModel() }, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + var passwordVisible by remember { mutableStateOf(false) } + + HermesScaffold( + title = { Text(stringResource(R.string.settings_sec_connection)) }, + navigationIcon = NavIcon.Back(onBack), + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + ConnectionSection( + state = state, + viewModel = viewModel, + passwordVisible = passwordVisible, + onPasswordVisibilityToggle = { passwordVisible = !passwordVisible }, + ) + + TestResultCard(testResult = state.testResult) + TestConnectionButton( + isTesting = state.isTesting, + onTest = viewModel::testConnection, + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Button( + onClick = { + viewModel.logout() + onLogout() + }, + modifier = Modifier.fillMaxWidth(), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.settings_logout)) + } + } + } +} + +@Composable +internal fun SettingsAppearancePage( + onBack: () -> Unit, + viewModel: SettingsViewModel = viewModel { SettingsViewModel() }, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + HermesScaffold( + title = { Text(stringResource(R.string.settings_sec_appearance)) }, + navigationIcon = NavIcon.Back(onBack), + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + AppearanceSection( + themePreference = state.themePreference, + onThemeChange = viewModel::onThemeChange, + useDynamicColors = state.useDynamicColors, + onUseDynamicColorsChange = viewModel::onUseDynamicColorsChange, + themePreset = state.themePreset, + onThemePresetChange = viewModel::onThemePresetChange, + appLanguage = state.appLanguage, + onAppLanguageChange = viewModel::onAppLanguageChange, + ) + } + } +} + +@Composable +internal fun SettingsChatPage( + onBack: () -> Unit, + viewModel: SettingsViewModel = viewModel { SettingsViewModel() }, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + HermesScaffold( + title = { Text(stringResource(R.string.settings_sec_chat)) }, + navigationIcon = NavIcon.Back(onBack), + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + ChatSection( + typingEffectEnabled = state.typingEffectEnabled, + onTypingEffectEnabledChange = viewModel::onTypingEffectEnabledChange, + typingEffectDelayMs = state.typingEffectDelayMs, + onTypingEffectDelayMsChange = viewModel::onTypingEffectDelayMsChange, + ) + } + } +} + +@Composable +internal fun SettingsNavBarPage( + onBack: () -> Unit, + viewModel: SettingsViewModel = viewModel { SettingsViewModel() }, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + HermesScaffold( + title = { Text(stringResource(R.string.settings_sec_nav_bar)) }, + navigationIcon = NavIcon.Back(onBack), + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + NavBarSection( + bottomNavDisplayMode = state.bottomNavDisplayMode, + onBottomNavDisplayModeChange = viewModel::onBottomNavDisplayModeChange, + selectedNavItems = state.selectedNavItems, + availableNavItems = viewModel.availableNavItems, + onReorderNavItems = viewModel::reorderNavItems, + onRemoveNavItem = viewModel::removeNavItem, + onAddNavItem = viewModel::addNavItem, + ) + } + } +} + +@Composable +internal fun SettingsBehaviorPage( + onBack: () -> Unit, + viewModel: SettingsViewModel = viewModel { SettingsViewModel() }, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + HermesScaffold( + title = { Text(stringResource(R.string.settings_sec_behavior)) }, + navigationIcon = NavIcon.Back(onBack), + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + BehaviorSection( + autoReconnect = state.autoReconnect, + onAutoReconnectChange = viewModel::onAutoReconnectChange, + ) + } + } +} + +@Composable +internal fun SettingsAboutPage( + onBack: () -> Unit, + onLogout: () -> Unit, + viewModel: SettingsViewModel = viewModel { SettingsViewModel() }, +) { + HermesScaffold( + title = { Text(stringResource(R.string.settings_sec_about)) }, + navigationIcon = NavIcon.Back(onBack), + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + AboutSection(onLogout = onLogout) + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6d414fd9..70b4cad0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -179,6 +179,16 @@ Debug Release Commit + + + %1$d saved + Typing effect on + Typing effect off + On + Off + System + Light + Dark Rename Save Add Profile