refactor: configure favorite-app bottom-bar tabs from the settings page

Reworks how favorites get into the bottom bar, per review: instead of a
grid Pin/Unpin that auto-appended to the bar, favorites are now activated
as a dedicated "Favorite apps" section in the bottom-bar settings page —
kept separate from the built-in destinations because favorites are dynamic
data, not the fixed NavBarItem enum.

- UiSettings/UiSettingsFlow/UISharedPreferences gain bottomBarFavoriteIds
  (a device-local list of FavoriteApp ids), persisted alongside the
  existing bottomBarItems.
- BottomBarSettingsScreen gets a "Favorite apps" section: one toggle per
  favorite to activate/deactivate it as a bottom-bar tab.
- AppBottomBar renders the favorite tabs from that settings list instead of
  a registry-side pinned set.
- FavoriteAppsRegistry drops the pinned-set machinery; the grid drops its
  Pin/Unpin item. The "All apps" grid remains a built-in destination.

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 15:22:52 +00:00
parent e82f1057c8
commit b373a297b6
8 changed files with 98 additions and 82 deletions
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.favorites
import android.content.Context
import android.util.Log
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
@@ -53,37 +52,24 @@ private val Context.favoriteAppsDataStore by preferencesDataStore(name = "favori
*/
object FavoriteAppsRegistry {
private val KEY = stringPreferencesKey("favorites")
private val PINNED_KEY = stringPreferencesKey("pinned")
private val _favorites = MutableStateFlow<List<FavoriteApp>>(emptyList())
val favorites: StateFlow<List<FavoriteApp>> = _favorites.asStateFlow()
// Ordered ids of favorites the user pinned as bottom-bar tabs. Only embeddable favorites
// (currently WebUrl) are ever pinned, so a pinned tab always swaps in place — never launches an
// activity from the bottom row.
private val _pinnedIds = MutableStateFlow<List<String>>(emptyList())
val pinnedIds: StateFlow<List<String>> = _pinnedIds.asStateFlow()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Volatile private var appContext: Context? = null
/** Binds the app context and hydrates the on-disk lists into [favorites] / [pinnedIds]. Idempotent. */
/** Binds the app context and hydrates the on-disk list into [favorites]. Idempotent. */
fun init(context: Context) {
if (appContext != null) return
val ctx = context.applicationContext
appContext = ctx
scope.launch {
val prefs = ctx.favoriteAppsDataStore.data.first()
prefs[KEY]?.let { json ->
val loaded = decode(json)
// Don't clobber adds made in this session before hydration finished.
update { current -> (loaded + current).distinctBy { it.id } }
}
prefs[PINNED_KEY]?.let { json ->
val loaded = decodeIds(json)
updatePinned { current -> (loaded + current).distinct() }
}
val json = ctx.favoriteAppsDataStore.data.first()[KEY] ?: return@launch
val loaded = decode(json)
// Don't clobber adds made in this session before hydration finished.
update { current -> (loaded + current).distinctBy { it.id } }
}
}
@@ -92,46 +78,22 @@ object FavoriteAppsRegistry {
/** Adds [app] to the end if not already present (by [FavoriteApp.id]). */
fun add(app: FavoriteApp) = update { current -> if (current.any { it.id == app.id }) current else current + app }
fun remove(id: String) {
update { current -> current.filterNot { it.id == id } }
// A removed favorite can't stay pinned to the bottom bar.
setPinned(id, false)
}
fun remove(id: String) = update { current -> current.filterNot { it.id == id } }
/** Replaces the whole list, e.g. after a drag-reorder. */
fun setOrder(newOrder: List<FavoriteApp>) = update { newOrder }
fun isPinned(id: String): Boolean = _pinnedIds.value.contains(id)
/** Pins or unpins [id] as a bottom-bar tab (appended in pin order). */
fun setPinned(
id: String,
pinned: Boolean,
) = updatePinned { current ->
if (pinned) (current + id).distinct() else current - id
}
private inline fun update(transform: (List<FavoriteApp>) -> List<FavoriteApp>) {
val next = transform(_favorites.value)
if (next == _favorites.value) return
_favorites.value = next
persist(KEY, encode(next))
persist(encode(next))
}
private inline fun updatePinned(transform: (List<String>) -> List<String>) {
val next = transform(_pinnedIds.value)
if (next == _pinnedIds.value) return
_pinnedIds.value = next
persist(PINNED_KEY, JsonMapper.toJson(next))
}
private fun persist(
key: Preferences.Key<String>,
json: String,
) {
private fun persist(json: String) {
val ctx = appContext ?: return
scope.launch {
ctx.favoriteAppsDataStore.edit { it[key] = json }
ctx.favoriteAppsDataStore.edit { it[KEY] = json }
}
}
@@ -171,14 +133,6 @@ object FavoriteAppsRegistry {
emptyList()
}
private fun decodeIds(json: String): List<String> =
try {
JsonMapper.fromJson<List<String>>(json)
} catch (e: Exception) {
Log.w("FavoriteAppsRegistry", "Failed to decode pinned favorites", e)
emptyList()
}
private const val TYPE_NOSTR = "nostr"
private const val TYPE_URL = "url"
}
@@ -54,6 +54,11 @@ 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(
@@ -54,6 +54,7 @@ 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?>>(
@@ -82,6 +83,7 @@ class UiSettingsFlow(
showProfileFollowersFeed,
dontShowOnchainPublicWarning,
suggestWorkoutsFromHealthConnect,
bottomBarFavoriteIds,
)
// emits at every change in any of the propertyes.
@@ -114,6 +116,7 @@ class UiSettingsFlow(
flows[22] as Boolean,
flows[23] as Boolean,
flows[24] as BooleanType,
flows[25] as List<String>,
)
}
@@ -144,6 +147,7 @@ class UiSettingsFlow(
showProfileFollowersFeed.value,
dontShowOnchainPublicWarning.value,
suggestWorkoutsFromHealthConnect.value,
bottomBarFavoriteIds.value,
)
fun update(torSettings: UiSettings): Boolean {
@@ -249,6 +253,10 @@ class UiSettingsFlow(
suggestWorkoutsFromHealthConnect.tryEmit(torSettings.suggestWorkoutsFromHealthConnect)
any = true
}
if (bottomBarFavoriteIds.value != torSettings.bottomBarFavoriteIds) {
bottomBarFavoriteIds.tryEmit(torSettings.bottomBarFavoriteIds)
any = true
}
return any
}
@@ -299,6 +307,7 @@ class UiSettingsFlow(
MutableStateFlow(uiSettings.showProfileFollowersFeed),
MutableStateFlow(uiSettings.dontShowOnchainPublicWarning),
MutableStateFlow(uiSettings.suggestWorkoutsFromHealthConnect),
MutableStateFlow(uiSettings.bottomBarFavoriteIds),
)
}
}
@@ -109,6 +109,7 @@ 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,6 +146,7 @@ class UiSharedPreferences(
?: 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(),
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,
@@ -195,6 +197,9 @@ class UiSharedPreferences(
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_SHOW_HOME_NEW_THREADS_TAB] = sharedSettings.showHomeNewThreadsTab
preferences[UI_SHOW_HOME_CONVERSATIONS_TAB] = sharedSettings.showHomeConversationsTab
preferences[UI_SHOW_HOME_EVERYTHING_TAB] = sharedSettings.showHomeEverythingTab
@@ -212,6 +217,8 @@ 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
@@ -90,19 +90,21 @@ fun AppBottomBar(
return
}
// User-pinned favorite apps appear as extra tabs after the built-in items. Both kinds embed
// in-process (WebUrl → browser surface, NostrApp → napplet surface), so a pinned tab always
// swaps in place rather than launching an activity from the bottom row.
// 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.
val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
val pinnedIds by FavoriteAppsRegistry.pinnedIds.collectAsStateWithLifecycle()
val pinnedFavorites =
remember(favorites, pinnedIds) {
pinnedIds.mapNotNull { id -> favorites.firstOrNull { it.id == id } }
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, pinnedFavorites, selectedRoute, accountViewModel, onClick)
RenderBottomMenu(items, favoriteTabs, selectedRoute, accountViewModel, onClick)
}
}
@@ -134,9 +134,6 @@ fun FavoriteAppsGrid(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(12.dp),
) {
// Any favorite can be pinned as a bottom-bar tab — both kinds embed in-process.
val pinnedIds by FavoriteAppsRegistry.pinnedIds.collectAsStateWithLifecycle()
LazyVerticalGrid(
columns = GridCells.Adaptive(96.dp),
modifier = modifier,
@@ -147,8 +144,6 @@ fun FavoriteAppsGrid(
items(apps, key = { it.id }) { app ->
FavoriteAppCell(
app = app,
isPinned = pinnedIds.contains(app.id),
onTogglePin = { FavoriteAppsRegistry.setPinned(app.id, !FavoriteAppsRegistry.isPinned(app.id)) },
onOpen = { onOpen(app) },
onRemove = { onRemove(app) },
)
@@ -160,8 +155,6 @@ fun FavoriteAppsGrid(
@Composable
private fun FavoriteAppCell(
app: FavoriteApp,
isPinned: Boolean,
onTogglePin: (() -> Unit)?,
onOpen: () -> Unit,
onRemove: () -> Unit,
) {
@@ -203,16 +196,6 @@ private fun FavoriteAppCell(
)
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
onTogglePin?.let { toggle ->
DropdownMenuItem(
text = { Text(stringResource(if (isPinned) R.string.favorite_app_unpin else R.string.favorite_app_pin)) },
leadingIcon = { Icon(if (isPinned) MaterialSymbols.Star else MaterialSymbols.StarBorder, contentDescription = null) },
onClick = {
menuOpen = false
toggle()
},
)
}
DropdownMenuItem(
text = { Text(stringResource(R.string.favorite_app_remove)) },
leadingIcon = { Icon(MaterialSymbols.Delete, contentDescription = null) },
@@ -58,9 +58,12 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
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.MaterialSymbols
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarItems
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
@@ -258,10 +261,64 @@ 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 pinned: Boolean,
+1 -2
View File
@@ -678,8 +678,7 @@
<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="favorite_app_pin">Pin to bottom bar</string>
<string name="favorite_app_unpin">Unpin from bottom bar</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>