diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 59dec480..778259cc 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -387,6 +387,10 @@ Follow system Hide activity section Hides the activity section in the recipe details view + Hides the bottom navigation bar when scrolling down + Hide bottom bar on scroll + Keeps the search bar at the top on the home tab + Pin home search bar Appearance Change kitshn's behavior Enable crash reporting diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/Settings.kt b/composeApp/src/commonMain/kotlin/de/kitshn/Settings.kt index 73911fc2..afa04f80 100644 --- a/composeApp/src/commonMain/kotlin/de/kitshn/Settings.kt +++ b/composeApp/src/commonMain/kotlin/de/kitshn/Settings.kt @@ -26,6 +26,8 @@ const val KEY_SETTINGS_APPEARANCE_COLOR_SCHEME = "appearance_color_scheme" const val KEY_SETTINGS_APPEARANCE_CUSTOM_COLOR_SCHEME_SEED = "appearance_custom_color_scheme_seed" const val KEY_SETTINGS_APPEARANCE_ENLARGE_SHOPPING_MODE = "appearance_enlarge_shopping_mode" const val KEY_SETTINGS_APPEARANCE_HIDE_ACTIVITY = "appearance_hide_activity" +const val KEY_SETTINGS_APPEARANCE_HIDE_BOTTOM_BAR_ON_SCROLL = "appearance_hide_bottom_bar_on_scroll" +const val KEY_SETTINGS_APPEARANCE_PIN_HOME_SEARCH_BAR = "appearance_pin_home_search_bar" const val KEY_SETTINGS_BEHAVIOR_USE_SHARE_WRAPPER = "behavior_use_share_wrapper" const val KEY_SETTINGS_BEHAVIOR_USE_SHARE_WRAPPER_HINT_SHOWN = @@ -152,6 +154,18 @@ class SettingsViewModel : ViewModel() { fun setHideActivity(hide: Boolean) = obs.putBoolean(KEY_SETTINGS_APPEARANCE_HIDE_ACTIVITY, hide) + val getHideBottomBarOnScroll: Flow = + obs.getBooleanFlow(KEY_SETTINGS_APPEARANCE_HIDE_BOTTOM_BAR_ON_SCROLL, false) + + fun setHideBottomBarOnScroll(hide: Boolean) = + obs.putBoolean(KEY_SETTINGS_APPEARANCE_HIDE_BOTTOM_BAR_ON_SCROLL, hide) + + val getPinHomeSearchBar: Flow = + obs.getBooleanFlow(KEY_SETTINGS_APPEARANCE_PIN_HOME_SEARCH_BAR, false) + + fun setPinHomeSearchBar(pin: Boolean) = + obs.putBoolean(KEY_SETTINGS_APPEARANCE_PIN_HOME_SEARCH_BAR, pin) + // behavior val getUseShareWrapper: Flow = obs.getBooleanFlow(KEY_SETTINGS_BEHAVIOR_USE_SHARE_WRAPPER, true) diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/ScrollToHideConnection.kt b/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/ScrollToHideConnection.kt new file mode 100644 index 00000000..6fdb1ae0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/de/kitshn/ui/component/ScrollToHideConnection.kt @@ -0,0 +1,92 @@ +package de.kitshn.ui.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.unit.Velocity +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun rememberScrollToHideConnection( + enabled: Boolean, + onHide: suspend () -> Unit, + onShow: suspend () -> Unit +): NestedScrollConnection { + val scope = rememberCoroutineScope() + + return remember(enabled) { + var showJob: Job? = null + var scrollDelta = 0f + + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (!enabled) return super.onPreScroll(available, source) + + val delta = available.y + + // Reset accumulation on direction change + if ((delta > 0 && scrollDelta < 0) || (delta < 0 && scrollDelta > 0)) { + scrollDelta = 0f + } + + // Restart show job on any interaction + if (source == NestedScrollSource.UserInput) { + showJob?.cancel() + showJob = scope.launch { + delay(600) + onShow() + scrollDelta = 0f + } + } + + return super.onPreScroll(available, source) + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + if (!enabled) return super.onPostScroll(consumed, available, source) + + // Only accumulate downward scroll if content actually moved + // (prevents hiding at the bottom of a list or when not scrollable) + if (consumed.y < 0) { + scrollDelta += consumed.y + } + + // Accumulate upward scroll (consumed OR available/overscroll at the top) + // ensures the bar shows even when the content is already at the top. + if (consumed.y > 0 || available.y > 0) { + scrollDelta += (consumed.y + available.y) + } + + if (scrollDelta < -20f) { // Significant scroll down + showJob?.cancel() + scope.launch { onHide() } + scrollDelta = 0f + } else if (scrollDelta > 20f) { // Significant scroll up + showJob?.cancel() + scope.launch { onShow() } + scrollDelta = 0f + } + + return super.onPostScroll(consumed, available, source) + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + if (enabled) { + showJob?.cancel() + onShow() + scrollDelta = 0f + } + return super.onPostFling(consumed, available) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/ui/route/main/Main.kt b/composeApp/src/commonMain/kotlin/de/kitshn/ui/route/main/Main.kt index 66c6a006..4c16b553 100644 --- a/composeApp/src/commonMain/kotlin/de/kitshn/ui/route/main/Main.kt +++ b/composeApp/src/commonMain/kotlin/de/kitshn/ui/route/main/Main.kt @@ -14,19 +14,25 @@ import androidx.compose.material.icons.rounded.ShoppingCart import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldValue +import androidx.compose.material3.adaptive.navigationsuite.rememberNavigationSuiteScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.style.TextOverflow import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState import de.kitshn.saveBreadcrumb +import de.kitshn.ui.component.rememberScrollToHideConnection import de.kitshn.ui.dialog.version.TandoorBetaInfoDialog import de.kitshn.ui.dialog.version.TandoorServerVersionCompatibilityDialog import de.kitshn.ui.route.RouteParameters @@ -37,6 +43,7 @@ import kitshn.composeapp.generated.resources.navigation_home import kitshn.composeapp.generated.resources.navigation_meal_plan import kitshn.composeapp.generated.resources.navigation_settings import kitshn.composeapp.generated.resources.navigation_shopping +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource @@ -79,6 +86,18 @@ fun RouteMain(p: RouteParameters) { var currentDestination by rememberSaveable { mutableStateOf(AppDestinations.HOME) } val mainSubNavHostController = rememberAlternateNavController() + val scaffoldState = rememberNavigationSuiteScaffoldState() + val scope = rememberCoroutineScope() + + val hideBottomBarOnScroll = + p.vm.settings.getHideBottomBarOnScroll.collectAsState(initial = false) + + val nestedScrollConnection = rememberScrollToHideConnection( + enabled = hideBottomBarOnScroll.value, + onHide = { scaffoldState.hide() }, + onShow = { scaffoldState.show() } + ) + val destination by mainSubNavHostController.currentBackStackEntryAsState() LaunchedEffect(destination) { val route = destination?.destination?.route ?: "" @@ -88,12 +107,19 @@ fun RouteMain(p: RouteParameters) { if(!route.startsWith(it.route)) return@forEach currentDestination = it } + + // Show bottom bar when destination changes + if (scaffoldState.currentValue == NavigationSuiteScaffoldValue.Hidden) { + scope.launch { scaffoldState.show() } + } } val isOffline = p.vm.uiState.offlineState.isOffline NavigationSuiteScaffold( - navigationSuiteColors = NavigationSuiteDefaults.colors( + state = scaffoldState, + modifier = Modifier.nestedScroll(nestedScrollConnection), + navigationSuiteColors = androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults.colors( navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest ), navigationSuiteItems = { diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/ui/route/main/subroute/home/Home.kt b/composeApp/src/commonMain/kotlin/de/kitshn/ui/route/main/subroute/home/Home.kt index c75d6317..01144c3a 100644 --- a/composeApp/src/commonMain/kotlin/de/kitshn/ui/route/main/subroute/home/Home.kt +++ b/composeApp/src/commonMain/kotlin/de/kitshn/ui/route/main/subroute/home/Home.kt @@ -113,7 +113,11 @@ fun RouteMainSubrouteHome( val selectionModeState = rememberSelectionModeState() - val searchBarScrollBehavior = SearchBarDefaults.enterAlwaysSearchBarScrollBehavior() + val pinHomeSearchBar by p.vm.settings.getPinHomeSearchBar.collectAsState(initial = false) + + val searchBarScrollBehavior = SearchBarDefaults.enterAlwaysSearchBarScrollBehavior( + canScroll = { !pinHomeSearchBar } + ) var isScrollingUp by rememberSaveable { mutableStateOf(true) } diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/ui/route/main/subroute/home/HomeDynamicLayout.kt b/composeApp/src/commonMain/kotlin/de/kitshn/ui/route/main/subroute/home/HomeDynamicLayout.kt index e9a632c8..e040f52d 100644 --- a/composeApp/src/commonMain/kotlin/de/kitshn/ui/route/main/subroute/home/HomeDynamicLayout.kt +++ b/composeApp/src/commonMain/kotlin/de/kitshn/ui/route/main/subroute/home/HomeDynamicLayout.kt @@ -195,7 +195,7 @@ fun HomeDynamicLayout( } } - if(homePageSectionList.size == 0 && pageLoadingState != ErrorLoadingSuccessState.SUCCESS) { + if(homePageSectionList.isEmpty() && pageLoadingState != ErrorLoadingSuccessState.SUCCESS) { repeat(5) { HomePageSectionView( client = p.vm.tandoorClient, diff --git a/composeApp/src/commonMain/kotlin/de/kitshn/ui/view/settings/SettingsAppearance.kt b/composeApp/src/commonMain/kotlin/de/kitshn/ui/view/settings/SettingsAppearance.kt index a1d4985b..5bb8a4b8 100644 --- a/composeApp/src/commonMain/kotlin/de/kitshn/ui/view/settings/SettingsAppearance.kt +++ b/composeApp/src/commonMain/kotlin/de/kitshn/ui/view/settings/SettingsAppearance.kt @@ -3,6 +3,7 @@ package de.kitshn.ui.view.settings import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ViewList import androidx.compose.material.icons.rounded.AutoAwesome import androidx.compose.material.icons.rounded.CommentsDisabled import androidx.compose.material.icons.rounded.DarkMode @@ -46,6 +47,10 @@ import kitshn.composeapp.generated.resources.settings_section_appearance_follow_ import kitshn.composeapp.generated.resources.settings_section_appearance_follow_system_label import kitshn.composeapp.generated.resources.settings_section_appearance_hide_activity_description import kitshn.composeapp.generated.resources.settings_section_appearance_hide_activity_label +import kitshn.composeapp.generated.resources.settings_section_appearance_hide_bottom_bar_on_scroll_description +import kitshn.composeapp.generated.resources.settings_section_appearance_hide_bottom_bar_on_scroll_label +import kitshn.composeapp.generated.resources.settings_section_appearance_pin_home_search_bar_description +import kitshn.composeapp.generated.resources.settings_section_appearance_pin_home_search_bar_label import kitshn.composeapp.generated.resources.settings_section_appearance_label import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource @@ -175,6 +180,40 @@ fun ViewSettingsAppearance( } } } + + item { + val hideBottomBarOnScroll = p.vm.settings.getHideBottomBarOnScroll.collectAsState(initial = false) + + SettingsSwitchListItem( + position = SettingsListItemPosition.TOP, + label = { Text(stringResource(Res.string.settings_section_appearance_hide_bottom_bar_on_scroll_label)) }, + description = { Text(stringResource(Res.string.settings_section_appearance_hide_bottom_bar_on_scroll_description)) }, + icon = Icons.AutoMirrored.Rounded.ViewList, + contentDescription = stringResource(Res.string.settings_section_appearance_hide_bottom_bar_on_scroll_label), + checked = hideBottomBarOnScroll.value + ) { + coroutineScope.launch { + p.vm.settings.setHideBottomBarOnScroll(it) + } + } + } + + item { + val pinHomeSearchBar = p.vm.settings.getPinHomeSearchBar.collectAsState(initial = false) + + SettingsSwitchListItem( + position = SettingsListItemPosition.BOTTOM, + label = { Text(stringResource(Res.string.settings_section_appearance_pin_home_search_bar_label)) }, + description = { Text(stringResource(Res.string.settings_section_appearance_pin_home_search_bar_description)) }, + icon = Icons.Rounded.Loupe, + contentDescription = stringResource(Res.string.settings_section_appearance_pin_home_search_bar_label), + checked = pinHomeSearchBar.value + ) { + coroutineScope.launch { + p.vm.settings.setPinHomeSearchBar(it) + } + } + } } }