mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
+9
-55
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -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
|
||||
|
||||
+10
-8
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-17
@@ -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) },
|
||||
|
||||
+57
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user