新增阅读记录展览界面
This commit is contained in:
+145
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user