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 committed 2026-02-21 02:08:37 +08:00
commit b0fc0c8649
19 files changed
+2740 -154

No files matched your search

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,7 +72,7 @@ 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(
@@ -77,6 +85,7 @@ class ReadRecordRepository(
ReadRecord( ReadRecord(
deviceId = session.deviceId, deviceId = session.deviceId,
bookName = session.bookName, bookName = session.bookName,
bookAuthor = session.bookAuthor,
readTime = durationDelta, readTime = durationDelta,
lastRead = session.endTime 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
@@ -103,6 +117,7 @@ class ReadRecordRepository(
ReadRecordDetail( ReadRecordDetail(
deviceId = session.deviceId, deviceId = session.deviceId,
bookName = session.bookName, bookName = session.bookName,
bookAuthor = session.bookAuthor,
date = dateString, date = dateString,
readTime = durationDelta, readTime = durationDelta,
readWords = wordsDelta, readWords = wordsDelta,
@@ -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)
} }
} }
@@ -121,6 +121,29 @@ fun Book.contains(word: String?): Boolean {
|| intro?.contains(word) == true || 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 { private val localUriCache by lazy {
ConcurrentHashMap<String, Uri>() ConcurrentHashMap<String, Uri>()
} }
@@ -150,17 +173,31 @@ fun Book.getLocalUri(): Uri {
// 查找书籍保存目录 // 查找书籍保存目录
if (!defaultBookDir.isNullOrBlank()) { if (!defaultBookDir.isNullOrBlank()) {
val treeUri = Uri.parse(defaultBookDir) val treeUri = defaultBookDir.toUri()
val treeFileDoc = FileDoc.fromUri(treeUri, true) val treeFileDoc = FileDoc.fromUri(treeUri, true)
if (!treeFileDoc.exists()) { if (!treeFileDoc.exists()) {
appCtx.toastOnUi("书籍保存目录失效,请重新设置!") appCtx.toastOnUi("书籍保存目录失效,请重新设置!")
} else { } else {
val fileDoc = treeFileDoc.find(originName, 5, 100) val fileDoc = treeFileDoc.find(originName, 5, 100)
if (fileDoc != null) { if (fileDoc != null) {
localUriCache[bookUrl] = fileDoc.uri val newBookUrl = fileDoc.toString()
//更新bookUrl 重启不用再找一遍 val oldBook = copy()
bookUrl = fileDoc.toString() if (!oldBook.canSafelyRebindTo(newBookUrl)) {
return fileDoc.uri
}
appDb.runInTransaction {
if (oldBook.bookUrl == newBookUrl) {
save() 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 return fileDoc.uri
} }
} }
@@ -176,9 +213,23 @@ fun Book.getLocalUri(): Uri {
val treeFileDoc = FileDoc.fromUri(treeUri, true) val treeFileDoc = FileDoc.fromUri(treeUri, true)
val fileDoc = treeFileDoc.find(originName, 5, 100) val fileDoc = treeFileDoc.find(originName, 5, 100)
if (fileDoc != null) { if (fileDoc != null) {
localUriCache[bookUrl] = fileDoc.uri val newBookUrl = fileDoc.toString()
bookUrl = fileDoc.toString() val oldBook = copy()
if (!oldBook.canSafelyRebindTo(newBookUrl)) {
return fileDoc.uri
}
appDb.runInTransaction {
if (oldBook.bookUrl == newBookUrl) {
save() 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 return fileDoc.uri
} }
} }
@@ -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
} }
@@ -23,6 +23,8 @@ import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.ContentProcessor import io.legado.app.help.book.ContentProcessor
import io.legado.app.help.book.addType import io.legado.app.help.book.addType
import io.legado.app.help.book.archiveName 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.getArchiveUri
import io.legado.app.help.book.getLocalUri import io.legado.app.help.book.getLocalUri
import io.legado.app.help.book.getRemoteUrl 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.WebDav
import io.legado.app.lib.webdav.WebDavException import io.legado.app.lib.webdav.WebDavException
import io.legado.app.model.analyzeRule.AnalyzeUrl 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.ArchiveUtils
import io.legado.app.utils.FileDoc import io.legado.app.utils.FileDoc
import io.legado.app.utils.FileUtils 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.isContentScheme
import io.legado.app.utils.isDataUrl import io.legado.app.utils.isDataUrl
import io.legado.app.utils.printOnDebug import io.legado.app.utils.printOnDebug
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.apache.commons.text.StringEscapeUtils import org.apache.commons.text.StringEscapeUtils
import splitties.init.appCtx import splitties.init.appCtx
@@ -60,7 +64,6 @@ import java.io.FileNotFoundException
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.InputStream import java.io.InputStream
import java.util.regex.Pattern import java.util.regex.Pattern
import kotlin.coroutines.coroutineContext
/** /**
* 书籍文件导入 目录正文解析 * 书籍文件导入 目录正文解析
@@ -403,7 +406,7 @@ object LocalBook {
val inputStream = when { val inputStream = when {
str.isAbsUrl() -> AnalyzeUrl( str.isAbsUrl() -> AnalyzeUrl(
str, source = source, callTimeout = 0, str, source = source, callTimeout = 0,
coroutineContext = coroutineContext coroutineContext = currentCoroutineContext()
).getInputStreamAwait() ).getInputStreamAwait()
str.isDataUrl() -> ByteArrayInputStream( str.isDataUrl() -> ByteArrayInputStream(
@@ -458,7 +461,7 @@ object LocalBook {
fun isOnBookShelf( fun isOnBookShelf(
fileName: String fileName: String
): Boolean { ): Boolean {
return appDb.bookDao.hasFile(fileName) == true return appDb.bookDao.hasFile(fileName)
} }
//文件类书源 合并在线书籍信息 在线 > 本地 //文件类书源 合并在线书籍信息 在线 > 本地
@@ -501,9 +504,31 @@ object LocalBook {
localBook.bookUrl = newBook.bookUrl localBook.bookUrl = newBook.bookUrl
} else { } else {
// txt epub pdf umd // txt epub pdf umd
val oldBook = localBook.copy()
val fileUri = saveBookFile(it, localBook.originName) val fileUri = saveBookFile(it, localBook.originName)
localBook.bookUrl = FileDoc.fromUri(fileUri, false).toString() 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() 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 return true
@@ -26,10 +26,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)
else { else {
@@ -5,6 +5,7 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
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
@@ -26,6 +27,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
@@ -39,6 +41,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
@@ -51,6 +55,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
@@ -65,8 +70,11 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
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
@@ -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.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
@@ -103,8 +112,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) }
@@ -114,6 +126,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()
@@ -130,6 +143,7 @@ fun ReadRecordScreen(
Scaffold( Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
Column { Column {
GlassMediumFlexibleTopAppBar( GlassMediumFlexibleTopAppBar(
@@ -231,7 +245,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
}
}
}
) )
} }
} }
@@ -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 @Composable
@@ -329,7 +412,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(
@@ -350,7 +433,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 = { }
) )
@@ -495,16 +578,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(
@@ -518,7 +603,7 @@ fun LazyListScope.renderListByMode(
ReadRecordItem( ReadRecordItem(
detail, detail,
viewModel, viewModel,
onClick = { onBookClick(detail.bookName) }) onClick = { onBookClick(detail.bookName, detail.bookAuthor) })
} }
} }
} }
@@ -549,7 +634,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(
@@ -558,12 +643,19 @@ 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) }
) )
} }
} }
@@ -580,8 +672,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(
@@ -599,18 +691,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
) )
} }
} }
@@ -620,15 +730,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} 章"
} }
@@ -645,7 +755,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
@@ -690,14 +800,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
) )
@@ -716,8 +833,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(
@@ -735,13 +852,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
) )
} }
} }
@@ -782,14 +904,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)
} }
} }
@@ -91,10 +91,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) ->
@@ -152,7 +151,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())
@@ -161,12 +163,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,55 +50,54 @@ fun SwipeActionContainer(
} }
LaunchedEffect(dismissState.settledValue) { LaunchedEffect(dismissState.settledValue) {
if (dismissState.settledValue == SwipeToDismissBoxValue.StartToEnd) { when (dismissState.settledValue) {
SwipeToDismissBoxValue.StartToEnd -> {
startAction?.onSwipe?.invoke() startAction?.onSwipe?.invoke()
dismissState.reset() dismissState.reset()
} }
SwipeToDismissBoxValue.EndToStart -> {
endAction?.onSwipe?.invoke()
dismissState.reset()
}
else -> {}
}
} }
SwipeToDismissBox( SwipeToDismissBox(
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 = {
Surface( Surface(
@@ -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
)
}
}