refactor(#619): kill DRAWER_GESTURE_SCREENS — screen-owned drawer gestures via HermesScaffold (#624)

Replaces the fragile DRAWER_GESTURE_SCREENS global set + defensive
LaunchedEffect(snapTo(Closed)) + closeDrawer callback workaround with a
single source of truth: each screen declares its drawer-gesture preference
via HermesScaffold's new drawerGesturesEnabled parameter (default true).

Architecture:
- DrawerGestureController holds the active gesture state. HermesScaffold
  reconciles each screen's preference into it via SideEffect; when a screen
  opts out while the drawer is open, the controller closes the drawer itself.
- LocalDrawerGestureController CompositionLocal flows the controller from
  MainNavigation down to ModalNavigationDrawer, which reads .enabled and
  passes it to Material's gesturesEnabled parameter.
- DisableDrawerGestures() helper for entry screens that don't use
  HermesScaffold (Landing, AuthLogin, PairingCodeEntry).
- All 6 Settings drill-down sub-pages pass drawerGesturesEnabled = false.
- NavigationController.closeDrawer removed; navigateTo no longer needs a
  synchronous drawer-dismiss hook.

Removes: DRAWER_GESTURE_SCREENS set, both LaunchedEffect(gesturesEnabled)
and LaunchedEffect(drawerGesturesEnabled) blocks, drawerGesturesEnabled
local state, closeDrawer callback on NavigationController.

Fixes the recurring drawer-scrim stuck-open bug class (PRs #445, #454, #455).

Verified: ktlintCheck, assembleDebug, testDebugUnitTest all green locally.

Co-authored-by: Hy4ri <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andHy4ri authored and GitHub committed 2026-07-18 14:25:23 +03:00
1 parent 0c2b9679b7
commit 0a0b612d3a
5 files changed
+297 -204

No files matched your search

@@ -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<DrawerGestureController?> {
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()