新增阅读记录展览界面
This commit is contained in:
12 files changed
+1990
-50
No files matched your search
+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