使用Compose重构的阅读记录模块的初始版本。
现在阅读记录功能更加强大,能够获取某一天所有书籍统计、可视化的阅读时间线等。 目前仅实现基本功能,且尚不完善,仅供尝鲜使用。 由于原版并无精细化的数据结构,所以时间线与日期查看仅在更新此版本后开始记录。原版的查看最后阅读功能仍可使用。
This commit is contained in:
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 首字母大写
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user