From 6b0bb9e859b29fa65cd78b4c943f15593bb7b68c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 17:21:52 +0000 Subject: [PATCH] refactor: unify the bottom bar into one ordered list of built-ins + favorites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the parallel bottomBarItems (List) + bottomBarFavoriteIds with a single ordered List, 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 can't be persisted without annotating the whole ~100-subtype hierarchy.) - UiSettings/UiSettingsFlow carry bottomBarItems: List; 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 Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN --- .../amethyst/model/UiSettings.kt | 11 +- .../amethyst/model/UiSettingsFlow.kt | 17 +-- .../model/preferences/UISharedPreferences.kt | 37 ++--- .../amethyst/ui/navigation/AppNavigation.kt | 5 +- .../ui/navigation/bottombars/AppBottomBar.kt | 50 +++--- .../navigation/bottombars/BottomBarEntry.kt | 51 +++++++ .../ui/navigation/bottombars/NavBarItem.kt | 3 + .../loggedIn/BottomBarFeedPreloaders.kt | 5 +- .../loggedIn/browser/FavoriteWebAppScreen.kt | 5 +- .../favorites/FavoriteNappletScreen.kt | 5 +- .../settings/BottomBarSettingsScreen.kt | 144 ++++++++---------- amethyst/src/main/res/values/strings.xml | 1 - 12 files changed, 178 insertions(+), 156 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarEntry.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt index b5c54835cf..069c8a8805 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt @@ -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 = DefaultBottomBarItems, + val bottomBarItems: List = 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 = emptyList(), ) enum class ThemeType( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt index 858d844719..176691dfe6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt @@ -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 = MutableStateFlow(BooleanType.ALWAYS), val useTrackedBroadcasts: MutableStateFlow = MutableStateFlow(BooleanType.ALWAYS), val automaticallyCreateDrafts: MutableStateFlow = MutableStateFlow(BooleanType.ALWAYS), - val bottomBarItems: MutableStateFlow> = MutableStateFlow(DefaultBottomBarItems), + val bottomBarItems: MutableStateFlow> = MutableStateFlow(DefaultBottomBarEntries), val showHomeNewThreadsTab: MutableStateFlow = MutableStateFlow(true), val showHomeConversationsTab: MutableStateFlow = MutableStateFlow(true), val showHomeEverythingTab: MutableStateFlow = MutableStateFlow(false), @@ -54,7 +54,6 @@ class UiSettingsFlow( val showProfileFollowersFeed: MutableStateFlow = MutableStateFlow(true), val dontShowOnchainPublicWarning: MutableStateFlow = MutableStateFlow(false), val suggestWorkoutsFromHealthConnect: MutableStateFlow = MutableStateFlow(BooleanType.ALWAYS), - val bottomBarFavoriteIds: MutableStateFlow> = MutableStateFlow(emptyList()), ) { val listOfFlows: List> = listOf>( @@ -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, + flows[15] as List, 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, ) } @@ -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), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt index c7efe02841..21670d0ce4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt @@ -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 = raw.split("\n").filter { it.isNotBlank() } - - private fun decodeBottomBarItems(raw: String): List { - if (raw.isEmpty()) return emptyList() - return raw - .split(",") - .mapNotNull { name -> - try { - NavBarItem.valueOf(name) - } catch (_: IllegalArgumentException) { - null - } - } + private fun decodeBottomBarItems(raw: String): List? { + if (raw.isBlank()) return emptyList() + // Current format: a JSON list of BottomBarEntry (built-ins + favorites). + runCatching { return JsonMapper.fromJson>(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() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 43cfd23c86..6d403a0763 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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()) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt index 5b2805e8f4..ea11efc6a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt @@ -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, - favoriteTabs: List, + items: List, + favorites: List, 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) + } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarEntry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarEntry.kt new file mode 100644 index 0000000000..19db0ce01c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarEntry.kt @@ -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.favoriteIds(): List = mapNotNull { (it as? BottomBarEntry.Favorite)?.favoriteId } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index 7b02bae1df..60eea1911f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -385,6 +385,9 @@ val DefaultBottomBarItems: List = NavBarItem.NOTIFICATIONS, ) +/** The default bottom bar as unified entries (all built-in; favorites are added by the user). */ +val DefaultBottomBarEntries: List = 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. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt index ad62a7341c..e98d500c0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/FavoriteWebAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/FavoriteWebAppScreen.kt index 5821a00d9b..60ed1830db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/FavoriteWebAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/FavoriteWebAppScreen.kt @@ -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) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteNappletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteNappletScreen.kt index 2b68e27b6f..e3f04cbdef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteNappletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteNappletScreen.kt @@ -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) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt index c362d3c38f..e3b2eb83ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt @@ -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) { 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): List { - 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, +): RowDisplay = + when (entry) { + is BottomBarEntry.BuiltIn -> { + val def = NavBarCatalog[entry.item] + if (def != null) RowDisplay(def.icon, stringRes(def.labelRes)) else RowDisplay(MaterialSymbols.Apps, "") + } + is BottomBarEntry.Favorite -> { + val app = favorites.firstOrNull { it.id == entry.favoriteId } + val icon = if (app is FavoriteApp.NostrApp) MaterialSymbols.Apps else MaterialSymbols.Public + RowDisplay(icon, app?.label ?: "") + } + } + +/** + * Builds the row list: [pinned] entries first (in saved order, dropping any that no longer resolve — + * e.g. a deleted favorite), then every still-available entry — built-in destinations and the user's + * favorite apps — in the available section. + */ +private fun initialRows( + pinned: List, + favorites: List, +): List { + val available: List = + NavBarCatalog.keys.map { BottomBarEntry.BuiltIn(it) } + favorites.map { BottomBarEntry.Favorite(it.id) } + val availableSet = available.toSet() + + val pinnedRows = pinned.filter { it in availableSet }.map { Row(it, pinned = true) } + val unpinnedRows = available.filter { it !in pinned }.map { Row(it, pinned = false) } return pinnedRows + unpinnedRows } @@ -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, ) } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index c3c6204b03..97925adaec 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -678,7 +678,6 @@ Add to favorites Remove from favorites Open in its own window - Favorite apps What this app can access Runs sandboxed with no special access to your account. What it can access