diff --git a/app/src/main/java/io/legado/app/domain/usecase/readRecord/GetReadRecordOverviewUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/readRecord/GetReadRecordOverviewUseCase.kt new file mode 100644 index 000000000..38887fc5a --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/usecase/readRecord/GetReadRecordOverviewUseCase.kt @@ -0,0 +1,145 @@ +package io.legado.app.domain.usecase.readRecord + +import io.legado.app.data.entities.readRecord.ReadRecord +import io.legado.app.data.entities.readRecord.ReadRecordDetail +import io.legado.app.ui.book.readRecord.ReadBookRanking +import io.legado.app.ui.book.readRecord.ReadPeriod +import io.legado.app.ui.book.readRecord.ReadRecordOverviewUiState +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.temporal.TemporalAdjusters + +class GetReadRecordOverviewUseCase { + + operator fun invoke( + period: ReadPeriod, + refDate: LocalDate, + details: List, + latestRecords: List + ): 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>() + 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 { + 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 + } + } +} diff --git a/app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt b/app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt new file mode 100644 index 000000000..625a70ef2 --- /dev/null +++ b/app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt @@ -0,0 +1,433 @@ +package io.legado.app.help.config + +import android.content.Context +import android.net.Uri +import com.google.gson.GsonBuilder +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import io.legado.app.ui.config.themeConfig.ThemeConfig +import io.legado.app.utils.GSON +import io.legado.app.utils.inputStream +import io.legado.app.utils.outputStream +import splitties.init.appCtx +import java.io.File + +/** + * 轻量级主题导入导出系统 + * 将所有主题配置导出为JSON文件,方便分享和备份 + * 支持保存多个命名主题并在之间切换 + */ +object ThemeImportExport { + + private const val DIR_NAME = "saved_themes" + private val baseDir get() = File(appCtx.filesDir, DIR_NAME) + + private val EXPORT_GSON = GsonBuilder() + .setPrettyPrinting() + .disableHtmlEscaping() + .create() + + private val _savedThemes = mutableListOf() + val savedThemes: List 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 +) diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordActivity.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordActivity.kt deleted file mode 100644 index 34ed3c536..000000000 --- a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordActivity.kt +++ /dev/null @@ -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 { - putExtra("key", bookName) - } - } - } - } - ) - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - } - -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordFormatter.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordFormatter.kt new file mode 100644 index 000000000..584a0d324 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordFormatter.kt @@ -0,0 +1,15 @@ +package io.legado.app.ui.book.readRecord + +import io.legado.app.utils.formatReadDuration + +object ReadRecordFormatter { + fun formatWords(words: Long): String { + return if (words >= 10000) { + String.format("%.1f万字", words / 10000f) + } else { + "${words}字" + } + } + + fun formatDuration(millis: Long): String = formatReadDuration(millis) +} diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewScreen.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewScreen.kt new file mode 100644 index 000000000..35851c44f --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewScreen.kt @@ -0,0 +1,372 @@ +package io.legado.app.ui.book.readRecord + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowLeft +import androidx.compose.material.icons.automirrored.filled.ArrowRight +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.legado.app.ui.book.readRecord.component.ReadingTimeBarChartCard +import io.legado.app.ui.book.readRecord.component.StatItem +import io.legado.app.ui.book.readRecord.component.StatsGridCard +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.theme.adaptiveHorizontalPadding +import io.legado.app.ui.widget.components.AppScaffold +import io.legado.app.ui.widget.components.button.MediumIconButton +import io.legado.app.ui.widget.components.card.GlassCard +import io.legado.app.ui.widget.components.cover.Cover +import io.legado.app.ui.widget.components.heatmap.HeatmapMode +import io.legado.app.ui.widget.components.text.AppText +import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar +import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults +import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton +import org.koin.androidx.compose.koinViewModel +import java.time.LocalDate +import java.time.YearMonth +import java.time.format.DateTimeFormatter + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReadRecordOverviewScreen( + viewModel: ReadRecordOverviewViewModel = koinViewModel(), + onBackClick: () -> Unit, + onBookClick: (String, String) -> Unit +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior() + + AppScaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + GlassMediumFlexibleTopAppBar( + title = "阅读总览", + scrollBehavior = scrollBehavior, + navigationIcon = { + TopBarNavigationButton(onClick = onBackClick) + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + PeriodSelector( + selectedPeriod = state.period, + onPeriodSelected = { viewModel.setPeriod(it) } + ) + + DateNavigator( + period = state.period, + referenceDate = state.referenceDate, + onPrevClick = { viewModel.prevDate() }, + onNextClick = { viewModel.nextDate() } + ) + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 16.dp) + ) { + item { + HeatmapCard(state) + } + + item { + val stats = listOf( + StatItem("阅读时间", ReadRecordFormatter.formatDuration(state.totalTime)), + StatItem("阅读天数", "${state.readingDays}天"), + StatItem("累计读过", "${state.totalBooks}本"), + StatItem("读完书籍", "${state.finishedBooks}本"), + StatItem("在读书籍", "${state.readingBooks}本"), + StatItem("阅读字数", ReadRecordFormatter.formatWords(state.totalWords)) + ) + StatsGridCard(title = "阅读数据", items = stats) + } + + if (state.period != ReadPeriod.ALL && state.dailyTimeData.isNotEmpty()) { + item { + ReadingTimeBarChartCard(data = state.dailyTimeData, period = state.period) + } + } + + if (state.topBooks.isNotEmpty()) { + item { + TopReadingListCard(state.topBooks, viewModel, onBookClick) + } + } + + item { + ReadingCalendarCard(state, viewModel) + } + } + } + } +} + +@Composable +fun PeriodSelector( + selectedPeriod: ReadPeriod, + onPeriodSelected: (ReadPeriod) -> Unit +) { + val periods = listOf( + ReadPeriod.DAY to "日", + ReadPeriod.WEEK to "周", + ReadPeriod.MONTH to "月", + ReadPeriod.YEAR to "年", + ReadPeriod.ALL to "总" + ) + + PrimaryTabRow( + selectedTabIndex = periods.indexOfFirst { it.first == selectedPeriod }, + containerColor = Color.Transparent, + divider = {} + ) { + periods.forEach { (period, label) -> + Tab( + selected = selectedPeriod == period, + onClick = { onPeriodSelected(period) }, + text = { AppText(label) } + ) + } + } +} + +@Composable +fun DateNavigator( + period: ReadPeriod, + referenceDate: LocalDate, + onPrevClick: () -> Unit, + onNextClick: () -> Unit +) { + if (period == ReadPeriod.ALL) return + + val dateText = when (period) { + ReadPeriod.DAY -> referenceDate.format(DateTimeFormatter.ofPattern("yyyy年M月d日")) + ReadPeriod.WEEK -> { + val start = referenceDate.with(java.time.DayOfWeek.MONDAY) + val end = referenceDate.with(java.time.DayOfWeek.SUNDAY) + "${start.format(DateTimeFormatter.ofPattern("M.d"))} - ${end.format(DateTimeFormatter.ofPattern("M.d"))}" + } + ReadPeriod.MONTH -> referenceDate.format(DateTimeFormatter.ofPattern("yyyy年M月")) + ReadPeriod.YEAR -> referenceDate.format(DateTimeFormatter.ofPattern("yyyy年")) + ReadPeriod.ALL -> "" + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + MediumIconButton( + onClick = onPrevClick, + imageVector = Icons.AutoMirrored.Filled.ArrowLeft + ) + AppText( + text = dateText, + style = LegadoTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp) + ) + MediumIconButton( + onClick = onNextClick, + imageVector = Icons.AutoMirrored.Filled.ArrowRight + ) + } +} + +@Composable +fun HeatmapCard(state: ReadRecordOverviewUiState) { + GlassCard( + modifier = Modifier + .fillMaxWidth() + .adaptiveHorizontalPadding(vertical = 8.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + AppText("阅读热力图", style = LegadoTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + HeatmapCalendarSection( + dailyReadCounts = state.allReadCounts, + dailyReadTimes = state.allReadTimes, + currentMode = HeatmapMode.TIME, + selectedDate = null, + onDateSelected = {} + ) + } + } +} + +@Composable +fun TopReadingListCard( + topBooks: List, + 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(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(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 + ) + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewViewModel.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewViewModel.kt new file mode 100644 index 000000000..3afaffa82 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewViewModel.kt @@ -0,0 +1,91 @@ +package io.legado.app.ui.book.readRecord + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.legado.app.data.repository.BookRepository +import io.legado.app.data.repository.ReadRecordRepository +import io.legado.app.domain.usecase.readRecord.GetReadRecordOverviewUseCase +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import java.time.LocalDate + +data class ReadRecordOverviewUiState( + val period: ReadPeriod = ReadPeriod.DAY, + val referenceDate: LocalDate = LocalDate.now(), + val totalTime: Long = 0, + val readingDays: Int = 0, + val totalBooks: Int = 0, + val finishedBooks: Int = 0, + val readingBooks: Int = 0, + val totalWords: Long = 0, + val dailyTimeData: List> = emptyList(), + val topBooks: List = emptyList(), + val dailyTopBook: Map> = emptyMap(), + val allReadTimes: Map = emptyMap(), + val allReadCounts: Map = 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 = 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) +} diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordCharts.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordCharts.kt new file mode 100644 index 000000000..3d3aff51f --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordCharts.kt @@ -0,0 +1,128 @@ +package io.legado.app.ui.book.readRecord.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.legado.app.ui.book.readRecord.ReadPeriod +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.theme.adaptiveHorizontalPadding +import io.legado.app.ui.widget.components.card.GlassCard +import io.legado.app.ui.widget.components.text.AppText +import io.legado.app.utils.formatReadDuration +import java.time.LocalDate + +@Composable +fun ReadingTimeBarChartCard( + data: List>, + 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 + ) + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordHeatmap.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordHeatmap.kt new file mode 100644 index 000000000..b701099e6 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordHeatmap.kt @@ -0,0 +1,85 @@ +package io.legado.app.ui.book.readRecord.component + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.widget.components.heatmap.* +import io.legado.app.ui.widget.components.text.AppText +import java.time.LocalDate + +@Composable +fun HeatmapCalendarSection( + modifier: Modifier = Modifier, + dailyReadCounts: Map, + dailyReadTimes: Map, + 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) } + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordListItems.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordListItems.kt new file mode 100644 index 000000000..d74034d74 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordListItems.kt @@ -0,0 +1,191 @@ +package io.legado.app.ui.book.readRecord.component + +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.legado.app.data.entities.readRecord.ReadRecord +import io.legado.app.data.entities.readRecord.ReadRecordDetail +import io.legado.app.ui.book.readRecord.ReadRecordFormatter +import io.legado.app.ui.book.readRecord.ReadRecordViewModel +import io.legado.app.ui.book.readRecord.TimelineItem +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.widget.components.cover.Cover +import io.legado.app.ui.widget.components.text.AppText +import io.legado.app.utils.StringUtils.formatFriendlyDate + +@Composable +fun LatestReadItem( + record: ReadRecord, + viewModel: ReadRecordViewModel, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + var coverPath by remember { mutableStateOf(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(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(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 + ) + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordStats.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordStats.kt new file mode 100644 index 000000000..1e78d07f9 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordStats.kt @@ -0,0 +1,72 @@ +package io.legado.app.ui.book.readRecord.component + +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.theme.adaptiveHorizontalPadding +import io.legado.app.ui.widget.components.card.GlassCard +import io.legado.app.ui.widget.components.text.AppText + +data class StatItem(val label: String, val value: String) + +@Composable +fun StatsGridCard( + title: String, + items: List, + 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 + ) + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordSummary.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordSummary.kt new file mode 100644 index 000000000..df5696b33 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/component/ReadRecordSummary.kt @@ -0,0 +1,118 @@ +package io.legado.app.ui.book.readRecord.component + +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.legado.app.ui.book.readRecord.ReadRecordFormatter +import io.legado.app.ui.book.readRecord.ReadRecordUiState +import io.legado.app.ui.book.readRecord.ReadRecordViewModel +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.theme.adaptiveHorizontalPadding +import io.legado.app.ui.widget.components.card.GlassCard +import io.legado.app.ui.widget.components.cover.Cover +import io.legado.app.ui.widget.components.text.AppText +import java.time.format.DateTimeFormatter + +@Composable +fun SummarySection( + state: ReadRecordUiState, + viewModel: ReadRecordViewModel, + onSummaryClick: () -> Unit +) { + val selectedDate = state.selectedDate + + if (selectedDate != null) { + val dateKey = selectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE) + val dailyDetails = state.groupedRecords[dateKey] ?: emptyList() + + if (dailyDetails.isNotEmpty()) { + val distinctBooks = dailyDetails.map { it.bookName to it.bookAuthor }.distinct() + val dailyTime = dailyDetails.sumOf { it.readTime } + + ReadingSummaryCard( + title = selectedDate.format(DateTimeFormatter.ofPattern("M月d日阅读概览")), + bookCount = distinctBooks.size, + totalTimeMillis = dailyTime, + bookNamesForCover = distinctBooks.take(3), + viewModel = viewModel, + onClick = onSummaryClick + ) + } + } else { + val allBooksCount = state.latestRecords.size + val totalTime = state.totalReadTime + + if (allBooksCount > 0) { + ReadingSummaryCard( + title = "累计阅读成就", + bookCount = allBooksCount, + totalTimeMillis = totalTime, + bookNamesForCover = state.latestRecords.take(5).map { it.bookName to it.bookAuthor }, + viewModel = viewModel, + onClick = onSummaryClick + ) + } + } +} + +@Composable +fun ReadingSummaryCard( + title: String, + bookCount: Int, + totalTimeMillis: Long, + bookNamesForCover: List>, + 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>, + viewModel: ReadRecordViewModel +) { + Box(contentAlignment = Alignment.CenterEnd) { + bookNamesForCover.reversed().forEachIndexed { index, (name, author) -> + var coverPath by remember { mutableStateOf(null) } + LaunchedEffect(name, author) { + coverPath = viewModel.getBookCover(name, author) + } + + Cover( + path = coverPath, + modifier = Modifier + .padding(end = (index * 12).dp) + .size(32.dp, 44.dp) + ) + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageScreen.kt b/app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageScreen.kt new file mode 100644 index 000000000..d599ce367 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageScreen.kt @@ -0,0 +1,340 @@ +package io.legado.app.ui.config.themeManage + +import android.os.Handler +import android.os.Looper +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.legado.app.R +import io.legado.app.help.config.SavedTheme +import io.legado.app.help.config.ThemeImportExport +import io.legado.app.ui.theme.adaptiveContentPadding +import io.legado.app.ui.widget.components.AppScaffold +import io.legado.app.ui.widget.components.AppTextField +import io.legado.app.ui.widget.components.SplicedColumnGroup +import io.legado.app.ui.widget.components.alert.AppAlertDialog +import io.legado.app.ui.widget.components.card.GlassCard +import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem +import io.legado.app.ui.widget.components.text.AppText +import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar +import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults +import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton +import io.legado.app.utils.restart +import io.legado.app.utils.toastOnUi + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ThemeManageScreen( + onBackClick: () -> Unit +) { + val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior() + val context = LocalContext.current + + var showSaveDialog by remember { mutableStateOf(false) } + var newThemeName by remember { mutableStateOf("") } + var deleteTarget by remember { mutableStateOf(null) } + var applyTarget by remember { mutableStateOf(null) } + var exportTarget by remember { mutableStateOf(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() + 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 + ) + } + } + } +}