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..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 @@ -38,6 +38,8 @@ 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 6f36f0e63f..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 @@ -80,10 +83,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,24 +107,33 @@ 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) } + // 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 // 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() } 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/layouts/ScreenLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayout.kt new file mode 100644 index 0000000000..57b66bc8b5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayout.kt @@ -0,0 +1,141 @@ +/* + * 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.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 +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 + +/** 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 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 { + 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..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 @@ -29,8 +29,10 @@ 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.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -59,6 +61,10 @@ 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.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 @@ -282,40 +288,56 @@ fun AppNavigation( ) { val nav = rememberNav() - 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() + // 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. 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() } + + // 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, + ) { + 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 @@ -363,9 +385,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) } @@ -382,10 +404,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) } @@ -424,7 +446,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) } @@ -477,7 +499,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..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 @@ -33,6 +35,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 +47,99 @@ 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) { +/** + * 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 + * 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, + 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) } + }, ) } -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, + enterTransition = { enterFromBottom() }, + exitTransition = { exitBehind() }, + popEnterTransition = { popEnterFromBehind() }, + popExitTransition = { popExitToBottom() }, + 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()) } } } @@ -90,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 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..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 @@ -49,6 +51,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 +76,22 @@ 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 + // 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 @@ -101,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, @@ -112,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 @@ -147,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) } } } @@ -175,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) }, @@ -216,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 new file mode 100644 index 0000000000..be8c77035e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt @@ -0,0 +1,130 @@ +/* + * 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.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.favorites.FavoriteApp +import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry +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 + +/** + * 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. Re-tapping the selected item routes + * through [TabReselectCoordinator] to the screen's own scroll-to-top/refresh handler. + */ +@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 } } + + val reselectCoordinator = LocalTabReselectCoordinator.current + + 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) { + reselectCoordinator.reselect(destination) + } else { + nav.navBottomBar(destination) + } + }, + icon = { NotifiableIcon(selected, def, destination, accountViewModel) }, + ) + } + + 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 + } + } + NavigationRailItem( + selected = selected, + 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/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/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 56136b06f9..6d17929ebe 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,7 @@ 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.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 +150,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 +429,10 @@ fun StatusEditBar( val currentStatus = remember { mutableStateOf(savedStatus ?: "") } + // 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. LaunchedEffect(nav.drawerState.isClosed) { - if (nav.drawerState.isClosed) { + if (!nav.isDrawerDocked && nav.drawerState.isClosed) { focusManager.clearFocus(true) onDone() } else { @@ -427,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, @@ -442,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 efdcfff438..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 @@ -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 @@ -62,18 +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. - if (nav.canPop()) { - IconButton(onClick = nav::popBack) { - ArrowBackIcon() - } - } else { - LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer) - } - }, + navigationIcon = { TopBarNavigationIcon(accountViewModel, nav) }, actions = { IconButton(onClick = { nav.nav(Route.Search) }) { SearchIcon(modifier = Size22Modifier, MaterialTheme.colorScheme.placeholderText) @@ -82,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 d4c7090b08..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,29 +22,43 @@ 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.Row +import androidx.compose.foundation.layout.fillMaxHeight +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.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.navigation.NavDestination.Companion.hasRoute import androidx.navigation.compose.currentBackStackEntryAsState +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,40 +89,31 @@ fun AccountSwitcherAndLeftDrawerLayout( } } - val orientation = LocalConfiguration.current.orientation - val currentDrawerState = nav.drawerState.currentValue - LaunchedEffect(key1 = orientation) { - if ( - orientation == Configuration.ORIENTATION_LANDSCAPE && currentDrawerState == DrawerValue.Closed - ) { - nav.drawerState.close() + // 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() } } + + val docked = LocalScreenLayout.current.navigationStyle == NavigationStyle.PERMANENT_DRAWER + + // 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) } } - val navBackStackEntry by nav.controller.currentBackStackEntryAsState() - val isTabPagerRoute = - navBackStackEntry?.destination?.let { dest -> - dest.hasRoute() || dest.hasRoute() - } ?: false - val drawerGesturesEnabled = - ( - !isTabPagerRoute || - nav.drawerState.isOpen || - nav.drawerState.targetValue != nav.drawerState.currentValue - ) && - // Suspend the left-edge swipe while a selection/caret handle is dragged over an embedded surface, - // so a handle drag near the left edge (or the auto-scroll edge drag) doesn't open the drawer. - !EmbeddedSelectionDrag.dragging - - ModalNavigationDrawer( - drawerState = nav.drawerState, - gesturesEnabled = drawerGesturesEnabled, - drawerContent = { - DrawerContent(nav, openSheetFunction, accountViewModel) - BackHandler(enabled = nav.drawerState.isOpen, nav::closeDrawer) - }, - content = content, - ) + if (docked) { + PermanentDrawerShell(accountViewModel, nav, openSheetFunction, movableContent) + } else { + ModalDrawerShell(accountViewModel, nav, openSheetFunction, movableContent) + } // Sheet content if (openAccountSwitcherBottomSheet) { @@ -131,3 +136,107 @@ fun AccountSwitcherAndLeftDrawerLayout( } } } + +/** + * 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 +private fun ModalDrawerShell( + accountViewModel: AccountViewModel, + nav: Nav, + openSheet: () -> Unit, + content: @Composable () -> Unit, +) { + val orientation = LocalConfiguration.current.orientation + LaunchedEffect(key1 = orientation) { + // 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() + } + } + + val navBackStackEntry by nav.controller.currentBackStackEntryAsState() + val isTabPagerRoute = + navBackStackEntry?.destination?.let { dest -> + dest.hasRoute() || dest.hasRoute() + } ?: false + val drawerGesturesEnabled = + ( + !isTabPagerRoute || + nav.drawerState.isOpen || + nav.drawerState.targetValue != nav.drawerState.currentValue + ) && + // Suspend the left-edge swipe while a selection/caret handle is dragged over an embedded surface, + // 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, + drawerContent = { + DrawerContent(nav, openSheet, accountViewModel) + BackHandler(enabled = nav.drawerState.isOpen, nav::closeDrawer) + }, + content = { + if (showRail) { + Row(Modifier.fillMaxSize()) { + AppNavigationRail(nav, accountViewModel) + VerticalDivider(thickness = DividerThickness) + CenterPane(Modifier.weight(1f), content) + } + } else { + content() + } + }, + ) +} + +/** + * 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. 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, +) { + Box(modifier.fillMaxHeight()) { + content() + } +} 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/chats/rooms/MessagesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt index ab8ac1842c..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 @@ -20,15 +20,14 @@ */ 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.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.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 @@ -40,37 +39,32 @@ 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. + // calculateFromSize keeps the Compact/Medium/Expanded breakpoints in one place + // (Material's) instead of restating 600/840 here. + BoxWithConstraints(Modifier.fillMaxSize()) { + val paneWidthClass = + WindowSizeClass + .calculateFromSize(DpSize(maxWidth, maxHeight)) + .widthSizeClass - val twoPane by remember(windowSizeClass.widthSizeClass) { - derivedStateOf { - when (windowSizeClass.widthSizeClass) { - WindowWidthSizeClass.Compact -> false - - WindowWidthSizeClass.Expanded, - WindowWidthSizeClass.Medium, - -> true - - else -> false - } + 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, + ) } } - - 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..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,12 +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.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 @@ -124,11 +124,7 @@ fun MessagesPager( HorizontalPager( state = pagerState, userScrollEnabled = true, - modifier = - modifier.zonedDrawerSwipe( - pagerState = pagerState, - openDrawer = nav::openDrawer, - ), + 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 2cf453a10c..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 @@ -63,8 +63,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 { 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..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 @@ -79,6 +78,7 @@ import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold 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 @@ -276,11 +276,7 @@ private fun HomePages( HorizontalPager( state = pagerState, userScrollEnabled = true, - modifier = - Modifier.zonedDrawerSwipe( - pagerState = pagerState, - openDrawer = nav::openDrawer, - ), + 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 e8183abcc3..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 @@ -39,9 +39,8 @@ import com.vitorpamplona.amethyst.model.TopFilter 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 @@ -70,13 +69,7 @@ fun NappletsTopBar( NappletsTopNavFilterBar(accountViewModel) } }, - navigationIcon = { - if (nav.canPop()) { - IconButton(onClick = nav::popBack) { ArrowBackIcon() } - } else { - 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 new file mode 100644 index 0000000000..737c9439c9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt @@ -0,0 +1,144 @@ +/* + * 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.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.ui.feeds.ScrollStateKeys +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 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( + accountViewModel: AccountViewModel, + nav: INav, + modifier: Modifier = Modifier, +) { + 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) + + // 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, + ) + } + + HorizontalDivider(thickness = DividerThickness) + + 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/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. 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..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 @@ -48,6 +48,19 @@ val LocalDisappearingScaffoldPadding = compositionLocalOf { PaddingValues(0.dp) */ val LocalDisappearingBarState = compositionLocalOf { null } +/** + * 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. + * + * 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 } + /** * Merges two [PaddingValues] component-wise, resolving start/end against the current * [LocalLayoutDirection]. @@ -70,7 +83,21 @@ 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 — all folded into a single + * remember slot, since this runs in every feed on every recomposition. */ @Composable -fun rememberFeedContentPadding(inner: PaddingValues): PaddingValues = rememberMergedPadding(LocalDisappearingScaffoldPadding.current, inner) +fun rememberFeedContentPadding(inner: PaddingValues): PaddingValues { + val outer = LocalDisappearingScaffoldPadding.current + val sidePadding = LocalFeedSidePadding.current + 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(), + ) + } +}