Make bottom navigation bar configurable

Users can now pick any subset of drawer items, in any order, to appear
in the bottom bar. If zero items are selected the bottom bar is hidden.

- NavBarItem enum + NavBarCatalog: every drawer destination gets an id,
  label, icon, and route resolver so items can be looked up by id.
- UiSettings gains bottomBarItems: List<NavBarItem>; persisted in the
  existing UiSharedPreferences DataStore as a comma-separated enum list.
- AppBottomBar reads the list from UiSettingsFlow and renders a zero-
  height spacer (preserving nav-bar insets) when the list is empty.
- New Navigate section at the top of the drawer exposes Home, Messages,
  Media, Discovery, Notifications as drawer destinations.
- New BottomBarSettingsScreen under All Settings lets users pin/unpin
  entries and drag to reorder (same UX as ReactionsSettingsScreen).
This commit is contained in:
Claude
2026-04-21 22:23:15 +00:00
parent 53db6fa10e
commit 376e404c96
12 changed files with 826 additions and 63 deletions
@@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarItems
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import kotlinx.serialization.Serializable
@Stable
@@ -40,6 +42,7 @@ data class UiSettings(
val featureSet: FeatureSetType = FeatureSetType.SIMPLIFIED,
val gallerySet: ProfileGalleryType = ProfileGalleryType.CLASSIC,
val automaticallyProposeAiImprovements: BooleanType = BooleanType.ALWAYS,
val bottomBarItems: List<NavBarItem> = DefaultBottomBarItems,
)
enum class ThemeType(
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarItems
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
@@ -40,6 +42,7 @@ class UiSettingsFlow(
val featureSet: MutableStateFlow<FeatureSetType> = MutableStateFlow(FeatureSetType.SIMPLIFIED),
val gallerySet: MutableStateFlow<ProfileGalleryType> = MutableStateFlow(ProfileGalleryType.CLASSIC),
val automaticallyProposeAiImprovements: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
val bottomBarItems: MutableStateFlow<List<NavBarItem>> = MutableStateFlow(DefaultBottomBarItems),
) {
val listOfFlows: List<Flow<Any?>> =
listOf<Flow<Any?>>(
@@ -56,9 +59,11 @@ class UiSettingsFlow(
featureSet,
gallerySet,
automaticallyProposeAiImprovements,
bottomBarItems,
)
// emits at every change in any of the propertyes.
@Suppress("UNCHECKED_CAST")
val propertyWatchFlow: Flow<UiSettings> =
combine<Any?, UiSettings>(listOfFlows) { flows: Array<Any?> ->
UiSettings(
@@ -75,6 +80,7 @@ class UiSettingsFlow(
flows[10] as FeatureSetType,
flows[11] as ProfileGalleryType,
flows[12] as BooleanType,
flows[13] as List<NavBarItem>,
)
}
@@ -93,6 +99,7 @@ class UiSettingsFlow(
featureSet.value,
gallerySet.value,
automaticallyProposeAiImprovements.value,
bottomBarItems.value,
)
fun update(torSettings: UiSettings): Boolean {
@@ -150,6 +157,10 @@ class UiSettingsFlow(
automaticallyProposeAiImprovements.tryEmit(torSettings.automaticallyProposeAiImprovements)
any = true
}
if (bottomBarItems.value != torSettings.bottomBarItems) {
bottomBarItems.tryEmit(torSettings.bottomBarItems)
any = true
}
return any
}
@@ -182,6 +193,7 @@ class UiSettingsFlow(
MutableStateFlow(uiSettings.featureSet),
MutableStateFlow(uiSettings.gallerySet),
MutableStateFlow(uiSettings.automaticallyProposeAiImprovements),
MutableStateFlow(uiSettings.bottomBarItems),
)
}
}
@@ -38,6 +38,8 @@ import com.vitorpamplona.amethyst.model.ProfileGalleryType
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarItems
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -104,6 +106,7 @@ class UiSharedPreferences(
val UI_FEATURE_SET = stringPreferencesKey("ui.feature_set")
val UI_GALLERY_SET = stringPreferencesKey("ui.gallery_set")
val UI_PROPOSE_AI_IMPROVEMENTS = stringPreferencesKey("ui.propose_ai_improvements")
val UI_BOTTOM_BAR_ITEMS = stringPreferencesKey("ui.bottom_bar_items")
suspend fun uiPreferences(context: Context): UiSettings? =
try {
@@ -124,6 +127,7 @@ class UiSharedPreferences(
featureSet = preferences[UI_FEATURE_SET]?.let { FeatureSetType.valueOf(it) } ?: FeatureSetType.SIMPLIFIED,
gallerySet = preferences[UI_GALLERY_SET]?.let { ProfileGalleryType.valueOf(it) } ?: ProfileGalleryType.CLASSIC,
automaticallyProposeAiImprovements = preferences[UI_PROPOSE_AI_IMPROVEMENTS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS,
bottomBarItems = preferences[UI_BOTTOM_BAR_ITEMS]?.let { decodeBottomBarItems(it) } ?: DefaultBottomBarItems,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
@@ -161,6 +165,7 @@ class UiSharedPreferences(
preferences[UI_FEATURE_SET] = sharedSettings.featureSet.name
preferences[UI_GALLERY_SET] = sharedSettings.gallerySet.name
preferences[UI_PROPOSE_AI_IMPROVEMENTS] = sharedSettings.automaticallyProposeAiImprovements.name
preferences[UI_BOTTOM_BAR_ITEMS] = sharedSettings.bottomBarItems.joinToString(",") { it.name }
}
} catch (e: Exception) {
if (e is CancellationException) throw e
@@ -168,5 +173,18 @@ class UiSharedPreferences(
Log.e("SharedPreferences") { "Error saving DataStore preferences: ${e.message}" }
}
}
private fun decodeBottomBarItems(raw: String): List<NavBarItem> {
if (raw.isEmpty()) return emptyList()
return raw
.split(",")
.mapNotNull { name ->
try {
NavBarItem.valueOf(name)
} catch (_: IllegalArgumentException) {
null
}
}
}
}
}
@@ -151,6 +151,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.RequestToVani
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.VanishEventsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AllSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BottomBarSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.CallSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NamecoinSettingsScreen
@@ -297,6 +298,7 @@ fun BuildNavigation(
composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.UserSettings> { UserSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.ReactionsSettings> { ReactionsSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.BottomBarSettings> { BottomBarSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.CallSettings> { CallSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.ImportFollowsSelectUser> { ImportFollowListSelectUserScreen(accountViewModel, nav) }
composableFromEndArgs<Route.ImportFollowsPickFollows> {
@@ -25,9 +25,11 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.BottomAppBarDefaults.windowInsets
import androidx.compose.material3.HorizontalDivider
@@ -37,6 +39,7 @@ import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
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.graphics.Color
@@ -49,6 +52,8 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size0dp
import com.vitorpamplona.amethyst.ui.theme.Size10Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size23dp
@Composable
fun AppBottomBar(
@@ -56,18 +61,33 @@ fun AppBottomBar(
accountViewModel: AccountViewModel,
nav: (Route) -> Unit,
) {
val items by accountViewModel.settings.uiSettingsFlow.bottomBarItems
.collectAsStateWithLifecycle()
if (items.isEmpty()) {
Spacer(
modifier =
Modifier
.fillMaxWidth()
.windowInsetsPadding(windowInsets)
.consumeWindowInsets(windowInsets),
)
return
}
val isKeyboardState by keyboardAsState()
if (isKeyboardState == KeyboardState.Closed) {
RenderBottomMenu(selectedRoute, accountViewModel, nav)
RenderBottomMenu(items, selectedRoute, accountViewModel, nav)
}
}
@Composable
private fun RenderBottomMenu(
items: List<NavBarItem>,
selectedRoute: Route?,
accountViewModel: AccountViewModel,
nav: (Route) -> Unit,
) {
val defs = remember(items) { items.mapNotNull(NavBarCatalog::get) }
Column(
modifier =
Modifier
@@ -84,8 +104,9 @@ private fun RenderBottomMenu(
containerColor = MaterialTheme.colorScheme.background,
tonalElevation = Size0dp,
) {
bottomNavigationItems.forEach { item ->
HasNewItemsIcon(item.route == selectedRoute, item, accountViewModel, nav)
defs.forEach { def ->
val destination = remember(def, accountViewModel) { def.resolveRoute(accountViewModel) }
HasNewItemsIcon(destination == selectedRoute, def, destination, accountViewModel, nav)
}
}
}
@@ -94,7 +115,8 @@ private fun RenderBottomMenu(
@Composable
private fun RowScope.HasNewItemsIcon(
selected: Boolean,
bottomNav: BottomBarRoute,
def: NavBarItemDef,
destination: Route,
accountViewModel: AccountViewModel,
nav: (Route) -> Unit,
) {
@@ -103,30 +125,48 @@ private fun RowScope.HasNewItemsIcon(
icon = {
NotifiableIcon(
selected,
bottomNav,
def,
destination,
accountViewModel,
)
},
selected = selected,
onClick = { nav(bottomNav.route) },
onClick = { nav(destination) },
)
}
@Composable
private fun NotifiableIcon(
selected: Boolean,
route: BottomBarRoute,
def: NavBarItemDef,
destination: Route,
accountViewModel: AccountViewModel,
) {
Box(route.notifSize) {
Icon(
painter = painterRes(resourceId = route.icon, 0),
contentDescription = stringRes(route.contentDescriptor),
modifier = route.iconSize,
tint = if (selected) MaterialTheme.colorScheme.primary else Color.Unspecified,
)
Box(Modifier.size(Size23dp)) {
val tint = if (selected) MaterialTheme.colorScheme.primary else Color.Unspecified
val iconSizeModifier = Modifier.size(Size20dp)
val description = stringRes(def.labelRes)
when (val icon = def.icon) {
is NavBarIcon.Drawable -> {
Icon(
painter = painterRes(resourceId = icon.resId, icon.reference),
contentDescription = description,
modifier = iconSizeModifier,
tint = tint,
)
}
AddNotifIconIfNeeded(route.route, accountViewModel, Modifier.align(Alignment.TopEnd))
is NavBarIcon.Vector -> {
Icon(
imageVector = icon.vector,
contentDescription = description,
modifier = iconSizeModifier,
tint = tint,
)
}
}
AddNotifIconIfNeeded(destination, accountViewModel, Modifier.align(Alignment.TopEnd))
}
}
@@ -1,48 +0,0 @@
/*
* 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.navigation.bottombars
import androidx.compose.foundation.layout.size
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size23dp
import com.vitorpamplona.amethyst.ui.theme.Size24dp
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import kotlinx.collections.immutable.persistentListOf
class BottomBarRoute(
val route: Route,
val icon: Int,
val contentDescriptor: Int,
val notifSize: Modifier = Modifier.size(Size23dp),
val iconSize: Modifier = Modifier.size(Size20dp),
)
val bottomNavigationItems =
persistentListOf(
BottomBarRoute(Route.Home, R.drawable.ic_home, R.string.route_home, Modifier.size(Size25dp), Modifier.size(Size24dp)),
BottomBarRoute(Route.Message, R.drawable.ic_dm, R.string.route_messages),
BottomBarRoute(Route.Video, R.drawable.ic_video, R.string.route_video),
BottomBarRoute(Route.Discover, R.drawable.ic_sensors, R.string.route_discover),
BottomBarRoute(Route.Notification(), R.drawable.ic_notifications, R.string.route_notifications),
)
@@ -0,0 +1,260 @@
/*
* 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.navigation.bottombars
import androidx.annotation.DrawableRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
import androidx.compose.material.icons.automirrored.outlined.Article
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.outlined.AccountBalanceWallet
import androidx.compose.material.icons.outlined.CollectionsBookmark
import androidx.compose.material.icons.outlined.Drafts
import androidx.compose.material.icons.outlined.EmojiEmotions
import androidx.compose.material.icons.outlined.Groups
import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.MilitaryTech
import androidx.compose.material.icons.outlined.Photo
import androidx.compose.material.icons.outlined.PlayCircle
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.SmartDisplay
import androidx.compose.material.icons.outlined.Storefront
import androidx.compose.material.icons.outlined.Tag
import androidx.compose.ui.graphics.vector.ImageVector
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.serialization.Serializable
/**
* Stable identifiers for every drawer destination that the user can pin to the bottom bar.
* Order in this enum has no semantic meaning — the user picks a subset and an order at runtime.
*/
@Serializable
enum class NavBarItem {
HOME,
MESSAGES,
VIDEO,
DISCOVER,
NOTIFICATIONS,
PROFILE,
MY_LISTS,
BOOKMARKS,
WEB_BOOKMARKS,
DRAFTS,
INTEREST_SETS,
EMOJI_PACKS,
WALLET,
COMMUNITIES,
ARTICLES,
PICTURES,
SHORTS,
LONGS,
BADGES,
PRODUCTS,
EMOJI_SETS,
SETTINGS,
}
sealed class NavBarIcon {
data class Drawable(
@DrawableRes val resId: Int,
val reference: Int = 0,
) : NavBarIcon()
data class Vector(
val vector: ImageVector,
) : NavBarIcon()
}
data class NavBarItemDef(
val id: NavBarItem,
val labelRes: Int,
val icon: NavBarIcon,
val resolveRoute: (AccountViewModel) -> Route,
)
val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
linkedMapOf(
NavBarItem.HOME to
NavBarItemDef(
id = NavBarItem.HOME,
labelRes = R.string.route_home,
icon = NavBarIcon.Drawable(R.drawable.ic_home),
resolveRoute = { Route.Home },
),
NavBarItem.MESSAGES to
NavBarItemDef(
id = NavBarItem.MESSAGES,
labelRes = R.string.route_messages,
icon = NavBarIcon.Drawable(R.drawable.ic_dm),
resolveRoute = { Route.Message },
),
NavBarItem.VIDEO to
NavBarItemDef(
id = NavBarItem.VIDEO,
labelRes = R.string.route_video,
icon = NavBarIcon.Drawable(R.drawable.ic_video),
resolveRoute = { Route.Video },
),
NavBarItem.DISCOVER to
NavBarItemDef(
id = NavBarItem.DISCOVER,
labelRes = R.string.route_discover,
icon = NavBarIcon.Drawable(R.drawable.ic_sensors),
resolveRoute = { Route.Discover },
),
NavBarItem.NOTIFICATIONS to
NavBarItemDef(
id = NavBarItem.NOTIFICATIONS,
labelRes = R.string.route_notifications,
icon = NavBarIcon.Drawable(R.drawable.ic_notifications),
resolveRoute = { Route.Notification() },
),
NavBarItem.PROFILE to
NavBarItemDef(
id = NavBarItem.PROFILE,
labelRes = R.string.profile,
icon = NavBarIcon.Vector(Icons.Default.AccountCircle),
resolveRoute = { Route.Profile(it.userProfile().pubkeyHex) },
),
NavBarItem.MY_LISTS to
NavBarItemDef(
id = NavBarItem.MY_LISTS,
labelRes = R.string.my_lists,
icon = NavBarIcon.Vector(Icons.AutoMirrored.Filled.FormatListBulleted),
resolveRoute = { Route.Lists },
),
NavBarItem.BOOKMARKS to
NavBarItemDef(
id = NavBarItem.BOOKMARKS,
labelRes = R.string.bookmarks,
icon = NavBarIcon.Vector(Icons.Outlined.CollectionsBookmark),
resolveRoute = { Route.BookmarkGroups },
),
NavBarItem.WEB_BOOKMARKS to
NavBarItemDef(
id = NavBarItem.WEB_BOOKMARKS,
labelRes = R.string.web_bookmarks,
icon = NavBarIcon.Vector(Icons.Outlined.Language),
resolveRoute = { Route.WebBookmarks },
),
NavBarItem.DRAFTS to
NavBarItemDef(
id = NavBarItem.DRAFTS,
labelRes = R.string.drafts,
icon = NavBarIcon.Vector(Icons.Outlined.Drafts),
resolveRoute = { Route.Drafts },
),
NavBarItem.INTEREST_SETS to
NavBarItemDef(
id = NavBarItem.INTEREST_SETS,
labelRes = R.string.interest_sets_title,
icon = NavBarIcon.Vector(Icons.Outlined.Tag),
resolveRoute = { Route.InterestSets },
),
NavBarItem.EMOJI_PACKS to
NavBarItemDef(
id = NavBarItem.EMOJI_PACKS,
labelRes = R.string.manage_emoji_packs,
icon = NavBarIcon.Vector(Icons.Outlined.EmojiEmotions),
resolveRoute = { Route.EmojiPacks },
),
NavBarItem.WALLET to
NavBarItemDef(
id = NavBarItem.WALLET,
labelRes = R.string.wallet,
icon = NavBarIcon.Vector(Icons.Outlined.AccountBalanceWallet),
resolveRoute = { Route.Wallet },
),
NavBarItem.COMMUNITIES to
NavBarItemDef(
id = NavBarItem.COMMUNITIES,
labelRes = R.string.communities,
icon = NavBarIcon.Vector(Icons.Outlined.Groups),
resolveRoute = { Route.Communities },
),
NavBarItem.ARTICLES to
NavBarItemDef(
id = NavBarItem.ARTICLES,
labelRes = R.string.discover_reads,
icon = NavBarIcon.Vector(Icons.AutoMirrored.Outlined.Article),
resolveRoute = { Route.Articles },
),
NavBarItem.PICTURES to
NavBarItemDef(
id = NavBarItem.PICTURES,
labelRes = R.string.pictures,
icon = NavBarIcon.Vector(Icons.Outlined.Photo),
resolveRoute = { Route.Pictures },
),
NavBarItem.SHORTS to
NavBarItemDef(
id = NavBarItem.SHORTS,
labelRes = R.string.shorts,
icon = NavBarIcon.Vector(Icons.Outlined.PlayCircle),
resolveRoute = { Route.Shorts },
),
NavBarItem.LONGS to
NavBarItemDef(
id = NavBarItem.LONGS,
labelRes = R.string.longs,
icon = NavBarIcon.Vector(Icons.Outlined.SmartDisplay),
resolveRoute = { Route.Longs },
),
NavBarItem.BADGES to
NavBarItemDef(
id = NavBarItem.BADGES,
labelRes = R.string.badges,
icon = NavBarIcon.Vector(Icons.Outlined.MilitaryTech),
resolveRoute = { Route.Badges },
),
NavBarItem.PRODUCTS to
NavBarItemDef(
id = NavBarItem.PRODUCTS,
labelRes = R.string.discover_marketplace,
icon = NavBarIcon.Vector(Icons.Outlined.Storefront),
resolveRoute = { Route.Products },
),
NavBarItem.EMOJI_SETS to
NavBarItemDef(
id = NavBarItem.EMOJI_SETS,
labelRes = R.string.emoji_sets,
icon = NavBarIcon.Vector(Icons.Outlined.EmojiEmotions),
resolveRoute = { Route.BrowseEmojiSets },
),
NavBarItem.SETTINGS to
NavBarItemDef(
id = NavBarItem.SETTINGS,
labelRes = R.string.settings,
icon = NavBarIcon.Vector(Icons.Outlined.Settings),
resolveRoute = { Route.AllSettings },
),
)
val DefaultBottomBarItems: List<NavBarItem> =
listOf(
NavBarItem.HOME,
NavBarItem.MESSAGES,
NavBarItem.VIDEO,
NavBarItem.DISCOVER,
NavBarItem.NOTIFICATIONS,
)
@@ -547,6 +547,53 @@ fun ListContent(
nav: INav,
) {
Column(modifier) {
CollapsibleSection(title = R.string.drawer_section_navigate) {
NavigationRow(
title = R.string.route_home,
icon = R.drawable.ic_home,
iconReference = 0,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.Home,
)
NavigationRow(
title = R.string.route_messages,
icon = R.drawable.ic_dm,
iconReference = 0,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.Message,
)
NavigationRow(
title = R.string.route_video,
icon = R.drawable.ic_video,
iconReference = 0,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.Video,
)
NavigationRow(
title = R.string.route_discover,
icon = R.drawable.ic_sensors,
iconReference = 0,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.Discover,
)
NavigationRow(
title = R.string.route_notifications,
icon = R.drawable.ic_notifications,
iconReference = 0,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
computeRoute = { Route.Notification() },
)
}
CollapsibleSection(title = R.string.drawer_section_you) {
NavigationRow(
title = R.string.profile,
@@ -795,6 +842,27 @@ fun NavigationRow(
)
}
@Composable
fun NavigationRow(
title: Int,
icon: Int,
iconReference: Int,
tint: Color,
nav: INav,
computeRoute: () -> Route,
) {
IconRow(
title,
icon,
iconReference,
tint,
onClick = {
nav.closeDrawer()
nav.nav(computeRoute)
},
)
}
@Composable
fun NavigationRow(
title: Int,
@@ -203,6 +203,8 @@ sealed class Route {
@Serializable object ReactionsSettings : Route()
@Serializable object BottomBarSettings : Route()
@Serializable object CallSettings : Route()
@Serializable object Lists : Route()
@@ -32,6 +32,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AutoAwesome
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.CloudUpload
import androidx.compose.material.icons.outlined.Dashboard
import androidx.compose.material.icons.outlined.DeleteForever
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material.icons.outlined.GroupAdd
@@ -209,6 +210,13 @@ fun AllSettingsScreen(
tint = tint,
onClick = { nav.nav(Route.ReactionsSettings) },
)
HorizontalDivider()
SettingsNavigationRow(
title = R.string.bottom_bar_settings,
icon = Icons.Outlined.Dashboard,
tint = tint,
onClick = { nav.nav(Route.BottomBarSettings) },
)
HorizontalDivider(thickness = 4.dp)
SettingsSectionHeader(R.string.danger_zone)
accountViewModel.account.settings.keyPair.privKey?.let {
@@ -0,0 +1,393 @@
/*
* 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.settings
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DragIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
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.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
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.ui.navigation.bottombars.NavBarCatalog
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarIcon
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItemDef
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
@Composable
@Preview(device = "spec:width=2100px,height=2340px,dpi=440")
fun BottomBarSettingsScreenPreview() {
ThemeComparisonRow {
BottomBarSettingsScreen(
mockAccountViewModel(),
EmptyNav(),
)
}
}
@Composable
fun BottomBarSettingsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
topBar = {
TopBarWithBackButton(stringRes(id = R.string.bottom_bar_settings), nav::popBack)
},
) { padding ->
Column(Modifier.padding(padding)) {
BottomBarSettingsContent(accountViewModel)
}
}
}
@Composable
fun BottomBarSettingsContent(accountViewModel: AccountViewModel) {
val pinned by accountViewModel.settings.uiSettingsFlow.bottomBarItems
.collectAsStateWithLifecycle()
var items by remember(pinned) {
mutableStateOf(initialRows(pinned))
}
fun save(newItems: List<Row>) {
items = newItems
accountViewModel.settings.uiSettingsFlow.bottomBarItems.tryEmit(
newItems.filter { it.pinned }.map { it.item },
)
}
var draggedItemIndex by remember { mutableIntStateOf(-1) }
var dragOffset by remember { mutableFloatStateOf(0f) }
val itemHeights = remember { mutableStateMapOf<Int, Float>() }
val isDragging = draggedItemIndex >= 0
val scrollState = remember { ScrollState(0) }
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(scrollState, enabled = !isDragging),
) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringRes(R.string.bottom_bar_settings_description),
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray,
modifier = Modifier.padding(bottom = 16.dp, start = Size20dp, end = Size20dp),
)
items.forEachIndexed { index, row ->
val rowIsDragging = draggedItemIndex == index
val targetElevation = if (rowIsDragging) 8f else 0f
val animatedElevation by animateFloatAsState(
targetValue = targetElevation,
label = "dragElevation",
)
NavBarItemCard(
row = row,
isDragging = rowIsDragging,
canDrag = row.pinned,
dragOffsetY = if (rowIsDragging) dragOffset else 0f,
elevation = animatedElevation,
onTogglePinned = {
val newItems = items.toMutableList()
val toggled = row.copy(pinned = !row.pinned)
newItems.removeAt(index)
val insertIndex =
if (toggled.pinned) {
newItems.indexOfFirst { !it.pinned }.let { if (it < 0) newItems.size else it }
} else {
val firstUnpinned = newItems.indexOfFirst { !it.pinned }
if (firstUnpinned < 0) newItems.size else firstUnpinned
}
newItems.add(insertIndex, toggled)
save(newItems)
},
onMeasured = { height ->
itemHeights[index] = height
},
onDragStart = {
draggedItemIndex = index
dragOffset = 0f
},
onDrag = { dragAmount ->
dragOffset += dragAmount
val currentIndex = draggedItemIndex
if (currentIndex < 0) return@NavBarItemCard
// Can only swap among pinned items (row.pinned == true).
if (dragOffset < 0 && currentIndex > 0 && items[currentIndex - 1].pinned) {
val aboveHeight = itemHeights[currentIndex - 1] ?: 0f
if (-dragOffset > aboveHeight / 2f) {
val newItems = items.toMutableList()
val temp = newItems[currentIndex - 1]
newItems[currentIndex - 1] = newItems[currentIndex]
newItems[currentIndex] = temp
items = newItems
val h1 = itemHeights[currentIndex]
val h2 = itemHeights[currentIndex - 1]
if (h1 != null) itemHeights[currentIndex - 1] = h1
if (h2 != null) itemHeights[currentIndex] = h2
dragOffset += aboveHeight
draggedItemIndex = currentIndex - 1
}
}
if (dragOffset > 0 &&
currentIndex < items.lastIndex &&
items[currentIndex + 1].pinned
) {
val belowHeight = itemHeights[currentIndex + 1] ?: 0f
if (dragOffset > belowHeight / 2f) {
val newItems = items.toMutableList()
val temp = newItems[currentIndex + 1]
newItems[currentIndex + 1] = newItems[currentIndex]
newItems[currentIndex] = temp
items = newItems
val h1 = itemHeights[currentIndex]
val h2 = itemHeights[currentIndex + 1]
if (h1 != null) itemHeights[currentIndex + 1] = h1
if (h2 != null) itemHeights[currentIndex] = h2
dragOffset -= belowHeight
draggedItemIndex = currentIndex + 1
}
}
},
onDragEnd = {
draggedItemIndex = -1
dragOffset = 0f
save(items)
},
onDragCancel = {
draggedItemIndex = -1
dragOffset = 0f
},
modifier =
Modifier
.zIndex(if (rowIsDragging) 1f else 0f),
)
val nextIsFirstUnpinned =
index < items.lastIndex && row.pinned && !items[index + 1].pinned
if (nextIsFirstUnpinned) {
SectionDivider(R.string.bottom_bar_settings_available)
} else if (index < items.lastIndex) {
HorizontalDivider(modifier = Modifier.padding(horizontal = Size20dp))
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
private data class Row(
val item: NavBarItem,
val pinned: Boolean,
)
private fun initialRows(pinned: List<NavBarItem>): List<Row> {
val pinnedRows = pinned.mapNotNull { id -> NavBarCatalog[id]?.let { Row(id, pinned = true) } }
val unpinnedRows =
NavBarCatalog.keys
.filter { it !in pinned }
.map { Row(it, pinned = false) }
return pinnedRows + unpinnedRows
}
@Composable
private fun SectionDivider(titleRes: Int) {
Text(
text = stringRes(titleRes),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 20.dp, bottom = 4.dp, start = Size20dp, end = Size20dp),
)
HorizontalDivider(modifier = Modifier.padding(horizontal = Size20dp))
}
@Composable
private fun NavBarItemCard(
row: Row,
isDragging: Boolean,
canDrag: Boolean,
dragOffsetY: Float,
elevation: Float,
onTogglePinned: () -> Unit,
onMeasured: (Float) -> Unit,
onDragStart: () -> Unit,
onDrag: (Float) -> Unit,
onDragEnd: () -> Unit,
onDragCancel: () -> Unit,
modifier: Modifier = Modifier,
) {
val def = NavBarCatalog[row.item] ?: return
val label = stringRes(def.labelRes)
Column(
modifier =
modifier
.fillMaxWidth()
.onGloballyPositioned { coordinates ->
onMeasured(coordinates.size.height.toFloat())
}.graphicsLayer {
translationY = dragOffsetY
shadowElevation = elevation
if (isDragging) {
scaleX = 1.02f
scaleY = 1.02f
}
}.padding(vertical = 8.dp, horizontal = Size20dp)
.then(
if (canDrag) {
Modifier.pointerInput(Unit) {
detectDragGestures(
onDragStart = { onDragStart() },
onDrag = { change, dragAmount ->
change.consume()
onDrag(dragAmount.y)
},
onDragEnd = { onDragEnd() },
onDragCancel = { onDragCancel() },
)
}
} else {
Modifier
},
),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
NavBarIconBox(def)
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Switch(
checked = row.pinned,
onCheckedChange = { onTogglePinned() },
)
Box(
modifier = Modifier.size(28.dp),
contentAlignment = Alignment.Center,
) {
if (canDrag) {
Icon(
Icons.Default.DragIndicator,
contentDescription = stringRes(R.string.bottom_bar_settings_reorder),
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@Composable
private fun NavBarIconBox(def: NavBarItemDef) {
Box(
modifier = Modifier.size(28.dp),
contentAlignment = Alignment.Center,
) {
val description = stringRes(def.labelRes)
val tint = MaterialTheme.colorScheme.onBackground
when (val icon = def.icon) {
is NavBarIcon.Drawable -> {
Icon(
painter = painterRes(icon.resId, icon.reference),
contentDescription = description,
modifier = Modifier.size(24.dp),
tint = tint,
)
}
is NavBarIcon.Vector -> {
Icon(
imageVector = icon.vector,
contentDescription = description,
modifier = Modifier.size(24.dp),
tint = tint,
)
}
}
}
}
+5
View File
@@ -369,6 +369,7 @@
<string name="private_conversation_notification">"&lt;Unable to decrypt private message&gt;\n\nYou were cited in a private/encrypted conversation between %1$s and %2$s."</string>
<string name="account_switch_add_account_dialog_title">Add New Account</string>
<string name="drawer_accounts">Accounts</string>
<string name="drawer_section_navigate">Navigate</string>
<string name="drawer_section_you">You</string>
<string name="drawer_section_feeds">Feeds</string>
<string name="drawer_section_create">Create</string>
@@ -1551,6 +1552,10 @@
<string name="change_reaction">Change Quick Reactions</string>
<string name="bottom_bar_settings">Bottom Navigation Bar</string>
<string name="bottom_bar_settings_description">Drag to reorder. Toggle to add or remove an item from the bottom bar. With zero items the bottom bar is hidden.</string>
<string name="bottom_bar_settings_available">Available</string>
<string name="bottom_bar_settings_reorder">Reorder</string>
<string name="reactions_settings">Reaction Row</string>
<string name="reactions_settings_description">Configure which reaction buttons are shown, their order, and whether to display counters.</string>
<string name="reactions_settings_enabled">Enabled</string>