Parcourir la source

新增阅读记录展览界面

HapeLee il y a 6 mois
Parent
commit
8a6d01d5d1

+ 145 - 0
app/src/main/java/io/legado/app/domain/usecase/readRecord/GetReadRecordOverviewUseCase.kt

@@ -0,0 +1,145 @@
+package io.legado.app.domain.usecase.readRecord
+
+import io.legado.app.data.entities.readRecord.ReadRecord
+import io.legado.app.data.entities.readRecord.ReadRecordDetail
+import io.legado.app.ui.book.readRecord.ReadBookRanking
+import io.legado.app.ui.book.readRecord.ReadPeriod
+import io.legado.app.ui.book.readRecord.ReadRecordOverviewUiState
+import java.time.DayOfWeek
+import java.time.LocalDate
+import java.time.format.DateTimeFormatter
+import java.time.temporal.TemporalAdjusters
+
+class GetReadRecordOverviewUseCase {
+
+    operator fun invoke(
+        period: ReadPeriod,
+        refDate: LocalDate,
+        details: List<ReadRecordDetail>,
+        latestRecords: List<ReadRecord>
+    ): ReadRecordOverviewUiState {
+        val (startDate, endDate) = getPeriodRange(period, refDate)
+
+        val filteredDetails = if (period == ReadPeriod.ALL) {
+            details
+        } else {
+            details.filter {
+                try {
+                    val d = LocalDate.parse(it.date, DateTimeFormatter.ISO_LOCAL_DATE)
+                    !d.isBefore(startDate) && !d.isAfter(endDate)
+                } catch (e: Exception) {
+                    false
+                }
+            }
+        }
+
+        val totalTime = filteredDetails.sumOf { it.readTime }
+        val totalWords = filteredDetails.sumOf { it.readWords }
+        val readingDays = filteredDetails.map { it.date }.distinct().size
+
+        val periodBooks = filteredDetails.groupBy { it.bookName to it.bookAuthor }
+        val totalBooks = periodBooks.size
+
+        val shelfBooksMap = latestRecords.associateBy { it.bookName to it.bookAuthor }
+        var readingCount = 0
+        periodBooks.keys.forEach { key ->
+            if (shelfBooksMap.containsKey(key)) {
+                readingCount++
+            }
+        }
+
+        val topBooks = periodBooks.map { (key, details) ->
+            ReadBookRanking(
+                bookName = key.first,
+                bookAuthor = key.second,
+                readTime = details.sumOf { it.readTime }
+            )
+        }.sortedByDescending { it.readTime }.take(10)
+
+        val dailyTopBookMap = details.groupBy { it.date }
+            .mapNotNull { (dateStr, dayDetails) ->
+                try {
+                    val date = LocalDate.parse(dateStr, DateTimeFormatter.ISO_LOCAL_DATE)
+                    val top = dayDetails.maxByOrNull { it.readTime }
+                    if (top != null) date to (top.bookName to top.bookAuthor) else null
+                } catch (e: Exception) {
+                    null
+                }
+            }.toMap()
+
+        val dailyTimeData = if (period == ReadPeriod.ALL) {
+            emptyList()
+        } else {
+            val dateToTime = filteredDetails.groupBy { it.date }
+                .mapValues { it.value.sumOf { d -> d.readTime } }
+
+            val daysList = mutableListOf<Pair<LocalDate, Long>>()
+            var curr = startDate
+            while (!curr.isAfter(endDate)) {
+                daysList.add(curr to (dateToTime[curr.format(DateTimeFormatter.ISO_LOCAL_DATE)] ?: 0L))
+                curr = curr.plusDays(1)
+            }
+            daysList
+        }
+
+        val allReadTimesMap = details.groupBy { it.date }
+            .mapKeys {
+                try {
+                    LocalDate.parse(it.key, DateTimeFormatter.ISO_LOCAL_DATE)
+                } catch (e: Exception) {
+                    LocalDate.MIN
+                }
+            }
+            .filterKeys { it != LocalDate.MIN }
+            .mapValues { it.value.sumOf { d -> d.readTime } }
+
+        val allReadCountsMap = details.groupBy { it.date }
+            .mapKeys {
+                try {
+                    LocalDate.parse(it.key, DateTimeFormatter.ISO_LOCAL_DATE)
+                } catch (e: Exception) {
+                    LocalDate.MIN
+                }
+            }
+            .filterKeys { it != LocalDate.MIN }
+            .mapValues { it.value.size }
+
+        return ReadRecordOverviewUiState(
+            period = period,
+            referenceDate = refDate,
+            totalTime = totalTime,
+            readingDays = readingDays,
+            totalBooks = totalBooks,
+            finishedBooks = 0, // Placeholder
+            readingBooks = readingCount,
+            totalWords = totalWords,
+            dailyTimeData = dailyTimeData,
+            topBooks = topBooks,
+            dailyTopBook = dailyTopBookMap,
+            allReadTimes = allReadTimesMap,
+            allReadCounts = allReadCountsMap
+        )
+    }
+
+    private fun getPeriodRange(period: ReadPeriod, refDate: LocalDate): Pair<LocalDate, LocalDate> {
+        return when (period) {
+            ReadPeriod.DAY -> refDate to refDate
+            ReadPeriod.WEEK -> {
+                val start = refDate.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
+                val end = refDate.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY))
+                start to end
+            }
+            ReadPeriod.MONTH -> {
+                val start = refDate.with(TemporalAdjusters.firstDayOfMonth())
+                val end = refDate.with(TemporalAdjusters.lastDayOfMonth())
+                start to end
+            }
+            ReadPeriod.YEAR -> {
+                val start = refDate.with(TemporalAdjusters.firstDayOfYear())
+                val end = refDate.with(TemporalAdjusters.lastDayOfYear())
+                start to end
+            }
+            ReadPeriod.ALL -> LocalDate.MIN to LocalDate.MAX
+        }
+    }
+}

+ 433 - 0
app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt

@@ -0,0 +1,433 @@
+package io.legado.app.help.config
+
+import android.content.Context
+import android.net.Uri
+import com.google.gson.GsonBuilder
+import com.google.gson.JsonObject
+import com.google.gson.JsonParser
+import io.legado.app.ui.config.themeConfig.ThemeConfig
+import io.legado.app.utils.GSON
+import io.legado.app.utils.inputStream
+import io.legado.app.utils.outputStream
+import splitties.init.appCtx
+import java.io.File
+
+/**
+ * 轻量级主题导入导出系统
+ * 将所有主题配置导出为JSON文件,方便分享和备份
+ * 支持保存多个命名主题并在之间切换
+ */
+object ThemeImportExport {
+
+    private const val DIR_NAME = "saved_themes"
+    private val baseDir get() = File(appCtx.filesDir, DIR_NAME)
+
+    private val EXPORT_GSON = GsonBuilder()
+        .setPrettyPrinting()
+        .disableHtmlEscaping()
+        .create()
+
+    private val _savedThemes = mutableListOf<SavedTheme>()
+    val savedThemes: List<SavedTheme> get() = _savedThemes
+
+    init {
+        loadAll()
+    }
+
+    private fun loadAll() {
+        _savedThemes.clear()
+        baseDir.mkdirs()
+        baseDir.listFiles()?.forEach { file ->
+            if (file.isFile && file.extension == "json") {
+                kotlin.runCatching {
+                    val json = file.readText()
+                    val data = EXPORT_GSON.fromJson(json, ThemeExportData::class.java)
+                    val name = file.nameWithoutExtension
+                    _savedThemes.add(SavedTheme(name = name, data = data))
+                }
+            }
+        }
+    }
+
+    fun reload() {
+        loadAll()
+    }
+
+    /**
+     * 保存当前设置为新主题
+     */
+    fun saveCurrentAsTheme(name: String): SavedTheme {
+        val data = exportFromCurrent()
+        val file = File(baseDir, "$name.json")
+        baseDir.mkdirs()
+        file.writeText(EXPORT_GSON.toJson(data))
+        val theme = SavedTheme(name = name, data = data)
+        _savedThemes.removeAll { it.name == name }
+        _savedThemes.add(theme)
+        return theme
+    }
+
+    /**
+     * 应用已保存的主题
+     */
+    fun applySavedTheme(theme: SavedTheme): Boolean {
+        return try {
+            applyToThemeConfig(theme.data)
+            true
+        } catch (e: Exception) {
+            e.printStackTrace()
+            false
+        }
+    }
+
+    /**
+     * 删除已保存的主题
+     */
+    fun deleteSavedTheme(theme: SavedTheme) {
+        val file = File(baseDir, "${theme.name}.json")
+        if (file.exists()) {
+            file.delete()
+        }
+        _savedThemes.remove(theme)
+    }
+
+    /**
+     * 导出已保存的主题到文件
+     */
+    fun exportSavedThemeToFile(context: Context, theme: SavedTheme, uri: Uri): Boolean {
+        return try {
+            val json = EXPORT_GSON.toJson(theme.data)
+            context.contentResolver.openOutputStream(uri)?.use {
+                it.write(json.toByteArray())
+            }
+            true
+        } catch (e: Exception) {
+            e.printStackTrace()
+            false
+        }
+    }
+
+    /**
+     * 从当前配置创建导出数据
+     */
+    fun exportFromCurrent(): ThemeExportData {
+        return ThemeExportData(
+            // 基础主题设置
+            appTheme = ThemeConfig.appTheme,
+            themeMode = ThemeConfig.themeMode,
+            isPureBlack = ThemeConfig.isPureBlack,
+            composeEngine = ThemeConfig.composeEngine,
+            paletteStyle = ThemeConfig.paletteStyle,
+            materialVersion = ThemeConfig.materialVersion,
+            customMode = ThemeConfig.customMode,
+            customContrast = ThemeConfig.customContrast,
+            launcherIcon = ThemeConfig.launcherIcon,
+            isPredictiveBackEnabled = ThemeConfig.isPredictiveBackEnabled,
+            fontScale = ThemeConfig.fontScale,
+
+            // 深度个性化颜色
+            enableDeepPersonalization = ThemeConfig.enableDeepPersonalization,
+            cPrimary = ThemeConfig.cPrimary,
+            cNPrimary = ThemeConfig.cNPrimary,
+            themeColor = ThemeConfig.themeColor,
+            secondaryThemeColor = ThemeConfig.secondaryThemeColor,
+            primaryTextColor = ThemeConfig.primaryTextColor,
+            secondaryTextColor = ThemeConfig.secondaryTextColor,
+            themeBackgroundColor = ThemeConfig.themeBackgroundColor,
+            labelContainerColor = ThemeConfig.labelContainerColor,
+            bookInfoInputColor = ThemeConfig.bookInfoInputColor,
+
+            // 容器设置
+            containerOpacity = ThemeConfig.containerOpacity,
+            enableContainerBorder = ThemeConfig.enableContainerBorder,
+            containerBorderWidth = ThemeConfig.containerBorderWidth,
+            containerBorderStyle = ThemeConfig.containerBorderStyle,
+            containerBorderColor = ThemeConfig.containerBorderColor,
+            containerBorderDashWidth = ThemeConfig.containerBorderDashWidth,
+
+            // 分割线设置
+            enableItemDivider = ThemeConfig.enableItemDivider,
+            itemDividerWidth = ThemeConfig.itemDividerWidth,
+            itemDividerLength = ThemeConfig.itemDividerLength,
+            itemDividerColor = ThemeConfig.itemDividerColor,
+
+            // 模糊设置
+            enableBlur = ThemeConfig.enableBlur,
+            enableProgressiveBlur = ThemeConfig.enableProgressiveBlur,
+            topBarBlurRadius = ThemeConfig.topBarBlurRadius,
+            bottomBarBlurRadius = ThemeConfig.bottomBarBlurRadius,
+            topBarBlurAlpha = ThemeConfig.topBarBlurAlpha,
+            bottomBarBlurAlpha = ThemeConfig.bottomBarBlurAlpha,
+            bottomBarLensRadius = ThemeConfig.bottomBarLensRadius,
+
+            // 透明度设置
+            topBarOpacity = ThemeConfig.topBarOpacity,
+            bottomBarOpacity = ThemeConfig.bottomBarOpacity,
+
+            // 标签颜色
+            enableCustomTagColors = ThemeConfig.enableCustomTagColors,
+            customTagColorsJson = ThemeConfig.customTagColorsJson,
+
+            // 主界面设置
+            showDiscovery = ThemeConfig.showDiscovery,
+            showRss = ThemeConfig.showRss,
+            showStatusBar = ThemeConfig.showStatusBar,
+            swipeAnimation = ThemeConfig.swipeAnimation,
+            showBottomView = ThemeConfig.showBottomView,
+            useFloatingBottomBar = ThemeConfig.useFloatingBottomBar,
+            useFloatingBottomBarLiquidGlass = ThemeConfig.useFloatingBottomBarLiquidGlass,
+            tabletInterface = ThemeConfig.tabletInterface,
+            labelVisibilityMode = ThemeConfig.labelVisibilityMode,
+            defaultHomePage = ThemeConfig.defaultHomePage,
+
+            // 导航栏图标
+            navIconBookshelf = ThemeConfig.navIconBookshelf,
+            navIconExplore = ThemeConfig.navIconExplore,
+            navIconRss = ThemeConfig.navIconRss,
+            navIconMy = ThemeConfig.navIconMy,
+
+            // Miuix 设置
+            useMiuixMonet = ThemeConfig.useMiuixMonet,
+
+            // 其他
+            useFlexibleTopAppBar = ThemeConfig.useFlexibleTopAppBar,
+            bgImageBlurring = ThemeConfig.bgImageBlurring,
+            bgImageNBlurring = ThemeConfig.bgImageNBlurring
+        )
+    }
+
+    /**
+     * 将导出数据应用到当前配置
+     */
+    fun applyToThemeConfig(data: ThemeExportData) {
+        // 基础主题设置
+        ThemeConfig.appTheme = data.appTheme
+        ThemeConfig.themeMode = data.themeMode
+        ThemeConfig.isPureBlack = data.isPureBlack
+        ThemeConfig.composeEngine = data.composeEngine
+        ThemeConfig.paletteStyle = data.paletteStyle
+        ThemeConfig.materialVersion = data.materialVersion
+        ThemeConfig.customMode = data.customMode
+        ThemeConfig.customContrast = data.customContrast
+        ThemeConfig.launcherIcon = data.launcherIcon
+        ThemeConfig.isPredictiveBackEnabled = data.isPredictiveBackEnabled
+        ThemeConfig.fontScale = data.fontScale
+
+        // 深度个性化颜色
+        ThemeConfig.enableDeepPersonalization = data.enableDeepPersonalization
+        ThemeConfig.cPrimary = data.cPrimary
+        ThemeConfig.cNPrimary = data.cNPrimary
+        ThemeConfig.themeColor = data.themeColor
+        ThemeConfig.secondaryThemeColor = data.secondaryThemeColor
+        ThemeConfig.primaryTextColor = data.primaryTextColor
+        ThemeConfig.secondaryTextColor = data.secondaryTextColor
+        ThemeConfig.themeBackgroundColor = data.themeBackgroundColor
+        ThemeConfig.labelContainerColor = data.labelContainerColor
+        ThemeConfig.bookInfoInputColor = data.bookInfoInputColor
+
+        // 容器设置
+        ThemeConfig.containerOpacity = data.containerOpacity
+        ThemeConfig.enableContainerBorder = data.enableContainerBorder
+        ThemeConfig.containerBorderWidth = data.containerBorderWidth
+        ThemeConfig.containerBorderStyle = data.containerBorderStyle
+        ThemeConfig.containerBorderColor = data.containerBorderColor
+        ThemeConfig.containerBorderDashWidth = data.containerBorderDashWidth
+
+        // 分割线设置
+        ThemeConfig.enableItemDivider = data.enableItemDivider
+        ThemeConfig.itemDividerWidth = data.itemDividerWidth
+        ThemeConfig.itemDividerLength = data.itemDividerLength
+        ThemeConfig.itemDividerColor = data.itemDividerColor
+
+        // 模糊设置
+        ThemeConfig.enableBlur = data.enableBlur
+        ThemeConfig.enableProgressiveBlur = data.enableProgressiveBlur
+        ThemeConfig.topBarBlurRadius = data.topBarBlurRadius
+        ThemeConfig.bottomBarBlurRadius = data.bottomBarBlurRadius
+        ThemeConfig.topBarBlurAlpha = data.topBarBlurAlpha
+        ThemeConfig.bottomBarBlurAlpha = data.bottomBarBlurAlpha
+        ThemeConfig.bottomBarLensRadius = data.bottomBarLensRadius
+
+        // 透明度设置
+        ThemeConfig.topBarOpacity = data.topBarOpacity
+        ThemeConfig.bottomBarOpacity = data.bottomBarOpacity
+
+        // 标签颜色
+        ThemeConfig.enableCustomTagColors = data.enableCustomTagColors
+        ThemeConfig.customTagColorsJson = data.customTagColorsJson
+
+        // 主界面设置
+        ThemeConfig.showDiscovery = data.showDiscovery
+        ThemeConfig.showRss = data.showRss
+        ThemeConfig.showStatusBar = data.showStatusBar
+        ThemeConfig.swipeAnimation = data.swipeAnimation
+        ThemeConfig.showBottomView = data.showBottomView
+        ThemeConfig.useFloatingBottomBar = data.useFloatingBottomBar
+        ThemeConfig.useFloatingBottomBarLiquidGlass = data.useFloatingBottomBarLiquidGlass
+        ThemeConfig.tabletInterface = data.tabletInterface
+        ThemeConfig.labelVisibilityMode = data.labelVisibilityMode
+        ThemeConfig.defaultHomePage = data.defaultHomePage
+
+        // 导航栏图标
+        ThemeConfig.navIconBookshelf = data.navIconBookshelf
+        ThemeConfig.navIconExplore = data.navIconExplore
+        ThemeConfig.navIconRss = data.navIconRss
+        ThemeConfig.navIconMy = data.navIconMy
+
+        // Miuix 设置
+        ThemeConfig.useMiuixMonet = data.useMiuixMonet
+
+        // 其他
+        ThemeConfig.useFlexibleTopAppBar = data.useFlexibleTopAppBar
+        ThemeConfig.bgImageBlurring = data.bgImageBlurring
+        ThemeConfig.bgImageNBlurring = data.bgImageNBlurring
+    }
+
+    /**
+     * 导出主题到JSON字符串
+     */
+    fun exportToJson(): String {
+        val data = exportFromCurrent()
+        return EXPORT_GSON.toJson(data)
+    }
+
+    /**
+     * 从JSON字符串导入主题
+     */
+    fun importFromJson(json: String): Boolean {
+        return try {
+            val data = GSON.fromJson(json, ThemeExportData::class.java)
+            applyToThemeConfig(data)
+            true
+        } catch (e: Exception) {
+            e.printStackTrace()
+            false
+        }
+    }
+
+    /**
+     * 从文件URI导入主题
+     */
+    fun importFromUri(context: Context, uri: Uri): Boolean {
+        return try {
+            val json = context.contentResolver.openInputStream(uri)?.use {
+                it.bufferedReader().readText()
+            } ?: return false
+            importFromJson(json)
+        } catch (e: Exception) {
+            e.printStackTrace()
+            false
+        }
+    }
+
+    /**
+     * 导出主题到文件
+     */
+    fun exportToFile(context: Context, uri: Uri): Boolean {
+        return try {
+            val json = exportToJson()
+            context.contentResolver.openOutputStream(uri)?.use {
+                it.write(json.toByteArray())
+            }
+            true
+        } catch (e: Exception) {
+            e.printStackTrace()
+            false
+        }
+    }
+}
+
+/**
+ * 主题导出数据类
+ */
+data class ThemeExportData(
+    // 基础主题设置
+    val appTheme: String = "0",
+    val themeMode: String = "0",
+    val isPureBlack: Boolean = false,
+    val composeEngine: String = "material",
+    val paletteStyle: String = "tonalSpot",
+    val materialVersion: String = "material3",
+    val customMode: String? = "tonalSpot",
+    val customContrast: String = "Default",
+    val launcherIcon: String = "ic_launcher",
+    val isPredictiveBackEnabled: Boolean = true,
+    val fontScale: Int = 10,
+
+    // 深度个性化颜色
+    val enableDeepPersonalization: Boolean = false,
+    val cPrimary: Int = 0,
+    val cNPrimary: Int = 0,
+    val themeColor: Int = 0,
+    val secondaryThemeColor: Int = 0,
+    val primaryTextColor: Int = 0,
+    val secondaryTextColor: Int = 0,
+    val themeBackgroundColor: Int = 0,
+    val labelContainerColor: Int = 0,
+    val bookInfoInputColor: Int = 0,
+
+    // 容器设置
+    val containerOpacity: Int = 100,
+    val enableContainerBorder: Boolean = false,
+    val containerBorderWidth: Float = 1f,
+    val containerBorderStyle: String = "solid",
+    val containerBorderColor: Int = 0,
+    val containerBorderDashWidth: Float = 4f,
+
+    // 分割线设置
+    val enableItemDivider: Boolean = false,
+    val itemDividerWidth: Float = 1f,
+    val itemDividerLength: Float = 80f,
+    val itemDividerColor: Int = 0,
+
+    // 模糊设置
+    val enableBlur: Boolean = false,
+    val enableProgressiveBlur: Boolean = false,
+    val topBarBlurRadius: Int = 24,
+    val bottomBarBlurRadius: Int = 8,
+    val topBarBlurAlpha: Int = 73,
+    val bottomBarBlurAlpha: Int = 40,
+    val bottomBarLensRadius: Float = 24f,
+
+    // 透明度设置
+    val topBarOpacity: Int = 100,
+    val bottomBarOpacity: Int = 100,
+
+    // 标签颜色
+    val enableCustomTagColors: Boolean = false,
+    val customTagColorsJson: String? = null,
+
+    // 主界面设置
+    val showDiscovery: Boolean = true,
+    val showRss: Boolean = true,
+    val showStatusBar: Boolean = true,
+    val swipeAnimation: Boolean = true,
+    val showBottomView: Boolean = true,
+    val useFloatingBottomBar: Boolean = false,
+    val useFloatingBottomBarLiquidGlass: Boolean = false,
+    val tabletInterface: String = "auto",
+    val labelVisibilityMode: String = "auto",
+    val defaultHomePage: String = "bookshelf",
+
+    // 导航栏图标
+    val navIconBookshelf: String = "",
+    val navIconExplore: String = "",
+    val navIconRss: String = "",
+    val navIconMy: String = "",
+
+    // Miuix 设置
+    val useMiuixMonet: Boolean = false,
+
+    // 其他
+    val useFlexibleTopAppBar: Boolean = true,
+    val bgImageBlurring: Int = 0,
+    val bgImageNBlurring: Int = 0
+)
+
+/**
+ * 已保存的主题
+ */
+data class SavedTheme(
+    val name: String,
+    val data: ThemeExportData
+)

+ 0 - 50
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordActivity.kt

@@ -1,50 +0,0 @@
-package io.legado.app.ui.book.readRecord
-
-import android.os.Bundle
-import androidx.compose.runtime.Composable
-import androidx.lifecycle.lifecycleScope
-import io.legado.app.base.BaseComposeActivity
-import io.legado.app.data.appDb
-import io.legado.app.data.entities.readRecord.ReadRecordSession
-import io.legado.app.ui.book.search.SearchActivity
-import io.legado.app.ui.theme.AppTheme
-import io.legado.app.utils.startActivity
-import io.legado.app.utils.startActivityForBook
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-
-data class TimelineItem(
-    val session: ReadRecordSession,
-    val showHeader: Boolean
-)
-
-class ReadRecordActivity : BaseComposeActivity() {
-
-    @Composable
-    override fun Content() {
-        AppTheme {
-            ReadRecordScreen(
-                onBackClick = { finish() },
-                onBookClick = { bookName, bookAuthor ->
-                    lifecycleScope.launch {
-                        val book = withContext(Dispatchers.IO) {
-                            appDb.bookDao.getBook(bookName, bookAuthor)
-                        }
-                        if (book != null) startActivityForBook(book)
-                        else {
-                            startActivity<SearchActivity> {
-                                putExtra("key", bookName)
-                            }
-                        }
-                    }
-                }
-            )
-        }
-    }
-
-    override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
-    }
-
-}

+ 15 - 0
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordFormatter.kt

@@ -0,0 +1,15 @@
+package io.legado.app.ui.book.readRecord
+
+import io.legado.app.utils.formatReadDuration
+
+object ReadRecordFormatter {
+    fun formatWords(words: Long): String {
+        return if (words >= 10000) {
+            String.format("%.1f万字", words / 10000f)
+        } else {
+            "${words}字"
+        }
+    }
+
+    fun formatDuration(millis: Long): String = formatReadDuration(millis)
+}

+ 372 - 0
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewScreen.kt

@@ -0,0 +1,372 @@
+package io.legado.app.ui.book.readRecord
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+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.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
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import io.legado.app.ui.book.readRecord.component.ReadingTimeBarChartCard
+import io.legado.app.ui.book.readRecord.component.StatItem
+import io.legado.app.ui.book.readRecord.component.StatsGridCard
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.adaptiveHorizontalPadding
+import io.legado.app.ui.widget.components.AppScaffold
+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.Cover
+import io.legado.app.ui.widget.components.heatmap.HeatmapMode
+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.ui.widget.components.topbar.TopBarNavigationButton
+import org.koin.androidx.compose.koinViewModel
+import java.time.LocalDate
+import java.time.YearMonth
+import java.time.format.DateTimeFormatter
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ReadRecordOverviewScreen(
+    viewModel: ReadRecordOverviewViewModel = koinViewModel(),
+    onBackClick: () -> Unit,
+    onBookClick: (String, String) -> Unit
+) {
+    val state by viewModel.uiState.collectAsStateWithLifecycle()
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        topBar = {
+            GlassMediumFlexibleTopAppBar(
+                title = "阅读总览",
+                scrollBehavior = scrollBehavior,
+                navigationIcon = {
+                    TopBarNavigationButton(onClick = onBackClick)
+                }
+            )
+        }
+    ) { padding ->
+        Column(
+            modifier = Modifier
+                .fillMaxSize()
+                .padding(padding)
+        ) {
+            PeriodSelector(
+                selectedPeriod = state.period,
+                onPeriodSelected = { viewModel.setPeriod(it) }
+            )
+
+            DateNavigator(
+                period = state.period,
+                referenceDate = state.referenceDate,
+                onPrevClick = { viewModel.prevDate() },
+                onNextClick = { viewModel.nextDate() }
+            )
+
+            LazyColumn(
+                modifier = Modifier.fillMaxSize(),
+                contentPadding = PaddingValues(bottom = 16.dp)
+            ) {
+                item {
+                    HeatmapCard(state)
+                }
+
+                item {
+                    val stats = listOf(
+                        StatItem("阅读时间", ReadRecordFormatter.formatDuration(state.totalTime)),
+                        StatItem("阅读天数", "${state.readingDays}天"),
+                        StatItem("累计读过", "${state.totalBooks}本"),
+                        StatItem("读完书籍", "${state.finishedBooks}本"),
+                        StatItem("在读书籍", "${state.readingBooks}本"),
+                        StatItem("阅读字数", ReadRecordFormatter.formatWords(state.totalWords))
+                    )
+                    StatsGridCard(title = "阅读数据", items = stats)
+                }
+
+                if (state.period != ReadPeriod.ALL && state.dailyTimeData.isNotEmpty()) {
+                    item {
+                        ReadingTimeBarChartCard(data = state.dailyTimeData, period = state.period)
+                    }
+                }
+
+                if (state.topBooks.isNotEmpty()) {
+                    item {
+                        TopReadingListCard(state.topBooks, viewModel, onBookClick)
+                    }
+                }
+
+                item {
+                    ReadingCalendarCard(state, viewModel)
+                }
+            }
+        }
+    }
+}
+
+@Composable
+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 "总"
+    )
+
+    PrimaryTabRow(
+        selectedTabIndex = periods.indexOfFirst { it.first == selectedPeriod },
+        containerColor = Color.Transparent,
+        divider = {}
+    ) {
+        periods.forEach { (period, label) ->
+            Tab(
+                selected = selectedPeriod == period,
+                onClick = { onPeriodSelected(period) },
+                text = { AppText(label) }
+            )
+        }
+    }
+}
+
+@Composable
+fun DateNavigator(
+    period: ReadPeriod,
+    referenceDate: LocalDate,
+    onPrevClick: () -> Unit,
+    onNextClick: () -> Unit
+) {
+    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,
+        verticalAlignment = Alignment.CenterVertically
+    ) {
+        MediumIconButton(
+            onClick = onPrevClick,
+            imageVector = Icons.AutoMirrored.Filled.ArrowLeft
+        )
+        AppText(
+            text = dateText,
+            style = LegadoTheme.typography.titleMedium,
+            modifier = Modifier.padding(horizontal = 16.dp)
+        )
+        MediumIconButton(
+            onClick = onNextClick,
+            imageVector = Icons.AutoMirrored.Filled.ArrowRight
+        )
+    }
+}
+
+@Composable
+fun HeatmapCard(state: ReadRecordOverviewUiState) {
+    GlassCard(
+        modifier = Modifier
+            .fillMaxWidth()
+            .adaptiveHorizontalPadding(vertical = 8.dp)
+    ) {
+        Column(modifier = Modifier.padding(16.dp)) {
+            AppText("阅读热力图", style = LegadoTheme.typography.titleMedium)
+            Spacer(modifier = Modifier.height(8.dp))
+            HeatmapCalendarSection(
+                dailyReadCounts = state.allReadCounts,
+                dailyReadTimes = state.allReadTimes,
+                currentMode = HeatmapMode.TIME,
+                selectedDate = null,
+                onDateSelected = {}
+            )
+        }
+    }
+}
+
+@Composable
+fun TopReadingListCard(
+    topBooks: List<ReadBookRanking>,
+    viewModel: ReadRecordOverviewViewModel,
+    onBookClick: (String, String) -> Unit
+) {
+    GlassCard(
+        modifier = Modifier
+            .fillMaxWidth()
+            .adaptiveHorizontalPadding(vertical = 8.dp)
+    ) {
+        Column(modifier = Modifier.padding(16.dp)) {
+            AppText("阅读时长榜", style = LegadoTheme.typography.titleMedium)
+            Spacer(modifier = Modifier.height(8.dp))
+            
+            topBooks.forEachIndexed { index, book ->
+                var coverPath by remember { mutableStateOf<String?>(null) }
+                LaunchedEffect(book.bookName, book.bookAuthor) {
+                    coverPath = viewModel.getBookCover(book.bookName, book.bookAuthor)
+                }
+
+                Row(
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .clickable { onBookClick(book.bookName, book.bookAuthor) }
+                        .padding(vertical = 8.dp),
+                    verticalAlignment = Alignment.CenterVertically
+                ) {
+                    AppText(
+                        text = "${index + 1}",
+                        style = LegadoTheme.typography.titleMedium,
+                        modifier = Modifier.width(32.dp),
+                        textAlign = TextAlign.Center,
+                        color = if (index < 3) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.onSurfaceVariant
+                    )
+                    Cover(path = coverPath, modifier = Modifier.size(40.dp, 56.dp))
+                    Spacer(modifier = Modifier.width(12.dp))
+                    Column(modifier = Modifier.weight(1f)) {
+                        AppText(
+                            text = book.bookName,
+                            style = LegadoTheme.typography.bodyMedium,
+                            fontWeight = FontWeight.Bold,
+                            maxLines = 1,
+                            overflow = TextOverflow.Ellipsis
+                        )
+                        AppText(
+                            text = book.bookAuthor,
+                            style = LegadoTheme.typography.labelSmall,
+                            color = LegadoTheme.colorScheme.onSurfaceVariant
+                        )
+                    }
+                    AppText(
+                        text = ReadRecordFormatter.formatDuration(book.readTime),
+                        style = LegadoTheme.typography.bodySmall,
+                        color = LegadoTheme.colorScheme.primary
+                    )
+                }
+            }
+        }
+    }
+}
+
+@Composable
+fun ReadingCalendarCard(
+    state: ReadRecordOverviewUiState,
+    viewModel: ReadRecordOverviewViewModel
+) {
+    val currentMonth = YearMonth.from(state.referenceDate)
+    val daysInMonth = currentMonth.lengthOfMonth()
+    val firstDayOfMonth = currentMonth.atDay(1)
+    val firstDayOfWeek = firstDayOfMonth.dayOfWeek.value % 7 // 0 for Sunday
+
+    GlassCard(
+        modifier = Modifier
+            .fillMaxWidth()
+            .adaptiveHorizontalPadding(vertical = 8.dp)
+    ) {
+        Column(modifier = Modifier.padding(16.dp)) {
+            AppText("读书日历", style = LegadoTheme.typography.titleMedium)
+            Spacer(modifier = Modifier.height(16.dp))
+            
+            Row(modifier = Modifier.fillMaxWidth()) {
+                val days = listOf("日", "一", "二", "三", "四", "五", "六")
+                days.forEach { day ->
+                    AppText(
+                        text = day,
+                        modifier = Modifier.weight(1f),
+                        textAlign = TextAlign.Center,
+                        style = LegadoTheme.typography.labelSmall
+                    )
+                }
+            }
+            
+            Spacer(modifier = Modifier.height(8.dp))
+
+            val totalCells = ((daysInMonth + firstDayOfWeek + 6) / 7) * 7
+            for (i in 0 until totalCells step 7) {
+                Row(modifier = Modifier.fillMaxWidth()) {
+                    for (j in 0 until 7) {
+                        val cellIndex = i + j
+                        val dayOfMonth = cellIndex - firstDayOfWeek + 1
+                        
+                        Box(
+                            modifier = Modifier
+                                .weight(1f)
+                                .aspectRatio(0.75f)
+                                .padding(2.dp),
+                            contentAlignment = Alignment.Center
+                        ) {
+                            if (dayOfMonth in 1..daysInMonth) {
+                                val date = currentMonth.atDay(dayOfMonth)
+                                CalendarDayCell(date, state, viewModel)
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+@Composable
+fun CalendarDayCell(
+    date: LocalDate,
+    state: ReadRecordOverviewUiState,
+    viewModel: ReadRecordOverviewViewModel
+) {
+    val topBook = state.dailyTopBook[date]
+    var coverPath by remember { mutableStateOf<String?>(null) }
+    
+    LaunchedEffect(topBook) {
+        topBook?.let { (name, author) ->
+            coverPath = viewModel.getBookCover(name, author)
+        }
+    }
+    
+    Box(
+        modifier = Modifier
+            .fillMaxSize()
+            .clip(RoundedCornerShape(4.dp))
+            .background(LegadoTheme.colorScheme.surfaceVariant),
+        contentAlignment = Alignment.Center
+    ) {
+        if (coverPath != null) {
+            Cover(
+                path = coverPath,
+                modifier = Modifier.fillMaxSize().alpha(0.4f)
+            )
+        }
+        
+        AppText(
+            text = date.dayOfMonth.toString(),
+            style = LegadoTheme.typography.bodySmall,
+            fontWeight = if (topBook != null) FontWeight.Bold else FontWeight.Normal,
+            color = if (topBook != null) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.onSurface
+        )
+    }
+}

+ 91 - 0
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewViewModel.kt

@@ -0,0 +1,91 @@
+package io.legado.app.ui.book.readRecord
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import io.legado.app.data.repository.BookRepository
+import io.legado.app.data.repository.ReadRecordRepository
+import io.legado.app.domain.usecase.readRecord.GetReadRecordOverviewUseCase
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import java.time.LocalDate
+
+data class ReadRecordOverviewUiState(
+    val period: ReadPeriod = ReadPeriod.DAY,
+    val referenceDate: LocalDate = LocalDate.now(),
+    val totalTime: Long = 0,
+    val readingDays: Int = 0,
+    val totalBooks: Int = 0,
+    val finishedBooks: Int = 0,
+    val readingBooks: Int = 0,
+    val totalWords: Long = 0,
+    val dailyTimeData: List<Pair<LocalDate, Long>> = emptyList(),
+    val topBooks: List<ReadBookRanking> = emptyList(),
+    val dailyTopBook: Map<LocalDate, Pair<String, String>> = emptyMap(),
+    val allReadTimes: Map<LocalDate, Long> = emptyMap(),
+    val allReadCounts: Map<LocalDate, Int> = emptyMap()
+)
+
+data class ReadBookRanking(
+    val bookName: String,
+    val bookAuthor: String,
+    val readTime: Long,
+    var coverPath: String? = null
+)
+
+enum class ReadPeriod {
+    DAY, WEEK, MONTH, YEAR, ALL
+}
+
+class ReadRecordOverviewViewModel(
+    private val repository: ReadRecordRepository,
+    private val bookRepository: BookRepository,
+    private val getReadRecordOverviewUseCase: GetReadRecordOverviewUseCase
+) : ViewModel() {
+
+    private val _period = MutableStateFlow(ReadPeriod.DAY)
+    private val _referenceDate = MutableStateFlow(LocalDate.now())
+
+    val uiState: StateFlow<ReadRecordOverviewUiState> = combine(
+        _period,
+        _referenceDate,
+        repository.getAllRecordDetails(""),
+        repository.getLatestReadRecords("")
+    ) { period, refDate, details, latestRecords ->
+        getReadRecordOverviewUseCase(period, refDate, details, latestRecords)
+    }.stateIn(
+        scope = viewModelScope,
+        started = SharingStarted.WhileSubscribed(5000),
+        initialValue = ReadRecordOverviewUiState()
+    )
+
+    fun setPeriod(period: ReadPeriod) {
+        _period.value = period
+    }
+
+    fun nextDate() {
+        val current = _referenceDate.value
+        _referenceDate.value = when (_period.value) {
+            ReadPeriod.DAY -> current.plusDays(1)
+            ReadPeriod.WEEK -> current.plusWeeks(1)
+            ReadPeriod.MONTH -> current.plusMonths(1)
+            ReadPeriod.YEAR -> current.plusYears(1)
+            ReadPeriod.ALL -> current
+        }
+    }
+
+    fun prevDate() {
+        val current = _referenceDate.value
+        _referenceDate.value = when (_period.value) {
+            ReadPeriod.DAY -> current.minusDays(1)
+            ReadPeriod.WEEK -> current.minusWeeks(1)
+            ReadPeriod.MONTH -> current.minusMonths(1)
+            ReadPeriod.YEAR -> current.minusYears(1)
+            ReadPeriod.ALL -> current
+        }
+    }
+
+    suspend fun getBookCover(name: String, author: String) = bookRepository.getBookCoverByNameAndAuthor(name, author)
+}

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

@@ -0,0 +1,128 @@
+package io.legado.app.ui.book.readRecord.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+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.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.text.AppText
+import io.legado.app.utils.formatReadDuration
+import java.time.LocalDate
+
+@Composable
+fun ReadingTimeBarChartCard(
+    data: List<Pair<LocalDate, Long>>,
+    period: ReadPeriod,
+    modifier: Modifier = Modifier
+) {
+    val maxTime = data.maxOfOrNull { it.second }?.coerceAtLeast(1L) ?: 1L
+
+    GlassCard(
+        modifier = modifier
+            .fillMaxWidth()
+            .adaptiveHorizontalPadding(vertical = 8.dp)
+    ) {
+        Column(modifier = Modifier.padding(16.dp)) {
+            AppText("阅读时长分布", style = LegadoTheme.typography.titleMedium)
+            Spacer(modifier = Modifier.height(16.dp))
+
+            Row(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .height(140.dp)
+            ) {
+                // Y-Axis
+                Column(
+                    modifier = Modifier
+                        .fillMaxHeight()
+                        .padding(bottom = 20.dp),
+                    verticalArrangement = Arrangement.SpaceBetween,
+                    horizontalAlignment = Alignment.End
+                ) {
+                    AppText(
+                        text = formatReadDuration(maxTime),
+                        style = LegadoTheme.typography.labelSmall,
+                        fontSize = 8.sp,
+                        color = LegadoTheme.colorScheme.onSurfaceVariant
+                    )
+                    AppText(
+                        text = "0",
+                        style = LegadoTheme.typography.labelSmall,
+                        fontSize = 8.sp,
+                        color = LegadoTheme.colorScheme.onSurfaceVariant
+                    )
+                }
+
+                Spacer(modifier = Modifier.width(8.dp))
+
+                // Chart
+                Row(
+                    modifier = Modifier
+                        .weight(1f)
+                        .fillMaxHeight(),
+                    verticalAlignment = Alignment.Bottom,
+                    horizontalArrangement = Arrangement.SpaceEvenly
+                ) {
+                    data.forEachIndexed { index, (date, time) ->
+                        val heightFactor = time.toFloat() / maxTime
+
+                        val showLabel = when (period) {
+                            ReadPeriod.DAY -> true
+                            ReadPeriod.WEEK -> date.dayOfWeek.value in listOf(2, 4, 6)
+                            ReadPeriod.MONTH -> date.dayOfMonth == 1 || date.dayOfMonth % 7 == 0 || index == data.lastIndex
+                            ReadPeriod.YEAR -> date.dayOfMonth == 1
+                            else -> false
+                        }
+
+                        val labelText = when (period) {
+                            ReadPeriod.YEAR -> "${date.monthValue}月"
+                            ReadPeriod.WEEK -> when (date.dayOfWeek.value) {
+                                2 -> "二"; 4 -> "四"; 6 -> "六"; else -> ""
+                            }
+                            else -> date.dayOfMonth.toString()
+                        }
+
+                        Column(
+                            modifier = Modifier
+                                .weight(1f)
+                                .fillMaxHeight(),
+                            verticalArrangement = Arrangement.Bottom,
+                            horizontalAlignment = Alignment.CenterHorizontally
+                        ) {
+                            Box(
+                                modifier = Modifier
+                                    .fillMaxWidth(0.6f)
+                                    .fillMaxHeight(heightFactor.coerceAtLeast(0.01f))
+                                    .padding(bottom = 4.dp)
+                                    .clip(RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp))
+                                    .background(
+                                        if (time > 0) LegadoTheme.colorScheme.primary
+                                        else LegadoTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
+                                    )
+                            )
+
+                            Box(modifier = Modifier.height(20.dp), contentAlignment = Alignment.TopCenter) {
+                                if (showLabel) {
+                                    AppText(
+                                        text = labelText,
+                                        style = LegadoTheme.typography.labelSmall,
+                                        fontSize = 8.sp,
+                                        color = LegadoTheme.colorScheme.onSurfaceVariant
+                                    )
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+}

+ 85 - 0
app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordHeatmap.kt

@@ -0,0 +1,85 @@
+package io.legado.app.ui.book.readRecord.component
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.heatmap.*
+import io.legado.app.ui.widget.components.text.AppText
+import java.time.LocalDate
+
+@Composable
+fun HeatmapCalendarSection(
+    modifier: Modifier = Modifier,
+    dailyReadCounts: Map<LocalDate, Int>,
+    dailyReadTimes: Map<LocalDate, Long>,
+    currentMode: HeatmapMode,
+    selectedDate: LocalDate?,
+    onDateSelected: (LocalDate) -> Unit,
+    config: HeatmapConfig = HeatmapConfig()
+) {
+    val (startDate, endDate) = rememberDateRange(dailyReadCounts, dailyReadTimes)
+    val days = rememberDaysInRange(startDate, endDate)
+    val weeks = rememberWeeks(days, startDate)
+
+    Column(
+        modifier = modifier.fillMaxWidth(),
+        horizontalAlignment = Alignment.Start
+    ) {
+        Row(
+            modifier = Modifier.fillMaxWidth(),
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            AppText(
+                text = HEATMAP_CALENDAR_TITLE,
+                style = LegadoTheme.typography.titleSmall,
+                color = LegadoTheme.colorScheme.onSurfaceVariant
+            )
+            Spacer(modifier = Modifier.weight(1f))
+            HeatmapLegend(mode = currentMode, config = config)
+        }
+
+        Spacer(modifier = Modifier.height(12.dp))
+
+        Row(modifier = Modifier.fillMaxWidth()) {
+            WeekdayLabelsColumn(
+                cellSize = config.cellSize,
+                cellSpacing = config.cellSpacing
+            )
+            Spacer(modifier = Modifier.width(4.dp))
+            LazyRow(
+                modifier = Modifier.weight(1f),
+                horizontalArrangement = Arrangement.spacedBy(config.cellSpacing),
+                reverseLayout = true
+            ) {
+                item {
+                    HeatmapCalendarEndAction(
+                        onClearDate = { onDateSelected(LocalDate.now()) }
+                    )
+                }
+                items(weeks) { week ->
+                    HeatmapWeekColumn(
+                        week = week,
+                        mode = currentMode,
+                        dailyReadCounts = dailyReadCounts,
+                        dailyReadTimes = dailyReadTimes,
+                        selectedDate = selectedDate,
+                        config = config,
+                        onDateSelected = onDateSelected
+                    )
+                }
+                item {
+                    HeatmapCalendarStartAction(
+                        currentMode = currentMode,
+                        onModeChanged = {}
+                    )
+                }
+                item { NoEarlierDataIndicator(cellSize = config.cellSize) }
+            }
+        }
+    }
+}

+ 191 - 0
app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordListItems.kt

@@ -0,0 +1,191 @@
+package io.legado.app.ui.book.readRecord.component
+
+import androidx.compose.foundation.basicMarquee
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import io.legado.app.data.entities.readRecord.ReadRecord
+import io.legado.app.data.entities.readRecord.ReadRecordDetail
+import io.legado.app.ui.book.readRecord.ReadRecordFormatter
+import io.legado.app.ui.book.readRecord.ReadRecordViewModel
+import io.legado.app.ui.book.readRecord.TimelineItem
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.cover.Cover
+import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.utils.StringUtils.formatFriendlyDate
+
+@Composable
+fun LatestReadItem(
+    record: ReadRecord,
+    viewModel: ReadRecordViewModel,
+    onClick: () -> Unit,
+    modifier: Modifier = Modifier
+) {
+    var coverPath by remember { mutableStateOf<String?>(null) }
+    LaunchedEffect(record.bookName, record.bookAuthor) {
+        coverPath = viewModel.getBookCover(record.bookName, record.bookAuthor)
+    }
+
+    Row(
+        modifier = modifier
+            .fillMaxWidth()
+            .clickable { onClick() }
+            .padding(16.dp),
+        verticalAlignment = Alignment.CenterVertically
+    ) {
+        Cover(path = coverPath, modifier = Modifier.size(48.dp, 64.dp))
+        Spacer(modifier = Modifier.width(16.dp))
+        Column(modifier = Modifier.weight(1f)) {
+            AppText(
+                text = record.bookName,
+                style = LegadoTheme.typography.titleMedium,
+                fontWeight = FontWeight.Bold,
+                maxLines = 1,
+                modifier = Modifier.basicMarquee()
+            )
+            AppText(
+                text = record.bookAuthor,
+                style = LegadoTheme.typography.labelSmall,
+                color = LegadoTheme.colorScheme.onSurfaceVariant
+            )
+            AppText(
+                text = "最后阅读: ${formatFriendlyDate(record.lastRead.toString())}",
+                style = LegadoTheme.typography.labelSmall,
+                color = LegadoTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
+            )
+        }
+        AppText(
+            text = ReadRecordFormatter.formatDuration(record.readTime),
+            style = LegadoTheme.typography.bodyMedium,
+            color = LegadoTheme.colorScheme.primary,
+            fontWeight = FontWeight.SemiBold
+        )
+    }
+}
+
+@Composable
+fun TimelineSessionItem(
+    item: TimelineItem,
+    viewModel: ReadRecordViewModel,
+    onBookClick: (String, String) -> Unit
+) {
+    val session = item.session
+    var coverPath by remember { mutableStateOf<String?>(null) }
+
+    LaunchedEffect(session.bookName, session.bookAuthor) {
+        coverPath = viewModel.getBookCover(session.bookName, session.bookAuthor)
+    }
+
+    Row(
+        modifier = Modifier
+            .fillMaxWidth()
+            .clickable { onBookClick(session.bookName, session.bookAuthor) }
+            .padding(horizontal = 16.dp, vertical = 12.dp),
+        verticalAlignment = Alignment.CenterVertically
+    ) {
+        Cover(path = coverPath, modifier = Modifier.size(40.dp, 56.dp))
+        Spacer(modifier = Modifier.width(12.dp))
+        Column(modifier = Modifier.weight(1f)) {
+            AppText(
+                text = session.bookName,
+                style = LegadoTheme.typography.bodyMedium,
+                fontWeight = FontWeight.Bold,
+                maxLines = 1,
+                overflow = TextOverflow.Ellipsis
+            )
+            AppText(
+                text = buildAnnotatedString {
+                    append(formatFriendlyDate(session.startTime.toString()))
+                    append(" · ")
+                    append(ReadRecordFormatter.formatDuration(session.endTime - session.startTime))
+                },
+                style = LegadoTheme.typography.labelSmall,
+                color = LegadoTheme.colorScheme.onSurfaceVariant
+            )
+        }
+    }
+}
+
+@Composable
+fun ReadRecordItem(
+    detail: ReadRecordDetail,
+    viewModel: ReadRecordViewModel,
+    onClick: () -> Unit,
+    modifier: Modifier = Modifier
+) {
+    var coverPath by remember { mutableStateOf<String?>(null) }
+    LaunchedEffect(detail.bookName, detail.bookAuthor) {
+        coverPath = viewModel.getBookCover(detail.bookName, detail.bookAuthor)
+    }
+
+    Row(
+        modifier = modifier
+            .fillMaxWidth()
+            .clickable { onClick() }
+            .padding(16.dp),
+        verticalAlignment = Alignment.CenterVertically
+    ) {
+        Cover(path = coverPath, modifier = Modifier.size(40.dp, 56.dp))
+        Spacer(modifier = Modifier.width(16.dp))
+        Column(modifier = Modifier.weight(1f)) {
+            AppText(
+                text = detail.bookName,
+                style = LegadoTheme.typography.bodyLarge,
+                fontWeight = FontWeight.Bold,
+                maxLines = 1,
+                overflow = TextOverflow.Ellipsis
+            )
+            AppText(
+                text = buildAnnotatedString {
+                    append(detail.bookAuthor)
+                    if (detail.readWords > 0) {
+                        append(" · ")
+                        append(ReadRecordFormatter.formatWords(detail.readWords))
+                    }
+                },
+                style = LegadoTheme.typography.labelSmall,
+                color = LegadoTheme.colorScheme.onSurfaceVariant
+            )
+        }
+        AppText(
+            text = ReadRecordFormatter.formatDuration(detail.readTime),
+            style = LegadoTheme.typography.bodyMedium,
+            color = LegadoTheme.colorScheme.primary
+        )
+    }
+}
+
+@Composable
+fun DateHeader(
+    date: String,
+    totalTimeMillis: Long? = null,
+    modifier: Modifier = Modifier
+) {
+    Row(
+        modifier = modifier
+            .fillMaxWidth()
+            .padding(horizontal = 16.dp, vertical = 8.dp),
+        verticalAlignment = Alignment.CenterVertically,
+        horizontalArrangement = Arrangement.SpaceBetween
+    ) {
+        AppText(
+            text = date,
+            style = LegadoTheme.typography.titleSmall,
+            color = LegadoTheme.colorScheme.primary,
+            fontWeight = FontWeight.Bold
+        )
+        if (totalTimeMillis != null) {
+            AppText(
+                text = ReadRecordFormatter.formatDuration(totalTimeMillis),
+                style = LegadoTheme.typography.labelSmall,
+                color = LegadoTheme.colorScheme.onSurfaceVariant
+            )
+        }
+    }
+}

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

@@ -0,0 +1,72 @@
+package io.legado.app.ui.book.readRecord.component
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+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.text.AppText
+
+data class StatItem(val label: String, val value: String)
+
+@Composable
+fun StatsGridCard(
+    title: String,
+    items: List<StatItem>,
+    modifier: Modifier = Modifier
+) {
+    GlassCard(
+        modifier = modifier
+            .fillMaxWidth()
+            .adaptiveHorizontalPadding(vertical = 8.dp)
+    ) {
+        Column(modifier = Modifier.padding(16.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()) {
+                        StatCell(items[i], Modifier.weight(1f))
+                        if (i + 1 < items.size) {
+                            StatCell(items[i + 1], Modifier.weight(1f))
+                        } else {
+                            Spacer(modifier = Modifier.weight(1f))
+                        }
+                    }
+                    if (i + 2 < items.size) Spacer(modifier = Modifier.height(16.dp))
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun StatCell(
+    item: StatItem,
+    modifier: Modifier = Modifier
+) {
+    Column(
+        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
+        )
+    }
+}

+ 118 - 0
app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordSummary.kt

@@ -0,0 +1,118 @@
+package io.legado.app.ui.book.readRecord.component
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import io.legado.app.ui.book.readRecord.ReadRecordFormatter
+import io.legado.app.ui.book.readRecord.ReadRecordUiState
+import io.legado.app.ui.book.readRecord.ReadRecordViewModel
+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.cover.Cover
+import io.legado.app.ui.widget.components.text.AppText
+import java.time.format.DateTimeFormatter
+
+@Composable
+fun SummarySection(
+    state: ReadRecordUiState,
+    viewModel: ReadRecordViewModel,
+    onSummaryClick: () -> Unit
+) {
+    val selectedDate = state.selectedDate
+
+    if (selectedDate != null) {
+        val dateKey = selectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE)
+        val dailyDetails = state.groupedRecords[dateKey] ?: emptyList()
+
+        if (dailyDetails.isNotEmpty()) {
+            val distinctBooks = dailyDetails.map { it.bookName to it.bookAuthor }.distinct()
+            val dailyTime = dailyDetails.sumOf { it.readTime }
+
+            ReadingSummaryCard(
+                title = selectedDate.format(DateTimeFormatter.ofPattern("M月d日阅读概览")),
+                bookCount = distinctBooks.size,
+                totalTimeMillis = dailyTime,
+                bookNamesForCover = distinctBooks.take(3),
+                viewModel = viewModel,
+                onClick = onSummaryClick
+            )
+        }
+    } else {
+        val allBooksCount = state.latestRecords.size
+        val totalTime = state.totalReadTime
+
+        if (allBooksCount > 0) {
+            ReadingSummaryCard(
+                title = "累计阅读成就",
+                bookCount = allBooksCount,
+                totalTimeMillis = totalTime,
+                bookNamesForCover = state.latestRecords.take(5).map { it.bookName to it.bookAuthor },
+                viewModel = viewModel,
+                onClick = onSummaryClick
+            )
+        }
+    }
+}
+
+@Composable
+fun ReadingSummaryCard(
+    title: String,
+    bookCount: Int,
+    totalTimeMillis: Long,
+    bookNamesForCover: List<Pair<String, String>>,
+    viewModel: ReadRecordViewModel,
+    onClick: () -> Unit
+) {
+    GlassCard(
+        modifier = Modifier
+            .fillMaxWidth()
+            .adaptiveHorizontalPadding(vertical = 8.dp),
+        onClick = onClick
+    ) {
+        Row(
+            modifier = Modifier.padding(16.dp),
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            Column(modifier = Modifier.weight(1f)) {
+                AppText(title, style = LegadoTheme.typography.titleMedium)
+                Spacer(modifier = Modifier.height(4.dp))
+                AppText(
+                    text = "共阅读 $bookCount 本书,时长 ${ReadRecordFormatter.formatDuration(totalTimeMillis)}",
+                    style = LegadoTheme.typography.bodyMedium,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant
+                )
+            }
+            
+            BookStackView(
+                bookNamesForCover = bookNamesForCover,
+                viewModel = viewModel
+            )
+        }
+    }
+}
+
+@Composable
+fun BookStackView(
+    bookNamesForCover: List<Pair<String, String>>,
+    viewModel: ReadRecordViewModel
+) {
+    Box(contentAlignment = Alignment.CenterEnd) {
+        bookNamesForCover.reversed().forEachIndexed { index, (name, author) ->
+            var coverPath by remember { mutableStateOf<String?>(null) }
+            LaunchedEffect(name, author) {
+                coverPath = viewModel.getBookCover(name, author)
+            }
+            
+            Cover(
+                path = coverPath,
+                modifier = Modifier
+                    .padding(end = (index * 12).dp)
+                    .size(32.dp, 44.dp)
+            )
+        }
+    }
+}

+ 340 - 0
app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageScreen.kt

@@ -0,0 +1,340 @@
+package io.legado.app.ui.config.themeManage
+
+import android.os.Handler
+import android.os.Looper
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.background
+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
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+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.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.help.config.SavedTheme
+import io.legado.app.help.config.ThemeImportExport
+import io.legado.app.ui.theme.adaptiveContentPadding
+import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.AppTextField
+import io.legado.app.ui.widget.components.SplicedColumnGroup
+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.settingItem.ClickableSettingItem
+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.ui.widget.components.topbar.TopBarNavigationButton
+import io.legado.app.utils.restart
+import io.legado.app.utils.toastOnUi
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ThemeManageScreen(
+    onBackClick: () -> Unit
+) {
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+    val context = LocalContext.current
+
+    var showSaveDialog by remember { mutableStateOf(false) }
+    var newThemeName by remember { mutableStateOf("") }
+    var deleteTarget by remember { mutableStateOf<SavedTheme?>(null) }
+    var applyTarget by remember { mutableStateOf<SavedTheme?>(null) }
+    var exportTarget by remember { mutableStateOf<SavedTheme?>(null) }
+    var showRestartDialog by remember { mutableStateOf(false) }
+    var savedThemesVersion by remember { mutableIntStateOf(0) }
+    val savedThemes = remember(savedThemesVersion) { ThemeImportExport.savedThemes.toList() }
+
+    val exportLauncher = rememberLauncherForActivityResult(
+        ActivityResultContracts.CreateDocument("application/json")
+    ) { uri ->
+        uri?.let {
+            val target = exportTarget
+            if (target != null) {
+                exportTarget = null
+                if (ThemeImportExport.exportSavedThemeToFile(context, target, it)) {
+                    context.toastOnUi("主题导出成功")
+                } else {
+                    context.toastOnUi("主题导出失败")
+                }
+            } else if (ThemeImportExport.exportToFile(context, it)) {
+                context.toastOnUi("主题导出成功")
+            } else {
+                context.toastOnUi("主题导出失败")
+            }
+        }
+    }
+
+    val importLauncher = rememberLauncherForActivityResult(
+        ActivityResultContracts.OpenDocument()
+    ) { uri ->
+        uri?.let {
+            if (ThemeImportExport.importFromUri(context, it)) {
+                context.toastOnUi("主题导入成功,部分设置需要重启生效")
+                showRestartDialog = true
+            } else {
+                context.toastOnUi("主题导入失败")
+            }
+        }
+    }
+
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        topBar = {
+            GlassMediumFlexibleTopAppBar(
+                title = "主题管理",
+                scrollBehavior = scrollBehavior,
+                navigationIcon = {
+                    TopBarNavigationButton(onClick = onBackClick)
+                }
+            )
+        }
+    ) { paddingValues ->
+        LazyColumn(
+            modifier = Modifier.fillMaxSize(),
+            contentPadding = adaptiveContentPadding(
+                top = paddingValues.calculateTopPadding(),
+                bottom = 120.dp
+            )
+        ) {
+            item {
+                SplicedColumnGroup {
+                    ClickableSettingItem(
+                        title = "保存当前设置",
+                        description = "保存当前主题配置为新主题",
+                        onClick = {
+                            newThemeName = ""
+                            showSaveDialog = true
+                        }
+                    )
+                    ClickableSettingItem(
+                        title = "导出当前主题",
+                        description = "将当前主题配置导出为JSON文件",
+                        onClick = {
+                            exportTarget = null
+                            exportLauncher.launch("legado_theme_${System.currentTimeMillis()}.json")
+                        }
+                    )
+                    ClickableSettingItem(
+                        title = "导入主题配置",
+                        description = "从JSON文件导入主题配置",
+                        onClick = {
+                            importLauncher.launch(arrayOf("application/json"))
+                        }
+                    )
+                }
+            }
+
+            if (savedThemes.isNotEmpty()) {
+                item {
+                    AppText(
+                        text = "已保存的主题",
+                        style = MaterialTheme.typography.titleSmall,
+                        color = MaterialTheme.colorScheme.onSurfaceVariant,
+                        modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 8.dp)
+                    )
+                }
+
+                items(savedThemes, key = { it.name }) { theme ->
+                    SavedThemeItem(
+                        theme = theme,
+                        onApply = { applyTarget = theme },
+                        onExport = {
+                            exportTarget = theme
+                            exportLauncher.launch("${theme.name}.json")
+                        },
+                        onDelete = { deleteTarget = theme }
+                    )
+                }
+            }
+        }
+    }
+
+    // Restart dialog
+    AppAlertDialog(
+        show = showRestartDialog,
+        onDismissRequest = { showRestartDialog = false },
+        title = stringResource(R.string.restart_required_message),
+        onConfirm = {
+            showRestartDialog = false
+            Handler(Looper.getMainLooper()).postDelayed({
+                context.restart()
+            }, 100)
+        },
+        confirmText = stringResource(R.string.ok),
+        onDismiss = {
+            showRestartDialog = false
+            context.toastOnUi(R.string.restart_later_message)
+        },
+        dismissText = stringResource(R.string.cancel)
+    )
+
+    // Save theme dialog
+    AppAlertDialog(
+        show = showSaveDialog,
+        onDismissRequest = { showSaveDialog = false },
+        title = "保存主题",
+        confirmText = "保存",
+        onConfirm = {
+            if (newThemeName.isNotBlank()) {
+                ThemeImportExport.saveCurrentAsTheme(newThemeName)
+                savedThemesVersion++
+                showSaveDialog = false
+            }
+        },
+        dismissText = "取消",
+        onDismiss = { showSaveDialog = false },
+        content = {
+            AppTextField(
+                value = newThemeName,
+                onValueChange = { newThemeName = it },
+                placeholder = { AppText(text = "请输入主题名称") },
+                singleLine = true,
+                modifier = Modifier.fillMaxWidth()
+            )
+        }
+    )
+
+    // Apply theme dialog
+    AppAlertDialog(
+        show = applyTarget != null,
+        onDismissRequest = { applyTarget = null },
+        title = "应用主题",
+        confirmText = "应用",
+        onConfirm = {
+            applyTarget?.let { theme ->
+                ThemeImportExport.applySavedTheme(theme)
+                showRestartDialog = true
+            }
+            applyTarget = null
+        },
+        dismissText = "取消",
+        onDismiss = { applyTarget = null },
+        text = "确定应用主题「${applyTarget?.name}」?应用后需要重启才能完全生效。"
+    )
+
+    // Delete theme dialog
+    AppAlertDialog(
+        show = deleteTarget != null,
+        onDismissRequest = { deleteTarget = null },
+        title = "删除主题",
+        confirmText = "删除",
+        onConfirm = {
+            deleteTarget?.let { theme ->
+                ThemeImportExport.deleteSavedTheme(theme)
+                savedThemesVersion++
+            }
+            deleteTarget = null
+        },
+        dismissText = "取消",
+        onDismiss = { deleteTarget = null },
+        text = "确定删除主题「${deleteTarget?.name}」?此操作不可恢复。"
+    )
+}
+
+@Composable
+private fun SavedThemeItem(
+    theme: SavedTheme,
+    onApply: () -> Unit,
+    onExport: () -> Unit,
+    onDelete: () -> Unit
+) {
+    GlassCard(
+        onClick = onApply,
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(horizontal = 16.dp, vertical = 4.dp),
+        cornerRadius = 20.dp
+    ) {
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 12.dp),
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            val previewColor = if (theme.data.themeColor != 0) {
+                Color(theme.data.themeColor)
+            } else if (theme.data.cPrimary != 0) {
+                Color(theme.data.cPrimary)
+            } else {
+                MaterialTheme.colorScheme.primary
+            }
+
+            Box(
+                modifier = Modifier
+                    .size(40.dp)
+                    .clip(RoundedCornerShape(12.dp))
+                    .background(previewColor)
+            )
+
+            Spacer(modifier = Modifier.width(12.dp))
+
+            Column(
+                modifier = Modifier.weight(1f)
+            ) {
+                AppText(
+                    text = theme.name,
+                    style = MaterialTheme.typography.bodyLarge
+                )
+                val features = mutableListOf<String>()
+                features.add(
+                    when (theme.data.appTheme) {
+                        "0" -> "动态取色"
+                        "12" -> "自定义颜色"
+                        else -> "预设主题"
+                    }
+                )
+                if (theme.data.enableBlur) features.add("模糊效果")
+                if (theme.data.useFloatingBottomBar) features.add("浮动底栏")
+                AppText(
+                    text = features.joinToString(" · "),
+                    style = MaterialTheme.typography.bodySmall,
+                    color = MaterialTheme.colorScheme.onSurfaceVariant
+                )
+            }
+
+            IconButton(onClick = onExport) {
+                Icon(
+                    imageVector = Icons.Default.Share,
+                    contentDescription = "导出",
+                    modifier = Modifier.size(20.dp)
+                )
+            }
+            IconButton(onClick = onDelete) {
+                Icon(
+                    imageVector = Icons.Default.Delete,
+                    contentDescription = "删除",
+                    modifier = Modifier.size(20.dp),
+                    tint = MaterialTheme.colorScheme.error
+                )
+            }
+        }
+    }
+}