mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
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:
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+16
-21
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
-26
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+51
@@ -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 }
|
||||
+3
@@ -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.
|
||||
|
||||
+4
-1
@@ -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)
|
||||
}
|
||||
|
||||
+3
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+64
-80
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user