mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
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:
+55
-10
@@ -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) }
|
||||
|
||||
+46
-1
@@ -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(
|
||||
|
||||
+155
@@ -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
|
||||
+18
@@ -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) },
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user