新增阅读记录展览界面

This commit is contained in:
HapeLee
2026-05-12 17:49:48 +08:00
parent 0308a9d396
commit 8a6d01d5d1
12 changed files with 1990 additions and 50 deletions
@@ -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
)
}
}
}
}