HapeLee il y a 5 mois
Parent
commit
8539cf818a
21 fichiers modifiés avec 797 ajouts et 395 suppressions
  1. 2 1
      .claude/settings.local.json
  2. 5 0
      app/src/main/java/io/legado/app/data/repository/BookRepository.kt
  3. 17 2
      app/src/main/java/io/legado/app/domain/usecase/readRecord/GetReadRecordOverviewUseCase.kt
  4. 1 0
      app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt
  5. 95 85
      app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt
  6. 57 16
      app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt
  7. 144 75
      app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt
  8. 9 2
      app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditViewModel.kt
  9. 16 21
      app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt
  10. 130 61
      app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewScreen.kt
  11. 4 3
      app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewViewModel.kt
  12. 4 58
      app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt
  13. 55 14
      app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordCharts.kt
  14. 47 10
      app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordStats.kt
  15. 11 0
      app/src/main/java/io/legado/app/ui/main/MainNavigator.kt
  16. 57 13
      app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt
  17. 3 7
      app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubScreen.kt
  18. 81 0
      app/src/main/java/io/legado/app/ui/theme/FadingEdge.kt
  19. 2 1
      app/src/main/java/io/legado/app/ui/widget/components/button/MediumIconButton.kt
  20. 56 25
      app/src/main/java/io/legado/app/ui/widget/components/tabRow/AppTabRow.kt
  21. 1 1
      app/src/main/java/io/legado/app/utils/ConvertExtensions.kt

+ 2 - 1
.claude/settings.local.json

@@ -82,7 +82,8 @@
       "Bash(findstr /i \"miuix.*\\\\.kt\")",
       "Bash(findstr /v \"\\\\\\\\build\\\\\\\\\")",
       "Bash(dir /s /b \"D:\\\\AndroidPrj\\\\legado-with-MD3\\\\.gradle\\\\*miuix*\")",
-      "Skill(legado-compose-review)"
+      "Skill(legado-compose-review)",
+      "Bash(cmd /c \".\\\\gradlew.bat :app:compileAppDebugKotlin\")"
     ]
   }
 }

+ 5 - 0
app/src/main/java/io/legado/app/data/repository/BookRepository.kt

@@ -5,12 +5,17 @@ import io.legado.app.data.dao.BookChapterDao
 import io.legado.app.data.dao.BookDao
 import io.legado.app.data.entities.Book
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.withContext
 
 class BookRepository(
     private val bookDao: BookDao,
     private val bookChapterDao: BookChapterDao
 ) {
+    fun getAllBooks(): Flow<List<Book>> {
+        return bookDao.flowAll()
+    }
+
     suspend fun getBookCoverByNameAndAuthor(bookName: String, bookAuthor: String): String? {
         return withContext(Dispatchers.IO) {
             bookDao.getBook(bookName, bookAuthor)?.getDisplayCover()

+ 17 - 2
app/src/main/java/io/legado/app/domain/usecase/readRecord/GetReadRecordOverviewUseCase.kt

@@ -1,5 +1,6 @@
 package io.legado.app.domain.usecase.readRecord
 
+import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.readRecord.ReadRecord
 import io.legado.app.data.entities.readRecord.ReadRecordDetail
 import io.legado.app.ui.book.readRecord.ReadBookRanking
@@ -16,7 +17,8 @@ class GetReadRecordOverviewUseCase {
         period: ReadPeriod,
         refDate: LocalDate,
         details: List<ReadRecordDetail>,
-        latestRecords: List<ReadRecord>
+        latestRecords: List<ReadRecord>,
+        allBooks: List<Book>
     ): ReadRecordOverviewUiState {
         val (startDate, endDate) = getPeriodRange(period, refDate)
 
@@ -41,11 +43,24 @@ class GetReadRecordOverviewUseCase {
         val totalBooks = periodBooks.size
 
         val shelfBooksMap = latestRecords.associateBy { it.bookName to it.bookAuthor }
+        val allShelfBooksMap = allBooks.associateBy { it.name to it.author }
+        
         var readingCount = 0
+        var finishedCount = 0
+        
         periodBooks.keys.forEach { key ->
             if (shelfBooksMap.containsKey(key)) {
                 readingCount++
             }
+            // 判断是否读完
+            allShelfBooksMap[key]?.let { book ->
+                if (book.totalChapterNum > 0 &&
+                    book.durChapterIndex >= book.totalChapterNum - 1 &&
+                    book.durChapterPos != 0
+                ) {
+                    finishedCount++
+                }
+            }
         }
 
         val topBooks = periodBooks.map { (key, details) ->
@@ -122,7 +137,7 @@ class GetReadRecordOverviewUseCase {
             totalTime = totalTime,
             readingDays = readingDays,
             totalBooks = totalBooks,
-            finishedBooks = 0, // Placeholder
+            finishedBooks = finishedCount,
             readingBooks = readingCount,
             totalWords = totalWords,
             dailyTimeData = dailyTimeData,

+ 1 - 0
app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt

@@ -75,6 +75,7 @@ sealed interface BookInfoIntent {
     data object ChangeSourceClick : BookInfoIntent
     data object ReadRecordClick : BookInfoIntent
     data object RemarkClick : BookInfoIntent
+    data class SaveCover(val path: String) : BookInfoIntent
     data class ConfirmDelete(val deleteOriginal: Boolean) : BookInfoIntent
     data class UpdateRemark(val remark: String) : BookInfoIntent
     data class SelectGroup(val groupId: Long) : BookInfoIntent

+ 95 - 85
app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt

@@ -82,6 +82,7 @@ import io.legado.app.ui.theme.LocalHazeState
 import io.legado.app.ui.theme.ProvideThemeOverride
 import io.legado.app.ui.theme.ThemeOverrideState
 import io.legado.app.ui.theme.ThemeResolver
+import io.legado.app.ui.theme.fadingEdge
 import io.legado.app.ui.theme.rememberImageSeedColor
 import io.legado.app.ui.theme.rememberThemeOverride
 import io.legado.app.ui.theme.responsiveHazeEffectFixedStyle
@@ -737,8 +738,12 @@ private fun BookInfoHeader(
                 }
             }
             if (kindLabels.isNotEmpty() || !groupNames.isNullOrBlank()) {
+                val kindListState = rememberLazyListState()
                 LazyRow(
-                    modifier = Modifier.fillMaxWidth(),
+                    state = kindListState,
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .fadingEdge(kindListState),
                     horizontalArrangement = Arrangement.spacedBy(8.dp),
                 ) {
                     groupNames?.takeIf { it.isNotBlank() }?.let {
@@ -963,101 +968,106 @@ private fun BookInfoDialogs(
     var deleteOriginal by remember(dialog, state.deleteOriginal) { mutableStateOf(state.deleteOriginal) }
     var remarkText by remember(dialog) { mutableStateOf((dialog as? BookInfoDialog.EditRemark)?.remark.orEmpty()) }
 
-    if (dialog is BookInfoDialog.DeleteBook) {
-        AppAlertDialog(
-            show = true,
-            onDismissRequest = { onIntent(BookInfoIntent.DismissDialog) },
-            title = stringResource(R.string.draw),
-            text = stringResource(R.string.sure_del),
-            confirmText = stringResource(android.R.string.ok),
-            onConfirm = {
-                onIntent(BookInfoIntent.ConfirmDelete(deleteOriginal))
-            },
-            dismissText = stringResource(android.R.string.cancel),
-            onDismiss = { onIntent(BookInfoIntent.DismissDialog) },
-            content = {
-                if (dialog.isLocal) {
-                    Row(verticalAlignment = Alignment.CenterVertically) {
-                        androidx.compose.material3.Checkbox(
-                            checked = deleteOriginal,
-                            onCheckedChange = { deleteOriginal = it },
-                            colors = androidx.compose.material3.CheckboxDefaults.colors(
-                                checkedColor = LegadoTheme.colorScheme.primary,
-                                checkmarkColor = LegadoTheme.colorScheme.onPrimary,
-                                uncheckedColor = LegadoTheme.colorScheme.onSurfaceVariant,
-                            )
+    AppAlertDialog(
+        data = dialog as? BookInfoDialog.DeleteBook,
+        onDismissRequest = { onIntent(BookInfoIntent.DismissDialog) },
+        title = stringResource(R.string.draw),
+        text = stringResource(R.string.sure_del),
+        confirmText = stringResource(android.R.string.ok),
+        onConfirm = {
+            onIntent(BookInfoIntent.ConfirmDelete(deleteOriginal))
+        },
+        dismissText = stringResource(android.R.string.cancel),
+        onDismiss = { onIntent(BookInfoIntent.DismissDialog) },
+        content = { d ->
+            if (d.isLocal) {
+                Row(verticalAlignment = Alignment.CenterVertically) {
+                    androidx.compose.material3.Checkbox(
+                        checked = deleteOriginal,
+                        onCheckedChange = { deleteOriginal = it },
+                        colors = androidx.compose.material3.CheckboxDefaults.colors(
+                            checkedColor = LegadoTheme.colorScheme.primary,
+                            checkmarkColor = LegadoTheme.colorScheme.onPrimary,
+                            uncheckedColor = LegadoTheme.colorScheme.onSurfaceVariant,
                         )
-                        Text(text = stringResource(R.string.delete_book_file))
-                    }
+                    )
+                    Text(text = stringResource(R.string.delete_book_file))
                 }
             }
-        )
-    }
+        }
+    )
 
-    if (dialog is BookInfoDialog.EditRemark) {
-        AppAlertDialog(
-            show = true,
-            onDismissRequest = { onIntent(BookInfoIntent.DismissDialog) },
-            title = stringResource(R.string.edit_remark),
-            confirmText = stringResource(android.R.string.ok),
-            onConfirm = { onIntent(BookInfoIntent.UpdateRemark(remarkText)) },
-            dismissText = stringResource(android.R.string.cancel),
-            onDismiss = { onIntent(BookInfoIntent.DismissDialog) },
-            content = {
-                AppTextField(
-                    value = remarkText,
-                    onValueChange = { remarkText = it },
-                    label = "备注",
-                    modifier = Modifier.fillMaxWidth(),
-                )
-            }
-        )
-    }
+    AppAlertDialog(
+        data = dialog as? BookInfoDialog.EditRemark,
+        onDismissRequest = { onIntent(BookInfoIntent.DismissDialog) },
+        title = stringResource(R.string.edit_remark),
+        confirmText = stringResource(android.R.string.ok),
+        onConfirm = { onIntent(BookInfoIntent.UpdateRemark(remarkText)) },
+        dismissText = stringResource(android.R.string.cancel),
+        onDismiss = { onIntent(BookInfoIntent.DismissDialog) },
+        content = {
+            AppTextField(
+                value = remarkText,
+                onValueChange = { remarkText = it },
+                label = stringResource(R.string.book_remark),
+                modifier = Modifier.fillMaxWidth(),
+            )
+        }
+    )
 
-    if (dialog is BookInfoDialog.UnsupportedWebFile) {
-        AppAlertDialog(
-            show = true,
-            onDismissRequest = { onIntent(BookInfoIntent.DismissDialog) },
-            title = stringResource(R.string.draw),
-            text = stringResource(R.string.file_not_supported, dialog.webFile.name),
-            confirmText = stringResource(R.string.open_fun),
-            onConfirm = { onIntent(BookInfoIntent.OpenUnsupportedWebFile(dialog.webFile)) },
-            dismissText = stringResource(android.R.string.cancel),
-            onDismiss = { onIntent(BookInfoIntent.DismissDialog) },
-        )
-    }
+    val unsupportedWebFile = dialog as? BookInfoDialog.UnsupportedWebFile
+    AppAlertDialog(
+        data = unsupportedWebFile,
+        onDismissRequest = { onIntent(BookInfoIntent.DismissDialog) },
+        title = stringResource(R.string.draw),
+        text = unsupportedWebFile?.let {
+            stringResource(
+                R.string.file_not_supported,
+                it.webFile.name
+            )
+        },
+        confirmText = stringResource(R.string.open_fun),
+        onConfirm = { onIntent(BookInfoIntent.OpenUnsupportedWebFile(it.webFile)) },
+        dismissText = stringResource(android.R.string.cancel),
+        onDismiss = { onIntent(BookInfoIntent.DismissDialog) },
+    )
 
-    if (dialog is BookInfoDialog.PhotoPreview) {
-        AppAlertDialog(
-            show = true,
-            onDismissRequest = { onIntent(BookInfoIntent.DismissDialog) },
-            title = stringResource(R.string.img_cover),
-            confirmText = stringResource(android.R.string.ok),
-            onConfirm = { onIntent(BookInfoIntent.DismissDialog) },
-            content = {
-                AsyncImage(
-                    model = dialog.path,
-                    contentDescription = null,
+    AppAlertDialog(
+        data = dialog as? BookInfoDialog.PhotoPreview,
+        onDismissRequest = { onIntent(BookInfoIntent.DismissDialog) },
+        title = stringResource(R.string.img_cover),
+        confirmText = "保存到相册",
+        onConfirm = { d ->
+            onIntent(BookInfoIntent.SaveCover(d.path))
+            onIntent(BookInfoIntent.DismissDialog)
+        },
+        dismissText = stringResource(android.R.string.cancel),
+        onDismiss = { onIntent(BookInfoIntent.DismissDialog) },
+        content = { d ->
+            Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
+                CoilBookCover(
+                    name = state.book?.name,
+                    author = state.book?.author,
+                    path = d.path,
+                    sourceOrigin = state.book?.origin,
+                    ignoreUseDefaultCover = true,
                     modifier = Modifier
-                        .fillMaxWidth()
-                        .heightIn(max = 420.dp),
-                    contentScale = ContentScale.Fit,
+                        .heightIn(max = 420.dp)
+                        .fillMaxWidth(0.6f)
                 )
             }
-        )
-    }
+        }
+    )
 
-    if (state.isBusy) {
-        AppAlertDialog(
-            show = true,
-            onDismissRequest = {},
-            content = {
-                Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
-                    AppCircularProgressIndicator()
-                }
+    AppAlertDialog(
+        show = state.isBusy,
+        onDismissRequest = {},
+        content = {
+            Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
+                AppCircularProgressIndicator()
             }
-        )
-    }
+        }
+    )
 
     AppLogSheet(show = state.showAppLogSheet, onDismissRequest = { onIntent(BookInfoIntent.DismissAppLogSheet) })
 }

+ 57 - 16
app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt

@@ -3,8 +3,12 @@ package io.legado.app.ui.book.info
 import android.app.Activity.RESULT_OK
 import android.app.Application
 import android.content.Intent
+import android.graphics.Bitmap
 import android.net.Uri
+import androidx.core.graphics.drawable.toBitmap
 import androidx.lifecycle.viewModelScope
+import coil.imageLoader
+import coil.request.SuccessResult
 import io.legado.app.R
 import io.legado.app.base.BaseViewModel
 import io.legado.app.constant.AppLog
@@ -16,11 +20,11 @@ import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.readRecord.ReadRecordTimelineDay
-import io.legado.app.domain.usecase.ChangeBookSourceUseCase
-import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
+import io.legado.app.data.repository.BookGroupRepository
 import io.legado.app.data.repository.ReadRecordRepository
 import io.legado.app.data.repository.RemoteBookRepository
-import io.legado.app.data.repository.BookGroupRepository
+import io.legado.app.domain.usecase.ChangeBookSourceUseCase
+import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
 import io.legado.app.domain.usecase.ClearBookCacheUseCase
 import io.legado.app.exception.NoBooksDirException
 import io.legado.app.exception.NoStackTraceException
@@ -33,7 +37,6 @@ import io.legado.app.help.book.isSameNameAuthor
 import io.legado.app.help.book.isWebFile
 import io.legado.app.help.book.removeType
 import io.legado.app.help.book.updateTo
-import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.LocalConfig
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.lib.webdav.ObjectNotFoundException
@@ -45,24 +48,29 @@ import io.legado.app.model.SourceCallBack
 import io.legado.app.model.analyzeRule.AnalyzeUrl
 import io.legado.app.model.localBook.LocalBook
 import io.legado.app.model.webBook.WebBook
+import io.legado.app.ui.config.coverConfig.CoverConfig
+import io.legado.app.ui.widget.components.cover.buildCoverImageRequest
 import io.legado.app.utils.ArchiveUtils
 import io.legado.app.utils.ConvertUtils
 import io.legado.app.utils.FileDoc
 import io.legado.app.utils.GSON
+import io.legado.app.utils.ImageSaveUtils
 import io.legado.app.utils.UrlUtil
 import io.legado.app.utils.postEvent
+import io.legado.app.utils.splitNotBlank
 import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Job
-import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
+import java.io.ByteArrayOutputStream
 
 class BookInfoViewModel(
     application: Application,
@@ -167,6 +175,9 @@ class BookInfoViewModel(
             BookInfoIntent.ChangeSourceClick -> setSheet(BookInfoSheet.SourcePicker)
             BookInfoIntent.ReadRecordClick -> setSheet(BookInfoSheet.ReadRecord)
             BookInfoIntent.RemarkClick -> showDialog(BookInfoDialog.EditRemark(currentBook?.remark))
+            is BookInfoIntent.SaveCover -> {
+                saveCoverToGallery(intent.path)
+            }
             is BookInfoIntent.ConfirmDelete -> {
                 dismissDialog()
                 deleteBook(intent.deleteOriginal)
@@ -445,6 +456,41 @@ class BookInfoViewModel(
         }
     }
 
+    private fun saveCoverToGallery(path: String) {
+        val book = currentBook
+        val sourceOrigin = if (book?.getDisplayCover() == path) book.origin else null
+        execute {
+            setBusy(true)
+            val request = buildCoverImageRequest(
+                context = context,
+                data = path,
+                sourceOrigin = sourceOrigin,
+                loadOnlyWifi = CoverConfig.loadCoverOnlyWifi,
+                crossfade = false
+            )
+            val result = context.imageLoader.execute(request)
+            if (result is SuccessResult) {
+                val bitmap = result.drawable.toBitmap()
+                val outputStream = ByteArrayOutputStream()
+                bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
+                val byteArray = outputStream.toByteArray()
+                ImageSaveUtils.saveImageToGallery(context, byteArray, "Cover_")
+            } else {
+                false
+            }
+        }.onSuccess { success ->
+            if (success) {
+                context.toastOnUi("保存成功")
+            } else {
+                context.toastOnUi("保存失败")
+            }
+        }.onFinally {
+            setBusy(false)
+        }.onError {
+            context.toastOnUi("保存出错: ${it.localizedMessage}")
+        }
+    }
+
     fun saveRemark(remark: String, success: (() -> Unit)? = null) {
         currentBook?.let { book ->
             execute {
@@ -713,27 +759,22 @@ class BookInfoViewModel(
 
     private fun refreshMeta(book: Book) {
         execute {
-            val allKinds = book.getKindList()
-            val customKinds = allKinds.filter { it.startsWith("#") }
-            val sourceKinds = allKinds.filter { !it.startsWith("#") }.toMutableList()
+            val fileSizePattern = Regex("""^\d[\d,.]*\s*(b|kb|M|G|T)$""", RegexOption.IGNORE_CASE)
+            val kinds = (book.kind?.splitNotBlank(",", "\n").orEmpty().toList())
+                .filter { !fileSizePattern.matches(it.trim()) }
+                .toMutableList()
             if (book.isLocal) {
                 val size = FileDoc.fromFile(book.bookUrl).size
                 if (size > 0) {
-                    sourceKinds.add(ConvertUtils.formatFileSize(size))
+                    kinds.add(ConvertUtils.formatFileSize(size))
                 }
             }
-            val mergedKinds = (customKinds + sourceKinds).distinct()
+            val finalKinds = kinds.distinct()
             val userGroupIds = appDb.bookGroupDao.idsSum
             val groupAnd = userGroupIds and book.group
             val hasCustomGroup = book.group > 0L && groupAnd != 0L
             val groupNames = appDb.bookGroupDao.getGroupNames(book.group).joinToString(",")
             val normalizedGroupNames = groupNames.ifBlank { null }
-            val persistedCustomKinds = currentKindLabels.filter { it.startsWith("#") }
-            val finalKinds = if (persistedCustomKinds.isNotEmpty()) {
-                (persistedCustomKinds + sourceKinds).distinct()
-            } else {
-                mergedKinds
-            }
             book.kind = finalKinds.joinToString(",")
             appDb.bookDao.update(book)
             Triple(finalKinds, normalizedGroupNames, hasCustomGroup)

+ 144 - 75
app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt

@@ -1,9 +1,9 @@
 package io.legado.app.ui.book.info.edit
 
 import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.horizontalScroll
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.consumeWindowInsets
@@ -13,14 +13,16 @@ import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.imePadding
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.foundation.text.input.TextFieldLineLimits
 import androidx.compose.foundation.text.input.rememberTextFieldState
 import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Add
-import androidx.compose.material.icons.filled.Close
 import androidx.compose.material.icons.filled.FolderOpen
 import androidx.compose.material.icons.filled.ImageSearch
 import androidx.compose.material.icons.filled.Replay
@@ -30,8 +32,7 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.ExposedDropdownMenuAnchorType
 import androidx.compose.material3.ExposedDropdownMenuBox
 import androidx.compose.material3.ExposedDropdownMenuDefaults
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
+import androidx.compose.material3.Surface
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
@@ -41,27 +42,37 @@ import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
 import androidx.compose.ui.input.nestedscroll.nestedScroll
 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.compose.ui.zIndex
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
 import io.legado.app.ui.book.changecover.ChangeCoverDialog
+import io.legado.app.ui.config.themeConfig.ThemeConfig
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.fadingEdge
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.AppTextField
+import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.button.MediumOutlinedIconButton
-import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import io.legado.app.ui.widget.components.cover.CoilBookCover
 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.settingItem.SwitchSettingItem
+import io.legado.app.ui.widget.components.text.AnimatedTextLine
 import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
-import io.legado.app.ui.config.themeConfig.ThemeConfig
+import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import io.legado.app.utils.SelectImageContract
 import io.legado.app.utils.launch
 import io.legado.app.utils.showDialogFragment
+import sh.calvin.reorderable.ReorderableItem
+import sh.calvin.reorderable.rememberReorderableLazyListState
 
 @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
 @Composable
@@ -214,6 +225,7 @@ fun BookInfoEditContent(
         KindEditor(
             kindList = uiState.kindList,
             onKindListChange = { viewModel.onKindListChange(it) },
+            onReset = { viewModel.resetKinds() },
             backgroundColor = inputBackgroundColor
         )
         Spacer(modifier = Modifier.height(8.dp))
@@ -298,89 +310,146 @@ fun BookTypeDropdown(
     }
 }
 
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
 @Composable
 fun KindEditor(
     kindList: List<String>,
     onKindListChange: (List<String>) -> Unit,
+    onReset: () -> Unit,
     backgroundColor: Color
 ) {
-    var newKindText by remember { mutableStateOf("") }
+    var editingIndex by remember { mutableStateOf<Int?>(null) }
+    var editText by remember { mutableStateOf("") }
+    val hapticFeedback = LocalHapticFeedback.current
 
-    Column(
-        modifier = Modifier.fillMaxWidth()
+    val listState = rememberLazyListState()
+    val reorderableState = rememberReorderableLazyListState(listState) { from, to ->
+        val mutable = kindList.toMutableList()
+        mutable.add(to.index, mutable.removeAt(from.index))
+        onKindListChange(mutable)
+    }
+
+    Row(
+        modifier = Modifier.fillMaxWidth(),
+        verticalAlignment = Alignment.CenterVertically
     ) {
-        Row(
-            modifier = Modifier.fillMaxWidth(),
+        LazyRow(
+            state = listState,
+            modifier = Modifier
+                .weight(1f)
+                .fadingEdge(listState),
+            horizontalArrangement = Arrangement.spacedBy(8.dp),
             verticalAlignment = Alignment.CenterVertically
         ) {
-            AppTextField(
-                value = newKindText,
-                onValueChange = { newKindText = it },
-                label = "标签",
-                backgroundColor = backgroundColor,
-                modifier = Modifier.weight(1f)
-            )
-            Spacer(modifier = Modifier.width(8.dp))
-            MediumOutlinedIconButton(
-                onClick = {
-                    val trimmed = newKindText.trim()
-                    val tag = if (trimmed.startsWith("#")) trimmed else "#$trimmed"
-                    if (tag.isNotBlank() && tag !in kindList) {
-                        val sortedList = (kindList + tag).sortedWith(
-                            compareBy<String> { !it.startsWith("#") }.thenBy { it }
-                        )
-                        onKindListChange(sortedList)
-                        newKindText = ""
-                    }
-                },
-                imageVector = Icons.Default.Add
-            )
-        }
-        Spacer(modifier = Modifier.height(4.dp))
-        if (kindList.isNotEmpty()) {
-            Row(
-                modifier = Modifier
-                    .fillMaxWidth()
-                    .horizontalScroll(rememberScrollState()),
-                horizontalArrangement = Arrangement.spacedBy(4.dp),
-                verticalAlignment = Alignment.CenterVertically
-            ) {
-                kindList.forEach { kind ->
-                    Row(
-                        verticalAlignment = Alignment.CenterVertically
-                    ) {
-                        AppTextField(
-                            value = kind,
-                            onValueChange = { newValue ->
-                                val index = kindList.indexOf(kind)
-                                if (index >= 0) {
-                                    val newList = kindList.toMutableList()
-                                    newList[index] = newValue
-                                    onKindListChange(newList)
+            items(kindList.size, key = { kindList[it] }) { index ->
+                val kind = kindList[index]
+                ReorderableItem(reorderableState, key = kind) { isDragging ->
+                    KindChip(
+                        text = kind,
+                        isDragging = isDragging,
+                        onClick = {
+                            editingIndex = index
+                            editText = kind
+                        },
+                        modifier = Modifier
+                            .longPressDraggableHandle(
+                                onDragStarted = {
+                                    hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
+                                },
+                                onDragStopped = {
+                                    hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
                                 }
-                            },
-                            label = null,
-                            backgroundColor = backgroundColor,
-                            singleLine = true,
-                            maxLines = 1,
-                            modifier = Modifier.width(80.dp)
-                        )
-                        IconButton(
-                            onClick = {
-                                onKindListChange(kindList - kind)
-                            },
-                            modifier = Modifier.padding(0.dp)
-                        ) {
-                            Icon(
-                                imageVector = Icons.Default.Close,
-                                contentDescription = "删除标签",
-                                tint = Color.Red.copy(alpha = 0.7f),
-                                modifier = Modifier.padding(4.dp)
                             )
-                        }
-                    }
+                            .animateItem()
+                    )
                 }
             }
         }
+        Spacer(modifier = Modifier.width(8.dp))
+        MediumOutlinedIconButton(
+            onClick = {
+                onReset()
+            },
+            imageVector = Icons.Default.Replay
+        )
+        Spacer(modifier = Modifier.width(8.dp))
+        MediumOutlinedIconButton(
+            onClick = {
+                editingIndex = -1
+                editText = ""
+            },
+            imageVector = Icons.Default.Add
+        )
+    }
+
+    if (editingIndex != null) {
+        val isAdding = editingIndex == -1
+        AppAlertDialog(
+            show = true,
+            onDismissRequest = { editingIndex = null },
+            title = if (isAdding) "添加标签" else "编辑标签",
+            content = {
+                AppTextField(
+                    value = editText,
+                    onValueChange = { editText = it },
+                    label = "标签",
+                    backgroundColor = backgroundColor,
+                    modifier = Modifier.fillMaxWidth()
+                )
+            },
+            confirmText = stringResource(android.R.string.ok),
+            onConfirm = {
+                val trimmed = editText.trim()
+                if (trimmed.isNotBlank()) {
+                    val mutable = kindList.toMutableList()
+                    if (isAdding) {
+                        if (trimmed !in kindList) mutable.add(trimmed)
+                    } else {
+                        mutable[editingIndex!!] = trimmed
+                    }
+                    onKindListChange(mutable)
+                }
+                editingIndex = null
+            },
+            dismissText = if (isAdding) stringResource(android.R.string.cancel) else stringResource(
+                R.string.delete
+            ),
+            onDismiss = if (isAdding) {
+                { editingIndex = null }
+            } else {
+                {
+                    onKindListChange(kindList.toMutableList().apply { removeAt(editingIndex!!) })
+                    editingIndex = null
+                }
+            }
+        )
+    }
+}
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+private fun KindChip(
+    text: String,
+    isDragging: Boolean,
+    onClick: () -> Unit,
+    modifier: Modifier = Modifier
+) {
+    Surface(
+        shape = RoundedCornerShape(8.dp),
+        color = LegadoTheme.colorScheme.surfaceContainer,
+        contentColor = LegadoTheme.colorScheme.onSurface,
+        shadowElevation = if (isDragging) 8.dp else 0.dp,
+        modifier = modifier
+            .zIndex(if (isDragging) 1f else 0f)
+            .clickable(onClick = onClick)
+    ) {
+        AnimatedTextLine(
+            text = text,
+            style = LegadoTheme.typography.labelLargeEmphasized,
+            color = LegadoTheme.colorScheme.onSurface,
+            maxLines = 1,
+            overflow = TextOverflow.Ellipsis,
+            modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
+        )
     }
 }

+ 9 - 2
app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditViewModel.kt

@@ -19,7 +19,6 @@ import io.legado.app.model.ReadBook
 import io.legado.app.utils.FileUtils
 import io.legado.app.utils.MD5Utils
 import io.legado.app.utils.inputStream
-import io.legado.app.utils.splitNotBlank
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asStateFlow
@@ -33,6 +32,7 @@ data class BookInfoEditUiState(
     val intro: String? = null,
     val remark: String? = null,
     val kindList: List<String> = emptyList(),
+    val originalKindList: List<String> = emptyList(),
     val selectedType: String = "文本",
     val bookTypes: List<String> = listOf("文本", "音频", "图片"),
     val fixedType: Boolean = false,
@@ -53,13 +53,16 @@ class BookInfoEditViewModel(application: Application) : BaseViewModel(applicatio
                     it.isAudio -> 1
                     else -> 0
                 }
+                val kinds =
+                    it.kind?.split(",", "\n")?.filter { kind -> kind.isNotBlank() }.orEmpty()
                 _uiState.value = BookInfoEditUiState(
                     name = it.name,
                     author = it.author,
                     coverUrl = it.getDisplayCover(),
                     intro = it.getDisplayIntro(),
                     remark = it.remark,
-                    kindList = it.kind?.split(",", "\n")?.filter { kind -> kind.isNotBlank() }.orEmpty(),
+                    kindList = kinds,
+                    originalKindList = kinds,
                     selectedType = _uiState.value.bookTypes[selectedTypeIndex],
                     fixedType = it.config.fixedType,
                     book = it
@@ -68,6 +71,10 @@ class BookInfoEditViewModel(application: Application) : BaseViewModel(applicatio
         }
     }
 
+    fun resetKinds() {
+        _uiState.value = _uiState.value.copy(kindList = _uiState.value.originalKindList.toList())
+    }
+
     fun onNameChange(name: String) {
         _uiState.value = _uiState.value.copy(name = name)
     }

+ 16 - 21
app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt

@@ -1,23 +1,20 @@
 package io.legado.app.ui.book.manage
 
-import androidx.compose.ui.focus.focusRequester
 import androidx.activity.compose.BackHandler
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.ExperimentalLayoutApi
-import androidx.compose.foundation.layout.FlowRow
-import androidx.compose.foundation.layout.offset
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.offset
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.layout.width
@@ -26,11 +23,11 @@ import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.MenuOpen
+import androidx.compose.material.icons.filled.Add
 import androidx.compose.material.icons.filled.Bookmarks
 import androidx.compose.material.icons.filled.Close
 import androidx.compose.material.icons.filled.Delete
 import androidx.compose.material.icons.filled.Download
-import androidx.compose.material.icons.filled.Add
 import androidx.compose.material.icons.filled.Info
 import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.PlayArrow
@@ -44,7 +41,6 @@ import androidx.compose.material3.Checkbox
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.FloatingActionButtonMenu
 import androidx.compose.material3.FloatingActionButtonMenuItem
-import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
 import androidx.compose.material3.Icon
 import androidx.compose.material3.RadioButton
 import androidx.compose.material3.Text
@@ -61,9 +57,10 @@ import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberUpdatedState
 import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.setValue
-import androidx.compose.ui.Modifier
 import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
 import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
@@ -72,7 +69,6 @@ import io.legado.app.R
 import io.legado.app.constant.IntentAction
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
-import io.legado.app.data.entities.BookGroup
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.BookSourcePart
 import io.legado.app.domain.usecase.BatchChangeSourcePreviewItem
@@ -87,12 +83,12 @@ import io.legado.app.ui.book.info.ChangeSourceSheet
 import io.legado.app.ui.book.info.GroupSelectSheet
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.adaptiveContentPadding
-import io.legado.app.ui.widget.components.progressIndicator.AppLinearProgressIndicator
+import io.legado.app.ui.theme.adaptiveHorizontalPadding
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
+import io.legado.app.ui.widget.components.button.MediumIconButton
 import io.legado.app.ui.widget.components.button.SmallTonalIconButton
 import io.legado.app.ui.widget.components.button.SmallTonalTextButton
-import io.legado.app.ui.widget.components.topbar.TopBarActionButton
 import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
 import io.legado.app.ui.widget.components.card.SelectionItemCard
@@ -100,26 +96,25 @@ import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.cover.CoilBookCover
 import io.legado.app.ui.widget.components.divider.PillDivider
 import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
-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.icon.AppIcons
 import io.legado.app.ui.widget.components.list.ListScaffold
 import io.legado.app.ui.widget.components.list.ListUiState
-import io.legado.app.ui.widget.components.modalBottomSheet.OptionCard
+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.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.modalBottomSheet.OptionCard
 import io.legado.app.ui.widget.components.modalBottomSheet.OptionSheet
+import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
 import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.ui.widget.components.topbar.TopBarActionButton
 import io.legado.app.utils.ACache
 import io.legado.app.utils.FileDoc
 import io.legado.app.utils.checkWrite
 import io.legado.app.utils.isContentScheme
 import io.legado.app.utils.move
-import io.legado.app.utils.startActivity
 import io.legado.app.utils.startService
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.verificationField
-import io.legado.app.ui.theme.adaptiveHorizontalPadding
-import io.legado.app.ui.widget.components.button.MediumIconButton
 import org.koin.androidx.compose.koinViewModel
 import sh.calvin.reorderable.ReorderableItem
 import sh.calvin.reorderable.rememberReorderableLazyListState
@@ -399,14 +394,14 @@ private fun BookshelfManageScreen(
             Icons.Default.SelectAll,
             stringResource(R.string.select_all)
         ) {
-            selectedBookUrls = state.books.mapTo(hashSetOf()) { it.bookUrl }
+            selectedBookUrls = filteredBooks.mapTo(hashSetOf()) { it.bookUrl }
         },
         BookshelfManageFabAction(
             Icons.Default.Refresh,
             stringResource(R.string.revert_selection)
         ) {
-            val visibleBookUrls = booksByUrl.keys
-            selectedBookUrls = visibleBookUrls - selectedBookUrls
+            val filteredUrls = filteredBooks.map { it.bookUrl }.toSet()
+            selectedBookUrls = (selectedBookUrls - filteredUrls) + (filteredUrls - selectedBookUrls)
         },
         BookshelfManageFabAction(
             Icons.Default.Download,

+ 130 - 61
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewScreen.kt

@@ -1,20 +1,47 @@
 package io.legado.app.ui.book.readRecord
 
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.SizeTransform
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.animation.togetherWith
 import androidx.compose.foundation.background
 import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.ArrowLeft
 import androidx.compose.material.icons.automirrored.filled.ArrowRight
-import androidx.compose.material3.*
-import androidx.compose.runtime.*
+import androidx.compose.material.icons.filled.CalendarMonth
+import androidx.compose.material.icons.filled.GridOn
+import androidx.compose.material.icons.filled.Leaderboard
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.alpha
 import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.text.style.TextAlign
@@ -31,6 +58,7 @@ import io.legado.app.ui.widget.components.button.MediumIconButton
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.cover.CoilBookCover
 import io.legado.app.ui.widget.components.heatmap.HeatmapMode
+import io.legado.app.ui.widget.components.tabRow.AppTabRow
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
@@ -83,8 +111,11 @@ fun ReadRecordOverviewScreen(
                 modifier = Modifier.fillMaxSize(),
                 contentPadding = PaddingValues(bottom = 16.dp)
             ) {
-                item {
-                    HeatmapCard(state)
+
+                if (state.period != ReadPeriod.ALL && state.dailyTimeData.isNotEmpty()) {
+                    item {
+                        ReadingTimeBarChartCard(data = state.dailyTimeData, period = state.period)
+                    }
                 }
 
                 item {
@@ -99,10 +130,8 @@ fun ReadRecordOverviewScreen(
                     StatsGridCard(title = "阅读数据", items = stats)
                 }
 
-                if (state.period != ReadPeriod.ALL && state.dailyTimeData.isNotEmpty()) {
-                    item {
-                        ReadingTimeBarChartCard(data = state.dailyTimeData, period = state.period)
-                    }
+                item {
+                    HeatmapCard(state)
                 }
 
                 if (state.topBooks.isNotEmpty()) {
@@ -124,27 +153,22 @@ fun PeriodSelector(
     selectedPeriod: ReadPeriod,
     onPeriodSelected: (ReadPeriod) -> Unit
 ) {
-    val periods = listOf(
-        ReadPeriod.DAY to "日",
-        ReadPeriod.WEEK to "周",
-        ReadPeriod.MONTH to "月",
-        ReadPeriod.YEAR to "年",
-        ReadPeriod.ALL to "总"
-    )
+    val periods = remember {
+        listOf(
+            ReadPeriod.DAY to "日",
+            ReadPeriod.WEEK to "周",
+            ReadPeriod.MONTH to "月",
+            ReadPeriod.YEAR to "年",
+            ReadPeriod.ALL to "总"
+        )
+    }
 
-    PrimaryTabRow(
+    AppTabRow(
+        tabTitles = periods.map { it.second },
         selectedTabIndex = periods.indexOfFirst { it.first == selectedPeriod },
-        containerColor = Color.Transparent,
-        divider = {}
-    ) {
-        periods.forEach { (period, label) ->
-            Tab(
-                selected = selectedPeriod == period,
-                onClick = { onPeriodSelected(period) },
-                text = { AppText(label) }
-            )
-        }
-    }
+        onTabSelected = { index -> onPeriodSelected(periods[index].first) },
+        isScrollable = false
+    )
 }
 
 @Composable
@@ -156,34 +180,50 @@ fun DateNavigator(
 ) {
     if (period == ReadPeriod.ALL) return
 
-    val dateText = when (period) {
-        ReadPeriod.DAY -> referenceDate.format(DateTimeFormatter.ofPattern("yyyy年M月d日"))
-        ReadPeriod.WEEK -> {
-            val start = referenceDate.with(java.time.DayOfWeek.MONDAY)
-            val end = referenceDate.with(java.time.DayOfWeek.SUNDAY)
-            "${start.format(DateTimeFormatter.ofPattern("M.d"))} - ${end.format(DateTimeFormatter.ofPattern("M.d"))}"
-        }
-        ReadPeriod.MONTH -> referenceDate.format(DateTimeFormatter.ofPattern("yyyy年M月"))
-        ReadPeriod.YEAR -> referenceDate.format(DateTimeFormatter.ofPattern("yyyy年"))
-        ReadPeriod.ALL -> ""
-    }
-
     Row(
         modifier = Modifier
             .fillMaxWidth()
-            .padding(vertical = 8.dp),
-        horizontalArrangement = Arrangement.Center,
+            .padding(vertical = 8.dp, horizontal = 12.dp),
+        horizontalArrangement = Arrangement.SpaceBetween,
         verticalAlignment = Alignment.CenterVertically
     ) {
         MediumIconButton(
             onClick = onPrevClick,
             imageVector = Icons.AutoMirrored.Filled.ArrowLeft
         )
-        AppText(
-            text = dateText,
-            style = LegadoTheme.typography.titleMedium,
-            modifier = Modifier.padding(horizontal = 16.dp)
-        )
+        AnimatedContent(
+            targetState = referenceDate,
+            transitionSpec = {
+                if (targetState.isAfter(initialState)) {
+                    (slideInHorizontally { it / 2 } + fadeIn()).togetherWith(slideOutHorizontally { -it / 2 } + fadeOut())
+                } else {
+                    (slideInHorizontally { -it / 2 } + fadeIn()).togetherWith(slideOutHorizontally { it / 2 } + fadeOut())
+                }.using(SizeTransform(clip = false))
+            },
+            label = "DateNavigator"
+        ) { targetDate ->
+            val text = when (period) {
+                ReadPeriod.DAY -> targetDate.format(DateTimeFormatter.ofPattern("yyyy年M月d日"))
+                ReadPeriod.WEEK -> {
+                    val start = targetDate.with(java.time.DayOfWeek.MONDAY)
+                    val end = targetDate.with(java.time.DayOfWeek.SUNDAY)
+                    "${start.format(DateTimeFormatter.ofPattern("M.d"))} - ${
+                        end.format(
+                            DateTimeFormatter.ofPattern("M.d")
+                        )
+                    }"
+                }
+
+                ReadPeriod.MONTH -> targetDate.format(DateTimeFormatter.ofPattern("yyyy年M月"))
+                ReadPeriod.YEAR -> targetDate.format(DateTimeFormatter.ofPattern("yyyy年"))
+                ReadPeriod.ALL -> ""
+            }
+            AppText(
+                text = text,
+                style = LegadoTheme.typography.titleMedium,
+                modifier = Modifier.padding(horizontal = 16.dp)
+            )
+        }
         MediumIconButton(
             onClick = onNextClick,
             imageVector = Icons.AutoMirrored.Filled.ArrowRight
@@ -199,7 +239,16 @@ fun HeatmapCard(state: ReadRecordOverviewUiState) {
             .adaptiveHorizontalPadding(vertical = 8.dp)
     ) {
         Column(modifier = Modifier.padding(16.dp)) {
-            AppText("阅读热力图", style = LegadoTheme.typography.titleMedium)
+            Row(verticalAlignment = Alignment.CenterVertically) {
+                Icon(
+                    imageVector = Icons.Default.GridOn,
+                    contentDescription = null,
+                    tint = LegadoTheme.colorScheme.primary,
+                    modifier = Modifier.size(20.dp)
+                )
+                Spacer(modifier = Modifier.width(8.dp))
+                AppText("阅读热力图", style = LegadoTheme.typography.titleMedium)
+            }
             Spacer(modifier = Modifier.height(8.dp))
             HeatmapCalendarSection(
                 dailyReadCounts = state.allReadCounts,
@@ -224,11 +273,22 @@ fun TopReadingListCard(
             .adaptiveHorizontalPadding(vertical = 8.dp)
     ) {
         Column(modifier = Modifier.padding(vertical = 16.dp)) {
-            AppText(
+            Row(
                 modifier = Modifier.padding(horizontal = 16.dp),
-                text = "阅读时长榜",
-                style = LegadoTheme.typography.titleMedium
-            )
+                verticalAlignment = Alignment.CenterVertically
+            ) {
+                Icon(
+                    imageVector = Icons.Default.Leaderboard,
+                    contentDescription = null,
+                    tint = LegadoTheme.colorScheme.primary,
+                    modifier = Modifier.size(20.dp)
+                )
+                Spacer(modifier = Modifier.width(8.dp))
+                AppText(
+                    text = "阅读时长榜",
+                    style = LegadoTheme.typography.titleMedium
+                )
+            }
             Spacer(modifier = Modifier.height(8.dp))
             
             topBooks.forEachIndexed { index, book ->
@@ -247,7 +307,7 @@ fun TopReadingListCard(
                     AppText(
                         text = "${index + 1}",
                         style = LegadoTheme.typography.titleMedium,
-                        modifier = Modifier.width(32.dp),
+                        modifier = Modifier.padding(start = 12.dp, end = 16.dp),
                         textAlign = TextAlign.Center,
                         color = if (index < 3) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.onSurfaceVariant
                     )
@@ -259,6 +319,12 @@ fun TopReadingListCard(
                     )
                     Spacer(modifier = Modifier.width(12.dp))
                     Column(modifier = Modifier.weight(1f)) {
+                        AppText(
+                            modifier = Modifier.padding(end = 8.dp),
+                            text = ReadRecordFormatter.formatDuration(book.readTime),
+                            style = LegadoTheme.typography.bodySmall,
+                            color = LegadoTheme.colorScheme.primary
+                        )
                         AppText(
                             text = book.bookName,
                             style = LegadoTheme.typography.bodyMedium,
@@ -272,12 +338,6 @@ fun TopReadingListCard(
                             color = LegadoTheme.colorScheme.onSurfaceVariant
                         )
                     }
-                    AppText(
-                        modifier = Modifier.padding(end = 8.dp),
-                        text = ReadRecordFormatter.formatDuration(book.readTime),
-                        style = LegadoTheme.typography.bodySmall,
-                        color = LegadoTheme.colorScheme.primary
-                    )
                 }
             }
         }
@@ -300,7 +360,16 @@ fun ReadingCalendarCard(
             .adaptiveHorizontalPadding(vertical = 8.dp)
     ) {
         Column(modifier = Modifier.padding(16.dp)) {
-            AppText("读书日历", style = LegadoTheme.typography.titleMedium)
+            Row(verticalAlignment = Alignment.CenterVertically) {
+                Icon(
+                    imageVector = Icons.Default.CalendarMonth,
+                    contentDescription = null,
+                    tint = LegadoTheme.colorScheme.primary,
+                    modifier = Modifier.size(20.dp)
+                )
+                Spacer(modifier = Modifier.width(8.dp))
+                AppText("读书日历", style = LegadoTheme.typography.titleMedium)
+            }
             Spacer(modifier = Modifier.height(16.dp))
             
             Row(modifier = Modifier.fillMaxWidth()) {

+ 4 - 3
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewViewModel.kt

@@ -52,9 +52,10 @@ class ReadRecordOverviewViewModel(
         _period,
         _referenceDate,
         repository.getAllRecordDetails(""),
-        repository.getLatestReadRecords("")
-    ) { period, refDate, details, latestRecords ->
-        getReadRecordOverviewUseCase(period, refDate, details, latestRecords)
+        repository.getLatestReadRecords(""),
+        bookRepository.getAllBooks()
+    ) { period, refDate, details, latestRecords, allBooks ->
+        getReadRecordOverviewUseCase(period, refDate, details, latestRecords, allBooks)
     }.stateIn(
         scope = viewModelScope,
         started = SharingStarted.WhileSubscribed(5000),

+ 4 - 58
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt

@@ -2,8 +2,6 @@ package io.legado.app.ui.book.readRecord
 
 import androidx.compose.animation.AnimatedContent
 import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.core.animateFloatAsState
-import androidx.compose.animation.core.tween
 import androidx.compose.foundation.basicMarquee
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Arrangement
@@ -49,14 +47,9 @@ import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.drawBehind
-import androidx.compose.ui.draw.drawWithContent
 import androidx.compose.ui.draw.rotate
 import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.graphics.BlendMode
-import androidx.compose.ui.graphics.Brush
 import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.CompositingStrategy
-import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.text.SpanStyle
 import androidx.compose.ui.text.buildAnnotatedString
@@ -69,17 +62,16 @@ import cn.hutool.core.date.DateUtil
 import io.legado.app.data.entities.readRecord.ReadRecord
 import io.legado.app.data.entities.readRecord.ReadRecordDetail
 import io.legado.app.data.entities.readRecord.ReadRecordSession
-import io.legado.app.ui.book.readRecord.component.*
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.adaptiveContentPaddingOnlyVertical
 import io.legado.app.ui.theme.adaptiveHorizontalPadding
+import io.legado.app.ui.theme.fadingEdge
 import io.legado.app.ui.widget.CollapsibleHeader
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.EmptyMessage
 import io.legado.app.ui.widget.components.SearchBar
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.button.AppIconButton
-import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.checkBox.CheckboxItem
@@ -104,8 +96,9 @@ import io.legado.app.ui.widget.components.swipe.SwipeActionContainer
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
-import io.legado.app.utils.formatReadDuration
+import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import io.legado.app.utils.StringUtils.formatFriendlyDate
+import io.legado.app.utils.formatReadDuration
 import kotlinx.coroutines.launch
 import org.koin.androidx.compose.koinViewModel
 import java.time.LocalDate
@@ -516,29 +509,6 @@ fun HeatmapCalendarSection(
         }
     }
 
-    val showLeftGradient by remember {
-        derivedStateOf {
-            listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
-        }
-    }
-    val showRightGradient by remember {
-        derivedStateOf {
-            listState.canScrollForward
-        }
-    }
-
-    val leftAlpha by animateFloatAsState(
-        targetValue = if (showLeftGradient) 1f else 0f,
-        animationSpec = tween(durationMillis = 200),
-        label = "LeftGradientAlpha"
-    )
-
-    val rightAlpha by animateFloatAsState(
-        targetValue = if (showRightGradient) 1f else 0f,
-        animationSpec = tween(durationMillis = 200),
-        label = "RightGradientAlpha"
-    )
-
     Column(
         modifier = modifier
             .fillMaxWidth()
@@ -556,31 +526,7 @@ fun HeatmapCalendarSection(
                 horizontalArrangement = Arrangement.spacedBy(config.cellSpacing),
                 modifier = Modifier
                     .weight(1f)
-                    .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
-                    .drawWithContent {
-                        drawContent()
-
-                        val width = size.width
-                        val gradientWidthPx = config.gradientWidth.toPx()
-                        val leftStop = gradientWidthPx / width
-                        val rightStop = 1f - (gradientWidthPx / width)
-
-                        val colorStops = arrayOf(
-                            0f to Color.Black.copy(alpha = 1f - leftAlpha),
-                            leftStop to Color.Black,
-                            rightStop to Color.Black,
-                            1f to Color.Black.copy(alpha = 1f - rightAlpha)
-                        )
-
-                        drawRect(
-                            brush = Brush.horizontalGradient(
-                                colorStops = colorStops,
-                                startX = 0f,
-                                endX = width
-                            ),
-                            blendMode = BlendMode.DstIn
-                        )
-                    }
+                    .fadingEdge(listState, config.gradientWidth)
             ) {
                 val firstReadDate = listOfNotNull(
                     dailyReadCounts.filterValues { it > 0 }.keys.minOrNull(),

+ 55 - 14
app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordCharts.kt

@@ -1,15 +1,35 @@
 package io.legado.app.ui.book.readRecord.component
 
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
 import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.layout.wrapContentWidth
 import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.BarChart
+import androidx.compose.material3.Icon
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
 import io.legado.app.ui.book.readRecord.ReadPeriod
+import io.legado.app.ui.book.readRecord.ReadRecordFormatter
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.adaptiveHorizontalPadding
 import io.legado.app.ui.widget.components.card.GlassCard
@@ -22,7 +42,16 @@ fun ReadingTimeBarChartCard(
     period: ReadPeriod,
     modifier: Modifier = Modifier
 ) {
-    val maxTime = data.maxOfOrNull { it.second }?.coerceAtLeast(1L) ?: 1L
+    val rawMaxTime = data.maxOfOrNull { it.second }?.coerceAtLeast(1L) ?: 1L
+
+    // 向上取整逻辑:根据时长跨度选择合适的对齐单位
+    val roundedMaxTime = when {
+        rawMaxTime < 60_000 -> 60_000L // 不足1分钟取1分钟
+        rawMaxTime < 10 * 60_000 -> ((rawMaxTime + 59_999) / 60_000) * 60_000L // 10分钟内按1分钟对齐
+        rawMaxTime < 60 * 60_000 -> ((rawMaxTime + 5 * 60_000 - 1) / (5 * 60_000)) * 5 * 60_000L // 1小时内按5分钟对齐
+        rawMaxTime < 12 * 3600_000 -> ((rawMaxTime + 3600_000 - 1) / 3600_000) * 3600_000L // 12小时内按1小时对齐
+        else -> ((rawMaxTime + 4 * 3600_000 - 1) / (4 * 3600_000)) * 4 * 3600_000L // 超过12小时按4小时对齐
+    }
 
     GlassCard(
         modifier = modifier
@@ -30,7 +59,16 @@ fun ReadingTimeBarChartCard(
             .adaptiveHorizontalPadding(vertical = 8.dp)
     ) {
         Column(modifier = Modifier.padding(16.dp)) {
-            AppText("阅读时长分布", style = LegadoTheme.typography.titleMedium)
+            Row(verticalAlignment = Alignment.CenterVertically) {
+                Icon(
+                    imageVector = Icons.Default.BarChart,
+                    contentDescription = null,
+                    tint = LegadoTheme.colorScheme.primary,
+                    modifier = Modifier.size(20.dp)
+                )
+                Spacer(modifier = Modifier.width(8.dp))
+                AppText("阅读时长分布", style = LegadoTheme.typography.titleMedium)
+            }
             Spacer(modifier = Modifier.height(16.dp))
 
             Row(
@@ -41,13 +79,14 @@ fun ReadingTimeBarChartCard(
                 // Y-Axis
                 Column(
                     modifier = Modifier
+                        .width(32.dp)
                         .fillMaxHeight()
                         .padding(bottom = 20.dp),
                     verticalArrangement = Arrangement.SpaceBetween,
                     horizontalAlignment = Alignment.End
                 ) {
                     AppText(
-                        text = formatChartDuration(maxTime),
+                        text = ReadRecordFormatter.formatDuration(roundedMaxTime),
                         style = LegadoTheme.typography.labelSmall,
                         fontSize = 8.sp,
                         color = LegadoTheme.colorScheme.onSurfaceVariant
@@ -71,7 +110,12 @@ fun ReadingTimeBarChartCard(
                     horizontalArrangement = Arrangement.SpaceEvenly
                 ) {
                     data.forEachIndexed { index, (date, time) ->
-                        val heightFactor = time.toFloat() / maxTime
+                        val targetHeightFactor = time.toFloat() / roundedMaxTime
+                        val heightFactor by animateFloatAsState(
+                            targetValue = targetHeightFactor,
+                            animationSpec = tween(durationMillis = 320, delayMillis = index * 20),
+                            label = "BarHeight"
+                        )
 
                         val showLabel = when (period) {
                             ReadPeriod.DAY -> true
@@ -98,6 +142,7 @@ fun ReadingTimeBarChartCard(
                             Box(
                                 modifier = Modifier
                                     .weight(1f)
+                                    .widthIn(max = 16.dp)
                                     .fillMaxWidth(),
                                 contentAlignment = Alignment.BottomCenter
                             ) {
@@ -109,7 +154,7 @@ fun ReadingTimeBarChartCard(
                                         .clip(RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp))
                                         .background(
                                             if (time > 0) LegadoTheme.colorScheme.primary
-                                            else LegadoTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
+                                            else LegadoTheme.colorScheme.surfaceVariant
                                         )
                                 )
                             }
@@ -120,7 +165,10 @@ fun ReadingTimeBarChartCard(
                                         text = labelText,
                                         style = LegadoTheme.typography.labelSmall,
                                         fontSize = 8.sp,
-                                        color = LegadoTheme.colorScheme.onSurfaceVariant
+                                        color = LegadoTheme.colorScheme.onSurfaceVariant,
+                                        softWrap = false,
+                                        overflow = TextOverflow.Visible,
+                                        modifier = Modifier.wrapContentWidth(unbounded = true)
                                     )
                                 }
                             }
@@ -131,10 +179,3 @@ fun ReadingTimeBarChartCard(
         }
     }
 }
-
-private fun formatChartDuration(millis: Long): String {
-    val totalMinutes = millis / (1000 * 60)
-    val hours = totalMinutes / 60
-    val minutes = totalMinutes % 60
-    return if (hours > 0) "${hours}h${if (minutes > 0) "${minutes}m" else ""}" else "${minutes}m"
-}

+ 47 - 10
app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordStats.kt

@@ -1,6 +1,22 @@
 package io.legado.app.ui.book.readRecord.component
 
-import androidx.compose.foundation.layout.*
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Equalizer
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
@@ -10,6 +26,7 @@ import androidx.compose.ui.unit.dp
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.adaptiveHorizontalPadding
 import io.legado.app.ui.widget.components.card.GlassCard
+import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.text.AppText
 
 data class StatItem(val label: String, val value: String)
@@ -26,12 +43,24 @@ fun StatsGridCard(
             .adaptiveHorizontalPadding(vertical = 8.dp)
     ) {
         Column(modifier = Modifier.padding(16.dp)) {
-            AppText(title, style = LegadoTheme.typography.titleMedium)
+            Row(verticalAlignment = Alignment.CenterVertically) {
+                AppIcon(
+                    imageVector = Icons.Default.Equalizer,
+                    contentDescription = null,
+                    tint = LegadoTheme.colorScheme.primary,
+                    modifier = Modifier.size(20.dp)
+                )
+                Spacer(modifier = Modifier.width(8.dp))
+                AppText(title, style = LegadoTheme.typography.titleMedium)
+            }
             Spacer(modifier = Modifier.height(16.dp))
 
             Column {
                 for (i in items.indices step 2) {
-                    Row(modifier = Modifier.fillMaxWidth()) {
+                    Row(
+                        modifier = Modifier.fillMaxWidth(),
+                        horizontalArrangement = Arrangement.spacedBy(12.dp)
+                    ) {
                         StatCell(items[i], Modifier.weight(1f))
                         if (i + 1 < items.size) {
                             StatCell(items[i + 1], Modifier.weight(1f))
@@ -55,18 +84,26 @@ private fun StatCell(
         modifier = modifier,
         horizontalAlignment = Alignment.Start
     ) {
-        AppText(
-            text = item.value,
-            style = LegadoTheme.typography.titleLarge,
-            fontWeight = FontWeight.Bold,
-            color = LegadoTheme.colorScheme.primary,
-            textAlign = TextAlign.Start
-        )
         AppText(
             text = item.label,
             style = LegadoTheme.typography.labelSmall,
             color = LegadoTheme.colorScheme.onSurfaceVariant,
             textAlign = TextAlign.Start
         )
+        AnimatedContent(
+            targetState = item.value,
+            transitionSpec = {
+                (fadeIn() + slideInVertically { it / 2 }).togetherWith(fadeOut() + slideOutVertically { -it / 2 })
+            },
+            label = "StatValue"
+        ) { targetValue ->
+            AppText(
+                text = targetValue,
+                style = LegadoTheme.typography.titleLarge,
+                fontWeight = FontWeight.Bold,
+                color = LegadoTheme.colorScheme.primary,
+                textAlign = TextAlign.Start
+            )
+        }
     }
 }

+ 11 - 0
app/src/main/java/io/legado/app/ui/main/MainNavigator.kt

@@ -128,6 +128,17 @@ object MainNavigator {
                 }
             }
 
+            MainRouteRssFavorites,
+            MainRouteRuleSub -> {
+                if (currentRoute == MainRouteHome) {
+                    backStack.add(route)
+                } else {
+                    backStack.clear()
+                    backStack.add(MainRouteHome)
+                    backStack.add(route)
+                }
+            }
+
             MainRouteReadRecord -> {
                 if (currentRoute == MainRouteHome) {
                     backStack.add(route)

+ 57 - 13
app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt

@@ -5,13 +5,16 @@ import androidx.compose.foundation.combinedClickable
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.GridItemSpan
 import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
 import androidx.compose.foundation.lazy.grid.items
 import androidx.compose.foundation.shape.RoundedCornerShape
@@ -51,14 +54,15 @@ import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.widget.components.SourceIcon
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
+import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.divider.PillDivider
 import io.legado.app.ui.widget.components.divider.PillHeaderDivider
+import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.list.ListScaffold
 import io.legado.app.ui.widget.components.menuItem.MenuItemIcon
 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
-import io.legado.app.ui.widget.components.topbar.TopBarActionButton
 import io.legado.app.utils.openUrl
 import io.legado.app.utils.startActivity
 import kotlinx.coroutines.flow.collectLatest
@@ -136,18 +140,6 @@ fun RssScreen(
         onSearchToggle = { viewModel.toggleSearchVisible(it) },
         onSearchQueryChange = { viewModel.search(it) },
         searchPlaceholder = stringResource(R.string.search_rss_source),
-        topBarActions = {
-            TopBarActionButton(
-                onClick = { viewModel.openRuleSub() },
-                imageVector = Icons.Default.Subscriptions,
-                contentDescription = stringResource(R.string.rule_subscription)
-            )
-            TopBarActionButton(
-                onClick = { viewModel.openFavorites() },
-                imageVector = Icons.Default.Star,
-                contentDescription = stringResource(R.string.favorite)
-            )
-        },
         dropDownMenuContent = { dismiss ->
             RoundDropdownMenuItem(
                 onClick = {
@@ -185,6 +177,58 @@ fun RssScreen(
             horizontalArrangement = Arrangement.spacedBy(12.dp),
             verticalArrangement = Arrangement.spacedBy(12.dp)
         ) {
+            item(span = { GridItemSpan(maxLineSpan) }) {
+                Row(
+                    modifier = Modifier.fillMaxWidth(),
+                    horizontalArrangement = Arrangement.spacedBy(12.dp)
+                ) {
+                    GlassCard(
+                        modifier = Modifier.weight(1f),
+                        onClick = { viewModel.openRuleSub() }
+                    ) {
+                        Row(
+                            modifier = Modifier
+                                .fillMaxWidth()
+                                .padding(all = 12.dp),
+                            verticalAlignment = Alignment.CenterVertically,
+                            horizontalArrangement = Arrangement.Center
+                        ) {
+                            AppIcon(
+                                imageVector = Icons.Default.Subscriptions,
+                                contentDescription = null
+                            )
+                            Spacer(modifier = Modifier.width(12.dp))
+                            AppText(
+                                text = stringResource(R.string.rule_subscription),
+                                style = LegadoTheme.typography.labelMediumEmphasized
+                            )
+                        }
+                    }
+                    GlassCard(
+                        modifier = Modifier.weight(1f),
+                        onClick = { viewModel.openFavorites() }
+                    ) {
+                        Row(
+                            modifier = Modifier
+                                .fillMaxWidth()
+                                .padding(all = 12.dp),
+                            verticalAlignment = Alignment.CenterVertically,
+                            horizontalArrangement = Arrangement.Center
+                        ) {
+                            AppIcon(
+                                imageVector = Icons.Default.Star,
+                                contentDescription = null
+                            )
+                            Spacer(modifier = Modifier.width(12.dp))
+                            AppText(
+                                text = stringResource(R.string.favorite),
+                                style = LegadoTheme.typography.labelMediumEmphasized
+                            )
+                        }
+                    }
+                }
+            }
+
             items(uiState.items, key = { it.sourceUrl }) { source ->
                 RssSourceGridItem(
                     modifier = Modifier.animateItem(),

+ 3 - 7
app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubScreen.kt

@@ -4,8 +4,6 @@ import androidx.appcompat.app.AppCompatActivity
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
@@ -86,11 +84,7 @@ fun RuleSubScreen(
             RoundDropdownMenuItem(
                 text = stringResource(R.string.sort),
                 leadingIcon = {
-                    Row(verticalAlignment = Alignment.CenterVertically) {
-                        Icon(Icons.AutoMirrored.Filled.Sort, null, modifier = Modifier.size(18.dp))
-                        Spacer(Modifier.size(12.dp))
-                        AppText(stringResource(R.string.sort))
-                    }
+                    Icon(Icons.AutoMirrored.Filled.Sort, null, modifier = Modifier.size(18.dp))
                 },
                 onClick = {
                     viewModel.resetOrder()
@@ -231,12 +225,14 @@ fun RuleSubEditDialog(
                     value = name,
                     onValueChange = { name = it },
                     label = stringResource(R.string.name),
+                    backgroundColor = LegadoTheme.colorScheme.onSheetContent,
                     modifier = Modifier.fillMaxWidth()
                 )
                 AppTextField(
                     value = url,
                     onValueChange = { url = it },
                     label = "URL",
+                    backgroundColor = LegadoTheme.colorScheme.onSheetContent,
                     modifier = Modifier.fillMaxWidth()
                 )
 

+ 81 - 0
app/src/main/java/io/legado/app/ui/theme/FadingEdge.kt

@@ -0,0 +1,81 @@
+package io.legado.app.ui.theme
+
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.CompositingStrategy
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+/**
+ * Applies a horizontal fading edge effect to a scrollable container.
+ *
+ * @param leftAlpha  0f = fully faded, 1f = fully visible
+ * @param rightAlpha 0f = fully faded, 1f = fully visible
+ * @param gradientWidth width of the fade gradient on each side
+ */
+fun Modifier.fadingEdge(
+    leftAlpha: Float,
+    rightAlpha: Float,
+    gradientWidth: Dp = 24.dp
+): Modifier = graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
+    .drawWithContent {
+        drawContent()
+        val width = size.width
+        val gradientWidthPx = gradientWidth.toPx()
+        val leftStop = gradientWidthPx / width
+        val rightStop = 1f - (gradientWidthPx / width)
+        drawRect(
+            brush = Brush.horizontalGradient(
+                colorStops = arrayOf(
+                    0f to Color.Black.copy(alpha = 1f - leftAlpha),
+                    leftStop to Color.Black,
+                    rightStop to Color.Black,
+                    1f to Color.Black.copy(alpha = 1f - rightAlpha)
+                ),
+                startX = 0f,
+                endX = width
+            ),
+            blendMode = BlendMode.DstIn
+        )
+    }
+
+/**
+ * Convenience overload that derives fade alphas from a [LazyListState].
+ * Left fade appears when scrolled past the start; right fade appears when more content is available.
+ */
+@Composable
+fun Modifier.fadingEdge(
+    listState: LazyListState,
+    gradientWidth: Dp = 24.dp
+): Modifier {
+    val showLeft by remember {
+        derivedStateOf {
+            listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
+        }
+    }
+    val showRight by remember {
+        derivedStateOf { listState.canScrollForward }
+    }
+    val leftAlpha by animateFloatAsState(
+        targetValue = if (showLeft) 1f else 0f,
+        animationSpec = tween(200),
+        label = "LeftFadeAlpha"
+    )
+    val rightAlpha by animateFloatAsState(
+        targetValue = if (showRight) 1f else 0f,
+        animationSpec = tween(200),
+        label = "RightFadeAlpha"
+    )
+    return fadingEdge(leftAlpha, rightAlpha, gradientWidth)
+}

+ 2 - 1
app/src/main/java/io/legado/app/ui/widget/components/button/MediumIconButton.kt

@@ -93,7 +93,8 @@ fun MediumOutlinedIconButton(
         ) {
             Icon(
                 imageVector = imageVector,
-                contentDescription = contentDescription
+                contentDescription = contentDescription,
+                tint = LegadoTheme.colorScheme.onSurface
             )
         }
     }

+ 56 - 25
app/src/main/java/io/legado/app/ui/widget/components/tabRow/AppTabRow.kt

@@ -1,10 +1,9 @@
 package io.legado.app.ui.widget.components.tabRow
 
 import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.PrimaryScrollableTabRow
+import androidx.compose.material3.PrimaryTabRow
 import androidx.compose.material3.Tab
-import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
@@ -12,6 +11,7 @@ import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.ThemeResolver
+import io.legado.app.ui.widget.components.text.AppText
 import top.yukonga.miuix.kmp.basic.TabRowDefaults
 import top.yukonga.miuix.kmp.basic.TabRowWithContour
 
@@ -20,7 +20,8 @@ fun AppTabRow(
     tabTitles: List<String>,
     selectedTabIndex: Int,
     onTabSelected: (Int) -> Unit,
-    modifier: Modifier = Modifier
+    modifier: Modifier = Modifier,
+    isScrollable: Boolean = true
 ) {
     val composeEngine = LegadoTheme.composeEngine
 
@@ -36,29 +37,59 @@ fun AppTabRow(
             )
         )
     } else {
-        PrimaryScrollableTabRow(
-            selectedTabIndex = selectedTabIndex,
-            edgePadding = 0.dp,
-            divider = { },
-            containerColor = Color.Transparent,
-            minTabWidth = 0.dp,
-            modifier = modifier
-        ) {
-            tabTitles.forEachIndexed { index, title ->
-                Tab(
-                    selected = selectedTabIndex == index,
-                    onClick = { onTabSelected(index) },
-                    text = {
-                        Text(
-                            text = title,
-                            maxLines = 1,
-                            overflow = TextOverflow.Ellipsis,
-                            modifier = Modifier.padding(horizontal = 8.dp),
-                            color = MaterialTheme.colorScheme.onSurfaceVariant
-                        )
-                    }
-                )
+        if (isScrollable) {
+            PrimaryScrollableTabRow(
+                selectedTabIndex = selectedTabIndex,
+                edgePadding = 0.dp,
+                divider = { },
+                containerColor = Color.Transparent,
+                minTabWidth = 0.dp,
+                modifier = modifier
+            ) {
+                tabTitles.forEachIndexed { index, title ->
+                    AppTab(
+                        selected = selectedTabIndex == index,
+                        onClick = { onTabSelected(index) },
+                        title = title
+                    )
+                }
+            }
+        } else {
+            PrimaryTabRow(
+                selectedTabIndex = selectedTabIndex,
+                divider = { },
+                containerColor = Color.Transparent,
+                modifier = modifier
+            ) {
+                tabTitles.forEachIndexed { index, title ->
+                    AppTab(
+                        selected = selectedTabIndex == index,
+                        onClick = { onTabSelected(index) },
+                        title = title
+                    )
+                }
             }
         }
     }
+}
+
+@Composable
+private fun AppTab(
+    selected: Boolean,
+    onClick: () -> Unit,
+    title: String
+) {
+    Tab(
+        selected = selected,
+        onClick = onClick,
+        text = {
+            AppText(
+                text = title,
+                maxLines = 1,
+                overflow = TextOverflow.Ellipsis,
+                modifier = Modifier.padding(horizontal = 8.dp),
+                color = if (selected) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.onSurfaceVariant
+            )
+        }
+    )
 }

+ 1 - 1
app/src/main/java/io/legado/app/utils/ConvertExtensions.kt

@@ -90,7 +90,7 @@ object ConvertUtils {
         //计算单位的,原理是利用lg,公式是 lg(1024^n) = nlg(1024),最后 nlg(1024)/lg(1024) = n。
         val digitGroups = (log10(length.toDouble()) / log10(1024.0)).toInt()
         //计算原理是,size/单位值。单位值指的是:比如说b = 1024,KB = 1024^2
-        return DecimalFormat("#,##0.##").format(length / 1024.0.pow(digitGroups.toDouble())) + " " + units[digitGroups]
+        return DecimalFormat("#.##").format(length / 1024.0.pow(digitGroups.toDouble())) + " " + units[digitGroups]
     }
 
     @JvmOverloads