Explorar o código

[新增] 悬浮底栏和液态玻璃底栏

HapeLee hai 7 meses
pai
achega
e38d96a6e5

+ 3 - 0
app/src/main/java/io/legado/app/constant/PreferKey.kt

@@ -207,6 +207,8 @@ object PreferKey {
     const val themePref = "themePref"
     const val bookshelfSortOrder = "bookshelfSortOrder"
     const val showBottomView = "showBottomView"
+    const val useFloatingBottomBar = "useFloatingBottomBar"
+    const val useFloatingBottomBarLiquidGlass = "useFloatingBottomBarLiquidGlass"
     const val mangaScrollMode = "mangaScrollMode"
     const val webtoonSidePaddingDp = "webtoonSidePaddingDp"
     const val mangaVolumeKeyPage = "mangaVolumeKeyPage"
@@ -267,6 +269,7 @@ object PreferKey {
     const val defaultSourceChangeAll = "defaultSourceChangeAll"
 
     const val showTip = "showTip"
+    const val showBookCount = "showBookCount"
     const val sliderVibrator = "sliderVibrator"
     const val selectVibrator = "selectVibrator"
     const val audioCacheCleanTime = "audioCacheCleanTime"

+ 142 - 0
app/src/main/java/io/legado/app/ui/animation/DampedDragAnimation.kt

@@ -0,0 +1,142 @@
+package io.legado.app.ui.animation
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.MutatorMutex
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.input.pointer.util.VelocityTracker
+import androidx.compose.ui.unit.IntSize
+import io.legado.app.ui.util.inspectDragGestures
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.android.awaitFrame
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlin.math.abs
+
+class DampedDragAnimation(
+    private val animationScope: CoroutineScope,
+    val initialValue: Float,
+    val valueRange: ClosedRange<Float>,
+    val visibilityThreshold: Float,
+    val initialScale: Float,
+    val pressedScale: Float,
+    val canDrag: (Offset) -> Boolean = { true },
+    val onDragStarted: DampedDragAnimation.(position: Offset) -> Unit,
+    val onDragStopped: DampedDragAnimation.() -> Unit,
+    val onDrag: DampedDragAnimation.(size: IntSize, dragAmount: Offset) -> Unit,
+) {
+
+    private val valueAnimationSpec = spring(1f, 1000f, visibilityThreshold)
+    private val velocityAnimationSpec = spring(0.5f, 300f, visibilityThreshold * 10f)
+    private val pressProgressAnimationSpec = spring(1f, 1000f, 0.001f)
+    private val scaleXAnimationSpec = spring(0.6f, 250f, 0.001f)
+    private val scaleYAnimationSpec = spring(0.7f, 250f, 0.001f)
+
+    private val valueAnimation = Animatable(initialValue, visibilityThreshold)
+    private val velocityAnimation = Animatable(0f, 5f)
+    private val pressProgressAnimation = Animatable(0f, 0.001f)
+    private val scaleXAnimation = Animatable(initialScale, 0.001f)
+    private val scaleYAnimation = Animatable(initialScale, 0.001f)
+
+    private val mutatorMutex = MutatorMutex()
+    private val velocityTracker = VelocityTracker()
+
+    val value: Float get() = valueAnimation.value
+    val targetValue: Float get() = valueAnimation.targetValue
+    val pressProgress: Float get() = pressProgressAnimation.value
+    val scaleX: Float get() = scaleXAnimation.value
+    val scaleY: Float get() = scaleYAnimation.value
+    val velocity: Float get() = velocityAnimation.value
+
+    val modifier: Modifier = Modifier.pointerInput(Unit) {
+        inspectDragGestures(
+            onDragStart = { down ->
+                onDragStarted(down.position)
+                press()
+            },
+            onDragEnd = {
+                onDragStopped()
+                release()
+            },
+            onDragCancel = {
+                onDragStopped()
+                release()
+            }
+        ) { change, dragAmount ->
+            val position = change.position
+            val previousPosition = change.previousPosition
+
+            val isInside = canDrag(position)
+            val wasInside = canDrag(previousPosition)
+
+            if (isInside && wasInside) {
+                onDrag(size, dragAmount)
+            }
+        }
+    }
+
+    fun press() {
+        velocityTracker.resetTracking()
+        animationScope.launch {
+            launch { pressProgressAnimation.animateTo(1f, pressProgressAnimationSpec) }
+            launch { scaleXAnimation.animateTo(pressedScale, scaleXAnimationSpec) }
+            launch { scaleYAnimation.animateTo(pressedScale, scaleYAnimationSpec) }
+        }
+    }
+
+    fun release() {
+        animationScope.launch {
+            awaitFrame()
+            if (value != targetValue) {
+                val threshold = (valueRange.endInclusive - valueRange.start) * 0.025f
+                snapshotFlow { valueAnimation.value }
+                    .filter { abs(it - valueAnimation.targetValue) < threshold }
+                    .first()
+            }
+            launch { pressProgressAnimation.animateTo(0f, pressProgressAnimationSpec) }
+            launch { scaleXAnimation.animateTo(initialScale, scaleXAnimationSpec) }
+            launch { scaleYAnimation.animateTo(initialScale, scaleYAnimationSpec) }
+        }
+    }
+
+    fun updateValue(value: Float) {
+        val coercedTargetValue = value.coerceIn(valueRange)
+        animationScope.launch {
+            launch {
+                valueAnimation.animateTo(coercedTargetValue, valueAnimationSpec) {
+                    updateVelocity()
+                }
+            }
+        }
+    }
+
+    fun animateToValue(value: Float) {
+        animationScope.launch {
+            mutatorMutex.mutate {
+                press()
+                val coercedTargetValue = value.coerceIn(valueRange)
+                launch { valueAnimation.animateTo(coercedTargetValue, valueAnimationSpec) }
+                if (velocity != 0f) {
+                    launch { velocityAnimation.animateTo(0f, velocityAnimationSpec) }
+                }
+                release()
+            }
+        }
+    }
+
+    private fun updateVelocity() {
+        velocityTracker.addPosition(
+            System.currentTimeMillis(),
+            Offset(value, 0f)
+        )
+        val targetVelocity =
+            velocityTracker.calculateVelocity().x / (valueRange.endInclusive - valueRange.start)
+        animationScope.launch {
+            velocityAnimation.animateTo(targetVelocity, velocityAnimationSpec)
+        }
+    }
+}

+ 109 - 0
app/src/main/java/io/legado/app/ui/animation/InteractiveHighlight.kt

@@ -0,0 +1,109 @@
+package io.legado.app.ui.animation
+
+import android.annotation.SuppressLint
+import android.graphics.RuntimeShader
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.VectorConverter
+import androidx.compose.animation.core.VisibilityThreshold
+import androidx.compose.animation.core.spring
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ShaderBrush
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.util.fastCoerceIn
+import io.legado.app.ui.util.inspectDragGestures
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import org.intellij.lang.annotations.Language
+
+@SuppressLint("NewApi")
+class InteractiveHighlight(
+    val animationScope: CoroutineScope,
+    val position: (size: Size, offset: Offset) -> Offset = { _, offset -> offset }
+) {
+
+    private val pressProgressAnimationSpec = spring(0.5f, 300f, 0.001f)
+    private val positionAnimationSpec = spring(0.5f, 300f, Offset.VisibilityThreshold)
+
+    private val pressProgressAnimation = Animatable(0f, 0.001f)
+    private val positionAnimation =
+        Animatable(Offset.Zero, Offset.VectorConverter, Offset.VisibilityThreshold)
+
+    private var startPosition = Offset.Zero
+
+    @Language("AGSL")
+    private val shader = RuntimeShader(
+        """
+        uniform float2 size;
+        layout(color) uniform half4 color;
+        uniform float radius;
+        uniform float2 position;
+
+        half4 main(float2 coord) {
+            float dist = distance(coord, position);
+            float intensity = smoothstep(radius, radius * 0.5, dist);
+            return color * intensity;
+        }
+        """
+    )
+
+    val modifier: Modifier =
+        Modifier.drawWithContent {
+            val progress = pressProgressAnimation.value
+            if (progress > 0f) {
+                drawRect(
+                    Color.White.copy(0.06f * progress),
+                    blendMode = BlendMode.Plus
+                )
+                shader.apply {
+                    val currentPosition = position(size, positionAnimation.value)
+                    setFloatUniform("size", size.width, size.height)
+                    setColorUniform("color", Color.White.copy(0.12f * progress).toArgb())
+                    setFloatUniform("radius", size.minDimension * 1.2f)
+                    setFloatUniform(
+                        "position",
+                        currentPosition.x.fastCoerceIn(0f, size.width),
+                        currentPosition.y.fastCoerceIn(0f, size.height)
+                    )
+                }
+                drawRect(
+                    ShaderBrush(shader),
+                    blendMode = BlendMode.Plus
+                )
+            }
+
+            drawContent()
+        }
+
+    val gestureModifier: Modifier =
+        Modifier.pointerInput(animationScope) {
+            inspectDragGestures(
+                onDragStart = { down ->
+                    startPosition = down.position
+                    animationScope.launch {
+                        launch { pressProgressAnimation.animateTo(1f, pressProgressAnimationSpec) }
+                        launch { positionAnimation.snapTo(startPosition) }
+                    }
+                },
+                onDragEnd = {
+                    animationScope.launch {
+                        launch { pressProgressAnimation.animateTo(0f, pressProgressAnimationSpec) }
+                        launch { positionAnimation.animateTo(startPosition, positionAnimationSpec) }
+                    }
+                },
+                onDragCancel = {
+                    animationScope.launch {
+                        launch { pressProgressAnimation.animateTo(0f, pressProgressAnimationSpec) }
+                        launch { positionAnimation.animateTo(startPosition, positionAnimationSpec) }
+                    }
+                }
+            ) { change, _ ->
+                animationScope.launch { positionAnimation.snapTo(change.position) }
+            }
+        }
+}

+ 5 - 0
app/src/main/java/io/legado/app/ui/config/mainConfig/MainConfig.kt

@@ -7,6 +7,11 @@ object MainConfig {
     var showDiscovery by prefDelegate(PreferKey.showDiscovery, true)
     var showRSS by prefDelegate(PreferKey.showRss, true)
     var showBottomView by prefDelegate(PreferKey.showBottomView, true)
+    var useFloatingBottomBar by prefDelegate(PreferKey.useFloatingBottomBar, false)
+    var useFloatingBottomBarLiquidGlass by prefDelegate(
+        PreferKey.useFloatingBottomBarLiquidGlass,
+        false
+    )
     var defaultHomePage by prefDelegate(PreferKey.defaultHomePage, "bookshelf")
     var tabletInterface by prefDelegate(PreferKey.tabletInterface, "auto")
     var labelVisibilityMode by prefDelegate(PreferKey.labelVisibilityMode, "auto")

+ 7 - 0
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt

@@ -72,6 +72,13 @@ object ThemeConfig {
 
     var showBottomView by prefDelegate(PreferKey.showBottomView, true)
 
+    var useFloatingBottomBar by prefDelegate(PreferKey.useFloatingBottomBar, false)
+
+    var useFloatingBottomBarLiquidGlass by prefDelegate(
+        PreferKey.useFloatingBottomBarLiquidGlass,
+        false
+    )
+
     var tabletInterface by prefDelegate(PreferKey.tabletInterface, "auto")
 
     var labelVisibilityMode by prefDelegate(PreferKey.labelVisibilityMode, "auto")

+ 14 - 4
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt

@@ -35,7 +35,6 @@ import androidx.compose.material.icons.filled.BrightnessMedium
 import androidx.compose.material.icons.filled.Check
 import androidx.compose.material.icons.filled.DarkMode
 import androidx.compose.material.icons.filled.LightMode
-import androidx.compose.material3.AlertDialog
 import androidx.compose.material3.ButtonGroupDefaults
 import androidx.compose.material3.Card
 import androidx.compose.material3.CardDefaults
@@ -43,10 +42,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.Icon
 import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedButton
-import androidx.compose.material3.OutlinedTextField
 import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
 import androidx.compose.material3.ToggleButton
 import androidx.compose.material3.ToggleButtonDefaults
 import androidx.compose.runtime.Composable
@@ -375,6 +371,20 @@ fun ThemeConfigScreen(
                     checked = ThemeConfig.showBottomView,
                     onCheckedChange = { ThemeConfig.showBottomView = it }
                 )
+                SwitchSettingItem(
+                    title = stringResource(R.string.floating_bottom_bar),
+                    description = stringResource(R.string.floating_bottom_bar_summary),
+                    checked = ThemeConfig.useFloatingBottomBar,
+                    onCheckedChange = { ThemeConfig.useFloatingBottomBar = it }
+                )
+                AnimatedVisibility(visible = ThemeConfig.useFloatingBottomBar) {
+                    SwitchSettingItem(
+                        title = stringResource(R.string.floating_bottom_bar_liquid_glass),
+                        description = stringResource(R.string.floating_bottom_bar_liquid_glass_summary),
+                        checked = ThemeConfig.useFloatingBottomBarLiquidGlass,
+                        onCheckedChange = { ThemeConfig.useFloatingBottomBarLiquidGlass = it }
+                    )
+                }
                 DropdownListSettingItem(
                     title = stringResource(R.string.tabletInterface),
                     selectedValue = ThemeConfig.tabletInterface,

+ 6 - 41
app/src/main/java/io/legado/app/ui/main/MainDestination.kt

@@ -1,68 +1,33 @@
 package io.legado.app.ui.main
 
 import androidx.annotation.StringRes
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.LibraryBooks
-import androidx.compose.material.icons.automirrored.outlined.LibraryBooks
-import androidx.compose.material.icons.filled.Explore
-import androidx.compose.material.icons.filled.Person
-import androidx.compose.material.icons.filled.RssFeed
-import androidx.compose.material.icons.outlined.Explore
-import androidx.compose.material.icons.outlined.Person
-import androidx.compose.material.icons.outlined.RssFeed
-import androidx.compose.ui.graphics.vector.ImageVector
 import io.legado.app.R
-import top.yukonga.miuix.kmp.icon.MiuixIcons
-import top.yukonga.miuix.kmp.icon.extended.Album
-import top.yukonga.miuix.kmp.icon.extended.ContactsBook
-import top.yukonga.miuix.kmp.icon.extended.Favorites
-import top.yukonga.miuix.kmp.icon.extended.Settings
 
 sealed class MainDestination(
     val route: String,
-    @StringRes val labelId: Int,
-    val m3Icon: ImageVector,
-    val m3SelectedIcon: ImageVector,
-    val miuixIcon: ImageVector,
-    val miuixSelectedIcon: ImageVector
+    @StringRes val labelId: Int
 ) {
     object Bookshelf : MainDestination(
         route = "bookshelf",
-        labelId = R.string.bookshelf,
-        m3Icon = Icons.AutoMirrored.Outlined.LibraryBooks,
-        m3SelectedIcon = Icons.AutoMirrored.Filled.LibraryBooks,
-        miuixIcon = MiuixIcons.Regular.ContactsBook, // 替换为实际的 MIUIX 线框图标
-        miuixSelectedIcon = MiuixIcons.Heavy.ContactsBook // 替换为实际的 MIUIX 填充图标
+        labelId = R.string.bookshelf
     )
 
     object Explore : MainDestination(
         route = "explore",
-        labelId = R.string.discovery,
-        m3Icon = Icons.Outlined.Explore,
-        m3SelectedIcon = Icons.Default.Explore,
-        miuixIcon = MiuixIcons.Regular.Album,
-        miuixSelectedIcon = MiuixIcons.Heavy.Album
+        labelId = R.string.discovery
     )
 
     object Rss : MainDestination(
         route = "rss",
-        labelId = R.string.rss,
-        m3Icon = Icons.Outlined.RssFeed,
-        m3SelectedIcon = Icons.Default.RssFeed,
-        miuixIcon = MiuixIcons.Regular.Favorites,
-        miuixSelectedIcon = MiuixIcons.Heavy.Favorites
+        labelId = R.string.rss
     )
 
     object My : MainDestination(
         route = "my",
-        labelId = R.string.my,
-        m3Icon = Icons.Outlined.Person,
-        m3SelectedIcon = Icons.Default.Person,
-        miuixIcon = MiuixIcons.Regular.Settings,
-        miuixSelectedIcon = MiuixIcons.Heavy.Settings
+        labelId = R.string.my
     )
 
     companion object {
         val mainDestinations = listOf(Bookshelf, Explore, Rss, My)
     }
-}
+}

+ 134 - 39
app/src/main/java/io/legado/app/ui/main/MainScreen.kt

@@ -1,14 +1,21 @@
 package io.legado.app.ui.main
 
+import android.os.Build
 import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.clickable
 import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
 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.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.defaultMinSize
 import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBars
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.pager.HorizontalPager
@@ -39,15 +46,18 @@ import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.hapticfeedback.HapticFeedbackType
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalHapticFeedback
 import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.kyant.backdrop.backdrops.layerBackdrop
+import com.kyant.backdrop.backdrops.rememberLayerBackdrop
 import dev.chrisbanes.haze.HazeState
-import dev.chrisbanes.haze.hazeSource
 import io.legado.app.R
 import io.legado.app.ui.book.info.BookInfoActivity
 import io.legado.app.ui.book.search.SearchActivity
@@ -57,11 +67,17 @@ import io.legado.app.ui.main.bookshelf.BookshelfViewModel
 import io.legado.app.ui.main.explore.ExploreScreen
 import io.legado.app.ui.main.my.MyScreen
 import io.legado.app.ui.main.rss.RssScreen
+import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.regularHazeEffect
+import io.legado.app.ui.theme.responsiveHazeSource
 import io.legado.app.ui.widget.components.AppNavigationBar
 import io.legado.app.ui.widget.components.AppNavigationBarItem
 import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.FloatingBottomBar
+import io.legado.app.ui.widget.components.FloatingBottomBarItem
 import io.legado.app.ui.widget.components.GlassDefaults
+import io.legado.app.ui.widget.components.icon.AppIcon
+import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.text.AppText
@@ -88,6 +104,11 @@ fun MainScreen(
     val bookshelfUiState by bookshelfViewModel.uiState.collectAsState()
 
     val hazeState = remember { HazeState() }
+    val floatingBarSurfaceColor = MaterialTheme.colorScheme.surface
+    val floatingBarBackdrop = rememberLayerBackdrop {
+        drawRect(floatingBarSurfaceColor)
+        drawContent()
+    }
     val destinations = remember(MainConfig.showDiscovery, MainConfig.showRSS) {
         MainDestination.mainDestinations.filter {
             when (it) {
@@ -103,6 +124,15 @@ fun MainScreen(
         if (index != -1) index else 0
     }
     val pagerState = rememberPagerState(initialPage = initialPage) { destinations.size }
+    val labelVisibilityMode = MainConfig.labelVisibilityMode
+    val isUnlabeled = labelVisibilityMode == "unlabeled"
+    val useFloatingBottomBar =
+        !useRail && MainConfig.showBottomView && MainConfig.useFloatingBottomBar
+    val useLiquidGlass = useFloatingBottomBar &&
+            MainConfig.useFloatingBottomBarLiquidGlass &&
+            Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
+    val alwaysShowLabel = labelVisibilityMode == "labeled"
+    val showLabel = !isUnlabeled
 
     val navState = rememberWideNavigationRailState(
         initialValue = if (MainConfig.navExtended)
@@ -230,51 +260,116 @@ fun MainScreen(
         }
 
         AppScaffold(
-            modifier = Modifier.weight(1f),
+            modifier = Modifier
+                .weight(1f)
+                .responsiveHazeSource(hazeState),
             bottomBar = {
                 if (!useRail && MainConfig.showBottomView) {
-                    val labelVisibilityMode = MainConfig.labelVisibilityMode
-                    val isUnlabeled = labelVisibilityMode == "unlabeled"
-
-                    AppNavigationBar(
-                        modifier = Modifier
-                            .regularHazeEffect(state = hazeState)
-                            .height(if (isUnlabeled) 64.dp else 80.dp)
-                    ) {
-                        val alwaysShowLabel = when (labelVisibilityMode) {
-                            "labeled" -> true
-                            else -> false
-                        }
-                        destinations.forEachIndexed { index, destination ->
-                            val selected = pagerState.targetPage == index
-                            AppNavigationBarItem(
-                                selected = selected,
-                                onClick = {
-                                    coroutineScope.launch { pagerState.animateScrollToPage(index) }
-                                },
-                                labelString = stringResource(destination.labelId),
-                                miuixIcon = if (selected) destination.miuixSelectedIcon else destination.miuixIcon,
-                                m3Icon = {
-                                    NavigationIcon(
-                                        destination = destination,
-                                        selected = selected,
-                                        upBooksCount = uiState.upBooksCount
+                    if (useFloatingBottomBar) {
+                        Box(modifier = Modifier.fillMaxWidth()) {
+                            FloatingBottomBar(
+                                modifier = Modifier
+                                    .align(Alignment.BottomCenter)
+                                    .clickable(
+                                        interactionSource = remember { MutableInteractionSource() },
+                                        indication = null,
+                                        onClick = {}
                                     )
+                                    .padding(
+                                        start = 16.dp,
+                                        end = 16.dp,
+                                        bottom = 12.dp + WindowInsets.navigationBars
+                                            .asPaddingValues()
+                                            .calculateBottomPadding()
+                                    ),
+                                selectedIndex = { pagerState.currentPage },
+                                onSelected = { index ->
+                                    coroutineScope.launch {
+                                        pagerState.animateScrollToPage(index)
+                                    }
                                 },
-                                m3IndicatorColor = GlassDefaults.glassColor(
-                                    noBlurColor = MaterialTheme.colorScheme.secondaryContainer,
-                                    blurAlpha = GlassDefaults.ThickBlurAlpha
-                                ),
-                                m3ShowLabel = !isUnlabeled,
-                                m3AlwaysShowLabel = alwaysShowLabel
-                            )
+                                backdrop = floatingBarBackdrop,
+                                tabsCount = destinations.size,
+                                isBlurEnabled = useLiquidGlass
+                            ) {
+                                destinations.forEachIndexed { index, destination ->
+                                    val selected = pagerState.currentPage == index
+                                    FloatingBottomBarItem(
+                                        onClick = {
+                                            coroutineScope.launch {
+                                                pagerState.animateScrollToPage(index)
+                                            }
+                                        },
+                                        modifier = Modifier.defaultMinSize(minWidth = 76.dp)
+                                    ) {
+                                        NavigationIcon(
+                                            destination = destination,
+                                            selected = selected,
+                                            upBooksCount = uiState.upBooksCount
+                                        )
+                                        if (showLabel && (alwaysShowLabel || selected)) {
+                                            Text(
+                                                text = stringResource(destination.labelId),
+                                                style = MaterialTheme.typography.labelSmall,
+                                                color = if (selected) {
+                                                    LegadoTheme.colorScheme.primary
+                                                } else {
+                                                    LegadoTheme.colorScheme.onSurface
+                                                },
+                                                maxLines = 1,
+                                                overflow = TextOverflow.Ellipsis
+                                            )
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    } else {
+                        AppNavigationBar(
+                            modifier = Modifier
+                                .regularHazeEffect(state = hazeState)
+                                .height(if (isUnlabeled) 64.dp else 80.dp)
+                        ) {
+                            destinations.forEachIndexed { index, destination ->
+                                val selected = pagerState.targetPage == index
+                                AppNavigationBarItem(
+                                    selected = selected,
+                                    onClick = {
+                                        coroutineScope.launch { pagerState.animateScrollToPage(index) }
+                                    },
+                                    labelString = stringResource(destination.labelId),
+                                    iconVector = AppIcons.mainDestination(destination, selected),
+                                    m3Icon = {
+                                        NavigationIcon(
+                                            destination = destination,
+                                            selected = selected,
+                                            upBooksCount = uiState.upBooksCount
+                                        )
+                                    },
+                                    m3IndicatorColor = GlassDefaults.glassColor(
+                                        noBlurColor = MaterialTheme.colorScheme.secondaryContainer,
+                                        blurAlpha = GlassDefaults.ThickBlurAlpha
+                                    ),
+                                    m3ShowLabel = showLabel,
+                                    m3AlwaysShowLabel = alwaysShowLabel
+                                )
+                            }
                         }
                     }
                 }
             },
             contentWindowInsets = WindowInsets(0)
         ) { _ ->
-            Box(modifier = Modifier.hazeSource(hazeState)) {
+            Box(
+                modifier = Modifier
+                    .then(
+                        if (useFloatingBottomBar) {
+                            Modifier.layerBackdrop(floatingBarBackdrop)
+                        } else {
+                            Modifier
+                        }
+                    )
+            ) {
                 HorizontalPager(
                     state = pagerState,
                     modifier = Modifier.fillMaxSize(),
@@ -319,14 +414,14 @@ private fun NavigationIcon(
     upBooksCount: Int,
     modifier: Modifier = Modifier
 ) {
-    val icon = if (selected) destination.m3SelectedIcon else destination.m3Icon
+    val icon = AppIcons.mainDestination(destination, selected)
     Box(modifier = modifier) {
         if (destination == MainDestination.Bookshelf && upBooksCount > 0) {
             BadgedBox(badge = { Badge { Text(upBooksCount.toString()) } }) {
-                Icon(icon, contentDescription = null)
+                AppIcon(icon, contentDescription = null)
             }
         } else {
-            Icon(icon, contentDescription = null)
+            AppIcon(icon, contentDescription = null)
         }
     }
 }

+ 82 - 0
app/src/main/java/io/legado/app/ui/util/DragGestureInspector.kt

@@ -0,0 +1,82 @@
+package io.legado.app.ui.util
+
+import androidx.compose.foundation.gestures.awaitEachGesture
+import androidx.compose.foundation.gestures.awaitFirstDown
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.pointer.AwaitPointerEventScope
+import androidx.compose.ui.input.pointer.PointerEventPass
+import androidx.compose.ui.input.pointer.PointerId
+import androidx.compose.ui.input.pointer.PointerInputChange
+import androidx.compose.ui.input.pointer.PointerInputScope
+import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
+import androidx.compose.ui.input.pointer.positionChange
+import androidx.compose.ui.util.fastFirstOrNull
+
+suspend fun PointerInputScope.inspectDragGestures(
+    onDragStart: (down: PointerInputChange) -> Unit = {},
+    onDragEnd: (change: PointerInputChange) -> Unit = {},
+    onDragCancel: () -> Unit = {},
+    onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
+) {
+    awaitEachGesture {
+        val initialDown = awaitFirstDown(false, PointerEventPass.Initial)
+        val down = awaitFirstDown(false)
+
+        onDragStart(down)
+        onDrag(initialDown, Offset.Zero)
+        val upEvent = drag(
+            pointerId = initialDown.id,
+            onDrag = { onDrag(it, it.positionChange()) }
+        )
+        if (upEvent == null) {
+            onDragCancel()
+        } else {
+            onDragEnd(upEvent)
+        }
+    }
+}
+
+private suspend inline fun AwaitPointerEventScope.drag(
+    pointerId: PointerId,
+    onDrag: (PointerInputChange) -> Unit
+): PointerInputChange? {
+    val isPointerUp = currentEvent.changes.fastFirstOrNull { it.id == pointerId }?.pressed != true
+    if (isPointerUp) {
+        return null
+    }
+    var pointer = pointerId
+    while (true) {
+        val change = awaitDragOrUp(pointer) ?: return null
+        if (change.isConsumed) {
+            return null
+        }
+        if (change.changedToUpIgnoreConsumed()) {
+            return change
+        }
+        onDrag(change)
+        pointer = change.id
+    }
+}
+
+private suspend inline fun AwaitPointerEventScope.awaitDragOrUp(
+    pointerId: PointerId
+): PointerInputChange? {
+    var pointer = pointerId
+    while (true) {
+        val event = awaitPointerEvent()
+        val dragEvent = event.changes.fastFirstOrNull { it.id == pointer } ?: return null
+        if (dragEvent.changedToUpIgnoreConsumed()) {
+            val otherDown = event.changes.fastFirstOrNull { it.pressed }
+            if (otherDown == null) {
+                return dragEvent
+            } else {
+                pointer = otherDown.id
+            }
+        } else {
+            val hasDragged = dragEvent.previousPosition != dragEvent.position
+            if (hasDragged) {
+                return dragEvent
+            }
+        }
+    }
+}

+ 3 - 3
app/src/main/java/io/legado/app/ui/widget/components/AppNavigationBar.kt

@@ -61,7 +61,7 @@ fun RowScope.AppNavigationBarItem(
     onClick: () -> Unit,
     modifier: Modifier = Modifier,
     labelString: String,
-    miuixIcon: ImageVector,
+    iconVector: ImageVector,
     m3Icon: @Composable () -> Unit,
     m3IndicatorColor: Color,
     m3ShowLabel: Boolean,
@@ -73,7 +73,7 @@ fun RowScope.AppNavigationBarItem(
         MiuixNavigationBarItem(
             selected = selected,
             onClick = onClick,
-            icon = miuixIcon,
+            icon = iconVector,
             label = labelString,
             modifier = modifier
         )
@@ -90,4 +90,4 @@ fun RowScope.AppNavigationBarItem(
             alwaysShowLabel = m3AlwaysShowLabel
         )
     }
-}
+}

+ 419 - 0
app/src/main/java/io/legado/app/ui/widget/components/FloatingBottomBar.kt

@@ -0,0 +1,419 @@
+// Portions of this file are derived from weishu/KernelSU
+// (https://github.com/tiann/KernelSU)
+// Copyright (C) KernelSU contributors
+// Licensed under GPL-3.0
+package io.legado.app.ui.widget.components
+
+import android.os.Build
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.EaseOut
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+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
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.util.fastCoerceIn
+import androidx.compose.ui.util.fastRoundToInt
+import androidx.compose.ui.util.lerp
+import com.kyant.backdrop.Backdrop
+import com.kyant.backdrop.backdrops.layerBackdrop
+import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
+import com.kyant.backdrop.backdrops.rememberLayerBackdrop
+import com.kyant.backdrop.drawBackdrop
+import com.kyant.backdrop.effects.blur
+import com.kyant.backdrop.effects.lens
+import com.kyant.backdrop.effects.vibrancy
+import com.kyant.backdrop.highlight.Highlight
+import com.kyant.backdrop.shadow.InnerShadow
+import com.kyant.backdrop.shadow.Shadow
+import com.kyant.capsule.ContinuousCapsule
+import io.legado.app.ui.animation.DampedDragAnimation
+import io.legado.app.ui.animation.InteractiveHighlight
+import io.legado.app.ui.theme.LegadoTheme
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.launch
+import kotlin.math.abs
+import kotlin.math.sign
+
+val LocalFloatingBottomBarTabScale = staticCompositionLocalOf { { 1f } }
+
+@Composable
+fun RowScope.FloatingBottomBarItem(
+    onClick: () -> Unit,
+    modifier: Modifier = Modifier,
+    content: @Composable ColumnScope.() -> Unit
+) {
+    val scale = LocalFloatingBottomBarTabScale.current
+    Column(
+        modifier
+            .clip(ContinuousCapsule)
+            .clickable(
+                interactionSource = null,
+                indication = null,
+                role = Role.Tab,
+                onClick = onClick
+            )
+            .fillMaxHeight()
+            .weight(1f)
+            .graphicsLayer {
+                val currentScale = scale()
+                scaleX = currentScale
+                scaleY = currentScale
+            },
+        verticalArrangement = Arrangement.spacedBy(1.dp, Alignment.CenterVertically),
+        horizontalAlignment = Alignment.CenterHorizontally,
+        content = content
+    )
+}
+
+@Composable
+fun FloatingBottomBar(
+    modifier: Modifier = Modifier,
+    selectedIndex: () -> Int,
+    onSelected: (index: Int) -> Unit,
+    backdrop: Backdrop,
+    tabsCount: Int,
+    isBlurEnabled: Boolean = true,
+    content: @Composable RowScope.() -> Unit
+) {
+    val isInLightTheme = !LegadoTheme.isDark
+    val accentColor = LegadoTheme.colorScheme.primary
+    val containerColor = if (isBlurEnabled) {
+        LegadoTheme.colorScheme.surfaceContainer.copy(alpha = 0.4f)
+    } else {
+        LegadoTheme.colorScheme.surfaceContainer
+    }
+
+    val tabsBackdrop = rememberLayerBackdrop()
+    val density = LocalDensity.current
+    val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
+    val animationScope = rememberCoroutineScope()
+
+    var tabWidthPx by remember { mutableFloatStateOf(0f) }
+    var totalWidthPx by remember { mutableFloatStateOf(0f) }
+
+    val offsetAnimation = remember { Animatable(0f) }
+    val panelOffset by remember(density) {
+        derivedStateOf {
+            if (totalWidthPx == 0f) {
+                0f
+            } else {
+                val fraction = (offsetAnimation.value / totalWidthPx).fastCoerceIn(-1f, 1f)
+                with(density) {
+                    4f.dp.toPx() * fraction.sign * EaseOut.transform(abs(fraction))
+                }
+            }
+        }
+    }
+
+    var currentIndex by remember(selectedIndex) { mutableIntStateOf(selectedIndex()) }
+
+    class DampedDragAnimationHolder {
+        var instance: DampedDragAnimation? = null
+    }
+
+    val holder = remember { DampedDragAnimationHolder() }
+
+    val dampedDragAnimation = remember(animationScope, tabsCount, density, isLtr) {
+        DampedDragAnimation(
+            animationScope = animationScope,
+            initialValue = selectedIndex().toFloat(),
+            valueRange = 0f..(tabsCount - 1).toFloat(),
+            visibilityThreshold = 0.001f,
+            initialScale = 1f,
+            pressedScale = 78f / 56f,
+            canDrag = { offset ->
+                val anim = holder.instance ?: return@DampedDragAnimation true
+                if (tabWidthPx == 0f) return@DampedDragAnimation false
+
+                val currentValue = anim.value
+                val indicatorX = currentValue * tabWidthPx
+                val padding = with(density) { 4.dp.toPx() }
+                val globalTouchX = if (isLtr) {
+                    val touchX = indicatorX + offset.x
+                    padding + touchX
+                } else {
+                    totalWidthPx - padding - tabWidthPx - indicatorX + offset.x
+                }
+                globalTouchX in 0f..totalWidthPx
+            },
+            onDragStarted = {},
+            onDragStopped = {
+                val targetIndex = targetValue.fastRoundToInt().fastCoerceIn(0, tabsCount - 1)
+                currentIndex = targetIndex
+                animateToValue(targetIndex.toFloat())
+                animationScope.launch {
+                    offsetAnimation.animateTo(0f, spring(1f, 300f, 0.5f))
+                }
+            },
+            onDrag = { _, dragAmount ->
+                if (tabWidthPx > 0f) {
+                    updateValue(
+                        (targetValue + dragAmount.x / tabWidthPx * if (isLtr) 1f else -1f)
+                            .fastCoerceIn(0f, (tabsCount - 1).toFloat())
+                    )
+                    animationScope.launch {
+                        offsetAnimation.snapTo(offsetAnimation.value + dragAmount.x)
+                    }
+                }
+            }
+        ).also { holder.instance = it }
+    }
+
+    LaunchedEffect(selectedIndex) {
+        snapshotFlow { selectedIndex() }.collectLatest { currentIndex = it }
+    }
+    LaunchedEffect(dampedDragAnimation) {
+        snapshotFlow { currentIndex }.drop(1).collectLatest { index ->
+            dampedDragAnimation.animateToValue(index.toFloat())
+            onSelected(index)
+        }
+    }
+
+    val interactiveHighlight =
+        if (isBlurEnabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+            remember(animationScope, tabWidthPx) {
+                InteractiveHighlight(
+                    animationScope = animationScope,
+                    position = { size, _ ->
+                        Offset(
+                            if (isLtr) {
+                                (dampedDragAnimation.value + 0.5f) * tabWidthPx + panelOffset
+                            } else {
+                                size.width - (dampedDragAnimation.value + 0.5f) * tabWidthPx + panelOffset
+                            },
+                            size.height / 2f
+                        )
+                    }
+                )
+            }
+        } else {
+            null
+        }
+
+    Box(
+        modifier = modifier.width(IntrinsicSize.Min),
+        contentAlignment = Alignment.CenterStart
+    ) {
+        Row(
+            Modifier
+                .onGloballyPositioned { coords ->
+                    totalWidthPx = coords.size.width.toFloat()
+                    val contentWidthPx = totalWidthPx - with(density) { 8.dp.toPx() }
+                    tabWidthPx = contentWidthPx / tabsCount
+                }
+                .graphicsLayer { translationX = panelOffset }
+                .clickable(
+                    interactionSource = remember { MutableInteractionSource() },
+                    indication = null,
+                    onClick = {}
+                )
+                .drawBackdrop(
+                    backdrop = backdrop,
+                    shape = { ContinuousCapsule },
+                    effects = {
+                        if (isBlurEnabled) {
+                            vibrancy()
+                            blur(8f.dp.toPx())
+                            lens(24f.dp.toPx(), 24f.dp.toPx())
+                        }
+                    },
+                    highlight = {
+                        Highlight.Default.copy(alpha = if (isBlurEnabled) 1f else 0f)
+                    },
+                    shadow = {
+                        Shadow.Default.copy(
+                            color = Color.Black.copy(if (isInLightTheme) 0.1f else 0.2f)
+                        )
+                    },
+                    layerBlock = {
+                        if (isBlurEnabled) {
+                            val progress = dampedDragAnimation.pressProgress
+                            val scale = lerp(1f, 1f + 16f.dp.toPx() / size.width, progress)
+                            scaleX = scale
+                            scaleY = scale
+                        }
+                    },
+                    onDrawSurface = { drawRect(containerColor) }
+                )
+                .then(
+                    if (isBlurEnabled && interactiveHighlight != null) {
+                        interactiveHighlight.modifier
+                    } else {
+                        Modifier
+                    }
+                )
+                .height(64.dp)
+                .padding(4.dp),
+            verticalAlignment = Alignment.CenterVertically,
+            content = content
+        )
+
+        CompositionLocalProvider(
+            LocalFloatingBottomBarTabScale provides {
+                if (isBlurEnabled) {
+                    lerp(1f, 1.2f, dampedDragAnimation.pressProgress)
+                } else {
+                    1f
+                }
+            }
+        ) {
+            Row(
+                Modifier
+                    .clearAndSetSemantics {}
+                    .alpha(0f)
+                    .layerBackdrop(tabsBackdrop)
+                    .graphicsLayer { translationX = panelOffset }
+                    .drawBackdrop(
+                        backdrop = backdrop,
+                        shape = { ContinuousCapsule },
+                        effects = {
+                            if (isBlurEnabled) {
+                                val progress = dampedDragAnimation.pressProgress
+                                vibrancy()
+                                blur(8f.dp.toPx())
+                                lens(24f.dp.toPx() * progress, 24f.dp.toPx() * progress)
+                            }
+                        },
+                        highlight = {
+                            Highlight.Default.copy(
+                                alpha = if (isBlurEnabled) {
+                                    dampedDragAnimation.pressProgress
+                                } else {
+                                    0f
+                                }
+                            )
+                        },
+                        onDrawSurface = { drawRect(containerColor) }
+                    )
+                    .then(
+                        if (isBlurEnabled && interactiveHighlight != null) {
+                            interactiveHighlight.modifier
+                        } else {
+                            Modifier
+                        }
+                    )
+                    .height(56.dp)
+                    .padding(horizontal = 4.dp)
+                    .graphicsLayer(colorFilter = ColorFilter.tint(accentColor)),
+                verticalAlignment = Alignment.CenterVertically,
+                content = content
+            )
+        }
+
+        if (tabWidthPx > 0f) {
+            Box(
+                Modifier
+                    .padding(horizontal = 4.dp)
+                    .graphicsLayer {
+                        val contentWidth = totalWidthPx - with(density) { 8.dp.toPx() }
+                        val singleTabWidth = contentWidth / tabsCount
+                        val progressOffset = dampedDragAnimation.value * singleTabWidth
+
+                        translationX = if (isLtr) {
+                            progressOffset + panelOffset
+                        } else {
+                            -progressOffset + panelOffset
+                        }
+                    }
+                    .then(
+                        if (isBlurEnabled && interactiveHighlight != null) {
+                            interactiveHighlight.gestureModifier
+                        } else {
+                            Modifier
+                        }
+                    )
+                    .then(dampedDragAnimation.modifier)
+                    .drawBackdrop(
+                        backdrop = rememberCombinedBackdrop(backdrop, tabsBackdrop),
+                        shape = { ContinuousCapsule },
+                        effects = {
+                            if (isBlurEnabled) {
+                                val progress = dampedDragAnimation.pressProgress
+                                lens(10f.dp.toPx() * progress, 14f.dp.toPx() * progress, true)
+                            }
+                        },
+                        highlight = {
+                            Highlight.Default.copy(
+                                alpha = if (isBlurEnabled) {
+                                    dampedDragAnimation.pressProgress
+                                } else {
+                                    0f
+                                }
+                            )
+                        },
+                        shadow = {
+                            Shadow(alpha = if (isBlurEnabled) dampedDragAnimation.pressProgress else 0f)
+                        },
+                        innerShadow = {
+                            InnerShadow(
+                                radius = 8f.dp * dampedDragAnimation.pressProgress,
+                                alpha = if (isBlurEnabled) dampedDragAnimation.pressProgress else 0f
+                            )
+                        },
+                        layerBlock = {
+                            if (isBlurEnabled) {
+                                scaleX = dampedDragAnimation.scaleX
+                                scaleY = dampedDragAnimation.scaleY
+                                val velocity = dampedDragAnimation.velocity / 10f
+                                scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.2f, 0.2f)
+                                scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.2f, 0.2f)
+                            }
+                        },
+                        onDrawSurface = {
+                            val progress =
+                                if (isBlurEnabled) dampedDragAnimation.pressProgress else 0f
+                            drawRect(
+                                color = if (isInLightTheme) {
+                                    Color.Black.copy(0.1f)
+                                } else {
+                                    Color.White.copy(0.1f)
+                                },
+                                alpha = 1f - progress
+                            )
+                            drawRect(Color.Black.copy(alpha = 0.03f * progress))
+                        }
+                    )
+                    .height(56.dp)
+                    .width(with(density) { ((totalWidthPx - 8.dp.toPx()) / tabsCount).toDp() })
+            )
+        }
+    }
+}

+ 46 - 19
app/src/main/java/io/legado/app/ui/widget/components/icon/AppIcons.kt

@@ -2,22 +2,32 @@ package io.legado.app.ui.widget.components.icon
 
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.LibraryBooks
+import androidx.compose.material.icons.automirrored.outlined.LibraryBooks
 import androidx.compose.material.icons.filled.Clear
+import androidx.compose.material.icons.filled.Explore
+import androidx.compose.material.icons.filled.Person
 import androidx.compose.material.icons.filled.Replay
+import androidx.compose.material.icons.filled.RssFeed
 import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.outlined.Explore
+import androidx.compose.material.icons.outlined.Person
+import androidx.compose.material.icons.outlined.RssFeed
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.graphics.vector.ImageVector
+import io.legado.app.ui.main.MainDestination
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.ThemeResolver
 import top.yukonga.miuix.kmp.icon.MiuixIcons
 import top.yukonga.miuix.kmp.icon.basic.Search
+import top.yukonga.miuix.kmp.icon.extended.Album
 import top.yukonga.miuix.kmp.icon.extended.Back
 import top.yukonga.miuix.kmp.icon.extended.Close
+import top.yukonga.miuix.kmp.icon.extended.ContactsBook
+import top.yukonga.miuix.kmp.icon.extended.Favorites
 import top.yukonga.miuix.kmp.icon.extended.Refresh
+import top.yukonga.miuix.kmp.icon.extended.Settings
 
-/**
- * 应用全局图标映射层
- */
 object AppIcons {
 
     private val isMiuix: Boolean
@@ -26,29 +36,46 @@ object AppIcons {
 
     val Search: ImageVector
         @Composable
-        get() = if (isMiuix) {
-            MiuixIcons.Basic.Search
-        } else {
-            Icons.Default.Search
-        }
+        get() = if (isMiuix) MiuixIcons.Basic.Search else Icons.Default.Search
 
     val Close: ImageVector
         @Composable
-        get() = if (isMiuix) {
-            MiuixIcons.Close
-        } else {
-            Icons.Default.Clear
-        }
+        get() = if (isMiuix) MiuixIcons.Close else Icons.Default.Clear
 
     val Back: ImageVector
         @Composable
-        get() = if (isMiuix) {
-            MiuixIcons.Back
-        } else {
-            Icons.AutoMirrored.Filled.ArrowBack
-        }
+        get() = if (isMiuix) MiuixIcons.Back else Icons.AutoMirrored.Filled.ArrowBack
 
     val Replay: ImageVector
         @Composable
         get() = if (isMiuix) MiuixIcons.Refresh ?: Icons.Default.Replay else Icons.Default.Replay
-}
+
+    @Composable
+    fun mainDestination(destination: MainDestination, selected: Boolean): ImageVector {
+        return when (destination) {
+            MainDestination.Bookshelf -> if (isMiuix) {
+                if (selected) MiuixIcons.Heavy.ContactsBook else MiuixIcons.Regular.ContactsBook
+            } else {
+                if (selected) Icons.AutoMirrored.Filled.LibraryBooks else Icons.AutoMirrored.Outlined.LibraryBooks
+            }
+
+            MainDestination.Explore -> if (isMiuix) {
+                if (selected) MiuixIcons.Heavy.Album else MiuixIcons.Regular.Album
+            } else {
+                if (selected) Icons.Default.Explore else Icons.Outlined.Explore
+            }
+
+            MainDestination.Rss -> if (isMiuix) {
+                if (selected) MiuixIcons.Heavy.Favorites else MiuixIcons.Regular.Favorites
+            } else {
+                if (selected) Icons.Default.RssFeed else Icons.Outlined.RssFeed
+            }
+
+            MainDestination.My -> if (isMiuix) {
+                if (selected) MiuixIcons.Heavy.Settings else MiuixIcons.Regular.Settings
+            } else {
+                if (selected) Icons.Default.Person else Icons.Outlined.Person
+            }
+        }
+    }
+}

+ 7 - 0
app/src/main/res/values-zh-rCN/strings.xml

@@ -325,6 +325,7 @@
     <string name="jf_convert_j">繁转简</string>
     <string name="page_mode">翻页模式</string>
     <string name="nb_file_sub_count">%1$d 项</string>
+    <string name="book_count">%1$d 本</string>
     <string name="nb_file_path">存储卡:</string>
     <string name="nb_file_add_shelf">加入书架</string>
     <string name="nb_file_add_shelves">加入书架(%1$d)</string>
@@ -577,6 +578,7 @@
     <string name="skip_next">下一个</string>
     <string name="music">音乐</string>
     <string name="audio">音频</string>
+    <string name="web_file">网页文件</string>
     <string name="is_enable">启用</string>
     <string name="enable_js">启用 JavaScript</string>
     <string name="load_with_base_url">加载 BaseUrl</string>
@@ -1293,6 +1295,10 @@
     <string name="custom_mode">自定义模式</string>
     <string name="be_swiped">仍可滑动切换页面</string>
     <string name="show_bottom_nav">显示导航栏</string>
+    <string name="floating_bottom_bar">悬浮底栏</string>
+    <string name="floating_bottom_bar_summary">底部导航栏使用悬浮胶囊样式</string>
+    <string name="floating_bottom_bar_liquid_glass">液态玻璃效果</string>
+    <string name="floating_bottom_bar_liquid_glass_summary">为悬浮底栏启用液态玻璃高光效果,需要 Android 13 及以上系统</string>
     <string name="nav_label_mode">导航栏标签显示方式</string>
     <string name="read">阅读</string>
     <string name="privacy">隐私</string>
@@ -1372,6 +1378,7 @@
     <string name="read_change_all">换源按钮默认弹出整书换源</string>
     <string name="read_change_all_s">反之则默认弹出单章换源</string>
     <string name="show_tip">显示书籍类型</string>
+    <string name="show_book_count">显示分组书籍数量</string>
     <string name="is_reading">在读</string>
     <string name="is_unread">未读</string>
     <string name="is_read_finished">已读</string>

+ 3 - 0
app/src/main/res/values-zh-rHK/strings.xml

@@ -302,6 +302,7 @@
     <string name="jf_convert_j">繁轉簡</string>
     <string name="page_mode">翻頁模式</string>
     <string name="nb_file_sub_count">%1$d 項</string>
+    <string name="book_count">%1$d 本</string>
     <string name="nb_file_path">存儲咭:</string>
     <string name="nb_file_add_shelf">加入書架</string>
     <string name="nb_file_add_shelves">加入書架 (%1$d)</string>
@@ -1220,4 +1221,6 @@
     <string name="top_bar_opacity_summary">%d</string>
     <string name="bottom_bar_opacity">底欄不透明度</string>
     <string name="bottom_bar_opacity_summary">%d</string>
+    <string name="web_file">網頁文件</string>
+    <string name="show_book_count">顯示分組書籍數量</string>
 </resources>

+ 3 - 0
app/src/main/res/values-zh-rTW/strings.xml

@@ -304,6 +304,7 @@
     <string name="jf_convert_j">繁轉簡</string>
     <string name="page_mode">翻頁模式</string>
     <string name="nb_file_sub_count">%1$d 項</string>
+    <string name="book_count">%1$d 本</string>
     <string name="nb_file_path">記憶卡:</string>
     <string name="nb_file_add_shelf">加入書架</string>
     <string name="nb_file_add_shelves">加入書架(%1$d)</string>
@@ -1222,4 +1223,6 @@
     <string name="top_bar_opacity_summary">%d</string>
     <string name="bottom_bar_opacity">底欄不透明度</string>
     <string name="bottom_bar_opacity_summary">%d</string>
+    <string name="web_file">網頁文件</string>
+    <string name="show_book_count">顯示分組書籍數量</string>
 </resources>

+ 7 - 0
app/src/main/res/values/strings.xml

@@ -328,6 +328,7 @@
     <string name="jf_convert_j">Traditional to simplified</string>
     <string name="page_mode">Flipping mode</string>
     <string name="nb_file_sub_count">%1$d items</string>
+    <string name="book_count">%1$d books</string>
     <string name="nb_file_path">Storage:</string>
     <string name="nb_file_add_shelf">Add to Bookshelf</string>
     <string name="nb_file_add_shelves">Add to Bookshelf(%1$d)</string>
@@ -580,6 +581,7 @@
     <string name="skip_next">Next</string>
     <string name="music">Music</string>
     <string name="audio">Audio</string>
+    <string name="web_file">Web file</string>
     <string name="is_enable">Enable</string>
     <string name="enable_js">Enable js</string>
     <string name="load_with_base_url">Load BaseUrl</string>
@@ -1296,6 +1298,10 @@
     <string name="custom_mode">Custom Mode</string>
     <string name="be_swiped">Pages can still be swiped</string>
     <string name="show_bottom_nav">Show Navigation Bar</string>
+    <string name="floating_bottom_bar">Floating Bottom Bar</string>
+    <string name="floating_bottom_bar_summary">Use a detached capsule style for the bottom navigation bar</string>
+    <string name="floating_bottom_bar_liquid_glass">Liquid Glass Effect</string>
+    <string name="floating_bottom_bar_liquid_glass_summary">Enable the liquid glass highlight for the floating bar. Requires blur and Android 13+</string>
     <string name="nav_label_mode">Navigation Bar Label Mode</string>
     <string name="read">Read</string>
     <string name="privacy">Privacy</string>
@@ -1376,6 +1382,7 @@
     <string name="read_change_all">换源按钮默认弹出整书换源</string>
     <string name="read_change_all_s">反之则默认弹出单章换源</string>
     <string name="show_tip">显示书籍类型</string>
+    <string name="show_book_count">显示分组书籍数量</string>
     <string name="is_reading">在读</string>
     <string name="is_unread">未读</string>
     <string name="is_read_finished">已读</string>