使用Compose重构的阅读记录模块的初始版本。

现在阅读记录功能更加强大,能够获取某一天所有书籍统计、可视化的阅读时间线等。
目前仅实现基本功能,且尚不完善,仅供尝鲜使用。
由于原版并无精细化的数据结构,所以时间线与日期查看仅在更新此版本后开始记录。原版的查看最后阅读功能仍可使用。
This commit is contained in:
HapeLee committed 2025-11-25 01:36:51 +08:00
1 parent 32489ac318
commit ba33e7cdd5
21 files changed
+3063 -45

No files matched your search

+15 -7
View File
@@ -273,19 +273,27 @@ jobs:
# --no-merges: 排除合并提交 # --no-merges: 排除合并提交
COMMIT_LOG=$(git log "$LAST_TAG"..HEAD --pretty=format:"* %s" --no-merges) COMMIT_LOG=$(git log "$LAST_TAG"..HEAD --pretty=format:"* %s" --no-merges)
# 检查是否获取到内容
if [ -z "$COMMIT_LOG" ]; then if [ -z "$COMMIT_LOG" ]; then
RELEASE_BODY="### 更新内容\n\n- 没有新的 Commit 记录." RELEASE_BODY=$(cat <<EOF
### 更新内容
- 没有新的 Commit 记录.
EOF
)
else else
# 格式化输出 RELEASE_BODY=$(cat <<EOF
RELEASE_BODY="### 更新内容\n\n$COMMIT_LOG" ### 更新内容
$COMMIT_LOG
EOF
)
fi fi
# 将多行字符串设置为 step output # 设置 step 输出
echo "release_body<<EOF" >> $GITHUB_OUTPUT echo "release_body<<EOF" >> $GITHUB_OUTPUT
echo "$RELEASE_BODY" >> $GITHUB_OUTPUT echo "$RELEASE_BODY" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT
echo "生成的 Release Body:" echo "生成的 Release Body:"
echo "$RELEASE_BODY" echo "$RELEASE_BODY"
File diff suppressed because it is too large. Load diff
+5
View File
@@ -332,6 +332,11 @@
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:theme="@style/AppTheme.Transparent" /> android:theme="@style/AppTheme.Transparent" />
<!-- 阅读记录 --> <!-- 阅读记录 -->
<activity
android:name=".ui.book.readRecord.ReadRecordActivity"
android:enableOnBackInvokedCallback="true"
android:configChanges="orientation|screenSize"
android:hardwareAccelerated="true" />
<activity <activity
android:name=".ui.about.ReadRecordActivity" android:name=".ui.about.ReadRecordActivity"
android:enableOnBackInvokedCallback="true" android:enableOnBackInvokedCallback="true"
+2 -1
View File
@@ -33,6 +33,7 @@ import io.legado.app.data.entities.rule.ContentRule
import io.legado.app.data.entities.rule.ExploreRule import io.legado.app.data.entities.rule.ExploreRule
import io.legado.app.data.entities.rule.SearchRule import io.legado.app.data.entities.rule.SearchRule
import io.legado.app.di.appDatabaseModule import io.legado.app.di.appDatabaseModule
import io.legado.app.di.readRecordModule
import io.legado.app.help.AppFreezeMonitor import io.legado.app.help.AppFreezeMonitor
import io.legado.app.help.AppWebDav import io.legado.app.help.AppWebDav
import io.legado.app.help.CrashHandler import io.legado.app.help.CrashHandler
@@ -75,7 +76,7 @@ class App : Application() {
override fun onCreate() { override fun onCreate() {
startKoin { startKoin {
modules(appDatabaseModule) modules(appDatabaseModule, readRecordModule)
} }
if (getPrefString("app_theme", "0") == "12") { if (getPrefString("app_theme", "0") == "12") {
if (AppConfig.customMode == "accent") if (AppConfig.customMode == "accent")
@@ -39,7 +39,7 @@ import io.legado.app.data.entities.Cookie
import io.legado.app.data.entities.DictRule import io.legado.app.data.entities.DictRule
import io.legado.app.data.entities.HttpTTS import io.legado.app.data.entities.HttpTTS
import io.legado.app.data.entities.KeyboardAssist import io.legado.app.data.entities.KeyboardAssist
import io.legado.app.data.entities.ReadRecord import io.legado.app.data.entities.readRecord.ReadRecord
import io.legado.app.data.entities.ReplaceRule import io.legado.app.data.entities.ReplaceRule
import io.legado.app.data.entities.RssArticle import io.legado.app.data.entities.RssArticle
import io.legado.app.data.entities.RssReadRecord import io.legado.app.data.entities.RssReadRecord
@@ -50,6 +50,8 @@ import io.legado.app.data.entities.SearchBook
import io.legado.app.data.entities.SearchKeyword import io.legado.app.data.entities.SearchKeyword
import io.legado.app.data.entities.Server import io.legado.app.data.entities.Server
import io.legado.app.data.entities.TxtTocRule import io.legado.app.data.entities.TxtTocRule
import io.legado.app.data.entities.readRecord.ReadRecordDetail
import io.legado.app.data.entities.readRecord.ReadRecordSession
import io.legado.app.help.DefaultData import io.legado.app.help.DefaultData
import org.intellij.lang.annotations.Language import org.intellij.lang.annotations.Language
import splitties.init.appCtx import splitties.init.appCtx
@@ -65,11 +67,12 @@ val appDb by lazy {
} }
@Database( @Database(
version = 78, version = 79,
exportSchema = true, exportSchema = true,
entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class, entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class,
ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class, ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class,
RssSource::class, Bookmark::class, RssArticle::class, RssReadRecord::class, RssSource::class, Bookmark::class, RssArticle::class,
RssReadRecord::class, ReadRecordDetail::class, ReadRecordSession::class,
RssStar::class, TxtTocRule::class, ReadRecord::class, HttpTTS::class, Cache::class, RssStar::class, TxtTocRule::class, ReadRecord::class, HttpTTS::class, Cache::class,
RuleSub::class, DictRule::class, KeyboardAssist::class, Server::class], RuleSub::class, DictRule::class, KeyboardAssist::class, Server::class],
views = [BookSourcePart::class], views = [BookSourcePart::class],
@@ -108,7 +111,8 @@ val appDb by lazy {
AutoMigration(from = 74, to = 75), AutoMigration(from = 74, to = 75),
AutoMigration(from = 75, to = 76), AutoMigration(from = 75, to = 76),
AutoMigration(from = 76, to = 77), AutoMigration(from = 76, to = 77),
AutoMigration(from = 77, to = 78) AutoMigration(from = 77, to = 78),
AutoMigration(from = 78, to = 79)
] ]
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@@ -6,8 +6,10 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.Update import androidx.room.Update
import io.legado.app.data.entities.ReadRecord import io.legado.app.data.entities.readRecord.ReadRecord
import io.legado.app.data.entities.ReadRecordShow import io.legado.app.data.entities.ReadRecordShow
import io.legado.app.data.entities.readRecord.ReadRecordDetail
import io.legado.app.data.entities.readRecord.ReadRecordSession
@Dao @Dao
interface ReadRecordDao { interface ReadRecordDao {
@@ -40,15 +42,17 @@ interface ReadRecordDao {
@Query("select sum(readTime) from readRecord where bookName = :bookName") @Query("select sum(readTime) from readRecord where bookName = :bookName")
fun getReadTime(bookName: String): Long? fun getReadTime(bookName: String): Long?
@Query("select readTime from readRecord where deviceId = :androidId and bookName = :bookName") @Query("select readTime from readRecord where deviceId = :deviceId and bookName = :bookName")
fun getReadTime(androidId: String, bookName: String): Long? fun getReadTime(deviceId: String, bookName: String): Long?
@Query("SELECT * FROM readRecord WHERE deviceId = :deviceId AND bookName = :bookName")
suspend fun getReadRecord(deviceId: String, bookName: String): ReadRecord?
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(vararg readRecord: ReadRecord) suspend fun insert(vararg readRecord: ReadRecord)
@Update @Update
fun update(vararg record: ReadRecord) suspend fun update(vararg record: ReadRecord)
@Delete @Delete
fun delete(vararg record: ReadRecord) fun delete(vararg record: ReadRecord)
@@ -58,4 +62,83 @@ interface ReadRecordDao {
@Query("delete from readRecord where bookName = :bookName") @Query("delete from readRecord where bookName = :bookName")
fun deleteByName(bookName: String) fun deleteByName(bookName: String)
/**
* 插入或更新每日聚合统计记录。
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertDetail(detail: ReadRecordDetail)
/**
* 获取某一本书某一天的详细统计
* @param date 日期, 推荐格式: YYYY-MM-DD
*/
@Query("SELECT * FROM readRecordDetail WHERE deviceId = :deviceId AND bookName = :bookName AND date = :date")
suspend fun getDetail(deviceId: String, bookName: String, date: String): ReadRecordDetail?
/**
* 查询所有发生过阅读的日期(用于日历标记)
*/
@Query("SELECT DISTINCT date FROM readRecordDetail WHERE deviceId = :deviceId ORDER BY date DESC")
fun getAllReadDates(deviceId: String): List<String>
/**
* 获取某一天所有书籍的详细统计 (用于日历页面总览)
*/
@Query("SELECT * FROM readRecordDetail WHERE deviceId = :deviceId AND date = :date")
suspend fun getDetailsByDate(deviceId: String, date: String): List<ReadRecordDetail>
// 清除每天的统计记录
@Query("DELETE FROM readRecordDetail WHERE bookName = :bookName")
fun deleteDetailByName(bookName: String)
/**
* 插入阅读会话记录。
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertSession(session: ReadRecordSession)
/** 获取所有 ReadRecord,按最后阅读时间倒序排列 */
@Query("SELECT * FROM readRecord ORDER BY lastRead DESC")
suspend fun getAllReadRecordsSortedByLastRead(): List<ReadRecord>
/** 搜索 ReadRecord,按最后阅读时间倒序排列 */
@Query("SELECT * FROM readRecord WHERE bookName LIKE '%' || :query || '%' ORDER BY lastRead DESC")
suspend fun searchReadRecordsByLastRead(query: String): List<ReadRecord>
/**
* 获取某一天某一本书的所有会话记录
*/
@Query("""
SELECT * FROM readRecordSession
WHERE deviceId = :deviceId
AND bookName = :bookName
AND STRFTIME('%Y-%m-%d', datetime(startTime/1000, 'unixepoch')) = :date
ORDER BY startTime ASC
""")
suspend fun getSessionsByBookAndDate(deviceId: String, bookName: String, date: String): List<ReadRecordSession>
/**
* 获取某一天所有书籍的会话记录
*/
@Query("""
SELECT * FROM readRecordSession
WHERE deviceId = :deviceId
AND STRFTIME('%Y-%m-%d', datetime(startTime/1000, 'unixepoch', 'localtime')) = :date
ORDER BY startTime DESC
""")
suspend fun getSessionsByDate(deviceId: String, date: String): List<ReadRecordSession>
// 清除会话记录
@Query("DELETE FROM readRecordSession WHERE bookName = :bookName")
fun deleteSessionByName(bookName: String)
@Query("SELECT * FROM readRecordDetail ORDER BY date DESC, lastReadTime DESC")
suspend fun getAllDetails(): List<ReadRecordDetail>
@Query("SELECT * FROM readRecordDetail WHERE bookName LIKE '%' || :query || '%' ORDER BY date DESC")
suspend fun searchDetails(query: String): List<ReadRecordDetail>
@Delete
suspend fun deleteDetail(detail: ReadRecordDetail)
} }
@@ -1,4 +1,4 @@
package io.legado.app.data.entities package io.legado.app.data.entities.readRecord
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
@@ -0,0 +1,28 @@
package io.legado.app.data.entities.readRecord
import androidx.room.ColumnInfo
import androidx.room.Entity
@Entity(
tableName = "readRecordDetail",
primaryKeys = ["deviceId", "bookName", "date"]
)
data class ReadRecordDetail(
val deviceId: String = "",
val bookName: String = "",
val date: String = "",
// 当天阅读总时长
@ColumnInfo(defaultValue = "0")
var readTime: Long = 0L,
// 当天阅读总字数
@ColumnInfo(defaultValue = "0")
var readWords: Long = 0L,
// 当天第一次阅读时间
@ColumnInfo(defaultValue = "0")
var firstReadTime: Long = 0L,
// 当天最后一次阅读时间
@ColumnInfo(defaultValue = "0")
var lastReadTime: Long = 0L
)
@@ -0,0 +1,20 @@
package io.legado.app.data.entities.readRecord
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "readRecordSession")
data class ReadRecordSession(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val deviceId: String = "",
val bookName: String = "",
// 一次阅读的开始/结束
val startTime: Long = 0,
val endTime: Long = 0,
// 本次阅读的字数
val words: Long = 0
)
@@ -0,0 +1,119 @@
package io.legado.app.data.repository
import androidx.room.Transaction
import cn.hutool.core.date.DatePattern
import cn.hutool.core.date.DateUtil
import io.legado.app.data.dao.ReadRecordDao
import io.legado.app.data.entities.readRecord.ReadRecord
import io.legado.app.data.entities.readRecord.ReadRecordDetail
import io.legado.app.data.entities.readRecord.ReadRecordSession
import java.util.Date
import kotlin.math.max
import kotlin.math.min
class ReadRecordRepository(
private val dao: ReadRecordDao
) {
private fun getCurrentDeviceId(): String = ""
/**
* 保存一个完整的阅读会话,并同步更新 ReadRecordDetail 和 ReadRecord。
*/
@Transaction
suspend fun saveReadSession(session: ReadRecordSession) {
dao.insertSession(session)
val sessionDuration = session.endTime - session.startTime
val dateString = DateUtil.format(Date(session.startTime), DatePattern.NORM_DATE_PATTERN)
updateReadRecordDetail(session, sessionDuration, dateString)
updateReadRecord(session, sessionDuration)
}
private suspend fun updateReadRecord(session: ReadRecordSession, sessionDuration: Long) {
val existingRecord = dao.getReadRecord(session.deviceId, session.bookName)
if (existingRecord != null) {
val updatedRecord = existingRecord.copy(
readTime = existingRecord.readTime + sessionDuration,
lastRead = session.endTime
)
dao.update(updatedRecord)
} else {
val newRecord = ReadRecord(
deviceId = session.deviceId,
bookName = session.bookName,
readTime = sessionDuration,
lastRead = session.endTime
)
dao.insert(newRecord)
}
}
private suspend fun updateReadRecordDetail(session: ReadRecordSession, sessionDuration: Long, dateString: String) {
val existingDetail = dao.getDetail(session.deviceId, session.bookName, dateString)
if (existingDetail != null) {
existingDetail.readTime += sessionDuration
existingDetail.readWords += session.words
existingDetail.firstReadTime = min(existingDetail.firstReadTime, session.startTime)
existingDetail.lastReadTime = max(existingDetail.lastReadTime, session.endTime)
dao.insertDetail(existingDetail)
} else {
val newDetail = ReadRecordDetail(
deviceId = session.deviceId,
bookName = session.bookName,
date = dateString,
readTime = sessionDuration,
readWords = session.words,
firstReadTime = session.startTime,
lastReadTime = session.endTime
)
dao.insertDetail(newDetail)
}
}
suspend fun getDailyDetails(deviceId: String, date: String): List<ReadRecordDetail> {
return dao.getDetailsByDate(deviceId, date)
}
suspend fun getDailySessions(deviceId: String, bookName: String, date: String): List<ReadRecordSession> {
return dao.getSessionsByBookAndDate(deviceId, bookName, date)
}
suspend fun getLatestReadRecords(query: String = ""): List<ReadRecord> {
return if (query.isBlank()) {
dao.getAllReadRecordsSortedByLastRead()
} else {
dao.searchReadRecordsByLastRead(query)
}
}
suspend fun getAllSessionsForDate(date: String): List<ReadRecordSession> {
val deviceId = getCurrentDeviceId()
return dao.getSessionsByDate(deviceId, date)
}
suspend fun getAllRecordDetails(query: String = ""): List<ReadRecordDetail> {
return if (query.isBlank()) {
dao.getAllDetails()
} else {
dao.searchDetails(query)
}
}
suspend fun deleteDetail(detail: ReadRecordDetail) {
dao.deleteDetail(detail)
}
suspend fun clearAll() {
dao.clear() // 清除总表
// dao.clearDetails()
}
// 暴露总时长
val allTime: Long
get() = dao.allTime
}
@@ -0,0 +1,15 @@
package io.legado.app.di
import io.legado.app.data.AppDatabase
import io.legado.app.data.repository.ReadRecordRepository
import io.legado.app.ui.book.readRecord.ReadRecordViewModel
import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module
val readRecordModule = module {
single { get<AppDatabase>().readRecordDao }
single { get<AppDatabase>().bookDao }
single { ReadRecordRepository(get()) }
viewModel { ReadRecordViewModel(get(), get()) }
}
@@ -18,7 +18,7 @@ import io.legado.app.data.entities.Bookmark
import io.legado.app.data.entities.DictRule import io.legado.app.data.entities.DictRule
import io.legado.app.data.entities.HttpTTS import io.legado.app.data.entities.HttpTTS
import io.legado.app.data.entities.KeyboardAssist import io.legado.app.data.entities.KeyboardAssist
import io.legado.app.data.entities.ReadRecord import io.legado.app.data.entities.readRecord.ReadRecord
import io.legado.app.data.entities.ReplaceRule import io.legado.app.data.entities.ReplaceRule
import io.legado.app.data.entities.RssSource import io.legado.app.data.entities.RssSource
import io.legado.app.data.entities.RssStar import io.legado.app.data.entities.RssStar
@@ -8,7 +8,9 @@ import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookProgress import io.legado.app.data.entities.BookProgress
import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.ReadRecord import io.legado.app.data.entities.readRecord.ReadRecord
import io.legado.app.data.entities.readRecord.ReadRecordSession
import io.legado.app.data.repository.ReadRecordRepository
import io.legado.app.help.AppWebDav import io.legado.app.help.AppWebDav
import io.legado.app.help.book.BookHelp import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.ContentProcessor import io.legado.app.help.book.ContentProcessor
@@ -50,6 +52,8 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import splitties.init.appCtx import splitties.init.appCtx
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
@@ -58,7 +62,7 @@ import kotlin.math.min
@Suppress("MemberVisibilityCanBePrivate") @Suppress("MemberVisibilityCanBePrivate")
object ReadBook : CoroutineScope by MainScope() { object ReadBook : CoroutineScope by MainScope(), KoinComponent {
var book: Book? = null var book: Book? = null
var callBack: CallBack? = null var callBack: CallBack? = null
var inBookshelf = false var inBookshelf = false
@@ -73,6 +77,8 @@ object ReadBook : CoroutineScope by MainScope() {
var nextTextChapter: TextChapter? = null var nextTextChapter: TextChapter? = null
var bookSource: BookSource? = null var bookSource: BookSource? = null
var msg: String? = null var msg: String? = null
private val readRecordRepository: ReadRecordRepository by inject()
private var lastReadLength: Long = 0
private val loadingChapters = arrayListOf<Int>() private val loadingChapters = arrayListOf<Int>()
private val readRecord = ReadRecord() private val readRecord = ReadRecord()
private val chapterLoadingJobs = ConcurrentHashMap<Int, Coroutine<*>>() private val chapterLoadingJobs = ConcurrentHashMap<Int, Coroutine<*>>()
@@ -280,16 +286,91 @@ object ReadBook : CoroutineScope by MainScope() {
} }
} }
// 阅读暂停的阈值
private const val PAUSE_THRESHOLD = 5 * 60 * 1000L
// 存储当前正在累积的阅读会话对象
private var currentActiveSession: ReadRecordSession? = null
//占位
private var currentReadLength: Long = 10L
fun upReadTime() { fun upReadTime() {
executor.execute { val currentLength = currentReadLength
if (!AppConfig.enableReadRecord) { if (!AppConfig.enableReadRecord) {
return@execute return
}
readRecord.readTime = readRecord.readTime + System.currentTimeMillis() - readStartTime
readStartTime = System.currentTimeMillis()
readRecord.lastRead = System.currentTimeMillis()
appDb.readRecordDao.insert(readRecord)
} }
val currentBookName = book?.name ?: return
val endTime = System.currentTimeMillis()
//计算本次片段的时长和字数
val duration = endTime - readStartTime
var wordChange = currentLength - lastReadLength
if (wordChange < 0) wordChange = 0
//过滤无效记录
if (duration < 1000L && wordChange == 0L) {
readStartTime = endTime
lastReadLength = currentLength
return
}
//检查是否中断
if (currentActiveSession != null) {
// 计算从上一个片段结束到当前片段开始的时间间隔
val timeSinceLastUpdate = readStartTime - currentActiveSession!!.endTime
if (timeSinceLastUpdate > PAUSE_THRESHOLD) {
// 如果间隔时间过长,说明用户暂停阅读后又回来了,提交旧会话
commitReadSession()
}
}
//累加或创建新会话
if (currentActiveSession == null) {
// 创建新的会话对象,使用 readStartTime 作为整个会话的起始时间
currentActiveSession = ReadRecordSession(
deviceId = "",
bookName = currentBookName,
startTime = readStartTime,
endTime = endTime,
words = 0
)
} else {
// 累加数据:只更新结束时间和总字数
currentActiveSession = currentActiveSession!!.copy(
endTime = endTime,
words = currentActiveSession!!.words + wordChange
)
}
readStartTime = endTime
lastReadLength = currentLength
}
/**
* 将当前累积的阅读会话(ReadRecordSession)写入数据库,并重置状态。
* 此方法必须在后台线程上调用。
*/
fun commitReadSession() {
// 检查是否有需要保存的数据
val sessionToSave = currentActiveSession ?: return
// 确保时长和字数有效
if (sessionToSave.words <= 0 && (sessionToSave.endTime - sessionToSave.startTime) < 10L) {
currentActiveSession = null
return
}
//使用 runBlocking 确保数据库保存操作在当前线程上同步完成
try {
kotlinx.coroutines.runBlocking {
readRecordRepository.saveReadSession(sessionToSave)
}
} catch (e: Exception) {
AppLog.put("保存阅读会话出错: ${sessionToSave.bookName}", e)
}
currentActiveSession = null
} }
fun upMsg(msg: String?) { fun upMsg(msg: String?) {
@@ -6,7 +6,9 @@ import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookProgress import io.legado.app.data.entities.BookProgress
import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.ReadRecord import io.legado.app.data.entities.readRecord.ReadRecord
import io.legado.app.data.entities.readRecord.ReadRecordSession
import io.legado.app.data.repository.ReadRecordRepository
import io.legado.app.help.AppWebDav import io.legado.app.help.AppWebDav
import io.legado.app.help.ConcurrentRateLimiter import io.legado.app.help.ConcurrentRateLimiter
import io.legado.app.help.book.BookHelp import io.legado.app.help.book.BookHelp
@@ -39,10 +41,13 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.toList import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import kotlin.getValue
import kotlin.math.min import kotlin.math.min
@Suppress("MemberVisibilityCanBePrivate") @Suppress("MemberVisibilityCanBePrivate")
object ReadManga : CoroutineScope by MainScope() { object ReadManga : CoroutineScope by MainScope() , KoinComponent{
var inBookshelf = false var inBookshelf = false
var book: Book? = null var book: Book? = null
val executor = globalExecutor val executor = globalExecutor
@@ -68,6 +73,9 @@ object ReadManga : CoroutineScope by MainScope() {
val mangaContents get() = buildMangaContent() val mangaContents get() = buildMangaContent()
val hasNextChapter get() = durChapterIndex < simulatedChapterSize - 1 val hasNextChapter get() = durChapterIndex < simulatedChapterSize - 1
private val readRecordRepository: ReadRecordRepository by inject()
private var lastReadLength: Long = 0
fun resetData(book: Book) { fun resetData(book: Book) {
ReadManga.book = book ReadManga.book = book
readRecord.bookName = book.name readRecord.bookName = book.name
@@ -127,16 +135,91 @@ object ReadManga : CoroutineScope by MainScope() {
} }
//每次切换章节更新阅读记录 //每次切换章节更新阅读记录
// 阅读暂停的阈值
private const val PAUSE_THRESHOLD = 5 * 60 * 1000L
// 存储当前正在累积的阅读会话对象
private var currentActiveSession: ReadRecordSession? = null
//占位
private var currentReadLength: Long = 10L
fun upReadTime() { fun upReadTime() {
executor.execute { val currentLength = currentReadLength
if (!AppConfig.enableReadRecord) { if (!AppConfig.enableReadRecord) {
return@execute return
}
readRecord.readTime = readRecord.readTime + System.currentTimeMillis() - readStartTime
readStartTime = System.currentTimeMillis()
readRecord.lastRead = System.currentTimeMillis()
appDb.readRecordDao.insert(readRecord)
} }
val currentBookName = book?.name ?: return
val endTime = System.currentTimeMillis()
//计算本次片段的时长和字数
val duration = endTime - readStartTime
var wordChange = currentLength - lastReadLength
if (wordChange < 0) wordChange = 0
//过滤无效记录
if (duration < 1000L && wordChange == 0L) {
readStartTime = endTime
lastReadLength = currentLength
return
}
//检查是否中断
if (currentActiveSession != null) {
// 计算从上一个片段结束到当前片段开始的时间间隔
val timeSinceLastUpdate = readStartTime - currentActiveSession!!.endTime
if (timeSinceLastUpdate > PAUSE_THRESHOLD) {
// 如果间隔时间过长,说明用户暂停阅读后又回来了,提交旧会话
commitReadSession()
}
}
//累加或创建新会话
if (currentActiveSession == null) {
// 创建新的会话对象,使用 readStartTime 作为整个会话的起始时间
currentActiveSession = ReadRecordSession(
deviceId = "",
bookName = currentBookName,
startTime = readStartTime,
endTime = endTime,
words = 0
)
} else {
// 累加数据:只更新结束时间和总字数
currentActiveSession = currentActiveSession!!.copy(
endTime = endTime,
words = currentActiveSession!!.words + wordChange
)
}
readStartTime = endTime
lastReadLength = currentLength
}
/**
* 将当前累积的阅读会话(ReadRecordSession)写入数据库,并重置状态。
* 此方法必须在后台线程上调用。
*/
fun commitReadSession() {
// 检查是否有需要保存的数据
val sessionToSave = currentActiveSession ?: return
// 确保时长和字数有效
if (sessionToSave.words <= 0 && (sessionToSave.endTime - sessionToSave.startTime) < 10L) {
currentActiveSession = null
return
}
//使用 runBlocking 确保数据库保存操作在当前线程上同步完成
try {
kotlinx.coroutines.runBlocking {
readRecordRepository.saveReadSession(sessionToSave)
}
} catch (e: Exception) {
AppLog.put("保存阅读会话出错: ${sessionToSave.bookName}", e)
}
currentActiveSession = null
} }
@Synchronized @Synchronized
@@ -13,7 +13,6 @@ import android.view.animation.LinearInterpolator
import androidx.activity.addCallback import androidx.activity.addCallback
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.graphics.toColorInt
import androidx.core.view.doOnLayout import androidx.core.view.doOnLayout
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
@@ -445,6 +444,7 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
Backup.autoBack(this) Backup.autoBack(this)
} }
} }
ReadManga.commitReadSession()
ReadManga.cancelPreDownloadTask() ReadManga.cancelPreDownloadTask()
networkChangedListener.unRegister() networkChangedListener.unRegister()
mScrollTimer.isEnabledPage = false mScrollTimer.isEnabledPage = false
@@ -337,8 +337,10 @@ class ReadBookActivity : BaseReadBookActivity(),
return@addCallback return@addCallback
} }
if (savedInstanceState != null || !ReadBook.inBookshelf) { if (savedInstanceState != null || !ReadBook.inBookshelf) {
ReadBook.commitReadSession()
finish() finish()
} else { } else {
ReadBook.commitReadSession()
supportFinishAfterTransition() supportFinishAfterTransition()
} }
//TODO: 有关测量相关问题 //TODO: 有关测量相关问题
@@ -421,6 +423,7 @@ class ReadBookActivity : BaseReadBookActivity(),
autoPageStop() autoPageStop()
backupJob?.cancel() backupJob?.cancel()
ReadBook.saveRead() ReadBook.saveRead()
ReadBook.commitReadSession()
ReadBook.cancelPreDownloadTask() ReadBook.cancelPreDownloadTask()
unregisterReceiver(timeBatteryReceiver) unregisterReceiver(timeBatteryReceiver)
upSystemUiVisibility() upSystemUiVisibility()
@@ -41,7 +41,6 @@ import io.legado.app.model.ReadBook
import io.legado.app.ui.browser.WebViewActivity import io.legado.app.ui.browser.WebViewActivity
import io.legado.app.ui.widget.seekbar.SeekBarChangeListener import io.legado.app.ui.widget.seekbar.SeekBarChangeListener
import io.legado.app.utils.ConstraintModify import io.legado.app.utils.ConstraintModify
import io.legado.app.utils.VibrationUtils
import io.legado.app.utils.activity import io.legado.app.utils.activity
import io.legado.app.utils.applyNavigationBarPadding import io.legado.app.utils.applyNavigationBarPadding
import io.legado.app.utils.dpToPx import io.legado.app.utils.dpToPx
@@ -451,14 +450,14 @@ class ReadMenu @JvmOverloads constructor(
if (fromUser) { if (fromUser) {
if (AppConfig.progressBarBehavior == "page") if (AppConfig.progressBarBehavior == "page")
ReadBook.skipToPage(value.toInt() - 1) ReadBook.skipToPage(value.toInt() - 1)
VibrationUtils.vibrate(context, 12) //VibrationUtils.vibrate(context, 12)
} }
} }
seekReadPage.addOnSliderTouchListener(object : Slider.OnSliderTouchListener { seekReadPage.addOnSliderTouchListener(object : Slider.OnSliderTouchListener {
override fun onStartTrackingTouch(slider: Slider) { override fun onStartTrackingTouch(slider: Slider) {
vwMenuBg.setOnClickListener(null) vwMenuBg.setOnClickListener(null)
VibrationUtils.vibrate(context, 16) //VibrationUtils.vibrate(context, 16)
} }
override fun onStopTrackingTouch(slider: Slider) { override fun onStopTrackingTouch(slider: Slider) {
@@ -0,0 +1,528 @@
package io.legado.app.ui.book.readRecord
import android.os.Bundle
import androidx.compose.animation.AnimatedVisibility
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.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import coil.compose.AsyncImage
import coil.request.ImageRequest
import io.legado.app.data.appDb
import io.legado.app.data.entities.readRecord.ReadRecordDetail
import io.legado.app.utils.startActivityForBook
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.nestedScroll
import cn.hutool.core.date.DateUtil
import io.legado.app.base.BaseComposeActivity
import io.legado.app.data.entities.readRecord.ReadRecord
import io.legado.app.data.entities.readRecord.ReadRecordSession
import io.legado.app.utils.StringUtils.formatFriendlyDate
import org.koin.androidx.compose.koinViewModel
// 包含绘制时间线所需的上下文信息
data class TimelineItem(
val session: ReadRecordSession,
// 是否显示封面和标题
val showHeader: Boolean
)
class ReadRecordActivity : BaseComposeActivity() {
@Composable
override fun Content() {
MaterialTheme {
val viewModel: ReadRecordViewModel = koinViewModel()
ReadRecordScreen(
viewModel = viewModel,
onBackClick = { finish() },
onBookClick = { bookName ->
lifecycleScope.launch {
val book = withContext(Dispatchers.IO) {
appDb.bookDao.findByName(bookName).firstOrNull()
}
if (book != null) startActivityForBook(book)
}
}
)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReadRecordScreen(
viewModel: ReadRecordViewModel,
onBackClick: () -> Unit,
onBookClick: (String) -> Unit
) {
val state by viewModel.uiState.collectAsState()
val displayMode by viewModel.displayMode.collectAsState()
var showSearch by remember { mutableStateOf(false) }
var searchText by remember { mutableStateOf("") }
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
LaunchedEffect(showSearch) {
if (!showSearch) {
viewModel.loadData("")
}
}
LaunchedEffect(searchText) {
if (showSearch) {
kotlinx.coroutines.delay(100L)
viewModel.loadData(searchText)
}
}
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
Column {
LargeTopAppBar(
title = { Text("阅读记录") },
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
actions = {
IconButton(onClick = {
val newMode = when (displayMode) {
DisplayMode.AGGREGATE -> DisplayMode.TIMELINE
DisplayMode.TIMELINE -> DisplayMode.LATEST
DisplayMode.LATEST -> DisplayMode.AGGREGATE
}
viewModel.setDisplayMode(newMode)
}) {
val icon = when (displayMode) {
DisplayMode.AGGREGATE -> Icons.Default.Timeline
DisplayMode.TIMELINE -> Icons.Default.Schedule
DisplayMode.LATEST -> Icons.AutoMirrored.Filled.List
}
val description = if (displayMode == DisplayMode.AGGREGATE) "Switch to Timeline" else "Switch to Aggregate"
Icon(icon, description)
}
IconButton(onClick = { showSearch = !showSearch }) {
Icon(Icons.Default.Search, contentDescription = null)
}
},
scrollBehavior = scrollBehavior
)
AnimatedVisibility(visible = showSearch) {
SearchBarSection(
query = searchText,
onQueryChange = { searchText = it }
)
}
}
}
) { padding ->
Column(modifier = Modifier.padding(padding).fillMaxSize()) {
TotalTimeHeader(state.totalReadTime)
LazyColumn {
when(displayMode){
DisplayMode.AGGREGATE -> {
state.groupedRecords.forEach { (date, details) ->
val dailyTotalTime = details.sumOf { it.readTime }
stickyHeader {
DateHeader(date, dailyTotalTime)
}
items(
items = details,
key = { it.bookName + it.readTime.toString() }
) { detail ->
ReadRecordItem(
detail = detail,
viewModel = viewModel,
onClick = { onBookClick(detail.bookName) },
onDelete = { viewModel.deleteDetail(detail) },
modifier = Modifier.animateItem()
)
}
}
}
DisplayMode.TIMELINE -> {
state.timelineRecords.forEach { (date, sessions) ->
val dailyTotalTime = sessions.sumOf { it.endTime - it.startTime }
stickyHeader { DateHeader(date, dailyTotalTime) }
val timelineItems = sessions.mapIndexed { index, session ->
val previousSession = sessions.getOrNull(index - 1)
val showHeader = index == 0 || session.bookName != previousSession?.bookName
TimelineItem(session, showHeader)
}
items(items = timelineItems, key = { it.session.id }) { item ->
TimelineSessionItem(
item = item,
onBookClick = onBookClick,
viewModel = viewModel
)
}
}
}
DisplayMode.LATEST -> {
items(items = state.latestRecords, key = { it.bookName + it.deviceId }) { record ->
LatestReadItem(
record = record,
viewModel = viewModel,
onClick = { onBookClick(record.bookName) },
modifier = Modifier.animateItem()
)
}
}
}
}
}
}
}
@Composable
fun LatestReadItem(
record: ReadRecord,
viewModel: ReadRecordViewModel,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
var coverPath by remember { mutableStateOf<String?>(null) }
LaunchedEffect(record.bookName) {
coverPath = viewModel.getBookCover(record.bookName)
}
Row(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
BookCoverWithPlaceholder(coverPath)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = record.bookName,
style = MaterialTheme.typography.titleMedium,
maxLines = 2
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "总时长: ${formatDuring(record.readTime)}",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
Text(
text = "最后阅读: ${DateUtil.format(java.util.Date(record.lastRead), "yyyy-MM-dd HH:mm")}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
@Composable
fun TimelineSessionItem(
item: TimelineItem,
viewModel: ReadRecordViewModel,
onBookClick: (String) -> Unit
) {
val session = item.session
var coverPath by remember { mutableStateOf<String?>(null) }
LaunchedEffect(session.bookName) {
coverPath = viewModel.getBookCover(session.bookName)
}
val startTimeText = DateUtil.format(java.util.Date(session.startTime), "HH:mm")
val endTimeText = DateUtil.format(java.util.Date(session.endTime), "HH:mm")
val duration = session.endTime - session.startTime
val nodeRadius = 4.dp
val lineWidth = 2.dp
val timelineX = 24.dp
val contentPaddingStart = 32.dp
val lineColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
val nodeColor = MaterialTheme.colorScheme.primary
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 0.dp)
.clickable { onBookClick(session.bookName) }
.drawBehind {
val x = timelineX.toPx()
val h = size.height
val cy = h / 2f
drawLine(
color = lineColor,
start = Offset(x, 0f),
end = Offset(x, h),
strokeWidth = lineWidth.toPx()
)
drawCircle(
color = nodeColor,
radius = nodeRadius.toPx(),
center = Offset(x, cy)
)
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = contentPaddingStart, end = 16.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.width(48.dp),
verticalArrangement = Arrangement.Center
) {
Text(
text = startTimeText,
style = MaterialTheme.typography.bodySmall
)
}
Column(modifier = Modifier.weight(1f)) {
if (item.showHeader) {
Row(verticalAlignment = Alignment.CenterVertically) {
BookCoverWithPlaceholder(coverPath)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = session.bookName,
style = MaterialTheme.typography.titleMedium,
maxLines = 1
)
}
Spacer(modifier = Modifier.height(6.dp))
}
Text(
"时长: ${formatDuring(duration)}",
style = MaterialTheme.typography.bodySmall
)
// Text(
// "字数: ${session.words}",
// style = MaterialTheme.typography.bodySmall,
// color = MaterialTheme.colorScheme.onSurfaceVariant
// )
}
}
}
}
@Composable
fun ReadRecordItem(
detail: ReadRecordDetail,
viewModel: ReadRecordViewModel,
onClick: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier
) {
var coverPath by remember { mutableStateOf<String?>(null) }
LaunchedEffect(detail.bookName) {
coverPath = viewModel.getBookCover(detail.bookName)
}
Row(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
BookCoverWithPlaceholder(coverPath)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = detail.bookName,
style = MaterialTheme.typography.titleMedium,
maxLines = 2
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "阅读时长: ${formatDuring(detail.readTime)}",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
}
IconButton(onClick = onDelete) {
Icon(Icons.Default.Close, "Delete", tint = Color.LightGray)
}
}
}
@Composable
fun BookCoverWithPlaceholder(path: String?) {
Box(
modifier = Modifier
.width(48.dp)
.height(68.dp)
.clip(RoundedCornerShape(4.dp))
.background(Color(0xFFEEEEEE)), // 灰色背景
contentAlignment = Alignment.Center
) {
if (path == null) {
Icon(Icons.Default.Book, null, tint = Color.Gray)
} else {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(path)
.crossfade(true)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
}
}
}
@Composable
fun DateHeader(
date: String,
dailyTotalTime: Long
) {
val dateText = formatFriendlyDate(date)
val totalTimeText = "阅读时长: ${formatDuring(dailyTotalTime)}"
Surface(
color = MaterialTheme.colorScheme.surface,
tonalElevation = 2.dp,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
Text(
text = dateText,
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold
),
color = MaterialTheme.colorScheme.secondary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = totalTimeText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
@Composable
fun TotalTimeHeader(time: Long) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f))
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("总阅读时长", style = MaterialTheme.typography.labelMedium)
Text(
text = formatDuring(time),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchBarSection(
query: String,
onQueryChange: (String) -> Unit
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
shape = RoundedCornerShape(32.dp),
color = MaterialTheme.colorScheme.surfaceContainerLow
) {
TextField(
value = query,
onValueChange = onQueryChange,
placeholder = { Text("搜索书名…") },
leadingIcon = { Icon(Icons.Default.Search, null) },
trailingIcon = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (query.isNotEmpty()) {
IconButton(onClick = { onQueryChange("") }) {
Icon(Icons.Default.Clear, contentDescription = null)
}
}
}
},
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
)
)
}
}
fun formatDuring(mss: Long): String {
val days = mss / (1000 * 60 * 60 * 24)
val hours = mss % (1000 * 60 * 60 * 24) / (1000 * 60 * 60)
val minutes = mss % (1000 * 60 * 60) / (1000 * 60)
val seconds = mss % (1000 * 60) / 1000
val d = if (days > 0) "${days}天" else ""
val h = if (hours > 0) "${hours}小时" else ""
val m = if (minutes > 0) "${minutes}分钟" else ""
val s = if (seconds > 0) "${seconds}秒" else ""
return if ("$d$h$m$s".isBlank()) "0秒" else "$d$h$m$s"
}
@@ -0,0 +1,90 @@
package io.legado.app.ui.book.readRecord
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.legado.app.data.dao.BookDao
import io.legado.app.data.entities.readRecord.ReadRecord
import io.legado.app.data.entities.readRecord.ReadRecordDetail
import io.legado.app.data.entities.readRecord.ReadRecordSession
import io.legado.app.data.repository.ReadRecordRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class ReadRecordUiState(
val isLoading: Boolean = true,
val totalReadTime: Long = 0,
//每日聚合明细
val groupedRecords: Map<String, List<ReadRecordDetail>> = emptyMap(),
//每日所有阅读会话
val timelineRecords: Map<String, List<ReadRecordSession>> = emptyMap(),
//最后阅读列表
val latestRecords: List<ReadRecord> = emptyList()
)
enum class DisplayMode {
AGGREGATE,
TIMELINE,
LATEST
}
class ReadRecordViewModel(
private val repository: ReadRecordRepository,
private val bookDao: BookDao
) : ViewModel() {
private val _displayMode = MutableStateFlow(DisplayMode.AGGREGATE)
val displayMode = _displayMode.asStateFlow()
private val _uiState = MutableStateFlow(ReadRecordUiState())
val uiState = _uiState.asStateFlow()
init {
loadData()
}
fun setDisplayMode(mode: DisplayMode) {
_displayMode.value = mode
}
fun loadData(query: String = "") {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
val details = repository.getAllRecordDetails(query)
val grouped = details.groupBy { it.date }
val uniqueDates = grouped.keys.toList()
val timelineMap = LinkedHashMap<String, List<ReadRecordSession>>()
for (date in uniqueDates) {
val sessions = repository.getAllSessionsForDate(date)
timelineMap[date] = sessions
}
val latest = repository.getLatestReadRecords(query)
val totalTime = withContext(Dispatchers.IO) { repository.allTime }
_uiState.update {
it.copy(
isLoading = false,
totalReadTime = totalTime,
groupedRecords = grouped,
timelineRecords = timelineMap,
latestRecords = latest
)
}
}
}
fun deleteDetail(detail: ReadRecordDetail) {
viewModelScope.launch {
repository.deleteDetail(detail)
loadData()
}
}
suspend fun getBookCover(bookName: String): String? {
return withContext(Dispatchers.IO) {
bookDao.findByName(bookName).firstOrNull()?.getDisplayCover()
}
}
}
@@ -15,11 +15,10 @@ import io.legado.app.databinding.FragmentMyConfigBinding
import io.legado.app.lib.dialogs.selector import io.legado.app.lib.dialogs.selector
import io.legado.app.lib.prefs.SwitchPreference import io.legado.app.lib.prefs.SwitchPreference
import io.legado.app.lib.prefs.fragment.PreferenceFragment import io.legado.app.lib.prefs.fragment.PreferenceFragment
//import io.legado.app.lib.theme.primaryColor
import io.legado.app.service.WebService import io.legado.app.service.WebService
import io.legado.app.ui.about.AboutActivity import io.legado.app.ui.about.AboutActivity
import io.legado.app.ui.about.ReadRecordActivity
import io.legado.app.ui.book.bookmark.AllBookmarkActivity import io.legado.app.ui.book.bookmark.AllBookmarkActivity
import io.legado.app.ui.book.readRecord.ReadRecordActivity
import io.legado.app.ui.book.source.manage.BookSourceActivity import io.legado.app.ui.book.source.manage.BookSourceActivity
import io.legado.app.ui.book.toc.rule.TxtTocRuleActivity import io.legado.app.ui.book.toc.rule.TxtTocRuleActivity
import io.legado.app.ui.config.ConfigActivity import io.legado.app.ui.config.ConfigActivity
@@ -8,6 +8,9 @@ import java.io.ByteArrayOutputStream
import java.io.IOException import java.io.IOException
import java.text.DecimalFormat import java.text.DecimalFormat
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.time.LocalDate
import java.time.ZoneId
import java.time.temporal.ChronoUnit
import java.util.Calendar import java.util.Calendar
import java.util.Locale import java.util.Locale
import java.util.regex.Matcher import java.util.regex.Matcher
@@ -97,6 +100,30 @@ object StringUtils {
return "" return ""
} }
/**
* 将日期字符串转换为“今天”、“昨天”、“N天前”或标准日期格式
* @param dateString 待转换的日期字符串 yyyy-MM-dd
* @param pattern 用于解析 dateString 的格式,通常是 yyyy-MM-dd
*/
fun formatFriendlyDate(dateString: String, pattern: String = "yyyy-MM-dd"): String {
return kotlin.runCatching {
val format = SimpleDateFormat(pattern, Locale.getDefault())
val date = format.parse(dateString) ?: return@runCatching dateString
val inputDate = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate()
val today = LocalDate.now(ZoneId.systemDefault())
val daysBetween = ChronoUnit.DAYS.between(inputDate, today)
when (daysBetween) {
0L -> "今天"
1L -> "昨天"
in 2L..5L -> "${daysBetween}天前"
else -> dateString
}
}.getOrElse {
it.printStackTrace()
dateString
}
}
/** /**
* 首字母大写 * 首字母大写
*/ */