refactor: unify the bottom bar into one ordered list of built-ins + favorites

Replaces the parallel bottomBarItems (List<NavBarItem>) + bottomBarFavoriteIds
with a single ordered List<BottomBarEntry>, so built-in destinations and
favorite apps live in one list and can be pinned and drag-reordered together.

- BottomBarEntry = BuiltIn(NavBarItem) | Favorite(favoriteId). The favorite id
  already encodes the route's parameters (the url / addressable coordinate), so
  each entry maps deterministically to its Route — BuiltIn via NavBarCatalog,
  Favorite via Route.FavoriteWebApp/FavoriteNostrApp. (Storing the raw Route
  isn't an option: the sealed Route parent isn't @Serializable, so a List<Route>
  can't be persisted without annotating the whole ~100-subtype hierarchy.)
- UiSettings/UiSettingsFlow carry bottomBarItems: List<BottomBarEntry>;
  UISharedPreferences serializes it as JSON, with a legacy comma-separated
  NavBarItem fallback so existing configs still load.
- BottomBarSettingsScreen now shows one reorderable list mixing built-ins and
  favorites; the separate favorites section is gone.
- AppBottomBar renders entries in saved order; warm-keep membership derives
  from the list's favorite entries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
This commit is contained in:
Claude
2026-06-23 17:21:52 +00:00
parent e4f2c5305d
commit 6b0bb9e859
12 changed files with 178 additions and 156 deletions
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarItems
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries
import kotlinx.serialization.Serializable
@Stable
@@ -44,7 +44,7 @@ data class UiSettings(
val automaticallyProposeAiImprovements: BooleanType = BooleanType.ALWAYS,
val useTrackedBroadcasts: BooleanType = BooleanType.ALWAYS,
val automaticallyCreateDrafts: BooleanType = BooleanType.ALWAYS,
val bottomBarItems: List<NavBarItem> = DefaultBottomBarItems,
val bottomBarItems: List<BottomBarEntry> = DefaultBottomBarEntries,
val showHomeNewThreadsTab: Boolean = true,
val showHomeConversationsTab: Boolean = true,
val showHomeEverythingTab: Boolean = false,
@@ -54,11 +54,6 @@ data class UiSettings(
val showProfileFollowersFeed: Boolean = true,
val dontShowOnchainPublicWarning: Boolean = false,
val suggestWorkoutsFromHealthConnect: BooleanType = BooleanType.ALWAYS,
// Ids ([FavoriteApp.id][com.vitorpamplona.amethyst.commons.favorites.FavoriteApp.id]) of favorite
// apps the user activated as bottom-bar tabs, configured in the bottom-bar settings page. Kept
// separate from [bottomBarItems] (the built-in destinations) because favorites are dynamic data,
// not a fixed enum.
val bottomBarFavoriteIds: List<String> = emptyList(),
)
enum class ThemeType(
@@ -21,8 +21,8 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarItems
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
@@ -44,7 +44,7 @@ class UiSettingsFlow(
val automaticallyProposeAiImprovements: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
val useTrackedBroadcasts: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
val automaticallyCreateDrafts: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
val bottomBarItems: MutableStateFlow<List<NavBarItem>> = MutableStateFlow(DefaultBottomBarItems),
val bottomBarItems: MutableStateFlow<List<BottomBarEntry>> = MutableStateFlow(DefaultBottomBarEntries),
val showHomeNewThreadsTab: MutableStateFlow<Boolean> = MutableStateFlow(true),
val showHomeConversationsTab: MutableStateFlow<Boolean> = MutableStateFlow(true),
val showHomeEverythingTab: MutableStateFlow<Boolean> = MutableStateFlow(false),
@@ -54,7 +54,6 @@ class UiSettingsFlow(
val showProfileFollowersFeed: MutableStateFlow<Boolean> = MutableStateFlow(true),
val dontShowOnchainPublicWarning: MutableStateFlow<Boolean> = MutableStateFlow(false),
val suggestWorkoutsFromHealthConnect: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
val bottomBarFavoriteIds: MutableStateFlow<List<String>> = MutableStateFlow(emptyList()),
) {
val listOfFlows: List<Flow<Any?>> =
listOf<Flow<Any?>>(
@@ -83,7 +82,6 @@ class UiSettingsFlow(
showProfileFollowersFeed,
dontShowOnchainPublicWarning,
suggestWorkoutsFromHealthConnect,
bottomBarFavoriteIds,
)
// emits at every change in any of the propertyes.
@@ -106,7 +104,7 @@ class UiSettingsFlow(
flows[12] as BooleanType,
flows[13] as BooleanType,
flows[14] as BooleanType,
flows[15] as List<NavBarItem>,
flows[15] as List<BottomBarEntry>,
flows[16] as Boolean,
flows[17] as Boolean,
flows[18] as Boolean,
@@ -116,7 +114,6 @@ class UiSettingsFlow(
flows[22] as Boolean,
flows[23] as Boolean,
flows[24] as BooleanType,
flows[25] as List<String>,
)
}
@@ -147,7 +144,6 @@ class UiSettingsFlow(
showProfileFollowersFeed.value,
dontShowOnchainPublicWarning.value,
suggestWorkoutsFromHealthConnect.value,
bottomBarFavoriteIds.value,
)
fun update(torSettings: UiSettings): Boolean {
@@ -253,10 +249,6 @@ class UiSettingsFlow(
suggestWorkoutsFromHealthConnect.tryEmit(torSettings.suggestWorkoutsFromHealthConnect)
any = true
}
if (bottomBarFavoriteIds.value != torSettings.bottomBarFavoriteIds) {
bottomBarFavoriteIds.tryEmit(torSettings.bottomBarFavoriteIds)
any = true
}
return any
}
@@ -307,7 +299,6 @@ class UiSettingsFlow(
MutableStateFlow(uiSettings.showProfileFollowersFeed),
MutableStateFlow(uiSettings.dontShowOnchainPublicWarning),
MutableStateFlow(uiSettings.suggestWorkoutsFromHealthConnect),
MutableStateFlow(uiSettings.bottomBarFavoriteIds),
)
}
}
@@ -38,8 +38,10 @@ import com.vitorpamplona.amethyst.model.ProfileGalleryType
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarItems
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.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -109,7 +111,6 @@ class UiSharedPreferences(
val UI_USE_TRACKED_BROADCASTS = stringPreferencesKey("ui.use_tracked_broadcasts")
val UI_AUTOMATICALLY_CREATE_DRAFTS = stringPreferencesKey("ui.automatically_create_drafts")
val UI_BOTTOM_BAR_ITEMS = stringPreferencesKey("ui.bottom_bar_items")
val UI_BOTTOM_BAR_FAVORITES = stringPreferencesKey("ui.bottom_bar_favorites")
val UI_SHOW_HOME_NEW_THREADS_TAB = booleanPreferencesKey("ui.show_home_new_threads_tab")
val UI_SHOW_HOME_CONVERSATIONS_TAB = booleanPreferencesKey("ui.show_home_conversations_tab")
val UI_SHOW_HOME_EVERYTHING_TAB = booleanPreferencesKey("ui.show_home_everything_tab")
@@ -145,8 +146,7 @@ class UiSharedPreferences(
preferences[UI_USE_TRACKED_BROADCASTS]?.let { BooleanType.valueOf(it) }
?: if (featureSet == FeatureSetType.COMPLETE) BooleanType.ALWAYS else BooleanType.NEVER,
automaticallyCreateDrafts = preferences[UI_AUTOMATICALLY_CREATE_DRAFTS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS,
bottomBarItems = preferences[UI_BOTTOM_BAR_ITEMS]?.let { decodeBottomBarItems(it) } ?: DefaultBottomBarItems,
bottomBarFavoriteIds = preferences[UI_BOTTOM_BAR_FAVORITES]?.let { decodeFavoriteIds(it) } ?: emptyList(),
bottomBarItems = preferences[UI_BOTTOM_BAR_ITEMS]?.let { decodeBottomBarItems(it) } ?: DefaultBottomBarEntries,
showHomeNewThreadsTab = preferences[UI_SHOW_HOME_NEW_THREADS_TAB] ?: true,
showHomeConversationsTab = preferences[UI_SHOW_HOME_CONVERSATIONS_TAB] ?: true,
showHomeEverythingTab = preferences[UI_SHOW_HOME_EVERYTHING_TAB] ?: false,
@@ -196,10 +196,7 @@ class UiSharedPreferences(
preferences[UI_PROPOSE_AI_IMPROVEMENTS] = sharedSettings.automaticallyProposeAiImprovements.name
preferences[UI_USE_TRACKED_BROADCASTS] = sharedSettings.useTrackedBroadcasts.name
preferences[UI_AUTOMATICALLY_CREATE_DRAFTS] = sharedSettings.automaticallyCreateDrafts.name
preferences[UI_BOTTOM_BAR_ITEMS] = sharedSettings.bottomBarItems.joinToString(",") { it.name }
// Favorite ids contain ':' and '/' (urls / coordinates) but never a newline, so a
// newline is a safe separator.
preferences[UI_BOTTOM_BAR_FAVORITES] = sharedSettings.bottomBarFavoriteIds.joinToString("\n")
preferences[UI_BOTTOM_BAR_ITEMS] = JsonMapper.toJson(sharedSettings.bottomBarItems)
preferences[UI_SHOW_HOME_NEW_THREADS_TAB] = sharedSettings.showHomeNewThreadsTab
preferences[UI_SHOW_HOME_CONVERSATIONS_TAB] = sharedSettings.showHomeConversationsTab
preferences[UI_SHOW_HOME_EVERYTHING_TAB] = sharedSettings.showHomeEverythingTab
@@ -217,19 +214,17 @@ class UiSharedPreferences(
}
}
private fun decodeFavoriteIds(raw: String): List<String> = raw.split("\n").filter { it.isNotBlank() }
private fun decodeBottomBarItems(raw: String): List<NavBarItem> {
if (raw.isEmpty()) return emptyList()
return raw
.split(",")
.mapNotNull { name ->
try {
NavBarItem.valueOf(name)
} catch (_: IllegalArgumentException) {
null
}
}
private fun decodeBottomBarItems(raw: String): List<BottomBarEntry>? {
if (raw.isBlank()) return emptyList()
// Current format: a JSON list of BottomBarEntry (built-ins + favorites).
runCatching { return JsonMapper.fromJson<List<BottomBarEntry>>(raw) }
// Legacy format: comma-joined NavBarItem enum names (before favorites/unified entries).
return runCatching {
raw
.split(",")
.mapNotNull { name -> runCatching { NavBarItem.valueOf(name) }.getOrNull() }
.map { BottomBarEntry.BuiltIn(it) }
}.getOrNull()
}
}
}
@@ -55,6 +55,7 @@ import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress
import com.vitorpamplona.amethyst.ui.call.CallActivity
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages
import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.navs.rememberNav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -254,9 +255,9 @@ fun AppNavigation(
// holding their surfaces attached. Below the drawer (drawn by the layout above) and below
// dialogs (separate windows). API 30+ only, matching the embedded-surface feature.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val barFavoriteIds by accountViewModel.settings.uiSettingsFlow.bottomBarFavoriteIds
val bottomBarItems by accountViewModel.settings.uiSettingsFlow.bottomBarItems
.collectAsStateWithLifecycle()
EmbeddedTabLayer(barFavoriteIds)
EmbeddedTabLayer(bottomBarItems.favoriteIds())
}
}
}
@@ -90,34 +90,25 @@ fun AppBottomBar(
return
}
// Favorite apps the user activated as bottom-bar tabs (configured in the bottom-bar settings page,
// stored in ui settings — not auto-appended). Both kinds embed in-process (WebUrl → browser
// surface, NostrApp → napplet surface), so such a tab swaps in place rather than launching an
// activity from the bottom row.
// Favorite entries in the unified list resolve to a live favorite for their icon/label and to an
// embedded-tab route. Both kinds embed in-process (WebUrl → browser surface, NostrApp → napplet
// surface), so such a tab swaps in place rather than launching an activity from the bottom row.
val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
val favoriteBarIds by accountViewModel.settings.uiSettingsFlow.bottomBarFavoriteIds
.collectAsStateWithLifecycle()
val favoriteTabs =
remember(favorites, favoriteBarIds) {
favoriteBarIds.mapNotNull { id -> favorites.firstOrNull { it.id == id } }
}
val isKeyboardState by keyboardAsState()
if (isKeyboardState == KeyboardState.Closed) {
RenderBottomMenu(items, favoriteTabs, selectedRoute, accountViewModel, onClick)
RenderBottomMenu(items, favorites, selectedRoute, accountViewModel, onClick)
}
}
@Composable
private fun RenderBottomMenu(
items: List<NavBarItem>,
favoriteTabs: List<FavoriteApp>,
items: List<BottomBarEntry>,
favorites: List<FavoriteApp>,
selectedRoute: Route?,
accountViewModel: AccountViewModel,
nav: (Route) -> Unit,
) {
val defs = remember(items) { items.mapNotNull(NavBarCatalog::get) }
Column(
modifier =
Modifier
@@ -135,18 +126,25 @@ private fun RenderBottomMenu(
containerColor = MaterialTheme.colorScheme.background,
tonalElevation = Size0dp,
) {
defs.forEach { def ->
val destination = remember(def, accountViewModel) { def.resolveRoute(accountViewModel) }
HasNewItemsIcon(destination == selectedRoute, def, destination, accountViewModel, nav)
}
favoriteTabs.forEach { fav ->
val destination =
when (fav) {
is FavoriteApp.WebUrl -> Route.FavoriteWebApp(fav.url)
is FavoriteApp.NostrApp -> Route.FavoriteNostrApp(fav.coordinate)
// Render in the user's saved order, built-ins and favorites interleaved.
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)
}
val icon = if (fav is FavoriteApp.NostrApp) MaterialSymbols.Apps else MaterialSymbols.Public
FavoriteNavItem(destination == selectedRoute, fav.label, icon, destination, nav)
is BottomBarEntry.Favorite -> {
val fav = favorites.firstOrNull { it.id == entry.favoriteId } ?: return@forEach
val destination =
when (fav) {
is FavoriteApp.WebUrl -> Route.FavoriteWebApp(fav.url)
is FavoriteApp.NostrApp -> Route.FavoriteNostrApp(fav.coordinate)
}
val icon = if (fav is FavoriteApp.NostrApp) MaterialSymbols.Apps else MaterialSymbols.Public
FavoriteNavItem(destination == selectedRoute, fav.label, icon, destination, nav)
}
}
}
}
}
@@ -0,0 +1,51 @@
/*
* 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 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.
*
* - [BuiltIn] resolves its [Route][com.vitorpamplona.amethyst.ui.navigation.routes.Route] (and its
* icon/label/notification badge) through [NavBarCatalog], like before.
* - [Favorite] points at a [FavoriteApp][com.vitorpamplona.amethyst.commons.favorites.FavoriteApp] by
* 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.FavoriteWebApp` / `Route.FavoriteNostrApp` for navigation.
*/
@Serializable
sealed interface BottomBarEntry {
@Serializable
data class BuiltIn(
val item: NavBarItem,
) : BottomBarEntry
@Serializable
data class Favorite(
val favoriteId: String,
) : BottomBarEntry
}
/** 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 }
@@ -385,6 +385,9 @@ val DefaultBottomBarItems: List<NavBarItem> =
NavBarItem.NOTIFICATIONS,
)
/** The default bottom bar as unified entries (all built-in; favorites are added by the user). */
val DefaultBottomBarEntries: List<BottomBarEntry> = DefaultBottomBarItems.map { BottomBarEntry.BuiltIn(it) }
// Ordered membership lists for each drawer section. The drawer renders these by looking up
// each id in NavBarCatalog, so adding a new screen only requires editing the catalog + the
// matching section list below — not two separate files.
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.datasource.ArticlesFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFilterAssemblerSubscription
@@ -64,7 +65,9 @@ fun BottomBarFeedPreloaders(accountViewModel: AccountViewModel) {
val items by accountViewModel.settings.uiSettingsFlow.bottomBarItems
.collectAsStateWithLifecycle()
items.forEach { item ->
// Only built-in destinations have feeds to preload; favorite-app entries embed their own content.
items.forEach { entry ->
val item = (entry as? BottomBarEntry.BuiltIn)?.item ?: return@forEach
key(item) {
PreloadFor(item, accountViewModel)
}
@@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -120,13 +121,13 @@ private fun EmbeddedFavoriteTab(
}
}
val barFavoritesFlow = accountViewModel.settings.uiSettingsFlow.bottomBarFavoriteIds
val bottomBarFlow = accountViewModel.settings.uiSettingsFlow.bottomBarItems
DisposableEffect(id) {
EmbeddedTabHost.setActive(id)
onDispose {
EmbeddedTabHost.clearActiveIfMatches(id)
// Only bottom-row apps stay warm; anything else restarts when it leaves.
if (id !in barFavoritesFlow.value) EmbeddedTabHost.evict(id)
if (id !in bottomBarFlow.value.favoriteIds()) EmbeddedTabHost.evict(id)
}
}
@@ -62,6 +62,7 @@ import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -137,13 +138,13 @@ private fun EmbeddedNappletTab(
}
}
val barFavoritesFlow = accountViewModel.settings.uiSettingsFlow.bottomBarFavoriteIds
val bottomBarFlow = accountViewModel.settings.uiSettingsFlow.bottomBarItems
DisposableEffect(id) {
EmbeddedTabHost.setActive(id)
onDispose {
EmbeddedTabHost.clearActiveIfMatches(id)
// Only bottom-row apps stay warm; anything else restarts when it leaves.
if (id !in barFavoritesFlow.value) EmbeddedTabHost.evict(id)
if (id !in bottomBarFlow.value.favoriteIds()) EmbeddedTabHost.evict(id)
}
}
@@ -62,12 +62,12 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarItems
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItemDef
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
@@ -107,12 +107,15 @@ fun BottomBarSettingsScreen(
@Composable
fun BottomBarSettingsContent(accountViewModel: AccountViewModel) {
val bottomBarItemsFlow = accountViewModel.settings.uiSettingsFlow.bottomBarItems
var items by remember { mutableStateOf(initialRows(bottomBarItemsFlow.value)) }
// Favorite apps appear in the SAME list as the built-in destinations, so they can be pinned and
// drag-reordered together. Re-seed when the favorites set changes (e.g. a favorite was deleted).
val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
var items by remember(favorites) { mutableStateOf(initialRows(bottomBarItemsFlow.value, favorites)) }
fun save(newItems: List<Row>) {
items = newItems
bottomBarItemsFlow.tryEmit(
newItems.filter { it.pinned }.map { it.item },
newItems.filter { it.pinned }.map { it.entry },
)
}
@@ -148,7 +151,7 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) {
onClick = {
draggedItemIndex = -1
dragOffset = 0f
save(initialRows(DefaultBottomBarItems))
save(initialRows(DefaultBottomBarEntries, favorites))
},
) {
Text(stringRes(R.string.bottom_bar_settings_restore_default))
@@ -156,6 +159,7 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) {
}
items.forEachIndexed { index, row ->
val display = rowDisplay(row.entry, favorites)
val rowIsDragging = draggedItemIndex == index
val targetElevation = if (rowIsDragging) 8f else 0f
val animatedElevation by animateFloatAsState(
@@ -164,11 +168,13 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) {
)
NavBarItemCard(
row = row,
icon = display.icon,
label = display.label,
isDragging = rowIsDragging,
canDrag = row.pinned,
dragOffsetY = if (rowIsDragging) dragOffset else 0f,
elevation = animatedElevation,
pinned = row.pinned,
onTogglePinned = {
val newItems = items.toMutableList()
val toggled = row.copy(pinned = !row.pinned)
@@ -261,75 +267,53 @@ fun BottomBarSettingsContent(accountViewModel: AccountViewModel) {
}
}
FavoriteAppsSection(accountViewModel)
Spacer(modifier = Modifier.height(16.dp))
}
}
/**
* Lets the user activate individual favorite apps (nsites / napplets / web clients) as bottom-bar tabs.
* A separate section from the built-in destinations above: favorites are dynamic data, stored as
* [com.vitorpamplona.amethyst.model.UiSettings.bottomBarFavoriteIds], not the fixed [NavBarItem] enum.
* (The "All apps" grid itself is a built-in destination in the list above.)
*/
@Composable
private fun FavoriteAppsSection(accountViewModel: AccountViewModel) {
val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
if (favorites.isEmpty()) return
val flow = accountViewModel.settings.uiSettingsFlow.bottomBarFavoriteIds
val activeIds by flow.collectAsStateWithLifecycle()
SectionDivider(R.string.bottom_bar_settings_favorite_apps)
favorites.forEach { app ->
val checked = app.id in activeIds
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp, horizontal = Size20dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(modifier = Modifier.size(28.dp), contentAlignment = Alignment.Center) {
Icon(
symbol = if (app is FavoriteApp.NostrApp) MaterialSymbols.Apps else MaterialSymbols.Public,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onBackground,
)
}
Text(
text = app.label,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Switch(
checked = checked,
onCheckedChange = { on ->
flow.tryEmit(if (on) activeIds + app.id else activeIds - app.id)
},
)
}
HorizontalDivider(modifier = Modifier.padding(horizontal = Size20dp))
}
}
private data class Row(
val item: NavBarItem,
val entry: BottomBarEntry,
val pinned: Boolean,
)
private fun initialRows(pinned: List<NavBarItem>): List<Row> {
val pinnedRows = pinned.mapNotNull { id -> NavBarCatalog[id]?.let { Row(id, pinned = true) } }
val unpinnedRows =
NavBarCatalog.keys
.filter { it !in pinned }
.map { Row(it, pinned = false) }
/** Display (icon + label) for an entry, resolving built-ins via the catalog and favorites via the registry. */
private class RowDisplay(
val icon: MaterialSymbol,
val label: String,
)
@Composable
private fun rowDisplay(
entry: BottomBarEntry,
favorites: List<FavoriteApp>,
): RowDisplay =
when (entry) {
is BottomBarEntry.BuiltIn -> {
val def = NavBarCatalog[entry.item]
if (def != null) RowDisplay(def.icon, stringRes(def.labelRes)) else RowDisplay(MaterialSymbols.Apps, "")
}
is BottomBarEntry.Favorite -> {
val app = favorites.firstOrNull { it.id == entry.favoriteId }
val icon = if (app is FavoriteApp.NostrApp) MaterialSymbols.Apps else MaterialSymbols.Public
RowDisplay(icon, app?.label ?: "")
}
}
/**
* Builds the row list: [pinned] entries first (in saved order, dropping any that no longer resolve —
* e.g. a deleted favorite), then every still-available entry — built-in destinations and the user's
* favorite apps — in the available section.
*/
private fun initialRows(
pinned: List<BottomBarEntry>,
favorites: List<FavoriteApp>,
): List<Row> {
val available: List<BottomBarEntry> =
NavBarCatalog.keys.map { BottomBarEntry.BuiltIn(it) } + favorites.map { BottomBarEntry.Favorite(it.id) }
val availableSet = available.toSet()
val pinnedRows = pinned.filter { it in availableSet }.map { Row(it, pinned = true) }
val unpinnedRows = available.filter { it !in pinned }.map { Row(it, pinned = false) }
return pinnedRows + unpinnedRows
}
@@ -346,11 +330,13 @@ private fun SectionDivider(titleRes: Int) {
@Composable
private fun NavBarItemCard(
row: Row,
icon: MaterialSymbol,
label: String,
isDragging: Boolean,
canDrag: Boolean,
dragOffsetY: Float,
elevation: Float,
pinned: Boolean,
onTogglePinned: () -> Unit,
onMeasured: (Float) -> Unit,
onDragStart: () -> Unit,
@@ -359,9 +345,6 @@ private fun NavBarItemCard(
onDragCancel: () -> Unit,
modifier: Modifier = Modifier,
) {
val def = NavBarCatalog[row.item] ?: return
val label = stringRes(def.labelRes)
Column(
modifier =
modifier
@@ -399,7 +382,7 @@ private fun NavBarItemCard(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
NavBarIconBox(def)
NavBarIconBox(icon, label)
Text(
text = label,
@@ -410,7 +393,7 @@ private fun NavBarItemCard(
)
Switch(
checked = row.pinned,
checked = pinned,
onCheckedChange = { onTogglePinned() },
)
@@ -432,18 +415,19 @@ private fun NavBarItemCard(
}
@Composable
private fun NavBarIconBox(def: NavBarItemDef) {
private fun NavBarIconBox(
icon: MaterialSymbol,
label: String,
) {
Box(
modifier = Modifier.size(28.dp),
contentAlignment = Alignment.Center,
) {
val description = stringRes(def.labelRes)
val tint = MaterialTheme.colorScheme.onBackground
Icon(
symbol = def.icon,
contentDescription = description,
symbol = icon,
contentDescription = label,
modifier = Modifier.size(24.dp),
tint = tint,
tint = MaterialTheme.colorScheme.onBackground,
)
}
}
-1
View File
@@ -678,7 +678,6 @@
<string name="favorite_app_add">Add to favorites</string>
<string name="favorite_app_remove">Remove from favorites</string>
<string name="favorite_app_open_window">Open in its own window</string>
<string name="bottom_bar_settings_favorite_apps">Favorite apps</string>
<string name="favorite_app_access_title">What this app can access</string>
<string name="favorite_app_access_static">Runs sandboxed with no special access to your account.</string>
<string name="favorite_app_access_show">What it can access</string>