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