feat: configurable bottom navigation • part 1 (#1111)

This commit is contained in:
Diego Beraldin 2024-07-11 08:31:57 +02:00 committed by GitHub
parent 3a1b04d010
commit e8199ed40f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 942 additions and 816 deletions

View File

@ -19,6 +19,10 @@ sealed interface TabNavigationSection {
data object Profile : TabNavigationSection
data object Inbox : TabNavigationSection
data object Settings : TabNavigationSection
data object Bookmarks : TabNavigationSection
}
sealed interface ComposeEvent {

View File

@ -0,0 +1,31 @@
package com.github.diegoberaldin.raccoonforlemmy.feature.settings
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.navigator.tab.Tab
import cafe.adriel.voyager.navigator.tab.TabOptions
import com.github.diegoberaldin.raccoonforlemmy.core.l10n.messages.LocalStrings
import com.github.diegoberaldin.raccoonforlemmy.feature.settings.main.SettingsScreen
object SettingsTab : Tab {
override val options: TabOptions
@Composable
get() {
val icon = rememberVectorPainter(Icons.Default.Settings)
val title = LocalStrings.current.navigationSettings
return TabOptions(
index = 4u,
title = title,
icon = icon,
)
}
@Composable
override fun Content() {
Navigator(SettingsScreen())
}
}

View File

@ -33,8 +33,9 @@ kotlin {
dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.material)
implementation(compose.material3)
implementation(compose.materialIconsExtended)
@OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
implementation(compose.components.resources)

View File

@ -2,6 +2,7 @@ package com.github.diegoberaldin.raccoonforlemmy
import cafe.adriel.voyager.core.model.ScreenModel
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.MviModel
import com.github.diegoberaldin.raccoonforlemmy.core.navigation.TabNavigationSection
interface MainMviModel :
MviModel<MainMviModel.Intent, MainMviModel.UiState, MainMviModel.Effect>,
@ -18,6 +19,13 @@ interface MainMviModel :
val bottomBarOffsetHeightPx: Float = 0f,
val customProfileUrl: String? = null,
val isLogged: Boolean = false,
val bottomBarSections: List<TabNavigationSection> =
listOf(
TabNavigationSection.Home,
TabNavigationSection.Explore,
TabNavigationSection.Inbox,
TabNavigationSection.Profile,
),
)
sealed interface Effect {

View File

@ -33,6 +33,7 @@ import androidx.compose.ui.unit.toSize
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.koin.getScreenModel
import cafe.adriel.voyager.navigator.tab.CurrentTab
import cafe.adriel.voyager.navigator.tab.Tab
import cafe.adriel.voyager.navigator.tab.TabNavigator
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.di.getThemeRepository
import com.github.diegoberaldin.raccoonforlemmy.core.l10n.messages.LocalStrings
@ -42,13 +43,10 @@ import com.github.diegoberaldin.raccoonforlemmy.core.navigation.di.getDrawerCoor
import com.github.diegoberaldin.raccoonforlemmy.core.navigation.di.getNavigationCoordinator
import com.github.diegoberaldin.raccoonforlemmy.core.notifications.NotificationCenterEvent
import com.github.diegoberaldin.raccoonforlemmy.core.notifications.di.getNotificationCenter
import com.github.diegoberaldin.raccoonforlemmy.core.utils.compose.rememberCallback
import com.github.diegoberaldin.raccoonforlemmy.core.utils.compose.rememberCallbackArgs
import com.github.diegoberaldin.raccoonforlemmy.feature.home.ui.HomeTab
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.ui.InboxTab
import com.github.diegoberaldin.raccoonforlemmy.feature.profile.ui.ProfileTab
import com.github.diegoberaldin.raccoonforlemmy.feature.search.ui.ExploreTab
import com.github.diegoberaldin.raccoonforlemmy.feature.settings.main.SettingsScreen
import com.github.diegoberaldin.raccoonforlemmy.ui.navigation.TabNavigationItem
import com.github.diegoberaldin.raccoonforlemmy.navigation.TabNavigationItem
import com.github.diegoberaldin.raccoonforlemmy.unit.manageaccounts.ManageAccountsScreen
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.drop
@ -225,6 +223,37 @@ internal object MainScreen : Screen {
uiFontSizeWorkaround = true
}.launchIn(this)
}
fun handleOnLongPress(
tab: Tab,
section: TabNavigationSection,
) {
when (section) {
TabNavigationSection.Explore -> {
tabNavigator.current = tab
navigationCoordinator.setCurrentSection(TabNavigationSection.Explore)
scope.launch {
notificationCenter.send(NotificationCenterEvent.OpenSearchInExplore)
}
}
TabNavigationSection.Inbox -> {
if (uiState.isLogged) {
model.reduce(MainMviModel.Intent.ReadAllInbox)
}
}
TabNavigationSection.Profile -> {
if (uiState.isLogged) {
val screen = ManageAccountsScreen()
navigationCoordinator.showBottomSheet(screen)
}
}
else -> Unit
}
}
if (uiFontSizeWorkaround) {
NavigationBar(
modifier =
@ -248,45 +277,27 @@ internal object MainScreen : Screen {
),
tonalElevation = 0.dp,
) {
for (section in uiState.bottomBarSections) {
TabNavigationItem(
tab = HomeTab,
withText = titleVisible,
)
TabNavigationItem(
tab = ExploreTab,
section = section,
withText = titleVisible,
customIconUrl =
if (section == TabNavigationSection.Profile) {
uiState.customProfileUrl
} else {
null
},
onClick =
rememberCallbackArgs { tab ->
tabNavigator.current = tab
navigationCoordinator.setCurrentSection(section)
},
onLongPress =
rememberCallback {
tabNavigator.current = ExploreTab
navigationCoordinator.setCurrentSection(TabNavigationSection.Explore)
scope.launch {
notificationCenter.send(NotificationCenterEvent.OpenSearchInExplore)
}
rememberCallbackArgs { tab ->
handleOnLongPress(tab, section)
},
)
TabNavigationItem(
tab = InboxTab,
withText = titleVisible,
onLongPress =
rememberCallback(model) {
if (uiState.isLogged) {
model.reduce(MainMviModel.Intent.ReadAllInbox)
}
},
)
TabNavigationItem(
tab = ProfileTab,
withText = titleVisible,
customIconUrl = uiState.customProfileUrl,
onLongPress =
rememberCallback {
if (uiState.isLogged) {
val screen = ManageAccountsScreen()
navigationCoordinator.showBottomSheet(screen)
}
},
)
}
}
}

View File

@ -0,0 +1,37 @@
package com.github.diegoberaldin.raccoonforlemmy.navigation
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.navigator.tab.Tab
import cafe.adriel.voyager.navigator.tab.TabOptions
import com.github.diegoberaldin.raccoonforlemmy.core.l10n.messages.LocalStrings
import com.github.diegoberaldin.raccoonforlemmy.unit.filteredcontents.FilteredContentsScreen
import com.github.diegoberaldin.raccoonforlemmy.unit.filteredcontents.FilteredContentsType
import com.github.diegoberaldin.raccoonforlemmy.unit.filteredcontents.toInt
object BookmarksTab : Tab {
override val options: TabOptions
@Composable
get() {
val icon = rememberVectorPainter(Icons.Default.Bookmark)
val title = LocalStrings.current.navigationDrawerTitleBookmarks
return TabOptions(
index = 5u,
title = title,
icon = icon,
)
}
@Composable
override fun Content() {
Navigator(
FilteredContentsScreen(
type = FilteredContentsType.Bookmarks.toInt(),
),
)
}
}

View File

@ -1,4 +1,4 @@
package com.github.diegoberaldin.raccoonforlemmy.ui.navigation
package com.github.diegoberaldin.raccoonforlemmy.navigation
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
@ -24,7 +24,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.style.TextOverflow
import cafe.adriel.voyager.navigator.tab.LocalTabNavigator
import cafe.adriel.voyager.navigator.tab.Tab
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.IconSize
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.Spacing
@ -32,39 +31,28 @@ import com.github.diegoberaldin.raccoonforlemmy.core.commonui.components.CustomI
import com.github.diegoberaldin.raccoonforlemmy.core.l10n.messages.LocalStrings
import com.github.diegoberaldin.raccoonforlemmy.core.navigation.TabNavigationSection
import com.github.diegoberaldin.raccoonforlemmy.core.navigation.di.getNavigationCoordinator
import com.github.diegoberaldin.raccoonforlemmy.feature.home.ui.HomeTab
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.ui.InboxTab
import com.github.diegoberaldin.raccoonforlemmy.feature.profile.ui.ProfileTab
import com.github.diegoberaldin.raccoonforlemmy.feature.search.ui.ExploreTab
import com.github.diegoberaldin.raccoonforlemmy.feature.settings.SettingsTab
@Composable
internal fun RowScope.TabNavigationItem(
tab: Tab,
section: TabNavigationSection,
withText: Boolean = true,
customIconUrl: String? = null,
onLongPress: (() -> Unit)? = null,
onClick: ((Tab) -> Unit)? = null,
onLongPress: ((Tab) -> Unit)? = null,
) {
val tabNavigator = LocalTabNavigator.current
val navigationCoordinator = remember { getNavigationCoordinator() }
val unread by navigationCoordinator.inboxUnread.collectAsState()
val color =
if (tabNavigator.current == tab) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline
}
val currentSection by navigationCoordinator.currentSection.collectAsState()
val interactionSource = remember { MutableInteractionSource() }
val tab = section.toTab()
fun handleClick() {
tabNavigator.current = tab
val section =
when (tab) {
ExploreTab -> TabNavigationSection.Explore
ProfileTab -> TabNavigationSection.Profile
InboxTab -> TabNavigationSection.Inbox
else -> TabNavigationSection.Home
}
navigationCoordinator.setCurrentSection(section)
onClick?.invoke(tab)
}
val pointerInputModifier =
@ -80,14 +68,14 @@ internal fun RowScope.TabNavigationItem(
handleClick()
},
onLongPress = {
onLongPress?.invoke()
onLongPress?.invoke(tab)
},
)
}
NavigationBarItem(
onClick = ::handleClick,
selected = tabNavigator.current == tab,
selected = section == currentSection,
interactionSource = interactionSource,
icon = {
val content = @Composable {
@ -107,7 +95,6 @@ internal fun RowScope.TabNavigationItem(
modifier = pointerInputModifier,
painter = tab.options.icon ?: rememberVectorPainter(Icons.Default.Home),
contentDescription = null,
tint = color,
)
}
}
@ -141,7 +128,6 @@ internal fun RowScope.TabNavigationItem(
Text(
modifier = Modifier,
text = tab.options.title,
color = color,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@ -149,3 +135,13 @@ internal fun RowScope.TabNavigationItem(
},
)
}
private fun TabNavigationSection.toTab(): Tab =
when (this) {
TabNavigationSection.Explore -> ExploreTab
TabNavigationSection.Profile -> ProfileTab
TabNavigationSection.Inbox -> InboxTab
TabNavigationSection.Settings -> SettingsTab
TabNavigationSection.Bookmarks -> BookmarksTab
else -> HomeTab
}

View File

@ -5,10 +5,10 @@ import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -25,6 +25,7 @@ import androidx.compose.material.icons.filled.ArrowCircleDown
import androidx.compose.material.icons.filled.ArrowCircleUp
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
@ -49,6 +50,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -60,6 +62,7 @@ import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.koin.getScreenModel
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.data.PostLayout
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.di.getThemeRepository
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.Dimensions
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.Spacing
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.components.FloatingActionButtonMenu
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.components.FloatingActionButtonMenuItem
@ -74,12 +77,14 @@ import com.github.diegoberaldin.raccoonforlemmy.core.commonui.lemmyui.PostCardPl
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.lemmyui.di.getFabNestedScrollConnection
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.modals.LikedTypeSheet
import com.github.diegoberaldin.raccoonforlemmy.core.l10n.messages.LocalStrings
import com.github.diegoberaldin.raccoonforlemmy.core.navigation.di.getDrawerCoordinator
import com.github.diegoberaldin.raccoonforlemmy.core.navigation.di.getNavigationCoordinator
import com.github.diegoberaldin.raccoonforlemmy.core.persistence.data.ActionOnSwipe
import com.github.diegoberaldin.raccoonforlemmy.core.persistence.di.getSettingsRepository
import com.github.diegoberaldin.raccoonforlemmy.core.utils.compose.onClick
import com.github.diegoberaldin.raccoonforlemmy.core.utils.compose.rememberCallback
import com.github.diegoberaldin.raccoonforlemmy.core.utils.compose.rememberCallbackArgs
import com.github.diegoberaldin.raccoonforlemmy.core.utils.toLocalPixel
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.CommentModel
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.PostModel
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.readableHandle
@ -97,6 +102,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.koin.core.parameter.parametersOf
import kotlin.math.roundToInt
class FilteredContentsScreen(
private val type: Int,
@ -111,6 +117,7 @@ class FilteredContentsScreen(
val fabNestedScrollConnection = remember { getFabNestedScrollConnection() }
val isFabVisible by fabNestedScrollConnection.isFabVisible.collectAsState()
val navigationCoordinator = remember { getNavigationCoordinator() }
val drawerCoordinator = remember { getDrawerCoordinator() }
val settingsRepository = remember { getSettingsRepository() }
val settings by settingsRepository.currentSettings.collectAsState()
val lazyListState = rememberLazyListState()
@ -137,7 +144,8 @@ class FilteredContentsScreen(
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(model) {
model.effects.onEach { effect ->
model.effects
.onEach { effect ->
when (effect) {
FilteredContentsMviModel.Effect.BackToTop -> {
runCatching {
@ -150,7 +158,8 @@ class FilteredContentsScreen(
}.launchIn(this)
}
LaunchedEffect(navigationCoordinator) {
navigationCoordinator.globalMessage.onEach { message ->
navigationCoordinator.globalMessage
.onEach { message ->
snackbarHostState.showSnackbar(
message = message,
)
@ -160,12 +169,26 @@ class FilteredContentsScreen(
Scaffold(
modifier = Modifier.background(MaterialTheme.colorScheme.background),
topBar = {
val maxTopInset = Dimensions.maxTopBarInset.toLocalPixel()
var topInset by remember { mutableStateOf(maxTopInset) }
snapshotFlow { topAppBarState.collapsedFraction }
.onEach {
topInset = maxTopInset * (1 - it)
}.launchIn(scope)
TopAppBar(
windowInsets =
if (settings.edgeToEdge) {
WindowInsets(0, topInset.roundToInt(), 0, 0)
} else {
TopAppBarDefaults.windowInsets
},
scrollBehavior = scrollBehavior,
navigationIcon = {
if (navigationCoordinator.canPop.value) {
Image(
modifier =
Modifier.onClick(
Modifier
.onClick(
onClick = {
navigationCoordinator.popScreen()
},
@ -174,6 +197,21 @@ class FilteredContentsScreen(
contentDescription = null,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground),
)
} else {
Image(
modifier =
Modifier.onClick(
onClick = {
scope.launch {
drawerCoordinator.toggleDrawer()
}
},
),
imageVector = Icons.Default.Menu,
contentDescription = null,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground),
)
}
},
title = {
Column(modifier = Modifier.padding(horizontal = Spacing.s)) {
@ -252,21 +290,25 @@ class FilteredContentsScreen(
}
},
) { padding ->
Column(
Box(
modifier =
Modifier
.padding(
top = padding.calculateTopPadding(),
)
.then(
).then(
if (settings.hideNavigationBarWhileScrolling) {
Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
} else {
Modifier
},
),
verticalArrangement = Arrangement.spacedBy(Spacing.s),
).nestedScroll(fabNestedScrollConnection)
.pullRefresh(pullRefreshState),
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = lazyListState,
) {
item {
if (!uiState.isPostOnly) {
SectionSelector(
titles =
@ -285,29 +327,16 @@ class FilteredContentsScreen(
1 -> FilteredContentsSection.Comments
else -> FilteredContentsSection.Posts
}
model.reduce(FilteredContentsMviModel.Intent.ChangeSection(section))
model.reduce(
FilteredContentsMviModel.Intent.ChangeSection(
section,
),
)
},
)
}
}
Box(
modifier =
Modifier
.fillMaxWidth()
.then(
if (settings.hideNavigationBarWhileScrolling) {
Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
} else {
Modifier
},
)
.nestedScroll(fabNestedScrollConnection)
.pullRefresh(pullRefreshState),
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = lazyListState,
) {
if (uiState.section == FilteredContentsSection.Posts) {
if (uiState.posts.isEmpty() && uiState.loading && uiState.initial) {
items(5) {
@ -588,17 +617,23 @@ class FilteredContentsScreen(
OptionId.FeaturePost ->
model.reduce(
FilteredContentsMviModel.Intent.ModFeaturePost(post.id),
FilteredContentsMviModel.Intent.ModFeaturePost(
post.id,
),
)
OptionId.AdminFeaturePost ->
model.reduce(
FilteredContentsMviModel.Intent.AdminFeaturePost(post.id),
FilteredContentsMviModel.Intent.AdminFeaturePost(
post.id,
),
)
OptionId.LockPost ->
model.reduce(
FilteredContentsMviModel.Intent.ModLockPost(post.id),
FilteredContentsMviModel.Intent.ModLockPost(
post.id,
),
)
OptionId.Remove -> {
@ -811,13 +846,17 @@ class FilteredContentsScreen(
onUpVote =
rememberCallback(model) {
model.reduce(
FilteredContentsMviModel.Intent.UpVoteComment(comment.id),
FilteredContentsMviModel.Intent.UpVoteComment(
comment.id,
),
)
},
onDownVote =
rememberCallback(model) {
model.reduce(
FilteredContentsMviModel.Intent.DownVoteComment(comment.id),
FilteredContentsMviModel.Intent.DownVoteComment(
comment.id,
),
)
},
onSave =
@ -988,7 +1027,6 @@ class FilteredContentsScreen(
contentColor = MaterialTheme.colorScheme.onBackground,
)
}
}
if (rawContent != null) {
when (val content = rawContent) {