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

现在阅读记录功能更加强大,能够获取某一天所有书籍统计、可视化的阅读时间线等。
目前仅实现基本功能,且尚不完善,仅供尝鲜使用。
由于原版并无精细化的数据结构,所以时间线与日期查看仅在更新此版本后开始记录。原版的查看最后阅读功能仍可使用。
This commit is contained in:
HapeLee
2025-11-25 01:36:51 +08:00
parent 32489ac318
commit ba33e7cdd5
21 changed files with 3063 additions and 45 deletions
+15 -7
View File
@@ -273,19 +273,27 @@ jobs:
# --no-merges: 排除合并提交
COMMIT_LOG=$(git log "$LAST_TAG"..HEAD --pretty=format:"* %s" --no-merges)
# 检查是否获取到内容
if [ -z "$COMMIT_LOG" ]; then
RELEASE_BODY="### 更新内容\n\n- 没有新的 Commit 记录."
RELEASE_BODY=$(cat <<EOF
### 更新内容
- 没有新的 Commit 记录.
EOF
)
else
# 格式化输出
RELEASE_BODY="### 更新内容\n\n$COMMIT_LOG"
RELEASE_BODY=$(cat <<EOF
### 更新内容
$COMMIT_LOG
EOF
)
fi
# 将多行字符串设置 step output
# 设置 step 输出
echo "release_body<<EOF" >> $GITHUB_OUTPUT
echo "$RELEASE_BODY" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
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:theme="@style/AppTheme.Transparent" />
<!-- 阅读记录 -->
<activity
android:name=".ui.book.readRecord.ReadRecordActivity"
android:enableOnBackInvokedCallback="true"
android:configChanges="orientation|screenSize"
android:hardwareAccelerated="true" />
<activity
android:name=".ui.about.ReadRecordActivity"
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.SearchRule
import io.legado.app.di.appDatabaseModule
import io.legado.app.di.readRecordModule
import io.legado.app.help.AppFreezeMonitor
import io.legado.app.help.AppWebDav
import io.legado.app.help.CrashHandler
@@ -75,7 +76,7 @@ class App : Application() {
override fun onCreate() {
startKoin {
modules(appDatabaseModule)
modules(appDatabaseModule, readRecordModule)
}
if (getPrefString("app_theme", "0") == "12") {
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.HttpTTS
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.RssArticle
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.Server
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 org.intellij.lang.annotations.Language
import splitties.init.appCtx
@@ -65,11 +67,12 @@ val appDb by lazy {
}
@Database(
version = 78,
version = 79,
exportSchema = true,
entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::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,
RuleSub::class, DictRule::class, KeyboardAssist::class, Server::class],
views = [BookSourcePart::class],
@@ -108,7 +111,8 @@ val appDb by lazy {
AutoMigration(from = 74, to = 75),
AutoMigration(from = 75, to = 76),
AutoMigration(from = 76, to = 77),
AutoMigration(from = 77, to = 78)
AutoMigration(from = 77, to = 78),
AutoMigration(from = 78, to = 79)
]
)
abstract class AppDatabase : RoomDatabase() {
@@ -6,8 +6,10 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
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.readRecord.ReadRecordDetail
import io.legado.app.data.entities.readRecord.ReadRecordSession
@Dao
interface ReadRecordDao {
@@ -40,15 +42,17 @@ interface ReadRecordDao {
@Query("select sum(readTime) from readRecord where bookName = :bookName")
fun getReadTime(bookName: String): Long?
@Query("select readTime from readRecord where deviceId = :androidId and bookName = :bookName")
fun getReadTime(androidId: String, bookName: String): Long?
@Query("select readTime from readRecord where deviceId = :deviceId and bookName = :bookName")
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)
fun insert(vararg readRecord: ReadRecord)
suspend fun insert(vararg readRecord: ReadRecord)
@Update
fun update(vararg record: ReadRecord)
suspend fun update(vararg record: ReadRecord)
@Delete
fun delete(vararg record: ReadRecord)
@@ -58,4 +62,83 @@ interface ReadRecordDao {
@Query("delete from readRecord where bookName = :bookName")
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.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.HttpTTS
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.RssSource
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.BookProgress
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.book.BookHelp
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.withLock
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import splitties.init.appCtx
import java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.cancellation.CancellationException
@@ -58,7 +62,7 @@ import kotlin.math.min
@Suppress("MemberVisibilityCanBePrivate")
object ReadBook : CoroutineScope by MainScope() {
object ReadBook : CoroutineScope by MainScope(), KoinComponent {
var book: Book? = null
var callBack: CallBack? = null
var inBookshelf = false
@@ -73,6 +77,8 @@ object ReadBook : CoroutineScope by MainScope() {
var nextTextChapter: TextChapter? = null
var bookSource: BookSource? = null
var msg: String? = null
private val readRecordRepository: ReadRecordRepository by inject()
private var lastReadLength: Long = 0
private val loadingChapters = arrayListOf<Int>()
private val readRecord = ReadRecord()
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() {
executor.execute {
if (!AppConfig.enableReadRecord) {
return@execute
}
readRecord.readTime = readRecord.readTime + System.currentTimeMillis() - readStartTime
readStartTime = System.currentTimeMillis()
readRecord.lastRead = System.currentTimeMillis()
appDb.readRecordDao.insert(readRecord)
val currentLength = currentReadLength
if (!AppConfig.enableReadRecord) {
return
}
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?) {
@@ -6,7 +6,9 @@ import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookProgress
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.ConcurrentRateLimiter
import io.legado.app.help.book.BookHelp
@@ -39,10 +41,13 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import kotlin.getValue
import kotlin.math.min
@Suppress("MemberVisibilityCanBePrivate")
object ReadManga : CoroutineScope by MainScope() {
object ReadManga : CoroutineScope by MainScope() , KoinComponent{
var inBookshelf = false
var book: Book? = null
val executor = globalExecutor
@@ -68,6 +73,9 @@ object ReadManga : CoroutineScope by MainScope() {
val mangaContents get() = buildMangaContent()
val hasNextChapter get() = durChapterIndex < simulatedChapterSize - 1
private val readRecordRepository: ReadRecordRepository by inject()
private var lastReadLength: Long = 0
fun resetData(book: Book) {
ReadManga.book = book
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() {
executor.execute {
if (!AppConfig.enableReadRecord) {
return@execute
}
readRecord.readTime = readRecord.readTime + System.currentTimeMillis() - readStartTime
readStartTime = System.currentTimeMillis()
readRecord.lastRead = System.currentTimeMillis()
appDb.readRecordDao.insert(readRecord)
val currentLength = currentReadLength
if (!AppConfig.enableReadRecord) {
return
}
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
@@ -13,7 +13,6 @@ import android.view.animation.LinearInterpolator
import androidx.activity.addCallback
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.core.graphics.toColorInt
import androidx.core.view.doOnLayout
import androidx.core.view.isGone
import androidx.core.view.isVisible
@@ -445,6 +444,7 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
Backup.autoBack(this)
}
}
ReadManga.commitReadSession()
ReadManga.cancelPreDownloadTask()
networkChangedListener.unRegister()
mScrollTimer.isEnabledPage = false
@@ -337,8 +337,10 @@ class ReadBookActivity : BaseReadBookActivity(),
return@addCallback
}
if (savedInstanceState != null || !ReadBook.inBookshelf) {
ReadBook.commitReadSession()
finish()
} else {
ReadBook.commitReadSession()
supportFinishAfterTransition()
}
//TODO: 有关测量相关问题
@@ -421,6 +423,7 @@ class ReadBookActivity : BaseReadBookActivity(),
autoPageStop()
backupJob?.cancel()
ReadBook.saveRead()
ReadBook.commitReadSession()
ReadBook.cancelPreDownloadTask()
unregisterReceiver(timeBatteryReceiver)
upSystemUiVisibility()
@@ -41,7 +41,6 @@ import io.legado.app.model.ReadBook
import io.legado.app.ui.browser.WebViewActivity
import io.legado.app.ui.widget.seekbar.SeekBarChangeListener
import io.legado.app.utils.ConstraintModify
import io.legado.app.utils.VibrationUtils
import io.legado.app.utils.activity
import io.legado.app.utils.applyNavigationBarPadding
import io.legado.app.utils.dpToPx
@@ -451,14 +450,14 @@ class ReadMenu @JvmOverloads constructor(
if (fromUser) {
if (AppConfig.progressBarBehavior == "page")
ReadBook.skipToPage(value.toInt() - 1)
VibrationUtils.vibrate(context, 12)
//VibrationUtils.vibrate(context, 12)
}
}
seekReadPage.addOnSliderTouchListener(object : Slider.OnSliderTouchListener {
override fun onStartTrackingTouch(slider: Slider) {
vwMenuBg.setOnClickListener(null)
VibrationUtils.vibrate(context, 16)
//VibrationUtils.vibrate(context, 16)
}
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.prefs.SwitchPreference
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.ui.about.AboutActivity
import io.legado.app.ui.about.ReadRecordActivity
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.toc.rule.TxtTocRuleActivity
import io.legado.app.ui.config.ConfigActivity
@@ -8,6 +8,9 @@ import java.io.ByteArrayOutputStream
import java.io.IOException
import java.text.DecimalFormat
import java.text.SimpleDateFormat
import java.time.LocalDate
import java.time.ZoneId
import java.time.temporal.ChronoUnit
import java.util.Calendar
import java.util.Locale
import java.util.regex.Matcher
@@ -97,6 +100,30 @@ object StringUtils {
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
}
}
/**
* 首字母大写
*/