Merge pull request #3553 from vitorpamplona/claude/large-screen-layout-v5otrb

Adaptive large-screen layout: nav rail, permanent drawer, notification panel, reading-column cap
This commit is contained in:
Vitor Pamplona
2026-07-14 11:19:10 -04:00
committed by GitHub
26 changed files with 1149 additions and 244 deletions
@@ -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"
@@ -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(
@@ -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,
)
}
}
@@ -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<Route.Home> { HomeScreen(accountViewModel, nav) }
composableCapped<Route.Home> { HomeScreen(accountViewModel, nav) }
composable<Route.Message> { MessagesScreen(accountViewModel, nav) }
composable<Route.Video> { VideoScreen(accountViewModel, nav) }
composableCapped<Route.Video> { VideoScreen(accountViewModel, nav) }
composableArgs<Route.Discover> { DiscoverScreen(it.initialTab, accountViewModel, nav) }
composableArgs<Route.Notification> { NotificationScreen(it.scrollToEventId, accountViewModel, nav) }
composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) }
@@ -382,10 +404,10 @@ fun BuildNavigation(
composableFromEnd<Route.SoftwareApps> { SoftwareAppsScreen(accountViewModel, nav) }
composableFromEnd<Route.Napplets> { NappletsScreen(accountViewModel, nav) }
composableFromEnd<Route.Nsites> { NsitesScreen(accountViewModel, nav) }
composableFromEnd<Route.Browser> { BrowserScreen(accountViewModel, nav) }
composableFromEnd<Route.Browser>(capWidth = false) { BrowserScreen(accountViewModel, nav) }
composableFromEnd<Route.FavoriteApps> { FavoriteAppsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.WebApp> { WebAppScreen(it.url, accountViewModel, nav) }
composableFromEndArgs<Route.NostrApp> { NostrAppScreen(it.coordinate, accountViewModel, nav) }
composableFromEndArgs<Route.WebApp>(capWidth = false) { WebAppScreen(it.url, accountViewModel, nav) }
composableFromEndArgs<Route.NostrApp>(capWidth = false) { NostrAppScreen(it.coordinate, accountViewModel, nav) }
composableFromEnd<Route.ConnectedApps> { ConnectedAppsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.ConnectedAppDetail> { ConnectedAppDetailScreen(it.coordinate, accountViewModel, nav) }
composableFromEnd<Route.RelayAuthSettings> { RelayAuthSettingsScreen(accountViewModel, nav) }
@@ -424,7 +446,7 @@ fun BuildNavigation(
composableFromEndArgs<Route.NewMusicPlaylist> { NewMusicPlaylistScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) }
composableFromEndArgs<Route.AddToMusicPlaylist> { AddToMusicPlaylistSheet(trackAddress = it.trackAddress, accountViewModel = accountViewModel, nav = nav) }
composableFromEnd<Route.NewHlsVideo> { NewHlsVideoScreen(accountViewModel, nav) }
composable<Route.Chess> { ChessLobbyScreen(accountViewModel, nav) }
composableCapped<Route.Chess> { ChessLobbyScreen(accountViewModel, nav) }
composableFromEnd<Route.Wallet> { WalletScreen(accountViewModel, nav) }
composableFromEndArgs<Route.WalletSend> { WalletSendScreen(it.walletId, accountViewModel, nav) }
@@ -477,7 +499,7 @@ fun BuildNavigation(
composableFromBottomArgs<Route.TopUpMint> { TopUpMintScreen(it.mintUrl, accountViewModel, nav) }
composableFromBottomArgs<Route.EditProfile> { NewUserMetadataScreen(nav, accountViewModel) }
composable<Route.Search> { SearchScreen(accountViewModel, nav) }
composableCapped<Route.Search> { SearchScreen(accountViewModel, nav) }
composableFromEnd<Route.AllSettings> { AllSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.AccountBackup> { AccountBackupScreen(accountViewModel, nav) }
@@ -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<Boolean>(BOTTOM_NAV_ROOT_KEY) == true
inline fun <reified T : Any> 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 <reified T : Any> NavGraphBuilder.composableCapped(noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit) {
composable<T> { entry ->
CappedScreenContent { content(entry) }
}
}
inline fun <reified T : Any> NavGraphBuilder.composableFromEnd(
capWidth: Boolean = true,
noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit,
) {
composable<T>(
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 <reified T : Any> NavGraphBuilder.composableFromEndArgs(noinline content: @Composable AnimatedContentScope.(T) -> Unit) {
composableFromEnd<T> {
inline fun <reified T : Any> NavGraphBuilder.composableFromEndArgs(
capWidth: Boolean = true,
noinline content: @Composable AnimatedContentScope.(T) -> Unit,
) {
composableFromEnd<T>(capWidth) {
content(it.toRoute<T>())
}
}
inline fun <reified T : Any> NavGraphBuilder.composableFromBottom(noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit) {
inline fun <reified T : Any> NavGraphBuilder.composableFromBottom(
capWidth: Boolean = true,
noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit,
) {
composable<T>(
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 <reified T : Any> NavGraphBuilder.composableFromBottomArgs(noinline content: @Composable AnimatedContentScope.(T) -> Unit) {
composableFromBottom<T> {
inline fun <reified T : Any> NavGraphBuilder.composableFromBottomArgs(
capWidth: Boolean = true,
noinline content: @Composable AnimatedContentScope.(T) -> Unit,
) {
composableFromBottom<T>(capWidth) {
content(it.toRoute())
}
}
inline fun <reified T : Any> NavGraphBuilder.composableArgs(noinline content: @Composable AnimatedContentScope.(T) -> Unit) {
composable<T> {
content(it.toRoute())
inline fun <reified T : Any> NavGraphBuilder.composableArgs(
capWidth: Boolean = true,
noinline content: @Composable AnimatedContentScope.(T) -> Unit,
) {
composable<T> { 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
@@ -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<BottomBarEntry>,
@@ -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,
@@ -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)) },
)
}
}
}
}
}
}
@@ -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.
@@ -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() }
@@ -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()
}
}
}
@@ -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)
}
@@ -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()
@@ -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() }
}
@@ -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,
@@ -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<Route.Home>() || dest.hasRoute<Route.Message>()
} ?: 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<Route.Home>() || dest.hasRoute<Route.Message>()
} ?: 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<Route.Notification>() != 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()
}
}
@@ -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,
) {
@@ -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,
)
}
}
@@ -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,
@@ -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 {
@@ -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,
@@ -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))
@@ -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)
@@ -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,
)
}
}
}
}
@@ -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),
@@ -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.
@@ -48,6 +48,19 @@ val LocalDisappearingScaffoldPadding = compositionLocalOf { PaddingValues(0.dp)
*/
val LocalDisappearingBarState = compositionLocalOf<DisappearingBarState?> { 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(),
)
}
}