Merge remote-tracking branch 'origin/main'

# Conflicts:
#	app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt
This commit is contained in:
HapeLee
2026-02-21 02:08:37 +08:00
19 changed files with 2773 additions and 187 deletions
File diff suppressed because it is too large Load Diff
@@ -67,7 +67,7 @@ val appDb by lazy {
}
@Database(
version = 82,
version = 83,
exportSchema = true,
entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class,
ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class,
@@ -20,6 +20,7 @@ object DatabaseMigrations {
migration_31_32, migration_32_33, migration_33_34, migration_34_35,
migration_35_36, migration_36_37, migration_37_38, migration_38_39,
migration_39_40, migration_40_41, migration_41_42, migration_42_43,
migration_82_83,
)
}
@@ -324,6 +325,132 @@ object DatabaseMigrations {
}
}
private val migration_82_83 = object : Migration(82, 83) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE readRecord RENAME TO readRecord_old")
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `readRecord` (
`deviceId` TEXT NOT NULL,
`bookName` TEXT NOT NULL,
`bookAuthor` TEXT NOT NULL DEFAULT '',
`readTime` INTEGER NOT NULL DEFAULT 0,
`lastRead` INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(`deviceId`, `bookName`, `bookAuthor`)
)
"""
)
database.execSQL(
"""
INSERT INTO readRecord(deviceId, bookName, bookAuthor, readTime, lastRead)
SELECT
rr.deviceId,
rr.bookName,
IFNULL(
(
SELECT CASE
WHEN COUNT(DISTINCT b.author) = 1 THEN MAX(b.author)
ELSE ''
END
FROM books b
WHERE b.name = rr.bookName
),
''
) AS bookAuthor,
rr.readTime,
rr.lastRead
FROM readRecord_old rr
"""
)
database.execSQL("DROP TABLE readRecord_old")
database.execSQL("ALTER TABLE readRecordDetail RENAME TO readRecordDetail_old")
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `readRecordDetail` (
`deviceId` TEXT NOT NULL,
`bookName` TEXT NOT NULL,
`bookAuthor` TEXT NOT NULL DEFAULT '',
`date` TEXT NOT NULL,
`readTime` INTEGER NOT NULL DEFAULT 0,
`readWords` INTEGER NOT NULL DEFAULT 0,
`firstReadTime` INTEGER NOT NULL DEFAULT 0,
`lastReadTime` INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(`deviceId`, `bookName`, `bookAuthor`, `date`)
)
"""
)
database.execSQL(
"""
INSERT INTO readRecordDetail(
deviceId, bookName, bookAuthor, date, readTime, readWords, firstReadTime, lastReadTime
)
SELECT
rd.deviceId,
rd.bookName,
IFNULL(
(
SELECT CASE
WHEN COUNT(DISTINCT b.author) = 1 THEN MAX(b.author)
ELSE ''
END
FROM books b
WHERE b.name = rd.bookName
),
''
) AS bookAuthor,
rd.date,
rd.readTime,
rd.readWords,
rd.firstReadTime,
rd.lastReadTime
FROM readRecordDetail_old rd
"""
)
database.execSQL("DROP TABLE readRecordDetail_old")
database.execSQL("ALTER TABLE readRecordSession RENAME TO readRecordSession_old")
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `readRecordSession` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`deviceId` TEXT NOT NULL,
`bookName` TEXT NOT NULL,
`bookAuthor` TEXT NOT NULL DEFAULT '',
`startTime` INTEGER NOT NULL,
`endTime` INTEGER NOT NULL,
`words` INTEGER NOT NULL
)
"""
)
database.execSQL(
"""
INSERT INTO readRecordSession(id, deviceId, bookName, bookAuthor, startTime, endTime, words)
SELECT
rs.id,
rs.deviceId,
rs.bookName,
IFNULL(
(
SELECT CASE
WHEN COUNT(DISTINCT b.author) = 1 THEN MAX(b.author)
ELSE ''
END
FROM books b
WHERE b.name = rs.bookName
),
''
) AS bookAuthor,
rs.startTime,
rs.endTime,
rs.words
FROM readRecordSession_old rs
"""
)
database.execSQL("DROP TABLE readRecordSession_old")
}
}
@Suppress("ClassName")
class Migration_54_55 : AutoMigrationSpec {
@@ -371,4 +498,4 @@ object DatabaseMigrations {
)
class Migration_64_65 : AutoMigrationSpec
}
}
@@ -26,10 +26,10 @@ interface ReadRecordDao {
@get:Query(
"""
select bookName, sum(readTime) as readTime, max(lastRead) as lastRead
select bookName, bookAuthor, sum(readTime) as readTime, max(lastRead) as lastRead
from readRecord
group by bookName
order by bookName collate localized"""
group by bookName, bookAuthor
order by bookName collate localized, bookAuthor collate localized"""
)
val allShow: List<ReadRecordShow>
@@ -38,22 +38,22 @@ interface ReadRecordDao {
@Query(
"""
select bookName, sum(readTime) as readTime, max(lastRead) as lastRead
select bookName, bookAuthor, sum(readTime) as readTime, max(lastRead) as lastRead
from readRecord
where bookName like '%' || :searchKey || '%'
group by bookName
order by bookName collate localized"""
where bookName like '%' || :searchKey || '%' or bookAuthor like '%' || :searchKey || '%'
group by bookName, bookAuthor
order by bookName collate localized, bookAuthor collate localized"""
)
fun search(searchKey: String): List<ReadRecordShow>
@Query("select sum(readTime) from readRecord where bookName = :bookName")
fun getReadTime(bookName: String): Long?
@Query("select readTime from readRecord where deviceId = :deviceId and bookName = :bookName")
fun getReadTime(deviceId: String, bookName: String): Long?
@Query("select readTime from readRecord where deviceId = :deviceId and bookName = :bookName and bookAuthor = :bookAuthor")
fun getReadTime(deviceId: String, bookName: String, bookAuthor: String): Long?
@Query("SELECT * FROM readRecord WHERE deviceId = :deviceId AND bookName = :bookName")
suspend fun getReadRecord(deviceId: String, bookName: String): ReadRecord?
@Query("SELECT * FROM readRecord WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor")
suspend fun getReadRecord(deviceId: String, bookName: String, bookAuthor: String): ReadRecord?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(vararg readRecord: ReadRecord)
@@ -67,8 +67,8 @@ interface ReadRecordDao {
@Query("delete from readRecord")
fun clear()
@Query("delete from readRecord where bookName = :bookName")
fun deleteByName(bookName: String)
@Query("delete from readRecord where bookName = :bookName and bookAuthor = :bookAuthor")
fun deleteByName(bookName: String, bookAuthor: String)
/**
* 插入或更新每日聚合统计记录。
@@ -80,8 +80,8 @@ interface ReadRecordDao {
* 获取某一本书某一天的详细统计
* @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 * FROM readRecordDetail WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor AND date = :date")
suspend fun getDetail(deviceId: String, bookName: String, bookAuthor: String, date: String): ReadRecordDetail?
/**
* 查询所有发生过阅读的日期(用于日历标记)
@@ -96,15 +96,15 @@ interface ReadRecordDao {
suspend fun getDetailsByDate(deviceId: String, date: String): List<ReadRecordDetail>
// 清除每天的统计记录
@Query("DELETE FROM readRecordDetail WHERE bookName = :bookName")
fun deleteDetailByName(bookName: String)
@Query("DELETE FROM readRecordDetail WHERE bookName = :bookName AND bookAuthor = :bookAuthor")
fun deleteDetailByName(bookName: String, bookAuthor: String)
/**
* 获取指定书籍的最后一条阅读会话
* 用于判断是否可以合并
*/
@Query("SELECT * FROM readRecordSession WHERE bookName = :bookName ORDER BY endTime DESC LIMIT 1")
suspend fun getLatestSessionByBook(bookName: String): ReadRecordSession?
@Query("SELECT * FROM readRecordSession WHERE bookName = :bookName AND bookAuthor = :bookAuthor ORDER BY endTime DESC LIMIT 1")
suspend fun getLatestSessionByBook(bookName: String, bookAuthor: String): ReadRecordSession?
/**
* 更新现有的会话
@@ -123,9 +123,16 @@ interface ReadRecordDao {
fun getAllReadRecordsSortedByLastRead(): Flow<List<ReadRecord>>
/** 搜索 ReadRecord,按最后阅读时间倒序排列 */
@Query("SELECT * FROM readRecord WHERE bookName LIKE '%' || :query || '%' ORDER BY lastRead DESC")
@Query("SELECT * FROM readRecord WHERE bookName LIKE '%' || :query || '%' OR bookAuthor LIKE '%' || :query || '%' ORDER BY lastRead DESC")
fun searchReadRecordsByLastRead(query: String): Flow<List<ReadRecord>>
@Query("SELECT * FROM readRecord WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor != :excludeAuthor ORDER BY lastRead DESC")
suspend fun getReadRecordsByNameExcludingAuthor(
deviceId: String,
bookName: String,
excludeAuthor: String
): List<ReadRecord>
/**
* 获取某一天某一本书的所有会话记录
*/
@@ -133,10 +140,16 @@ interface ReadRecordDao {
SELECT * FROM readRecordSession
WHERE deviceId = :deviceId
AND bookName = :bookName
AND bookAuthor = :bookAuthor
AND STRFTIME('%Y-%m-%d', datetime(startTime/1000, 'unixepoch', 'localtime')) = :date
ORDER BY startTime ASC
""")
suspend fun getSessionsByBookAndDate(deviceId: String, bookName: String, date: String): List<ReadRecordSession>
suspend fun getSessionsByBookAndDate(
deviceId: String,
bookName: String,
bookAuthor: String,
date: String
): List<ReadRecordSession>
/**
* 获取某一天所有书籍的会话记录
@@ -149,24 +162,24 @@ interface ReadRecordDao {
""")
suspend fun getSessionsByDate(deviceId: String, date: String): List<ReadRecordSession>
@Query("SELECT * FROM readRecordDetail WHERE deviceId = :deviceId AND date = :date AND bookName LIKE '%' || :query || '%'")
@Query("SELECT * FROM readRecordDetail WHERE deviceId = :deviceId AND date = :date AND (bookName LIKE '%' || :query || '%' OR bookAuthor LIKE '%' || :query || '%')")
suspend fun searchDetailsByDate(deviceId: String, date: String, query: String): List<ReadRecordDetail>
// 清除会话记录
@Query("DELETE FROM readRecordSession WHERE bookName = :bookName")
fun deleteSessionByName(bookName: String)
@Query("DELETE FROM readRecordSession WHERE bookName = :bookName AND bookAuthor = :bookAuthor")
fun deleteSessionByName(bookName: String, bookAuthor: String)
@Query("SELECT * FROM readRecordDetail ORDER BY date DESC, lastReadTime DESC")
fun getAllDetails(): Flow<List<ReadRecordDetail>>
@Query("SELECT * FROM readRecordDetail WHERE bookName LIKE '%' || :query || '%' ORDER BY date DESC, lastReadTime DESC")
@Query("SELECT * FROM readRecordDetail WHERE bookName LIKE '%' || :query || '%' OR bookAuthor LIKE '%' || :query || '%' ORDER BY date DESC, lastReadTime DESC")
fun searchDetails(query: String): Flow<List<ReadRecordDetail>>
@Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId ORDER BY startTime ASC")
fun getAllSessions(deviceId: String): Flow<List<ReadRecordSession>>
@Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId AND bookName = :bookName")
suspend fun getSessionsByBook(deviceId: String, bookName: String): List<ReadRecordSession>
@Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor")
suspend fun getSessionsByBook(deviceId: String, bookName: String, bookAuthor: String): List<ReadRecordSession>
@Delete
suspend fun deleteDetail(detail: ReadRecordDetail)
@@ -176,10 +189,16 @@ interface ReadRecordDao {
DELETE FROM readRecordSession
WHERE deviceId = :deviceId
AND bookName = :bookName
AND bookAuthor = :bookAuthor
AND STRFTIME('%Y-%m-%d', datetime(startTime/1000, 'unixepoch', 'localtime')) = :date
"""
)
suspend fun deleteSessionsByBookAndDate(deviceId: String, bookName: String, date: String)
suspend fun deleteSessionsByBookAndDate(
deviceId: String,
bookName: String,
bookAuthor: String,
date: String
)
@Delete
suspend fun deleteSession(session: ReadRecordSession)
@@ -187,9 +206,12 @@ interface ReadRecordDao {
@Delete
suspend fun deleteReadRecord(record: ReadRecord)
@Query("DELETE FROM readRecordDetail WHERE deviceId = :deviceId AND bookName = :bookName")
suspend fun deleteDetailsByBook(deviceId: String, bookName: String)
@Query("DELETE FROM readRecordDetail WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor")
suspend fun deleteDetailsByBook(deviceId: String, bookName: String, bookAuthor: String)
@Query("DELETE FROM readRecordSession WHERE deviceId = :deviceId AND bookName = :bookName")
suspend fun deleteSessionsByBook(deviceId: String, bookName: String)
}
@Query("DELETE FROM readRecordSession WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor")
suspend fun deleteSessionsByBook(deviceId: String, bookName: String, bookAuthor: String)
@Query("SELECT * FROM readRecordDetail WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor")
suspend fun getDetailsByBook(deviceId: String, bookName: String, bookAuthor: String): List<ReadRecordDetail>
}
@@ -2,6 +2,7 @@ package io.legado.app.data.entities
data class ReadRecordShow(
var bookName: String,
var bookAuthor: String,
var readTime: Long,
var lastRead: Long
)
)
@@ -3,10 +3,12 @@ package io.legado.app.data.entities.readRecord
import androidx.room.ColumnInfo
import androidx.room.Entity
@Entity(tableName = "readRecord", primaryKeys = ["deviceId", "bookName"])
@Entity(tableName = "readRecord", primaryKeys = ["deviceId", "bookName", "bookAuthor"])
data class ReadRecord(
var deviceId: String = "",
var bookName: String = "",
@ColumnInfo(defaultValue = "")
var bookAuthor: String = "",
@ColumnInfo(defaultValue = "0")
var readTime: Long = 0L,
@ColumnInfo(defaultValue = "0")
@@ -5,11 +5,13 @@ import androidx.room.Entity
@Entity(
tableName = "readRecordDetail",
primaryKeys = ["deviceId", "bookName", "date"]
primaryKeys = ["deviceId", "bookName", "bookAuthor", "date"]
)
data class ReadRecordDetail(
val deviceId: String = "",
val bookName: String = "",
@ColumnInfo(defaultValue = "")
val bookAuthor: String = "",
val date: String = "",
// 当天阅读总时长
@@ -10,6 +10,7 @@ data class ReadRecordSession(
val deviceId: String = "",
val bookName: String = "",
val bookAuthor: String = "",
// 一次阅读的开始/结束
val startTime: Long = 0,
@@ -11,15 +11,15 @@ class BookRepository(
private val bookDao: BookDao,
private val bookChapterDao: BookChapterDao
) {
suspend fun getBookCoverByName(bookName: String): String? {
suspend fun getBookCoverByNameAndAuthor(bookName: String, bookAuthor: String): String? {
return withContext(Dispatchers.IO) {
bookDao.findByName(bookName).firstOrNull()?.getDisplayCover()
bookDao.getBook(bookName, bookAuthor)?.getDisplayCover()
}
}
suspend fun getChapterTitle(bookName: String, chapterIndex: Int): String? {
suspend fun getChapterTitle(bookName: String, bookAuthor: String, chapterIndex: Int): String? {
return withContext(Dispatchers.IO) {
val book = bookDao.findByName(bookName).firstOrNull()
val book = bookDao.getBook(bookName, bookAuthor)
val bookUrl = book?.bookUrl
if (bookUrl.isNullOrEmpty()) return@withContext null
@@ -51,6 +51,14 @@ class ReadRecordRepository(
return dao.getAllSessions(getCurrentDeviceId())
}
suspend fun getMergeCandidates(targetRecord: ReadRecord): List<ReadRecord> {
return dao.getReadRecordsByNameExcludingAuthor(
targetRecord.deviceId,
targetRecord.bookName,
targetRecord.bookAuthor
)
}
/**
* 保存一个完整的阅读会话.
*/
@@ -64,21 +72,22 @@ class ReadRecordRepository(
private suspend fun updateReadRecord(session: ReadRecordSession, durationDelta: Long) {
if (durationDelta <= 0) return
val existingRecord = dao.getReadRecord(session.deviceId, session.bookName)
val existingRecord = dao.getReadRecord(session.deviceId, session.bookName, session.bookAuthor)
if (existingRecord != null) {
dao.update(
existingRecord.copy(
readTime = existingRecord.readTime + durationDelta,
lastRead = session.endTime
readTime = existingRecord.readTime + durationDelta,
lastRead = session.endTime
)
)
} else {
dao.insert(
ReadRecord(
deviceId = session.deviceId,
bookName = session.bookName,
readTime = durationDelta,
lastRead = session.endTime
deviceId = session.deviceId,
bookName = session.bookName,
bookAuthor = session.bookAuthor,
readTime = durationDelta,
lastRead = session.endTime
)
)
}
@@ -91,7 +100,12 @@ class ReadRecordRepository(
dateString: String
) {
if (durationDelta <= 0 && wordsDelta <= 0) return
val existingDetail = dao.getDetail(session.deviceId, session.bookName, dateString)
val existingDetail = dao.getDetail(
session.deviceId,
session.bookName,
session.bookAuthor,
dateString
)
if (existingDetail != null) {
existingDetail.readTime += durationDelta
existingDetail.readWords += wordsDelta
@@ -101,13 +115,14 @@ class ReadRecordRepository(
} else {
dao.insertDetail(
ReadRecordDetail(
deviceId = session.deviceId,
bookName = session.bookName,
date = dateString,
readTime = durationDelta,
readWords = wordsDelta,
firstReadTime = session.startTime,
lastReadTime = session.endTime
deviceId = session.deviceId,
bookName = session.bookName,
bookAuthor = session.bookAuthor,
date = dateString,
readTime = durationDelta,
readWords = wordsDelta,
firstReadTime = session.startTime,
lastReadTime = session.endTime
)
)
}
@@ -115,8 +130,13 @@ class ReadRecordRepository(
suspend fun deleteDetail(detail: ReadRecordDetail) {
dao.deleteDetail(detail)
dao.deleteSessionsByBookAndDate(detail.deviceId, detail.bookName, detail.date)
updateReadRecordTotal(detail.deviceId, detail.bookName)
dao.deleteSessionsByBookAndDate(
detail.deviceId,
detail.bookName,
detail.bookAuthor,
detail.date
)
updateReadRecordTotal(detail.deviceId, detail.bookName, detail.bookAuthor)
}
@Transaction
@@ -125,10 +145,20 @@ class ReadRecordRepository(
val dateString = DateUtil.format(Date(session.startTime), "yyyy-MM-dd")
val remainingSessions =
dao.getSessionsByBookAndDate(session.deviceId, session.bookName, dateString)
dao.getSessionsByBookAndDate(
session.deviceId,
session.bookName,
session.bookAuthor,
dateString
)
if (remainingSessions.isEmpty()) {
val detail = dao.getDetail(session.deviceId, session.bookName, dateString)
val detail = dao.getDetail(
session.deviceId,
session.bookName,
session.bookAuthor,
dateString
)
detail?.let { dao.deleteDetail(it) }
} else {
val totalTime = remainingSessions.sumOf { it.endTime - it.startTime }
@@ -136,7 +166,12 @@ class ReadRecordRepository(
val firstRead = remainingSessions.minOf { it.startTime }
val lastRead = remainingSessions.maxOf { it.endTime }
val existingDetail = dao.getDetail(session.deviceId, session.bookName, dateString)
val existingDetail = dao.getDetail(
session.deviceId,
session.bookName,
session.bookAuthor,
dateString
)
existingDetail?.copy(
readTime = totalTime,
readWords = totalWords,
@@ -145,19 +180,19 @@ class ReadRecordRepository(
)?.let { dao.insertDetail(it) }
}
updateReadRecordTotal(session.deviceId, session.bookName)
updateReadRecordTotal(session.deviceId, session.bookName, session.bookAuthor)
}
private suspend fun updateReadRecordTotal(deviceId: String, bookName: String) {
val allRemainingSessions = dao.getSessionsByBook(deviceId, bookName)
private suspend fun updateReadRecordTotal(deviceId: String, bookName: String, bookAuthor: String) {
val allRemainingSessions = dao.getSessionsByBook(deviceId, bookName, bookAuthor)
if (allRemainingSessions.isEmpty()) {
dao.getReadRecord(deviceId, bookName)?.let { dao.deleteReadRecord(it) }
dao.getReadRecord(deviceId, bookName, bookAuthor)?.let { dao.deleteReadRecord(it) }
} else {
val totalTime = allRemainingSessions.sumOf { it.endTime - it.startTime }
val lastRead = allRemainingSessions.maxOf { it.endTime }
dao.getReadRecord(deviceId, bookName)?.copy(
dao.getReadRecord(deviceId, bookName, bookAuthor)?.copy(
readTime = totalTime,
lastRead = lastRead
)?.let { dao.update(it) }
@@ -166,8 +201,84 @@ class ReadRecordRepository(
suspend fun deleteReadRecord(record: ReadRecord) {
dao.deleteReadRecord(record)
dao.deleteDetailsByBook(record.deviceId, record.bookName)
dao.deleteSessionsByBook(record.deviceId, record.bookName)
dao.deleteDetailsByBook(record.deviceId, record.bookName, record.bookAuthor)
dao.deleteSessionsByBook(record.deviceId, record.bookName, record.bookAuthor)
}
}
@Transaction
suspend fun mergeReadRecordInto(targetRecord: ReadRecord, sourceRecords: List<ReadRecord>) {
sourceRecords.forEach { sourceRecord ->
mergeSingleReadRecordInto(targetRecord, sourceRecord)
}
}
@Transaction
private suspend fun mergeSingleReadRecordInto(targetRecord: ReadRecord, sourceRecord: ReadRecord) {
if (targetRecord == sourceRecord) return
if (targetRecord.deviceId != sourceRecord.deviceId) return
if (targetRecord.bookName != sourceRecord.bookName) return
val source = dao.getReadRecord(
sourceRecord.deviceId,
sourceRecord.bookName,
sourceRecord.bookAuthor
) ?: return
val target = dao.getReadRecord(
targetRecord.deviceId,
targetRecord.bookName,
targetRecord.bookAuthor
) ?: targetRecord
dao.insert(
target.copy(
readTime = target.readTime + source.readTime,
lastRead = max(target.lastRead, source.lastRead)
)
)
val sourceDetails = dao.getDetailsByBook(
sourceRecord.deviceId,
sourceRecord.bookName,
sourceRecord.bookAuthor
)
sourceDetails.forEach { detail ->
val existingTargetDetail = dao.getDetail(
targetRecord.deviceId,
targetRecord.bookName,
targetRecord.bookAuthor,
detail.date
)
if (existingTargetDetail == null) {
dao.insertDetail(
detail.copy(
bookAuthor = targetRecord.bookAuthor
)
)
} else {
dao.insertDetail(
existingTargetDetail.copy(
readTime = existingTargetDetail.readTime + detail.readTime,
readWords = existingTargetDetail.readWords + detail.readWords,
firstReadTime = min(existingTargetDetail.firstReadTime, detail.firstReadTime),
lastReadTime = max(existingTargetDetail.lastReadTime, detail.lastReadTime)
)
)
}
}
dao.deleteDetailsByBook(sourceRecord.deviceId, sourceRecord.bookName, sourceRecord.bookAuthor)
val sourceSessions = dao.getSessionsByBook(
sourceRecord.deviceId,
sourceRecord.bookName,
sourceRecord.bookAuthor
)
sourceSessions.forEach { session ->
dao.updateSession(session.copy(bookAuthor = targetRecord.bookAuthor))
}
dao.deleteReadRecord(source)
updateReadRecordTotal(targetRecord.deviceId, targetRecord.bookName, targetRecord.bookAuthor)
}
}
@@ -121,6 +121,29 @@ fun Book.contains(word: String?): Boolean {
|| intro?.contains(word) == true
}
/**
* 仅在目标bookUrl未被其他书占用,或判定为同一本书时,允许迁移主键。
*/
fun Book.canSafelyRebindTo(newBookUrl: String): Boolean {
if (newBookUrl == bookUrl) return true
val targetBook = appDb.bookDao.getBook(newBookUrl) ?: return true
val sameOriginName = originName.isNotBlank() && originName == targetBook.originName
val sameNameAuthor = name.isNotBlank() && author.isNotBlank()
&& name == targetBook.name && author == targetBook.author
val sameOrigin = origin.isNotBlank() && origin == targetBook.origin
val canMerge = sameOriginName && (sameNameAuthor || sameOrigin)
if (!canMerge) {
AppLog.put(
"书籍重定位冲突,已跳过迁移\n" +
"old=$bookUrl\nnew=$newBookUrl\n" +
"oldName=$name oldAuthor=$author oldOriginName=$originName\n" +
"targetName=${targetBook.name} targetAuthor=${targetBook.author} targetOriginName=${targetBook.originName}"
)
}
return canMerge
}
private val localUriCache by lazy {
ConcurrentHashMap<String, Uri>()
}
@@ -150,17 +173,31 @@ fun Book.getLocalUri(): Uri {
// 查找书籍保存目录
if (!defaultBookDir.isNullOrBlank()) {
val treeUri = Uri.parse(defaultBookDir)
val treeUri = defaultBookDir.toUri()
val treeFileDoc = FileDoc.fromUri(treeUri, true)
if (!treeFileDoc.exists()) {
appCtx.toastOnUi("书籍保存目录失效,请重新设置!")
} else {
val fileDoc = treeFileDoc.find(originName, 5, 100)
if (fileDoc != null) {
localUriCache[bookUrl] = fileDoc.uri
//更新bookUrl 重启不用再找一遍
bookUrl = fileDoc.toString()
save()
val newBookUrl = fileDoc.toString()
val oldBook = copy()
if (!oldBook.canSafelyRebindTo(newBookUrl)) {
return fileDoc.uri
}
appDb.runInTransaction {
if (oldBook.bookUrl == newBookUrl) {
save()
} else {
val newBook = oldBook.copy(bookUrl = newBookUrl)
appDb.bookDao.replace(oldBook, newBook)
BookHelp.updateCacheFolder(oldBook, newBook)
this.bookUrl = newBookUrl
}
}
localUriCache[newBookUrl] = fileDoc.uri
return fileDoc.uri
}
}
@@ -176,9 +213,23 @@ fun Book.getLocalUri(): Uri {
val treeFileDoc = FileDoc.fromUri(treeUri, true)
val fileDoc = treeFileDoc.find(originName, 5, 100)
if (fileDoc != null) {
localUriCache[bookUrl] = fileDoc.uri
bookUrl = fileDoc.toString()
save()
val newBookUrl = fileDoc.toString()
val oldBook = copy()
if (!oldBook.canSafelyRebindTo(newBookUrl)) {
return fileDoc.uri
}
appDb.runInTransaction {
if (oldBook.bookUrl == newBookUrl) {
save()
} else {
val newBook = oldBook.copy(bookUrl = newBookUrl)
appDb.bookDao.replace(oldBook, newBook)
BookHelp.updateCacheFolder(oldBook, newBook)
this.bookUrl = newBookUrl
}
}
localUriCache[newBookUrl] = fileDoc.uri
return fileDoc.uri
}
}
@@ -438,4 +489,4 @@ fun tryParesExportFileName(jsStr: String): Boolean {
RhinoScriptEngine.eval(jsStr, bindings)
true
}.getOrDefault(false)
}
}
@@ -177,7 +177,7 @@ object Restore {
appDb.readRecordDao.insert(readRecord)
} else {
val time = appDb.readRecordDao
.getReadTime(readRecord.deviceId, readRecord.bookName)
.getReadTime(readRecord.deviceId, readRecord.bookName, readRecord.bookAuthor)
if (time == null || time < readRecord.readTime) {
appDb.readRecordDao.insert(readRecord)
}
@@ -325,4 +325,4 @@ object Restore {
return null
}
}
}
@@ -117,7 +117,8 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
fun resetData(book: Book) {
ReadBook.book = book
readRecord.bookName = book.name
readRecord.readTime = appDb.readRecordDao.getReadTime(book.name) ?: 0
readRecord.bookAuthor = book.author
readRecord.readTime = appDb.readRecordDao.getReadTime("", book.name, book.author) ?: 0
chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl)
simulatedChapterSize = if (book.readSimulating()) {
book.simulatedTotalChapterNum()
@@ -307,7 +308,10 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
fun initReadTime() {
val currentBookName = book?.name ?: return
if (currentActiveSession != null && currentActiveSession!!.bookName != currentBookName) {
val currentBookAuthor = book?.author ?: ""
if (currentActiveSession != null &&
(currentActiveSession!!.bookName != currentBookName || currentActiveSession!!.bookAuthor != currentBookAuthor)
) {
commitReadSession()
}
@@ -316,6 +320,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
currentActiveSession = ReadRecordSession(
deviceId = "",
bookName = currentBookName,
bookAuthor = currentBookAuthor,
startTime = readStartTime,
endTime = readStartTime,
words = durChapterIndex.toLong()
@@ -326,9 +331,13 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
fun upReadTime() {
val currentLength = currentReadLength
val currentBookName = book?.name ?: return
val currentBookAuthor = book?.author ?: ""
val endTime = System.currentTimeMillis()
if (currentActiveSession == null || currentActiveSession!!.bookName != currentBookName) {
if (currentActiveSession == null ||
currentActiveSession!!.bookName != currentBookName ||
currentActiveSession!!.bookAuthor != currentBookAuthor
) {
initReadTime()
return
}
@@ -1131,4 +1140,4 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
fun cancelSelect()
}
}
}
@@ -90,7 +90,8 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
fun resetData(book: Book) {
ReadManga.book = book
readRecord.bookName = book.name
readRecord.readTime = appDb.readRecordDao.getReadTime(book.name) ?: 0
readRecord.bookAuthor = book.author
readRecord.readTime = appDb.readRecordDao.getReadTime("", book.name, book.author) ?: 0
chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl)
simulatedChapterSize = if (book.readSimulating()) {
book.simulatedTotalChapterNum()
@@ -147,7 +148,10 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
fun initReadTime() {
val currentBookName = book?.name ?: return
if (currentActiveSession != null && currentActiveSession!!.bookName != currentBookName) {
val currentBookAuthor = book?.author ?: ""
if (currentActiveSession != null &&
(currentActiveSession!!.bookName != currentBookName || currentActiveSession!!.bookAuthor != currentBookAuthor)
) {
commitReadSession()
}
@@ -156,6 +160,7 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
currentActiveSession = ReadRecordSession(
deviceId = "",
bookName = currentBookName,
bookAuthor = currentBookAuthor,
startTime = readStartTime,
endTime = readStartTime,
words = durChapterIndex.toLong()
@@ -166,9 +171,13 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
fun upReadTime() {
val currentLength = currentReadLength
val currentBookName = book?.name ?: return
val currentBookAuthor = book?.author ?: ""
val endTime = System.currentTimeMillis()
if (currentActiveSession == null || currentActiveSession!!.bookName != currentBookName) {
if (currentActiveSession == null ||
currentActiveSession!!.bookName != currentBookName ||
currentActiveSession!!.bookAuthor != currentBookAuthor
) {
initReadTime()
return
}
@@ -23,6 +23,8 @@ import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.ContentProcessor
import io.legado.app.help.book.addType
import io.legado.app.help.book.archiveName
import io.legado.app.help.book.cacheLocalUri
import io.legado.app.help.book.canSafelyRebindTo
import io.legado.app.help.book.getArchiveUri
import io.legado.app.help.book.getLocalUri
import io.legado.app.help.book.getRemoteUrl
@@ -37,6 +39,7 @@ import io.legado.app.help.config.AppConfig
import io.legado.app.lib.webdav.WebDav
import io.legado.app.lib.webdav.WebDavException
import io.legado.app.model.analyzeRule.AnalyzeUrl
import io.legado.app.model.analyzeRule.CustomUrl
import io.legado.app.utils.ArchiveUtils
import io.legado.app.utils.FileDoc
import io.legado.app.utils.FileUtils
@@ -50,6 +53,7 @@ import io.legado.app.utils.isAbsUrl
import io.legado.app.utils.isContentScheme
import io.legado.app.utils.isDataUrl
import io.legado.app.utils.printOnDebug
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.runBlocking
import org.apache.commons.text.StringEscapeUtils
import splitties.init.appCtx
@@ -60,7 +64,6 @@ import java.io.FileNotFoundException
import java.io.FileOutputStream
import java.io.InputStream
import java.util.regex.Pattern
import kotlin.coroutines.coroutineContext
/**
* 书籍文件导入 目录正文解析
@@ -403,7 +406,7 @@ object LocalBook {
val inputStream = when {
str.isAbsUrl() -> AnalyzeUrl(
str, source = source, callTimeout = 0,
coroutineContext = coroutineContext
coroutineContext = currentCoroutineContext()
).getInputStreamAwait()
str.isDataUrl() -> ByteArrayInputStream(
@@ -458,7 +461,7 @@ object LocalBook {
fun isOnBookShelf(
fileName: String
): Boolean {
return appDb.bookDao.hasFile(fileName) == true
return appDb.bookDao.hasFile(fileName)
}
//文件类书源 合并在线书籍信息 在线 > 本地
@@ -501,9 +504,31 @@ object LocalBook {
localBook.bookUrl = newBook.bookUrl
} else {
// txt epub pdf umd
val oldBook = localBook.copy()
val fileUri = saveBookFile(it, localBook.originName)
localBook.bookUrl = FileDoc.fromUri(fileUri, false).toString()
localBook.save()
val newBookUrl = FileDoc.fromUri(fileUri, false).toString()
if (!oldBook.canSafelyRebindTo(newBookUrl)) {
localBook.cacheLocalUri(fileUri)
return true
}
appDb.runInTransaction {
if (oldBook.bookUrl == newBookUrl) {
localBook.origin =
BookType.webDavTag + CustomUrl(webDavUrl).toString()
localBook.save()
} else {
val newBook = oldBook.copy(
bookUrl = newBookUrl,
origin = BookType.webDavTag + CustomUrl(webDavUrl).toString()
)
appDb.bookDao.replace(oldBook, newBook)
BookHelp.updateCacheFolder(oldBook, newBook)
localBook.bookUrl = newBookUrl
localBook.origin = newBook.origin
}
}
}
}
return true
@@ -26,10 +26,10 @@ class ReadRecordActivity : BaseComposeActivity() {
AppTheme {
ReadRecordScreen(
onBackClick = { finish() },
onBookClick = { bookName ->
onBookClick = { bookName, bookAuthor ->
lifecycleScope.launch {
val book = withContext(Dispatchers.IO) {
appDb.bookDao.findByName(bookName).firstOrNull()
appDb.bookDao.getBook(bookName, bookAuthor)
}
if (book != null) startActivityForBook(book)
else {
@@ -5,6 +5,7 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -26,6 +27,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Merge
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Timeline
@@ -39,6 +41,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -51,6 +55,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -65,8 +70,11 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import cn.hutool.core.date.DateUtil
@@ -93,6 +101,7 @@ import io.legado.app.ui.widget.components.heatmap.rememberWeeks
import io.legado.app.ui.widget.components.swipe.SwipeAction
import io.legado.app.ui.widget.components.swipe.SwipeActionContainer
import io.legado.app.utils.StringUtils.formatFriendlyDate
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import java.time.LocalDate
import java.time.format.DateTimeFormatter
@@ -103,8 +112,11 @@ import java.util.Date
fun ReadRecordScreen(
viewModel: ReadRecordViewModel = koinViewModel(),
onBackClick: () -> Unit,
onBookClick: (String) -> Unit
onBookClick: (String, String) -> Unit
) {
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val state by viewModel.uiState.collectAsState()
val displayMode by viewModel.displayMode.collectAsState()
var showSearch by remember { mutableStateOf(false) }
@@ -114,6 +126,7 @@ fun ReadRecordScreen(
var skipDeleteConfirm by remember { mutableStateOf(false) }
var pendingDeleteAction by remember { mutableStateOf<(() -> Unit)?>(null) }
var mergeDialogData by remember { mutableStateOf<Pair<ReadRecord, List<ReadRecord>>?>(null) }
val onConfirmDelete: (() -> Unit) -> Unit = { action ->
if (skipDeleteConfirm) {
action()
@@ -130,6 +143,7 @@ fun ReadRecordScreen(
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
Column {
GlassMediumFlexibleTopAppBar(
@@ -231,7 +245,17 @@ fun ReadRecordScreen(
state = state,
viewModel = viewModel,
onBookClick = onBookClick,
onConfirmDelete = onConfirmDelete
onConfirmDelete = onConfirmDelete,
onMergeClick = { record ->
scope.launch {
val candidates = viewModel.getMergeCandidates(record)
if (candidates.isEmpty()) {
snackbarHostState.showSnackbar("没有可合并的同名记录")
} else {
mergeDialogData = record to candidates
}
}
}
)
}
}
@@ -315,6 +339,65 @@ fun ReadRecordScreen(
}
}
mergeDialogData?.let { (targetRecord, candidates) ->
var selectedAuthors by remember(targetRecord, candidates) {
mutableStateOf(candidates.map { it.bookAuthor }.toSet())
}
AlertDialog(
onDismissRequest = { mergeDialogData = null },
title = { Text("合并阅读记录") },
text = {
Column {
Text("将以下作者的“${targetRecord.bookName}”合并到 ${targetRecord.bookAuthor.ifBlank { "未知作者" }}")
Spacer(modifier = Modifier.height(8.dp))
candidates.forEach { candidate ->
val author = candidate.bookAuthor.ifBlank { "未知作者" }
val isChecked = selectedAuthors.contains(candidate.bookAuthor)
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
selectedAuthors =
if (isChecked) selectedAuthors - candidate.bookAuthor
else selectedAuthors + candidate.bookAuthor
}
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(checked = isChecked, onCheckedChange = null)
Text(
text = "$author${formatDuring(candidate.readTime)}",
modifier = Modifier.padding(start = 8.dp),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
},
confirmButton = {
TextButton(
onClick = {
viewModel.mergeReadRecords(
targetRecord,
candidates.filter { selectedAuthors.contains(it.bookAuthor) }
)
mergeDialogData = null
}
) {
Text("合并")
}
},
dismissButton = {
TextButton(onClick = { mergeDialogData = null }) {
Text("取消")
}
}
)
}
}
@Composable
@@ -329,7 +412,7 @@ fun SummarySection(
val dailyDetails = state.groupedRecords[dateKey] ?: emptyList()
if (dailyDetails.isNotEmpty()) {
val distinctBooks = dailyDetails.map { it.bookName }.distinct()
val distinctBooks = dailyDetails.map { it.bookName to it.bookAuthor }.distinct()
val dailyTime = dailyDetails.sumOf { it.readTime }
ReadingSummaryCard(
@@ -350,7 +433,7 @@ fun SummarySection(
title = "累计阅读成就",
bookCount = allBooksCount,
totalTimeMillis = totalTime,
bookNamesForCover = state.latestRecords.take(5).map { it.bookName },
bookNamesForCover = state.latestRecords.take(5).map { it.bookName to it.bookAuthor },
viewModel = viewModel,
onClick = { }
)
@@ -495,16 +578,18 @@ fun LazyListScope.renderListByMode(
displayMode: DisplayMode,
state: ReadRecordUiState,
viewModel: ReadRecordViewModel,
onBookClick: (String) -> Unit,
onConfirmDelete: (() -> Unit) -> Unit
onBookClick: (String, String) -> Unit,
onConfirmDelete: (() -> Unit) -> Unit,
onMergeClick: (ReadRecord) -> Unit
) {
when (displayMode) {
DisplayMode.AGGREGATE -> {
state.groupedRecords.forEach { (date, details) ->
stickyHeader(key = "header_$date") {
DateHeader(date, details.sumOf { it.readTime })
}
items(items = details, key = { "${it.bookName}_${it.date}" }) { detail ->
items(items = details, key = { "${it.bookName}_${it.bookAuthor}_${it.date}" }) { detail ->
SwipeActionContainer(
modifier = Modifier.animateItem(),
startAction = SwipeAction(
@@ -518,7 +603,7 @@ fun LazyListScope.renderListByMode(
ReadRecordItem(
detail,
viewModel,
onClick = { onBookClick(detail.bookName) })
onClick = { onBookClick(detail.bookName, detail.bookAuthor) })
}
}
}
@@ -549,7 +634,7 @@ fun LazyListScope.renderListByMode(
}
DisplayMode.LATEST -> {
items(items = state.latestRecords, key = { it.bookName }) { record ->
items(items = state.latestRecords, key = { "${it.bookName}_${it.bookAuthor}" }) { record ->
SwipeActionContainer(
modifier = Modifier.animateItem(),
startAction = SwipeAction(
@@ -558,16 +643,23 @@ fun LazyListScope.renderListByMode(
onSwipe = {
onConfirmDelete { viewModel.deleteReadRecord(record) }
}
),
endAction = SwipeAction(
icon = Icons.Default.Merge,
background = MaterialTheme.colorScheme.primary,
onSwipe = {
onMergeClick(record)
}
)
) {
LatestReadItem(
record = record,
viewModel = viewModel,
onClick = { onBookClick(record.bookName) }
)
LatestReadItem(
record = record,
viewModel = viewModel,
onClick = { onBookClick(record.bookName, record.bookAuthor) }
)
}
}
}
}
}
}
@@ -580,8 +672,8 @@ fun LatestReadItem(
) {
var coverPath by remember { mutableStateOf<String?>(null) }
LaunchedEffect(record.bookName) {
coverPath = viewModel.getBookCover(record.bookName)
LaunchedEffect(record.bookName, record.bookAuthor) {
coverPath = viewModel.getBookCover(record.bookName, record.bookAuthor)
}
Row(
@@ -599,18 +691,36 @@ fun LatestReadItem(
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
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "最后阅读: ${DateUtil.format(Date(record.lastRead), "yyyy-MM-dd HH:mm")}",
text = record.bookAuthor.ifBlank { "未知作者" },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
color = MaterialTheme.colorScheme.outline,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
Text(
modifier = Modifier
.basicMarquee(
iterations = Int.MAX_VALUE,
repeatDelayMillis = 2000,
initialDelayMillis = 1000
),
text = buildAnnotatedString {
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.outline)) {
append(formatDuring(record.readTime))
append("")
}
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {
append("${DateUtil.format(Date(record.lastRead), "yyyy-MM-dd HH:mm")}")
}
},
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@@ -620,15 +730,15 @@ fun LatestReadItem(
fun TimelineSessionItem(
item: TimelineItem,
viewModel: ReadRecordViewModel,
onBookClick: (String) -> Unit
onBookClick: (String, String) -> Unit
) {
val session = item.session
var coverPath by remember { mutableStateOf<String?>(null) }
var chapterTitle by remember { mutableStateOf<String?>("加载中...") }
LaunchedEffect(session.bookName) {
coverPath = viewModel.getBookCover(session.bookName)
val title = viewModel.getChapterTitle(session.bookName, session.words)
LaunchedEffect(session.bookName, session.bookAuthor) {
coverPath = viewModel.getBookCover(session.bookName, session.bookAuthor)
val title = viewModel.getChapterTitle(session.bookName, session.bookAuthor, session.words)
chapterTitle = title ?: "${session.words}"
}
@@ -645,7 +755,7 @@ fun TimelineSessionItem(
Box(
modifier = Modifier
.fillMaxWidth()
.clickable { onBookClick(session.bookName) }
.clickable { onBookClick(session.bookName, session.bookAuthor) }
.drawBehind {
val x = timelineX.toPx()
val h = size.height
@@ -690,14 +800,21 @@ fun TimelineSessionItem(
Text(
text = session.bookName,
style = MaterialTheme.typography.titleMedium,
maxLines = 2,
minLines = 2,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = chapterTitle.orEmpty(),
text = session.bookAuthor.ifBlank { "未知作者" },
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
color = MaterialTheme.colorScheme.outline,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = chapterTitle.orEmpty(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -716,8 +833,8 @@ fun ReadRecordItem(
) {
var coverPath by remember { mutableStateOf<String?>(null) }
LaunchedEffect(detail.bookName) {
coverPath = viewModel.getBookCover(detail.bookName)
LaunchedEffect(detail.bookName, detail.bookAuthor) {
coverPath = viewModel.getBookCover(detail.bookName, detail.bookAuthor)
}
Row(
@@ -735,13 +852,18 @@ fun ReadRecordItem(
Text(
text = detail.bookName,
style = MaterialTheme.typography.titleMedium,
maxLines = 2
maxLines = 1
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = detail.bookAuthor.ifBlank { "未知作者" },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "阅读时长: ${formatDuring(detail.readTime)}",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
color = MaterialTheme.colorScheme.outline,
style = MaterialTheme.typography.labelSmall
)
}
}
@@ -782,14 +904,14 @@ fun ReadingSummaryCard(
title: String,
bookCount: Int,
totalTimeMillis: Long,
bookNamesForCover: List<String>,
bookNamesForCover: List<Pair<String, String>>,
viewModel: ReadRecordViewModel,
onClick: () -> Unit
) {
val coverPaths by produceState(initialValue = emptyList(), key1 = bookNamesForCover) {
value = bookNamesForCover.map { name ->
viewModel.getBookCover(name)
value = bookNamesForCover.map { (name, author) ->
viewModel.getBookCover(name, author)
}
}
@@ -899,4 +1021,4 @@ fun formatDuring(mss: Long): String {
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"
}
}
@@ -91,10 +91,9 @@ class ReadRecordViewModel(
.filter { session ->
val sDate = DateUtil.format(Date(session.startTime), "yyyy-MM-dd")
(dateStr == null || sDate == dateStr) &&
(searchKey.isEmpty() || session.bookName.contains(
searchKey,
ignoreCase = true
))
(searchKey.isEmpty() ||
session.bookName.contains(searchKey, ignoreCase = true) ||
session.bookAuthor.contains(searchKey, ignoreCase = true))
}
.groupBy { DateUtil.format(Date(it.startTime), "yyyy-MM-dd") }
.mapValues { (_, sessions) ->
@@ -152,7 +151,10 @@ class ReadRecordViewModel(
for (i in 1 until sessions.size) {
val current = sessions[i]
val last = mergedList.last()
if (current.bookName == last.bookName && (current.startTime - last.endTime) <= gapLimit) {
if (current.bookName == last.bookName &&
current.bookAuthor == last.bookAuthor &&
(current.startTime - last.endTime) <= gapLimit
) {
mergedList[mergedList.lastIndex] = last.copy(endTime = current.endTime)
} else {
mergedList.add(current.copy())
@@ -161,12 +163,23 @@ class ReadRecordViewModel(
return mergedList
}
suspend fun getChapterTitle(bookName: String, chapterIndexLong: Long): String? {
return bookRepository.getChapterTitle(bookName, chapterIndexLong.toInt())
suspend fun getChapterTitle(bookName: String, bookAuthor: String, chapterIndexLong: Long): String? {
return bookRepository.getChapterTitle(bookName, bookAuthor, chapterIndexLong.toInt())
}
suspend fun getBookCover(bookName: String): String? {
return bookRepository.getBookCoverByName(bookName)
suspend fun getBookCover(bookName: String, bookAuthor: String): String? {
return bookRepository.getBookCoverByNameAndAuthor(bookName, bookAuthor)
}
suspend fun getMergeCandidates(targetRecord: ReadRecord): List<ReadRecord> {
return repository.getMergeCandidates(targetRecord)
}
fun mergeReadRecords(targetRecord: ReadRecord, sourceRecords: List<ReadRecord>) {
if (sourceRecords.isEmpty()) return
viewModelScope.launch {
repository.mergeReadRecordInto(targetRecord, sourceRecords)
}
}
private data class LoadedData(
@@ -175,4 +188,4 @@ class ReadRecordViewModel(
val latestRecords: List<ReadRecord>,
val sessions: List<ReadRecordSession>
)
}
}
@@ -31,6 +31,7 @@ import androidx.compose.ui.unit.dp
fun SwipeActionContainer(
modifier: Modifier = Modifier,
startAction: SwipeAction? = null,
endAction: SwipeAction? = null,
content: @Composable () -> Unit
) {
val haptic = LocalHapticFeedback.current
@@ -39,8 +40,6 @@ fun SwipeActionContainer(
totalDistance * 0.6f
}
)
val isThresholdReached =
dismissState.progress > 0.5f && dismissState.targetValue == SwipeToDismissBoxValue.StartToEnd
LaunchedEffect(dismissState.targetValue) {
if (dismissState.targetValue == SwipeToDismissBoxValue.StartToEnd) {
@@ -51,9 +50,18 @@ fun SwipeActionContainer(
}
LaunchedEffect(dismissState.settledValue) {
if (dismissState.settledValue == SwipeToDismissBoxValue.StartToEnd) {
startAction?.onSwipe?.invoke()
dismissState.reset()
when (dismissState.settledValue) {
SwipeToDismissBoxValue.StartToEnd -> {
startAction?.onSwipe?.invoke()
dismissState.reset()
}
SwipeToDismissBoxValue.EndToStart -> {
endAction?.onSwipe?.invoke()
dismissState.reset()
}
else -> {}
}
}
@@ -61,44 +69,34 @@ fun SwipeActionContainer(
state = dismissState,
modifier = modifier,
enableDismissFromStartToEnd = startAction != null,
enableDismissFromEndToStart = false,
enableDismissFromEndToStart = endAction != null,
backgroundContent = {
val direction = dismissState.dismissDirection
val progress = dismissState.progress
if (direction == SwipeToDismissBoxValue.StartToEnd) {
val backgroundColor by animateColorAsState(
targetValue = if (isThresholdReached) startAction!!.background
else MaterialTheme.colorScheme.surfaceVariant,
label = "bgColor"
)
when (direction) {
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
.padding(horizontal = 24.dp),
contentAlignment = Alignment.CenterStart
) {
val iconScale by animateFloatAsState(
targetValue = if (isThresholdReached) 1.3f else progress.coerceIn(0.5f, 1f),
label = "iconScale"
)
Icon(
imageVector = startAction!!.icon,
contentDescription = null,
modifier = Modifier
.size(24.dp)
.graphicsLayer {
scaleX = iconScale
scaleY = iconScale
},
tint = if (isThresholdReached)
contentColorFor(startAction.background)
else MaterialTheme.colorScheme.onSurfaceVariant
)
SwipeToDismissBoxValue.StartToEnd -> {
startAction?.let { action ->
SwipeBackground(
action = action,
progress = progress,
alignStart = true
)
}
}
SwipeToDismissBoxValue.EndToStart -> {
endAction?.let { action ->
SwipeBackground(
action = action,
progress = progress,
alignStart = false
)
}
}
else -> {}
}
},
content = {
@@ -111,4 +109,53 @@ fun SwipeActionContainer(
}
}
)
}
@Composable
private fun SwipeBackground(
action: SwipeAction,
progress: Float,
alignStart: Boolean
) {
val isThresholdReached = progress > 0.6f
val backgroundColor by animateColorAsState(
targetValue = if (isThresholdReached)
action.background
else
MaterialTheme.colorScheme.surfaceVariant,
label = "bgColor"
)
val iconScale by animateFloatAsState(
targetValue = if (isThresholdReached) 1.3f
else progress.coerceIn(0.5f, 1f),
label = "iconScale"
)
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
.padding(horizontal = 24.dp),
contentAlignment = if (alignStart)
Alignment.CenterStart
else
Alignment.CenterEnd
) {
Icon(
imageVector = action.icon,
contentDescription = null,
modifier = Modifier
.size(24.dp)
.graphicsLayer {
scaleX = iconScale
scaleY = iconScale
},
tint = if (isThresholdReached)
contentColorFor(action.background)
else
MaterialTheme.colorScheme.onSurfaceVariant
)
}
}