diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppsRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppsRegistry.kt index b9a4d267de..138e07dae8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppsRegistry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppsRegistry.kt @@ -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>(emptyList()) val favorites: StateFlow> = _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>(emptyList()) - val pinnedIds: StateFlow> = _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) = 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) -> List) { 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) -> List) { - 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, - 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 = - try { - JsonMapper.fromJson>(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" } 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 a4bc260c99..b5c54835cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt @@ -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 = 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 546cc50293..858d844719 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt @@ -54,6 +54,7 @@ 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>( @@ -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, ) } @@ -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), ) } } 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 77082c8411..c7efe02841 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 @@ -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 = raw.split("\n").filter { it.isNotBlank() } + private fun decodeBottomBarItems(raw: String): List { if (raw.isEmpty()) return emptyList() return raw 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 331456dadf..5b2805e8f4 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,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) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt index 02bb497f1f..5d0c1a9bbc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteAppsScreen.kt @@ -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) }, 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 953296ba00..c362d3c38f 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 @@ -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, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 5e44e5ada9..c3c6204b03 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -678,8 +678,7 @@ Add to favorites Remove from favorites Open in its own window - Pin to bottom bar - Unpin from bottom bar + Favorite apps What this app can access Runs sandboxed with no special access to your account. What it can access