diff --git a/AGENTS.md b/AGENTS.md index a3d1d775..69cb07f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,17 +77,25 @@ Uses `androidx.navigation3` (`NavKey`, `NavBackStack`, `NavDisplay`, `entry`) deduplication guard. Bypassing it stacks duplicate screen entries that compete for touch events and become unresponsive. -**⚠ Drawer scrim gets stuck when navigating to a non-gesture screen.** Screens -not in `DRAWER_GESTURE_SCREENS` disable drawer gestures. If the drawer is open -when this happens, the scrim stays visible and blocks touch. Always pair gesture -transitions with a `LaunchedEffect`: -```kotlin -LaunchedEffect(gesturesEnabled) { - if (!gesturesEnabled && drawerState.isOpen) { - drawerState.snapTo(DrawerValue.Closed) - } -} -``` +**Drawer gesture state is screen-owned (issue #619).** Each screen declares whether +the modal drawer's swipe gestures should be active while it is visible, via a single +source of truth — no global gesture set, no defensive `LaunchedEffect(snapTo(Closed))`, +no `closeDrawer` callback on `NavigationController`. + +- `HermesScaffold(drawerGesturesEnabled = true)` — default; primary screens and + most list screens. The scaffold reconciles this preference into the + `DrawerGestureController` via a `SideEffect`. +- `HermesScaffold(drawerGesturesEnabled = false)` — drill-down sub-pages (e.g. + `SettingsConnectionPage`, `SettingsAppearancePage`, …). The controller closes + the drawer itself if it was open when the screen composed, so the scrim can't + stick around and intercept the next tap. +- `DisableDrawerGestures()` — for entry screens that don't use `HermesScaffold` + (Landing, AuthLogin, PairingCodeEntry). + +`ModalNavigationDrawer` in `Navigation.kt` reads `gestureController.enabled` +(provided via `LocalDrawerGestureController`) and passes it to Material's +`gesturesEnabled` parameter. To change a screen's gesture behavior, edit the +screen — not `Navigation.kt`. ### Activity-Scoped ViewModels diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index f6ecf07c..6e4d6706 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -30,13 +30,12 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider 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 @@ -54,6 +53,9 @@ 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.common.DisableDrawerGestures +import com.m57.hermescontrol.ui.common.DrawerGestureController +import com.m57.hermescontrol.ui.common.LocalDrawerGestureController import com.m57.hermescontrol.ui.settings.SettingsAboutPage import com.m57.hermescontrol.ui.settings.SettingsAppearancePage import com.m57.hermescontrol.ui.settings.SettingsBehaviorPage @@ -69,8 +71,6 @@ import com.m57.hermescontrol.ui.pairing.PairingCodeEntryScreen as PairingCodeEnt private fun resolveBottomNavItems(names: List): List = names.mapNotNull { name -> ScreenRegistry.ALL_SCREENS.firstOrNull { it.key::class.simpleName == name } } -private val DRAWER_GESTURE_SCREENS: Set = ScreenRegistry.ALL_SCREENS.mapTo(mutableSetOf()) { it.key } - private fun appEntryProvider( sessionId: String?, openDrawer: () -> Unit, @@ -85,6 +85,8 @@ private fun appEntryProvider( NavigationController.navigateTo(PairingCodeEntryScreen) }, ) + // Landing doesn't use HermesScaffold — opt out of drawer gestures explicitly (issue #619). + DisableDrawerGestures() } entry { @@ -96,6 +98,7 @@ private fun appEntryProvider( NavigationController.goBack() }, ) + DisableDrawerGestures() } entry { @@ -107,6 +110,7 @@ private fun appEntryProvider( NavigationController.goBack() }, ) + DisableDrawerGestures() } ScreenRegistry.ALL_SCREENS.forEach { screen -> @@ -116,6 +120,10 @@ private fun appEntryProvider( } // ── Settings drill-down sub-pages ─────────────────────────────────── + // Each passes drawerGesturesEnabled = false to HermesScaffold — this is the + // single source of truth that prevents the drawer-scrim stuck-open bug + // (issue #619). No global gesture set, no closeDrawer callback, no + // LaunchedEffect(snapTo(Closed)) — the scaffold reconciles automatically. entry { SettingsConnectionPage( onBack = { NavigationController.goBack() }, @@ -169,9 +177,16 @@ 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() } } + // Single source of truth for drawer gestures (issue #619). + // HermesScaffold / DisableDrawerGestures reconcile each screen's preference + // into this controller via SideEffect; ModalNavigationDrawer reads .enabled + // below. No DRAWER_GESTURE_SCREENS set, no LaunchedEffect(snapTo(Closed)), + // no closeDrawer callback — the controller closes the drawer itself when a + // screen opts out. + val gestureController = + remember(drawerState, scope) { + DrawerGestureController(drawerState, scope) + } val bottomNavItemsState by AuthManager.bottomNavItemsFlow.collectAsState() val bottomNavDisplayMode by AuthManager.bottomNavDisplayModeFlow.collectAsState() @@ -186,194 +201,175 @@ fun MainNavigation(sessionId: String? = null) { currentScreen != LandingScreen && 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. - // 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 = drawerGesturesEnabled, - drawerContent = { - 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), + CompositionLocalProvider(LocalDrawerGestureController provides gestureController) { + ModalNavigationDrawer( + drawerState = drawerState, + gesturesEnabled = gestureController.enabled, + drawerContent = { + ModalDrawerSheet( + modifier = Modifier.verticalScroll(rememberScrollState()), ) { - 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, - ) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + val connectionStatus by HermesWsClient.connectionStatus.collectAsState() + val statusColor = + when (connectionStatus) { + ConnectionStatus.CONNECTED -> LocalHermesStatusColors.current.success - 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, - ), - ) - } - } + ConnectionStatus.CONNECTING, + ConnectionStatus.RECONNECTING, + -> LocalHermesStatusColors.current.warning - Spacer(modifier = Modifier.height(8.dp)) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - Spacer(modifier = Modifier.height(12.dp)) - } - }, - ) { - Scaffold( - contentWindowInsets = WindowInsets.navigationBars, - bottomBar = { - if (showBottomBar) { - val barHeight = - when (bottomNavDisplayMode) { - BottomNavDisplayMode.ICON_ONLY -> 56.dp - BottomNavDisplayMode.TEXT_ONLY -> 44.dp - BottomNavDisplayMode.ICON_AND_TEXT -> 80.dp + ConnectionStatus.DISCONNECTED, + ConnectionStatus.NO_NETWORK, + ConnectionStatus.AUTH_EXPIRED, + -> LocalHermesStatusColors.current.error } - NavigationBar( - modifier = Modifier.height(barHeight), + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 2.dp), ) { - bottomNavItems.forEach { item -> - val showIcon = - bottomNavDisplayMode == BottomNavDisplayMode.ICON_AND_TEXT || - bottomNavDisplayMode == BottomNavDisplayMode.ICON_ONLY - val showLabel = - bottomNavDisplayMode == BottomNavDisplayMode.ICON_AND_TEXT || - bottomNavDisplayMode == BottomNavDisplayMode.TEXT_ONLY - - val isSelected = currentScreen == item.key - - NavigationBarItem( - selected = isSelected, - onClick = { NavigationController.navigateTo(item.key) }, - colors = - if (bottomNavDisplayMode == BottomNavDisplayMode.TEXT_ONLY) { - NavigationBarItemDefaults.colors( - indicatorColor = Color.Transparent, - ) - } else { - NavigationBarItemDefaults.colors() - }, - icon = { - if (showIcon) { - Icon(item.icon, contentDescription = stringResource(item.labelRes)) - } else { - Text( - text = stringResource(item.labelRes), - style = MaterialTheme.typography.labelMedium, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - ) - } - }, - label = - if (showLabel && showIcon) { - { Text(stringResource(item.labelRes)) } - } else { - null - }, - modifier = - Modifier.testTag( - "nav_${item.key::class.simpleName?.lowercase()?.removeSuffix("screen") ?: ""}", - ), - ) - } + 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, + ) + 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)) } }, - ) { paddingValues -> - NavDisplay( - backStack = backStack, - onBack = { NavigationController.goBack() }, - entryProvider = - appEntryProvider(sessionId, openDrawer), - modifier = - Modifier - .padding(paddingValues) - .consumeWindowInsets(paddingValues) - .fillMaxSize(), - ) + ) { + Scaffold( + contentWindowInsets = WindowInsets.navigationBars, + bottomBar = { + if (showBottomBar) { + val barHeight = + when (bottomNavDisplayMode) { + BottomNavDisplayMode.ICON_ONLY -> 56.dp + BottomNavDisplayMode.TEXT_ONLY -> 44.dp + BottomNavDisplayMode.ICON_AND_TEXT -> 80.dp + } + NavigationBar( + modifier = Modifier.height(barHeight), + ) { + bottomNavItems.forEach { item -> + val showIcon = + bottomNavDisplayMode == BottomNavDisplayMode.ICON_AND_TEXT || + bottomNavDisplayMode == BottomNavDisplayMode.ICON_ONLY + val showLabel = + bottomNavDisplayMode == BottomNavDisplayMode.ICON_AND_TEXT || + bottomNavDisplayMode == BottomNavDisplayMode.TEXT_ONLY + + val isSelected = currentScreen == item.key + + NavigationBarItem( + selected = isSelected, + onClick = { NavigationController.navigateTo(item.key) }, + colors = + if (bottomNavDisplayMode == BottomNavDisplayMode.TEXT_ONLY) { + NavigationBarItemDefaults.colors( + indicatorColor = Color.Transparent, + ) + } else { + NavigationBarItemDefaults.colors() + }, + icon = { + if (showIcon) { + Icon(item.icon, contentDescription = stringResource(item.labelRes)) + } else { + Text( + text = stringResource(item.labelRes), + style = MaterialTheme.typography.labelMedium, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + ) + } + }, + label = + if (showLabel && showIcon) { + { Text(stringResource(item.labelRes)) } + } else { + null + }, + modifier = + Modifier.testTag( + "nav_${item.key::class.simpleName?.lowercase()?.removeSuffix( + "screen", + ) ?: ""}", + ), + ) + } + } + } + }, + ) { paddingValues -> + NavDisplay( + backStack = backStack, + onBack = { NavigationController.goBack() }, + entryProvider = + appEntryProvider(sessionId, openDrawer), + modifier = + Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + .fillMaxSize(), + ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt index 0780aa3a..8b3f1475 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt @@ -18,11 +18,6 @@ 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. @@ -51,11 +46,11 @@ 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() } + // Drawer dismissal is handled by DrawerGestureController (issue #619): + // when a non-gesture sub-page composes, its HermesScaffold reconciles + // drawerGesturesEnabled=false and the controller closes the drawer + // itself via SideEffect. No synchronous closeDrawer callback here. stack.add(key) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt index 6520b6a2..43a00c09 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt @@ -11,6 +11,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.DrawerState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -21,6 +22,11 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.layout @@ -29,6 +35,8 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import com.m57.hermescontrol.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import kotlin.math.roundToInt /** Sealed interface for navigation icon types — eliminates boolean-flag anti-pattern. */ @@ -44,6 +52,66 @@ sealed interface NavIcon { ) : NavIcon } +/** + * Single source of truth for whether the modal navigation drawer's swipe gestures + * should be enabled for the currently-composed screen. + * + * Each [HermesScaffold] (or [DisableDrawerGestures]) reconciles its preference into + * the singleton instance via a [SideEffect]; `ModalNavigationDrawer` in `Navigation.kt` + * reads [enabled] and passes it straight to Material's `gesturesEnabled` parameter. + * + * This replaces the old `DRAWER_GESTURE_SCREENS` set + `LaunchedEffect(snapTo(Closed))` + * workaround documented in issue #619: gesture state now flows from the rendered + * screen automatically, and the drawer controller closes the drawer itself when a + * screen opts out — no global allow-list, no defensive close callbacks. + */ +class DrawerGestureController( + private val drawerState: DrawerState, + private val scope: CoroutineScope, +) { + var enabled by mutableStateOf(false) + private set + + /** + * Reconcile a screen's desired gesture state. + * + * Called from a [SideEffect] on every screen composition. If a screen opts out + * of gestures while the drawer is open, we close it synchronously so the scrim + * cannot intercept the next tap (the bug class from PRs #445 / #454 / #455). + */ + fun reconcile(desired: Boolean) { + if (desired == enabled) return + enabled = desired + if (!desired && drawerState.isOpen) { + scope.launch { drawerState.close() } + } + } +} + +/** + * CompositionLocal that exposes the active [DrawerGestureController]. + * + * Provided by `MainNavigation` and read both by [HermesScaffold] (to reconcile a + * screen's preference) and by `ModalNavigationDrawer` (to gate gestures). + */ +val LocalDrawerGestureController = + compositionLocalOf { + null + } + +/** + * Opt out of drawer gestures for a screen that does NOT use [HermesScaffold]. + * + * Used by full-bleed entry screens (Landing, AuthLogin, PairingCodeEntry) that + * render their own layout. Scaffold-based screens should instead pass + * `drawerGesturesEnabled = false` to [HermesScaffold]. + */ +@Composable +fun DisableDrawerGestures() { + val controller = LocalDrawerGestureController.current ?: return + SideEffect { controller.reconcile(false) } +} + /** * Shared Scaffold wrapper that standardizes the TopAppBar. * @@ -52,6 +120,7 @@ sealed interface NavIcon { * - Uses Spacing tokens internally * - Simplified API: [navigationIcon] replaces the old showBack/onBack/onOpenDrawer trio * - Safe-area aware via Scaffold insets + * - Drawer-gesture aware via [drawerGesturesEnabled] (issue #619) * * Screens that still need a Back arrow instead of a Menu icon can set * `navigationIcon = NavIcon.Back { … }` instead. @@ -62,6 +131,16 @@ fun HermesScaffold( modifier: Modifier = Modifier, title: @Composable () -> Unit, navigationIcon: NavIcon? = null, + /** + * Whether the modal navigation drawer's swipe-open/swipe-close gestures should + * be active while this screen is visible. Defaults to `true` (primary screens). + * + * Pass `false` for drill-down sub-pages (e.g. Settings sub-pages) where the + * drawer should be closed and locked. The [DrawerGestureController] reconciles + * this preference and closes the drawer automatically if a screen opts out + * while the drawer is open — see issue #619. + */ + drawerGesturesEnabled: Boolean = true, onRefresh: (() -> Unit)? = null, isRefreshing: Boolean = false, pinTopBar: Boolean = false, @@ -69,6 +148,9 @@ fun HermesScaffold( actions: @Composable () -> Unit = {}, content: @Composable (PaddingValues) -> Unit, ) { + val gestureController = LocalDrawerGestureController.current + SideEffect { gestureController?.reconcile(drawerGesturesEnabled) } + val scrollBehavior = if (pinTopBar) { TopAppBarDefaults.pinnedScrollBehavior() 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 index 33e0dff4..a385aea5 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt @@ -39,6 +39,10 @@ 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. + * + * All sub-pages pass `drawerGesturesEnabled = false` to [HermesScaffold] so the + * modal drawer's swipe gestures are disabled and the drawer auto-closes when a + * sub-page is entered — the single source of truth introduced in issue #619. */ @Composable @@ -53,6 +57,9 @@ internal fun SettingsConnectionPage( HermesScaffold( title = { Text(stringResource(R.string.settings_sec_connection)) }, navigationIcon = NavIcon.Back(onBack), + // Non-primary drill-down: opt out of drawer gestures so the scrim can't + // get stuck open (issue #619). DrawerGestureController handles the close. + drawerGesturesEnabled = false, ) { Column( modifier = @@ -104,6 +111,7 @@ internal fun SettingsAppearancePage( HermesScaffold( title = { Text(stringResource(R.string.settings_sec_appearance)) }, navigationIcon = NavIcon.Back(onBack), + drawerGesturesEnabled = false, ) { Column( modifier = @@ -137,6 +145,7 @@ internal fun SettingsChatPage( HermesScaffold( title = { Text(stringResource(R.string.settings_sec_chat)) }, navigationIcon = NavIcon.Back(onBack), + drawerGesturesEnabled = false, ) { Column( modifier = @@ -166,6 +175,7 @@ internal fun SettingsNavBarPage( HermesScaffold( title = { Text(stringResource(R.string.settings_sec_nav_bar)) }, navigationIcon = NavIcon.Back(onBack), + drawerGesturesEnabled = false, ) { Column( modifier = @@ -198,6 +208,7 @@ internal fun SettingsBehaviorPage( HermesScaffold( title = { Text(stringResource(R.string.settings_sec_behavior)) }, navigationIcon = NavIcon.Back(onBack), + drawerGesturesEnabled = false, ) { Column( modifier = @@ -224,6 +235,7 @@ internal fun SettingsAboutPage( HermesScaffold( title = { Text(stringResource(R.string.settings_sec_about)) }, navigationIcon = NavIcon.Back(onBack), + drawerGesturesEnabled = false, ) { Column( modifier =