mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
Merge remote-tracking branch 'origin/main' into claude/modernize-chat-rendering-kigcsh
This commit is contained in:
+49
-53
@@ -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. */
|
||||
|
||||
+36
-49
@@ -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
|
||||
}
|
||||
|
||||
+42
-2
@@ -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 }
|
||||
|
||||
+210
@@ -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,
|
||||
)
|
||||
}
|
||||
+94
@@ -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,
|
||||
|
||||
+9
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+13
-2
@@ -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(
|
||||
|
||||
+10
@@ -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,
|
||||
) {
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+10
@@ -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,
|
||||
) {
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
+671
-268
File diff suppressed because it is too large
Load Diff
+108
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -2889,10 +2889,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 & 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 & 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>
|
||||
|
||||
+44
@@ -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())
|
||||
}
|
||||
}
|
||||
+15
-2
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+139
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user