From 34cb21bc62be0cf3a2d66ec9d23f69dc39f19b28 Mon Sep 17 00:00:00 2001 From: Rainxch Zed Date: Sun, 4 Jan 2026 15:32:42 +0500 Subject: [PATCH 1/2] feat(nav): Add liquid bottom navigation bar This commit introduces a new bottom navigation bar that utilizes the "liquid" effect library. Key changes include: * **Bottom Navigation Component**: A new `BottomNavigation` composable has been created, which appears on the home screen. It uses a liquid effect on supported platforms. * **Liquid State Management**: A `CompositionLocal` (`LocalBottomNavigationLiquid`) is introduced to provide the liquid state to child composables like the home screen's repository list, allowing them to interact with the navigation bar's animation. * **UI Integration**: The main `AppNavigation` now wraps the `NavDisplay` in a `Box` to overlay the bottom navigation bar. The home screen and its items are made "liquefiable" to create a cohesive visual effect when scrolling. * **Code Refactoring**: Top bar actions from the `HomeRoot` have been removed, as their functionality is now handled by the new bottom navigation. --- .../app/navigation/AppNavigation.kt | 247 ++++++++++-------- .../app/navigation/BottomNavigation.kt | 84 ++++++ .../app/navigation/BottomNavigationUtils.kt | 47 ++++ .../navigation/LocalBottomNavigationLiquid.kt | 8 + .../feature/home/presentation/HomeRoot.kt | 25 +- 5 files changed, 293 insertions(+), 118 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/BottomNavigation.kt create mode 100644 composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/BottomNavigationUtils.kt create mode 100644 composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/LocalBottomNavigationLiquid.kt diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt index 8528b6e92..fe788a713 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt @@ -6,15 +6,23 @@ import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.snapshots.toInt +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay +import io.github.fletchmckee.liquid.rememberLiquidState import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf import zed.rainxch.githubstore.feature.apps.presentation.AppsRoot @@ -23,135 +31,156 @@ import zed.rainxch.githubstore.feature.details.presentation.DetailsRoot import zed.rainxch.githubstore.feature.home.presentation.HomeRoot import zed.rainxch.githubstore.feature.search.presentation.SearchRoot import zed.rainxch.githubstore.feature.settings.presentation.SettingsRoot +import kotlin.Boolean @Composable fun AppNavigation( navBackStack: SnapshotStateList ) { + val liquidState = rememberLiquidState() - NavDisplay( - backStack = navBackStack, - onBack = { - navBackStack.removeLastOrNull() - }, - entryProvider = entryProvider { - entry { - HomeRoot( - onNavigateToSearch = { - navBackStack.add(GithubStoreGraph.SearchScreen) - }, - onNavigateToSettings = { - navBackStack.add(GithubStoreGraph.SettingsScreen) - }, - onNavigateToApps = { - navBackStack.add(GithubStoreGraph.AppsScreen) - }, - onNavigateToDetails = { repo -> - navBackStack.add( - GithubStoreGraph.DetailsScreen( - repositoryId = repo.id.toInt() - ) + CompositionLocalProvider( + value = LocalBottomNavigationLiquid provides liquidState + ) { + Box( + modifier = Modifier.fillMaxSize() + ) { + NavDisplay( + backStack = navBackStack, + onBack = { + navBackStack.removeLastOrNull() + }, + entryProvider = entryProvider { + entry { + HomeRoot( + onNavigateToSearch = { + navBackStack.add(GithubStoreGraph.SearchScreen) + }, + onNavigateToSettings = { + navBackStack.add(GithubStoreGraph.SettingsScreen) + }, + onNavigateToApps = { + navBackStack.add(GithubStoreGraph.AppsScreen) + }, + onNavigateToDetails = { repo -> + navBackStack.add( + GithubStoreGraph.DetailsScreen( + repositoryId = repo.id.toInt() + ) + ) + } ) } - ) - } - entry { - SearchRoot( - onNavigateBack = { - navBackStack.removeLastOrNull() - }, - onNavigateToDetails = { repo -> - navBackStack.add( - GithubStoreGraph.DetailsScreen( - repositoryId = repo.id.toInt() - ) + entry { + SearchRoot( + onNavigateBack = { + navBackStack.removeLastOrNull() + }, + onNavigateToDetails = { repo -> + navBackStack.add( + GithubStoreGraph.DetailsScreen( + repositoryId = repo.id.toInt() + ) + ) + } ) } - ) - } - entry { args -> - DetailsRoot( - onNavigateBack = { - navBackStack.removeLastOrNull() - }, - onOpenRepositoryInApp = { repoId -> - navBackStack.add( - GithubStoreGraph.DetailsScreen( - repositoryId = repoId - ) + entry { args -> + DetailsRoot( + onNavigateBack = { + navBackStack.removeLastOrNull() + }, + onOpenRepositoryInApp = { repoId -> + navBackStack.add( + GithubStoreGraph.DetailsScreen( + repositoryId = repoId + ) + ) + }, + viewModel = koinViewModel { + parametersOf(args.repositoryId) + } ) - }, - viewModel = koinViewModel { - parametersOf(args.repositoryId) } - ) - } - entry { - AuthenticationRoot( - onNavigateToHome = { - navBackStack.clear() - navBackStack.add(GithubStoreGraph.HomeScreen) + entry { + AuthenticationRoot( + onNavigateToHome = { + navBackStack.clear() + navBackStack.add(GithubStoreGraph.HomeScreen) + } + ) } - ) - } - entry { - SettingsRoot( - onNavigateBack = { - navBackStack.removeLastOrNull() + entry { + SettingsRoot( + onNavigateBack = { + navBackStack.removeLastOrNull() + } + ) } - ) - } - entry { - AppsRoot( - onNavigateBack = { - navBackStack.removeLastOrNull() - }, - onNavigateToRepo = { repoId -> - navBackStack.add( - GithubStoreGraph.DetailsScreen( - repositoryId = repoId.toInt() - ) + entry { + AppsRoot( + onNavigateBack = { + navBackStack.removeLastOrNull() + }, + onNavigateToRepo = { repoId -> + navBackStack.add( + GithubStoreGraph.DetailsScreen( + repositoryId = repoId.toInt() + ) + ) + } ) } - ) - } - }, - entryDecorators = listOf( - rememberSaveableStateHolderNavEntryDecorator(), - rememberViewModelStoreNavEntryDecorator() - ), - transitionSpec = { - slideInHorizontally( - initialOffsetX = { it }, - animationSpec = spring(Spring.DampingRatioLowBouncy) - ) togetherWith slideOutHorizontally( - targetOffsetX = { -it }, - animationSpec = spring(Spring.DampingRatioLowBouncy) + }, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator() + ), + transitionSpec = { + slideInHorizontally( + initialOffsetX = { it }, + animationSpec = spring(Spring.DampingRatioLowBouncy) + ) togetherWith slideOutHorizontally( + targetOffsetX = { -it }, + animationSpec = spring(Spring.DampingRatioLowBouncy) + ) + }, + popTransitionSpec = { + slideInHorizontally( + initialOffsetX = { -it }, + animationSpec = spring(Spring.DampingRatioLowBouncy) + ) togetherWith slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = spring(Spring.DampingRatioLowBouncy) + ) + }, + predictivePopTransitionSpec = { + slideInHorizontally( + initialOffsetX = { -it }, + animationSpec = spring(Spring.DampingRatioLowBouncy) + ) togetherWith slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = spring(Spring.DampingRatioLowBouncy) + ) + }, + modifier = Modifier.background(MaterialTheme.colorScheme.background) ) - }, - popTransitionSpec = { - slideInHorizontally( - initialOffsetX = { -it }, - animationSpec = spring(Spring.DampingRatioLowBouncy) - ) togetherWith slideOutHorizontally( - targetOffsetX = { it }, - animationSpec = spring(Spring.DampingRatioLowBouncy) - ) - }, - predictivePopTransitionSpec = { - slideInHorizontally( - initialOffsetX = { -it }, - animationSpec = spring(Spring.DampingRatioLowBouncy) - ) togetherWith slideOutHorizontally( - targetOffsetX = { it }, - animationSpec = spring(Spring.DampingRatioLowBouncy) + + BottomNavigation( + currentScreen = navBackStack.last(), + onNavigate = { + navBackStack.add(it) + }, + modifier = Modifier + .align(Alignment.BottomCenter) + .statusBarsPadding() + .padding(bottom = 24.dp) ) - }, - modifier = Modifier.background(MaterialTheme.colorScheme.background) - ) -} + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/BottomNavigation.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/BottomNavigation.kt new file mode 100644 index 000000000..6ac6d9f50 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/BottomNavigation.kt @@ -0,0 +1,84 @@ +package zed.rainxch.githubstore.app.navigation + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import io.github.fletchmckee.liquid.liquefiable +import io.github.fletchmckee.liquid.liquid +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.resources.vectorResource +import zed.rainxch.githubstore.core.domain.getPlatform +import zed.rainxch.githubstore.core.domain.model.PlatformType +import zed.rainxch.githubstore.feature.details.presentation.utils.isLiquidTopbarEnabled + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun BottomNavigation( + currentScreen: GithubStoreGraph, + onNavigate: (GithubStoreGraph) -> Unit, + modifier: Modifier = Modifier +) { + val liquidState = LocalBottomNavigationLiquid.current + if (currentScreen in BottomNavigationUtils.allowedScreens()) { + Row( + modifier = modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .then( + if (isLiquidTopbarEnabled()) { + Modifier.liquid(liquidState) { + this.shape = CircleShape + this.frost = 16.dp + this.curve = .4f + this.refraction = .1f + this.dispersion = .2f + this.saturation = .5f + } + } else Modifier + ) + .padding(10.dp), + ) { + BottomNavigationUtils + .items() + .filterNot { + getPlatform().type != PlatformType.ANDROID && + it.screen == GithubStoreGraph.AppsScreen + } + .forEach { item -> + IconButton( + onClick = { + onNavigate(item.screen) + }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = if (item.screen == currentScreen) { + MaterialTheme.colorScheme.primaryContainer + } else Color.Transparent, + contentColor = if (item.screen == currentScreen) { + MaterialTheme.colorScheme.onPrimaryContainer + } else MaterialTheme.colorScheme.onSurface, + ), + shapes = IconButtonDefaults.shapes() + ) { + Icon( + imageVector = item.iconRes, + contentDescription = stringResource(item.titleRes), + modifier = Modifier + .padding(6.dp) + ) + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/BottomNavigationUtils.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/BottomNavigationUtils.kt new file mode 100644 index 000000000..dabd2c40f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/BottomNavigationUtils.kt @@ -0,0 +1,47 @@ +package zed.rainxch.githubstore.app.navigation + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Apps +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.outlined.Apps +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.ui.graphics.vector.ImageVector +import githubstore.composeapp.generated.resources.Res +import githubstore.composeapp.generated.resources.installed_apps +import githubstore.composeapp.generated.resources.search_repositories_hint +import githubstore.composeapp.generated.resources.settings_title +import org.jetbrains.compose.resources.StringResource + +data class BottomNavigationItem( + val titleRes: StringResource, + val iconRes: ImageVector, + val screen: GithubStoreGraph +) + +object BottomNavigationUtils { + fun items(): List { + return listOf( + BottomNavigationItem( + titleRes = Res.string.search_repositories_hint, + iconRes = Icons.Outlined.Search, + screen = GithubStoreGraph.SearchScreen + ), + BottomNavigationItem( + titleRes = Res.string.installed_apps, + iconRes = Icons.Outlined.Apps, + screen = GithubStoreGraph.AppsScreen + ), + BottomNavigationItem( + titleRes = Res.string.settings_title, + iconRes = Icons.Outlined.Settings, + screen = GithubStoreGraph.SettingsScreen + ) + ) + } + + fun allowedScreens(): List { + return listOf(GithubStoreGraph.HomeScreen) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/LocalBottomNavigationLiquid.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/LocalBottomNavigationLiquid.kt new file mode 100644 index 000000000..861d65926 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/LocalBottomNavigationLiquid.kt @@ -0,0 +1,8 @@ +package zed.rainxch.githubstore.app.navigation + +import androidx.compose.runtime.compositionLocalOf +import io.github.fletchmckee.liquid.LiquidState + +val LocalBottomNavigationLiquid = compositionLocalOf { + error("State not declared") +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/home/presentation/HomeRoot.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/home/presentation/HomeRoot.kt index bd4d50387..ada975d7d 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/home/presentation/HomeRoot.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/home/presentation/HomeRoot.kt @@ -60,10 +60,13 @@ import githubstore.composeapp.generated.resources.home_retry import githubstore.composeapp.generated.resources.installed_apps import githubstore.composeapp.generated.resources.search_repositories_hint import githubstore.composeapp.generated.resources.settings_title +import io.github.fletchmckee.liquid.LiquidState +import io.github.fletchmckee.liquid.liquefiable import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview import org.koin.compose.viewmodel.koinViewModel +import zed.rainxch.githubstore.app.navigation.LocalBottomNavigationLiquid import zed.rainxch.githubstore.core.presentation.components.GithubStoreButton import zed.rainxch.githubstore.core.presentation.components.RepositoryCard import zed.rainxch.githubstore.core.presentation.theme.GithubStoreTheme @@ -113,6 +116,7 @@ fun HomeScreen( onAction: (HomeAction) -> Unit, ) { val listState = rememberLazyStaggeredGridState() + val liquidState = LocalBottomNavigationLiquid.current val shouldLoadMore by remember { derivedStateOf { @@ -148,6 +152,7 @@ fun HomeScreen( .fillMaxSize() .padding(innerPadding) .padding(horizontal = 8.dp) + .liquefiable(liquidState) ) { FilterChips(state, onAction) @@ -156,7 +161,12 @@ fun HomeScreen( ErrorState(state, onAction) - MainState(state, listState, onAction) + MainState( + state = state, + listState = listState, + onAction = onAction, + liquidState = liquidState + ) } } } @@ -166,7 +176,8 @@ fun HomeScreen( private fun MainState( state: HomeState, listState: LazyStaggeredGridState, - onAction: (HomeAction) -> Unit + onAction: (HomeAction) -> Unit, + liquidState: LiquidState ) { if (state.repos.isNotEmpty()) { LazyVerticalStaggeredGrid( @@ -189,7 +200,9 @@ private fun MainState( onClick = { onAction(HomeAction.OnRepositoryClick(homeRepo.repo)) }, - modifier = Modifier.animateItem() + modifier = Modifier + .animateItem() + .liquefiable(liquidState) ) } @@ -349,12 +362,6 @@ private fun TopAppBar( overflow = TextOverflow.Ellipsis ) }, - actions = { - TopbarActions( - state = state, - onAction = onAction - ) - }, modifier = Modifier.padding(12.dp) ) } From ec263d384dfafe150538cfb5019a8143f601f88e Mon Sep 17 00:00:00 2001 From: Rainxch Zed Date: Sun, 4 Jan 2026 17:07:31 +0500 Subject: [PATCH 2/2] refactor(preview): Fix bottom navigation in HomeScreen preview Wrapped the `HomeScreen` preview in `CompositionLocalProvider` and provided a `LocalBottomNavigationLiquid` state. This resolves rendering issues for components relying on this provider, such as the bottom navigation bar. Additionally, corrected the padding in `AppNavigation` from `statusBarsPadding` to `navigationBarsPadding` for the bottom navigation component, ensuring it is correctly positioned above the navigation bar. --- .../githubstore/app/navigation/AppNavigation.kt | 6 ++---- .../feature/home/presentation/HomeRoot.kt | 16 ++++++++++++---- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt index fe788a713..1a70a6319 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt @@ -8,13 +8,12 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.compose.runtime.snapshots.toInt import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -31,7 +30,6 @@ import zed.rainxch.githubstore.feature.details.presentation.DetailsRoot import zed.rainxch.githubstore.feature.home.presentation.HomeRoot import zed.rainxch.githubstore.feature.search.presentation.SearchRoot import zed.rainxch.githubstore.feature.settings.presentation.SettingsRoot -import kotlin.Boolean @Composable fun AppNavigation( @@ -178,7 +176,7 @@ fun AppNavigation( }, modifier = Modifier .align(Alignment.BottomCenter) - .statusBarsPadding() + .navigationBarsPadding() .padding(bottom = 24.dp) ) } diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/home/presentation/HomeRoot.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/home/presentation/HomeRoot.kt index ada975d7d..207631399 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/home/presentation/HomeRoot.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/home/presentation/HomeRoot.kt @@ -36,6 +36,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -62,6 +63,7 @@ import githubstore.composeapp.generated.resources.search_repositories_hint import githubstore.composeapp.generated.resources.settings_title import io.github.fletchmckee.liquid.LiquidState import io.github.fletchmckee.liquid.liquefiable +import io.github.fletchmckee.liquid.rememberLiquidState import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview @@ -449,9 +451,15 @@ private fun TopbarActions( @Composable private fun Preview() { GithubStoreTheme { - HomeScreen( - state = HomeState(), - onAction = {} - ) + val liquidState = rememberLiquidState() + + CompositionLocalProvider( + value = LocalBottomNavigationLiquid provides liquidState + ) { + HomeScreen( + state = HomeState(), + onAction = {} + ) + } } } \ No newline at end of file