mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
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:
@@ -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"
|
||||
|
||||
+21
-5
@@ -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) }
|
||||
|
||||
+106
-19
@@ -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
|
||||
|
||||
+60
-27
@@ -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,
|
||||
|
||||
+130
@@ -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)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -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.
|
||||
|
||||
+67
@@ -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() }
|
||||
+88
-43
@@ -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() }
|
||||
}
|
||||
|
||||
|
||||
+22
-12
@@ -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,
|
||||
|
||||
+140
-31
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+8
-3
@@ -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,
|
||||
) {
|
||||
|
||||
+30
-36
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-6
@@ -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,
|
||||
|
||||
+4
-1
@@ -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 {
|
||||
|
||||
+2
-6
@@ -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,
|
||||
|
||||
+2
-9
@@ -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))
|
||||
|
||||
+9
-2
@@ -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)
|
||||
|
||||
|
||||
+144
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -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),
|
||||
|
||||
+9
-2
@@ -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.
|
||||
|
||||
+29
-2
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user