[修复] bookName 导致同名不同作者的书籍会被错误合并阅读时长

This commit is contained in:
Kudomaga
2026-02-21 02:04:56 +08:00
committed by GitHub
17 changed files with 2683 additions and 173 deletions
File diff suppressed because it is too large Load Diff
@@ -67,7 +67,7 @@ val appDb by lazy {
} }
@Database( @Database(
version = 82, version = 83,
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,
@@ -20,6 +20,7 @@ object DatabaseMigrations {
migration_31_32, migration_32_33, migration_33_34, migration_34_35, 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_35_36, migration_36_37, migration_37_38, migration_38_39,
migration_39_40, migration_40_41, migration_41_42, migration_42_43, 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") @Suppress("ClassName")
class Migration_54_55 : AutoMigrationSpec { class Migration_54_55 : AutoMigrationSpec {
@@ -26,10 +26,10 @@ interface ReadRecordDao {
@get:Query( @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 from readRecord
group by bookName group by bookName, bookAuthor
order by bookName collate localized""" order by bookName collate localized, bookAuthor collate localized"""
) )
val allShow: List<ReadRecordShow> val allShow: List<ReadRecordShow>
@@ -38,22 +38,22 @@ interface ReadRecordDao {
@Query( @Query(
""" """
select bookName, sum(readTime) as readTime, max(lastRead) as lastRead select bookName, bookAuthor, sum(readTime) as readTime, max(lastRead) as lastRead
from readRecord from readRecord
where bookName like '%' || :searchKey || '%' where bookName like '%' || :searchKey || '%' or bookAuthor like '%' || :searchKey || '%'
group by bookName group by bookName, bookAuthor
order by bookName collate localized""" order by bookName collate localized, bookAuthor collate localized"""
) )
fun search(searchKey: String): List<ReadRecordShow> fun search(searchKey: String): List<ReadRecordShow>
@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 = :deviceId and bookName = :bookName") @Query("select readTime from readRecord where deviceId = :deviceId and bookName = :bookName and bookAuthor = :bookAuthor")
fun getReadTime(deviceId: String, bookName: String): Long? fun getReadTime(deviceId: String, bookName: String, bookAuthor: String): Long?
@Query("SELECT * FROM readRecord WHERE deviceId = :deviceId AND bookName = :bookName") @Query("SELECT * FROM readRecord WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor")
suspend fun getReadRecord(deviceId: String, bookName: String): ReadRecord? suspend fun getReadRecord(deviceId: String, bookName: String, bookAuthor: String): ReadRecord?
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(vararg readRecord: ReadRecord) suspend fun insert(vararg readRecord: ReadRecord)
@@ -67,8 +67,8 @@ interface ReadRecordDao {
@Query("delete from readRecord") @Query("delete from readRecord")
fun clear() fun clear()
@Query("delete from readRecord where bookName = :bookName") @Query("delete from readRecord where bookName = :bookName and bookAuthor = :bookAuthor")
fun deleteByName(bookName: String) fun deleteByName(bookName: String, bookAuthor: String)
/** /**
* 插入或更新每日聚合统计记录。 * 插入或更新每日聚合统计记录。
@@ -80,8 +80,8 @@ interface ReadRecordDao {
* 获取某一本书某一天的详细统计 * 获取某一本书某一天的详细统计
* @param date 日期, 推荐格式: YYYY-MM-DD * @param date 日期, 推荐格式: YYYY-MM-DD
*/ */
@Query("SELECT * FROM readRecordDetail WHERE deviceId = :deviceId AND bookName = :bookName AND date = :date") @Query("SELECT * FROM readRecordDetail WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor AND date = :date")
suspend fun getDetail(deviceId: String, bookName: String, date: String): ReadRecordDetail? 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> suspend fun getDetailsByDate(deviceId: String, date: String): List<ReadRecordDetail>
// 清除每天的统计记录 // 清除每天的统计记录
@Query("DELETE FROM readRecordDetail WHERE bookName = :bookName") @Query("DELETE FROM readRecordDetail WHERE bookName = :bookName AND bookAuthor = :bookAuthor")
fun deleteDetailByName(bookName: String) fun deleteDetailByName(bookName: String, bookAuthor: String)
/** /**
* 获取指定书籍的最后一条阅读会话 * 获取指定书籍的最后一条阅读会话
* 用于判断是否可以合并 * 用于判断是否可以合并
*/ */
@Query("SELECT * FROM readRecordSession WHERE bookName = :bookName ORDER BY endTime DESC LIMIT 1") @Query("SELECT * FROM readRecordSession WHERE bookName = :bookName AND bookAuthor = :bookAuthor ORDER BY endTime DESC LIMIT 1")
suspend fun getLatestSessionByBook(bookName: String): ReadRecordSession? suspend fun getLatestSessionByBook(bookName: String, bookAuthor: String): ReadRecordSession?
/** /**
* 更新现有的会话 * 更新现有的会话
@@ -123,9 +123,16 @@ interface ReadRecordDao {
fun getAllReadRecordsSortedByLastRead(): Flow<List<ReadRecord>> fun getAllReadRecordsSortedByLastRead(): Flow<List<ReadRecord>>
/** 搜索 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>> 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 SELECT * FROM readRecordSession
WHERE deviceId = :deviceId WHERE deviceId = :deviceId
AND bookName = :bookName AND bookName = :bookName
AND bookAuthor = :bookAuthor
AND STRFTIME('%Y-%m-%d', datetime(startTime/1000, 'unixepoch', 'localtime')) = :date AND STRFTIME('%Y-%m-%d', datetime(startTime/1000, 'unixepoch', 'localtime')) = :date
ORDER BY startTime ASC 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> 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> suspend fun searchDetailsByDate(deviceId: String, date: String, query: String): List<ReadRecordDetail>
// 清除会话记录 // 清除会话记录
@Query("DELETE FROM readRecordSession WHERE bookName = :bookName") @Query("DELETE FROM readRecordSession WHERE bookName = :bookName AND bookAuthor = :bookAuthor")
fun deleteSessionByName(bookName: String) fun deleteSessionByName(bookName: String, bookAuthor: String)
@Query("SELECT * FROM readRecordDetail ORDER BY date DESC, lastReadTime DESC") @Query("SELECT * FROM readRecordDetail ORDER BY date DESC, lastReadTime DESC")
fun getAllDetails(): Flow<List<ReadRecordDetail>> 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>> fun searchDetails(query: String): Flow<List<ReadRecordDetail>>
@Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId ORDER BY startTime ASC") @Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId ORDER BY startTime ASC")
fun getAllSessions(deviceId: String): Flow<List<ReadRecordSession>> fun getAllSessions(deviceId: String): Flow<List<ReadRecordSession>>
@Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId AND bookName = :bookName") @Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor")
suspend fun getSessionsByBook(deviceId: String, bookName: String): List<ReadRecordSession> suspend fun getSessionsByBook(deviceId: String, bookName: String, bookAuthor: String): List<ReadRecordSession>
@Delete @Delete
suspend fun deleteDetail(detail: ReadRecordDetail) suspend fun deleteDetail(detail: ReadRecordDetail)
@@ -176,10 +189,16 @@ interface ReadRecordDao {
DELETE FROM readRecordSession DELETE FROM readRecordSession
WHERE deviceId = :deviceId WHERE deviceId = :deviceId
AND bookName = :bookName AND bookName = :bookName
AND bookAuthor = :bookAuthor
AND STRFTIME('%Y-%m-%d', datetime(startTime/1000, 'unixepoch', 'localtime')) = :date 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 @Delete
suspend fun deleteSession(session: ReadRecordSession) suspend fun deleteSession(session: ReadRecordSession)
@@ -187,9 +206,12 @@ interface ReadRecordDao {
@Delete @Delete
suspend fun deleteReadRecord(record: ReadRecord) suspend fun deleteReadRecord(record: ReadRecord)
@Query("DELETE FROM readRecordDetail WHERE deviceId = :deviceId AND bookName = :bookName") @Query("DELETE FROM readRecordDetail WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor")
suspend fun deleteDetailsByBook(deviceId: String, bookName: String) suspend fun deleteDetailsByBook(deviceId: String, bookName: String, bookAuthor: String)
@Query("DELETE FROM readRecordSession WHERE deviceId = :deviceId AND bookName = :bookName") @Query("DELETE FROM readRecordSession WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor")
suspend fun deleteSessionsByBook(deviceId: String, bookName: String) 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( data class ReadRecordShow(
var bookName: String, var bookName: String,
var bookAuthor: String,
var readTime: Long, var readTime: Long,
var lastRead: Long var lastRead: Long
) )
@@ -3,10 +3,12 @@ package io.legado.app.data.entities.readRecord
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
@Entity(tableName = "readRecord", primaryKeys = ["deviceId", "bookName"]) @Entity(tableName = "readRecord", primaryKeys = ["deviceId", "bookName", "bookAuthor"])
data class ReadRecord( data class ReadRecord(
var deviceId: String = "", var deviceId: String = "",
var bookName: String = "", var bookName: String = "",
@ColumnInfo(defaultValue = "")
var bookAuthor: String = "",
@ColumnInfo(defaultValue = "0") @ColumnInfo(defaultValue = "0")
var readTime: Long = 0L, var readTime: Long = 0L,
@ColumnInfo(defaultValue = "0") @ColumnInfo(defaultValue = "0")
@@ -5,11 +5,13 @@ import androidx.room.Entity
@Entity( @Entity(
tableName = "readRecordDetail", tableName = "readRecordDetail",
primaryKeys = ["deviceId", "bookName", "date"] primaryKeys = ["deviceId", "bookName", "bookAuthor", "date"]
) )
data class ReadRecordDetail( data class ReadRecordDetail(
val deviceId: String = "", val deviceId: String = "",
val bookName: String = "", val bookName: String = "",
@ColumnInfo(defaultValue = "")
val bookAuthor: String = "",
val date: String = "", val date: String = "",
// 当天阅读总时长 // 当天阅读总时长
@@ -10,6 +10,7 @@ data class ReadRecordSession(
val deviceId: String = "", val deviceId: String = "",
val bookName: String = "", val bookName: String = "",
val bookAuthor: String = "",
// 一次阅读的开始/结束 // 一次阅读的开始/结束
val startTime: Long = 0, val startTime: Long = 0,
@@ -11,15 +11,15 @@ class BookRepository(
private val bookDao: BookDao, private val bookDao: BookDao,
private val bookChapterDao: BookChapterDao private val bookChapterDao: BookChapterDao
) { ) {
suspend fun getBookCoverByName(bookName: String): String? { suspend fun getBookCoverByNameAndAuthor(bookName: String, bookAuthor: String): String? {
return withContext(Dispatchers.IO) { 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) { return withContext(Dispatchers.IO) {
val book = bookDao.findByName(bookName).firstOrNull() val book = bookDao.getBook(bookName, bookAuthor)
val bookUrl = book?.bookUrl val bookUrl = book?.bookUrl
if (bookUrl.isNullOrEmpty()) return@withContext null if (bookUrl.isNullOrEmpty()) return@withContext null
@@ -51,6 +51,14 @@ class ReadRecordRepository(
return dao.getAllSessions(getCurrentDeviceId()) 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) { private suspend fun updateReadRecord(session: ReadRecordSession, durationDelta: Long) {
if (durationDelta <= 0) return 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) { if (existingRecord != null) {
dao.update( dao.update(
existingRecord.copy( existingRecord.copy(
readTime = existingRecord.readTime + durationDelta, readTime = existingRecord.readTime + durationDelta,
lastRead = session.endTime lastRead = session.endTime
) )
) )
} else { } else {
dao.insert( dao.insert(
ReadRecord( ReadRecord(
deviceId = session.deviceId, deviceId = session.deviceId,
bookName = session.bookName, bookName = session.bookName,
readTime = durationDelta, bookAuthor = session.bookAuthor,
lastRead = session.endTime readTime = durationDelta,
lastRead = session.endTime
) )
) )
} }
@@ -91,7 +100,12 @@ class ReadRecordRepository(
dateString: String dateString: String
) { ) {
if (durationDelta <= 0 && wordsDelta <= 0) return 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) { if (existingDetail != null) {
existingDetail.readTime += durationDelta existingDetail.readTime += durationDelta
existingDetail.readWords += wordsDelta existingDetail.readWords += wordsDelta
@@ -101,13 +115,14 @@ class ReadRecordRepository(
} else { } else {
dao.insertDetail( dao.insertDetail(
ReadRecordDetail( ReadRecordDetail(
deviceId = session.deviceId, deviceId = session.deviceId,
bookName = session.bookName, bookName = session.bookName,
date = dateString, bookAuthor = session.bookAuthor,
readTime = durationDelta, date = dateString,
readWords = wordsDelta, readTime = durationDelta,
firstReadTime = session.startTime, readWords = wordsDelta,
lastReadTime = session.endTime firstReadTime = session.startTime,
lastReadTime = session.endTime
) )
) )
} }
@@ -115,8 +130,13 @@ class ReadRecordRepository(
suspend fun deleteDetail(detail: ReadRecordDetail) { suspend fun deleteDetail(detail: ReadRecordDetail) {
dao.deleteDetail(detail) dao.deleteDetail(detail)
dao.deleteSessionsByBookAndDate(detail.deviceId, detail.bookName, detail.date) dao.deleteSessionsByBookAndDate(
updateReadRecordTotal(detail.deviceId, detail.bookName) detail.deviceId,
detail.bookName,
detail.bookAuthor,
detail.date
)
updateReadRecordTotal(detail.deviceId, detail.bookName, detail.bookAuthor)
} }
@Transaction @Transaction
@@ -125,10 +145,20 @@ class ReadRecordRepository(
val dateString = DateUtil.format(Date(session.startTime), "yyyy-MM-dd") val dateString = DateUtil.format(Date(session.startTime), "yyyy-MM-dd")
val remainingSessions = val remainingSessions =
dao.getSessionsByBookAndDate(session.deviceId, session.bookName, dateString) dao.getSessionsByBookAndDate(
session.deviceId,
session.bookName,
session.bookAuthor,
dateString
)
if (remainingSessions.isEmpty()) { 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) } detail?.let { dao.deleteDetail(it) }
} else { } else {
val totalTime = remainingSessions.sumOf { it.endTime - it.startTime } val totalTime = remainingSessions.sumOf { it.endTime - it.startTime }
@@ -136,7 +166,12 @@ class ReadRecordRepository(
val firstRead = remainingSessions.minOf { it.startTime } val firstRead = remainingSessions.minOf { it.startTime }
val lastRead = remainingSessions.maxOf { it.endTime } 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( existingDetail?.copy(
readTime = totalTime, readTime = totalTime,
readWords = totalWords, readWords = totalWords,
@@ -145,19 +180,19 @@ class ReadRecordRepository(
)?.let { dao.insertDetail(it) } )?.let { dao.insertDetail(it) }
} }
updateReadRecordTotal(session.deviceId, session.bookName) updateReadRecordTotal(session.deviceId, session.bookName, session.bookAuthor)
} }
private suspend fun updateReadRecordTotal(deviceId: String, bookName: String) { private suspend fun updateReadRecordTotal(deviceId: String, bookName: String, bookAuthor: String) {
val allRemainingSessions = dao.getSessionsByBook(deviceId, bookName) val allRemainingSessions = dao.getSessionsByBook(deviceId, bookName, bookAuthor)
if (allRemainingSessions.isEmpty()) { if (allRemainingSessions.isEmpty()) {
dao.getReadRecord(deviceId, bookName)?.let { dao.deleteReadRecord(it) } dao.getReadRecord(deviceId, bookName, bookAuthor)?.let { dao.deleteReadRecord(it) }
} else { } else {
val totalTime = allRemainingSessions.sumOf { it.endTime - it.startTime } val totalTime = allRemainingSessions.sumOf { it.endTime - it.startTime }
val lastRead = allRemainingSessions.maxOf { it.endTime } val lastRead = allRemainingSessions.maxOf { it.endTime }
dao.getReadRecord(deviceId, bookName)?.copy( dao.getReadRecord(deviceId, bookName, bookAuthor)?.copy(
readTime = totalTime, readTime = totalTime,
lastRead = lastRead lastRead = lastRead
)?.let { dao.update(it) } )?.let { dao.update(it) }
@@ -166,8 +201,84 @@ class ReadRecordRepository(
suspend fun deleteReadRecord(record: ReadRecord) { suspend fun deleteReadRecord(record: ReadRecord) {
dao.deleteReadRecord(record) dao.deleteReadRecord(record)
dao.deleteDetailsByBook(record.deviceId, record.bookName) dao.deleteDetailsByBook(record.deviceId, record.bookName, record.bookAuthor)
dao.deleteSessionsByBook(record.deviceId, record.bookName) 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)
} }
} }
@@ -177,7 +177,7 @@ object Restore {
appDb.readRecordDao.insert(readRecord) appDb.readRecordDao.insert(readRecord)
} else { } else {
val time = appDb.readRecordDao val time = appDb.readRecordDao
.getReadTime(readRecord.deviceId, readRecord.bookName) .getReadTime(readRecord.deviceId, readRecord.bookName, readRecord.bookAuthor)
if (time == null || time < readRecord.readTime) { if (time == null || time < readRecord.readTime) {
appDb.readRecordDao.insert(readRecord) appDb.readRecordDao.insert(readRecord)
} }
@@ -117,7 +117,8 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
fun resetData(book: Book) { fun resetData(book: Book) {
ReadBook.book = book ReadBook.book = book
readRecord.bookName = book.name 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) chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl)
simulatedChapterSize = if (book.readSimulating()) { simulatedChapterSize = if (book.readSimulating()) {
book.simulatedTotalChapterNum() book.simulatedTotalChapterNum()
@@ -307,7 +308,10 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
fun initReadTime() { fun initReadTime() {
val currentBookName = book?.name ?: return 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() commitReadSession()
} }
@@ -316,6 +320,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
currentActiveSession = ReadRecordSession( currentActiveSession = ReadRecordSession(
deviceId = "", deviceId = "",
bookName = currentBookName, bookName = currentBookName,
bookAuthor = currentBookAuthor,
startTime = readStartTime, startTime = readStartTime,
endTime = readStartTime, endTime = readStartTime,
words = durChapterIndex.toLong() words = durChapterIndex.toLong()
@@ -326,9 +331,13 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
fun upReadTime() { fun upReadTime() {
val currentLength = currentReadLength val currentLength = currentReadLength
val currentBookName = book?.name ?: return val currentBookName = book?.name ?: return
val currentBookAuthor = book?.author ?: ""
val endTime = System.currentTimeMillis() val endTime = System.currentTimeMillis()
if (currentActiveSession == null || currentActiveSession!!.bookName != currentBookName) { if (currentActiveSession == null ||
currentActiveSession!!.bookName != currentBookName ||
currentActiveSession!!.bookAuthor != currentBookAuthor
) {
initReadTime() initReadTime()
return return
} }
@@ -90,7 +90,8 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
fun resetData(book: Book) { fun resetData(book: Book) {
ReadManga.book = book ReadManga.book = book
readRecord.bookName = book.name 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) chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl)
simulatedChapterSize = if (book.readSimulating()) { simulatedChapterSize = if (book.readSimulating()) {
book.simulatedTotalChapterNum() book.simulatedTotalChapterNum()
@@ -147,7 +148,10 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
fun initReadTime() { fun initReadTime() {
val currentBookName = book?.name ?: return 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() commitReadSession()
} }
@@ -156,6 +160,7 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
currentActiveSession = ReadRecordSession( currentActiveSession = ReadRecordSession(
deviceId = "", deviceId = "",
bookName = currentBookName, bookName = currentBookName,
bookAuthor = currentBookAuthor,
startTime = readStartTime, startTime = readStartTime,
endTime = readStartTime, endTime = readStartTime,
words = durChapterIndex.toLong() words = durChapterIndex.toLong()
@@ -166,9 +171,13 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
fun upReadTime() { fun upReadTime() {
val currentLength = currentReadLength val currentLength = currentReadLength
val currentBookName = book?.name ?: return val currentBookName = book?.name ?: return
val currentBookAuthor = book?.author ?: ""
val endTime = System.currentTimeMillis() val endTime = System.currentTimeMillis()
if (currentActiveSession == null || currentActiveSession!!.bookName != currentBookName) { if (currentActiveSession == null ||
currentActiveSession!!.bookName != currentBookName ||
currentActiveSession!!.bookAuthor != currentBookAuthor
) {
initReadTime() initReadTime()
return return
} }
@@ -25,10 +25,10 @@ class ReadRecordActivity : BaseComposeActivity() {
AppTheme { AppTheme {
ReadRecordScreen( ReadRecordScreen(
onBackClick = { finish() }, onBackClick = { finish() },
onBookClick = { bookName -> onBookClick = { bookName, bookAuthor ->
lifecycleScope.launch { lifecycleScope.launch {
val book = withContext(Dispatchers.IO) { val book = withContext(Dispatchers.IO) {
appDb.bookDao.findByName(bookName).firstOrNull() appDb.bookDao.getBook(bookName, bookAuthor)
} }
if (book != null) startActivityForBook(book) if (book != null) startActivityForBook(book)
} }
@@ -3,6 +3,7 @@ package io.legado.app.ui.book.readRecord
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -23,6 +24,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Delete 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.Schedule
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Timeline import androidx.compose.material.icons.filled.Timeline
@@ -36,6 +38,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -47,6 +51,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -56,8 +61,11 @@ import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import cn.hutool.core.date.DateUtil import cn.hutool.core.date.DateUtil
@@ -75,6 +83,7 @@ import io.legado.app.ui.widget.components.button.SmallTopBarButton
import io.legado.app.ui.widget.components.swipe.SwipeAction import io.legado.app.ui.widget.components.swipe.SwipeAction
import io.legado.app.ui.widget.components.swipe.SwipeActionContainer import io.legado.app.ui.widget.components.swipe.SwipeActionContainer
import io.legado.app.utils.StringUtils.formatFriendlyDate import io.legado.app.utils.StringUtils.formatFriendlyDate
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import java.time.LocalDate import java.time.LocalDate
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@@ -85,8 +94,11 @@ import java.util.Date
fun ReadRecordScreen( fun ReadRecordScreen(
viewModel: ReadRecordViewModel = koinViewModel(), viewModel: ReadRecordViewModel = koinViewModel(),
onBackClick: () -> Unit, onBackClick: () -> Unit,
onBookClick: (String) -> Unit onBookClick: (String, String) -> Unit
) { ) {
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val displayMode by viewModel.displayMode.collectAsState() val displayMode by viewModel.displayMode.collectAsState()
var showSearch by remember { mutableStateOf(false) } var showSearch by remember { mutableStateOf(false) }
@@ -96,6 +108,7 @@ fun ReadRecordScreen(
var skipDeleteConfirm by remember { mutableStateOf(false) } var skipDeleteConfirm by remember { mutableStateOf(false) }
var pendingDeleteAction by remember { mutableStateOf<(() -> Unit)?>(null) } var pendingDeleteAction by remember { mutableStateOf<(() -> Unit)?>(null) }
var mergeDialogData by remember { mutableStateOf<Pair<ReadRecord, List<ReadRecord>>?>(null) }
val onConfirmDelete: (() -> Unit) -> Unit = { action -> val onConfirmDelete: (() -> Unit) -> Unit = { action ->
if (skipDeleteConfirm) { if (skipDeleteConfirm) {
action() action()
@@ -112,6 +125,7 @@ fun ReadRecordScreen(
Scaffold( Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
Column { Column {
GlassMediumFlexibleTopAppBar( GlassMediumFlexibleTopAppBar(
@@ -226,7 +240,17 @@ fun ReadRecordScreen(
state = state, state = state,
viewModel = viewModel, viewModel = viewModel,
onBookClick = onBookClick, 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
}
}
}
) )
} }
} }
@@ -286,6 +310,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 @Composable
@@ -300,7 +383,7 @@ fun SummarySection(
val dailyDetails = state.groupedRecords[dateKey] ?: emptyList() val dailyDetails = state.groupedRecords[dateKey] ?: emptyList()
if (dailyDetails.isNotEmpty()) { 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 } val dailyTime = dailyDetails.sumOf { it.readTime }
ReadingSummaryCard( ReadingSummaryCard(
@@ -321,7 +404,7 @@ fun SummarySection(
title = "累计阅读成就", title = "累计阅读成就",
bookCount = allBooksCount, bookCount = allBooksCount,
totalTimeMillis = totalTime, totalTimeMillis = totalTime,
bookNamesForCover = state.latestRecords.take(5).map { it.bookName }, bookNamesForCover = state.latestRecords.take(5).map { it.bookName to it.bookAuthor },
viewModel = viewModel, viewModel = viewModel,
onClick = { } onClick = { }
) )
@@ -333,16 +416,18 @@ fun LazyListScope.renderListByMode(
displayMode: DisplayMode, displayMode: DisplayMode,
state: ReadRecordUiState, state: ReadRecordUiState,
viewModel: ReadRecordViewModel, viewModel: ReadRecordViewModel,
onBookClick: (String) -> Unit, onBookClick: (String, String) -> Unit,
onConfirmDelete: (() -> Unit) -> Unit onConfirmDelete: (() -> Unit) -> Unit,
onMergeClick: (ReadRecord) -> Unit
) { ) {
when (displayMode) { when (displayMode) {
DisplayMode.AGGREGATE -> { DisplayMode.AGGREGATE -> {
state.groupedRecords.forEach { (date, details) -> state.groupedRecords.forEach { (date, details) ->
stickyHeader(key = "header_$date") { stickyHeader(key = "header_$date") {
DateHeader(date, details.sumOf { it.readTime }) 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( SwipeActionContainer(
modifier = Modifier.animateItem(), modifier = Modifier.animateItem(),
startAction = SwipeAction( startAction = SwipeAction(
@@ -356,7 +441,7 @@ fun LazyListScope.renderListByMode(
ReadRecordItem( ReadRecordItem(
detail, detail,
viewModel, viewModel,
onClick = { onBookClick(detail.bookName) }) onClick = { onBookClick(detail.bookName, detail.bookAuthor) })
} }
} }
} }
@@ -387,7 +472,7 @@ fun LazyListScope.renderListByMode(
} }
DisplayMode.LATEST -> { DisplayMode.LATEST -> {
items(items = state.latestRecords, key = { it.bookName }) { record -> items(items = state.latestRecords, key = { "${it.bookName}_${it.bookAuthor}" }) { record ->
SwipeActionContainer( SwipeActionContainer(
modifier = Modifier.animateItem(), modifier = Modifier.animateItem(),
startAction = SwipeAction( startAction = SwipeAction(
@@ -396,16 +481,23 @@ fun LazyListScope.renderListByMode(
onSwipe = { onSwipe = {
onConfirmDelete { viewModel.deleteReadRecord(record) } onConfirmDelete { viewModel.deleteReadRecord(record) }
} }
),
endAction = SwipeAction(
icon = Icons.Default.Merge,
background = MaterialTheme.colorScheme.primary,
onSwipe = {
onMergeClick(record)
}
) )
) { ) {
LatestReadItem( LatestReadItem(
record = record, record = record,
viewModel = viewModel, viewModel = viewModel,
onClick = { onBookClick(record.bookName) } onClick = { onBookClick(record.bookName, record.bookAuthor) }
) )
}
} }
} }
}
} }
} }
@@ -418,8 +510,8 @@ fun LatestReadItem(
) { ) {
var coverPath by remember { mutableStateOf<String?>(null) } var coverPath by remember { mutableStateOf<String?>(null) }
LaunchedEffect(record.bookName) { LaunchedEffect(record.bookName, record.bookAuthor) {
coverPath = viewModel.getBookCover(record.bookName) coverPath = viewModel.getBookCover(record.bookName, record.bookAuthor)
} }
Row( Row(
@@ -437,18 +529,36 @@ fun LatestReadItem(
Text( Text(
text = record.bookName, text = record.bookName,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
maxLines = 2 maxLines = 1,
) overflow = TextOverflow.Ellipsis
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "总时长: ${formatDuring(record.readTime)}",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
) )
Text( Text(
text = "最后阅读: ${DateUtil.format(Date(record.lastRead), "yyyy-MM-dd HH:mm")}", text = record.bookAuthor.ifBlank { "未知作者" },
style = MaterialTheme.typography.bodySmall, 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
) )
} }
} }
@@ -458,15 +568,15 @@ fun LatestReadItem(
fun TimelineSessionItem( fun TimelineSessionItem(
item: TimelineItem, item: TimelineItem,
viewModel: ReadRecordViewModel, viewModel: ReadRecordViewModel,
onBookClick: (String) -> Unit onBookClick: (String, String) -> Unit
) { ) {
val session = item.session val session = item.session
var coverPath by remember { mutableStateOf<String?>(null) } var coverPath by remember { mutableStateOf<String?>(null) }
var chapterTitle by remember { mutableStateOf<String?>("加载中...") } var chapterTitle by remember { mutableStateOf<String?>("加载中...") }
LaunchedEffect(session.bookName) { LaunchedEffect(session.bookName, session.bookAuthor) {
coverPath = viewModel.getBookCover(session.bookName) coverPath = viewModel.getBookCover(session.bookName, session.bookAuthor)
val title = viewModel.getChapterTitle(session.bookName, session.words) val title = viewModel.getChapterTitle(session.bookName, session.bookAuthor, session.words)
chapterTitle = title ?: "${session.words}" chapterTitle = title ?: "${session.words}"
} }
@@ -483,7 +593,7 @@ fun TimelineSessionItem(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onBookClick(session.bookName) } .clickable { onBookClick(session.bookName, session.bookAuthor) }
.drawBehind { .drawBehind {
val x = timelineX.toPx() val x = timelineX.toPx()
val h = size.height val h = size.height
@@ -528,14 +638,21 @@ fun TimelineSessionItem(
Text( Text(
text = session.bookName, text = session.bookName,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
maxLines = 2, maxLines = 1,
minLines = 2,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Text( Text(
text = chapterTitle.orEmpty(), text = session.bookAuthor.ifBlank { "未知作者" },
style = MaterialTheme.typography.bodySmall, 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, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
@@ -554,8 +671,8 @@ fun ReadRecordItem(
) { ) {
var coverPath by remember { mutableStateOf<String?>(null) } var coverPath by remember { mutableStateOf<String?>(null) }
LaunchedEffect(detail.bookName) { LaunchedEffect(detail.bookName, detail.bookAuthor) {
coverPath = viewModel.getBookCover(detail.bookName) coverPath = viewModel.getBookCover(detail.bookName, detail.bookAuthor)
} }
Row( Row(
@@ -573,13 +690,18 @@ fun ReadRecordItem(
Text( Text(
text = detail.bookName, text = detail.bookName,
style = MaterialTheme.typography.titleMedium, 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(
text = "阅读时长: ${formatDuring(detail.readTime)}", text = "阅读时长: ${formatDuring(detail.readTime)}",
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline,
color = Color.Gray style = MaterialTheme.typography.labelSmall
) )
} }
} }
@@ -636,14 +758,14 @@ fun ReadingSummaryCard(
title: String, title: String,
bookCount: Int, bookCount: Int,
totalTimeMillis: Long, totalTimeMillis: Long,
bookNamesForCover: List<String>, bookNamesForCover: List<Pair<String, String>>,
viewModel: ReadRecordViewModel, viewModel: ReadRecordViewModel,
onClick: () -> Unit onClick: () -> Unit
) { ) {
val coverPaths by produceState(initialValue = emptyList(), key1 = bookNamesForCover) { val coverPaths by produceState(initialValue = emptyList(), key1 = bookNamesForCover) {
value = bookNamesForCover.map { name -> value = bookNamesForCover.map { (name, author) ->
viewModel.getBookCover(name) viewModel.getBookCover(name, author)
} }
} }
@@ -80,10 +80,9 @@ class ReadRecordViewModel(
.filter { session -> .filter { session ->
val sDate = DateUtil.format(Date(session.startTime), "yyyy-MM-dd") val sDate = DateUtil.format(Date(session.startTime), "yyyy-MM-dd")
(dateStr == null || sDate == dateStr) && (dateStr == null || sDate == dateStr) &&
(searchKey.isEmpty() || session.bookName.contains( (searchKey.isEmpty() ||
searchKey, session.bookName.contains(searchKey, ignoreCase = true) ||
ignoreCase = true session.bookAuthor.contains(searchKey, ignoreCase = true))
))
} }
.groupBy { DateUtil.format(Date(it.startTime), "yyyy-MM-dd") } .groupBy { DateUtil.format(Date(it.startTime), "yyyy-MM-dd") }
.mapValues { (_, sessions) -> .mapValues { (_, sessions) ->
@@ -139,7 +138,10 @@ class ReadRecordViewModel(
for (i in 1 until sessions.size) { for (i in 1 until sessions.size) {
val current = sessions[i] val current = sessions[i]
val last = mergedList.last() 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) mergedList[mergedList.lastIndex] = last.copy(endTime = current.endTime)
} else { } else {
mergedList.add(current.copy()) mergedList.add(current.copy())
@@ -148,12 +150,23 @@ class ReadRecordViewModel(
return mergedList return mergedList
} }
suspend fun getChapterTitle(bookName: String, chapterIndexLong: Long): String? { suspend fun getChapterTitle(bookName: String, bookAuthor: String, chapterIndexLong: Long): String? {
return bookRepository.getChapterTitle(bookName, chapterIndexLong.toInt()) return bookRepository.getChapterTitle(bookName, bookAuthor, chapterIndexLong.toInt())
} }
suspend fun getBookCover(bookName: String): String? { suspend fun getBookCover(bookName: String, bookAuthor: String): String? {
return bookRepository.getBookCoverByName(bookName) 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( private data class LoadedData(
@@ -31,6 +31,7 @@ import androidx.compose.ui.unit.dp
fun SwipeActionContainer( fun SwipeActionContainer(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
startAction: SwipeAction? = null, startAction: SwipeAction? = null,
endAction: SwipeAction? = null,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
@@ -39,8 +40,6 @@ fun SwipeActionContainer(
totalDistance * 0.6f totalDistance * 0.6f
} }
) )
val isThresholdReached =
dismissState.progress > 0.5f && dismissState.targetValue == SwipeToDismissBoxValue.StartToEnd
LaunchedEffect(dismissState.targetValue) { LaunchedEffect(dismissState.targetValue) {
if (dismissState.targetValue == SwipeToDismissBoxValue.StartToEnd) { if (dismissState.targetValue == SwipeToDismissBoxValue.StartToEnd) {
@@ -51,9 +50,18 @@ fun SwipeActionContainer(
} }
LaunchedEffect(dismissState.settledValue) { LaunchedEffect(dismissState.settledValue) {
if (dismissState.settledValue == SwipeToDismissBoxValue.StartToEnd) { when (dismissState.settledValue) {
startAction?.onSwipe?.invoke() SwipeToDismissBoxValue.StartToEnd -> {
dismissState.reset() startAction?.onSwipe?.invoke()
dismissState.reset()
}
SwipeToDismissBoxValue.EndToStart -> {
endAction?.onSwipe?.invoke()
dismissState.reset()
}
else -> {}
} }
} }
@@ -61,44 +69,34 @@ fun SwipeActionContainer(
state = dismissState, state = dismissState,
modifier = modifier, modifier = modifier,
enableDismissFromStartToEnd = startAction != null, enableDismissFromStartToEnd = startAction != null,
enableDismissFromEndToStart = false, enableDismissFromEndToStart = endAction != null,
backgroundContent = { backgroundContent = {
val direction = dismissState.dismissDirection val direction = dismissState.dismissDirection
val progress = dismissState.progress val progress = dismissState.progress
if (direction == SwipeToDismissBoxValue.StartToEnd) { when (direction) {
val backgroundColor by animateColorAsState(
targetValue = if (isThresholdReached) startAction!!.background
else MaterialTheme.colorScheme.surfaceVariant,
label = "bgColor"
)
Box( SwipeToDismissBoxValue.StartToEnd -> {
modifier = Modifier startAction?.let { action ->
.fillMaxSize() SwipeBackground(
.background(backgroundColor) action = action,
.padding(horizontal = 24.dp), progress = progress,
contentAlignment = Alignment.CenterStart alignStart = true
) { )
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.EndToStart -> {
endAction?.let { action ->
SwipeBackground(
action = action,
progress = progress,
alignStart = false
)
}
}
else -> {}
} }
}, },
content = { content = {
@@ -112,3 +110,52 @@ 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
)
}
}