diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt index b1e9c9466..3393bd4aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt @@ -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 = DefaultBottomBarItems, ) enum class ThemeType( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt index 7a4bbeb1b..d4a5144ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt @@ -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 = MutableStateFlow(FeatureSetType.SIMPLIFIED), val gallerySet: MutableStateFlow = MutableStateFlow(ProfileGalleryType.CLASSIC), val automaticallyProposeAiImprovements: MutableStateFlow = MutableStateFlow(BooleanType.ALWAYS), + val bottomBarItems: MutableStateFlow> = MutableStateFlow(DefaultBottomBarItems), ) { val listOfFlows: List> = listOf>( @@ -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 = combine(listOfFlows) { flows: Array -> UiSettings( @@ -75,6 +80,7 @@ class UiSettingsFlow( flows[10] as FeatureSetType, flows[11] as ProfileGalleryType, flows[12] as BooleanType, + flows[13] as List, ) } @@ -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), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt index c899fcbdc..586bacb81 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt @@ -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 { + if (raw.isEmpty()) return emptyList() + return raw + .split(",") + .mapNotNull { name -> + try { + NavBarItem.valueOf(name) + } catch (_: IllegalArgumentException) { + null + } + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 8ff743384..0ab6ef971 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -154,6 +154,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 @@ -303,6 +304,7 @@ fun BuildNavigation( composableFromEnd { SettingsScreen(accountViewModel, nav) } composableFromEnd { UserSettingsScreen(accountViewModel, nav) } composableFromEnd { ReactionsSettingsScreen(accountViewModel, nav) } + composableFromEnd { BottomBarSettingsScreen(accountViewModel, nav) } composableFromEnd { CallSettingsScreen(accountViewModel, nav) } composableFromEnd { ImportFollowListSelectUserScreen(accountViewModel, nav) } composableFromEndArgs { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt index f98f1c367..126cf9a5b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt @@ -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, 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)) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarRoutes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarRoutes.kt deleted file mode 100644 index 134400932..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarRoutes.kt +++ /dev/null @@ -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), - ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt new file mode 100644 index 000000000..18e26e0a5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -0,0 +1,334 @@ +/* + * 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.automirrored.outlined.Chat +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.Sensors +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, + PUBLIC_CHATS, + FOLLOW_PACKS, + LIVE_STREAMS, + LONGS, + POLLS, + 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 = + 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.PUBLIC_CHATS to + NavBarItemDef( + id = NavBarItem.PUBLIC_CHATS, + labelRes = R.string.public_chats, + icon = NavBarIcon.Vector(Icons.AutoMirrored.Outlined.Chat), + resolveRoute = { Route.PublicChats }, + ), + NavBarItem.FOLLOW_PACKS to + NavBarItemDef( + id = NavBarItem.FOLLOW_PACKS, + labelRes = R.string.follow_packs, + icon = NavBarIcon.Vector(Icons.Outlined.CollectionsBookmark), + resolveRoute = { Route.FollowPacks }, + ), + NavBarItem.LIVE_STREAMS to + NavBarItemDef( + id = NavBarItem.LIVE_STREAMS, + labelRes = R.string.live_streams, + icon = NavBarIcon.Vector(Icons.Outlined.Sensors), + resolveRoute = { Route.LiveStreams }, + ), + NavBarItem.LONGS to + NavBarItemDef( + id = NavBarItem.LONGS, + labelRes = R.string.longs, + icon = NavBarIcon.Vector(Icons.Outlined.SmartDisplay), + resolveRoute = { Route.Longs }, + ), + NavBarItem.POLLS to + NavBarItemDef( + id = NavBarItem.POLLS, + labelRes = R.string.polls, + icon = NavBarIcon.Drawable(R.drawable.ic_poll, reference = 1), + resolveRoute = { Route.Polls }, + ), + 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 = + listOf( + NavBarItem.HOME, + NavBarItem.MESSAGES, + NavBarItem.VIDEO, + NavBarItem.DISCOVER, + NavBarItem.NOTIFICATIONS, + ) + +// Ordered membership lists for each drawer section. The drawer renders these by looking up +// each id in NavBarCatalog, so adding a new screen only requires editing the catalog + the +// matching section list below — not two separate files. +val DrawerNavigateItems: List = + listOf( + NavBarItem.HOME, + NavBarItem.MESSAGES, + NavBarItem.VIDEO, + NavBarItem.DISCOVER, + NavBarItem.NOTIFICATIONS, + ) + +val DrawerYouItems: List = + listOf( + NavBarItem.PROFILE, + NavBarItem.MY_LISTS, + NavBarItem.BOOKMARKS, + NavBarItem.WEB_BOOKMARKS, + NavBarItem.DRAFTS, + NavBarItem.INTEREST_SETS, + NavBarItem.EMOJI_PACKS, + NavBarItem.WALLET, + ) + +val DrawerFeedsItems: List = + listOf( + NavBarItem.COMMUNITIES, + NavBarItem.ARTICLES, + NavBarItem.PICTURES, + NavBarItem.SHORTS, + NavBarItem.PUBLIC_CHATS, + NavBarItem.FOLLOW_PACKS, + NavBarItem.LIVE_STREAMS, + NavBarItem.LONGS, + NavBarItem.POLLS, + NavBarItem.BADGES, + NavBarItem.PRODUCTS, + NavBarItem.EMOJI_SETS, + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index 4dbb9a65b..4e419ed3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -50,30 +50,17 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.FormatListBulleted import androidx.compose.material.icons.automirrored.filled.Send -import androidx.compose.material.icons.automirrored.outlined.Article -import androidx.compose.material.icons.automirrored.outlined.Chat import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Delete -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.ExpandLess import androidx.compose.material.icons.outlined.ExpandMore import androidx.compose.material.icons.outlined.GroupAdd -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.Sensors -import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.SettingsInputAntenna import androidx.compose.material.icons.outlined.SmartDisplay -import androidx.compose.material.icons.outlined.Storefront -import androidx.compose.material.icons.outlined.Tag import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -122,6 +109,13 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserStatuses import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerFeedsItems +import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerNavigateItems +import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerYouItems +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.routes.Route @@ -549,172 +543,9 @@ fun ListContent( nav: INav, ) { Column(modifier) { - CollapsibleSection(title = R.string.drawer_section_you) { - NavigationRow( - title = R.string.profile, - icon = Icons.Default.AccountCircle, - tint = MaterialTheme.colorScheme.primary, - nav = nav, - computeRoute = { - Route.Profile(accountViewModel.userProfile().pubkeyHex) - }, - ) - - NavigationRow( - title = R.string.my_lists, - icon = Icons.AutoMirrored.Filled.FormatListBulleted, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.Lists, - ) - - NavigationRow( - title = R.string.bookmarks, - icon = Icons.Outlined.CollectionsBookmark, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.BookmarkGroups, - ) - - NavigationRow( - title = R.string.web_bookmarks, - icon = Icons.Outlined.Language, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.WebBookmarks, - ) - - NavigationRow( - title = R.string.drafts, - icon = Icons.Outlined.Drafts, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.Drafts, - ) - - NavigationRow( - title = R.string.interest_sets_title, - icon = Icons.Outlined.Tag, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.InterestSets, - ) - - NavigationRow( - title = R.string.manage_emoji_packs, - icon = Icons.Outlined.EmojiEmotions, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.EmojiPacks, - ) - - NavigationRow( - title = R.string.wallet, - icon = Icons.Outlined.AccountBalanceWallet, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.Wallet, - ) - } - - CollapsibleSection(title = R.string.drawer_section_feeds) { - NavigationRow( - title = R.string.communities, - icon = Icons.Outlined.Groups, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.Communities, - ) - - NavigationRow( - title = R.string.discover_reads, - icon = Icons.AutoMirrored.Outlined.Article, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.Articles, - ) - - NavigationRow( - title = R.string.pictures, - icon = Icons.Outlined.Photo, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.Pictures, - ) - - NavigationRow( - title = R.string.shorts, - icon = Icons.Outlined.PlayCircle, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.Shorts, - ) - - NavigationRow( - title = R.string.public_chats, - icon = Icons.AutoMirrored.Outlined.Chat, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.PublicChats, - ) - - NavigationRow( - title = R.string.follow_packs, - icon = Icons.Outlined.CollectionsBookmark, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.FollowPacks, - ) - - NavigationRow( - title = R.string.live_streams, - icon = Icons.Outlined.Sensors, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.LiveStreams, - ) - - NavigationRow( - title = R.string.longs, - icon = Icons.Outlined.SmartDisplay, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.Longs, - ) - - NavigationRow( - title = R.string.polls, - icon = R.drawable.ic_poll, - iconReference = 1, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.Polls, - ) - - NavigationRow( - title = R.string.badges, - icon = Icons.Outlined.MilitaryTech, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.Badges, - ) - - NavigationRow( - title = R.string.discover_marketplace, - icon = Icons.Outlined.Storefront, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.Products, - ) - - NavigationRow( - title = R.string.emoji_sets, - icon = Icons.Outlined.EmojiEmotions, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.BrowseEmojiSets, - ) - } + CatalogSection(R.string.drawer_section_navigate, DrawerNavigateItems, accountViewModel, nav) + CatalogSection(R.string.drawer_section_you, DrawerYouItems, accountViewModel, nav) + CatalogSection(R.string.drawer_section_feeds, DrawerFeedsItems, accountViewModel, nav) CollapsibleSection(title = R.string.drawer_section_create) { NavigationRow( @@ -746,13 +577,9 @@ fun ListContent( }, ) - NavigationRow( - title = R.string.settings, - icon = Icons.Outlined.Settings, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.AllSettings, - ) + NavBarCatalog[NavBarItem.SETTINGS]?.let { + CatalogNavigationRow(it, MaterialTheme.colorScheme.onBackground, accountViewModel, nav) + } } Spacer(modifier = Modifier.weight(1f)) @@ -766,6 +593,61 @@ fun ListContent( } } +/** + * Renders a drawer section by iterating [ids] and looking each one up in [NavBarCatalog]. + * Profile gets the primary-colored tint; every other item uses onBackground. + */ +@Composable +fun CatalogSection( + titleRes: Int, + ids: List, + accountViewModel: AccountViewModel, + nav: INav, +) { + val primary = MaterialTheme.colorScheme.primary + val onBackground = MaterialTheme.colorScheme.onBackground + + CollapsibleSection(title = titleRes) { + ids.forEach { id -> + NavBarCatalog[id]?.let { def -> + val tint = if (def.id == NavBarItem.PROFILE) primary else onBackground + CatalogNavigationRow(def, tint, accountViewModel, nav) + } + } + } +} + +@Composable +fun CatalogNavigationRow( + def: NavBarItemDef, + tint: Color, + accountViewModel: AccountViewModel, + nav: INav, +) { + when (val icon = def.icon) { + is NavBarIcon.Drawable -> { + NavigationRow( + title = def.labelRes, + icon = icon.resId, + iconReference = icon.reference, + tint = tint, + nav = nav, + computeRoute = { def.resolveRoute(accountViewModel) }, + ) + } + + is NavBarIcon.Vector -> { + NavigationRow( + title = def.labelRes, + icon = icon.vector, + tint = tint, + nav = nav, + computeRoute = { def.resolveRoute(accountViewModel) }, + ) + } + } +} + @Composable fun CollapsibleSection( title: Int, @@ -821,6 +703,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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 216c34df8..f84f394bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -209,6 +209,8 @@ sealed class Route { @Serializable object ReactionsSettings : Route() + @Serializable object BottomBarSettings : Route() + @Serializable object CallSettings : Route() @Serializable object Lists : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index f040d037e..072c26c95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -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 { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt new file mode 100644 index 000000000..09b15e84c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt @@ -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) { + 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() } + 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): List { + 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, + ) + } + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a2f4b23b3..19796e0d8 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -369,6 +369,7 @@ "<Unable to decrypt private message>\n\nYou were cited in a private/encrypted conversation between %1$s and %2$s." Add New Account Accounts + Navigate You Feeds Create @@ -1554,6 +1555,10 @@ Change Quick Reactions + Bottom Navigation Bar + Drag to reorder. Toggle to add or remove an item from the bottom bar. With zero items the bottom bar is hidden. + Available + Reorder Reaction Row Configure which reaction buttons are shown, their order, and whether to display counters. Enabled