Merge pull request #3576 from vitorpamplona/claude/bottom-nav-item-selection-xt85y9

Bottom nav: pin individual chats/groups + redesigned setup screen
This commit is contained in:
Vitor Pamplona
2026-07-15 14:51:19 -04:00
committed by GitHub
17 changed files with 1470 additions and 378 deletions
@@ -24,7 +24,6 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
@@ -185,67 +184,64 @@ private fun RenderBottomMenu(
containerColor = MaterialTheme.colorScheme.background,
tonalElevation = Size0dp,
) {
// Render in the user's saved order, built-ins and favorites interleaved.
// Render in the user's saved order built-ins, favorites and pinned groups interleaved.
// Each entry resolves to a shared BottomBarSlot (route + icon), the same one the rail uses.
items.forEach { entry ->
when (entry) {
is BottomBarEntry.BuiltIn -> {
val def = NavBarCatalog[entry.item] ?: return@forEach
val destination = remember(def, accountViewModel) { def.resolveRoute(accountViewModel) }
HasNewItemsIcon(destination == selectedRoute, def, destination, accountViewModel, nav)
}
is BottomBarEntry.Favorite -> {
val fav = favoritesById[entry.favoriteId] ?: return@forEach
val destination =
when (fav) {
is FavoriteApp.WebApp -> Route.WebApp(fav.url)
is FavoriteApp.NostrApp -> Route.NostrApp(fav.coordinate)
}
FavoriteNavItem(destination == selectedRoute, fav, rememberFavoriteIconModel(fav), destination, nav)
}
}
val slot = rememberBottomBarSlot(entry, favoritesById, accountViewModel) ?: return@forEach
val selected = slot.route == selectedRoute
NavigationBarItem(
alwaysShowLabel = false,
icon = { slot.icon(selected) },
selected = selected,
onClick = { nav(slot.route) },
)
}
}
}
}
@Composable
private fun RowScope.FavoriteNavItem(
selected: Boolean,
fav: FavoriteApp,
iconModel: Any?,
destination: Route,
nav: (Route) -> Unit,
) {
NavigationBarItem(
alwaysShowLabel = false,
icon = { FavoriteEntryIcon(fav, selected, iconModel) },
// No label — favorite tabs match the built-in items, which show icon only.
selected = selected,
onClick = { nav(destination) },
)
}
/**
* A resolved bottom-bar/rail slot: the [route] to navigate to and the [icon] to draw for a given
* selected state. Built from a [BottomBarEntry] by [rememberBottomBarSlot], so the phone bar and the
* large-screen rail render every entry kind (built-in, favorite, pinned group) through one code path.
*/
class BottomBarSlot(
val route: Route,
val icon: @Composable (selected: Boolean) -> Unit,
)
/** Resolves an entry to its live [BottomBarSlot], or null if it no longer resolves (deleted favorite, etc.). */
@Composable
private fun RowScope.HasNewItemsIcon(
selected: Boolean,
def: NavBarItemDef,
destination: Route,
internal fun rememberBottomBarSlot(
entry: BottomBarEntry,
favoritesById: Map<String, FavoriteApp>,
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. */
@@ -32,9 +32,10 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -75,56 +76,42 @@ fun AppNavigationRail(
modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Same entry list + resolution as the phone bottom bar; only the item chrome and the
// selection source (live back stack vs the bar's passed-in route) differ per surface.
items.forEach { entry ->
when (entry) {
is BottomBarEntry.BuiltIn -> {
val def = NavBarCatalog[entry.item] ?: return@forEach
val destination = remember(def, accountViewModel) { def.resolveRoute(accountViewModel) }
val selected = currentDestination?.hasRoute(destination::class) == true
NavigationRailItem(
selected = selected,
onClick = {
if (selected) {
reselectCoordinator.reselect(destination)
} else {
nav.navBottomBar(destination)
}
},
icon = { NotifiableIcon(selected, def, destination, accountViewModel) },
)
}
is BottomBarEntry.Favorite -> {
val fav = favoritesById[entry.favoriteId] ?: return@forEach
val destination =
when (fav) {
is FavoriteApp.WebApp -> Route.WebApp(fav.url)
is FavoriteApp.NostrApp -> Route.NostrApp(fav.coordinate)
}
// Favorites carry arguments (url / coordinate), so class matching alone
// would light up every pinned app of the same kind; compare the full route.
val selected =
remember(navBackStackEntry, destination) {
when (destination) {
is Route.WebApp -> getRouteWithArguments(Route.WebApp::class, nav.controller) == destination
is Route.NostrApp -> getRouteWithArguments(Route.NostrApp::class, nav.controller) == destination
else -> false
}
}
NavigationRailItem(
selected = selected,
onClick = {
if (selected) {
reselectCoordinator.reselect(destination)
} else {
nav.navBottomBar(destination)
}
},
icon = { FavoriteEntryIcon(fav, selected, rememberFavoriteIconModel(fav)) },
)
}
}
val slot = rememberBottomBarSlot(entry, favoritesById, accountViewModel) ?: return@forEach
val selected = remember(navBackStackEntry, slot.route) { railSelected(slot.route, nav.controller, currentDestination) }
NavigationRailItem(
selected = selected,
onClick = {
if (selected) {
reselectCoordinator.reselect(slot.route)
} else {
nav.navBottomBar(slot.route)
}
},
icon = { slot.icon(selected) },
)
}
}
}
}
/**
* Whether [route] is the rail's currently-selected destination. Parameterized routes (favorites and
* pinned groups carry a url / coordinate / id) must match the full route — class matching alone would
* light up every pinned app or group of the same kind; parameterless object routes match on class.
*/
private fun railSelected(
route: Route,
controller: NavHostController,
currentDestination: NavDestination?,
): Boolean =
when (route) {
is Route.WebApp -> getRouteWithArguments(Route.WebApp::class, controller) == route
is Route.NostrApp -> getRouteWithArguments(Route.NostrApp::class, controller) == route
is Route.PublicChatChannel -> getRouteWithArguments(Route.PublicChatChannel::class, controller) == route
is Route.RelayGroup -> getRouteWithArguments(Route.RelayGroup::class, controller) == route
is Route.ConcordServer -> getRouteWithArguments(Route.ConcordServer::class, controller) == route
else -> currentDestination?.hasRoute(route::class) == true
}
@@ -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<BottomBarEntry>.favoriteIds(): List<String> = mapNotNull { (it as? BottomBarEntry.Favorite)?.favoriteId }
@@ -0,0 +1,210 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.navigation.bottombars
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.Dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.ChannelState
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordImageModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
/**
* The resolved presentation of a pinned chat/group [BottomBarEntry] — enough to render its avatar in
* the bottom bar and its row in the settings picker, and to navigate when tapped. Resolved live from
* the local cache, so the name/avatar fill in as the group's metadata arrives.
*/
@Immutable
data class GroupEntryDisplay(
val label: String,
val robotSeed: String,
val model: String?,
val route: Route,
)
/**
* Observes a channel's metadata for a display row. [subscribe] gates the relay REQ:
* - the live bottom bar / rail passes true — a pinned group keeps a REQ open so its name/avatar
* refresh even if the group is never opened (bounded by the handful of pinned slots);
* - the settings picker passes false — it reads whatever metadata is already cached (filled by the
* chats/group screens) so expanding a category with many joined groups doesn't fan out into one
* subscription per row.
*/
@Composable
private fun observeChannelMetadata(
channel: Channel,
accountViewModel: AccountViewModel,
subscribe: Boolean,
): State<ChannelState?> {
if (subscribe) ChannelFinderFilterAssemblerSubscription(channel, accountViewModel)
return channel
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
}
@Composable
fun rememberPublicChatEntryDisplay(
entry: BottomBarEntry.PublicChat,
accountViewModel: AccountViewModel,
subscribe: Boolean = true,
): GroupEntryDisplay {
val channel = remember(entry.channelId) { LocalCache.getOrCreatePublicChatChannel(entry.channelId) }
val state by observeChannelMetadata(channel, accountViewModel, subscribe)
val current = (state?.channel as? PublicChatChannel) ?: channel
return GroupEntryDisplay(
label = current.toBestDisplayName(),
robotSeed = entry.channelId,
model = current.profilePicture(),
route = Route.PublicChatChannel(entry.channelId),
)
}
@Composable
fun rememberRelayGroupEntryDisplay(
entry: BottomBarEntry.RelayGroup,
accountViewModel: AccountViewModel,
subscribe: Boolean = true,
): GroupEntryDisplay {
val relay = remember(entry.relayUrl) { RelayUrlNormalizer.normalizeOrNull(entry.relayUrl) }
val channel = remember(entry.groupId, relay) { relay?.let { LocalCache.getOrCreateRelayGroupChannel(GroupId(entry.groupId, it)) } }
// Always call the observer (with a null channel when the relay won't normalize) so the composable
// call structure is unconditional; a null channel just yields a null state and the id fallback.
val state by observeChannelMetadataOrNull(channel, accountViewModel, subscribe)
val current = (state?.channel as? RelayGroupChannel) ?: channel
// The name from the group's own kind-39000 metadata, once it has loaded.
val metaName = current?.event?.name()?.ifBlank { null }
// Otherwise the name the user's joined-groups list stored for this group (the NIP-51
// ["group", id, relay, name] tag) — so the row reads as a name even before 39000 is fetched,
// which the read-only settings picker never does.
val joined by accountViewModel.account.relayGroupList.liveRelayGroupList
.collectAsStateWithLifecycle()
val tagName =
remember(joined, entry) {
joined.firstOrNull { it.groupId == entry.groupId && it.relayUrl == entry.relayUrl }?.name?.ifBlank { null }
}
return GroupEntryDisplay(
label = metaName ?: tagName ?: entry.groupId,
robotSeed = entry.groupId,
model = current?.profilePicture(),
route = Route.RelayGroup(entry.groupId, entry.relayUrl),
)
}
/** [observeChannelMetadata] tolerant of a null channel (unresolvable relay), so callers avoid an early return. */
@Composable
private fun observeChannelMetadataOrNull(
channel: Channel?,
accountViewModel: AccountViewModel,
subscribe: Boolean,
): State<ChannelState?> {
if (channel == null) return remember { mutableStateOf<ChannelState?>(null) }
return observeChannelMetadata(channel, accountViewModel, subscribe)
}
@Composable
fun rememberConcordEntryDisplay(
entry: BottomBarEntry.Concord,
accountViewModel: AccountViewModel,
): GroupEntryDisplay {
val account = accountViewModel.account
// Recompute the folded metadata (name / icon) whenever a Control Plane folds.
val revision by account.concordSessions.revision.collectAsStateWithLifecycle()
val communities by account.concordChannelList.liveCommunities.collectAsStateWithLifecycle()
val session = remember(entry.communityId, revision) { account.concordSessions.sessionFor(entry.communityId) }
val metadata =
session
?.state
?.value
.takeIf { revision >= 0 }
?.metadata
val fallbackName = remember(communities, entry.communityId) { communities.firstOrNull { it.id == entry.communityId }?.name?.ifBlank { null } }
val label = metadata?.name?.takeIf { it.isNotBlank() } ?: fallbackName ?: stringRes(R.string.concord_home_title)
val model = rememberConcordImageModel(metadata?.icon, accountViewModel)
return GroupEntryDisplay(
label = label,
robotSeed = entry.communityId,
model = model,
route = Route.ConcordServer(entry.communityId),
)
}
/**
* Resolves any chat/group entry to its live display, or null for a non-group entry. [subscribe] is
* forwarded to the channel observers: true (the default) keeps a relay REQ open — used by the live
* bar/rail; false reads only cached metadata — used by the settings picker (see [observeChannelMetadata]).
*/
@Composable
fun rememberGroupEntryDisplay(
entry: BottomBarEntry,
accountViewModel: AccountViewModel,
subscribe: Boolean = true,
): GroupEntryDisplay? =
when (entry) {
is BottomBarEntry.PublicChat -> rememberPublicChatEntryDisplay(entry, accountViewModel, subscribe)
is BottomBarEntry.RelayGroup -> rememberRelayGroupEntryDisplay(entry, accountViewModel, subscribe)
is BottomBarEntry.Concord -> rememberConcordEntryDisplay(entry, accountViewModel)
is BottomBarEntry.BuiltIn -> null
is BottomBarEntry.Favorite -> null
}
/** The circular avatar for a pinned chat/group, shared by the bottom bar and the settings picker. */
@Composable
fun GroupEntryAvatar(
display: GroupEntryDisplay,
size: Dp,
accountViewModel: AccountViewModel,
) {
RobohashFallbackAsyncImage(
robot = display.robotSeed,
model = display.model,
contentDescription = display.label,
modifier = Modifier.size(size).clip(CircleShape),
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif = false,
)
}
@@ -437,6 +437,100 @@ val DrawerYouItems: List<NavBarItem> =
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<NavBarItem>,
)
/**
* 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<NavBarCategory> =
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<NavBarItem> =
listOfNotNull(
NavBarItem.ARTICLES,
@@ -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,
)
}
@@ -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(
@@ -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,
) {
@@ -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,
)
}
@@ -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,
) {
@@ -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,
)
@@ -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<BottomBarEntry>,
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<BottomBarEntry>,
entry: BottomBarEntry,
): List<BottomBarEntry> =
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<BottomBarEntry>,
from: Int,
to: Int,
): List<BottomBarEntry> {
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<BottomBarEntry>,
private val persist: (List<BottomBarEntry>) -> Unit,
) {
var pinned by mutableStateOf(initial)
private set
fun isPinned(entry: BottomBarEntry): Boolean = BottomBarEditing.isPinned(pinned, entry)
fun pinnedKeys(): Set<String> = 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<BottomBarEntry>) {
if (items != pinned) pinned = items
}
private fun update(newItems: List<BottomBarEntry>) {
pinned = newItems
persist(newItems)
}
}
+16 -1
View File
@@ -2888,10 +2888,25 @@
<string name="change_reaction">Change Quick Reactions</string>
<string name="bottom_bar_settings">Bottom Navigation Bar</string>
<string name="bottom_bar_settings_description">Drag to reorder. Toggle to add or remove an item from the bottom bar. With zero items the bottom bar is hidden.</string>
<string name="bottom_bar_settings_description">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.</string>
<string name="bottom_bar_settings_available">Available</string>
<string name="bottom_bar_settings_pinned">Your bottom bar</string>
<string name="bottom_bar_settings_pinned_empty">No items pinned. The bottom bar is hidden until you add at least one.</string>
<string name="bottom_bar_settings_reorder">Reorder</string>
<string name="bottom_bar_settings_expand">Show options</string>
<string name="bottom_bar_settings_no_favorites">No favorites yet. Star an app in the Browser to add it here.</string>
<string name="bottom_bar_settings_no_groups">No joined groups yet.</string>
<string name="bottom_bar_settings_reorder_hint">Drag to reorder · tap ✕ to remove</string>
<string name="bottom_bar_settings_add">Add</string>
<string name="bottom_bar_settings_added">Added</string>
<string name="bottom_bar_settings_remove">Remove</string>
<string name="bottom_bar_settings_restore_default">Restore Default</string>
<string name="bottom_bar_category_main">Main</string>
<string name="bottom_bar_category_chats">Chats &amp; Groups</string>
<string name="bottom_bar_category_you">You</string>
<string name="bottom_bar_category_feeds">Feeds</string>
<string name="bottom_bar_category_apps">Apps &amp; Web</string>
<string name="bottom_bar_category_other">Other</string>
<string name="home_tabs_settings">Home Tabs</string>
<string name="home_tabs_settings_description">Pick which tabs appear on the Home screen. When only one tab is active the tab bar is hidden.</string>
<string name="home_tab_everything">Everything</string>
@@ -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())
}
}
@@ -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<List<BottomBarEntry>>(json))
}
@@ -63,11 +70,17 @@ class BottomBarEntrySerializationTest {
runCatching { JsonMapper.fromJson<List<BottomBarEntry>>(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<List<BottomBarEntry>>(migrated))
assertEquals(expected, JsonMapper.fromJson<List<BottomBarEntry>>(migrated))
}
}
@@ -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<BottomBarEntry>? = 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<BottomBarEntry>? = 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))
}
}