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:
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -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> {
|
||||
|
||||
+55
-15
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-48
@@ -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),
|
||||
)
|
||||
+260
@@ -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,
|
||||
)
|
||||
+68
@@ -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()
|
||||
|
||||
+8
@@ -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 {
|
||||
|
||||
+393
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -369,6 +369,7 @@
|
||||
<string name="private_conversation_notification">"<Unable to decrypt private message>\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>
|
||||
|
||||
Reference in New Issue
Block a user