Merge pull request #2489 from vitorpamplona/claude/configurable-bottom-nav-rFdQw
Add customizable bottom navigation bar with drag-to-reorder UI
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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),
|
||||
)
|
||||
+334
@@ -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<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.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<NavBarItem> =
|
||||
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<NavBarItem> =
|
||||
listOf(
|
||||
NavBarItem.HOME,
|
||||
NavBarItem.MESSAGES,
|
||||
NavBarItem.VIDEO,
|
||||
NavBarItem.DISCOVER,
|
||||
NavBarItem.NOTIFICATIONS,
|
||||
)
|
||||
|
||||
val DrawerYouItems: List<NavBarItem> =
|
||||
listOf(
|
||||
NavBarItem.PROFILE,
|
||||
NavBarItem.MY_LISTS,
|
||||
NavBarItem.BOOKMARKS,
|
||||
NavBarItem.WEB_BOOKMARKS,
|
||||
NavBarItem.DRAFTS,
|
||||
NavBarItem.INTEREST_SETS,
|
||||
NavBarItem.EMOJI_PACKS,
|
||||
NavBarItem.WALLET,
|
||||
)
|
||||
|
||||
val DrawerFeedsItems: List<NavBarItem> =
|
||||
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,
|
||||
)
|
||||
+89
-186
@@ -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<NavBarItem>,
|
||||
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,
|
||||
|
||||
@@ -209,6 +209,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>
|
||||
@@ -1554,6 +1555,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