diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt index 930f08d4d9..dedc9b5521 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt @@ -24,7 +24,6 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth @@ -185,67 +184,64 @@ private fun RenderBottomMenu( containerColor = MaterialTheme.colorScheme.background, tonalElevation = Size0dp, ) { - // Render in the user's saved order, built-ins and favorites interleaved. + // Render in the user's saved order — built-ins, favorites and pinned groups interleaved. + // Each entry resolves to a shared BottomBarSlot (route + icon), the same one the rail uses. items.forEach { entry -> - when (entry) { - is BottomBarEntry.BuiltIn -> { - val def = NavBarCatalog[entry.item] ?: return@forEach - val destination = remember(def, accountViewModel) { def.resolveRoute(accountViewModel) } - HasNewItemsIcon(destination == selectedRoute, def, destination, accountViewModel, nav) - } - 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) - } - FavoriteNavItem(destination == selectedRoute, fav, rememberFavoriteIconModel(fav), destination, nav) - } - } + val slot = rememberBottomBarSlot(entry, favoritesById, accountViewModel) ?: return@forEach + val selected = slot.route == selectedRoute + NavigationBarItem( + alwaysShowLabel = false, + icon = { slot.icon(selected) }, + selected = selected, + onClick = { nav(slot.route) }, + ) } } } } -@Composable -private fun RowScope.FavoriteNavItem( - selected: Boolean, - fav: FavoriteApp, - iconModel: Any?, - destination: Route, - nav: (Route) -> Unit, -) { - NavigationBarItem( - alwaysShowLabel = false, - icon = { FavoriteEntryIcon(fav, selected, iconModel) }, - // No label — favorite tabs match the built-in items, which show icon only. - selected = selected, - onClick = { nav(destination) }, - ) -} +/** + * A resolved bottom-bar/rail slot: the [route] to navigate to and the [icon] to draw for a given + * selected state. Built from a [BottomBarEntry] by [rememberBottomBarSlot], so the phone bar and the + * large-screen rail render every entry kind (built-in, favorite, pinned group) through one code path. + */ +class BottomBarSlot( + val route: Route, + val icon: @Composable (selected: Boolean) -> Unit, +) +/** Resolves an entry to its live [BottomBarSlot], or null if it no longer resolves (deleted favorite, etc.). */ @Composable -private fun RowScope.HasNewItemsIcon( - selected: Boolean, - def: NavBarItemDef, - destination: Route, +internal fun rememberBottomBarSlot( + entry: BottomBarEntry, + favoritesById: Map, accountViewModel: AccountViewModel, - nav: (Route) -> Unit, -) { - NavigationBarItem( - alwaysShowLabel = false, - icon = { - NotifiableIcon( - selected, - def, - destination, - accountViewModel, - ) - }, - selected = selected, - onClick = { nav(destination) }, - ) +): BottomBarSlot? { + return when (entry) { + is BottomBarEntry.BuiltIn -> { + val def = NavBarCatalog[entry.item] ?: return null + val route = remember(def, accountViewModel) { def.resolveRoute(accountViewModel) } + BottomBarSlot(route) { selected -> NotifiableIcon(selected, def, route, accountViewModel) } + } + is BottomBarEntry.Favorite -> { + val fav = favoritesById[entry.favoriteId] ?: return null + val route = + when (fav) { + is FavoriteApp.WebApp -> Route.WebApp(fav.url) + is FavoriteApp.NostrApp -> Route.NostrApp(fav.coordinate) + } + val iconModel = rememberFavoriteIconModel(fav) + BottomBarSlot(route) { selected -> FavoriteEntryIcon(fav, selected, iconModel) } + } + is BottomBarEntry.PublicChat, + is BottomBarEntry.RelayGroup, + is BottomBarEntry.Concord, + -> { + val display = rememberGroupEntryDisplay(entry, accountViewModel) ?: return null + // A pinned chat/group shows its avatar, like the favorite-app tabs — icon only. + BottomBarSlot(display.route) { Box(Size27Modifier, contentAlignment = Alignment.Center) { GroupEntryAvatar(display, 25.dp, accountViewModel) } } + } + } } /** The icon block for a built-in entry (catalog icon + new-items dot), shared by the bottom bar and the rail. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt index be8c77035e..90ad79490a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt @@ -32,9 +32,10 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavDestination import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.NavHostController 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 @@ -75,56 +76,42 @@ fun AppNavigationRail( modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally, ) { + // Same entry list + resolution as the phone bottom bar; only the item chrome and the + // selection source (live back stack vs the bar's passed-in route) differ per surface. 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)) }, - ) - } - } + val slot = rememberBottomBarSlot(entry, favoritesById, accountViewModel) ?: return@forEach + val selected = remember(navBackStackEntry, slot.route) { railSelected(slot.route, nav.controller, currentDestination) } + NavigationRailItem( + selected = selected, + onClick = { + if (selected) { + reselectCoordinator.reselect(slot.route) + } else { + nav.navBottomBar(slot.route) + } + }, + icon = { slot.icon(selected) }, + ) } } } } + +/** + * Whether [route] is the rail's currently-selected destination. Parameterized routes (favorites and + * pinned groups carry a url / coordinate / id) must match the full route — class matching alone would + * light up every pinned app or group of the same kind; parameterless object routes match on class. + */ +private fun railSelected( + route: Route, + controller: NavHostController, + currentDestination: NavDestination?, +): Boolean = + when (route) { + is Route.WebApp -> getRouteWithArguments(Route.WebApp::class, controller) == route + is Route.NostrApp -> getRouteWithArguments(Route.NostrApp::class, controller) == route + is Route.PublicChatChannel -> getRouteWithArguments(Route.PublicChatChannel::class, controller) == route + is Route.RelayGroup -> getRouteWithArguments(Route.RelayGroup::class, controller) == route + is Route.ConcordServer -> getRouteWithArguments(Route.ConcordServer::class, controller) == route + else -> currentDestination?.hasRoute(route::class) == true + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarEntry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarEntry.kt index 3911885096..6024211f8b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarEntry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarEntry.kt @@ -25,8 +25,9 @@ import kotlinx.serialization.Serializable /** * One slot in the bottom navigation bar. A single ordered list of these (persisted in - * [com.vitorpamplona.amethyst.model.UiSettings.bottomBarItems]) holds **both** built-in destinations - * and favorite apps, so the user can pin and drag-reorder them together in one list. + * [com.vitorpamplona.amethyst.model.UiSettings.bottomBarItems]) holds built-in destinations, + * favorite apps, and individual joined chats/groups, so the user can pin and drag-reorder them + * together in one list. * * - [BuiltIn] resolves its [Route][com.vitorpamplona.amethyst.ui.navigation.routes.Route] (and its * icon/label/notification badge) through [NavBarCatalog], like before. @@ -34,6 +35,9 @@ import kotlinx.serialization.Serializable * its stable id (which already encodes the route's parameters — the `url` or addressable * `coordinate`); the bar resolves it to a live favorite for its icon/label and to * `Route.WebApp` / `Route.NostrApp` for navigation. + * - [PublicChat], [RelayGroup] and [Concord] each pin one specific joined chat the user picked from + * their joined list (NIP-28 channel, NIP-29 relay group, or a Concord community). The bar resolves + * each to the chat's avatar + name from the local cache and to its chat/home route for navigation. */ @Serializable sealed interface BottomBarEntry { @@ -50,7 +54,43 @@ sealed interface BottomBarEntry { data class Favorite( val favoriteId: String, ) : BottomBarEntry + + /** A pinned NIP-28 public chat channel, keyed by its channel event id (hex). */ + @Serializable + @SerialName("publicChat") + data class PublicChat( + val channelId: String, + ) : BottomBarEntry + + /** A pinned NIP-29 relay group, keyed by the (group id, host relay) pair — the group's real key. */ + @Serializable + @SerialName("relayGroup") + data class RelayGroup( + val groupId: String, + val relayUrl: String, + ) : BottomBarEntry + + /** A pinned Concord community, keyed by its community id; opens the community's channel list. */ + @Serializable + @SerialName("concord") + data class Concord( + val communityId: String, + ) : BottomBarEntry } +/** + * Stable, type-discriminated identity for an entry — used as a Compose list key and for membership + * checks / de-duplication in the settings picker. + */ +val BottomBarEntry.stableKey: String + get() = + when (this) { + is BottomBarEntry.BuiltIn -> "builtIn:${item.name}" + is BottomBarEntry.Favorite -> "favorite:$favoriteId" + is BottomBarEntry.PublicChat -> "publicChat:$channelId" + is BottomBarEntry.RelayGroup -> "relayGroup:$relayUrl|$groupId" + is BottomBarEntry.Concord -> "concord:$communityId" + } + /** The favorite-app ids in this bottom-bar config — the apps that should be kept warm as bottom-row tabs. */ fun List.favoriteIds(): List = mapNotNull { (it as? BottomBarEntry.Favorite)?.favoriteId } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/GroupBottomBarEntries.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/GroupBottomBarEntries.kt new file mode 100644 index 0000000000..242dcf9eef --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/GroupBottomBarEntries.kt @@ -0,0 +1,210 @@ +/* + * 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.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.Dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.Channel +import com.vitorpamplona.amethyst.commons.model.ChannelState +import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordImageModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId + +/** + * The resolved presentation of a pinned chat/group [BottomBarEntry] — enough to render its avatar in + * the bottom bar and its row in the settings picker, and to navigate when tapped. Resolved live from + * the local cache, so the name/avatar fill in as the group's metadata arrives. + */ +@Immutable +data class GroupEntryDisplay( + val label: String, + val robotSeed: String, + val model: String?, + val route: Route, +) + +/** + * Observes a channel's metadata for a display row. [subscribe] gates the relay REQ: + * - the live bottom bar / rail passes true — a pinned group keeps a REQ open so its name/avatar + * refresh even if the group is never opened (bounded by the handful of pinned slots); + * - the settings picker passes false — it reads whatever metadata is already cached (filled by the + * chats/group screens) so expanding a category with many joined groups doesn't fan out into one + * subscription per row. + */ +@Composable +private fun observeChannelMetadata( + channel: Channel, + accountViewModel: AccountViewModel, + subscribe: Boolean, +): State { + if (subscribe) ChannelFinderFilterAssemblerSubscription(channel, accountViewModel) + return channel + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() +} + +@Composable +fun rememberPublicChatEntryDisplay( + entry: BottomBarEntry.PublicChat, + accountViewModel: AccountViewModel, + subscribe: Boolean = true, +): GroupEntryDisplay { + val channel = remember(entry.channelId) { LocalCache.getOrCreatePublicChatChannel(entry.channelId) } + val state by observeChannelMetadata(channel, accountViewModel, subscribe) + val current = (state?.channel as? PublicChatChannel) ?: channel + return GroupEntryDisplay( + label = current.toBestDisplayName(), + robotSeed = entry.channelId, + model = current.profilePicture(), + route = Route.PublicChatChannel(entry.channelId), + ) +} + +@Composable +fun rememberRelayGroupEntryDisplay( + entry: BottomBarEntry.RelayGroup, + accountViewModel: AccountViewModel, + subscribe: Boolean = true, +): GroupEntryDisplay { + val relay = remember(entry.relayUrl) { RelayUrlNormalizer.normalizeOrNull(entry.relayUrl) } + val channel = remember(entry.groupId, relay) { relay?.let { LocalCache.getOrCreateRelayGroupChannel(GroupId(entry.groupId, it)) } } + // Always call the observer (with a null channel when the relay won't normalize) so the composable + // call structure is unconditional; a null channel just yields a null state and the id fallback. + val state by observeChannelMetadataOrNull(channel, accountViewModel, subscribe) + val current = (state?.channel as? RelayGroupChannel) ?: channel + + // The name from the group's own kind-39000 metadata, once it has loaded. + val metaName = current?.event?.name()?.ifBlank { null } + // Otherwise the name the user's joined-groups list stored for this group (the NIP-51 + // ["group", id, relay, name] tag) — so the row reads as a name even before 39000 is fetched, + // which the read-only settings picker never does. + val joined by accountViewModel.account.relayGroupList.liveRelayGroupList + .collectAsStateWithLifecycle() + val tagName = + remember(joined, entry) { + joined.firstOrNull { it.groupId == entry.groupId && it.relayUrl == entry.relayUrl }?.name?.ifBlank { null } + } + + return GroupEntryDisplay( + label = metaName ?: tagName ?: entry.groupId, + robotSeed = entry.groupId, + model = current?.profilePicture(), + route = Route.RelayGroup(entry.groupId, entry.relayUrl), + ) +} + +/** [observeChannelMetadata] tolerant of a null channel (unresolvable relay), so callers avoid an early return. */ +@Composable +private fun observeChannelMetadataOrNull( + channel: Channel?, + accountViewModel: AccountViewModel, + subscribe: Boolean, +): State { + if (channel == null) return remember { mutableStateOf(null) } + return observeChannelMetadata(channel, accountViewModel, subscribe) +} + +@Composable +fun rememberConcordEntryDisplay( + entry: BottomBarEntry.Concord, + accountViewModel: AccountViewModel, +): GroupEntryDisplay { + val account = accountViewModel.account + // Recompute the folded metadata (name / icon) whenever a Control Plane folds. + val revision by account.concordSessions.revision.collectAsStateWithLifecycle() + val communities by account.concordChannelList.liveCommunities.collectAsStateWithLifecycle() + + val session = remember(entry.communityId, revision) { account.concordSessions.sessionFor(entry.communityId) } + val metadata = + session + ?.state + ?.value + .takeIf { revision >= 0 } + ?.metadata + + val fallbackName = remember(communities, entry.communityId) { communities.firstOrNull { it.id == entry.communityId }?.name?.ifBlank { null } } + val label = metadata?.name?.takeIf { it.isNotBlank() } ?: fallbackName ?: stringRes(R.string.concord_home_title) + val model = rememberConcordImageModel(metadata?.icon, accountViewModel) + + return GroupEntryDisplay( + label = label, + robotSeed = entry.communityId, + model = model, + route = Route.ConcordServer(entry.communityId), + ) +} + +/** + * Resolves any chat/group entry to its live display, or null for a non-group entry. [subscribe] is + * forwarded to the channel observers: true (the default) keeps a relay REQ open — used by the live + * bar/rail; false reads only cached metadata — used by the settings picker (see [observeChannelMetadata]). + */ +@Composable +fun rememberGroupEntryDisplay( + entry: BottomBarEntry, + accountViewModel: AccountViewModel, + subscribe: Boolean = true, +): GroupEntryDisplay? = + when (entry) { + is BottomBarEntry.PublicChat -> rememberPublicChatEntryDisplay(entry, accountViewModel, subscribe) + is BottomBarEntry.RelayGroup -> rememberRelayGroupEntryDisplay(entry, accountViewModel, subscribe) + is BottomBarEntry.Concord -> rememberConcordEntryDisplay(entry, accountViewModel) + is BottomBarEntry.BuiltIn -> null + is BottomBarEntry.Favorite -> null + } + +/** The circular avatar for a pinned chat/group, shared by the bottom bar and the settings picker. */ +@Composable +fun GroupEntryAvatar( + display: GroupEntryDisplay, + size: Dp, + accountViewModel: AccountViewModel, +) { + RobohashFallbackAsyncImage( + robot = display.robotSeed, + model = display.model, + contentDescription = display.label, + modifier = Modifier.size(size).clip(CircleShape), + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = false, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index ea83801688..5845fe237c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -437,6 +437,100 @@ val DrawerYouItems: List = NavBarItem.WALLET, ) +/** + * A titled, collapsible group of selectable destinations in the bottom-bar settings picker. The + * catalog's [linkedMapOf] insertion order is hand-maintained and reads as scattered in the flat + * picker; these curated categories give the "Available" list a deliberate, grouped order instead. + * Every [NavBarItem] in [NavBarCatalog] appears in exactly one category (see [BottomBarCategories]). + */ +data class NavBarCategory( + val titleRes: Int, + val items: List, +) + +/** + * The ordered, grouped catalog for the settings picker. Kept in sync with [NavBarCatalog]: every + * catalog id must appear here exactly once (asserted by BottomBarCategoriesTest), so a newly added + * screen surfaces in the picker under a deliberate heading rather than vanishing. + */ +val BottomBarCategories: List = + listOf( + NavBarCategory( + R.string.bottom_bar_category_main, + listOf( + NavBarItem.HOME, + NavBarItem.MESSAGES, + NavBarItem.VIDEO, + NavBarItem.DISCOVER, + NavBarItem.NOTIFICATIONS, + ), + ), + NavBarCategory( + R.string.bottom_bar_category_chats, + listOf( + NavBarItem.PUBLIC_CHATS, + NavBarItem.RELAY_GROUPS, + NavBarItem.CONCORD, + ), + ), + NavBarCategory( + R.string.bottom_bar_category_you, + listOf( + NavBarItem.PROFILE, + NavBarItem.MY_LISTS, + NavBarItem.BOOKMARKS, + NavBarItem.WEB_BOOKMARKS, + NavBarItem.DRAFTS, + NavBarItem.SCHEDULED_POSTS, + NavBarItem.INTEREST_SETS, + NavBarItem.FAVORITE_ALGO_FEEDS, + NavBarItem.EMOJI_PACKS, + NavBarItem.WALLET, + ), + ), + NavBarCategory( + R.string.bottom_bar_category_feeds, + listOf( + NavBarItem.ARTICLES, + NavBarItem.LONGS, + NavBarItem.PICTURES, + NavBarItem.SHORTS, + NavBarItem.LIVE_STREAMS, + NavBarItem.NESTS, + NavBarItem.PODCASTS, + NavBarItem.PODCAST_EPISODES, + NavBarItem.MUSIC_TRACKS, + NavBarItem.MUSIC_PLAYLISTS, + NavBarItem.POLLS, + NavBarItem.PRODUCTS, + NavBarItem.WORKOUTS, + NavBarItem.GIT_REPOSITORIES, + NavBarItem.COMMUNITIES, + NavBarItem.FOLLOW_PACKS, + NavBarItem.CALENDARS, + NavBarItem.CALENDAR_COLLECTIONS, + NavBarItem.BADGES, + NavBarItem.EMOJI_SETS, + ), + ), + NavBarCategory( + R.string.bottom_bar_category_apps, + listOf( + NavBarItem.BROWSER, + NavBarItem.FAVORITE_APPS, + NavBarItem.SOFTWARE_APPS, + NavBarItem.NAPPLETS, + NavBarItem.NSITES, + ), + ), + NavBarCategory( + R.string.bottom_bar_category_other, + listOf( + NavBarItem.SETTINGS, + ), + ), + ) + val DrawerFeedsItems: List = listOfNotNull( NavBarItem.ARTICLES, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarExtensibleWithBackButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarExtensibleWithBackButton.kt index ed5f975e6e..2b768efb6d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarExtensibleWithBackButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarExtensibleWithBackButton.kt @@ -45,12 +45,20 @@ fun TopBarExtensibleWithBackButton( title: @Composable RowScope.() -> Unit, extendableRow: (@Composable () -> Unit)? = null, actions: @Composable RowScope.() -> Unit = {}, + // A back arrow is meaningless when there's nothing to pop (e.g. this screen is a bottom-nav + // root); callers pass nav.canPop() so the arrow hides and the bottom bar takes its place. + showBackButton: Boolean = true, popBack: () -> Unit, ) { MyExtensibleTopAppBar( title = title, extendableRow = extendableRow, - navigationIcon = { IconButton(onClick = popBack) { ArrowBackIcon() } }, + navigationIcon = + if (showBackButton) { + { IconButton(onClick = popBack) { ArrowBackIcon() } } + } else { + null + }, actions = actions, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index a6cfb14e46..84121fe740 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -62,6 +62,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.components.util.setText +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -168,8 +169,11 @@ fun ConcordChannelListScreen( Text(title, maxLines = 1) }, navigationIcon = { - IconButton(onClick = { nav.popBack() }) { - SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.back)) + // Back arrow only when pushed from elsewhere; as a bottom-nav tab the bar takes its place. + if (nav.canPop()) { + IconButton(onClick = { nav.popBack() }) { + SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.back)) + } } }, actions = { @@ -207,6 +211,13 @@ fun ConcordChannelListScreen( }, ) }, + bottomBar = { + // Renders only when this is a bottom-nav root (AppBottomBar hides itself when canPop), + // so a pinned Concord community works both as a pushed detail and as a bottom-nav tab. + AppBottomBar(Route.ConcordServer(communityId), nav, accountViewModel) { route -> + if (route != Route.ConcordServer(communityId)) nav.navBottomBar(route) + } + }, floatingActionButton = { if (canManageChannels) { FloatingActionButton( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt index d4286b56f0..5a7c6aca94 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt @@ -26,7 +26,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.PublicChatTopBar @@ -44,6 +46,7 @@ fun PublicChatChannelScreen( val draft = remember(draftId) { draftId?.let { accountViewModel.getNoteIfExists(it) } } val replyTo = remember(replyToId) { replyToId?.let { accountViewModel.checkGetOrCreateNote(it) } } + val selfRoute = remember(channelId) { Route.PublicChatChannel(channelId) } DisappearingScaffold( isInvertedLayout = true, @@ -52,6 +55,13 @@ fun PublicChatChannelScreen( PublicChatTopBar(it, accountViewModel, nav) } }, + // Renders only when this is a bottom-nav root (AppBottomBar hides itself when canPop), + // so a pinned public chat works both as a pushed detail and as a bottom-nav tab. + bottomBar = { + AppBottomBar(selfRoute, nav, accountViewModel) { route -> + if (route != selfRoute) nav.navBottomBar(route) + } + }, accountViewModel = accountViewModel, allowBarHide = false, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatTopBar.kt index be8d91c1e0..84343f3ef0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatTopBar.kt @@ -43,6 +43,8 @@ fun PublicChatTopBar( extendableRow = { LongPublicChatChannelHeader(baseChannel = baseChannel, accountViewModel = accountViewModel, nav = nav) }, + // No back arrow when opened as a bottom-nav tab (nothing to pop); the bottom bar shows instead. + showBackButton = nav.canPop(), popBack = nav::popBack, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChatScreen.kt index 8e3b894cd0..045d32638d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChatScreen.kt @@ -26,7 +26,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar 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.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @@ -46,6 +48,7 @@ fun RelayGroupChatScreen( val channelId = remember(id, relay) { GroupId(id, relay) } val draft = remember(draftId) { draftId?.let { accountViewModel.getNoteIfExists(it) } } val replyTo = remember(replyToId) { replyToId?.let { accountViewModel.checkGetOrCreateNote(it) } } + val selfRoute = remember(id, relayUrl) { Route.RelayGroup(id, relayUrl) } DisappearingScaffold( isInvertedLayout = true, @@ -54,6 +57,13 @@ fun RelayGroupChatScreen( RelayGroupTopBar(it, inviteCode, accountViewModel, nav) } }, + // Renders only when this is a bottom-nav root (AppBottomBar hides itself when canPop), + // so a pinned relay group works both as a pushed detail and as a bottom-nav tab. + bottomBar = { + AppBottomBar(selfRoute, nav, accountViewModel) { route -> + if (route != selfRoute) nav.navBottomBar(route) + } + }, accountViewModel = accountViewModel, allowBarHide = false, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt index 186be5f1f5..ed000cffd1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt @@ -243,6 +243,8 @@ fun RelayGroupTopBar( } } }, + // No back arrow when opened as a bottom-nav tab (nothing to pop); the bottom bar shows instead. + showBackButton = nav.canPop(), popBack = nav::popBack, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt index e3b2eb83ee..bd7fce2cef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt @@ -20,40 +20,55 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.ScrollState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -61,13 +76,21 @@ import androidx.compose.ui.zIndex import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry +import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarCategories import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry -import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries +import com.vitorpamplona.amethyst.ui.navigation.bottombars.GroupEntryAvatar +import com.vitorpamplona.amethyst.ui.navigation.bottombars.GroupEntryDisplay import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog +import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCategory +import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem +import com.vitorpamplona.amethyst.ui.navigation.bottombars.rememberFavoriteIconModel +import com.vitorpamplona.amethyst.ui.navigation.bottombars.rememberGroupEntryDisplay +import com.vitorpamplona.amethyst.ui.navigation.bottombars.stableKey import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton @@ -77,6 +100,23 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow +/** The chat catalog items whose picker row expands to a per-item picker (favorites / joined groups). */ +private val ExpandableItems = + setOf( + NavBarItem.BROWSER, + NavBarItem.PUBLIC_CHATS, + NavBarItem.RELAY_GROUPS, + NavBarItem.CONCORD, + ) + +/** Soft guidance, not a hard cap: a Material bottom bar reads best at ~5 tabs. */ +private const val RECOMMENDED_SLOTS = 5 + +// Reveal expandable sections by unrolling straight down from the top edge (the default AnimatedVisibility +// enter also expands horizontally from the bottom-end, which reads as a diagonal slide from the top-left). +private val SectionExpand = expandVertically(expandFrom = Alignment.Top) + fadeIn() +private val SectionCollapse = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut() + @Composable @Preview(device = "spec:width=2100px,height=2340px,dpi=440") fun BottomBarSettingsScreenPreview() { @@ -107,327 +147,690 @@ fun BottomBarSettingsScreen( @Composable fun BottomBarSettingsContent(accountViewModel: AccountViewModel) { val bottomBarItemsFlow = accountViewModel.settings.uiSettingsFlow.bottomBarItems - // Favorite apps appear in the SAME list as the built-in destinations, so they can be pinned and - // drag-reordered together. Re-seed when the favorites set changes (e.g. a favorite was deleted). - val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() - var items by remember(favorites) { mutableStateOf(initialRows(bottomBarItemsFlow.value, favorites)) } + val savedItems by bottomBarItemsFlow.collectAsStateWithLifecycle() - fun save(newItems: List) { - items = newItems - bottomBarItemsFlow.tryEmit( - newItems.filter { it.pinned }.map { it.entry }, - ) - } + // All pin/unpin/reorder logic lives in the holder (unit-tested); the composable only renders and + // forwards events. syncFrom re-seeds when the saved list changes elsewhere without clobbering a drag. + val state = remember { BottomBarSettingsState(savedItems) { bottomBarItemsFlow.tryEmit(it) } } + LaunchedEffect(savedItems) { state.syncFrom(savedItems) } - var draggedItemIndex by remember { mutableIntStateOf(-1) } - var dragOffset by remember { mutableFloatStateOf(0f) } - val itemHeights = remember { mutableStateMapOf() } - val isDragging = draggedItemIndex >= 0 - val scrollState = remember { ScrollState(0) } + val pinned = state.pinned + val pinnedKeys = remember(pinned) { state.pinnedKeys() } + + val expandedCategories = remember { mutableStateMapOf() } + val expandedItems = remember { mutableStateMapOf() } Column( modifier = Modifier .fillMaxSize() - .verticalScroll(scrollState, enabled = !isDragging), + .verticalScroll(rememberScrollState()), ) { - Spacer(modifier = Modifier.height(16.dp)) + Spacer(Modifier.height(12.dp)) - Text( - text = stringRes(R.string.bottom_bar_settings_description), - style = MaterialTheme.typography.bodyMedium, - color = Color.Gray, - modifier = Modifier.padding(bottom = 8.dp, start = Size20dp, end = Size20dp), - ) + // --- The editable bar: a real preview you drag to reorder and tap ✕ to remove from. --- + EditableBarCard(state, pinned, accountViewModel) Row( - modifier = - Modifier - .fillMaxWidth() - .padding(bottom = 8.dp, start = Size20dp, end = Size20dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp), horizontalArrangement = Arrangement.End, ) { - TextButton( - onClick = { - draggedItemIndex = -1 - dragOffset = 0f - save(initialRows(DefaultBottomBarEntries, favorites)) - }, - ) { + TextButton(onClick = { state.restoreDefault() }) { Text(stringRes(R.string.bottom_bar_settings_restore_default)) } } - items.forEachIndexed { index, row -> - val display = rowDisplay(row.entry, favorites) - val rowIsDragging = draggedItemIndex == index - val targetElevation = if (rowIsDragging) 8f else 0f - val animatedElevation by animateFloatAsState( - targetValue = targetElevation, - label = "dragElevation", + Spacer(Modifier.height(4.dp)) + + // --- Available catalogue, grouped into collapsible category cards. --- + SectionHeader(title = stringRes(R.string.bottom_bar_settings_available)) + + BottomBarCategories.forEach { category -> + CategoryCard( + category = category, + pinnedKeys = pinnedKeys, + expanded = expandedCategories[category.titleRes] ?: false, + onToggleExpand = { expandedCategories[category.titleRes] = !(expandedCategories[category.titleRes] ?: false) }, + expandedItems = expandedItems, + accountViewModel = accountViewModel, + onTogglePin = state::togglePin, ) + } - NavBarItemCard( - icon = display.icon, - label = display.label, - isDragging = rowIsDragging, - canDrag = row.pinned, - dragOffsetY = if (rowIsDragging) dragOffset else 0f, - elevation = animatedElevation, - pinned = row.pinned, - onTogglePinned = { - val newItems = items.toMutableList() - val toggled = row.copy(pinned = !row.pinned) - newItems.removeAt(index) - val insertIndex = - if (toggled.pinned) { - newItems.indexOfFirst { !it.pinned }.let { if (it < 0) newItems.size else it } - } else { - val firstUnpinned = newItems.indexOfFirst { !it.pinned } - if (firstUnpinned < 0) newItems.size else firstUnpinned - } - newItems.add(insertIndex, toggled) - save(newItems) - }, - onMeasured = { height -> - itemHeights[index] = height - }, - onDragStart = { - draggedItemIndex = index - dragOffset = 0f - }, - onDrag = { dragAmount -> - dragOffset += dragAmount + Spacer(Modifier.height(24.dp)) + } +} - val currentIndex = draggedItemIndex - if (currentIndex < 0) return@NavBarItemCard +// ------------------------------------------------------------------------------------------------ +// The editable preview bar — WYSIWYG: this IS the reorder & remove surface. +// ------------------------------------------------------------------------------------------------ - // Can only swap among pinned items (row.pinned == true). - if (dragOffset < 0 && currentIndex > 0 && items[currentIndex - 1].pinned) { - val aboveHeight = itemHeights[currentIndex - 1] ?: 0f - if (-dragOffset > aboveHeight / 2f) { - val newItems = items.toMutableList() - val temp = newItems[currentIndex - 1] - newItems[currentIndex - 1] = newItems[currentIndex] - newItems[currentIndex] = temp - items = newItems +@Composable +private fun EditableBarCard( + state: BottomBarSettingsState, + pinned: List, + accountViewModel: AccountViewModel, +) { + val accent = MaterialTheme.colorScheme.primary + Surface( + shape = RoundedCornerShape(22.dp), + color = accent.copy(alpha = 0.07f), + border = BorderStroke(1.dp, accent.copy(alpha = 0.22f)), + modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp, vertical = 4.dp), + ) { + Column(Modifier.padding(14.dp)) { + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringRes(R.string.bottom_bar_settings_pinned), + style = MaterialTheme.typography.labelMedium, + color = accent, + fontWeight = FontWeight.Bold, + ) + Text( + text = "${pinned.size} / $RECOMMENDED_SLOTS", + style = MaterialTheme.typography.labelMedium, + color = if (pinned.size > RECOMMENDED_SLOTS) MaterialTheme.colorScheme.error else accent, + fontWeight = FontWeight.Bold, + ) + } - val h1 = itemHeights[currentIndex] - val h2 = itemHeights[currentIndex - 1] - if (h1 != null) itemHeights[currentIndex - 1] = h1 - if (h2 != null) itemHeights[currentIndex] = h2 - - dragOffset += aboveHeight - draggedItemIndex = currentIndex - 1 - } + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.background, + shadowElevation = 3.dp, + modifier = Modifier.fillMaxWidth(), + ) { + if (pinned.isEmpty()) { + Box(Modifier.fillMaxWidth().height(60.dp), contentAlignment = Alignment.Center) { + Text( + stringRes(R.string.bottom_bar_settings_pinned_empty), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) } + } else { + EditableBar(state, pinned, accountViewModel) + } + } - if (dragOffset > 0 && - currentIndex < items.lastIndex && - items[currentIndex + 1].pinned - ) { - val belowHeight = itemHeights[currentIndex + 1] ?: 0f - if (dragOffset > belowHeight / 2f) { - val newItems = items.toMutableList() - val temp = newItems[currentIndex + 1] - newItems[currentIndex + 1] = newItems[currentIndex] - newItems[currentIndex] = temp - items = newItems - - val h1 = itemHeights[currentIndex] - val h2 = itemHeights[currentIndex + 1] - if (h1 != null) itemHeights[currentIndex + 1] = h1 - if (h2 != null) itemHeights[currentIndex] = h2 - - dragOffset -= belowHeight - draggedItemIndex = currentIndex + 1 - } - } - }, - onDragEnd = { - draggedItemIndex = -1 - dragOffset = 0f - save(items) - }, - onDragCancel = { - draggedItemIndex = -1 - dragOffset = 0f - }, - modifier = - Modifier - .zIndex(if (rowIsDragging) 1f else 0f), + Text( + text = stringRes(R.string.bottom_bar_settings_reorder_hint), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), ) + } + } +} - val nextIsFirstUnpinned = - index < items.lastIndex && row.pinned && !items[index + 1].pinned - if (nextIsFirstUnpinned) { - SectionDivider(R.string.bottom_bar_settings_available) - } else if (index < items.lastIndex) { - HorizontalDivider(modifier = Modifier.padding(horizontal = Size20dp)) +@Composable +private fun EditableBar( + state: BottomBarSettingsState, + pinned: List, + accountViewModel: AccountViewModel, +) { + var draggedIndex by remember { mutableIntStateOf(-1) } + var dragOffsetX by remember { mutableFloatStateOf(0f) } + val widths = remember { mutableStateMapOf() } + + Row( + modifier = Modifier.fillMaxWidth().height(64.dp).padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + pinned.forEachIndexed { index, entry -> + // Key by identity so a swap MOVES the dragged tab (and its live gesture) instead of + // recomposing a different entry into this slot — which would restart its pointerInput + // (keyed on entry) and cancel the drag mid-swap. + key(entry.stableKey) { + val dragging = draggedIndex == index + PreviewTab( + entry = entry, + // The first tab is where the bar lands on open, so preview it as selected. + selected = index == 0, + dragging = dragging, + dragOffsetX = if (dragging) dragOffsetX else 0f, + accountViewModel = accountViewModel, + onRemove = { state.togglePin(entry) }, + onMeasured = { widths[index] = it }, + onDragStart = { + draggedIndex = index + dragOffsetX = 0f + }, + onDrag = { dx -> + dragOffsetX += dx + val current = draggedIndex + if (current < 0) return@PreviewTab + + if (dragOffsetX < 0 && current > 0) { + val leftW = widths[current - 1] ?: 0f + if (-dragOffsetX > leftW / 2f) { + state.moveTransient(current, current - 1) + dragOffsetX += leftW + draggedIndex = current - 1 + } + } + if (dragOffsetX > 0 && current < pinned.lastIndex) { + val rightW = widths[current + 1] ?: 0f + if (dragOffsetX > rightW / 2f) { + state.moveTransient(current, current + 1) + dragOffsetX -= rightW + draggedIndex = current + 1 + } + } + }, + onDragEnd = { + draggedIndex = -1 + dragOffsetX = 0f + state.commit() + }, + onDragCancel = { + draggedIndex = -1 + dragOffsetX = 0f + }, + ) } } - - Spacer(modifier = Modifier.height(16.dp)) } } -private data class Row( - val entry: BottomBarEntry, - val pinned: Boolean, -) - -/** Display (icon + label) for an entry, resolving built-ins via the catalog and favorites via the registry. */ -private class RowDisplay( - val icon: MaterialSymbol, - val label: String, -) - @Composable -private fun rowDisplay( +private fun RowScope.PreviewTab( entry: BottomBarEntry, - favorites: List, -): RowDisplay = - when (entry) { - is BottomBarEntry.BuiltIn -> { - val def = NavBarCatalog[entry.item] - if (def != null) RowDisplay(def.icon, stringRes(def.labelRes)) else RowDisplay(MaterialSymbols.Apps, "") - } - is BottomBarEntry.Favorite -> { - val app = favorites.firstOrNull { it.id == entry.favoriteId } - val icon = if (app is FavoriteApp.NostrApp) MaterialSymbols.Apps else MaterialSymbols.Public - RowDisplay(icon, app?.label ?: "") - } - } - -/** - * Builds the row list: [pinned] entries first (in saved order, dropping any that no longer resolve — - * e.g. a deleted favorite), then every still-available entry — built-in destinations and the user's - * favorite apps — in the available section. - */ -private fun initialRows( - pinned: List, - favorites: List, -): List { - val available: List = - NavBarCatalog.keys.map { BottomBarEntry.BuiltIn(it) } + favorites.map { BottomBarEntry.Favorite(it.id) } - val availableSet = available.toSet() - - val pinnedRows = pinned.filter { it in availableSet }.map { Row(it, pinned = true) } - val unpinnedRows = available.filter { it !in pinned }.map { Row(it, pinned = false) } - return pinnedRows + unpinnedRows -} - -@Composable -private fun SectionDivider(titleRes: Int) { - Text( - text = stringRes(titleRes), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 20.dp, bottom = 4.dp, start = Size20dp, end = Size20dp), - ) - HorizontalDivider(modifier = Modifier.padding(horizontal = Size20dp)) -} - -@Composable -private fun NavBarItemCard( - icon: MaterialSymbol, - label: String, - isDragging: Boolean, - canDrag: Boolean, - dragOffsetY: Float, - elevation: Float, - pinned: Boolean, - onTogglePinned: () -> Unit, + selected: Boolean, + dragging: Boolean, + dragOffsetX: Float, + accountViewModel: AccountViewModel, + onRemove: () -> Unit, onMeasured: (Float) -> Unit, onDragStart: () -> Unit, onDrag: (Float) -> Unit, onDragEnd: () -> Unit, onDragCancel: () -> Unit, +) { + val visual = rememberPinnedVisual(entry, accountViewModel) + val lift by animateFloatAsState(if (dragging) 1.12f else 1f, label = "tabLift") + + Box( + modifier = + Modifier + .weight(1f) + .fillMaxWidth() + .zIndex(if (dragging) 1f else 0f) + .onGloballyPositioned { onMeasured(it.size.width.toFloat()) } + .graphicsLayer { + translationX = dragOffsetX + scaleX = lift + scaleY = lift + }.pointerInput(entry.stableKey) { + detectDragGestures( + onDragStart = { onDragStart() }, + onDrag = { change, amount -> + change.consume() + onDrag(amount.x) + }, + onDragEnd = { onDragEnd() }, + onDragCancel = { onDragCancel() }, + ) + }, + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.padding(top = 6.dp), + ) { + PreviewTabIcon(visual, selected, accountViewModel) + if (selected) { + Text( + text = visual.label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.SemiBold, + ) + } + } + + // Editing affordance: a small ✕ removes this tab. This is why the preview isn't just a mirror. + RemoveBadge(onRemove, Modifier.align(Alignment.TopEnd)) + } +} + +@Composable +private fun BoxScope.RemoveBadge( + onRemove: () -> Unit, modifier: Modifier = Modifier, ) { - Column( + Box( modifier = modifier - .fillMaxWidth() - .onGloballyPositioned { coordinates -> - onMeasured(coordinates.size.height.toFloat()) - }.graphicsLayer { - translationY = dragOffsetY - shadowElevation = elevation - if (isDragging) { - scaleX = 1.02f - scaleY = 1.02f - } - }.padding(vertical = 8.dp, horizontal = Size20dp) - .then( - if (canDrag) { - Modifier.pointerInput(Unit) { - detectDragGestures( - onDragStart = { onDragStart() }, - onDrag = { change, dragAmount -> - change.consume() - onDrag(dragAmount.y) - }, - onDragEnd = { onDragEnd() }, - onDragCancel = { onDragCancel() }, - ) - } - } else { - Modifier - }, - ), + .size(18.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(onClick = onRemove), + contentAlignment = Alignment.Center, ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - NavBarIconBox(icon, label) + Icon( + symbol = MaterialSymbols.Close, + contentDescription = stringRes(R.string.bottom_bar_settings_remove), + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} - Text( - text = label, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) +/** The icon block of a preview tab: catalog glyph, the favorite's real favicon, or the group's avatar. */ +@Composable +private fun PreviewTabIcon( + visual: PinnedVisual, + selected: Boolean, + accountViewModel: AccountViewModel, +) { + val accent = MaterialTheme.colorScheme.primary + val tint = if (selected) accent else MaterialTheme.colorScheme.onSurfaceVariant + Box( + modifier = + Modifier + .size(width = 42.dp, height = 28.dp) + .clip(CircleShape) + .background(if (selected) accent.copy(alpha = 0.16f) else Color.Transparent), + contentAlignment = Alignment.Center, + ) { + when (visual) { + is PinnedVisual.Glyph -> Icon(symbol = visual.icon, contentDescription = visual.label, modifier = Modifier.size(21.dp), tint = tint) + is PinnedVisual.Favorite -> FavoriteAppIcon(app = visual.app, tint = tint, modifier = Modifier.size(21.dp), iconModel = rememberFavoriteIconModel(visual.app)) + is PinnedVisual.Avatar -> GroupEntryAvatar(visual.display, 22.dp, accountViewModel) + } + } +} - Switch( - checked = pinned, - onCheckedChange = { onTogglePinned() }, - ) +// ------------------------------------------------------------------------------------------------ +// Available catalogue — category cards +// ------------------------------------------------------------------------------------------------ - Box( - modifier = Modifier.size(28.dp), - contentAlignment = Alignment.Center, +@Composable +private fun CategoryCard( + category: NavBarCategory, + pinnedKeys: Set, + expanded: Boolean, + onToggleExpand: () -> Unit, + expandedItems: SnapshotStateMap, + accountViewModel: AccountViewModel, + onTogglePin: (BottomBarEntry) -> Unit, +) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp, vertical = 5.dp), + ) { + Column { + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onToggleExpand).padding(13.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - if (canDrag) { + Box( + modifier = + Modifier + .size(34.dp) + .clip(RoundedCornerShape(11.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { Icon( - MaterialSymbols.DragIndicator, - contentDescription = stringRes(R.string.bottom_bar_settings_reorder), - modifier = Modifier.size(24.dp), + symbol = categoryIcon(category.titleRes), + contentDescription = null, + modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } + Text( + text = stringRes(category.titleRes), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + Icon( + symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + AnimatedVisibility(visible = expanded, enter = SectionExpand, exit = SectionCollapse) { + Column(Modifier.padding(bottom = 6.dp)) { + category.items.forEach { item -> + val def = NavBarCatalog[item] ?: return@forEach + val entry = BottomBarEntry.BuiltIn(item) + if (item in ExpandableItems) { + ExpandableAvailableRow( + icon = def.icon, + label = stringRes(def.labelRes), + pinned = entry.stableKey in pinnedKeys, + expanded = expandedItems[item] ?: false, + onTogglePin = { onTogglePin(entry) }, + onToggleExpand = { expandedItems[item] = !(expandedItems[item] ?: false) }, + ) { + PickerChildren(item, pinnedKeys, accountViewModel, onTogglePin) + } + } else { + AvailableRow( + leading = { LeadingGlyph(def.icon) }, + label = stringRes(def.labelRes), + pinned = entry.stableKey in pinnedKeys, + onToggle = { onTogglePin(entry) }, + ) + } + } + } } } } } +/** Child rows (favorites / joined groups) revealed when an expandable picker row opens. */ @Composable -private fun NavBarIconBox( - icon: MaterialSymbol, - label: String, +private fun PickerChildren( + item: NavBarItem, + pinnedKeys: Set, + accountViewModel: AccountViewModel, + onTogglePin: (BottomBarEntry) -> Unit, ) { - Box( - modifier = Modifier.size(28.dp), - contentAlignment = Alignment.Center, - ) { - Icon( - symbol = icon, - contentDescription = label, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onBackground, + when (item) { + NavBarItem.BROWSER -> { + val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + if (favorites.isEmpty()) { + EmptyChildHint(R.string.bottom_bar_settings_no_favorites) + } else { + favorites.forEach { fav -> + val entry = BottomBarEntry.Favorite(fav.id) + AvailableRow( + leading = { FavoriteLeading(fav) }, + label = fav.label, + pinned = entry.stableKey in pinnedKeys, + onToggle = { onTogglePin(entry) }, + indent = true, + ) + } + } + } + + NavBarItem.PUBLIC_CHATS -> { + val channels by accountViewModel.account.publicChatList.flow + .collectAsStateWithLifecycle() + val entries = remember(channels) { channels.map { BottomBarEntry.PublicChat(it.eventId) } } + GroupChildList(entries, pinnedKeys, accountViewModel, onTogglePin) + } + + NavBarItem.RELAY_GROUPS -> { + val groups by accountViewModel.account.relayGroupList.liveRelayGroupList + .collectAsStateWithLifecycle() + val entries = + remember(groups) { + groups + .sortedBy { (it.name ?: it.groupId).lowercase() } + .map { BottomBarEntry.RelayGroup(it.groupId, it.relayUrl) } + } + GroupChildList(entries, pinnedKeys, accountViewModel, onTogglePin) + } + + NavBarItem.CONCORD -> { + val communities by accountViewModel.account.concordChannelList.liveCommunities + .collectAsStateWithLifecycle() + val entries = remember(communities) { communities.map { BottomBarEntry.Concord(it.id) } } + GroupChildList(entries, pinnedKeys, accountViewModel, onTogglePin) + } + + else -> {} + } +} + +@Composable +private fun GroupChildList( + entries: List, + pinnedKeys: Set, + accountViewModel: AccountViewModel, + onTogglePin: (BottomBarEntry) -> Unit, +) { + if (entries.isEmpty()) { + EmptyChildHint(R.string.bottom_bar_settings_no_groups) + return + } + entries.forEach { entry -> + // Read-only: the picker resolves names/avatars from cache, it must not open a REQ per row. + val display = rememberGroupEntryDisplay(entry, accountViewModel, subscribe = false) ?: return@forEach + AvailableRow( + leading = { GroupEntryAvatar(display, 34.dp, accountViewModel) }, + label = display.label, + pinned = entry.stableKey in pinnedKeys, + onToggle = { onTogglePin(entry) }, + indent = true, ) } } + +// ------------------------------------------------------------------------------------------------ +// Rows & shared bits +// ------------------------------------------------------------------------------------------------ + +@Composable +private fun AvailableRow( + leading: @Composable () -> Unit, + label: String, + pinned: Boolean, + onToggle: () -> Unit, + indent: Boolean = false, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onToggle) + .padding(start = if (indent) 24.dp else 13.dp, end = 13.dp, top = 7.dp, bottom = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + leading() + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + AddPill(added = pinned, onClick = onToggle) + } +} + +@Composable +private fun ExpandableAvailableRow( + icon: MaterialSymbol, + label: String, + pinned: Boolean, + expanded: Boolean, + onTogglePin: () -> Unit, + onToggleExpand: () -> Unit, + children: @Composable () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onToggleExpand) + .padding(start = 13.dp, end = 13.dp, top = 7.dp, bottom = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + LeadingGlyph(icon) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Icon( + symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, + contentDescription = stringRes(R.string.bottom_bar_settings_expand), + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + AddPill(added = pinned, onClick = onTogglePin) + } + AnimatedVisibility(visible = expanded, enter = SectionExpand, exit = SectionCollapse) { + Column { children() } + } +} + +/** + * Outlined "Add" that fills to "Added" once pinned — states the action and its result. Both states + * share one Row body (only color/border/tint differ) so the pill keeps a constant height and the rows + * stay aligned whether an item is added or not. + */ +@Composable +private fun AddPill( + added: Boolean, + onClick: () -> Unit, +) { + val accent = MaterialTheme.colorScheme.primary + val content = if (added) MaterialTheme.colorScheme.onPrimary else accent + Surface( + shape = CircleShape, + color = if (added) accent else Color.Transparent, + border = if (added) null else BorderStroke(1.dp, accent), + ) { + Row( + modifier = Modifier.clickable(onClick = onClick).padding(horizontal = 14.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + symbol = if (added) MaterialSymbols.Check else MaterialSymbols.Add, + contentDescription = null, + modifier = Modifier.size(15.dp), + tint = content, + ) + Text( + text = stringRes(if (added) R.string.bottom_bar_settings_added else R.string.bottom_bar_settings_add), + style = MaterialTheme.typography.labelLarge, + color = content, + ) + } + } +} + +/** A category/destination glyph in a soft accent-tinted circle. */ +@Composable +private fun LeadingGlyph(icon: MaterialSymbol) { + Box( + modifier = Modifier.size(34.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center, + ) { + Icon(symbol = icon, contentDescription = null, modifier = Modifier.size(19.dp), tint = MaterialTheme.colorScheme.primary) + } +} + +/** A favorite web-app / nsite / napplet's real favicon in a tinted circle (glyph fallback). */ +@Composable +private fun FavoriteLeading(app: FavoriteApp) { + Box( + modifier = Modifier.size(34.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center, + ) { + FavoriteAppIcon( + app = app, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp), + iconModel = rememberFavoriteIconModel(app), + ) + } +} + +@Composable +private fun SectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = Size20dp, end = Size20dp, top = 18.dp, bottom = 6.dp), + ) +} + +@Composable +private fun EmptyChildHint(textRes: Int) { + Text( + text = stringRes(textRes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 24.dp, end = 13.dp, top = 6.dp, bottom = 6.dp), + ) +} + +private fun categoryIcon(titleRes: Int): MaterialSymbol = + when (titleRes) { + R.string.bottom_bar_category_main -> MaterialSymbols.Home + R.string.bottom_bar_category_chats -> MaterialSymbols.Group + R.string.bottom_bar_category_you -> MaterialSymbols.AccountCircle + R.string.bottom_bar_category_feeds -> MaterialSymbols.Subscriptions + R.string.bottom_bar_category_apps -> MaterialSymbols.Apps + else -> MaterialSymbols.Settings + } + +// ------------------------------------------------------------------------------------------------ +// Leading/label resolution for a pinned entry (built-in glyph, favorite icon, or group avatar). +// Computed once so a group's channel is subscribed at most once per row. +// ------------------------------------------------------------------------------------------------ + +private sealed interface PinnedVisual { + val label: String + + data class Glyph( + val icon: MaterialSymbol, + override val label: String, + ) : PinnedVisual + + data class Favorite( + val app: FavoriteApp, + ) : PinnedVisual { + override val label: String get() = app.label + } + + data class Avatar( + val display: GroupEntryDisplay, + ) : PinnedVisual { + override val label: String get() = display.label + } +} + +@Composable +private fun rememberPinnedVisual( + entry: BottomBarEntry, + accountViewModel: AccountViewModel, +): PinnedVisual = + when (entry) { + is BottomBarEntry.BuiltIn -> { + val def = NavBarCatalog[entry.item] + PinnedVisual.Glyph(def?.icon ?: MaterialSymbols.Apps, def?.let { stringRes(it.labelRes) } ?: "") + } + is BottomBarEntry.Favorite -> { + val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val app = favorites.firstOrNull { it.id == entry.favoriteId } + if (app != null) PinnedVisual.Favorite(app) else PinnedVisual.Glyph(MaterialSymbols.Public, "") + } + is BottomBarEntry.PublicChat, + is BottomBarEntry.RelayGroup, + is BottomBarEntry.Concord, + -> { + // Read-only: the settings list resolves from cache; the live bar owns the subscription. + val display = rememberGroupEntryDisplay(entry, accountViewModel, subscribe = false) + if (display != null) PinnedVisual.Avatar(display) else PinnedVisual.Glyph(MaterialSymbols.Group, "") + } + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsState.kt new file mode 100644 index 0000000000..075e38f086 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsState.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry +import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries +import com.vitorpamplona.amethyst.ui.navigation.bottombars.stableKey + +/** + * Pure list transforms for the bottom-bar pinned list. Kept free of Compose/Android so the pin, + * unpin and reorder rules are exercised directly by unit tests (BottomBarSettingsStateTest) rather + * than only through the drag UI. [BottomBarSettingsState] is the thin stateful wrapper the screen uses. + */ +object BottomBarEditing { + fun isPinned( + items: List, + entry: BottomBarEntry, + ): Boolean = items.any { it.stableKey == entry.stableKey } + + /** Adds [entry] to the end if absent, or removes it (by stable identity) if present. */ + fun togglePin( + items: List, + entry: BottomBarEntry, + ): List = + if (isPinned(items, entry)) { + items.filter { it.stableKey != entry.stableKey } + } else { + items + entry + } + + /** Moves the item at [from] to index [to]; a no-op if either index is out of bounds. */ + fun move( + items: List, + from: Int, + to: Int, + ): List { + if (from == to || from !in items.indices || to !in items.indices) return items + return items.toMutableList().apply { add(to, removeAt(from)) } + } +} + +/** + * State holder for the Bottom Bar settings screen: owns the ordered pinned list and the pin / unpin / + * reorder / restore-default operations, so the composable only renders and forwards events. During a + * drag, [moveTransient] reorders without persisting; [commit] writes the final order once the drag ends. + */ +@Stable +class BottomBarSettingsState( + initial: List, + private val persist: (List) -> Unit, +) { + var pinned by mutableStateOf(initial) + private set + + fun isPinned(entry: BottomBarEntry): Boolean = BottomBarEditing.isPinned(pinned, entry) + + fun pinnedKeys(): Set = pinned.mapTo(HashSet(pinned.size)) { it.stableKey } + + fun togglePin(entry: BottomBarEntry) = update(BottomBarEditing.togglePin(pinned, entry)) + + fun restoreDefault() = update(DefaultBottomBarEntries) + + /** Reorder mid-drag WITHOUT persisting. Pair with [commit] when the gesture ends. */ + fun moveTransient( + from: Int, + to: Int, + ) { + pinned = BottomBarEditing.move(pinned, from, to) + } + + /** Persist the current (post-drag) order. */ + fun commit() = persist(pinned) + + /** + * Re-seed from an external change (the saved settings flow emitted) without re-persisting. A no-op + * when equal, so the echo of our own [persist] doesn't clobber an in-progress edit. + */ + fun syncFrom(items: List) { + if (items != pinned) pinned = items + } + + private fun update(newItems: List) { + pinned = newItems + persist(newItems) + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index afe558183d..46cdabece6 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2889,10 +2889,25 @@ Change Quick Reactions Bottom Navigation Bar - Drag to reorder. Toggle to add or remove an item from the bottom bar. With zero items the bottom bar is hidden. + Drag the pinned items to reorder. Open a category below and toggle an item to add or remove it. Open Browser or a chat type to pin a favorite or a specific group. With zero items the bottom bar is hidden. Available + Your bottom bar + No items pinned. The bottom bar is hidden until you add at least one. Reorder + Show options + No favorites yet. Star an app in the Browser to add it here. + No joined groups yet. + Drag to reorder · tap ✕ to remove + Add + Added + Remove Restore Default + Main + Chats & Groups + You + Feeds + Apps & Web + Other Home Tabs Pick which tabs appear on the Home screen. When only one tab is active the tab bar is hidden. Everything diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/BottomBarCategoriesTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/BottomBarCategoriesTest.kt new file mode 100644 index 0000000000..f6ce46fa14 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/BottomBarCategoriesTest.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.navigation + +import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarCategories +import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * The settings picker builds its grouped "Available" list from [BottomBarCategories], not from the raw + * catalog. If a newly added [NavBarCatalog] destination isn't placed in a category, it would silently + * vanish from the picker — this pins that every catalog id appears in exactly one category. + */ +class BottomBarCategoriesTest { + @Test + fun everyCatalogItemAppearsInExactlyOneCategory() { + val categorized = BottomBarCategories.flatMap { it.items } + + // No duplicates across categories. + assertEquals("an item is listed in more than one category", categorized.size, categorized.toSet().size) + + // Exact coverage of the catalog. + assertEquals(NavBarCatalog.keys.toSet(), categorized.toSet()) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/BottomBarEntrySerializationTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/BottomBarEntrySerializationTest.kt index e4da67a3ad..e6714281a7 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/BottomBarEntrySerializationTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/BottomBarEntrySerializationTest.kt @@ -38,6 +38,9 @@ class BottomBarEntrySerializationTest { listOf( BottomBarEntry.BuiltIn(NavBarItem.HOME), BottomBarEntry.Favorite("url:https://example.com"), + BottomBarEntry.PublicChat("25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb"), + BottomBarEntry.RelayGroup("abcd1234", "wss://groups.example.com"), + BottomBarEntry.Concord("f".repeat(64)), ) @Test @@ -45,6 +48,10 @@ class BottomBarEntrySerializationTest { val json = JsonMapper.toJson(sample) // Stable short names, NOT the fragile fully-qualified class name. assertTrue("expected stable discriminators, got: $json", json.contains("\"builtIn\"") && json.contains("\"favorite\"")) + assertTrue( + "expected group discriminators, got: $json", + json.contains("\"publicChat\"") && json.contains("\"relayGroup\"") && json.contains("\"concord\""), + ) assertEquals(sample, JsonMapper.fromJson>(json)) } @@ -63,11 +70,17 @@ class BottomBarEntrySerializationTest { runCatching { JsonMapper.fromJson>(legacy) } .onSuccess { error("legacy discriminator unexpectedly decoded directly: $it") } - // ...but the migration (rewrite FQN -> short name) recovers the exact same config. + // ...but the migration (rewrite FQN -> short name) recovers the exact same config. The legacy + // format predates the group entries, so the recovered config is just the built-in + favorite. + val expected = + listOf( + BottomBarEntry.BuiltIn(NavBarItem.HOME), + BottomBarEntry.Favorite("url:https://example.com"), + ) val migrated = legacy .replace("com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry.BuiltIn", "builtIn") .replace("com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry.Favorite", "favorite") - assertEquals(sample, JsonMapper.fromJson>(migrated)) + assertEquals(expected, JsonMapper.fromJson>(migrated)) } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/BottomBarSettingsStateTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/BottomBarSettingsStateTest.kt new file mode 100644 index 0000000000..ddc52a8cb0 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/BottomBarSettingsStateTest.kt @@ -0,0 +1,139 @@ +/* + * 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.navigation + +import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry +import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries +import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem +import com.vitorpamplona.amethyst.ui.navigation.bottombars.stableKey +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BottomBarEditing +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BottomBarSettingsState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BottomBarSettingsStateTest { + private val home = BottomBarEntry.BuiltIn(NavBarItem.HOME) + private val messages = BottomBarEntry.BuiltIn(NavBarItem.MESSAGES) + private val group = BottomBarEntry.RelayGroup("abcd", "wss://relay.example") + private val chat = BottomBarEntry.PublicChat("f".repeat(64)) + + // --- Pure edit operations --- + + @Test + fun togglePinAddsWhenAbsentAndRemovesWhenPresent() { + val once = BottomBarEditing.togglePin(listOf(home), messages) + assertEquals(listOf(home, messages), once) + + val twice = BottomBarEditing.togglePin(once, messages) + assertEquals(listOf(home), twice) + } + + @Test + fun togglePinIsIdempotentOverTwoCalls() { + val start = listOf(home, group) + val roundTrip = BottomBarEditing.togglePin(BottomBarEditing.togglePin(start, chat), chat) + assertEquals(start, roundTrip) + } + + @Test + fun togglePinRemovesByStableIdentityNotReferenceEquality() { + val start = listOf(BottomBarEntry.RelayGroup("abcd", "wss://relay.example")) + // A distinct instance with the same (id, relay) resolves to the same stableKey. + val result = BottomBarEditing.togglePin(start, BottomBarEntry.RelayGroup("abcd", "wss://relay.example")) + assertTrue(result.isEmpty()) + } + + @Test + fun moveReordersAndClampsOutOfBounds() { + val start = listOf(home, messages, group) + assertEquals(listOf(messages, group, home), BottomBarEditing.move(start, 0, 2)) + assertEquals(start, BottomBarEditing.move(start, 1, 1)) // no-op: same index + assertEquals(start, BottomBarEditing.move(start, -1, 2)) // no-op: out of bounds + assertEquals(start, BottomBarEditing.move(start, 0, 9)) // no-op: out of bounds + } + + @Test + fun stableKeyIsUniquePerTypeEvenWhenUnderlyingStringMatches() { + val a = BottomBarEntry.PublicChat("x").stableKey + val b = BottomBarEntry.Concord("x").stableKey + val c = BottomBarEntry.Favorite("x").stableKey + assertEquals(3, setOf(a, b, c).size) + } + + // --- Holder: persistence semantics --- + + @Test + fun togglePinPersistsImmediately() { + var saved: List? = null + val state = BottomBarSettingsState(listOf(home)) { saved = it } + + state.togglePin(messages) + + assertEquals(listOf(home, messages), state.pinned) + assertEquals(listOf(home, messages), saved) + } + + @Test + fun moveTransientDoesNotPersistUntilCommit() { + var saveCount = 0 + val state = BottomBarSettingsState(listOf(home, messages)) { saveCount++ } + + state.moveTransient(0, 1) + assertEquals(listOf(messages, home), state.pinned) + assertEquals(0, saveCount) // dragging does not write + + state.commit() + assertEquals(1, saveCount) // drag end writes once + } + + @Test + fun restoreDefaultPersistsTheDefaults() { + var saved: List? = null + val state = BottomBarSettingsState(listOf(home)) { saved = it } + + state.restoreDefault() + + assertEquals(DefaultBottomBarEntries, state.pinned) + assertEquals(state.pinned, saved) + } + + @Test + fun syncFromOnlyUpdatesWhenDifferent() { + var saveCount = 0 + val state = BottomBarSettingsState(listOf(home)) { saveCount++ } + + state.syncFrom(listOf(home)) // equal → no change, and never persists + assertEquals(listOf(home), state.pinned) + + state.syncFrom(listOf(home, messages)) // external change adopted + assertEquals(listOf(home, messages), state.pinned) + assertEquals(0, saveCount) // syncFrom must never persist (it would echo-loop) + } + + @Test + fun isPinnedReflectsMembership() { + val state = BottomBarSettingsState(listOf(home, group)) {} + assertTrue(state.isPinned(group)) + assertFalse(state.isPinned(messages)) + } +}