feat: pin favorite web apps as embedded, swap-in-place bottom-bar tabs

Builds on the favorites system so a favorite can live in the bottom row
as a real tab instead of only launching a full-screen activity.

- FavoriteAppsRegistry gains a pinned-ids set (persisted device-locally,
  alongside the favorites list). Only WebUrl favorites are pinnable today
  — they're the ones that embed in-process — so a pinned tab always swaps
  in place and never launches an activity from the bottom row. Removing a
  favorite unpins it.
- Route.FavoriteWebApp(url) + FavoriteWebAppScreen render the embedded
  :napplet browser surface as an in-app tab: the app bottom bar stays, so
  switching to/from it is an ordinary tab swap. A pop-out action hands the
  same URL to the full-screen BrowserHostActivity for users who want it as
  its own window.
- AppBottomBar appends pinned favorites as tabs after the built-in items,
  navigating via navBottomBar (marked a tab root, so the bar stays).
- The Favorite Apps grid gains a Pin/Unpin action for WebUrl favorites.

Known follow-ups (intentionally out of this commit): NostrApp favorites
can't embed as tabs yet (they need an embedded nsite/napplet surface in
nappletHost), and embedded tabs rebuild on return rather than staying warm.

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 14:40:51 +00:00
parent c21b592cb1
commit 0606566238
7 changed files with 282 additions and 11 deletions
@@ -22,6 +22,7 @@ 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
@@ -52,24 +53,37 @@ 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 list into [favorites]. Idempotent. */
/** Binds the app context and hydrates the on-disk lists into [favorites] / [pinnedIds]. Idempotent. */
fun init(context: Context) {
if (appContext != null) return
val ctx = context.applicationContext
appContext = ctx
scope.launch {
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 } }
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() }
}
}
}
@@ -78,23 +92,46 @@ 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 } }
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)
}
/** 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(next)
persist(KEY, encode(next))
}
private fun persist(list: List<FavoriteApp>) {
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,
) {
val ctx = appContext ?: return
val json = encode(list)
scope.launch {
ctx.favoriteAppsDataStore.edit { it[KEY] = json }
ctx.favoriteAppsDataStore.edit { it[key] = json }
}
}
@@ -134,6 +171,14 @@ 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"
}
@@ -77,6 +77,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipMa
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.BrowserScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.FavoriteWebAppScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarCollectionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarReminderSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsScreen
@@ -297,6 +298,7 @@ fun BuildNavigation(
composableFromEnd<Route.Nsites> { NsitesScreen(accountViewModel, nav) }
composableFromEnd<Route.Browser> { BrowserScreen(accountViewModel, nav) }
composableFromEnd<Route.FavoriteApps> { FavoriteAppsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.FavoriteWebApp> { FavoriteWebAppScreen(it.url, accountViewModel, nav) }
composableFromEnd<Route.NappletPermissions> { NappletPermissionsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.SoftwareAppDetail> { SoftwareAppDetailScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEnd<Route.Calendars> { CalendarsScreen(accountViewModel, nav) }
@@ -35,14 +35,19 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -83,15 +88,26 @@ fun AppBottomBar(
)
return
}
// User-pinned favorite web apps appear as extra tabs after the built-in items. Only WebUrl
// favorites are pinnable (they embed in-process), so a pinned tab always swaps in place.
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 } as? FavoriteApp.WebUrl }
}
val isKeyboardState by keyboardAsState()
if (isKeyboardState == KeyboardState.Closed) {
RenderBottomMenu(items, selectedRoute, accountViewModel, onClick)
RenderBottomMenu(items, pinnedFavorites, selectedRoute, accountViewModel, onClick)
}
}
@Composable
private fun RenderBottomMenu(
items: List<NavBarItem>,
favoriteTabs: List<FavoriteApp.WebUrl>,
selectedRoute: Route?,
accountViewModel: AccountViewModel,
nav: (Route) -> Unit,
@@ -119,10 +135,39 @@ private fun RenderBottomMenu(
val destination = remember(def, accountViewModel) { def.resolveRoute(accountViewModel) }
HasNewItemsIcon(destination == selectedRoute, def, destination, accountViewModel, nav)
}
favoriteTabs.forEach { fav ->
val destination = Route.FavoriteWebApp(fav.url)
FavoriteNavItem(destination == selectedRoute, fav, destination, nav)
}
}
}
}
@Composable
private fun RowScope.FavoriteNavItem(
selected: Boolean,
fav: FavoriteApp.WebUrl,
destination: Route,
nav: (Route) -> Unit,
) {
NavigationBarItem(
alwaysShowLabel = false,
icon = {
Box(Size27Modifier, contentAlignment = Alignment.Center) {
Icon(
symbol = MaterialSymbols.Public,
contentDescription = fav.label,
modifier = Size25Modifier,
tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65,
)
}
},
label = { Text(fav.label, maxLines = 1, overflow = TextOverflow.Ellipsis) },
selected = selected,
onClick = { nav(destination) },
)
}
@Composable
private fun RowScope.HasNewItemsIcon(
selected: Boolean,
@@ -95,6 +95,10 @@ sealed class Route {
@Serializable object FavoriteApps : Route()
@Serializable data class FavoriteWebApp(
val url: String,
) : Route()
@Serializable object NappletPermissions : Route()
@Serializable data class SoftwareAppDetail(
@@ -0,0 +1,155 @@
/*
* 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.screen.loggedIn.browser
import android.net.Uri
import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
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.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
* A pinned web client rendered as an **in-app tab**: the embedded `:napplet` browser surface fills the
* screen, but the app's bottom bar stays put, so switching to and from it is an ordinary tab swap — no
* new activity, no jarring task switch. The pop-out action hands the same URL to the full-screen
* [BrowserHostActivity] for users who want it as its own window/recents entry.
*
* Only [FavoriteApp.WebUrl][com.vitorpamplona.amethyst.commons.favorites.FavoriteApp.WebUrl] favorites
* reach this screen (they're the only ones pinnable to the bottom bar today); requires API 30+ for the
* cross-process surface.
*/
@Composable
fun FavoriteWebAppScreen(
url: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
EmbeddedFavoriteTab(url, accountViewModel, nav)
} else {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
stringResource(R.string.browser_unsupported),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@RequiresApi(Build.VERSION_CODES.R)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EmbeddedFavoriteTab(
url: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val context = LocalContext.current
var currentUrl by remember { mutableStateOf(url) }
var canGoBack by remember { mutableStateOf(false) }
val proxyAvailable = remember { Amethyst.instance.torManager.activePortOrNull.value != null }
var torOn by remember { mutableStateOf(proxyAvailable) }
val controller =
rememberBrowserController(startUrl = url) { newUrl, back ->
if (newUrl != "about:blank") currentUrl = newUrl
canGoBack = back
}
BackHandler(enabled = canGoBack) { controller.back() }
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = hostLabel(currentUrl),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
actions = {
if (proxyAvailable) {
IconButton(onClick = {
torOn = !torOn
controller.setTor(torOn)
}) {
Icon(
MaterialSymbols.Security,
contentDescription = stringResource(if (torOn) R.string.browser_tor_on else R.string.browser_tor_off),
tint = if (torOn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
IconButton(onClick = { controller.reload() }) {
Icon(MaterialSymbols.Refresh, contentDescription = stringResource(R.string.browser_reload))
}
IconButton(onClick = { FavoriteAppLauncher.launchUrl(context, url) }) {
Icon(MaterialSymbols.AutoMirrored.OpenInNew, contentDescription = stringResource(R.string.favorite_app_open_window))
}
},
)
},
bottomBar = {
AppBottomBar(Route.FavoriteWebApp(url), nav, accountViewModel) { route -> nav.navBottomBar(route) }
},
) { padding ->
EmbeddedBrowserSurface(
controller = controller,
modifier =
Modifier
.fillMaxSize()
.padding(padding),
)
}
}
/** The host of [url] for the tab title, falling back to the raw string. */
internal fun hostLabel(url: String): String = runCatching { Uri.parse(url).host }.getOrNull()?.takeIf { it.isNotBlank() } ?: url
@@ -134,6 +134,9 @@ fun FavoriteAppsGrid(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(12.dp),
) {
// Only WebUrl favorites can be pinned as bottom-bar tabs today (they embed in-process).
val pinnedIds by FavoriteAppsRegistry.pinnedIds.collectAsStateWithLifecycle()
LazyVerticalGrid(
columns = GridCells.Adaptive(96.dp),
modifier = modifier,
@@ -142,8 +145,11 @@ fun FavoriteAppsGrid(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(apps, key = { it.id }) { app ->
val pinnable = app is FavoriteApp.WebUrl
FavoriteAppCell(
app = app,
isPinned = pinnable && pinnedIds.contains(app.id),
onTogglePin = if (pinnable) ({ FavoriteAppsRegistry.setPinned(app.id, !FavoriteAppsRegistry.isPinned(app.id)) }) else null,
onOpen = { onOpen(app) },
onRemove = { onRemove(app) },
)
@@ -155,6 +161,8 @@ fun FavoriteAppsGrid(
@Composable
private fun FavoriteAppCell(
app: FavoriteApp,
isPinned: Boolean,
onTogglePin: (() -> Unit)?,
onOpen: () -> Unit,
onRemove: () -> Unit,
) {
@@ -196,6 +204,16 @@ 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) },
+2
View File
@@ -678,6 +678,8 @@
<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="favorite_app_still_loading">That app isn\'t loaded yet. Try again in a moment.</string>
<string name="favorite_app_recent">Recent</string>
<string name="napplet_permissions">nApplet permissions</string>