From 8a34ae692f8bef101389812da10b53c72c393060 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 02:04:35 +0000 Subject: [PATCH 1/6] feat: adapt the app shell to large screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three layout tiers driven by the window width size class, published once through LocalScreenLayout (ScreenLayout.kt): - Compact (phones): unchanged — bottom bar + modal drawer. - Medium (portrait tablets, unfolded foldables): the bottom bar is replaced by a left NavigationRail built from the same user-configured BottomBarEntry list (customization, pinned favorites and new-item dots carry over); the drawer stays modal behind the rail's avatar button. - Expanded (landscape tablets, desktop windows): the drawer is permanently docked on the left (no ModalNavigationDrawer), and on windows >= 1200dp a docked notification panel renders the notifications card feed on the right, sharing last-read marking with the full screen. The panel hides while the Notifications screen itself is open. Large screens also pin the chrome: DisappearingScaffold stops hiding the top/bottom bars on scroll (and stops toggling the OS status bar), and AppBottomBar renders nothing everywhere. Feed content width is capped at 600dp inside wide center panes: the shell measures the center pane and provides (paneWidth - 600dp) / 2 via LocalFeedSidePadding (commons), which rememberFeedContentPadding merges into every feed's contentPadding — the scroll surface stays full-width so pull-to-refresh and edge scrolling keep working. Panes that manage their own width (Messages two-pane, the notification panel) override it back to 0. Screen sweep: Messages now picks single/two-pane from its actual pane width instead of the window size class; the Home/Messages pagers only attach the drawer edge-swipe when a modal drawer exists; top-bar avatar drawer-openers hide on large screens; FABs keep their bottom spacing without the bar; the status editor in the drawer no longer cancels editing when the drawer is permanent. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RHSoAVvYioihaJeSumX8J7 --- .../ui/feeds/RememberForeverStates.kt | 1 + .../ui/layouts/DisappearingScaffold.kt | 10 +- .../amethyst/ui/layouts/ScreenLayout.kt | 108 ++++++++++++ .../amethyst/ui/navigation/AppNavigation.kt | 19 +++ .../ui/navigation/bottombars/AppBottomBar.kt | 5 + .../bottombars/AppNavigationRail.kt | 155 ++++++++++++++++++ .../bottombars/FabBottomBarPadding.kt | 9 +- .../ui/navigation/drawer/DrawerContent.kt | 128 ++++++++++----- .../topbars/UserDrawerSearchTopBar.kt | 7 +- .../AccountSwitcherAndLeftDrawerLayout.kt | 140 +++++++++++++--- .../loggedIn/chats/rooms/MessagesScreen.kt | 67 ++++---- .../chats/rooms/feed/ChatroomListTabs.kt | 18 +- .../chats/rooms/twopane/MessagesTwoPane.kt | 134 ++++++++------- .../ui/screen/loggedIn/home/HomeScreen.kt | 18 +- .../loggedIn/napplets/NappletsTopBar.kt | 3 +- .../notifications/NotificationSidePanel.kt | 127 ++++++++++++++ .../commons/ui/layouts/PaddingMerge.kt | 22 ++- 17 files changed, 797 insertions(+), 174 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayout.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index 4611f09a70..fb56c99551 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -38,6 +38,7 @@ private data class ScrollState( object ScrollStateKeys { const val NOTIFICATION_SCREEN = "NotificationsFeed" + const val NOTIFICATION_SIDE_PANEL = "NotificationsSidePanel" const val NOTIFICATION_FOLLOWING = "NotificationsFollowingFeed" const val NOTIFICATION_EVERYONE = "NotificationsEveryoneFeed" const val VIDEO_SCREEN = "VideoFeed" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt index 6f36f0e63f..fd8e8ab850 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt @@ -80,10 +80,14 @@ fun DisappearingScaffold( ) { val state = rememberDisappearingBarState() + // Large screens (rail / permanent drawer) pin the chrome: bars never slide away on + // scroll, and the immersive status-bar hiding stays off. + val canHideBars = allowBarHide && !LocalScreenLayout.current.isLargeScreen + // Hold the latest values in state so the NSC's captured lambda stays fresh across // recompositions without rebuilding the NSC itself. val latestIsActive by rememberUpdatedState(isActive) - val latestAllowBarHide by rememberUpdatedState(allowBarHide) + val latestAllowBarHide by rememberUpdatedState(canHideBars) val latestAccountViewModel by rememberUpdatedState(accountViewModel) val connection = @@ -100,7 +104,7 @@ fun DisappearingScaffold( } // Only wire the lifecycle observer + system-bar control when the scaffold actually moves its bars. - if (allowBarHide) { + if (canHideBars) { ResetBarsOnResume(state) ImmersiveStatusBarEffect(state) } @@ -110,7 +114,7 @@ fun DisappearingScaffold( // LocalContentColor, matching M3 Scaffold's behaviour (without it, default text // color falls back to Color.Black and is invisible on the dark theme). val baseModifier = - if (allowBarHide) { + if (canHideBars) { Modifier.imePadding().nestedScroll(connection) } else { Modifier.imePadding() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayout.kt new file mode 100644 index 0000000000..ae373a5ed6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayout.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.layouts + +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.ui.components.getActivity + +/** How the app shell presents its top-level navigation for the current window size. */ +enum class NavigationStyle { + /** Compact windows (phones): bottom navigation bar + modal drawer. */ + BOTTOM_BAR, + + /** + * Medium windows (portrait tablets, unfolded foldables): a left navigation rail + * replaces the bottom bar; the drawer stays modal behind the rail's avatar button. + */ + NAV_RAIL, + + /** Expanded windows (landscape tablets, desktop windows): the drawer docks permanently on the left. */ + PERMANENT_DRAWER, +} + +/** + * The shell layout decisions for the current window, published once per window size change + * through [LocalScreenLayout] so every screen, bar and panel agrees on the same tier. + */ +@Immutable +data class ScreenLayoutSpec( + val navigationStyle: NavigationStyle, + val showsNotificationPanel: Boolean, +) { + /** + * True on the rail and permanent-drawer tiers. Large screens hide the bottom bar and pin + * the top/bottom chrome (no disappearing bars on scroll). + */ + val isLargeScreen: Boolean get() = navigationStyle != NavigationStyle.BOTTOM_BAR + + companion object { + val Phone = ScreenLayoutSpec(NavigationStyle.BOTTOM_BAR, showsNotificationPanel = false) + } +} + +val LocalScreenLayout = compositionLocalOf { ScreenLayoutSpec.Phone } + +/** + * Minimum window width for the docked notification panel: the permanent drawer + * ([PermanentDrawerWidth]) + a readable center pane + the panel ([NotificationPanelWidth]) + * only coexist comfortably from a landscape-tablet-sized window up. + */ +private const val NOTIFICATION_PANEL_MIN_WINDOW_DP = 1200 + +val PermanentDrawerWidth = 300.dp + +val NotificationPanelWidth = 360.dp + +/** + * Maximum width of a feed's content column inside a wide center pane. Wider than this, + * feeds center themselves via [com.vitorpamplona.amethyst.commons.ui.layouts.LocalFeedSidePadding]; + * the scroll surface itself stays full-pane so pull-to-refresh and scrolling work edge to edge. + */ +val FeedContentMaxWidth = 600.dp + +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +@Composable +fun rememberScreenLayoutSpec(): ScreenLayoutSpec { + val widthSizeClass = calculateWindowSizeClass(getActivity()).widthSizeClass + val windowWidthDp = LocalConfiguration.current.screenWidthDp + return remember(widthSizeClass, windowWidthDp) { + val style = + when (widthSizeClass) { + WindowWidthSizeClass.Expanded -> NavigationStyle.PERMANENT_DRAWER + WindowWidthSizeClass.Medium -> NavigationStyle.NAV_RAIL + else -> NavigationStyle.BOTTOM_BAR + } + ScreenLayoutSpec( + navigationStyle = style, + showsNotificationPanel = + style == NavigationStyle.PERMANENT_DRAWER && + windowWidthDp >= NOTIFICATION_PANEL_MIN_WINDOW_DP, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index d4866a71b0..0e25c9e778 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -29,6 +29,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -59,6 +60,8 @@ import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress import com.vitorpamplona.amethyst.ui.call.CallActivity import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages +import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout +import com.vitorpamplona.amethyst.ui.layouts.rememberScreenLayoutSpec import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.navs.rememberNav @@ -282,6 +285,22 @@ fun AppNavigation( ) { val nav = rememberNav() + // One layout decision per window size for the whole shell: bottom bar vs rail vs + // permanent drawer, plus the docked notification panel. Every screen, bar and panel + // below reads the same spec through LocalScreenLayout. + val screenLayout = rememberScreenLayoutSpec() + + CompositionLocalProvider(LocalScreenLayout provides screenLayout) { + AppNavigationLayers(accountViewModel, accountSessionManager, nav) + } +} + +@Composable +private fun AppNavigationLayers( + accountViewModel: AccountViewModel, + accountSessionManager: AccountSessionManager, + nav: Nav, +) { AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) { Box(Modifier.fillMaxSize()) { BuildNavigation(accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt index 145f145807..a635f485ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt @@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel +import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -73,6 +74,10 @@ fun AppBottomBar( accountViewModel: AccountViewModel, onClick: (Route) -> Unit, ) { + // Large screens replace the bottom bar with the navigation rail (Medium) or the + // permanently docked drawer (Expanded). + if (LocalScreenLayout.current.isLargeScreen) return + // Hide the bar on entries that aren't a tab root (drawer or in-app // pushes). Mirrors the back-arrow rule in canPop(). if (nav.canPop()) return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt new file mode 100644 index 0000000000..d90eb99281 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.navigation.bottombars + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.compose.currentBackStackEntryAsState +import com.vitorpamplona.amethyst.commons.browser.OmniboxInput +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry +import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry +import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments +import com.vitorpamplona.amethyst.ui.navigation.topbars.LoggedInUserPictureDrawer +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size25Modifier +import com.vitorpamplona.amethyst.ui.theme.Size27Modifier +import com.vitorpamplona.amethyst.ui.theme.onSurface65 + +/** + * Medium-width windows: a left rail that carries the same user-configured destinations as the + * phone bottom bar ([BottomBarEntry] list, built-ins and pinned favorites interleaved), so the + * user's customization and new-item dots carry over. The header avatar opens the modal drawer, + * mirroring the avatar button in the phone top bars. + */ +@Composable +fun AppNavigationRail( + nav: Nav, + accountViewModel: AccountViewModel, +) { + val items by accountViewModel.settings.uiSettingsFlow.bottomBarItems + .collectAsStateWithLifecycle() + val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val favoritesById = remember(favorites) { favorites.associateBy { it.id } } + + // Captured favicons, so a pinned web favorite shows the site's icon instead of the generic globe. + val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle() + + val navBackStackEntry by nav.controller.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + + NavigationRail( + containerColor = MaterialTheme.colorScheme.background, + header = { + // Same affordance as the phone top bars: the account avatar opens the drawer. + LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer) + }, + ) { + Column( + modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + items.forEach { entry -> + when (entry) { + is BottomBarEntry.BuiltIn -> { + val def = NavBarCatalog[entry.item] ?: return@forEach + val destination = remember(def, accountViewModel) { def.resolveRoute(accountViewModel) } + val selected = currentDestination?.hasRoute(destination::class) == true + NavigationRailItem( + selected = selected, + onClick = { if (!selected) nav.navBottomBar(destination) }, + icon = { + Box(Size27Modifier, contentAlignment = Alignment.Center) { + Icon( + symbol = def.icon, + contentDescription = stringRes(def.labelRes), + modifier = Size25Modifier, + tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65, + ) + AddNotifIconIfNeeded(destination, accountViewModel, Modifier.align(Alignment.TopEnd)) + } + }, + ) + } + + is BottomBarEntry.Favorite -> { + val fav = favoritesById[entry.favoriteId] ?: return@forEach + val destination = + when (fav) { + is FavoriteApp.WebApp -> Route.WebApp(fav.url) + is FavoriteApp.NostrApp -> Route.NostrApp(fav.coordinate) + } + // Favorites carry arguments (url / coordinate), so class matching alone + // would light up every pinned app of the same kind; compare the full route. + val selected = + remember(navBackStackEntry, destination) { + when (destination) { + is Route.WebApp -> getRouteWithArguments(Route.WebApp::class, nav.controller) == destination + is Route.NostrApp -> getRouteWithArguments(Route.NostrApp::class, nav.controller) == destination + else -> false + } + } + val iconModel = + when (fav) { + is FavoriteApp.WebApp -> + remember(fav, iconKeys) { + OmniboxInput.hostOf(fav.url)?.let(BrowserIconRegistry::iconModelFor) + } + is FavoriteApp.NostrApp -> rememberNappletIconModel(fav.coordinate) + } + NavigationRailItem( + selected = selected, + onClick = { if (!selected) nav.navBottomBar(destination) }, + icon = { + Box(Size27Modifier, contentAlignment = Alignment.Center) { + FavoriteAppIcon( + app = fav, + tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65, + modifier = Size25Modifier, + iconModel = iconModel, + ) + } + }, + ) + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/FabBottomBarPadding.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/FabBottomBarPadding.kt index 18d199d594..c7fc2c4c5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/FabBottomBarPadding.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/FabBottomBarPadding.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout import com.vitorpamplona.amethyst.ui.navigation.navs.INav /** @@ -39,7 +40,13 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav val FABPaddingFromBottom = 30.dp @Composable -fun Modifier.fabBottomBarPadding(nav: INav): Modifier = if (nav.canPop()) padding(bottom = FABPaddingFromBottom) else this +fun Modifier.fabBottomBarPadding(nav: INav): Modifier = + if (nav.canPop() || LocalScreenLayout.current.isLargeScreen) { + // canPop entries hide the bar on phones; large screens never render it at all. + padding(bottom = FABPaddingFromBottom) + } else { + this + } /** * Convenience wrapper that places [content] in a [Box] with [fabBottomBarPadding] applied. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index a4ba397054..2c2ed264f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -43,6 +43,7 @@ import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -56,6 +57,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -102,6 +104,9 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout +import com.vitorpamplona.amethyst.ui.layouts.NavigationStyle +import com.vitorpamplona.amethyst.ui.layouts.PermanentDrawerWidth import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerFeedsItems import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerNavigateItems import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerYouItems @@ -147,59 +152,93 @@ fun DrawerContent( openSheet: () -> Unit, accountViewModel: AccountViewModel, ) { - val onClickUser = { - nav.nav(routeFor(accountViewModel.userProfile())) - nav.closeDrawer() - } - ModalDrawerSheet( windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Bottom + WindowInsetsSides.Start), drawerContainerColor = MaterialTheme.colorScheme.background, drawerTonalElevation = 0.dp, + ) { + DrawerContentBody(nav, openSheet, accountViewModel) + } +} + +/** + * Expanded windows: the same drawer content, permanently docked on the left of the shell + * instead of sliding in as a modal sheet. + */ +@Composable +fun PermanentDrawerContent( + nav: INav, + openSheet: () -> Unit, + accountViewModel: AccountViewModel, +) { + Surface( + modifier = Modifier.width(PermanentDrawerWidth).fillMaxHeight(), + color = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, ) { Column( - Modifier - .fillMaxHeight() - .verticalScroll(rememberScrollState()), + Modifier.windowInsetsPadding( + WindowInsets.systemBars.only(WindowInsetsSides.Bottom + WindowInsetsSides.Start), + ), ) { - ProfileContent( - baseAccountUser = accountViewModel.account.userProfile(), - modifier = profileContentHeaderModifier, - accountViewModel, - onClickUser, - ) - - Column(drawerSpacing) { - EditStatusBoxes(accountViewModel.account.userProfile(), accountViewModel, nav) - } - - FollowingAndFollowerCounts(accountViewModel.account, accountViewModel, onClickUser) - - HorizontalDivider( - thickness = DividerThickness, - modifier = Modifier.padding(top = 20.dp), - ) - - Spacer(modifier = StdHorzSpacer) - - ListContent( - modifier = Modifier.fillMaxWidth(), - openSheet, - accountViewModel, - nav, - ) - - Spacer(modifier = Modifier.weight(1f)) - - BottomContent( - accountViewModel.account.userProfile(), - accountViewModel, - nav, - ) + DrawerContentBody(nav, openSheet, accountViewModel) } } } +@Composable +private fun DrawerContentBody( + nav: INav, + openSheet: () -> Unit, + accountViewModel: AccountViewModel, +) { + val onClickUser = { + nav.nav(routeFor(accountViewModel.userProfile())) + nav.closeDrawer() + } + + Column( + Modifier + .fillMaxHeight() + .verticalScroll(rememberScrollState()), + ) { + ProfileContent( + baseAccountUser = accountViewModel.account.userProfile(), + modifier = profileContentHeaderModifier, + accountViewModel, + onClickUser, + ) + + Column(drawerSpacing) { + EditStatusBoxes(accountViewModel.account.userProfile(), accountViewModel, nav) + } + + FollowingAndFollowerCounts(accountViewModel.account, accountViewModel, onClickUser) + + HorizontalDivider( + thickness = DividerThickness, + modifier = Modifier.padding(top = 20.dp), + ) + + Spacer(modifier = StdHorzSpacer) + + ListContent( + modifier = Modifier.fillMaxWidth(), + openSheet, + accountViewModel, + nav, + ) + + Spacer(modifier = Modifier.weight(1f)) + + BottomContent( + accountViewModel.account.userProfile(), + accountViewModel, + nav, + ) + } +} + @Composable fun ProfileContent( baseAccountUser: User, @@ -392,8 +431,11 @@ fun StatusEditBar( val currentStatus = remember { mutableStateOf(savedStatus ?: "") } + // In the permanent drawer the DrawerState never opens (it stays Closed while the drawer + // is always on screen), so the modal close-cancels-editing behavior must not apply there. + val isPermanentDrawer = LocalScreenLayout.current.navigationStyle == NavigationStyle.PERMANENT_DRAWER LaunchedEffect(nav.drawerState.isClosed) { - if (nav.drawerState.isClosed) { + if (!isPermanentDrawer && nav.drawerState.isClosed) { focusManager.clearFocus(true) onDone() } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt index efdcfff438..36ca168941 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt @@ -35,6 +35,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPicture import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon @@ -65,12 +66,14 @@ fun UserDrawerSearchTopBar( navigationIcon = { // When this screen sits on top of a back stack (entered via the drawer // or any deep link), show a back arrow. When it's the root (entered via - // the bottom nav, which clears the stack), show the drawer opener. + // the bottom nav, which clears the stack), show the drawer opener — + // unless a large-screen shell already shows the drawer (rail avatar or + // permanently docked pane). if (nav.canPop()) { IconButton(onClick = nav::popBack) { ArrowBackIcon() } - } else { + } else if (!LocalScreenLayout.current.isLargeScreen) { LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer) } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt index d4c7090b08..494cce6135 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt @@ -22,13 +22,19 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.content.res.Configuration import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.SheetValue +import androidx.compose.material3.VerticalDivider import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -36,15 +42,25 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.dp import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.compose.currentBackStackEntryAsState +import com.vitorpamplona.amethyst.commons.ui.layouts.LocalFeedSidePadding +import com.vitorpamplona.amethyst.ui.layouts.FeedContentMaxWidth +import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout +import com.vitorpamplona.amethyst.ui.layouts.NavigationStyle +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppNavigationRail import com.vitorpamplona.amethyst.ui.navigation.drawer.AccountSwitchBottomSheet import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerContent +import com.vitorpamplona.amethyst.ui.navigation.drawer.PermanentDrawerContent import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSelectionDrag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationSidePanel +import com.vitorpamplona.amethyst.ui.theme.DividerThickness import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -75,6 +91,51 @@ fun AccountSwitcherAndLeftDrawerLayout( } } + when (LocalScreenLayout.current.navigationStyle) { + NavigationStyle.PERMANENT_DRAWER -> + PermanentDrawerShell(accountViewModel, nav, openSheetFunction, content) + + NavigationStyle.NAV_RAIL -> + ModalDrawerShell(accountViewModel, nav, openSheetFunction, showRail = true, content) + + NavigationStyle.BOTTOM_BAR -> + ModalDrawerShell(accountViewModel, nav, openSheetFunction, showRail = false, content) + } + + // Sheet content + if (openAccountSwitcherBottomSheet) { + ModalBottomSheet( + onDismissRequest = { + scope + .launch { sheetState.hide() } + .invokeOnCompletion { + if (!sheetState.isVisible) { + openAccountSwitcherBottomSheet = false + } + } + }, + sheetState = sheetState, + ) { + AccountSwitchBottomSheet( + accountViewModel = accountViewModel, + accountSessionManager = accountSessionManager, + ) + } + } +} + +/** + * Compact and Medium windows: the drawer slides in as a modal sheet. On Medium a + * [AppNavigationRail] sits at the left edge in place of the phone bottom bar. + */ +@Composable +private fun ModalDrawerShell( + accountViewModel: AccountViewModel, + nav: Nav, + openSheet: () -> Unit, + showRail: Boolean, + content: @Composable () -> Unit, +) { val orientation = LocalConfiguration.current.orientation val currentDrawerState = nav.drawerState.currentValue LaunchedEffect(key1 = orientation) { @@ -104,30 +165,69 @@ fun AccountSwitcherAndLeftDrawerLayout( drawerState = nav.drawerState, gesturesEnabled = drawerGesturesEnabled, drawerContent = { - DrawerContent(nav, openSheetFunction, accountViewModel) + DrawerContent(nav, openSheet, accountViewModel) BackHandler(enabled = nav.drawerState.isOpen, nav::closeDrawer) }, - content = content, + content = { + if (showRail) { + Row(Modifier.fillMaxSize()) { + AppNavigationRail(nav, accountViewModel) + VerticalDivider(thickness = DividerThickness) + CenterPane(Modifier.weight(1f), content) + } + } else { + content() + } + }, ) +} - // Sheet content - if (openAccountSwitcherBottomSheet) { - ModalBottomSheet( - onDismissRequest = { - scope - .launch { sheetState.hide() } - .invokeOnCompletion { - if (!sheetState.isVisible) { - openAccountSwitcherBottomSheet = false - } - } - }, - sheetState = sheetState, - ) { - AccountSwitchBottomSheet( - accountViewModel = accountViewModel, - accountSessionManager = accountSessionManager, - ) +/** + * Expanded windows: the drawer is permanently docked on the left, the bottom bar disappears, + * and — when the window is wide enough — the notification feed docks on the right. + */ +@Composable +private fun PermanentDrawerShell( + accountViewModel: AccountViewModel, + nav: Nav, + openSheet: () -> Unit, + content: @Composable () -> Unit, +) { + val navBackStackEntry by nav.controller.currentBackStackEntryAsState() + // The panel duplicates the Notifications screen, so it steps aside while the user is there. + val showPanel = + LocalScreenLayout.current.showsNotificationPanel && + navBackStackEntry?.destination?.hasRoute() != true + + Row(Modifier.fillMaxSize()) { + PermanentDrawerContent(nav, openSheet, accountViewModel) + + VerticalDivider(thickness = DividerThickness) + + CenterPane(Modifier.weight(1f), content) + + if (showPanel) { + VerticalDivider(thickness = DividerThickness) + NotificationSidePanel(accountViewModel, nav) + } + } +} + +/** + * Hosts the navigation content and publishes the side padding feeds need to cap their content + * at [FeedContentMaxWidth] within this pane, via [LocalFeedSidePadding]. + */ +@Composable +private fun CenterPane( + modifier: Modifier, + content: @Composable () -> Unit, +) { + BoxWithConstraints(modifier) { + val sidePadding = ((maxWidth - FeedContentMaxWidth) / 2).coerceAtLeast(0.dp) + CompositionLocalProvider(LocalFeedSidePadding provides sidePadding) { + Box(Modifier.fillMaxSize()) { + content() + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt index ab8ac1842c..0ecafb852b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt @@ -20,57 +20,48 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms -import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass -import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext -import com.vitorpamplona.amethyst.ui.components.getActivity +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.singlepane.MessagesSinglePane import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.twopane.MessagesTwoPane -@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Composable fun MessagesScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val act = LocalContext.current.getActivity() - val windowSizeClass = calculateWindowSizeClass(act) + // Decide single- vs two-pane from the pane this screen actually occupies, not the + // window: on large screens the shell already spends width on the rail / permanent + // drawer / notification panel, so window-level size classes would overestimate. + BoxWithConstraints(Modifier.fillMaxSize()) { + val paneWidth = maxWidth - val twoPane by remember(windowSizeClass.widthSizeClass) { - derivedStateOf { - when (windowSizeClass.widthSizeClass) { - WindowWidthSizeClass.Compact -> false - - WindowWidthSizeClass.Expanded, - WindowWidthSizeClass.Medium, - -> true - - else -> false - } + if (paneWidth >= 600.dp) { + MessagesTwoPane( + knownFeedContentState = accountViewModel.feedStates.dmKnown, + newFeedContentState = accountViewModel.feedStates.dmNew, + widthSizeClass = + if (paneWidth >= 840.dp) { + WindowWidthSizeClass.Expanded + } else { + WindowWidthSizeClass.Medium + }, + accountViewModel = accountViewModel, + nav = nav, + ) + } else { + MessagesSinglePane( + knownFeedContentState = accountViewModel.feedStates.dmKnown, + newFeedContentState = accountViewModel.feedStates.dmNew, + accountViewModel = accountViewModel, + nav = nav, + ) } } - - if (twoPane) { - MessagesTwoPane( - knownFeedContentState = accountViewModel.feedStates.dmKnown, - newFeedContentState = accountViewModel.feedStates.dmNew, - widthSizeClass = windowSizeClass.widthSizeClass, - accountViewModel = accountViewModel, - nav = nav, - ) - } else { - MessagesSinglePane( - knownFeedContentState = accountViewModel.feedStates.dmKnown, - newFeedContentState = accountViewModel.feedStates.dmNew, - accountViewModel = accountViewModel, - nav = nav, - ) - } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt index 805aaaba77..5d486b9323 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt @@ -47,6 +47,8 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.ui.components.M3ActionRow import com.vitorpamplona.amethyst.ui.components.M3ActionSection +import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout +import com.vitorpamplona.amethyst.ui.layouts.NavigationStyle import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -121,14 +123,22 @@ fun MessagesPager( nav: INav, modifier: Modifier = Modifier, ) { + // The left-edge swipe opens the modal drawer; with the drawer permanently docked + // there is nothing to open, so leave the pager's own gestures alone. + val modalDrawerExists = + LocalScreenLayout.current.navigationStyle != NavigationStyle.PERMANENT_DRAWER HorizontalPager( state = pagerState, userScrollEnabled = true, modifier = - modifier.zonedDrawerSwipe( - pagerState = pagerState, - openDrawer = nav::openDrawer, - ), + if (modalDrawerExists) { + modifier.zonedDrawerSwipe( + pagerState = pagerState, + openDrawer = nav::openDrawer, + ) + } else { + modifier + }, ) { page -> ChatroomListFeedView( feedContentState = tabs[page].feedContentState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt index 2cf453a10c..c71fb18663 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt @@ -21,22 +21,27 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.twopane import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp import com.google.accompanist.adaptive.FoldAwareConfiguration import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy import com.google.accompanist.adaptive.TwoPane +import com.google.accompanist.adaptive.TwoPaneStrategy import com.google.accompanist.adaptive.calculateDisplayFeatures import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.ui.layouts.LocalFeedSidePadding import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -72,9 +77,6 @@ fun MessagesTwoPane( } } - val act = LocalContext.current.getActivity() - val displayFeatures = calculateDisplayFeatures(act) - Scaffold( modifier = Modifier.imePadding(), topBar = { @@ -91,58 +93,78 @@ fun MessagesTwoPane( } }, ) { padding -> - TwoPane( - first = { - Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.BottomEnd) { - RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel) - - // Pre-warm NIP-11 for joined groups' host relays so the relay-signed check is a - // cache hit when those groups surface in discovery or any gated surface. - WarmJoinedRelayGroupNip11(accountViewModel) - - // The inline-vs-grouped NIP-29 preference lives in Settings › Messages; joined - // groups (or per-relay rows in grouped mode) are woven into the list itself. - ChatroomList( - knownFeedContentState, - newFeedContentState, - accountViewModel, - twoPaneNav, - ) - - Box(Modifier.padding(Size20dp), contentAlignment = Alignment.Center) { - ChannelFabColumn(nav) - } - } - }, - second = { - Box(Modifier.fillMaxSize().padding(padding)) { - twoPaneNav.innerNav.value?.let { - if (it is Route.Room) { - ChatroomView( - room = it.toKey(), - accountViewModel = accountViewModel, - draftMessage = it.message, - replyToNote = it.replyId, - editFromDraft = it.draftId, - expiresDays = it.expiresDays, - nav = nav, - ) - } - - if (it is Route.PublicChatChannel) { - PublicChatChannelView( - channelId = it.id, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } - }, - strategy = strategy, - displayFeatures = displayFeatures, - foldAwareConfiguration = FoldAwareConfiguration.VerticalFoldsOnly, - modifier = Modifier.fillMaxSize(), - ) + // Each pane manages its own width; the shell's center-pane reading cap must not + // re-pad the lists inside them. + CompositionLocalProvider(LocalFeedSidePadding provides 0.dp) { + TwoPaneContent(padding, knownFeedContentState, newFeedContentState, twoPaneNav, strategy, accountViewModel, nav) + } } } + +@Composable +private fun TwoPaneContent( + padding: PaddingValues, + knownFeedContentState: FeedContentState, + newFeedContentState: FeedContentState, + twoPaneNav: TwoPaneNav, + strategy: TwoPaneStrategy, + accountViewModel: AccountViewModel, + nav: INav, +) { + val act = LocalContext.current.getActivity() + val displayFeatures = calculateDisplayFeatures(act) + + TwoPane( + first = { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.BottomEnd) { + RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel) + + // Pre-warm NIP-11 for joined groups' host relays so the relay-signed check is a + // cache hit when those groups surface in discovery or any gated surface. + WarmJoinedRelayGroupNip11(accountViewModel) + + // The inline-vs-grouped NIP-29 preference lives in Settings › Messages; joined + // groups (or per-relay rows in grouped mode) are woven into the list itself. + ChatroomList( + knownFeedContentState, + newFeedContentState, + accountViewModel, + twoPaneNav, + ) + + Box(Modifier.padding(Size20dp), contentAlignment = Alignment.Center) { + ChannelFabColumn(nav) + } + } + }, + second = { + Box(Modifier.fillMaxSize().padding(padding)) { + twoPaneNav.innerNav.value?.let { + if (it is Route.Room) { + ChatroomView( + room = it.toKey(), + accountViewModel = accountViewModel, + draftMessage = it.message, + replyToNote = it.replyId, + editFromDraft = it.draftId, + expiresDays = it.expiresDays, + nav = nav, + ) + } + + if (it is Route.PublicChatChannel) { + PublicChatChannelView( + channelId = it.id, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + }, + strategy = strategy, + displayFeatures = displayFeatures, + foldAwareConfiguration = FoldAwareConfiguration.VerticalFoldsOnly, + modifier = Modifier.fillMaxSize(), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index fc2f1a88ab..7621c99dc5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -76,6 +76,8 @@ import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout +import com.vitorpamplona.amethyst.ui.layouts.NavigationStyle import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -273,14 +275,22 @@ private fun HomePages( // scaffold padding from LocalDisappearingScaffoldPadding via // rememberFeedContentPadding, so feed items still scroll behind the bars. Box(modifier = Modifier.fillMaxSize()) { + // The left-edge swipe opens the modal drawer; with the drawer permanently + // docked there is nothing to open, so leave the pager's own gestures alone. + val modalDrawerExists = + LocalScreenLayout.current.navigationStyle != NavigationStyle.PERMANENT_DRAWER HorizontalPager( state = pagerState, userScrollEnabled = true, modifier = - Modifier.zonedDrawerSwipe( - pagerState = pagerState, - openDrawer = nav::openDrawer, - ), + if (modalDrawerExists) { + Modifier.zonedDrawerSwipe( + pagerState = pagerState, + openDrawer = nav::openDrawer, + ) + } else { + Modifier + }, ) { page -> HomeFeeds( feedState = tabs[page].feedState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt index e8183abcc3..210a126acc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner @@ -73,7 +74,7 @@ fun NappletsTopBar( navigationIcon = { if (nav.canPop()) { IconButton(onClick = nav::popBack) { ArrowBackIcon() } - } else { + } else if (!LocalScreenLayout.current.isLargeScreen) { LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer) } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt new file mode 100644 index 0000000000..7f43ec4d17 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.ui.layouts.LocalFeedSidePadding +import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.feeds.rememberForeverLazyListState +import com.vitorpamplona.amethyst.ui.layouts.NotificationPanelWidth +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.Size22Modifier +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer + +/** + * The docked notification feed shown on very wide windows, to the right of the center pane. + * It renders the same card feed as the Notifications screen (same last-read marking, so + * reading here clears the new-item dot too); tapping its header opens the full screen, + * which keeps the summary chart and the Following/Everyone split. + */ +@Composable +fun NotificationSidePanel( + accountViewModel: AccountViewModel, + nav: INav, + modifier: Modifier = Modifier, +) { + val notifFeedContentState = accountViewModel.feedStates.notifications + WatchAccountForNotifications(notifFeedContentState, accountViewModel) + + Column( + modifier + .width(NotificationPanelWidth) + .fillMaxHeight() + .windowInsetsPadding( + WindowInsets.systemBars.only( + WindowInsetsSides.Top + WindowInsetsSides.Bottom + WindowInsetsSides.End, + ), + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { nav.nav(Route.Notification()) } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.Notifications, + contentDescription = null, + modifier = Size22Modifier, + tint = MaterialTheme.colorScheme.onBackground, + ) + Spacer(modifier = StdHorzSpacer) + Text( + text = stringRes(R.string.route_notifications), + style = MaterialTheme.typography.titleMedium, + ) + } + + HorizontalDivider(thickness = DividerThickness) + + // The panel is its own narrow pane; never apply the center pane's reading-width cap here. + CompositionLocalProvider(LocalFeedSidePadding provides 0.dp) { + Box(Modifier.fillMaxWidth()) { + RefresheableBox(notifFeedContentState, true) { + val listState = rememberForeverLazyListState(ScrollStateKeys.NOTIFICATION_SIDE_PANEL) + + RenderCardFeed( + feedContent = notifFeedContentState, + pollContent = accountViewModel.feedStates.notificationsOpenPolls, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = NOTIFICATION_LAST_READ_KEY, + ) + } + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt index eb2b94bd87..fa25234ba5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt @@ -48,6 +48,18 @@ val LocalDisappearingScaffoldPadding = compositionLocalOf { PaddingValues(0.dp) */ val LocalDisappearingBarState = compositionLocalOf { null } +/** + * Extra start/end padding wide layouts ask feeds to apply so their content column stays at a + * readable width. The shell computes it as `(paneWidth - feedMaxWidth) / 2` and provides it + * around the center pane; feeds pick it up through [rememberFeedContentPadding], so the + * scroll surface stays full-pane-width (scrolling and pull-to-refresh keep working edge to + * edge) while items center themselves. Defaults to 0 (phones, and any host that doesn't cap). + * + * Full-bleed surfaces (video/shorts pagers) and secondary panes with their own width (side + * panels, two-pane splits) must override this back to 0 for their subtree. + */ +val LocalFeedSidePadding = compositionLocalOf { 0.dp } + /** * Merges two [PaddingValues] component-wise, resolving start/end against the current * [LocalLayoutDirection]. @@ -70,7 +82,13 @@ fun rememberMergedPadding( /** * Convenience for inner LazyColumns/LazyVerticalGrids inside a [DisappearingScaffold]: - * merges the scaffold's reserved space with the list's own baseline padding. + * merges the scaffold's reserved space with the list's own baseline padding, plus the + * [LocalFeedSidePadding] width cap requested by wide layouts. */ @Composable -fun rememberFeedContentPadding(inner: PaddingValues): PaddingValues = rememberMergedPadding(LocalDisappearingScaffoldPadding.current, inner) +fun rememberFeedContentPadding(inner: PaddingValues): PaddingValues { + val merged = rememberMergedPadding(LocalDisappearingScaffoldPadding.current, inner) + val sidePadding = LocalFeedSidePadding.current + if (sidePadding <= 0.dp) return merged + return rememberMergedPadding(merged, PaddingValues(start = sidePadding, end = sidePadding)) +} From c72024578e7b952834dc138281b786b660767324 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 04:31:01 +0000 Subject: [PATCH 2/6] fix: harden the large-screen shell against runtime window-size changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from an adversarially verified audit of the large-screen commit. The two most serious bugs shared a root cause: the shell was correct at any fixed size but mishandled the size CHANGING mid-session, which foldables and multi-window make routine (MainActivity handles those configChanges without recreation). Bug fixes: - Hoist the shell content into movableContentOf so crossing a layout tier (fold/unfold, rotate, resize) MOVES the NavHost subtree between shells instead of disposing it — screen state (drafts, pager tabs, expanded states, warm embedded tabs) now survives. - DisappearingScaffold snaps bars back to visible when hiding gets disabled, so chrome scrolled away before a resize is no longer stranded off-screen with no reset path. - ProfileScreen keeps WindowInsets.navigationBars instead of zeroing all content insets; with the bottom bar gone (large screens, and pushed entries on phones) content no longer underlaps the system bar. - New TabReselectCoordinator: AppBottomBar registers each screen's re-tap handler even when the bar renders nothing, and the rail routes selected-item taps through it — restoring tap-current-tab-scrolls-to- top on the rail tier with the screens' existing logic. - NotificationSidePanel now reuses the screen's SingleNotificationsBody (parameterized by scroll-state key), which restores WatchScrollToTop — previously the panel stranded scrolltoTopPending=true on the shared feed state, suppressing later send-to-top requests — and the inbox- relay warning header; it also honors split notifications by showing the Following feed when that setting is on. - Entering the permanent-drawer tier snaps a stale Open drawerState to Closed, so returning to a modal tier no longer pops the drawer uninvited. - MessagesTwoPane keys its TwoPane strategy on the width size class so the split fraction updates when the pane crosses 840dp in place. - The drawer status editor calls onDone() after send/delete, so it can collapse back to the read-only bar in the docked drawer (and no longer waits for a drawer close in the modal one). - The landscape auto-close drawer effect's inverted condition (close-only-when-already-closed, a pre-existing no-op) now closes an open drawer as intended. Structure and performance: - INav.isDrawerDocked models docked-ness explicitly: Nav.openDrawer() no-ops while docked, and consumers stop inferring from a DrawerState that never transitions. - zonedDrawerSwipeIfModal wraps the edge-swipe modifier with the docked check so call sites can't forget it; TopBarNavigationIcon centralizes the back-arrow/avatar-or-nothing leading slot. - The rail reuses AppBottomBar's entry icons (NotifiableIcon, FavoriteEntryIcon, rememberFavoriteIconModel) instead of duplicating them. - MessagesScreen derives its pane size class via WindowSizeClass.calculateFromSize instead of restating the 600/840 breakpoints. - rememberFeedContentPadding folds the scaffold, baseline, and side paddings into one remember slot; the shell quantizes the feed side padding to 8dp steps so continuous resizes don't invalidate every feed per pixel. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RHSoAVvYioihaJeSumX8J7 --- .../ui/feeds/RememberForeverStates.kt | 1 + .../ui/layouts/DisappearingScaffold.kt | 8 ++ .../amethyst/ui/navigation/AppNavigation.kt | 84 ++++++----- .../ui/navigation/bottombars/AppBottomBar.kt | 82 +++++++---- .../bottombars/AppNavigationRail.kt | 55 ++------ .../bottombars/TabReselectCoordinator.kt | 67 +++++++++ .../ui/navigation/drawer/DrawerContent.kt | 13 +- .../ui/navigation/navs/DrawerSwipe.kt | 44 ++++++ .../amethyst/ui/navigation/navs/INav.kt | 8 ++ .../amethyst/ui/navigation/navs/Nav.kt | 8 ++ .../topbars/UserDrawerSearchTopBar.kt | 35 +++-- .../AccountSwitcherAndLeftDrawerLayout.kt | 59 +++++--- .../loggedIn/chats/rooms/MessagesScreen.kt | 35 ++--- .../chats/rooms/feed/ChatroomListTabs.kt | 18 +-- .../chats/rooms/twopane/MessagesTwoPane.kt | 132 ++++++++---------- .../ui/screen/loggedIn/home/HomeScreen.kt | 18 +-- .../loggedIn/napplets/NappletsTopBar.kt | 12 +- .../notifications/NotificationScreen.kt | 11 +- .../notifications/NotificationSidePanel.kt | 56 +++++--- .../screen/loggedIn/profile/ProfileScreen.kt | 6 +- .../commons/ui/layouts/PaddingMerge.kt | 21 ++- 21 files changed, 466 insertions(+), 307 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/TabReselectCoordinator.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/DrawerSwipe.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index fb56c99551..a6595089cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -39,6 +39,7 @@ private data class ScrollState( object ScrollStateKeys { const val NOTIFICATION_SCREEN = "NotificationsFeed" const val NOTIFICATION_SIDE_PANEL = "NotificationsSidePanel" + const val NOTIFICATION_SIDE_PANEL_FOLLOWING = "NotificationsSidePanelFollowing" const val NOTIFICATION_FOLLOWING = "NotificationsFollowingFeed" const val NOTIFICATION_EVERYONE = "NotificationsEveryoneFeed" const val VIDEO_SCREEN = "VideoFeed" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt index fd8e8ab850..40b32b4327 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt @@ -109,6 +109,14 @@ fun DisappearingScaffold( ImmersiveStatusBarEffect(state) } + // If the bars were scrolled away when hiding got disabled (e.g. the window grew to a + // large tier mid-scroll), nothing above can bring them back — the nested-scroll + // connection and the resume reset are gone. Snap them visible here instead of + // leaving the chrome stranded off-screen. + LaunchedEffect(canHideBars, state) { + if (!canHideBars) state.resetToVisible() + } + // When bars are pinned, skip attaching the nested-scroll connection entirely. // The outer Surface provides the Material container color + onBackground as // LocalContentColor, matching M3 Scaffold's behaviour (without it, default text diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 0e25c9e778..bf8203d6f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -62,6 +62,8 @@ import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout import com.vitorpamplona.amethyst.ui.layouts.rememberScreenLayoutSpec +import com.vitorpamplona.amethyst.ui.navigation.bottombars.LocalTabReselectCoordinator +import com.vitorpamplona.amethyst.ui.navigation.bottombars.TabReselectCoordinator import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.navs.rememberNav @@ -287,54 +289,50 @@ fun AppNavigation( // One layout decision per window size for the whole shell: bottom bar vs rail vs // permanent drawer, plus the docked notification panel. Every screen, bar and panel - // below reads the same spec through LocalScreenLayout. + // below reads the same spec through LocalScreenLayout. The provider wraps this whole + // function body so anything added to AppNavigation later is inside it by construction. val screenLayout = rememberScreenLayoutSpec() + val tabReselectCoordinator = remember { TabReselectCoordinator() } - CompositionLocalProvider(LocalScreenLayout provides screenLayout) { - AppNavigationLayers(accountViewModel, accountSessionManager, nav) - } -} - -@Composable -private fun AppNavigationLayers( - accountViewModel: AccountViewModel, - accountSessionManager: AccountSessionManager, - nav: Nav, -) { - AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) { - Box(Modifier.fillMaxSize()) { - BuildNavigation(accountViewModel, nav) - // Pull each pinned nsite/napplet's manifest into LocalCache (and keep a device-local copy) - // so its favorite resolves as reliably as a pinned web app's URL — the data the embedded - // preloader below and the full-screen launcher both need. Not API-gated: every device's - // launcher benefits, and it's the only preload step that runs below API 30. - FavoriteAppManifestPreloader(accountViewModel) - // Persistent layer that keeps pinned embedded tabs (browser / nsite / napplet) warm by - // holding their surfaces attached. Below the drawer (drawn by the layout above) and below - // dialogs (separate windows). API 30+ only, matching the embedded-surface feature. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val bottomBarItems by accountViewModel.settings.uiSettingsFlow.bottomBarItems - .collectAsStateWithLifecycle() - EmbeddedTabLayer(bottomBarItems.favoriteIds()) - // Warm every pinned tab at startup so the first tap is instant (content already local). - EmbeddedTabPreloader(accountViewModel) - // Rebuild the warm surfaces in the new theme when the app's DARK/LIGHT preference flips - // (an embed WebView's theme is fixed at construction, so it can't follow a live switch). - EmbeddedTabThemeWatcher() + CompositionLocalProvider( + LocalScreenLayout provides screenLayout, + LocalTabReselectCoordinator provides tabReselectCoordinator, + ) { + AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) { + Box(Modifier.fillMaxSize()) { + BuildNavigation(accountViewModel, nav) + // Pull each pinned nsite/napplet's manifest into LocalCache (and keep a device-local copy) + // so its favorite resolves as reliably as a pinned web app's URL — the data the embedded + // preloader below and the full-screen launcher both need. Not API-gated: every device's + // launcher benefits, and it's the only preload step that runs below API 30. + FavoriteAppManifestPreloader(accountViewModel) + // Persistent layer that keeps pinned embedded tabs (browser / nsite / napplet) warm by + // holding their surfaces attached. Below the drawer (drawn by the layout above) and below + // dialogs (separate windows). API 30+ only, matching the embedded-surface feature. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val bottomBarItems by accountViewModel.settings.uiSettingsFlow.bottomBarItems + .collectAsStateWithLifecycle() + EmbeddedTabLayer(bottomBarItems.favoriteIds()) + // Warm every pinned tab at startup so the first tap is instant (content already local). + EmbeddedTabPreloader(accountViewModel) + // Rebuild the warm surfaces in the new theme when the app's DARK/LIGHT preference flips + // (an embed WebView's theme is fixed at construction, so it can't follow a live switch). + EmbeddedTabThemeWatcher() + } } } + + TrackScreenTime(nav) + NavigateIfIntentRequested(nav, accountViewModel, accountSessionManager) + + DisplayErrorMessages(accountViewModel.toastManager, accountViewModel, nav) + DisplayNotifyMessages(accountViewModel, nav) + DisplayCrashMessages(accountViewModel, nav) + DisplayResourceUsageAlert(accountViewModel, nav) + DisplayBroadcastProgress(accountViewModel) + + ObserveIncomingCalls(accountViewModel) } - - TrackScreenTime(nav) - NavigateIfIntentRequested(nav, accountViewModel, accountSessionManager) - - DisplayErrorMessages(accountViewModel.toastManager, accountViewModel, nav) - DisplayNotifyMessages(accountViewModel, nav) - DisplayCrashMessages(accountViewModel, nav) - DisplayResourceUsageAlert(accountViewModel, nav) - DisplayBroadcastProgress(accountViewModel) - - ObserveIncomingCalls(accountViewModel) } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt index a635f485ac..930f08d4d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt @@ -36,8 +36,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -74,6 +76,18 @@ fun AppBottomBar( accountViewModel: AccountViewModel, onClick: (Route) -> Unit, ) { + // Publish this screen's re-tap behavior even when the bar renders nothing: on large + // screens the navigation rail routes reselect taps back through the coordinator so the + // same per-screen scroll-to-top/refresh logic runs. + val coordinator = LocalTabReselectCoordinator.current + val latestRoute by rememberUpdatedState(selectedRoute) + val latestOnClick by rememberUpdatedState(onClick) + DisposableEffect(coordinator) { + val handler: (Route) -> Unit = { latestOnClick(it) } + coordinator.register({ latestRoute }, handler) + onDispose { coordinator.unregister(handler) } + } + // Large screens replace the bottom bar with the navigation rail (Medium) or the // permanently docked drawer (Expanded). if (LocalScreenLayout.current.isLargeScreen) return @@ -106,6 +120,43 @@ fun AppBottomBar( } } +/** + * Resolves the icon model for a pinned favorite: a web favorite's captured favicon (else the + * generic globe), an nsite/napplet's verified manifest icon bundled in its own content (the + * iframe sandbox rules out live capture; else the grid glyph). Shared by the bottom bar and + * the navigation rail. + */ +@Composable +internal fun rememberFavoriteIconModel(fav: FavoriteApp): Any? = + when (fav) { + is FavoriteApp.WebApp -> { + // Captured favicons, keyed so the icon appears once the site's capture lands. + val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle() + remember(fav, iconKeys) { + OmniboxInput.hostOf(fav.url)?.let(BrowserIconRegistry::iconModelFor) + } + } + + is FavoriteApp.NostrApp -> rememberNappletIconModel(fav.coordinate) + } + +/** The icon block for a pinned favorite entry, shared by the bottom bar and the rail. */ +@Composable +internal fun FavoriteEntryIcon( + fav: FavoriteApp, + selected: Boolean, + iconModel: Any?, +) { + Box(Size27Modifier, contentAlignment = Alignment.Center) { + FavoriteAppIcon( + app = fav, + tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65, + modifier = Size25Modifier, + iconModel = iconModel, + ) + } +} + @Composable private fun RenderBottomMenu( items: List, @@ -117,9 +168,6 @@ private fun RenderBottomMenu( // Index favorites by id so resolving each Favorite entry is a map lookup, not a per-entry scan. val favoritesById = remember(favorites) { favorites.associateBy { it.id } } - // Captured favicons, so a pinned web favorite shows the site's icon instead of the generic globe. - val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle() - Column( modifier = Modifier @@ -152,17 +200,7 @@ private fun RenderBottomMenu( is FavoriteApp.WebApp -> Route.WebApp(fav.url) is FavoriteApp.NostrApp -> Route.NostrApp(fav.coordinate) } - // A web favorite uses its captured favicon; an nsite/napplet uses the verified - // icon blob bundled in its own content (the iframe sandbox rules out live capture). - val iconModel = - when (fav) { - is FavoriteApp.WebApp -> - remember(fav, iconKeys) { - OmniboxInput.hostOf(fav.url)?.let(BrowserIconRegistry::iconModelFor) - } - is FavoriteApp.NostrApp -> rememberNappletIconModel(fav.coordinate) - } - FavoriteNavItem(destination == selectedRoute, fav, iconModel, destination, nav) + FavoriteNavItem(destination == selectedRoute, fav, rememberFavoriteIconModel(fav), destination, nav) } } } @@ -180,18 +218,7 @@ private fun RowScope.FavoriteNavItem( ) { NavigationBarItem( alwaysShowLabel = false, - icon = { - Box(Size27Modifier, contentAlignment = Alignment.Center) { - // A web favorite's captured favicon (else the globe); an nsite/napplet's manifest icon (else - // the grid glyph). - FavoriteAppIcon( - app = fav, - tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65, - modifier = Size25Modifier, - iconModel = iconModel, - ) - } - }, + icon = { FavoriteEntryIcon(fav, selected, iconModel) }, // No label — favorite tabs match the built-in items, which show icon only. selected = selected, onClick = { nav(destination) }, @@ -221,8 +248,9 @@ private fun RowScope.HasNewItemsIcon( ) } +/** The icon block for a built-in entry (catalog icon + new-items dot), shared by the bottom bar and the rail. */ @Composable -private fun NotifiableIcon( +internal fun NotifiableIcon( selected: Boolean, def: NavBarItemDef, destination: Route, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt index d90eb99281..be8c77035e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.navigation.bottombars -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -35,28 +34,20 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.compose.currentBackStackEntryAsState -import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp -import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry -import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments import com.vitorpamplona.amethyst.ui.navigation.topbars.LoggedInUserPictureDrawer import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.Size25Modifier -import com.vitorpamplona.amethyst.ui.theme.Size27Modifier -import com.vitorpamplona.amethyst.ui.theme.onSurface65 /** * Medium-width windows: a left rail that carries the same user-configured destinations as the * phone bottom bar ([BottomBarEntry] list, built-ins and pinned favorites interleaved), so the * user's customization and new-item dots carry over. The header avatar opens the modal drawer, - * mirroring the avatar button in the phone top bars. + * mirroring the avatar button in the phone top bars. Re-tapping the selected item routes + * through [TabReselectCoordinator] to the screen's own scroll-to-top/refresh handler. */ @Composable fun AppNavigationRail( @@ -68,8 +59,7 @@ fun AppNavigationRail( val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() val favoritesById = remember(favorites) { favorites.associateBy { it.id } } - // Captured favicons, so a pinned web favorite shows the site's icon instead of the generic globe. - val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle() + val reselectCoordinator = LocalTabReselectCoordinator.current val navBackStackEntry by nav.controller.currentBackStackEntryAsState() val currentDestination = navBackStackEntry?.destination @@ -93,18 +83,14 @@ fun AppNavigationRail( val selected = currentDestination?.hasRoute(destination::class) == true NavigationRailItem( selected = selected, - onClick = { if (!selected) nav.navBottomBar(destination) }, - icon = { - Box(Size27Modifier, contentAlignment = Alignment.Center) { - Icon( - symbol = def.icon, - contentDescription = stringRes(def.labelRes), - modifier = Size25Modifier, - tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65, - ) - AddNotifIconIfNeeded(destination, accountViewModel, Modifier.align(Alignment.TopEnd)) + onClick = { + if (selected) { + reselectCoordinator.reselect(destination) + } else { + nav.navBottomBar(destination) } }, + icon = { NotifiableIcon(selected, def, destination, accountViewModel) }, ) } @@ -125,27 +111,16 @@ fun AppNavigationRail( else -> false } } - val iconModel = - when (fav) { - is FavoriteApp.WebApp -> - remember(fav, iconKeys) { - OmniboxInput.hostOf(fav.url)?.let(BrowserIconRegistry::iconModelFor) - } - is FavoriteApp.NostrApp -> rememberNappletIconModel(fav.coordinate) - } NavigationRailItem( selected = selected, - onClick = { if (!selected) nav.navBottomBar(destination) }, - icon = { - Box(Size27Modifier, contentAlignment = Alignment.Center) { - FavoriteAppIcon( - app = fav, - tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65, - modifier = Size25Modifier, - iconModel = iconModel, - ) + onClick = { + if (selected) { + reselectCoordinator.reselect(destination) + } else { + nav.navBottomBar(destination) } }, + icon = { FavoriteEntryIcon(fav, selected, rememberFavoriteIconModel(fav)) }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/TabReselectCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/TabReselectCoordinator.kt new file mode 100644 index 0000000000..b67bc5c66c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/TabReselectCoordinator.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.navigation.bottombars + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.staticCompositionLocalOf +import com.vitorpamplona.amethyst.ui.navigation.routes.Route + +/** + * Bridges the shell-level [AppNavigationRail] to the per-screen re-tap behaviors that live in + * each screen's [AppBottomBar] onClick lambda (scroll-to-top, feed refresh, ...). + * + * On phones the bottom bar invokes the screen's lambda directly. On large screens the bar + * renders nothing, but it still registers the screen's lambda here; when the user taps the + * rail item that is already selected, the rail routes the tap back through [reselect] so the + * exact same per-screen logic runs. + */ +@Stable +class TabReselectCoordinator { + private var currentRoute: (() -> Route?)? = null + private var currentHandler: ((Route) -> Unit)? = null + + fun register( + route: () -> Route?, + handler: (Route) -> Unit, + ) { + currentRoute = route + currentHandler = handler + } + + /** Unregisters only if [handler] is still the active one, so a newly composed screen's + * registration is not torn down by the outgoing screen's dispose during a transition. */ + fun unregister(handler: (Route) -> Unit) { + if (currentHandler === handler) { + currentHandler = null + currentRoute = null + } + } + + /** Invokes the active tab root's handler if it owns [route]. Returns true if handled. */ + fun reselect(route: Route): Boolean { + val handler = currentHandler ?: return false + if (currentRoute?.invoke() != route) return false + handler(route) + return true + } +} + +val LocalTabReselectCoordinator = staticCompositionLocalOf { TabReselectCoordinator() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index 2c2ed264f8..bb6f93f288 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -104,8 +104,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage -import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout -import com.vitorpamplona.amethyst.ui.layouts.NavigationStyle import com.vitorpamplona.amethyst.ui.layouts.PermanentDrawerWidth import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerFeedsItems import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerNavigateItems @@ -431,11 +429,10 @@ fun StatusEditBar( val currentStatus = remember { mutableStateOf(savedStatus ?: "") } - // In the permanent drawer the DrawerState never opens (it stays Closed while the drawer + // In the docked drawer the DrawerState never opens (it stays Closed while the drawer // is always on screen), so the modal close-cancels-editing behavior must not apply there. - val isPermanentDrawer = LocalScreenLayout.current.navigationStyle == NavigationStyle.PERMANENT_DRAWER LaunchedEffect(nav.drawerState.isClosed) { - if (!isPermanentDrawer && nav.drawerState.isClosed) { + if (!nav.isDrawerDocked && nav.drawerState.isClosed) { focusManager.clearFocus(true) onDone() } else { @@ -469,6 +466,10 @@ fun StatusEditBar( } focusManager.clearFocus(true) + // Collapse back to the read-only bar: in the docked drawer no + // drawer-close will ever do it, and in the modal drawer this beats + // staying in edit mode until the drawer closes. + onDone() }, ), singleLine = true, @@ -484,12 +485,14 @@ fun StatusEditBar( accountViewModel.updateStatus(address, currentStatus.value) } focusManager.clearFocus(true) + onDone() } } else { if (address != null) { UserStatusDeleteButton { accountViewModel.deleteStatus(address) focusManager.clearFocus(true) + onDone() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/DrawerSwipe.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/DrawerSwipe.kt new file mode 100644 index 0000000000..0380e182b0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/DrawerSwipe.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.navigation.navs + +import androidx.compose.foundation.pager.PagerState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.commons.ui.components.zonedDrawerSwipe + +/** + * [zonedDrawerSwipe] gated on the drawer actually being modal. With the drawer permanently + * docked ([INav.isDrawerDocked]) there is nothing to open, so the left-edge swipe zone is + * not attached at all and the pager keeps its own gestures. Use this instead of calling + * [zonedDrawerSwipe] directly from screens — the guard then can't be forgotten at new + * call sites. + */ +@Composable +fun Modifier.zonedDrawerSwipeIfModal( + pagerState: PagerState, + nav: INav, +): Modifier = + if (nav.isDrawerDocked) { + this + } else { + zonedDrawerSwipe(pagerState, nav::openDrawer) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/INav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/INav.kt index f7ceff737b..229b86e212 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/INav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/INav.kt @@ -32,6 +32,14 @@ interface INav { val navigationScope: CoroutineScope val drawerState: DrawerState + /** + * True while the shell renders the drawer permanently docked (Expanded windows). In that + * mode [drawerState] never transitions — it stays [androidx.compose.material3.DrawerValue.Closed] + * while the drawer is visibly on screen — so drawer consumers (edge swipes, open buttons, + * close-driven effects) should consult this instead of inferring from [drawerState]. + */ + val isDrawerDocked: Boolean get() = false + fun closeDrawer() fun openDrawer() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt index 05ca546fd7..1578cbc853 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt @@ -26,6 +26,8 @@ import androidx.compose.material3.DrawerValue import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState @@ -44,11 +46,17 @@ class Nav( ) : INav { override val drawerState = DrawerState(DrawerValue.Closed) + /** Set by the shell when the layout tier docks the drawer permanently. */ + override var isDrawerDocked: Boolean by mutableStateOf(false) + override fun closeDrawer() { navigationScope.launch { drawerState.close() } } override fun openDrawer() { + // Nothing renders the modal drawer while it is docked; opening the state would + // only strand an Open value for the next modal tier to trip over. + if (isDrawerDocked) return navigationScope.launch { drawerState.open() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt index 36ca168941..da7fcbbb4f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt @@ -63,20 +63,7 @@ fun UserDrawerSearchTopBar( content() } }, - navigationIcon = { - // When this screen sits on top of a back stack (entered via the drawer - // or any deep link), show a back arrow. When it's the root (entered via - // the bottom nav, which clears the stack), show the drawer opener — - // unless a large-screen shell already shows the drawer (rail avatar or - // permanently docked pane). - if (nav.canPop()) { - IconButton(onClick = nav::popBack) { - ArrowBackIcon() - } - } else if (!LocalScreenLayout.current.isLargeScreen) { - LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer) - } - }, + navigationIcon = { TopBarNavigationIcon(accountViewModel, nav) }, actions = { IconButton(onClick = { nav.nav(Route.Search) }) { SearchIcon(modifier = Size22Modifier, MaterialTheme.colorScheme.placeholderText) @@ -85,6 +72,26 @@ fun UserDrawerSearchTopBar( ) } +/** + * The standard leading slot for root-capable top bars: a back arrow when the screen sits on + * top of a back stack; otherwise the avatar drawer-opener — unless a large-screen shell + * already presents the drawer (rail avatar or permanently docked pane), in which case + * nothing is shown. Use this instead of hand-writing the branches per top bar. + */ +@Composable +fun TopBarNavigationIcon( + accountViewModel: AccountViewModel, + nav: INav, +) { + if (nav.canPop()) { + IconButton(onClick = nav::popBack) { + ArrowBackIcon() + } + } else if (!LocalScreenLayout.current.isLargeScreen) { + LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer) + } +} + @Composable internal fun LoggedInUserPictureDrawer( accountViewModel: AccountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt index 494cce6135..74733ffad4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt @@ -22,9 +22,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.content.res.Configuration import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api @@ -37,13 +37,16 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.compose.currentBackStackEntryAsState @@ -91,15 +94,30 @@ fun AccountSwitcherAndLeftDrawerLayout( } } - when (LocalScreenLayout.current.navigationStyle) { - NavigationStyle.PERMANENT_DRAWER -> - PermanentDrawerShell(accountViewModel, nav, openSheetFunction, content) + // The layout tier can change while the app runs (fold/unfold, rotation, window resize) + // and the shells below place `content` at different composition positions. Movable + // content lets the whole NavHost subtree MOVE between those positions instead of being + // disposed and rebuilt, preserving every screen's remember/rememberSaveable state. + val currentContent by rememberUpdatedState(content) + val movableContent = remember { movableContentOf { currentContent() } } - NavigationStyle.NAV_RAIL -> - ModalDrawerShell(accountViewModel, nav, openSheetFunction, showRail = true, content) + val docked = LocalScreenLayout.current.navigationStyle == NavigationStyle.PERMANENT_DRAWER - NavigationStyle.BOTTOM_BAR -> - ModalDrawerShell(accountViewModel, nav, openSheetFunction, showRail = false, content) + // Publish docked-ness on the Nav so drawer consumers (openDrawer, edge swipes, the + // status editor) can behave correctly without each re-deriving the layout tier. + LaunchedEffect(docked) { + nav.isDrawerDocked = docked + // Entering the permanent tier with the modal drawer still Open would otherwise + // carry the stale Open value back to the modal tier and pop the drawer uninvited. + if (docked && !nav.drawerState.isClosed) { + nav.drawerState.snapTo(DrawerValue.Closed) + } + } + + if (docked) { + PermanentDrawerShell(accountViewModel, nav, openSheetFunction, movableContent) + } else { + ModalDrawerShell(accountViewModel, nav, openSheetFunction, movableContent) } // Sheet content @@ -125,7 +143,7 @@ fun AccountSwitcherAndLeftDrawerLayout( } /** - * Compact and Medium windows: the drawer slides in as a modal sheet. On Medium a + * Compact and Medium windows: the drawer slides in as a modal sheet. On Medium an * [AppNavigationRail] sits at the left edge in place of the phone bottom bar. */ @Composable @@ -133,15 +151,13 @@ private fun ModalDrawerShell( accountViewModel: AccountViewModel, nav: Nav, openSheet: () -> Unit, - showRail: Boolean, content: @Composable () -> Unit, ) { val orientation = LocalConfiguration.current.orientation - val currentDrawerState = nav.drawerState.currentValue LaunchedEffect(key1 = orientation) { - if ( - orientation == Configuration.ORIENTATION_LANDSCAPE && currentDrawerState == DrawerValue.Closed - ) { + // Dismiss an open drawer when the device rotates to landscape; the layout + // underneath changes too much for the sheet to stay meaningful. + if (orientation == Configuration.ORIENTATION_LANDSCAPE) { nav.drawerState.close() } } @@ -161,6 +177,8 @@ private fun ModalDrawerShell( // so a handle drag near the left edge (or the auto-scroll edge drag) doesn't open the drawer. !EmbeddedSelectionDrag.dragging + val showRail = LocalScreenLayout.current.navigationStyle == NavigationStyle.NAV_RAIL + ModalNavigationDrawer( drawerState = nav.drawerState, gesturesEnabled = drawerGesturesEnabled, @@ -213,6 +231,10 @@ private fun PermanentDrawerShell( } } +/** Step size for the feed side padding, so continuous window resizes republish the value + * (and invalidate every feed reading it) at most once per step instead of once per pixel. */ +private val SidePaddingQuantum = 8.dp + /** * Hosts the navigation content and publishes the side padding feeds need to cap their content * at [FeedContentMaxWidth] within this pane, via [LocalFeedSidePadding]. @@ -222,12 +244,11 @@ private fun CenterPane( modifier: Modifier, content: @Composable () -> Unit, ) { - BoxWithConstraints(modifier) { - val sidePadding = ((maxWidth - FeedContentMaxWidth) / 2).coerceAtLeast(0.dp) + BoxWithConstraints(modifier.fillMaxHeight()) { + val rawPadding = ((maxWidth - FeedContentMaxWidth) / 2).coerceAtLeast(0.dp) + val sidePadding = Dp((rawPadding.value / SidePaddingQuantum.value).toInt() * SidePaddingQuantum.value) CompositionLocalProvider(LocalFeedSidePadding provides sidePadding) { - Box(Modifier.fillMaxSize()) { - content() - } + content() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt index 0ecafb852b..514fccede2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt @@ -22,15 +22,18 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.DpSize import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.singlepane.MessagesSinglePane import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.twopane.MessagesTwoPane +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Composable fun MessagesScreen( accountViewModel: AccountViewModel, @@ -39,29 +42,29 @@ fun MessagesScreen( // Decide single- vs two-pane from the pane this screen actually occupies, not the // window: on large screens the shell already spends width on the rail / permanent // drawer / notification panel, so window-level size classes would overestimate. + // calculateFromSize keeps the Compact/Medium/Expanded breakpoints in one place + // (Material's) instead of restating 600/840 here. BoxWithConstraints(Modifier.fillMaxSize()) { - val paneWidth = maxWidth + val paneWidthClass = + WindowSizeClass + .calculateFromSize(DpSize(maxWidth, maxHeight)) + .widthSizeClass - if (paneWidth >= 600.dp) { - MessagesTwoPane( - knownFeedContentState = accountViewModel.feedStates.dmKnown, - newFeedContentState = accountViewModel.feedStates.dmNew, - widthSizeClass = - if (paneWidth >= 840.dp) { - WindowWidthSizeClass.Expanded - } else { - WindowWidthSizeClass.Medium - }, - accountViewModel = accountViewModel, - nav = nav, - ) - } else { + if (paneWidthClass == WindowWidthSizeClass.Compact) { MessagesSinglePane( knownFeedContentState = accountViewModel.feedStates.dmKnown, newFeedContentState = accountViewModel.feedStates.dmNew, accountViewModel = accountViewModel, nav = nav, ) + } else { + MessagesTwoPane( + knownFeedContentState = accountViewModel.feedStates.dmKnown, + newFeedContentState = accountViewModel.feedStates.dmNew, + widthSizeClass = paneWidthClass, + accountViewModel = accountViewModel, + nav = nav, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt index 5d486b9323..157702ec24 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt @@ -42,14 +42,12 @@ import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.ui.components.zonedDrawerSwipe import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.ui.components.M3ActionRow import com.vitorpamplona.amethyst.ui.components.M3ActionSection -import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout -import com.vitorpamplona.amethyst.ui.layouts.NavigationStyle import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.zonedDrawerSwipeIfModal import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size40dp @@ -123,22 +121,10 @@ fun MessagesPager( nav: INav, modifier: Modifier = Modifier, ) { - // The left-edge swipe opens the modal drawer; with the drawer permanently docked - // there is nothing to open, so leave the pager's own gestures alone. - val modalDrawerExists = - LocalScreenLayout.current.navigationStyle != NavigationStyle.PERMANENT_DRAWER HorizontalPager( state = pagerState, userScrollEnabled = true, - modifier = - if (modalDrawerExists) { - modifier.zonedDrawerSwipe( - pagerState = pagerState, - openDrawer = nav::openDrawer, - ) - } else { - modifier - }, + modifier = modifier.zonedDrawerSwipeIfModal(pagerState, nav), ) { page -> ChatroomListFeedView( feedContentState = tabs[page].feedContentState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt index c71fb18663..9d16c3ea38 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.twopane import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding @@ -38,7 +37,6 @@ import androidx.compose.ui.unit.dp import com.google.accompanist.adaptive.FoldAwareConfiguration import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy import com.google.accompanist.adaptive.TwoPane -import com.google.accompanist.adaptive.TwoPaneStrategy import com.google.accompanist.adaptive.calculateDisplayFeatures import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.layouts.LocalFeedSidePadding @@ -68,8 +66,11 @@ fun MessagesTwoPane( val scope = rememberCoroutineScope() val twoPaneNav = remember { TwoPaneNav(nav, scope) } + // Keyed on the size class: the pane can cross the Medium/Expanded boundary while this + // screen stays composed (window resize, the notification panel docking/undocking), and + // an unkeyed remember would keep serving the stale split fraction. val strategy = - remember { + remember(widthSizeClass) { if (widthSizeClass == WindowWidthSizeClass.Expanded) { HorizontalTwoPaneStrategy(splitFraction = 1f / 3f) } else { @@ -77,6 +78,9 @@ fun MessagesTwoPane( } } + val act = LocalContext.current.getActivity() + val displayFeatures = calculateDisplayFeatures(act) + Scaffold( modifier = Modifier.imePadding(), topBar = { @@ -96,75 +100,59 @@ fun MessagesTwoPane( // Each pane manages its own width; the shell's center-pane reading cap must not // re-pad the lists inside them. CompositionLocalProvider(LocalFeedSidePadding provides 0.dp) { - TwoPaneContent(padding, knownFeedContentState, newFeedContentState, twoPaneNav, strategy, accountViewModel, nav) + TwoPane( + first = { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.BottomEnd) { + RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel) + + // Pre-warm NIP-11 for joined groups' host relays so the relay-signed check is a + // cache hit when those groups surface in discovery or any gated surface. + WarmJoinedRelayGroupNip11(accountViewModel) + + // The inline-vs-grouped NIP-29 preference lives in Settings › Messages; joined + // groups (or per-relay rows in grouped mode) are woven into the list itself. + ChatroomList( + knownFeedContentState, + newFeedContentState, + accountViewModel, + twoPaneNav, + ) + + Box(Modifier.padding(Size20dp), contentAlignment = Alignment.Center) { + ChannelFabColumn(nav) + } + } + }, + second = { + Box(Modifier.fillMaxSize().padding(padding)) { + twoPaneNav.innerNav.value?.let { + if (it is Route.Room) { + ChatroomView( + room = it.toKey(), + accountViewModel = accountViewModel, + draftMessage = it.message, + replyToNote = it.replyId, + editFromDraft = it.draftId, + expiresDays = it.expiresDays, + nav = nav, + ) + } + + if (it is Route.PublicChatChannel) { + PublicChatChannelView( + channelId = it.id, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + }, + strategy = strategy, + displayFeatures = displayFeatures, + foldAwareConfiguration = FoldAwareConfiguration.VerticalFoldsOnly, + modifier = Modifier.fillMaxSize(), + ) } } } - -@Composable -private fun TwoPaneContent( - padding: PaddingValues, - knownFeedContentState: FeedContentState, - newFeedContentState: FeedContentState, - twoPaneNav: TwoPaneNav, - strategy: TwoPaneStrategy, - accountViewModel: AccountViewModel, - nav: INav, -) { - val act = LocalContext.current.getActivity() - val displayFeatures = calculateDisplayFeatures(act) - - TwoPane( - first = { - Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.BottomEnd) { - RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel) - - // Pre-warm NIP-11 for joined groups' host relays so the relay-signed check is a - // cache hit when those groups surface in discovery or any gated surface. - WarmJoinedRelayGroupNip11(accountViewModel) - - // The inline-vs-grouped NIP-29 preference lives in Settings › Messages; joined - // groups (or per-relay rows in grouped mode) are woven into the list itself. - ChatroomList( - knownFeedContentState, - newFeedContentState, - accountViewModel, - twoPaneNav, - ) - - Box(Modifier.padding(Size20dp), contentAlignment = Alignment.Center) { - ChannelFabColumn(nav) - } - } - }, - second = { - Box(Modifier.fillMaxSize().padding(padding)) { - twoPaneNav.innerNav.value?.let { - if (it is Route.Room) { - ChatroomView( - room = it.toKey(), - accountViewModel = accountViewModel, - draftMessage = it.message, - replyToNote = it.replyId, - editFromDraft = it.draftId, - expiresDays = it.expiresDays, - nav = nav, - ) - } - - if (it is Route.PublicChatChannel) { - PublicChatChannelView( - channelId = it.id, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } - }, - strategy = strategy, - displayFeatures = displayFeatures, - foldAwareConfiguration = FoldAwareConfiguration.VerticalFoldsOnly, - modifier = Modifier.fillMaxSize(), - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 7621c99dc5..9dcf4352e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -58,7 +58,6 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel -import com.vitorpamplona.amethyst.commons.ui.components.zonedDrawerSwipe import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding @@ -76,11 +75,10 @@ import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout -import com.vitorpamplona.amethyst.ui.layouts.NavigationStyle import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.zonedDrawerSwipeIfModal import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -275,22 +273,10 @@ private fun HomePages( // scaffold padding from LocalDisappearingScaffoldPadding via // rememberFeedContentPadding, so feed items still scroll behind the bars. Box(modifier = Modifier.fillMaxSize()) { - // The left-edge swipe opens the modal drawer; with the drawer permanently - // docked there is nothing to open, so leave the pager's own gestures alone. - val modalDrawerExists = - LocalScreenLayout.current.navigationStyle != NavigationStyle.PERMANENT_DRAWER HorizontalPager( state = pagerState, userScrollEnabled = true, - modifier = - if (modalDrawerExists) { - Modifier.zonedDrawerSwipe( - pagerState = pagerState, - openDrawer = nav::openDrawer, - ) - } else { - Modifier - }, + modifier = Modifier.zonedDrawerSwipeIfModal(pagerState, nav), ) { page -> HomeFeeds( feedState = tabs[page].feedState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt index 210a126acc..96dfa7982b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt @@ -36,13 +36,11 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.TopFilter -import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner -import com.vitorpamplona.amethyst.ui.navigation.topbars.LoggedInUserPictureDrawer import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar -import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarNavigationIcon import com.vitorpamplona.amethyst.ui.note.SearchIcon import com.vitorpamplona.amethyst.ui.screen.FeedDefinition import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -71,13 +69,7 @@ fun NappletsTopBar( NappletsTopNavFilterBar(accountViewModel) } }, - navigationIcon = { - if (nav.canPop()) { - IconButton(onClick = nav::popBack) { ArrowBackIcon() } - } else if (!LocalScreenLayout.current.isLargeScreen) { - LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer) - } - }, + navigationIcon = { TopBarNavigationIcon(accountViewModel, nav) }, actions = { IconButton(onClick = { nav.nav(Route.ConnectedApps) }) { Icon(MaterialSymbols.Tune, contentDescription = stringResource(R.string.napplet_manage_permissions)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt index b3e6823b45..d9ad7b2826 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt @@ -214,16 +214,23 @@ private fun SplitNotificationsScaffold( } } +/** + * The refreshable notifications card feed (list + scroll-to-top watcher + inbox-relay + * warning header). Shared between the Notifications screen and the docked + * [NotificationSidePanel], parameterized by the persisted scroll-state key so each + * surface keeps its own position. + */ @Composable -private fun SingleNotificationsBody( +internal fun SingleNotificationsBody( notifFeedContentState: CardFeedContentState, notifPolls: OpenPollsState, scrollToEventId: String?, accountViewModel: AccountViewModel, nav: INav, + scrollStateKey: String = ScrollStateKeys.NOTIFICATION_SCREEN, ) { RefresheableBox(notifFeedContentState, true) { - val listState = rememberForeverLazyListState(ScrollStateKeys.NOTIFICATION_SCREEN) + val listState = rememberForeverLazyListState(scrollStateKey) WatchScrollToTop(notifFeedContentState, listState) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt index 7f43ec4d17..598be6ef1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt @@ -39,30 +39,35 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.ui.layouts.LocalFeedSidePadding -import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys -import com.vitorpamplona.amethyst.ui.feeds.rememberForeverLazyListState import com.vitorpamplona.amethyst.ui.layouts.NotificationPanelWidth import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.Size12dp +import com.vitorpamplona.amethyst.ui.theme.Size16dp import com.vitorpamplona.amethyst.ui.theme.Size22Modifier import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer /** * The docked notification feed shown on very wide windows, to the right of the center pane. - * It renders the same card feed as the Notifications screen (same last-read marking, so - * reading here clears the new-item dot too); tapping its header opens the full screen, - * which keeps the summary chart and the Following/Everyone split. + * It renders the same card feed body as the Notifications screen (same last-read marking — + * cards mark themselves read as they become visible here, exactly as they would on the + * screen, so the new-item dot only lights for items the panel hasn't displayed). When the + * user has split notifications enabled, the panel shows the Following feed to match the + * screen's default tab. Tapping the header opens the full screen, which adds the summary + * chart and the Following/Everyone tabs. */ @Composable fun NotificationSidePanel( @@ -70,7 +75,22 @@ fun NotificationSidePanel( nav: INav, modifier: Modifier = Modifier, ) { - val notifFeedContentState = accountViewModel.feedStates.notifications + val split by accountViewModel.account.settings.splitNotificationsEnabled + .collectAsStateWithLifecycle() + + val notifFeedContentState = + if (split) { + accountViewModel.feedStates.notificationsFollowing + } else { + accountViewModel.feedStates.notifications + } + val scrollStateKey = + if (split) { + ScrollStateKeys.NOTIFICATION_SIDE_PANEL_FOLLOWING + } else { + ScrollStateKeys.NOTIFICATION_SIDE_PANEL + } + WatchAccountForNotifications(notifFeedContentState, accountViewModel) Column( @@ -88,7 +108,7 @@ fun NotificationSidePanel( Modifier .fillMaxWidth() .clickable { nav.nav(Route.Notification()) } - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(horizontal = Size16dp, vertical = Size12dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( @@ -108,19 +128,15 @@ fun NotificationSidePanel( // The panel is its own narrow pane; never apply the center pane's reading-width cap here. CompositionLocalProvider(LocalFeedSidePadding provides 0.dp) { - Box(Modifier.fillMaxWidth()) { - RefresheableBox(notifFeedContentState, true) { - val listState = rememberForeverLazyListState(ScrollStateKeys.NOTIFICATION_SIDE_PANEL) - - RenderCardFeed( - feedContent = notifFeedContentState, - pollContent = accountViewModel.feedStates.notificationsOpenPolls, - accountViewModel = accountViewModel, - listState = listState, - nav = nav, - routeForLastRead = NOTIFICATION_LAST_READ_KEY, - ) - } + Box(Modifier.weight(1f).fillMaxWidth()) { + SingleNotificationsBody( + notifFeedContentState = notifFeedContentState, + notifPolls = accountViewModel.feedStates.notificationsOpenPolls, + scrollToEventId = null, + accountViewModel = accountViewModel, + nav = nav, + scrollStateKey = scrollStateKey, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt index 78bd24ab41..3a1cf39a2a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.pager.HorizontalPager @@ -335,7 +336,10 @@ fun ProfileScreen( topBar = { ProfileTopBar(baseUser, accountViewModel, nav) }, - contentWindowInsets = WindowInsets(0), + // Status-bar handling is done inside RenderSurface, but the bottom inset must stay: + // when AppBottomBar renders nothing (pushed entries on phones, all large-screen + // tiers) this inset is the only thing keeping content clear of the system nav bar. + contentWindowInsets = WindowInsets.navigationBars, bottomBar = { AppBottomBar( Route.Profile(accountViewModel.userProfile().pubkeyHex), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt index fa25234ba5..f66a2c42a9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt @@ -55,8 +55,9 @@ val LocalDisappearingBarState = compositionLocalOf { null * scroll surface stays full-pane-width (scrolling and pull-to-refresh keep working edge to * edge) while items center themselves. Defaults to 0 (phones, and any host that doesn't cap). * - * Full-bleed surfaces (video/shorts pagers) and secondary panes with their own width (side - * panels, two-pane splits) must override this back to 0 for their subtree. + * Panes that manage their own width — side panels, the panes of a two-pane split — must + * override this back to 0 for their subtree: the shell's value was computed against the full + * center pane and would over-pad a narrower list. */ val LocalFeedSidePadding = compositionLocalOf { 0.dp } @@ -83,12 +84,20 @@ fun rememberMergedPadding( /** * Convenience for inner LazyColumns/LazyVerticalGrids inside a [DisappearingScaffold]: * merges the scaffold's reserved space with the list's own baseline padding, plus the - * [LocalFeedSidePadding] width cap requested by wide layouts. + * [LocalFeedSidePadding] width cap requested by wide layouts — all folded into a single + * remember slot, since this runs in every feed on every recomposition. */ @Composable fun rememberFeedContentPadding(inner: PaddingValues): PaddingValues { - val merged = rememberMergedPadding(LocalDisappearingScaffoldPadding.current, inner) + val outer = LocalDisappearingScaffoldPadding.current val sidePadding = LocalFeedSidePadding.current - if (sidePadding <= 0.dp) return merged - return rememberMergedPadding(merged, PaddingValues(start = sidePadding, end = sidePadding)) + val layoutDirection = LocalLayoutDirection.current + return remember(outer, inner, sidePadding, layoutDirection) { + PaddingValues( + start = outer.calculateStartPadding(layoutDirection) + inner.calculateStartPadding(layoutDirection) + sidePadding, + top = outer.calculateTopPadding() + inner.calculateTopPadding(), + end = outer.calculateEndPadding(layoutDirection) + inner.calculateEndPadding(layoutDirection) + sidePadding, + bottom = outer.calculateBottomPadding() + inner.calculateBottomPadding(), + ) + } } From 6b6f0fd2aefe9176fd3a20ab153f691530fa441f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 13:41:34 +0000 Subject: [PATCH 3/6] feat: cap every screen to the reading-column width on wide panes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capping only the feeds' contentPadding left top bars, list screens, bookmarks and settings stretched across the whole center pane. Move the cap up a level: every NavHost destination is wrapped in CappedScreenContent (600dp, centered) through the shared route builders in NavigationEffects, so each screen's entire surface — top bar, tabs, content — shares one reading column, on all ~200 destinations at once. Opt-outs at registration: Route.Message keeps the full pane for its two-pane list/conversation split, and Browser/WebApp/NostrApp stay full-pane so the warm EmbeddedTabLayer surfaces keep lining up. This supersedes the LocalFeedSidePadding-based capping on Android: the shell no longer provides side padding (CenterPane is a plain Box again) and the now-dead overrides in MessagesTwoPane and NotificationSidePanel are removed. The commons local stays, documented as the padding-based alternative for hosts like a desktop reading column where gutters should still scroll. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RHSoAVvYioihaJeSumX8J7 --- .../amethyst/ui/layouts/ScreenLayout.kt | 39 ++++++- .../amethyst/ui/navigation/AppNavigation.kt | 14 +-- .../ui/navigation/NavigationEffects.kt | 66 +++++++++-- .../AccountSwitcherAndLeftDrawerLayout.kt | 24 +--- .../chats/rooms/twopane/MessagesTwoPane.kt | 103 ++++++++---------- .../notifications/NotificationSidePanel.kt | 24 ++-- .../commons/ui/layouts/PaddingMerge.kt | 16 +-- 7 files changed, 169 insertions(+), 117 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayout.kt index ae373a5ed6..57b66bc8b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayout.kt @@ -20,6 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.layouts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass @@ -27,6 +32,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.ui.components.getActivity @@ -80,12 +87,38 @@ val PermanentDrawerWidth = 300.dp val NotificationPanelWidth = 360.dp /** - * Maximum width of a feed's content column inside a wide center pane. Wider than this, - * feeds center themselves via [com.vitorpamplona.amethyst.commons.ui.layouts.LocalFeedSidePadding]; - * the scroll surface itself stays full-pane so pull-to-refresh and scrolling work edge to edge. + * Maximum width of a screen's content column inside a wide center pane. Every NavHost + * destination is wrapped in [CappedScreenContent] (via the builders in NavigationEffects), + * so the whole screen — top bar, tabs, feed, settings rows — shares one centered reading + * column instead of stretching across the pane. Screens that genuinely need the full pane + * (Messages' two-pane split, the embedded browser surfaces) opt out at registration. */ val FeedContentMaxWidth = 600.dp +/** + * Centers a destination's content at [FeedContentMaxWidth]. The outer box paints the theme + * background so the gutters match the screens' own surfaces; on Compact windows the cap is + * wider than the pane and this is a visual no-op. + */ +@Composable +fun CappedScreenContent(content: @Composable () -> Unit) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.TopCenter, + ) { + Box( + Modifier + .widthIn(max = FeedContentMaxWidth) + .fillMaxSize(), + ) { + content() + } + } +} + @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Composable fun rememberScreenLayoutSpec(): ScreenLayoutSpec { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index bf8203d6f5..087ebdb43c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -380,9 +380,9 @@ fun BuildNavigation( enterTransition = { fadeIn(animationSpec = tween(200)) }, exitTransition = { fadeOut(animationSpec = tween(200)) }, ) { - composable { HomeScreen(accountViewModel, nav) } + composableCapped { HomeScreen(accountViewModel, nav) } composable { MessagesScreen(accountViewModel, nav) } - composable { VideoScreen(accountViewModel, nav) } + composableCapped { VideoScreen(accountViewModel, nav) } composableArgs { DiscoverScreen(it.initialTab, accountViewModel, nav) } composableArgs { NotificationScreen(it.scrollToEventId, accountViewModel, nav) } composableFromEnd { PollsScreen(accountViewModel, nav) } @@ -399,10 +399,10 @@ fun BuildNavigation( composableFromEnd { SoftwareAppsScreen(accountViewModel, nav) } composableFromEnd { NappletsScreen(accountViewModel, nav) } composableFromEnd { NsitesScreen(accountViewModel, nav) } - composableFromEnd { BrowserScreen(accountViewModel, nav) } + composableFromEnd(capWidth = false) { BrowserScreen(accountViewModel, nav) } composableFromEnd { FavoriteAppsScreen(accountViewModel, nav) } - composableFromEndArgs { WebAppScreen(it.url, accountViewModel, nav) } - composableFromEndArgs { NostrAppScreen(it.coordinate, accountViewModel, nav) } + composableFromEndArgs(capWidth = false) { WebAppScreen(it.url, accountViewModel, nav) } + composableFromEndArgs(capWidth = false) { NostrAppScreen(it.coordinate, accountViewModel, nav) } composableFromEnd { ConnectedAppsScreen(accountViewModel, nav) } composableFromEndArgs { ConnectedAppDetailScreen(it.coordinate, accountViewModel, nav) } composableFromEnd { RelayAuthSettingsScreen(accountViewModel, nav) } @@ -441,7 +441,7 @@ fun BuildNavigation( composableFromEndArgs { NewMusicPlaylistScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) } composableFromEndArgs { AddToMusicPlaylistSheet(trackAddress = it.trackAddress, accountViewModel = accountViewModel, nav = nav) } composableFromEnd { NewHlsVideoScreen(accountViewModel, nav) } - composable { ChessLobbyScreen(accountViewModel, nav) } + composableCapped { ChessLobbyScreen(accountViewModel, nav) } composableFromEnd { WalletScreen(accountViewModel, nav) } composableFromEndArgs { WalletSendScreen(it.walletId, accountViewModel, nav) } @@ -494,7 +494,7 @@ fun BuildNavigation( composableFromBottomArgs { TopUpMintScreen(it.mintUrl, accountViewModel, nav) } composableFromBottomArgs { NewUserMetadataScreen(nav, accountViewModel) } - composable { SearchScreen(accountViewModel, nav) } + composableCapped { SearchScreen(accountViewModel, nav) } composableFromEnd { AllSettingsScreen(accountViewModel, nav) } composableFromEnd { AccountBackupScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt index cf4a4ee7e9..8b16865a31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt @@ -33,6 +33,7 @@ import androidx.navigation.NavBackStackEntry import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import androidx.navigation.toRoute +import com.vitorpamplona.amethyst.ui.layouts.CappedScreenContent // Per-entry hint stamped by Nav.navBottomBar marking that the entry was // reached via a bottom-nav tab. Used in two places: @@ -44,41 +45,84 @@ const val BOTTOM_NAV_ROOT_KEY = "bottomNavRoot" fun NavBackStackEntry.isBottomNavRoot(): Boolean = savedStateHandle.get(BOTTOM_NAV_ROOT_KEY) == true -inline fun NavGraphBuilder.composableFromEnd(noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit) { +/** + * Applies the wide-pane reading-column cap ([CappedScreenContent]) to a destination unless + * it opted out with `capWidth = false`. Every builder below routes through this, so all + * destinations — top bars included — share the centered column on large screens by default. + */ +@Composable +fun MaybeCappedScreen( + capWidth: Boolean, + content: @Composable () -> Unit, +) { + if (capWidth) { + CappedScreenContent(content) + } else { + content() + } +} + +/** Stock fade-transition destination, capped to the reading-column width on wide panes. */ +inline fun NavGraphBuilder.composableCapped(noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit) { + composable { entry -> + CappedScreenContent { content(entry) } + } +} + +inline fun NavGraphBuilder.composableFromEnd( + capWidth: Boolean = true, + noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, +) { composable( enterTransition = { if (targetState.isBottomNavRoot()) null else slideInHorizontallyFromEnd }, exitTransition = { if (targetState.isBottomNavRoot()) null else scaleOut }, popEnterTransition = { if (initialState.isBottomNavRoot()) null else scaleIn }, popExitTransition = { if (initialState.isBottomNavRoot()) null else slideOutHorizontallyToEnd }, - content = content, + content = { entry -> + MaybeCappedScreen(capWidth) { content(entry) } + }, ) } -inline fun NavGraphBuilder.composableFromEndArgs(noinline content: @Composable AnimatedContentScope.(T) -> Unit) { - composableFromEnd { +inline fun NavGraphBuilder.composableFromEndArgs( + capWidth: Boolean = true, + noinline content: @Composable AnimatedContentScope.(T) -> Unit, +) { + composableFromEnd(capWidth) { content(it.toRoute()) } } -inline fun NavGraphBuilder.composableFromBottom(noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit) { +inline fun NavGraphBuilder.composableFromBottom( + capWidth: Boolean = true, + noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, +) { composable( enterTransition = { slideInVerticallyFromBottom }, exitTransition = { scaleOut }, popEnterTransition = { scaleIn }, popExitTransition = { slideOutVerticallyToBottom }, - content = content, + content = { entry -> + MaybeCappedScreen(capWidth) { content(entry) } + }, ) } -inline fun NavGraphBuilder.composableFromBottomArgs(noinline content: @Composable AnimatedContentScope.(T) -> Unit) { - composableFromBottom { +inline fun NavGraphBuilder.composableFromBottomArgs( + capWidth: Boolean = true, + noinline content: @Composable AnimatedContentScope.(T) -> Unit, +) { + composableFromBottom(capWidth) { content(it.toRoute()) } } -inline fun NavGraphBuilder.composableArgs(noinline content: @Composable AnimatedContentScope.(T) -> Unit) { - composable { - content(it.toRoute()) +inline fun NavGraphBuilder.composableArgs( + capWidth: Boolean = true, + noinline content: @Composable AnimatedContentScope.(T) -> Unit, +) { + composable { entry -> + MaybeCappedScreen(capWidth) { content(entry.toRoute()) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt index 74733ffad4..dbeddda760 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.content.res.Configuration import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -34,7 +34,6 @@ import androidx.compose.material3.SheetValue import androidx.compose.material3.VerticalDivider import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.movableContentOf @@ -46,12 +45,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.compose.currentBackStackEntryAsState -import com.vitorpamplona.amethyst.commons.ui.layouts.LocalFeedSidePadding -import com.vitorpamplona.amethyst.ui.layouts.FeedContentMaxWidth import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout import com.vitorpamplona.amethyst.ui.layouts.NavigationStyle import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppNavigationRail @@ -231,24 +226,17 @@ private fun PermanentDrawerShell( } } -/** Step size for the feed side padding, so continuous window resizes republish the value - * (and invalidate every feed reading it) at most once per step instead of once per pixel. */ -private val SidePaddingQuantum = 8.dp - /** - * Hosts the navigation content and publishes the side padding feeds need to cap their content - * at [FeedContentMaxWidth] within this pane, via [LocalFeedSidePadding]. + * Hosts the navigation content. Screen width capping happens per NavHost destination + * ([com.vitorpamplona.amethyst.ui.layouts.CappedScreenContent] via the NavigationEffects + * builders), so this pane just claims the leftover row width. */ @Composable private fun CenterPane( modifier: Modifier, content: @Composable () -> Unit, ) { - BoxWithConstraints(modifier.fillMaxHeight()) { - val rawPadding = ((maxWidth - FeedContentMaxWidth) / 2).coerceAtLeast(0.dp) - val sidePadding = Dp((rawPadding.value / SidePaddingQuantum.value).toInt() * SidePaddingQuantum.value) - CompositionLocalProvider(LocalFeedSidePadding provides sidePadding) { - content() - } + Box(modifier.fillMaxHeight()) { + content() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt index 9d16c3ea38..826d46673d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt @@ -27,19 +27,16 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp import com.google.accompanist.adaptive.FoldAwareConfiguration import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy import com.google.accompanist.adaptive.TwoPane import com.google.accompanist.adaptive.calculateDisplayFeatures import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.commons.ui.layouts.LocalFeedSidePadding import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -97,62 +94,58 @@ fun MessagesTwoPane( } }, ) { padding -> - // Each pane manages its own width; the shell's center-pane reading cap must not - // re-pad the lists inside them. - CompositionLocalProvider(LocalFeedSidePadding provides 0.dp) { - TwoPane( - first = { - Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.BottomEnd) { - RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel) + TwoPane( + first = { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.BottomEnd) { + RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel) - // Pre-warm NIP-11 for joined groups' host relays so the relay-signed check is a - // cache hit when those groups surface in discovery or any gated surface. - WarmJoinedRelayGroupNip11(accountViewModel) + // Pre-warm NIP-11 for joined groups' host relays so the relay-signed check is a + // cache hit when those groups surface in discovery or any gated surface. + WarmJoinedRelayGroupNip11(accountViewModel) - // The inline-vs-grouped NIP-29 preference lives in Settings › Messages; joined - // groups (or per-relay rows in grouped mode) are woven into the list itself. - ChatroomList( - knownFeedContentState, - newFeedContentState, - accountViewModel, - twoPaneNav, - ) + // The inline-vs-grouped NIP-29 preference lives in Settings › Messages; joined + // groups (or per-relay rows in grouped mode) are woven into the list itself. + ChatroomList( + knownFeedContentState, + newFeedContentState, + accountViewModel, + twoPaneNav, + ) - Box(Modifier.padding(Size20dp), contentAlignment = Alignment.Center) { - ChannelFabColumn(nav) + Box(Modifier.padding(Size20dp), contentAlignment = Alignment.Center) { + ChannelFabColumn(nav) + } + } + }, + second = { + Box(Modifier.fillMaxSize().padding(padding)) { + twoPaneNav.innerNav.value?.let { + if (it is Route.Room) { + ChatroomView( + room = it.toKey(), + accountViewModel = accountViewModel, + draftMessage = it.message, + replyToNote = it.replyId, + editFromDraft = it.draftId, + expiresDays = it.expiresDays, + nav = nav, + ) + } + + if (it is Route.PublicChatChannel) { + PublicChatChannelView( + channelId = it.id, + accountViewModel = accountViewModel, + nav = nav, + ) } } - }, - second = { - Box(Modifier.fillMaxSize().padding(padding)) { - twoPaneNav.innerNav.value?.let { - if (it is Route.Room) { - ChatroomView( - room = it.toKey(), - accountViewModel = accountViewModel, - draftMessage = it.message, - replyToNote = it.replyId, - editFromDraft = it.draftId, - expiresDays = it.expiresDays, - nav = nav, - ) - } - - if (it is Route.PublicChatChannel) { - PublicChatChannelView( - channelId = it.id, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } - }, - strategy = strategy, - displayFeatures = displayFeatures, - foldAwareConfiguration = FoldAwareConfiguration.VerticalFoldsOnly, - modifier = Modifier.fillMaxSize(), - ) - } + } + }, + strategy = strategy, + displayFeatures = displayFeatures, + foldAwareConfiguration = FoldAwareConfiguration.VerticalFoldsOnly, + modifier = Modifier.fillMaxSize(), + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt index 598be6ef1a..d40dd9db6c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt @@ -38,16 +38,13 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.ui.layouts.LocalFeedSidePadding import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.layouts.NotificationPanelWidth import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -126,18 +123,15 @@ fun NotificationSidePanel( HorizontalDivider(thickness = DividerThickness) - // The panel is its own narrow pane; never apply the center pane's reading-width cap here. - CompositionLocalProvider(LocalFeedSidePadding provides 0.dp) { - Box(Modifier.weight(1f).fillMaxWidth()) { - SingleNotificationsBody( - notifFeedContentState = notifFeedContentState, - notifPolls = accountViewModel.feedStates.notificationsOpenPolls, - scrollToEventId = null, - accountViewModel = accountViewModel, - nav = nav, - scrollStateKey = scrollStateKey, - ) - } + Box(Modifier.weight(1f).fillMaxWidth()) { + SingleNotificationsBody( + notifFeedContentState = notifFeedContentState, + notifPolls = accountViewModel.feedStates.notificationsOpenPolls, + scrollToEventId = null, + accountViewModel = accountViewModel, + nav = nav, + scrollStateKey = scrollStateKey, + ) } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt index f66a2c42a9..3e9b7261c2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt @@ -49,15 +49,15 @@ val LocalDisappearingScaffoldPadding = compositionLocalOf { PaddingValues(0.dp) val LocalDisappearingBarState = compositionLocalOf { null } /** - * Extra start/end padding wide layouts ask feeds to apply so their content column stays at a - * readable width. The shell computes it as `(paneWidth - feedMaxWidth) / 2` and provides it - * around the center pane; feeds pick it up through [rememberFeedContentPadding], so the - * scroll surface stays full-pane-width (scrolling and pull-to-refresh keep working edge to - * edge) while items center themselves. Defaults to 0 (phones, and any host that doesn't cap). + * Extra start/end padding a host can ask feeds to apply so their content column stays at a + * readable width while the scroll surface stays full-pane (scrolling and pull-to-refresh + * keep working edge to edge). Feeds pick it up through [rememberFeedContentPadding]. + * Defaults to 0 everywhere. * - * Panes that manage their own width — side panels, the panes of a two-pane split — must - * override this back to 0 for their subtree: the shell's value was computed against the full - * center pane and would over-pad a narrower list. + * The Android shell does NOT provide this — it caps each NavHost destination's width + * instead (CappedScreenContent), which also constrains top bars and non-feed screens. + * The local remains for hosts that prefer padding-based capping (e.g. a desktop-style + * reading column where gutters should still scroll). */ val LocalFeedSidePadding = compositionLocalOf { 0.dp } From bced238db4e7bf1d8fbd504f56adbb5a88f8f835 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 13:46:27 +0000 Subject: [PATCH 4/6] fix: give the notification panel a Surface so its text follows the theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel's Column sat in a bare Row with no Surface above it, so LocalContentColor fell back to Color.Black and the header label was invisible on the dark theme — the same trap DisappearingScaffold documents for its own root. The Surface provides the container color and onBackground content color for everything inside the panel. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RHSoAVvYioihaJeSumX8J7 --- .../notifications/NotificationSidePanel.kt | 77 ++++++++++--------- 1 file changed, 42 insertions(+), 35 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt index d40dd9db6c..737c9439c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt @@ -36,6 +36,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -90,48 +91,54 @@ fun NotificationSidePanel( WatchAccountForNotifications(notifFeedContentState, accountViewModel) - Column( - modifier - .width(NotificationPanelWidth) - .fillMaxHeight() - .windowInsetsPadding( + // The Surface provides the Material container color + onBackground as LocalContentColor; + // in a bare Row the default text color falls back to Color.Black and is invisible on the + // dark theme (same reason DisappearingScaffold roots itself in a Surface). + Surface( + modifier = modifier.width(NotificationPanelWidth).fillMaxHeight(), + color = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + ) { + Column( + Modifier.windowInsetsPadding( WindowInsets.systemBars.only( WindowInsetsSides.Top + WindowInsetsSides.Bottom + WindowInsetsSides.End, ), ), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable { nav.nav(Route.Notification()) } - .padding(horizontal = Size16dp, vertical = Size12dp), - verticalAlignment = Alignment.CenterVertically, ) { - Icon( - symbol = MaterialSymbols.Notifications, - contentDescription = null, - modifier = Size22Modifier, - tint = MaterialTheme.colorScheme.onBackground, - ) - Spacer(modifier = StdHorzSpacer) - Text( - text = stringRes(R.string.route_notifications), - style = MaterialTheme.typography.titleMedium, - ) - } + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { nav.nav(Route.Notification()) } + .padding(horizontal = Size16dp, vertical = Size12dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.Notifications, + contentDescription = null, + modifier = Size22Modifier, + tint = MaterialTheme.colorScheme.onBackground, + ) + Spacer(modifier = StdHorzSpacer) + Text( + text = stringRes(R.string.route_notifications), + style = MaterialTheme.typography.titleMedium, + ) + } - HorizontalDivider(thickness = DividerThickness) + HorizontalDivider(thickness = DividerThickness) - Box(Modifier.weight(1f).fillMaxWidth()) { - SingleNotificationsBody( - notifFeedContentState = notifFeedContentState, - notifPolls = accountViewModel.feedStates.notificationsOpenPolls, - scrollToEventId = null, - accountViewModel = accountViewModel, - nav = nav, - scrollStateKey = scrollStateKey, - ) + Box(Modifier.weight(1f).fillMaxWidth()) { + SingleNotificationsBody( + notifFeedContentState = notifFeedContentState, + notifPolls = accountViewModel.feedStates.notificationsOpenPolls, + scrollToEventId = null, + accountViewModel = accountViewModel, + nav = nav, + scrollStateKey = scrollStateKey, + ) + } } } } From cc03651460b50285d20bc3bea30b88708130d49e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 13:49:18 +0000 Subject: [PATCH 5/6] fix: pad custom top bars by systemBars so desktop caption bars are respected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Search and Browser top bars are plain layouts (not Material3 TopAppBars) padded with statusBarsPadding only, and DisappearingScaffold used the same for its no-top-bar fallback. In a desktop-style window (Waydroid/DeX freeform) the window's caption/title bar is a separate inset that statusBars does not include — Material's own top bars pad by systemBars and were fine, but these three drew underneath the title bar. Pad by WindowInsets.systemBars top instead, which covers both. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RHSoAVvYioihaJeSumX8J7 --- .../amethyst/ui/layouts/DisappearingScaffold.kt | 8 ++++++-- .../ui/screen/loggedIn/browser/BrowserScreen.kt | 11 ++++++++--- .../ui/screen/loggedIn/search/SearchScreen.kt | 11 +++++++++-- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt index 40b32b4327..a9c963f370 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt @@ -24,10 +24,13 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -129,7 +132,8 @@ fun DisappearingScaffold( } val rootModifier = baseModifier - .let { if (topBar == null) it.statusBarsPadding() else it } + // systemBars (not just statusBars) so a desktop window's caption bar is respected too. + .let { if (topBar == null) it.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)) else it } .let { if (bottomBar == null) it.navigationBarsPadding() else it } Surface( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt index a74ac3db4e..4e0f47f509 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt @@ -28,12 +28,16 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyGridScope @@ -313,8 +317,9 @@ private fun OmniBar( Modifier .fillMaxWidth() // The omnibox is a plain Row in the topBar slot (not a Material3 TopAppBar), so it must - // apply the status-bar inset itself — otherwise it draws under the status bar. - .statusBarsPadding() + // apply the top system insets itself — systemBars rather than just statusBars, so the + // caption bar of a desktop window (Waydroid/DeX freeform) is respected too. + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)) .padding(horizontal = 4.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index c3d39717bf..94823f094f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -26,13 +26,17 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.selection.selectable @@ -197,7 +201,10 @@ private fun SearchBar( modifier = Modifier .background(MaterialTheme.colorScheme.surface) - .statusBarsPadding(), + // A custom bar (not a Material3 TopAppBar) must apply the top system insets + // itself. systemBars — not just statusBars — so the caption bar of a desktop + // window (Waydroid/DeX freeform) is respected too. + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)), ) { SearchTextField(searchBarViewModel, Modifier) // Inline Namecoin lookup feedback for the global search field. From adb26df838b46234328901b71a39873133006f74 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 14:00:48 +0000 Subject: [PATCH 6/6] feat: tier-scaled navigation transitions for large screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-width slide-from-end pushes read as disconnected on large screens: the click comes from the docked drawer on the left and a whole 600dp+ column flies across the pane from the right. Keep one navigation grammar (drill-in from end, modal from bottom, tab switches fade) and scale the motion per tier: phones keep the existing full-width slides, large screens get shared-axis moves — a 1/10-pane nudge plus fade — and the screen behind a push fades with its slight scale so both aren't visible mid-transition. Transition specs run outside composition and can't read LocalScreenLayout, so AppNavigation mirrors the tier into NavTransitionTier for the spec lambdas, all of which live in NavigationEffects' shared builders. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RHSoAVvYioihaJeSumX8J7 --- .../amethyst/ui/navigation/AppNavigation.kt | 5 ++ .../ui/navigation/NavigationEffects.kt | 59 ++++++++++++++++--- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 087ebdb43c..149d0ff90f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -32,6 +32,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -294,6 +295,10 @@ fun AppNavigation( val screenLayout = rememberScreenLayoutSpec() val tabReselectCoordinator = remember { TabReselectCoordinator() } + // Mirror the tier for the nav-transition specs, which run outside composition and so + // can't read LocalScreenLayout (see NavTransitionTier). + SideEffect { NavTransitionTier.isLargeScreen = screenLayout.isLargeScreen } + CompositionLocalProvider( LocalScreenLayout provides screenLayout, LocalTabReselectCoordinator provides tabReselectCoordinator, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt index 8b16865a31..3e01d5bb98 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.navigation import androidx.compose.animation.AnimatedContentScope import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInHorizontally @@ -45,6 +47,21 @@ const val BOTTOM_NAV_ROOT_KEY = "bottomNavRoot" fun NavBackStackEntry.isBottomNavRoot(): Boolean = savedStateHandle.get(BOTTOM_NAV_ROOT_KEY) == true +/** + * The shell's current layout tier, mirrored for the transition specs below. Transition + * lambdas run when a navigation starts — outside composition — so they can't read + * LocalScreenLayout; AppNavigation mirrors the spec here instead. + * + * One navigation grammar, tier-scaled motion: phones keep full-width slides (a pushed + * screen physically stacks on top), while large screens use short shared-axis moves — + * content there swaps inside a persistent shell, and a full-pane slide from the right + * reads as disconnected when the click came from the docked drawer on the left. + */ +object NavTransitionTier { + @Volatile + var isLargeScreen: Boolean = false +} + /** * Applies the wide-pane reading-column cap ([CappedScreenContent]) to a destination unless * it opted out with `capWidth = false`. Every builder below routes through this, so all @@ -74,10 +91,10 @@ inline fun NavGraphBuilder.composableFromEnd( noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, ) { composable( - enterTransition = { if (targetState.isBottomNavRoot()) null else slideInHorizontallyFromEnd }, - exitTransition = { if (targetState.isBottomNavRoot()) null else scaleOut }, - popEnterTransition = { if (initialState.isBottomNavRoot()) null else scaleIn }, - popExitTransition = { if (initialState.isBottomNavRoot()) null else slideOutHorizontallyToEnd }, + enterTransition = { if (targetState.isBottomNavRoot()) null else enterFromEnd() }, + exitTransition = { if (targetState.isBottomNavRoot()) null else exitBehind() }, + popEnterTransition = { if (initialState.isBottomNavRoot()) null else popEnterFromBehind() }, + popExitTransition = { if (initialState.isBottomNavRoot()) null else popExitToEnd() }, content = { entry -> MaybeCappedScreen(capWidth) { content(entry) } }, @@ -98,10 +115,10 @@ inline fun NavGraphBuilder.composableFromBottom( noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, ) { composable( - enterTransition = { slideInVerticallyFromBottom }, - exitTransition = { scaleOut }, - popEnterTransition = { scaleIn }, - popExitTransition = { slideOutVerticallyToBottom }, + enterTransition = { enterFromBottom() }, + exitTransition = { exitBehind() }, + popEnterTransition = { popEnterFromBehind() }, + popExitTransition = { popExitToBottom() }, content = { entry -> MaybeCappedScreen(capWidth) { content(entry) } }, @@ -134,3 +151,29 @@ val slideOutHorizontallyToEnd = slideOutHorizontally(animationSpec = tween(), ta val scaleIn = scaleIn(animationSpec = tween(), initialScale = 0.9f) val scaleOut = scaleOut(animationSpec = tween(), targetScale = 0.9f) + +/** Fraction of the pane a shared-axis move travels on large screens — a nudge, not a fly-in. */ +private const val SHARED_AXIS_FRACTION = 10 + +val sharedAxisEnterFromEnd = slideInHorizontally(animationSpec = tween()) { it / SHARED_AXIS_FRACTION } + fadeIn(animationSpec = tween()) +val sharedAxisExitToEnd = slideOutHorizontally(animationSpec = tween()) { it / SHARED_AXIS_FRACTION } + fadeOut(animationSpec = tween()) +val sharedAxisEnterFromBottom = slideInVertically(animationSpec = tween()) { it / SHARED_AXIS_FRACTION } + fadeIn(animationSpec = tween()) +val sharedAxisExitToBottom = slideOutVertically(animationSpec = tween()) { it / SHARED_AXIS_FRACTION } + fadeOut(animationSpec = tween()) + +// The outgoing/incoming screen *behind* a push: on phones the pushed screen covers it, so a +// slight scale is enough; on large screens the incoming screen fades, so the one behind must +// fade too or both stay visible mid-transition. +val fadeScaleOut = scaleOut + fadeOut(animationSpec = tween()) +val fadeScaleIn = scaleIn + fadeIn(animationSpec = tween()) + +fun enterFromEnd() = if (NavTransitionTier.isLargeScreen) sharedAxisEnterFromEnd else slideInHorizontallyFromEnd + +fun popExitToEnd() = if (NavTransitionTier.isLargeScreen) sharedAxisExitToEnd else slideOutHorizontallyToEnd + +fun enterFromBottom() = if (NavTransitionTier.isLargeScreen) sharedAxisEnterFromBottom else slideInVerticallyFromBottom + +fun popExitToBottom() = if (NavTransitionTier.isLargeScreen) sharedAxisExitToBottom else slideOutVerticallyToBottom + +fun exitBehind() = if (NavTransitionTier.isLargeScreen) fadeScaleOut else scaleOut + +fun popEnterFromBehind() = if (NavTransitionTier.isLargeScreen) fadeScaleIn else scaleIn