From d12093ec51b65560d8f1a0f49555f0a9640e565a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 13:57:55 +0000 Subject: [PATCH 1/8] feat: pick individual chats/groups and favorites for the bottom nav bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the Bottom Navigation Bar settings picker so users can pin specific joined chats — not just the aggregate list screens. - Add PublicChat, RelayGroup and Concord variants to BottomBarEntry (stable @SerialName discriminators), so a specific NIP-28 channel, NIP-29 relay group or Concord community can be pinned as its own tab. The bottom bar and the navigation rail resolve each to its avatar + chat/home route, live from the local cache via a shared GroupBottomBarEntries resolver. - Redesign BottomBarSettingsScreen: the pinned bar stays a drag-reorderable section on top; the "Available" list is now grouped into ordered, collapsible categories (Main, Chats & Groups, You, Feeds, Apps & Web, Other) instead of the flat, scattered catalog order. Browser expands to your favorite apps, and each chat type expands to your joined groups, each child pinnable with a toggle. - Curated category ordering lives in BottomBarCategories, covered by a test that asserts every catalog id is placed in exactly one category. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017mxdSFQcarsKaL179tSub1 --- .../ui/navigation/bottombars/AppBottomBar.kt | 24 + .../bottombars/AppNavigationRail.kt | 30 + .../navigation/bottombars/BottomBarEntry.kt | 44 +- .../bottombars/GroupBottomBarEntries.kt | 158 ++++++ .../ui/navigation/bottombars/NavBarItem.kt | 94 ++++ .../settings/BottomBarSettingsScreen.kt | 526 +++++++++++++----- amethyst/src/main/res/values/strings.xml | 13 +- .../navigation/BottomBarCategoriesTest.kt | 44 ++ .../BottomBarEntrySerializationTest.kt | 17 +- 9 files changed, 815 insertions(+), 135 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/GroupBottomBarEntries.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/BottomBarCategoriesTest.kt 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..d97ed95b59 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 @@ -202,6 +202,13 @@ private fun RenderBottomMenu( } FavoriteNavItem(destination == selectedRoute, fav, rememberFavoriteIconModel(fav), destination, nav) } + is BottomBarEntry.PublicChat, + is BottomBarEntry.RelayGroup, + is BottomBarEntry.Concord, + -> { + val display = rememberGroupEntryDisplay(entry, accountViewModel) ?: return@forEach + GroupNavItem(display.route == selectedRoute, display, accountViewModel, display.route, nav) + } } } } @@ -225,6 +232,23 @@ private fun RowScope.FavoriteNavItem( ) } +@Composable +private fun RowScope.GroupNavItem( + selected: Boolean, + display: GroupEntryDisplay, + accountViewModel: AccountViewModel, + destination: Route, + nav: (Route) -> Unit, +) { + NavigationBarItem( + alwaysShowLabel = false, + // A pinned chat/group shows its avatar, like the favorite-app tabs — icon only. + icon = { Box(Size27Modifier, contentAlignment = Alignment.Center) { GroupEntryAvatar(display, 25.dp, accountViewModel) } }, + selected = selected, + onClick = { nav(destination) }, + ) +} + @Composable private fun RowScope.HasNewItemsIcon( selected: Boolean, 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..db3c4a9692 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 @@ -31,6 +31,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.compose.currentBackStackEntryAsState @@ -123,6 +124,35 @@ fun AppNavigationRail( icon = { FavoriteEntryIcon(fav, selected, rememberFavoriteIconModel(fav)) }, ) } + + is BottomBarEntry.PublicChat, + is BottomBarEntry.RelayGroup, + is BottomBarEntry.Concord, + -> { + val display = rememberGroupEntryDisplay(entry, accountViewModel) ?: return@forEach + val destination = display.route + // Group routes carry ids, so match the full route (not just its class). + val selected = + remember(navBackStackEntry, destination) { + when (destination) { + is Route.PublicChatChannel -> getRouteWithArguments(Route.PublicChatChannel::class, nav.controller) == destination + is Route.RelayGroup -> getRouteWithArguments(Route.RelayGroup::class, nav.controller) == destination + is Route.ConcordServer -> getRouteWithArguments(Route.ConcordServer::class, nav.controller) == destination + else -> false + } + } + NavigationRailItem( + selected = selected, + onClick = { + if (selected) { + reselectCoordinator.reselect(destination) + } else { + nav.navBottomBar(destination) + } + }, + icon = { GroupEntryAvatar(display, 25.dp, accountViewModel) }, + ) + } } } } 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..9b2910e684 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/GroupBottomBarEntries.kt @@ -0,0 +1,158 @@ +/* + * 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.getValue +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.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel +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, +) + +@Composable +fun rememberPublicChatEntryDisplay( + entry: BottomBarEntry.PublicChat, + accountViewModel: AccountViewModel, +): GroupEntryDisplay { + val channel = remember(entry.channelId) { LocalCache.getOrCreatePublicChatChannel(entry.channelId) } + val state by observeChannel(channel, accountViewModel) + 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, +): GroupEntryDisplay { + val relay = remember(entry.relayUrl) { RelayUrlNormalizer.normalizeOrNull(entry.relayUrl) } + val route = Route.RelayGroup(entry.groupId, entry.relayUrl) + + if (relay == null) { + return GroupEntryDisplay(entry.groupId, entry.groupId, null, route) + } + + val channel = remember(entry.groupId, relay) { LocalCache.getOrCreateRelayGroupChannel(GroupId(entry.groupId, relay)) } + val state by observeChannel(channel, accountViewModel) + val current = (state?.channel as? RelayGroupChannel) ?: channel + return GroupEntryDisplay( + label = current.toBestDisplayName(), + robotSeed = entry.groupId, + model = current.profilePicture(), + route = route, + ) +} + +@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. */ +@Composable +fun rememberGroupEntryDisplay( + entry: BottomBarEntry, + accountViewModel: AccountViewModel, +): GroupEntryDisplay? = + when (entry) { + is BottomBarEntry.PublicChat -> rememberPublicChatEntryDisplay(entry, accountViewModel) + is BottomBarEntry.RelayGroup -> rememberRelayGroupEntryDisplay(entry, accountViewModel) + 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/screen/loggedIn/settings/BottomBarSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt index e3b2eb83ee..6e69fdaf9d 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,8 +20,10 @@ */ 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.foundation.clickable import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -57,7 +59,6 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp @@ -65,9 +66,15 @@ 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.NavBarItem +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 @@ -104,19 +111,37 @@ fun BottomBarSettingsScreen( } } +/** 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, + ) + @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 }, - ) + // A local, drag-mutable copy of the pinned list. Re-seeded whenever the saved list changes from + // elsewhere (e.g. a favorite/group got pinned from its picker row, or Restore Default ran). + var pinned by remember(savedItems) { mutableStateOf(savedItems) } + + fun save(newItems: List) { + pinned = newItems + bottomBarItemsFlow.tryEmit(newItems) + } + + val pinnedKeys = remember(pinned) { pinned.map { it.stableKey }.toSet() } + + fun togglePin(entry: BottomBarEntry) { + if (entry.stableKey in pinnedKeys) { + save(pinned.filter { it.stableKey != entry.stableKey }) + } else { + save(pinned + entry) + } } var draggedItemIndex by remember { mutableIntStateOf(-1) } @@ -125,6 +150,9 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) { val isDragging = draggedItemIndex >= 0 val scrollState = remember { ScrollState(0) } + val expandedCategories = remember { mutableStateMapOf() } + val expandedItems = remember { mutableStateMapOf() } + Column( modifier = Modifier @@ -151,15 +179,17 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) { onClick = { draggedItemIndex = -1 dragOffset = 0f - save(initialRows(DefaultBottomBarEntries, favorites)) + save(DefaultBottomBarEntries) }, ) { Text(stringRes(R.string.bottom_bar_settings_restore_default)) } } - items.forEachIndexed { index, row -> - val display = rowDisplay(row.entry, favorites) + // --- Pinned section: the current bottom bar, drag-reorderable. --- + SectionDivider(R.string.bottom_bar_settings_pinned) + + pinned.forEachIndexed { index, entry -> val rowIsDragging = draggedItemIndex == index val targetElevation = if (rowIsDragging) 8f else 0f val animatedElevation by animateFloatAsState( @@ -167,31 +197,14 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) { label = "dragElevation", ) - NavBarItemCard( - icon = display.icon, - label = display.label, + PinnedEntryCard( + entry = entry, + accountViewModel = accountViewModel, 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 - }, + onUnpin = { togglePin(entry) }, + onMeasured = { height -> itemHeights[index] = height }, onDragStart = { draggedItemIndex = index dragOffset = 0f @@ -200,17 +213,16 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) { dragOffset += dragAmount val currentIndex = draggedItemIndex - if (currentIndex < 0) return@NavBarItemCard + if (currentIndex < 0) return@PinnedEntryCard - // Can only swap among pinned items (row.pinned == true). - if (dragOffset < 0 && currentIndex > 0 && items[currentIndex - 1].pinned) { + if (dragOffset < 0 && currentIndex > 0) { val aboveHeight = itemHeights[currentIndex - 1] ?: 0f if (-dragOffset > aboveHeight / 2f) { - val newItems = items.toMutableList() + val newItems = pinned.toMutableList() val temp = newItems[currentIndex - 1] newItems[currentIndex - 1] = newItems[currentIndex] newItems[currentIndex] = temp - items = newItems + pinned = newItems val h1 = itemHeights[currentIndex] val h2 = itemHeights[currentIndex - 1] @@ -222,17 +234,14 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) { } } - if (dragOffset > 0 && - currentIndex < items.lastIndex && - items[currentIndex + 1].pinned - ) { + if (dragOffset > 0 && currentIndex < pinned.lastIndex) { val belowHeight = itemHeights[currentIndex + 1] ?: 0f if (dragOffset > belowHeight / 2f) { - val newItems = items.toMutableList() + val newItems = pinned.toMutableList() val temp = newItems[currentIndex + 1] newItems[currentIndex + 1] = newItems[currentIndex] newItems[currentIndex] = temp - items = newItems + pinned = newItems val h1 = itemHeights[currentIndex] val h2 = itemHeights[currentIndex + 1] @@ -247,74 +256,295 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) { onDragEnd = { draggedItemIndex = -1 dragOffset = 0f - save(items) + save(pinned) }, onDragCancel = { draggedItemIndex = -1 dragOffset = 0f }, - modifier = - Modifier - .zIndex(if (rowIsDragging) 1f else 0f), ) - 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) { + if (index < pinned.lastIndex) { HorizontalDivider(modifier = Modifier.padding(horizontal = Size20dp)) } } + if (pinned.isEmpty()) { + Text( + text = stringRes(R.string.bottom_bar_settings_pinned_empty), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp, horizontal = Size20dp), + ) + } + + // --- Available section: the full catalog, grouped into collapsible categories. --- + Spacer(modifier = Modifier.height(8.dp)) + + BottomBarCategories.forEach { category -> + val expanded = expandedCategories[category.titleRes] ?: false + CategoryHeader( + titleRes = category.titleRes, + expanded = expanded, + onToggle = { expandedCategories[category.titleRes] = !expanded }, + ) + AnimatedVisibility(visible = expanded) { + Column { + category.items.forEach { item -> + val def = NavBarCatalog[item] ?: return@forEach + val entry = BottomBarEntry.BuiltIn(item) + if (item in ExpandableItems) { + ExpandablePickerRow( + icon = def.icon, + label = stringRes(def.labelRes), + pinned = entry.stableKey in pinnedKeys, + expanded = expandedItems[item] ?: false, + onTogglePin = { togglePin(entry) }, + onToggleExpand = { expandedItems[item] = !(expandedItems[item] ?: false) }, + ) { + PickerChildren(item, pinnedKeys, accountViewModel, ::togglePin) + } + } else { + SimpleAvailableRow( + icon = def.icon, + label = stringRes(def.labelRes), + pinned = entry.stableKey in pinnedKeys, + onToggle = { togglePin(entry) }, + ) + } + } + } + } + } + Spacer(modifier = Modifier.height(16.dp)) } } -private data class Row( - val entry: BottomBarEntry, - val pinned: Boolean, -) +/** The joined-groups (or favorites) child rows revealed when an expandable picker row opens. */ +@Composable +private fun PickerChildren( + item: NavBarItem, + pinnedKeys: Set, + accountViewModel: AccountViewModel, + onTogglePin: (BottomBarEntry) -> Unit, +) { + 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) + ChildRow( + leading = { FavoriteChildIcon(fav) }, + label = fav.label, + pinned = entry.stableKey in pinnedKeys, + onToggle = { onTogglePin(entry) }, + ) + } + } + } -/** 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, -) + NavBarItem.PUBLIC_CHATS -> { + val channels by accountViewModel.account.publicChatList.flow + .collectAsStateWithLifecycle() + val sorted = remember(channels) { channels.map { BottomBarEntry.PublicChat(it.eventId) } } + if (sorted.isEmpty()) { + EmptyChildHint(R.string.bottom_bar_settings_no_groups) + } else { + sorted.forEach { entry -> GroupChildRow(entry, pinnedKeys, accountViewModel, onTogglePin) } + } + } + + NavBarItem.RELAY_GROUPS -> { + val groups by accountViewModel.account.relayGroupList.liveRelayGroupList + .collectAsStateWithLifecycle() + val sorted = + remember(groups) { + groups + .sortedBy { (it.name ?: it.groupId).lowercase() } + .map { BottomBarEntry.RelayGroup(it.groupId, it.relayUrl) } + } + if (sorted.isEmpty()) { + EmptyChildHint(R.string.bottom_bar_settings_no_groups) + } else { + sorted.forEach { entry -> GroupChildRow(entry, pinnedKeys, accountViewModel, onTogglePin) } + } + } + + NavBarItem.CONCORD -> { + val communities by accountViewModel.account.concordChannelList.liveCommunities + .collectAsStateWithLifecycle() + val sorted = remember(communities) { communities.map { BottomBarEntry.Concord(it.id) } } + if (sorted.isEmpty()) { + EmptyChildHint(R.string.bottom_bar_settings_no_groups) + } else { + sorted.forEach { entry -> GroupChildRow(entry, pinnedKeys, accountViewModel, onTogglePin) } + } + } + + else -> {} + } +} @Composable -private fun rowDisplay( +private fun GroupChildRow( 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 ?: "") - } + pinnedKeys: Set, + accountViewModel: AccountViewModel, + onTogglePin: (BottomBarEntry) -> Unit, +) { + val display = rememberGroupEntryDisplay(entry, accountViewModel) ?: return + ChildRow( + leading = { GroupEntryAvatar(display, 28.dp, accountViewModel) }, + label = display.label, + pinned = entry.stableKey in pinnedKeys, + onToggle = { onTogglePin(entry) }, + ) +} + +@Composable +private fun FavoriteChildIcon(app: FavoriteApp) { + val icon = if (app is FavoriteApp.NostrApp) MaterialSymbols.Apps else MaterialSymbols.Public + NavBarIconBox(icon, app.label) +} + +@Composable +private fun CategoryHeader( + titleRes: Int, + expanded: Boolean, + onToggle: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onToggle) + .padding(top = 16.dp, bottom = 6.dp, start = Size20dp, end = Size20dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringRes(titleRes), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f), + ) + Icon( + symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) } + HorizontalDivider(modifier = Modifier.padding(horizontal = Size20dp)) +} -/** - * 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() +@Composable +private fun SimpleAvailableRow( + icon: MaterialSymbol, + label: String, + pinned: Boolean, + onToggle: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onToggle) + .padding(vertical = 8.dp, horizontal = Size20dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + NavBarIconBox(icon, label) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Switch(checked = pinned, onCheckedChange = { onToggle() }) + } +} - 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 ExpandablePickerRow( + icon: MaterialSymbol, + label: String, + pinned: Boolean, + expanded: Boolean, + onTogglePin: () -> Unit, + onToggleExpand: () -> Unit, + children: @Composable () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onToggleExpand) + .padding(vertical = 8.dp, horizontal = Size20dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + NavBarIconBox(icon, label) + 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(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Switch(checked = pinned, onCheckedChange = { onTogglePin() }) + } + AnimatedVisibility(visible = expanded) { + Column { children() } + } +} + +@Composable +private fun ChildRow( + leading: @Composable () -> Unit, + label: String, + pinned: Boolean, + onToggle: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onToggle) + .padding(start = 44.dp, top = 6.dp, end = Size20dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + leading() + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Switch(checked = pinned, onCheckedChange = { onToggle() }) + } +} + +@Composable +private fun EmptyChildHint(textRes: Int) { + Text( + text = stringRes(textRes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 44.dp, top = 6.dp, end = Size20dp, bottom = 6.dp), + ) } @Composable @@ -329,15 +559,13 @@ private fun SectionDivider(titleRes: Int) { } @Composable -private fun NavBarItemCard( - icon: MaterialSymbol, - label: String, +private fun PinnedEntryCard( + entry: BottomBarEntry, + accountViewModel: AccountViewModel, isDragging: Boolean, - canDrag: Boolean, dragOffsetY: Float, elevation: Float, - pinned: Boolean, - onTogglePinned: () -> Unit, + onUnpin: () -> Unit, onMeasured: (Float) -> Unit, onDragStart: () -> Unit, onDrag: (Float) -> Unit, @@ -349,43 +577,42 @@ private fun NavBarItemCard( modifier = modifier .fillMaxWidth() - .onGloballyPositioned { coordinates -> - onMeasured(coordinates.size.height.toFloat()) - }.graphicsLayer { + .graphicsLayer { translationY = dragOffsetY shadowElevation = elevation if (isDragging) { scaleX = 1.02f scaleY = 1.02f } + }.onGloballyPositioned { coordinates -> + onMeasured(coordinates.size.height.toFloat()) }.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 - }, - ), + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { onDragStart() }, + onDrag = { change, dragAmount -> + change.consume() + onDrag(dragAmount.y) + }, + onDragEnd = { onDragEnd() }, + onDragCancel = { onDragCancel() }, + ) + }, ) { + val visual = rememberPinnedVisual(entry, accountViewModel) + Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - NavBarIconBox(icon, label) + when (visual) { + is PinnedVisual.Glyph -> NavBarIconBox(visual.icon, visual.label) + is PinnedVisual.Avatar -> GroupEntryAvatar(visual.display, 28.dp, accountViewModel) + } Text( - text = label, + text = visual.label, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -393,27 +620,66 @@ private fun NavBarItemCard( ) Switch( - checked = pinned, - onCheckedChange = { onTogglePinned() }, + checked = true, + onCheckedChange = { onUnpin() }, ) Box( modifier = Modifier.size(28.dp), contentAlignment = Alignment.Center, ) { - if (canDrag) { - Icon( - MaterialSymbols.DragIndicator, - contentDescription = stringRes(R.string.bottom_bar_settings_reorder), - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + Icon( + MaterialSymbols.DragIndicator, + contentDescription = stringRes(R.string.bottom_bar_settings_reorder), + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } } +/** Resolved leading + label for a pinned entry, computed once so a group's channel is subscribed once. */ +private sealed interface PinnedVisual { + val label: String + + data class Glyph( + val icon: MaterialSymbol, + override val label: String, + ) : PinnedVisual + + 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 } + val icon = if (app is FavoriteApp.NostrApp) MaterialSymbols.Apps else MaterialSymbols.Public + PinnedVisual.Glyph(icon, app?.label ?: "") + } + is BottomBarEntry.PublicChat, + is BottomBarEntry.RelayGroup, + is BottomBarEntry.Concord, + -> { + val display = rememberGroupEntryDisplay(entry, accountViewModel) + if (display != null) PinnedVisual.Avatar(display) else PinnedVisual.Glyph(MaterialSymbols.Group, "") + } + } + @Composable private fun NavBarIconBox( icon: MaterialSymbol, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 51de54a1d0..0d65224e53 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2822,10 +2822,21 @@ 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. 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)) } } From df313970b1f7e296c809487efe1861dff43c480e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 14:53:20 +0000 Subject: [PATCH 2/8] refactor: extract bottom-bar settings state, share the entry resolver, read-only picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quality follow-up to the customizable bottom nav, addressing the audit's three highest-impact items — behavior unchanged. - Extract BottomBarSettingsState + the pure BottomBarEditing transforms out of BottomBarSettingsContent, so pin/unpin/reorder/restore-default are unit-tested (BottomBarSettingsStateTest) instead of only exercisable through the drag UI. The composable now just renders and forwards events; moveTransient reorders mid-drag and commit() persists once on drag end. - Unify the phone bottom bar and the navigation rail on one rememberBottomBarSlot resolver (route + icon per entry), removing the duplicated built-in/favorite/ group branches and the divergent selection logic that had drifted between them. - Give the group resolvers a `subscribe` flag: the live bar/rail keeps a REQ open per pinned group (bounded by the few slots), but the settings picker reads cached metadata only — so expanding a chat category with many joined groups no longer fans out into one relay subscription per row. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017mxdSFQcarsKaL179tSub1 --- .../ui/navigation/bottombars/AppBottomBar.kt | 126 ++++++---------- .../bottombars/AppNavigationRail.kt | 115 +++++---------- .../bottombars/GroupBottomBarEntries.kt | 71 +++++++-- .../settings/BottomBarSettingsScreen.kt | 59 +++----- .../settings/BottomBarSettingsState.kt | 108 ++++++++++++++ .../navigation/BottomBarSettingsStateTest.kt | 139 ++++++++++++++++++ 6 files changed, 409 insertions(+), 209 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsState.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/BottomBarSettingsStateTest.kt 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 d97ed95b59..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,91 +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) - } - is BottomBarEntry.PublicChat, - is BottomBarEntry.RelayGroup, - is BottomBarEntry.Concord, - -> { - val display = rememberGroupEntryDisplay(entry, accountViewModel) ?: return@forEach - GroupNavItem(display.route == selectedRoute, display, accountViewModel, display.route, 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.GroupNavItem( - selected: Boolean, - display: GroupEntryDisplay, +internal fun rememberBottomBarSlot( + entry: BottomBarEntry, + favoritesById: Map, accountViewModel: AccountViewModel, - destination: Route, - nav: (Route) -> Unit, -) { - NavigationBarItem( - alwaysShowLabel = false, - // A pinned chat/group shows its avatar, like the favorite-app tabs — icon only. - icon = { Box(Size27Modifier, contentAlignment = Alignment.Center) { GroupEntryAvatar(display, 25.dp, accountViewModel) } }, - selected = selected, - onClick = { nav(destination) }, - ) -} - -@Composable -private fun RowScope.HasNewItemsIcon( - selected: Boolean, - def: NavBarItemDef, - destination: Route, - 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 db3c4a9692..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 @@ -31,11 +31,11 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp 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 @@ -76,85 +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)) }, - ) - } - - is BottomBarEntry.PublicChat, - is BottomBarEntry.RelayGroup, - is BottomBarEntry.Concord, - -> { - val display = rememberGroupEntryDisplay(entry, accountViewModel) ?: return@forEach - val destination = display.route - // Group routes carry ids, so match the full route (not just its class). - val selected = - remember(navBackStackEntry, destination) { - when (destination) { - is Route.PublicChatChannel -> getRouteWithArguments(Route.PublicChatChannel::class, nav.controller) == destination - is Route.RelayGroup -> getRouteWithArguments(Route.RelayGroup::class, nav.controller) == destination - is Route.ConcordServer -> getRouteWithArguments(Route.ConcordServer::class, nav.controller) == destination - else -> false - } - } - NavigationRailItem( - selected = selected, - onClick = { - if (selected) { - reselectCoordinator.reselect(destination) - } else { - nav.navBottomBar(destination) - } - }, - icon = { GroupEntryAvatar(display, 25.dp, accountViewModel) }, - ) - } - } + 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/GroupBottomBarEntries.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/GroupBottomBarEntries.kt index 9b2910e684..7b0a2a60ef 100644 --- 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 @@ -24,17 +24,21 @@ 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.observeChannel +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 @@ -56,13 +60,35 @@ data class GroupEntryDisplay( 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 observeChannel(channel, accountViewModel) + val state by observeChannelMetadata(channel, accountViewModel, subscribe) val current = (state?.channel as? PublicChatChannel) ?: channel return GroupEntryDisplay( label = current.toBestDisplayName(), @@ -76,25 +102,33 @@ fun rememberPublicChatEntryDisplay( fun rememberRelayGroupEntryDisplay( entry: BottomBarEntry.RelayGroup, accountViewModel: AccountViewModel, + subscribe: Boolean = true, ): GroupEntryDisplay { val relay = remember(entry.relayUrl) { RelayUrlNormalizer.normalizeOrNull(entry.relayUrl) } - val route = Route.RelayGroup(entry.groupId, entry.relayUrl) - - if (relay == null) { - return GroupEntryDisplay(entry.groupId, entry.groupId, null, route) - } - - val channel = remember(entry.groupId, relay) { LocalCache.getOrCreateRelayGroupChannel(GroupId(entry.groupId, relay)) } - val state by observeChannel(channel, accountViewModel) + 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 return GroupEntryDisplay( - label = current.toBestDisplayName(), + label = current?.toBestDisplayName() ?: entry.groupId, robotSeed = entry.groupId, - model = current.profilePicture(), - route = route, + 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, @@ -125,15 +159,20 @@ fun rememberConcordEntryDisplay( ) } -/** Resolves any chat/group entry to its live display, or null for a non-group entry. */ +/** + * 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) - is BottomBarEntry.RelayGroup -> rememberRelayGroupEntryDisplay(entry, accountViewModel) + 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 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 6e69fdaf9d..f4cc7bbe80 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 @@ -43,11 +43,11 @@ import androidx.compose.material3.Switch 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.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.ui.Alignment @@ -68,7 +68,6 @@ 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 @@ -125,24 +124,14 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) { val bottomBarItemsFlow = accountViewModel.settings.uiSettingsFlow.bottomBarItems val savedItems by bottomBarItemsFlow.collectAsStateWithLifecycle() - // A local, drag-mutable copy of the pinned list. Re-seeded whenever the saved list changes from - // elsewhere (e.g. a favorite/group got pinned from its picker row, or Restore Default ran). - var pinned by remember(savedItems) { mutableStateOf(savedItems) } + // 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 (a picker toggle, an + // external device edit, Restore Default) without clobbering an in-progress drag. + val state = remember { BottomBarSettingsState(savedItems) { bottomBarItemsFlow.tryEmit(it) } } + LaunchedEffect(savedItems) { state.syncFrom(savedItems) } - fun save(newItems: List) { - pinned = newItems - bottomBarItemsFlow.tryEmit(newItems) - } - - val pinnedKeys = remember(pinned) { pinned.map { it.stableKey }.toSet() } - - fun togglePin(entry: BottomBarEntry) { - if (entry.stableKey in pinnedKeys) { - save(pinned.filter { it.stableKey != entry.stableKey }) - } else { - save(pinned + entry) - } - } + val pinned = state.pinned + val pinnedKeys = remember(pinned) { state.pinnedKeys() } var draggedItemIndex by remember { mutableIntStateOf(-1) } var dragOffset by remember { mutableFloatStateOf(0f) } @@ -179,7 +168,7 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) { onClick = { draggedItemIndex = -1 dragOffset = 0f - save(DefaultBottomBarEntries) + state.restoreDefault() }, ) { Text(stringRes(R.string.bottom_bar_settings_restore_default)) @@ -203,7 +192,7 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) { isDragging = rowIsDragging, dragOffsetY = if (rowIsDragging) dragOffset else 0f, elevation = animatedElevation, - onUnpin = { togglePin(entry) }, + onUnpin = { state.togglePin(entry) }, onMeasured = { height -> itemHeights[index] = height }, onDragStart = { draggedItemIndex = index @@ -218,11 +207,8 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) { if (dragOffset < 0 && currentIndex > 0) { val aboveHeight = itemHeights[currentIndex - 1] ?: 0f if (-dragOffset > aboveHeight / 2f) { - val newItems = pinned.toMutableList() - val temp = newItems[currentIndex - 1] - newItems[currentIndex - 1] = newItems[currentIndex] - newItems[currentIndex] = temp - pinned = newItems + // Swap with the row above (transient — persisted on drag end). + state.moveTransient(currentIndex, currentIndex - 1) val h1 = itemHeights[currentIndex] val h2 = itemHeights[currentIndex - 1] @@ -237,11 +223,8 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) { if (dragOffset > 0 && currentIndex < pinned.lastIndex) { val belowHeight = itemHeights[currentIndex + 1] ?: 0f if (dragOffset > belowHeight / 2f) { - val newItems = pinned.toMutableList() - val temp = newItems[currentIndex + 1] - newItems[currentIndex + 1] = newItems[currentIndex] - newItems[currentIndex] = temp - pinned = newItems + // Swap with the row below (transient — persisted on drag end). + state.moveTransient(currentIndex, currentIndex + 1) val h1 = itemHeights[currentIndex] val h2 = itemHeights[currentIndex + 1] @@ -256,7 +239,7 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) { onDragEnd = { draggedItemIndex = -1 dragOffset = 0f - save(pinned) + state.commit() }, onDragCancel = { draggedItemIndex = -1 @@ -299,17 +282,17 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) { label = stringRes(def.labelRes), pinned = entry.stableKey in pinnedKeys, expanded = expandedItems[item] ?: false, - onTogglePin = { togglePin(entry) }, + onTogglePin = { state.togglePin(entry) }, onToggleExpand = { expandedItems[item] = !(expandedItems[item] ?: false) }, ) { - PickerChildren(item, pinnedKeys, accountViewModel, ::togglePin) + PickerChildren(item, pinnedKeys, accountViewModel, state::togglePin) } } else { SimpleAvailableRow( icon = def.icon, label = stringRes(def.labelRes), pinned = entry.stableKey in pinnedKeys, - onToggle = { togglePin(entry) }, + onToggle = { state.togglePin(entry) }, ) } } @@ -396,7 +379,8 @@ private fun GroupChildRow( accountViewModel: AccountViewModel, onTogglePin: (BottomBarEntry) -> Unit, ) { - val display = rememberGroupEntryDisplay(entry, accountViewModel) ?: return + // 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 ChildRow( leading = { GroupEntryAvatar(display, 28.dp, accountViewModel) }, label = display.label, @@ -675,7 +659,8 @@ private fun rememberPinnedVisual( is BottomBarEntry.RelayGroup, is BottomBarEntry.Concord, -> { - val display = rememberGroupEntryDisplay(entry, accountViewModel) + // 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/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)) + } +} From 9892ba495b7838980d8617b43909e83e748ab51c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 15:33:29 +0000 Subject: [PATCH 3/8] =?UTF-8?q?feat(ui):=20redesign=20the=20bottom-bar=20s?= =?UTF-8?q?etup=20screen=20=E2=80=94=20live=20preview=20+=20tab=20chips?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give the settings screen a point of view instead of a stock Material list. Presentation only — same BottomBarEntry model, holder and resolver underneath. - Live preview: a real mini nav bar at the top renders the pinned tabs (first one highlighted like the bar on open) and updates as you add, remove and reorder — the screen is now WYSIWYG. - Your tabs: the pinned set is a horizontal, drag-to-reorder chip strip that mirrors the bar's own shape, each chip with a leading avatar/icon and an ✕, plus an "n / 5" slot hint that turns red past the recommended count. - Available catalogue: collapsible category cards with a leading icon tile; every option row has a tinted circular leading and an "Add → ✓ Added" pill instead of a bare switch, so the control states its action and result. - Group rows show the real group avatar; Browser / Public Chats / Relay Groups / Concord expand to your favorites / joined groups (read-only, no REQ storm). Reorder reuses the holder's moveTransient/commit; all edit logic stays in the unit-tested BottomBarSettingsState. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017mxdSFQcarsKaL179tSub1 --- .../settings/BottomBarSettingsScreen.kt | 869 +++++++++++------- amethyst/src/main/res/values/strings.xml | 5 + 2 files changed, 549 insertions(+), 325 deletions(-) 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 f4cc7bbe80..f2694814f2 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 @@ -22,12 +22,15 @@ 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.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.fillMaxSize @@ -35,11 +38,15 @@ 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.layout.width +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.OutlinedButton 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 @@ -50,14 +57,18 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateMapOf 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 import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R @@ -71,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry 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.rememberGroupEntryDisplay import com.vitorpamplona.amethyst.ui.navigation.bottombars.stableKey @@ -83,6 +95,18 @@ 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 + @Composable @Preview(device = "spec:width=2100px,height=2340px,dpi=440") fun BottomBarSettingsScreenPreview() { @@ -110,35 +134,19 @@ fun BottomBarSettingsScreen( } } -/** 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, - ) - @Composable fun BottomBarSettingsContent(accountViewModel: AccountViewModel) { val bottomBarItemsFlow = accountViewModel.settings.uiSettingsFlow.bottomBarItems val savedItems by bottomBarItemsFlow.collectAsStateWithLifecycle() // 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 (a picker toggle, an - // external device edit, Restore Default) without clobbering an in-progress drag. + // 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) } val pinned = state.pinned val pinnedKeys = remember(pinned) { state.pinnedKeys() } - 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 expandedCategories = remember { mutableStateMapOf() } val expandedItems = remember { mutableStateMapOf() } @@ -146,165 +154,402 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) { 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), + // --- WYSIWYG preview: the bar you're actually building, updating live. --- + BottomBarPreview(pinned, accountViewModel) + + // --- Your tabs: a horizontal, drag-reorderable strip mirroring the bar's own shape. --- + SectionHeader( + title = stringRes(R.string.bottom_bar_settings_pinned), + trailing = "${pinned.size} / $RECOMMENDED_SLOTS", + over = pinned.size > RECOMMENDED_SLOTS, ) + PinnedTabsStrip(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 - state.restoreDefault() - }, - ) { + TextButton(onClick = { state.restoreDefault() }) { Text(stringRes(R.string.bottom_bar_settings_restore_default)) } } - // --- Pinned section: the current bottom bar, drag-reorderable. --- - SectionDivider(R.string.bottom_bar_settings_pinned) + Spacer(Modifier.height(4.dp)) - pinned.forEachIndexed { index, entry -> - val rowIsDragging = draggedItemIndex == index - val targetElevation = if (rowIsDragging) 8f else 0f - val animatedElevation by animateFloatAsState( - targetValue = targetElevation, - label = "dragElevation", - ) + // --- Available catalogue, grouped into collapsible category cards. --- + SectionHeader(title = stringRes(R.string.bottom_bar_settings_available)) - PinnedEntryCard( - entry = entry, + 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, - isDragging = rowIsDragging, - dragOffsetY = if (rowIsDragging) dragOffset else 0f, - elevation = animatedElevation, - onUnpin = { state.togglePin(entry) }, - onMeasured = { height -> itemHeights[index] = height }, - onDragStart = { - draggedItemIndex = index - dragOffset = 0f - }, - onDrag = { dragAmount -> - dragOffset += dragAmount + onTogglePin = state::togglePin, + ) + } - val currentIndex = draggedItemIndex - if (currentIndex < 0) return@PinnedEntryCard + Spacer(Modifier.height(24.dp)) + } +} - if (dragOffset < 0 && currentIndex > 0) { - val aboveHeight = itemHeights[currentIndex - 1] ?: 0f - if (-dragOffset > aboveHeight / 2f) { - // Swap with the row above (transient — persisted on drag end). - state.moveTransient(currentIndex, currentIndex - 1) +// ------------------------------------------------------------------------------------------------ +// Live preview +// ------------------------------------------------------------------------------------------------ - val h1 = itemHeights[currentIndex] - val h2 = itemHeights[currentIndex - 1] - if (h1 != null) itemHeights[currentIndex - 1] = h1 - if (h2 != null) itemHeights[currentIndex] = h2 +@Composable +private fun BottomBarPreview( + 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_preview), + style = MaterialTheme.typography.labelMedium, + color = accent, + fontWeight = FontWeight.Bold, + ) + Text( + text = stringRes(R.string.bottom_bar_settings_live), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } - 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(58.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 { + Row( + modifier = Modifier.fillMaxWidth().height(58.dp).padding(horizontal = 6.dp), + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.CenterVertically, + ) { + // Highlight the first tab as the landing destination, like the real bar on open. + pinned.forEachIndexed { index, entry -> + PreviewTab(entry, selected = index == 0, accountViewModel) } } + } + } + } + } +} - if (dragOffset > 0 && currentIndex < pinned.lastIndex) { - val belowHeight = itemHeights[currentIndex + 1] ?: 0f - if (dragOffset > belowHeight / 2f) { - // Swap with the row below (transient — persisted on drag end). - state.moveTransient(currentIndex, currentIndex + 1) +@Composable +private fun PreviewTab( + entry: BottomBarEntry, + selected: Boolean, + accountViewModel: AccountViewModel, +) { + val visual = rememberPinnedVisual(entry, accountViewModel) + val accent = MaterialTheme.colorScheme.primary + Column( + modifier = Modifier.width(58.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Box( + modifier = + Modifier + .size(width = 44.dp, height = 30.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 = if (selected) accent else MaterialTheme.colorScheme.onSurfaceVariant, + ) + is PinnedVisual.Avatar -> GroupEntryAvatar(visual.display, 22.dp, accountViewModel) + } + } + if (selected) { + Text( + text = visual.label, + style = MaterialTheme.typography.labelSmall, + color = accent, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.SemiBold, + ) + } + } +} - val h1 = itemHeights[currentIndex] - val h2 = itemHeights[currentIndex + 1] - if (h1 != null) itemHeights[currentIndex + 1] = h1 - if (h2 != null) itemHeights[currentIndex] = h2 +// ------------------------------------------------------------------------------------------------ +// Pinned tabs — horizontal, drag-to-reorder chip strip +// ------------------------------------------------------------------------------------------------ - dragOffset -= belowHeight - draggedItemIndex = currentIndex + 1 +@Composable +private fun PinnedTabsStrip( + state: BottomBarSettingsState, + pinned: List, + accountViewModel: AccountViewModel, +) { + if (pinned.isEmpty()) { + Text( + text = stringRes(R.string.bottom_bar_settings_pinned_empty), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = Size20dp, vertical = 6.dp), + ) + return + } + + var draggedIndex by remember { mutableIntStateOf(-1) } + var dragOffsetX by remember { mutableFloatStateOf(0f) } + val widths = remember { mutableStateMapOf() } + val scroll = rememberScrollState() + + Row( + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(scroll, enabled = draggedIndex < 0) + .padding(horizontal = Size20dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + pinned.forEachIndexed { index, entry -> + val dragging = draggedIndex == index + PinnedChip( + entry = entry, + accountViewModel = accountViewModel, + dragging = dragging, + dragOffsetX = if (dragging) dragOffsetX else 0f, + onRemove = { state.togglePin(entry) }, + onMeasured = { widths[index] = it }, + onDragStart = { + draggedIndex = index + dragOffsetX = 0f + }, + onDrag = { dx -> + dragOffsetX += dx + val current = draggedIndex + if (current < 0) return@PinnedChip + + 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 = { - draggedItemIndex = -1 - dragOffset = 0f + draggedIndex = -1 + dragOffsetX = 0f state.commit() }, onDragCancel = { - draggedItemIndex = -1 - dragOffset = 0f + draggedIndex = -1 + dragOffsetX = 0f }, ) + } + } +} - if (index < pinned.lastIndex) { - HorizontalDivider(modifier = Modifier.padding(horizontal = Size20dp)) +@Composable +private fun PinnedChip( + entry: BottomBarEntry, + accountViewModel: AccountViewModel, + dragging: Boolean, + dragOffsetX: Float, + onRemove: () -> Unit, + onMeasured: (Float) -> Unit, + onDragStart: () -> Unit, + onDrag: (Float) -> Unit, + onDragEnd: () -> Unit, + onDragCancel: () -> Unit, +) { + val visual = rememberPinnedVisual(entry, accountViewModel) + val elevation by animateFloatAsState(if (dragging) 8f else 0f, label = "chipElevation") + + Surface( + shape = CircleShape, + color = if (dragging) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceVariant, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = + Modifier + .onGloballyPositioned { onMeasured(it.size.width.toFloat()) } + .graphicsLayer { + translationX = dragOffsetX + shadowElevation = elevation + if (dragging) { + scaleX = 1.03f + scaleY = 1.03f + } + }.pointerInput(entry.stableKey) { + detectDragGestures( + onDragStart = { onDragStart() }, + onDrag = { change, amount -> + change.consume() + onDrag(amount.x) + }, + onDragEnd = { onDragEnd() }, + onDragCancel = { onDragCancel() }, + ) + }, + ) { + Row( + modifier = Modifier.padding(start = 6.dp, end = 4.dp, top = 5.dp, bottom = 5.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(7.dp), + ) { + LeadingVisual(visual, accountViewModel, size = 24.dp) + Text( + text = visual.label, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Box( + modifier = Modifier.size(24.dp).clip(CircleShape).clickable(onClick = onRemove), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = MaterialSymbols.Close, + contentDescription = stringRes(R.string.bottom_bar_settings_remove), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } + } +} - if (pinned.isEmpty()) { - Text( - text = stringRes(R.string.bottom_bar_settings_pinned_empty), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 8.dp, horizontal = Size20dp), - ) - } +// ------------------------------------------------------------------------------------------------ +// Available catalogue — category cards +// ------------------------------------------------------------------------------------------------ - // --- Available section: the full catalog, grouped into collapsible categories. --- - Spacer(modifier = Modifier.height(8.dp)) +@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), + ) { + Box( + modifier = + Modifier + .size(34.dp) + .clip(RoundedCornerShape(11.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + Icon( + 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, + ) + } - BottomBarCategories.forEach { category -> - val expanded = expandedCategories[category.titleRes] ?: false - CategoryHeader( - titleRes = category.titleRes, - expanded = expanded, - onToggle = { expandedCategories[category.titleRes] = !expanded }, - ) AnimatedVisibility(visible = expanded) { - Column { + 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) { - ExpandablePickerRow( + ExpandableAvailableRow( icon = def.icon, label = stringRes(def.labelRes), pinned = entry.stableKey in pinnedKeys, expanded = expandedItems[item] ?: false, - onTogglePin = { state.togglePin(entry) }, + onTogglePin = { onTogglePin(entry) }, onToggleExpand = { expandedItems[item] = !(expandedItems[item] ?: false) }, ) { - PickerChildren(item, pinnedKeys, accountViewModel, state::togglePin) + PickerChildren(item, pinnedKeys, accountViewModel, onTogglePin) } } else { - SimpleAvailableRow( - icon = def.icon, + AvailableRow( + leading = { LeadingGlyph(def.icon, tinted = true) }, label = stringRes(def.labelRes), + subtitle = null, pinned = entry.stableKey in pinnedKeys, - onToggle = { state.togglePin(entry) }, + onToggle = { onTogglePin(entry) }, ) } } } } } - - Spacer(modifier = Modifier.height(16.dp)) } } -/** The joined-groups (or favorites) child rows revealed when an expandable picker row opens. */ +/** Child rows (favorites / joined groups) revealed when an expandable picker row opens. */ @Composable private fun PickerChildren( item: NavBarItem, @@ -320,11 +565,14 @@ private fun PickerChildren( } else { favorites.forEach { fav -> val entry = BottomBarEntry.Favorite(fav.id) - ChildRow( - leading = { FavoriteChildIcon(fav) }, + val icon = if (fav is FavoriteApp.NostrApp) MaterialSymbols.Apps else MaterialSymbols.Public + AvailableRow( + leading = { LeadingGlyph(icon, tinted = true) }, label = fav.label, + subtitle = null, pinned = entry.stableKey in pinnedKeys, onToggle = { onTogglePin(entry) }, + indent = true, ) } } @@ -333,39 +581,27 @@ private fun PickerChildren( NavBarItem.PUBLIC_CHATS -> { val channels by accountViewModel.account.publicChatList.flow .collectAsStateWithLifecycle() - val sorted = remember(channels) { channels.map { BottomBarEntry.PublicChat(it.eventId) } } - if (sorted.isEmpty()) { - EmptyChildHint(R.string.bottom_bar_settings_no_groups) - } else { - sorted.forEach { entry -> GroupChildRow(entry, pinnedKeys, accountViewModel, onTogglePin) } - } + 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 sorted = + val entries = remember(groups) { groups .sortedBy { (it.name ?: it.groupId).lowercase() } .map { BottomBarEntry.RelayGroup(it.groupId, it.relayUrl) } } - if (sorted.isEmpty()) { - EmptyChildHint(R.string.bottom_bar_settings_no_groups) - } else { - sorted.forEach { entry -> GroupChildRow(entry, pinnedKeys, accountViewModel, onTogglePin) } - } + GroupChildList(entries, pinnedKeys, accountViewModel, onTogglePin) } NavBarItem.CONCORD -> { val communities by accountViewModel.account.concordChannelList.liveCommunities .collectAsStateWithLifecycle() - val sorted = remember(communities) { communities.map { BottomBarEntry.Concord(it.id) } } - if (sorted.isEmpty()) { - EmptyChildHint(R.string.bottom_bar_settings_no_groups) - } else { - sorted.forEach { entry -> GroupChildRow(entry, pinnedKeys, accountViewModel, onTogglePin) } - } + val entries = remember(communities) { communities.map { BottomBarEntry.Concord(it.id) } } + GroupChildList(entries, pinnedKeys, accountViewModel, onTogglePin) } else -> {} @@ -373,88 +609,76 @@ private fun PickerChildren( } @Composable -private fun GroupChildRow( - entry: BottomBarEntry, +private fun GroupChildList( + entries: List, pinnedKeys: Set, accountViewModel: AccountViewModel, onTogglePin: (BottomBarEntry) -> Unit, ) { - // 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 - ChildRow( - leading = { GroupEntryAvatar(display, 28.dp, accountViewModel) }, - label = display.label, - pinned = entry.stableKey in pinnedKeys, - onToggle = { onTogglePin(entry) }, - ) -} - -@Composable -private fun FavoriteChildIcon(app: FavoriteApp) { - val icon = if (app is FavoriteApp.NostrApp) MaterialSymbols.Apps else MaterialSymbols.Public - NavBarIconBox(icon, app.label) -} - -@Composable -private fun CategoryHeader( - titleRes: Int, - expanded: Boolean, - onToggle: () -> Unit, -) { - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable(onClick = onToggle) - .padding(top = 16.dp, bottom = 6.dp, start = Size20dp, end = Size20dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringRes(titleRes), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.weight(1f), - ) - Icon( - symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, + 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, 30.dp, accountViewModel) }, + label = display.label, + subtitle = null, + pinned = entry.stableKey in pinnedKeys, + onToggle = { onTogglePin(entry) }, + indent = true, ) } - HorizontalDivider(modifier = Modifier.padding(horizontal = Size20dp)) } +// ------------------------------------------------------------------------------------------------ +// Rows & shared bits +// ------------------------------------------------------------------------------------------------ + @Composable -private fun SimpleAvailableRow( - icon: MaterialSymbol, +private fun AvailableRow( + leading: @Composable () -> Unit, label: String, + subtitle: String?, pinned: Boolean, onToggle: () -> Unit, + indent: Boolean = false, ) { Row( modifier = Modifier .fillMaxWidth() .clickable(onClick = onToggle) - .padding(vertical = 8.dp, horizontal = Size20dp), + .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), ) { - NavBarIconBox(icon, label) - Text( - text = label, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - Switch(checked = pinned, onCheckedChange = { onToggle() }) + leading() + Column(Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + AddPill(added = pinned, onClick = onToggle) } } @Composable -private fun ExpandablePickerRow( +private fun ExpandableAvailableRow( icon: MaterialSymbol, label: String, pinned: Boolean, @@ -468,11 +692,11 @@ private fun ExpandablePickerRow( Modifier .fillMaxWidth() .clickable(onClick = onToggleExpand) - .padding(vertical = 8.dp, horizontal = Size20dp), + .padding(start = 13.dp, end = 13.dp, top = 7.dp, bottom = 7.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - NavBarIconBox(icon, label) + LeadingGlyph(icon, tinted = true) Text( text = label, style = MaterialTheme.typography.bodyLarge, @@ -483,41 +707,132 @@ private fun ExpandablePickerRow( Icon( symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, contentDescription = stringRes(R.string.bottom_bar_settings_expand), - modifier = Modifier.size(24.dp), + modifier = Modifier.size(22.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) - Switch(checked = pinned, onCheckedChange = { onTogglePin() }) + AddPill(added = pinned, onClick = onTogglePin) } AnimatedVisibility(visible = expanded) { Column { children() } } } +/** Outlined "Add" that fills to "Added" once pinned — states the action and its result. */ @Composable -private fun ChildRow( - leading: @Composable () -> Unit, - label: String, - pinned: Boolean, - onToggle: () -> Unit, +private fun AddPill( + added: Boolean, + onClick: () -> Unit, +) { + val accent = MaterialTheme.colorScheme.primary + if (added) { + Surface( + shape = CircleShape, + color = accent, + ) { + Row( + modifier = Modifier.clickable(onClick = onClick).padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + symbol = MaterialSymbols.Check, + contentDescription = null, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colorScheme.onPrimary, + ) + Text( + stringRes(R.string.bottom_bar_settings_added), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onPrimary, + ) + } + } + } else { + OutlinedButton( + onClick = onClick, + border = BorderStroke(1.dp, accent), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + ) { + Icon( + symbol = MaterialSymbols.Add, + contentDescription = null, + modifier = Modifier.size(15.dp), + tint = accent, + ) + Spacer(Modifier.width(4.dp)) + Text(stringRes(R.string.bottom_bar_settings_add), style = MaterialTheme.typography.labelLarge, color = accent) + } + } +} + +/** A category glyph in a soft accent-tinted circle. */ +@Composable +private fun LeadingGlyph( + icon: MaterialSymbol, + tinted: Boolean, +) { + val bg = if (tinted) MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) else MaterialTheme.colorScheme.surfaceVariant + Box( + modifier = Modifier.size(34.dp).clip(CircleShape).background(bg), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = icon, + contentDescription = null, + modifier = Modifier.size(19.dp), + tint = if (tinted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun LeadingVisual( + visual: PinnedVisual, + accountViewModel: AccountViewModel, + size: Dp, +) { + when (visual) { + is PinnedVisual.Glyph -> + Box( + modifier = Modifier.size(size + 6.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = visual.icon, + contentDescription = visual.label, + modifier = Modifier.size(size * 0.7f), + tint = MaterialTheme.colorScheme.primary, + ) + } + is PinnedVisual.Avatar -> GroupEntryAvatar(visual.display, size, accountViewModel) + } +} + +@Composable +private fun SectionHeader( + title: String, + trailing: String? = null, + over: Boolean = false, ) { Row( - modifier = - Modifier - .fillMaxWidth() - .clickable(onClick = onToggle) - .padding(start = 44.dp, top = 6.dp, end = Size20dp, bottom = 6.dp), + modifier = Modifier.fillMaxWidth().padding(start = Size20dp, end = Size20dp, top = 18.dp, bottom = 6.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - leading() Text( - text = label, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + text = title, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f), ) - Switch(checked = pinned, onCheckedChange = { onToggle() }) + if (trailing != null) { + Text( + text = trailing, + style = MaterialTheme.typography.labelMedium, + color = if (over) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + } } } @@ -527,103 +842,25 @@ private fun EmptyChildHint(textRes: Int) { text = stringRes(textRes), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 44.dp, top = 6.dp, end = Size20dp, bottom = 6.dp), + modifier = Modifier.padding(start = 24.dp, end = 13.dp, top = 6.dp, bottom = 6.dp), ) } -@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 PinnedEntryCard( - entry: BottomBarEntry, - accountViewModel: AccountViewModel, - isDragging: Boolean, - dragOffsetY: Float, - elevation: Float, - onUnpin: () -> Unit, - onMeasured: (Float) -> Unit, - onDragStart: () -> Unit, - onDrag: (Float) -> Unit, - onDragEnd: () -> Unit, - onDragCancel: () -> Unit, - modifier: Modifier = Modifier, -) { - Column( - modifier = - modifier - .fillMaxWidth() - .graphicsLayer { - translationY = dragOffsetY - shadowElevation = elevation - if (isDragging) { - scaleX = 1.02f - scaleY = 1.02f - } - }.onGloballyPositioned { coordinates -> - onMeasured(coordinates.size.height.toFloat()) - }.padding(vertical = 8.dp, horizontal = Size20dp) - .pointerInput(Unit) { - detectDragGestures( - onDragStart = { onDragStart() }, - onDrag = { change, dragAmount -> - change.consume() - onDrag(dragAmount.y) - }, - onDragEnd = { onDragEnd() }, - onDragCancel = { onDragCancel() }, - ) - }, - ) { - val visual = rememberPinnedVisual(entry, accountViewModel) - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - when (visual) { - is PinnedVisual.Glyph -> NavBarIconBox(visual.icon, visual.label) - is PinnedVisual.Avatar -> GroupEntryAvatar(visual.display, 28.dp, accountViewModel) - } - - Text( - text = visual.label, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - - Switch( - checked = true, - onCheckedChange = { onUnpin() }, - ) - - Box( - modifier = Modifier.size(28.dp), - contentAlignment = Alignment.Center, - ) { - Icon( - MaterialSymbols.DragIndicator, - contentDescription = stringRes(R.string.bottom_bar_settings_reorder), - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } +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 } -} -/** Resolved leading + label for a pinned entry, computed once so a group's channel is subscribed once. */ +// ------------------------------------------------------------------------------------------------ +// Leading/label resolution for a pinned entry (built-in glyph, favorite glyph, or group avatar). +// Computed once so a group's channel is subscribed at most once per row. +// ------------------------------------------------------------------------------------------------ + private sealed interface PinnedVisual { val label: String @@ -664,21 +901,3 @@ private fun rememberPinnedVisual( if (display != null) PinnedVisual.Avatar(display) else PinnedVisual.Glyph(MaterialSymbols.Group, "") } } - -@Composable -private fun NavBarIconBox( - icon: MaterialSymbol, - label: String, -) { - Box( - modifier = Modifier.size(28.dp), - contentAlignment = Alignment.Center, - ) { - Icon( - symbol = icon, - contentDescription = label, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onBackground, - ) - } -} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0d65224e53..98fe5c0a7d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2830,6 +2830,11 @@ Show options No favorites yet. Star an app in the Browser to add it here. No joined groups yet. + Preview + Live + Add + Added + Remove Restore Default Main Chats & Groups From 9180061655b5764d6571ef9399c2067dfad0f8c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 16:09:01 +0000 Subject: [PATCH 4/8] feat(ui): drag-reorder inside the preview bar + real favicons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address two gaps in the redesigned bottom-bar setup screen. - The preview IS the editor now: the mini nav bar is the reorder & remove surface. Drag a tab within the bar to reorder it (holder moveTransient/commit), tap its ✕ badge to remove. Drops the separate chip strip — one WYSIWYG bar instead of a preview plus a duplicate list. - Favorites render their real favicon / nsite / napplet icon (via the same FavoriteAppIcon + rememberFavoriteIconModel the live bar uses) instead of a generic globe glyph — in the preview tabs and the Browser picker rows alike. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017mxdSFQcarsKaL179tSub1 --- .../settings/BottomBarSettingsScreen.kt | 378 +++++++----------- amethyst/src/main/res/values/strings.xml | 3 +- 2 files changed, 150 insertions(+), 231 deletions(-) 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 f2694814f2..124fb82367 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 @@ -26,12 +26,13 @@ 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.horizontalScroll 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.PaddingValues 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 @@ -68,11 +69,12 @@ 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 import androidx.compose.ui.unit.dp +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 @@ -84,6 +86,7 @@ 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 @@ -158,16 +161,8 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) { ) { Spacer(Modifier.height(12.dp)) - // --- WYSIWYG preview: the bar you're actually building, updating live. --- - BottomBarPreview(pinned, accountViewModel) - - // --- Your tabs: a horizontal, drag-reorderable strip mirroring the bar's own shape. --- - SectionHeader( - title = stringRes(R.string.bottom_bar_settings_pinned), - trailing = "${pinned.size} / $RECOMMENDED_SLOTS", - over = pinned.size > RECOMMENDED_SLOTS, - ) - PinnedTabsStrip(state, pinned, accountViewModel) + // --- The editable bar: a real preview you drag to reorder and tap ✕ to remove from. --- + EditableBarCard(state, pinned, accountViewModel) Row( modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp), @@ -200,11 +195,12 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) { } // ------------------------------------------------------------------------------------------------ -// Live preview +// The editable preview bar — WYSIWYG: this IS the reorder & remove surface. // ------------------------------------------------------------------------------------------------ @Composable -private fun BottomBarPreview( +private fun EditableBarCard( + state: BottomBarSettingsState, pinned: List, accountViewModel: AccountViewModel, ) { @@ -222,15 +218,16 @@ private fun BottomBarPreview( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = stringRes(R.string.bottom_bar_settings_preview), + text = stringRes(R.string.bottom_bar_settings_pinned), style = MaterialTheme.typography.labelMedium, color = accent, fontWeight = FontWeight.Bold, ) Text( - text = stringRes(R.string.bottom_bar_settings_live), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = "${pinned.size} / $RECOMMENDED_SLOTS", + style = MaterialTheme.typography.labelMedium, + color = if (pinned.size > RECOMMENDED_SLOTS) MaterialTheme.colorScheme.error else accent, + fontWeight = FontWeight.Bold, ) } @@ -241,7 +238,7 @@ private fun BottomBarPreview( modifier = Modifier.fillMaxWidth(), ) { if (pinned.isEmpty()) { - Box(Modifier.fillMaxWidth().height(58.dp), contentAlignment = Alignment.Center) { + Box(Modifier.fillMaxWidth().height(60.dp), contentAlignment = Alignment.Center) { Text( stringRes(R.string.bottom_bar_settings_pinned_empty), style = MaterialTheme.typography.bodySmall, @@ -250,108 +247,43 @@ private fun BottomBarPreview( ) } } else { - Row( - modifier = Modifier.fillMaxWidth().height(58.dp).padding(horizontal = 6.dp), - horizontalArrangement = Arrangement.SpaceAround, - verticalAlignment = Alignment.CenterVertically, - ) { - // Highlight the first tab as the landing destination, like the real bar on open. - pinned.forEachIndexed { index, entry -> - PreviewTab(entry, selected = index == 0, accountViewModel) - } - } + EditableBar(state, pinned, accountViewModel) } } - } - } -} -@Composable -private fun PreviewTab( - entry: BottomBarEntry, - selected: Boolean, - accountViewModel: AccountViewModel, -) { - val visual = rememberPinnedVisual(entry, accountViewModel) - val accent = MaterialTheme.colorScheme.primary - Column( - modifier = Modifier.width(58.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(3.dp), - ) { - Box( - modifier = - Modifier - .size(width = 44.dp, height = 30.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 = if (selected) accent else MaterialTheme.colorScheme.onSurfaceVariant, - ) - is PinnedVisual.Avatar -> GroupEntryAvatar(visual.display, 22.dp, accountViewModel) - } - } - if (selected) { Text( - text = visual.label, + text = stringRes(R.string.bottom_bar_settings_reorder_hint), style = MaterialTheme.typography.labelSmall, - color = accent, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), ) } } } -// ------------------------------------------------------------------------------------------------ -// Pinned tabs — horizontal, drag-to-reorder chip strip -// ------------------------------------------------------------------------------------------------ - @Composable -private fun PinnedTabsStrip( +private fun EditableBar( state: BottomBarSettingsState, pinned: List, accountViewModel: AccountViewModel, ) { - if (pinned.isEmpty()) { - Text( - text = stringRes(R.string.bottom_bar_settings_pinned_empty), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = Size20dp, vertical = 6.dp), - ) - return - } - var draggedIndex by remember { mutableIntStateOf(-1) } var dragOffsetX by remember { mutableFloatStateOf(0f) } val widths = remember { mutableStateMapOf() } - val scroll = rememberScrollState() Row( - modifier = - Modifier - .fillMaxWidth() - .horizontalScroll(scroll, enabled = draggedIndex < 0) - .padding(horizontal = Size20dp, vertical = 6.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().height(64.dp).padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { pinned.forEachIndexed { index, entry -> val dragging = draggedIndex == index - PinnedChip( + PreviewTab( entry = entry, - accountViewModel = accountViewModel, + // 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 = { @@ -361,7 +293,7 @@ private fun PinnedTabsStrip( onDrag = { dx -> dragOffsetX += dx val current = draggedIndex - if (current < 0) return@PinnedChip + if (current < 0) return@PreviewTab if (dragOffsetX < 0 && current > 0) { val leftW = widths[current - 1] ?: 0f @@ -395,11 +327,12 @@ private fun PinnedTabsStrip( } @Composable -private fun PinnedChip( +private fun RowScope.PreviewTab( entry: BottomBarEntry, - accountViewModel: AccountViewModel, + selected: Boolean, dragging: Boolean, dragOffsetX: Float, + accountViewModel: AccountViewModel, onRemove: () -> Unit, onMeasured: (Float) -> Unit, onDragStart: () -> Unit, @@ -408,22 +341,19 @@ private fun PinnedChip( onDragCancel: () -> Unit, ) { val visual = rememberPinnedVisual(entry, accountViewModel) - val elevation by animateFloatAsState(if (dragging) 8f else 0f, label = "chipElevation") + val lift by animateFloatAsState(if (dragging) 1.12f else 1f, label = "tabLift") - Surface( - shape = CircleShape, - color = if (dragging) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceVariant, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + Box( modifier = Modifier + .weight(1f) + .fillMaxWidth() + .zIndex(if (dragging) 1f else 0f) .onGloballyPositioned { onMeasured(it.size.width.toFloat()) } .graphicsLayer { translationX = dragOffsetX - shadowElevation = elevation - if (dragging) { - scaleX = 1.03f - scaleY = 1.03f - } + scaleX = lift + scaleY = lift }.pointerInput(entry.stableKey) { detectDragGestures( onDragStart = { onDragStart() }, @@ -435,31 +365,76 @@ private fun PinnedChip( onDragCancel = { onDragCancel() }, ) }, + contentAlignment = Alignment.Center, ) { - Row( - modifier = Modifier.padding(start = 6.dp, end = 4.dp, top = 5.dp, bottom = 5.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(7.dp), + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.padding(top = 6.dp), ) { - LeadingVisual(visual, accountViewModel, size = 24.dp) - Text( - text = visual.label, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Box( - modifier = Modifier.size(24.dp).clip(CircleShape).clickable(onClick = onRemove), - contentAlignment = Alignment.Center, - ) { - Icon( - symbol = MaterialSymbols.Close, - contentDescription = stringRes(R.string.bottom_bar_settings_remove), - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, + 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, +) { + Box( + modifier = + modifier + .size(18.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(onClick = onRemove), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = MaterialSymbols.Close, + contentDescription = stringRes(R.string.bottom_bar_settings_remove), + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** 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) + } } } @@ -535,9 +510,8 @@ private fun CategoryCard( } } else { AvailableRow( - leading = { LeadingGlyph(def.icon, tinted = true) }, + leading = { LeadingGlyph(def.icon) }, label = stringRes(def.labelRes), - subtitle = null, pinned = entry.stableKey in pinnedKeys, onToggle = { onTogglePin(entry) }, ) @@ -565,11 +539,9 @@ private fun PickerChildren( } else { favorites.forEach { fav -> val entry = BottomBarEntry.Favorite(fav.id) - val icon = if (fav is FavoriteApp.NostrApp) MaterialSymbols.Apps else MaterialSymbols.Public AvailableRow( - leading = { LeadingGlyph(icon, tinted = true) }, + leading = { FavoriteLeading(fav) }, label = fav.label, - subtitle = null, pinned = entry.stableKey in pinnedKeys, onToggle = { onTogglePin(entry) }, indent = true, @@ -623,9 +595,8 @@ private fun GroupChildList( // 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, 30.dp, accountViewModel) }, + leading = { GroupEntryAvatar(display, 34.dp, accountViewModel) }, label = display.label, - subtitle = null, pinned = entry.stableKey in pinnedKeys, onToggle = { onTogglePin(entry) }, indent = true, @@ -641,7 +612,6 @@ private fun GroupChildList( private fun AvailableRow( leading: @Composable () -> Unit, label: String, - subtitle: String?, pinned: Boolean, onToggle: () -> Unit, indent: Boolean = false, @@ -656,23 +626,13 @@ private fun AvailableRow( horizontalArrangement = Arrangement.spacedBy(12.dp), ) { leading() - Column(Modifier.weight(1f)) { - Text( - text = label, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (subtitle != null) { - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) AddPill(added = pinned, onClick = onToggle) } } @@ -696,7 +656,7 @@ private fun ExpandableAvailableRow( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - LeadingGlyph(icon, tinted = true) + LeadingGlyph(icon) Text( text = label, style = MaterialTheme.typography.bodyLarge, @@ -725,10 +685,7 @@ private fun AddPill( ) { val accent = MaterialTheme.colorScheme.primary if (added) { - Surface( - shape = CircleShape, - color = accent, - ) { + Surface(shape = CircleShape, color = accent) { Row( modifier = Modifier.clickable(onClick = onClick).padding(horizontal = 12.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, @@ -740,11 +697,7 @@ private fun AddPill( modifier = Modifier.size(15.dp), tint = MaterialTheme.colorScheme.onPrimary, ) - Text( - stringRes(R.string.bottom_bar_settings_added), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onPrimary, - ) + Text(stringRes(R.string.bottom_bar_settings_added), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onPrimary) } } } else { @@ -753,89 +706,51 @@ private fun AddPill( border = BorderStroke(1.dp, accent), contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), ) { - Icon( - symbol = MaterialSymbols.Add, - contentDescription = null, - modifier = Modifier.size(15.dp), - tint = accent, - ) + Icon(symbol = MaterialSymbols.Add, contentDescription = null, modifier = Modifier.size(15.dp), tint = accent) Spacer(Modifier.width(4.dp)) Text(stringRes(R.string.bottom_bar_settings_add), style = MaterialTheme.typography.labelLarge, color = accent) } } } -/** A category glyph in a soft accent-tinted circle. */ +/** A category/destination glyph in a soft accent-tinted circle. */ @Composable -private fun LeadingGlyph( - icon: MaterialSymbol, - tinted: Boolean, -) { - val bg = if (tinted) MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) else MaterialTheme.colorScheme.surfaceVariant +private fun LeadingGlyph(icon: MaterialSymbol) { Box( - modifier = Modifier.size(34.dp).clip(CircleShape).background(bg), + 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 = if (tinted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - ) + 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 LeadingVisual( - visual: PinnedVisual, - accountViewModel: AccountViewModel, - size: Dp, -) { - when (visual) { - is PinnedVisual.Glyph -> - Box( - modifier = Modifier.size(size + 6.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)), - contentAlignment = Alignment.Center, - ) { - Icon( - symbol = visual.icon, - contentDescription = visual.label, - modifier = Modifier.size(size * 0.7f), - tint = MaterialTheme.colorScheme.primary, - ) - } - is PinnedVisual.Avatar -> GroupEntryAvatar(visual.display, size, accountViewModel) - } -} - -@Composable -private fun SectionHeader( - title: String, - trailing: String? = null, - over: Boolean = false, -) { - Row( - modifier = Modifier.fillMaxWidth().padding(start = Size20dp, end = Size20dp, top = 18.dp, bottom = 6.dp), - verticalAlignment = Alignment.CenterVertically, +private fun FavoriteLeading(app: FavoriteApp) { + Box( + modifier = Modifier.size(34.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center, ) { - Text( - text = title, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f), + FavoriteAppIcon( + app = app, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp), + iconModel = rememberFavoriteIconModel(app), ) - if (trailing != null) { - Text( - text = trailing, - style = MaterialTheme.typography.labelMedium, - color = if (over) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - ) - } } } +@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( @@ -857,7 +772,7 @@ private fun categoryIcon(titleRes: Int): MaterialSymbol = } // ------------------------------------------------------------------------------------------------ -// Leading/label resolution for a pinned entry (built-in glyph, favorite glyph, or group avatar). +// 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. // ------------------------------------------------------------------------------------------------ @@ -869,6 +784,12 @@ private sealed interface PinnedVisual { 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 { @@ -889,8 +810,7 @@ private fun rememberPinnedVisual( is BottomBarEntry.Favorite -> { val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() val app = favorites.firstOrNull { it.id == entry.favoriteId } - val icon = if (app is FavoriteApp.NostrApp) MaterialSymbols.Apps else MaterialSymbols.Public - PinnedVisual.Glyph(icon, app?.label ?: "") + if (app != null) PinnedVisual.Favorite(app) else PinnedVisual.Glyph(MaterialSymbols.Public, "") } is BottomBarEntry.PublicChat, is BottomBarEntry.RelayGroup, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 98fe5c0a7d..2843617bc0 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2830,8 +2830,7 @@ Show options No favorites yet. Star an app in the Browser to add it here. No joined groups yet. - Preview - Live + Drag to reorder · tap ✕ to remove Add Added Remove From 8212e52a50e32c080196ee6a1b248da016148f12 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 16:36:09 +0000 Subject: [PATCH 5/8] fix(ui): expand inner options straight down, not diagonally AnimatedVisibility's default enter (fadeIn + expandIn from the bottom-end) made the category / chat child lists slide in from the top-left. Switch both to a pure vertical expandVertically(Top) / shrinkVertically(Top) so the options unroll straight down with the toggle, matching the accordion's open gesture. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017mxdSFQcarsKaL179tSub1 --- .../loggedIn/settings/BottomBarSettingsScreen.kt | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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 124fb82367..8f46301eec 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 @@ -22,6 +22,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState +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 @@ -110,6 +114,11 @@ private val ExpandableItems = /** 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() { @@ -492,7 +501,7 @@ private fun CategoryCard( ) } - AnimatedVisibility(visible = expanded) { + AnimatedVisibility(visible = expanded, enter = SectionExpand, exit = SectionCollapse) { Column(Modifier.padding(bottom = 6.dp)) { category.items.forEach { item -> val def = NavBarCatalog[item] ?: return@forEach @@ -672,7 +681,7 @@ private fun ExpandableAvailableRow( ) AddPill(added = pinned, onClick = onTogglePin) } - AnimatedVisibility(visible = expanded) { + AnimatedVisibility(visible = expanded, enter = SectionExpand, exit = SectionCollapse) { Column { children() } } } From e1a9a5cd7e4c3be78ebbd0c1abe055ca45a3f977 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 18:12:19 +0000 Subject: [PATCH 6/8] fix(ui): keep drag alive across swaps + equalize Add/Added pill height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drag reorder: key each preview tab by its stable identity. Without a key the tabs were position-identified, so when a swap reordered the list, the slot under the finger recomposed with a different entry — restarting its pointerInput (keyed on the entry) and cancelling the in-flight gesture, so dragging stopped on the first swap. Keying moves the dragged composable (and its live gesture) instead. - Add/Added button: both states now share one Surface + Row body (only color, border and tint differ) instead of an OutlinedButton vs a Surface. The OutlinedButton's ~40dp min height made "Add" taller than "Added" and broke row alignment; a single body keeps the pill height constant. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017mxdSFQcarsKaL179tSub1 --- .../settings/BottomBarSettingsScreen.kt | 146 +++++++++--------- 1 file changed, 75 insertions(+), 71 deletions(-) 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 8f46301eec..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 @@ -34,7 +34,6 @@ 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.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer @@ -43,13 +42,11 @@ 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.layout.width 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.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -57,6 +54,7 @@ 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 @@ -285,52 +283,57 @@ private fun EditableBar( verticalAlignment = Alignment.CenterVertically, ) { pinned.forEachIndexed { index, entry -> - 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 + // 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 > 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 + 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 - }, - ) + }, + onDragEnd = { + draggedIndex = -1 + dragOffsetX = 0f + state.commit() + }, + onDragCancel = { + draggedIndex = -1 + dragOffsetX = 0f + }, + ) + } } } } @@ -686,38 +689,39 @@ private fun ExpandableAvailableRow( } } -/** Outlined "Add" that fills to "Added" once pinned — states the action and its result. */ +/** + * 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 - if (added) { - Surface(shape = CircleShape, color = accent) { - Row( - modifier = Modifier.clickable(onClick = onClick).padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Icon( - symbol = MaterialSymbols.Check, - contentDescription = null, - modifier = Modifier.size(15.dp), - tint = MaterialTheme.colorScheme.onPrimary, - ) - Text(stringRes(R.string.bottom_bar_settings_added), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onPrimary) - } - } - } else { - OutlinedButton( - onClick = onClick, - border = BorderStroke(1.dp, accent), - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + 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 = MaterialSymbols.Add, contentDescription = null, modifier = Modifier.size(15.dp), tint = accent) - Spacer(Modifier.width(4.dp)) - Text(stringRes(R.string.bottom_bar_settings_add), style = MaterialTheme.typography.labelLarge, color = accent) + 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, + ) } } } From 7dc033ec796aa2db7ed6c82cd395ad19329cf439 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 18:33:19 +0000 Subject: [PATCH 7/8] fix(ui): show NIP-29 group names in the picker, not their ids The read-only settings picker never fetches a group's kind-39000 metadata, so with no cached event RelayGroupChannel.toBestDisplayName() fell back to the raw group id. Resolve the label from the group's metadata name when loaded, otherwise the name the user's joined-groups list already stored for it (the NIP-51 ["group", id, relay, name] tag), and only then the id. Also helps the live bar before 39000 arrives. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017mxdSFQcarsKaL179tSub1 --- .../bottombars/GroupBottomBarEntries.kt | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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 index 7b0a2a60ef..242dcf9eef 100644 --- 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 @@ -110,8 +110,21 @@ fun rememberRelayGroupEntryDisplay( // 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 = current?.toBestDisplayName() ?: entry.groupId, + label = metaName ?: tagName ?: entry.groupId, robotSeed = entry.groupId, model = current?.profilePicture(), route = Route.RelayGroup(entry.groupId, entry.relayUrl), From 029f012fe89b4a53721f0bdbefa3744da4c094c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 18:40:16 +0000 Subject: [PATCH 8/8] feat(nav): pinned group tabs behave as bottom-nav roots The public-chat, relay-group and Concord-community screens are reached both as pushed details (from a list) and as pinned bottom-nav tabs. Give them the same dual-mode chrome the other tab roots use, keyed off nav.canPop(): - Show the back arrow only when there is something to pop. Added an optional showBackButton (defaulting to the current behavior) to TopBarExtensibleWithBackButton and passed nav.canPop() from PublicChatTopBar and RelayGroupTopBar; gated the Concord list screen's back icon the same way. - Render AppBottomBar in all three screens. It hides itself when canPop, so a pushed instance shows the back arrow and no bar, and a bottom-nav instance shows the bar and no back arrow. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017mxdSFQcarsKaL179tSub1 --- .../topbars/TopBarExtensibleWithBackButton.kt | 10 +++++++++- .../concord/ConcordChannelListScreen.kt | 15 +++++++++++++-- .../nip28PublicChat/PublicChatChannelScreen.kt | 10 ++++++++++ .../nip28PublicChat/header/PublicChatTopBar.kt | 2 ++ .../relayGroup/RelayGroupChatScreen.kt | 10 ++++++++++ .../publicChannels/relayGroup/RelayGroupTopBar.kt | 2 ++ 6 files changed, 46 insertions(+), 3 deletions(-) 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 1ec3fcc42f..5d7edd605d 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, )