[修复] 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(
version = 82,
version = 83,
exportSchema = true,
entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class,
ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class,
@@ -20,6 +20,7 @@ object DatabaseMigrations {
migration_31_32, migration_32_33, migration_33_34, migration_34_35,
migration_35_36, migration_36_37, migration_37_38, migration_38_39,
migration_39_40, migration_40_41, migration_41_42, migration_42_43,
migration_82_83,
)
}
@@ -324,6 +325,132 @@ object DatabaseMigrations {
}
}
private val migration_82_83 = object : Migration(82, 83) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE readRecord RENAME TO readRecord_old")
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `readRecord` (
`deviceId` TEXT NOT NULL,
`bookName` TEXT NOT NULL,
`bookAuthor` TEXT NOT NULL DEFAULT '',
`readTime` INTEGER NOT NULL DEFAULT 0,
`lastRead` INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(`deviceId`, `bookName`, `bookAuthor`)
)
"""
)
database.execSQL(
"""
INSERT INTO readRecord(deviceId, bookName, bookAuthor, readTime, lastRead)
SELECT
rr.deviceId,
rr.bookName,
IFNULL(
(
SELECT CASE
WHEN COUNT(DISTINCT b.author) = 1 THEN MAX(b.author)
ELSE ''
END
FROM books b
WHERE b.name = rr.bookName
),
''
) AS bookAuthor,
rr.readTime,
rr.lastRead
FROM readRecord_old rr
"""
)
database.execSQL("DROP TABLE readRecord_old")
database.execSQL("ALTER TABLE readRecordDetail RENAME TO readRecordDetail_old")
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `readRecordDetail` (
`deviceId` TEXT NOT NULL,
`bookName` TEXT NOT NULL,
`bookAuthor` TEXT NOT NULL DEFAULT '',
`date` TEXT NOT NULL,
`readTime` INTEGER NOT NULL DEFAULT 0,
`readWords` INTEGER NOT NULL DEFAULT 0,
`firstReadTime` INTEGER NOT NULL DEFAULT 0,
`lastReadTime` INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(`deviceId`, `bookName`, `bookAuthor`, `date`)
)
"""
)
database.execSQL(
"""
INSERT INTO readRecordDetail(
deviceId, bookName, bookAuthor, date, readTime, readWords, firstReadTime, lastReadTime
)
SELECT
rd.deviceId,
rd.bookName,
IFNULL(
(
SELECT CASE
WHEN COUNT(DISTINCT b.author) = 1 THEN MAX(b.author)
ELSE ''
END
FROM books b
WHERE b.name = rd.bookName
),
''
) AS bookAuthor,
rd.date,
rd.readTime,
rd.readWords,
rd.firstReadTime,
rd.lastReadTime
FROM readRecordDetail_old rd
"""
)
database.execSQL("DROP TABLE readRecordDetail_old")
database.execSQL("ALTER TABLE readRecordSession RENAME TO readRecordSession_old")
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `readRecordSession` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`deviceId` TEXT NOT NULL,
`bookName` TEXT NOT NULL,
`bookAuthor` TEXT NOT NULL DEFAULT '',
`startTime` INTEGER NOT NULL,
`endTime` INTEGER NOT NULL,
`words` INTEGER NOT NULL
)
"""
)
database.execSQL(
"""
INSERT INTO readRecordSession(id, deviceId, bookName, bookAuthor, startTime, endTime, words)
SELECT
rs.id,
rs.deviceId,
rs.bookName,
IFNULL(
(
SELECT CASE
WHEN COUNT(DISTINCT b.author) = 1 THEN MAX(b.author)
ELSE ''
END
FROM books b
WHERE b.name = rs.bookName
),
''
) AS bookAuthor,
rs.startTime,
rs.endTime,
rs.words
FROM readRecordSession_old rs
"""
)
database.execSQL("DROP TABLE readRecordSession_old")
}
}
@Suppress("ClassName")
class Migration_54_55 : AutoMigrationSpec {
@@ -371,4 +498,4 @@ object DatabaseMigrations {
)
class Migration_64_65 : AutoMigrationSpec
}
}
@@ -26,10 +26,10 @@ interface ReadRecordDao {
@get:Query(
"""
select bookName, sum(readTime) as readTime, max(lastRead) as lastRead
select bookName, bookAuthor, sum(readTime) as readTime, max(lastRead) as lastRead
from readRecord
group by bookName
order by bookName collate localized"""
group by bookName, bookAuthor
order by bookName collate localized, bookAuthor collate localized"""
)
val allShow: List<ReadRecordShow>
@@ -38,22 +38,22 @@ interface ReadRecordDao {
@Query(
"""
select bookName, sum(readTime) as readTime, max(lastRead) as lastRead
select bookName, bookAuthor, sum(readTime) as readTime, max(lastRead) as lastRead
from readRecord
where bookName like '%' || :searchKey || '%'
group by bookName
order by bookName collate localized"""
where bookName like '%' || :searchKey || '%' or bookAuthor like '%' || :searchKey || '%'
group by bookName, bookAuthor
order by bookName collate localized, bookAuthor collate localized"""
)
fun search(searchKey: String): List<ReadRecordShow>
@Query("select sum(readTime) from readRecord where bookName = :bookName")
fun getReadTime(bookName: String): Long?
@Query("select readTime from readRecord where deviceId = :deviceId and bookName = :bookName")
fun getReadTime(deviceId: String, bookName: String): Long?
@Query("select readTime from readRecord where deviceId = :deviceId and bookName = :bookName and bookAuthor = :bookAuthor")
fun getReadTime(deviceId: String, bookName: String, bookAuthor: String): Long?
@Query("SELECT * FROM readRecord WHERE deviceId = :deviceId AND bookName = :bookName")
suspend fun getReadRecord(deviceId: String, bookName: String): ReadRecord?
@Query("SELECT * FROM readRecord WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor")
suspend fun getReadRecord(deviceId: String, bookName: String, bookAuthor: String): ReadRecord?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(vararg readRecord: ReadRecord)
@@ -67,8 +67,8 @@ interface ReadRecordDao {
@Query("delete from readRecord")
fun clear()
@Query("delete from readRecord where bookName = :bookName")
fun deleteByName(bookName: String)
@Query("delete from readRecord where bookName = :bookName and bookAuthor = :bookAuthor")
fun deleteByName(bookName: String, bookAuthor: String)
/**
* 插入或更新每日聚合统计记录。
@@ -80,8 +80,8 @@ interface ReadRecordDao {
* 获取某一本书某一天的详细统计
* @param date 日期, 推荐格式: YYYY-MM-DD
*/
@Query("SELECT * FROM readRecordDetail WHERE deviceId = :deviceId AND bookName = :bookName AND date = :date")
suspend fun getDetail(deviceId: String, bookName: String, date: String): ReadRecordDetail?
@Query("SELECT * FROM readRecordDetail WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor AND date = :date")
suspend fun getDetail(deviceId: String, bookName: String, bookAuthor: String, date: String): ReadRecordDetail?
/**
* 查询所有发生过阅读的日期(用于日历标记)
@@ -96,15 +96,15 @@ interface ReadRecordDao {
suspend fun getDetailsByDate(deviceId: String, date: String): List<ReadRecordDetail>
// 清除每天的统计记录
@Query("DELETE FROM readRecordDetail WHERE bookName = :bookName")
fun deleteDetailByName(bookName: String)
@Query("DELETE FROM readRecordDetail WHERE bookName = :bookName AND bookAuthor = :bookAuthor")
fun deleteDetailByName(bookName: String, bookAuthor: String)
/**
* 获取指定书籍的最后一条阅读会话
* 用于判断是否可以合并
*/
@Query("SELECT * FROM readRecordSession WHERE bookName = :bookName ORDER BY endTime DESC LIMIT 1")
suspend fun getLatestSessionByBook(bookName: String): ReadRecordSession?
@Query("SELECT * FROM readRecordSession WHERE bookName = :bookName AND bookAuthor = :bookAuthor ORDER BY endTime DESC LIMIT 1")
suspend fun getLatestSessionByBook(bookName: String, bookAuthor: String): ReadRecordSession?
/**
* 更新现有的会话
@@ -123,9 +123,16 @@ interface ReadRecordDao {
fun getAllReadRecordsSortedByLastRead(): Flow<List<ReadRecord>>
/** 搜索 ReadRecord,按最后阅读时间倒序排列 */
@Query("SELECT * FROM readRecord WHERE bookName LIKE '%' || :query || '%' ORDER BY lastRead DESC")
@Query("SELECT * FROM readRecord WHERE bookName LIKE '%' || :query || '%' OR bookAuthor LIKE '%' || :query || '%' ORDER BY lastRead DESC")
fun searchReadRecordsByLastRead(query: String): Flow<List<ReadRecord>>
@Query("SELECT * FROM readRecord WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor != :excludeAuthor ORDER BY lastRead DESC")
suspend fun getReadRecordsByNameExcludingAuthor(
deviceId: String,
bookName: String,
excludeAuthor: String
): List<ReadRecord>
/**
* 获取某一天某一本书的所有会话记录
*/
@@ -133,10 +140,16 @@ interface ReadRecordDao {
SELECT * FROM readRecordSession
WHERE deviceId = :deviceId
AND bookName = :bookName
AND bookAuthor = :bookAuthor
AND STRFTIME('%Y-%m-%d', datetime(startTime/1000, 'unixepoch', 'localtime')) = :date
ORDER BY startTime ASC
""")
suspend fun getSessionsByBookAndDate(deviceId: String, bookName: String, date: String): List<ReadRecordSession>
suspend fun getSessionsByBookAndDate(
deviceId: String,
bookName: String,
bookAuthor: String,
date: String
): List<ReadRecordSession>
/**
* 获取某一天所有书籍的会话记录
@@ -149,24 +162,24 @@ interface ReadRecordDao {
""")
suspend fun getSessionsByDate(deviceId: String, date: String): List<ReadRecordSession>
@Query("SELECT * FROM readRecordDetail WHERE deviceId = :deviceId AND date = :date AND bookName LIKE '%' || :query || '%'")
@Query("SELECT * FROM readRecordDetail WHERE deviceId = :deviceId AND date = :date AND (bookName LIKE '%' || :query || '%' OR bookAuthor LIKE '%' || :query || '%')")
suspend fun searchDetailsByDate(deviceId: String, date: String, query: String): List<ReadRecordDetail>
// 清除会话记录
@Query("DELETE FROM readRecordSession WHERE bookName = :bookName")
fun deleteSessionByName(bookName: String)
@Query("DELETE FROM readRecordSession WHERE bookName = :bookName AND bookAuthor = :bookAuthor")
fun deleteSessionByName(bookName: String, bookAuthor: String)
@Query("SELECT * FROM readRecordDetail ORDER BY date DESC, lastReadTime DESC")
fun getAllDetails(): Flow<List<ReadRecordDetail>>
@Query("SELECT * FROM readRecordDetail WHERE bookName LIKE '%' || :query || '%' ORDER BY date DESC, lastReadTime DESC")
@Query("SELECT * FROM readRecordDetail WHERE bookName LIKE '%' || :query || '%' OR bookAuthor LIKE '%' || :query || '%' ORDER BY date DESC, lastReadTime DESC")
fun searchDetails(query: String): Flow<List<ReadRecordDetail>>
@Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId ORDER BY startTime ASC")
fun getAllSessions(deviceId: String): Flow<List<ReadRecordSession>>
@Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId AND bookName = :bookName")
suspend fun getSessionsByBook(deviceId: String, bookName: String): List<ReadRecordSession>
@Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor")
suspend fun getSessionsByBook(deviceId: String, bookName: String, bookAuthor: String): List<ReadRecordSession>
@Delete
suspend fun deleteDetail(detail: ReadRecordDetail)
@@ -176,10 +189,16 @@ interface ReadRecordDao {
DELETE FROM readRecordSession
WHERE deviceId = :deviceId
AND bookName = :bookName
AND bookAuthor = :bookAuthor
AND STRFTIME('%Y-%m-%d', datetime(startTime/1000, 'unixepoch', 'localtime')) = :date
"""
)
suspend fun deleteSessionsByBookAndDate(deviceId: String, bookName: String, date: String)
suspend fun deleteSessionsByBookAndDate(
deviceId: String,
bookName: String,
bookAuthor: String,
date: String
)
@Delete
suspend fun deleteSession(session: ReadRecordSession)
@@ -187,9 +206,12 @@ interface ReadRecordDao {
@Delete
suspend fun deleteReadRecord(record: ReadRecord)
@Query("DELETE FROM readRecordDetail WHERE deviceId = :deviceId AND bookName = :bookName")
suspend fun deleteDetailsByBook(deviceId: String, bookName: String)
@Query("DELETE FROM readRecordDetail WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor")
suspend fun deleteDetailsByBook(deviceId: String, bookName: String, bookAuthor: String)
@Query("DELETE FROM readRecordSession WHERE deviceId = :deviceId AND bookName = :bookName")
suspend fun deleteSessionsByBook(deviceId: String, bookName: String)
}
@Query("DELETE FROM readRecordSession WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor")
suspend fun deleteSessionsByBook(deviceId: String, bookName: String, bookAuthor: String)
@Query("SELECT * FROM readRecordDetail WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor")
suspend fun getDetailsByBook(deviceId: String, bookName: String, bookAuthor: String): List<ReadRecordDetail>
}
@@ -2,6 +2,7 @@ package io.legado.app.data.entities
data class ReadRecordShow(
var bookName: String,
var bookAuthor: String,
var readTime: Long,
var lastRead: Long
)
)
@@ -3,10 +3,12 @@ package io.legado.app.data.entities.readRecord
import androidx.room.ColumnInfo
import androidx.room.Entity
@Entity(tableName = "readRecord", primaryKeys = ["deviceId", "bookName"])
@Entity(tableName = "readRecord", primaryKeys = ["deviceId", "bookName", "bookAuthor"])
data class ReadRecord(
var deviceId: String = "",
var bookName: String = "",
@ColumnInfo(defaultValue = "")
var bookAuthor: String = "",
@ColumnInfo(defaultValue = "0")
var readTime: Long = 0L,
@ColumnInfo(defaultValue = "0")
@@ -5,11 +5,13 @@ import androidx.room.Entity
@Entity(
tableName = "readRecordDetail",
primaryKeys = ["deviceId", "bookName", "date"]
primaryKeys = ["deviceId", "bookName", "bookAuthor", "date"]
)
data class ReadRecordDetail(
val deviceId: String = "",
val bookName: String = "",
@ColumnInfo(defaultValue = "")
val bookAuthor: String = "",
val date: String = "",
// 当天阅读总时长
@@ -10,6 +10,7 @@ data class ReadRecordSession(
val deviceId: String = "",
val bookName: String = "",
val bookAuthor: String = "",
// 一次阅读的开始/结束
val startTime: Long = 0,
@@ -11,15 +11,15 @@ class BookRepository(
private val bookDao: BookDao,
private val bookChapterDao: BookChapterDao
) {
suspend fun getBookCoverByName(bookName: String): String? {
suspend fun getBookCoverByNameAndAuthor(bookName: String, bookAuthor: String): String? {
return withContext(Dispatchers.IO) {
bookDao.findByName(bookName).firstOrNull()?.getDisplayCover()
bookDao.getBook(bookName, bookAuthor)?.getDisplayCover()
}
}
suspend fun getChapterTitle(bookName: String, chapterIndex: Int): String? {
suspend fun getChapterTitle(bookName: String, bookAuthor: String, chapterIndex: Int): String? {
return withContext(Dispatchers.IO) {
val book = bookDao.findByName(bookName).firstOrNull()
val book = bookDao.getBook(bookName, bookAuthor)
val bookUrl = book?.bookUrl
if (bookUrl.isNullOrEmpty()) return@withContext null
@@ -51,6 +51,14 @@ class ReadRecordRepository(
return dao.getAllSessions(getCurrentDeviceId())
}
suspend fun getMergeCandidates(targetRecord: ReadRecord): List<ReadRecord> {
return dao.getReadRecordsByNameExcludingAuthor(
targetRecord.deviceId,
targetRecord.bookName,
targetRecord.bookAuthor
)
}
/**
* 保存一个完整的阅读会话.
*/
@@ -64,21 +72,22 @@ class ReadRecordRepository(
private suspend fun updateReadRecord(session: ReadRecordSession, durationDelta: Long) {
if (durationDelta <= 0) return
val existingRecord = dao.getReadRecord(session.deviceId, session.bookName)
val existingRecord = dao.getReadRecord(session.deviceId, session.bookName, session.bookAuthor)
if (existingRecord != null) {
dao.update(
existingRecord.copy(
readTime = existingRecord.readTime + durationDelta,
lastRead = session.endTime
readTime = existingRecord.readTime + durationDelta,
lastRead = session.endTime
)
)
} else {
dao.insert(
ReadRecord(
deviceId = session.deviceId,
bookName = session.bookName,
readTime = durationDelta,
lastRead = session.endTime
deviceId = session.deviceId,
bookName = session.bookName,
bookAuthor = session.bookAuthor,
readTime = durationDelta,
lastRead = session.endTime
)
)
}
@@ -91,7 +100,12 @@ class ReadRecordRepository(
dateString: String
) {
if (durationDelta <= 0 && wordsDelta <= 0) return
val existingDetail = dao.getDetail(session.deviceId, session.bookName, dateString)
val existingDetail = dao.getDetail(
session.deviceId,
session.bookName,
session.bookAuthor,
dateString
)
if (existingDetail != null) {
existingDetail.readTime += durationDelta
existingDetail.readWords += wordsDelta
@@ -101,13 +115,14 @@ class ReadRecordRepository(
} else {
dao.insertDetail(
ReadRecordDetail(
deviceId = session.deviceId,
bookName = session.bookName,
date = dateString,
readTime = durationDelta,
readWords = wordsDelta,
firstReadTime = session.startTime,
lastReadTime = session.endTime
deviceId = session.deviceId,
bookName = session.bookName,
bookAuthor = session.bookAuthor,
date = dateString,
readTime = durationDelta,
readWords = wordsDelta,
firstReadTime = session.startTime,
lastReadTime = session.endTime
)
)
}
@@ -115,8 +130,13 @@ class ReadRecordRepository(
suspend fun deleteDetail(detail: ReadRecordDetail) {
dao.deleteDetail(detail)
dao.deleteSessionsByBookAndDate(detail.deviceId, detail.bookName, detail.date)
updateReadRecordTotal(detail.deviceId, detail.bookName)
dao.deleteSessionsByBookAndDate(
detail.deviceId,
detail.bookName,
detail.bookAuthor,
detail.date
)
updateReadRecordTotal(detail.deviceId, detail.bookName, detail.bookAuthor)
}
@Transaction
@@ -125,10 +145,20 @@ class ReadRecordRepository(
val dateString = DateUtil.format(Date(session.startTime), "yyyy-MM-dd")
val remainingSessions =
dao.getSessionsByBookAndDate(session.deviceId, session.bookName, dateString)
dao.getSessionsByBookAndDate(
session.deviceId,
session.bookName,
session.bookAuthor,
dateString
)
if (remainingSessions.isEmpty()) {
val detail = dao.getDetail(session.deviceId, session.bookName, dateString)
val detail = dao.getDetail(
session.deviceId,
session.bookName,
session.bookAuthor,
dateString
)
detail?.let { dao.deleteDetail(it) }
} else {
val totalTime = remainingSessions.sumOf { it.endTime - it.startTime }
@@ -136,7 +166,12 @@ class ReadRecordRepository(
val firstRead = remainingSessions.minOf { it.startTime }
val lastRead = remainingSessions.maxOf { it.endTime }
val existingDetail = dao.getDetail(session.deviceId, session.bookName, dateString)
val existingDetail = dao.getDetail(
session.deviceId,
session.bookName,
session.bookAuthor,
dateString
)
existingDetail?.copy(
readTime = totalTime,
readWords = totalWords,
@@ -145,19 +180,19 @@ class ReadRecordRepository(
)?.let { dao.insertDetail(it) }
}
updateReadRecordTotal(session.deviceId, session.bookName)
updateReadRecordTotal(session.deviceId, session.bookName, session.bookAuthor)
}
private suspend fun updateReadRecordTotal(deviceId: String, bookName: String) {
val allRemainingSessions = dao.getSessionsByBook(deviceId, bookName)
private suspend fun updateReadRecordTotal(deviceId: String, bookName: String, bookAuthor: String) {
val allRemainingSessions = dao.getSessionsByBook(deviceId, bookName, bookAuthor)
if (allRemainingSessions.isEmpty()) {
dao.getReadRecord(deviceId, bookName)?.let { dao.deleteReadRecord(it) }
dao.getReadRecord(deviceId, bookName, bookAuthor)?.let { dao.deleteReadRecord(it) }
} else {
val totalTime = allRemainingSessions.sumOf { it.endTime - it.startTime }
val lastRead = allRemainingSessions.maxOf { it.endTime }
dao.getReadRecord(deviceId, bookName)?.copy(
dao.getReadRecord(deviceId, bookName, bookAuthor)?.copy(
readTime = totalTime,
lastRead = lastRead
)?.let { dao.update(it) }
@@ -166,8 +201,84 @@ class ReadRecordRepository(
suspend fun deleteReadRecord(record: ReadRecord) {
dao.deleteReadRecord(record)
dao.deleteDetailsByBook(record.deviceId, record.bookName)
dao.deleteSessionsByBook(record.deviceId, record.bookName)
dao.deleteDetailsByBook(record.deviceId, record.bookName, record.bookAuthor)
dao.deleteSessionsByBook(record.deviceId, record.bookName, record.bookAuthor)
}
}
@Transaction
suspend fun mergeReadRecordInto(targetRecord: ReadRecord, sourceRecords: List<ReadRecord>) {
sourceRecords.forEach { sourceRecord ->
mergeSingleReadRecordInto(targetRecord, sourceRecord)
}
}
@Transaction
private suspend fun mergeSingleReadRecordInto(targetRecord: ReadRecord, sourceRecord: ReadRecord) {
if (targetRecord == sourceRecord) return
if (targetRecord.deviceId != sourceRecord.deviceId) return
if (targetRecord.bookName != sourceRecord.bookName) return
val source = dao.getReadRecord(
sourceRecord.deviceId,
sourceRecord.bookName,
sourceRecord.bookAuthor
) ?: return
val target = dao.getReadRecord(
targetRecord.deviceId,
targetRecord.bookName,
targetRecord.bookAuthor
) ?: targetRecord
dao.insert(
target.copy(
readTime = target.readTime + source.readTime,
lastRead = max(target.lastRead, source.lastRead)
)
)
val sourceDetails = dao.getDetailsByBook(
sourceRecord.deviceId,
sourceRecord.bookName,
sourceRecord.bookAuthor
)
sourceDetails.forEach { detail ->
val existingTargetDetail = dao.getDetail(
targetRecord.deviceId,
targetRecord.bookName,
targetRecord.bookAuthor,
detail.date
)
if (existingTargetDetail == null) {
dao.insertDetail(
detail.copy(
bookAuthor = targetRecord.bookAuthor
)
)
} else {
dao.insertDetail(
existingTargetDetail.copy(
readTime = existingTargetDetail.readTime + detail.readTime,
readWords = existingTargetDetail.readWords + detail.readWords,
firstReadTime = min(existingTargetDetail.firstReadTime, detail.firstReadTime),
lastReadTime = max(existingTargetDetail.lastReadTime, detail.lastReadTime)
)
)
}
}
dao.deleteDetailsByBook(sourceRecord.deviceId, sourceRecord.bookName, sourceRecord.bookAuthor)
val sourceSessions = dao.getSessionsByBook(
sourceRecord.deviceId,
sourceRecord.bookName,
sourceRecord.bookAuthor
)
sourceSessions.forEach { session ->
dao.updateSession(session.copy(bookAuthor = targetRecord.bookAuthor))
}
dao.deleteReadRecord(source)
updateReadRecordTotal(targetRecord.deviceId, targetRecord.bookName, targetRecord.bookAuthor)
}
}
@@ -177,7 +177,7 @@ object Restore {
appDb.readRecordDao.insert(readRecord)
} else {
val time = appDb.readRecordDao
.getReadTime(readRecord.deviceId, readRecord.bookName)
.getReadTime(readRecord.deviceId, readRecord.bookName, readRecord.bookAuthor)
if (time == null || time < readRecord.readTime) {
appDb.readRecordDao.insert(readRecord)
}
@@ -325,4 +325,4 @@ object Restore {
return null
}
}
}
@@ -117,7 +117,8 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
fun resetData(book: Book) {
ReadBook.book = book
readRecord.bookName = book.name
readRecord.readTime = appDb.readRecordDao.getReadTime(book.name) ?: 0
readRecord.bookAuthor = book.author
readRecord.readTime = appDb.readRecordDao.getReadTime("", book.name, book.author) ?: 0
chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl)
simulatedChapterSize = if (book.readSimulating()) {
book.simulatedTotalChapterNum()
@@ -307,7 +308,10 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
fun initReadTime() {
val currentBookName = book?.name ?: return
if (currentActiveSession != null && currentActiveSession!!.bookName != currentBookName) {
val currentBookAuthor = book?.author ?: ""
if (currentActiveSession != null &&
(currentActiveSession!!.bookName != currentBookName || currentActiveSession!!.bookAuthor != currentBookAuthor)
) {
commitReadSession()
}
@@ -316,6 +320,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
currentActiveSession = ReadRecordSession(
deviceId = "",
bookName = currentBookName,
bookAuthor = currentBookAuthor,
startTime = readStartTime,
endTime = readStartTime,
words = durChapterIndex.toLong()
@@ -326,9 +331,13 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
fun upReadTime() {
val currentLength = currentReadLength
val currentBookName = book?.name ?: return
val currentBookAuthor = book?.author ?: ""
val endTime = System.currentTimeMillis()
if (currentActiveSession == null || currentActiveSession!!.bookName != currentBookName) {
if (currentActiveSession == null ||
currentActiveSession!!.bookName != currentBookName ||
currentActiveSession!!.bookAuthor != currentBookAuthor
) {
initReadTime()
return
}
@@ -1131,4 +1140,4 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
fun cancelSelect()
}
}
}
@@ -90,7 +90,8 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
fun resetData(book: Book) {
ReadManga.book = book
readRecord.bookName = book.name
readRecord.readTime = appDb.readRecordDao.getReadTime(book.name) ?: 0
readRecord.bookAuthor = book.author
readRecord.readTime = appDb.readRecordDao.getReadTime("", book.name, book.author) ?: 0
chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl)
simulatedChapterSize = if (book.readSimulating()) {
book.simulatedTotalChapterNum()
@@ -147,7 +148,10 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
fun initReadTime() {
val currentBookName = book?.name ?: return
if (currentActiveSession != null && currentActiveSession!!.bookName != currentBookName) {
val currentBookAuthor = book?.author ?: ""
if (currentActiveSession != null &&
(currentActiveSession!!.bookName != currentBookName || currentActiveSession!!.bookAuthor != currentBookAuthor)
) {
commitReadSession()
}
@@ -156,6 +160,7 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
currentActiveSession = ReadRecordSession(
deviceId = "",
bookName = currentBookName,
bookAuthor = currentBookAuthor,
startTime = readStartTime,
endTime = readStartTime,
words = durChapterIndex.toLong()
@@ -166,9 +171,13 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
fun upReadTime() {
val currentLength = currentReadLength
val currentBookName = book?.name ?: return
val currentBookAuthor = book?.author ?: ""
val endTime = System.currentTimeMillis()
if (currentActiveSession == null || currentActiveSession!!.bookName != currentBookName) {
if (currentActiveSession == null ||
currentActiveSession!!.bookName != currentBookName ||
currentActiveSession!!.bookAuthor != currentBookAuthor
) {
initReadTime()
return
}
@@ -25,10 +25,10 @@ class ReadRecordActivity : BaseComposeActivity() {
AppTheme {
ReadRecordScreen(
onBackClick = { finish() },
onBookClick = { bookName ->
onBookClick = { bookName, bookAuthor ->
lifecycleScope.launch {
val book = withContext(Dispatchers.IO) {
appDb.bookDao.findByName(bookName).firstOrNull()
appDb.bookDao.getBook(bookName, bookAuthor)
}
if (book != null) startActivityForBook(book)
}
@@ -3,6 +3,7 @@ package io.legado.app.ui.book.readRecord
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -23,6 +24,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Merge
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Timeline
@@ -36,6 +38,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -47,6 +51,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -56,8 +61,11 @@ import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import cn.hutool.core.date.DateUtil
@@ -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.SwipeActionContainer
import io.legado.app.utils.StringUtils.formatFriendlyDate
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import java.time.LocalDate
import java.time.format.DateTimeFormatter
@@ -85,8 +94,11 @@ import java.util.Date
fun ReadRecordScreen(
viewModel: ReadRecordViewModel = koinViewModel(),
onBackClick: () -> Unit,
onBookClick: (String) -> Unit
onBookClick: (String, String) -> Unit
) {
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val state by viewModel.uiState.collectAsState()
val displayMode by viewModel.displayMode.collectAsState()
var showSearch by remember { mutableStateOf(false) }
@@ -96,6 +108,7 @@ fun ReadRecordScreen(
var skipDeleteConfirm by remember { mutableStateOf(false) }
var pendingDeleteAction by remember { mutableStateOf<(() -> Unit)?>(null) }
var mergeDialogData by remember { mutableStateOf<Pair<ReadRecord, List<ReadRecord>>?>(null) }
val onConfirmDelete: (() -> Unit) -> Unit = { action ->
if (skipDeleteConfirm) {
action()
@@ -112,6 +125,7 @@ fun ReadRecordScreen(
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
Column {
GlassMediumFlexibleTopAppBar(
@@ -226,7 +240,17 @@ fun ReadRecordScreen(
state = state,
viewModel = viewModel,
onBookClick = onBookClick,
onConfirmDelete = onConfirmDelete
onConfirmDelete = onConfirmDelete,
onMergeClick = { record ->
scope.launch {
val candidates = viewModel.getMergeCandidates(record)
if (candidates.isEmpty()) {
snackbarHostState.showSnackbar("没有可合并的同名记录")
} else {
mergeDialogData = record to candidates
}
}
}
)
}
}
@@ -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
@@ -300,7 +383,7 @@ fun SummarySection(
val dailyDetails = state.groupedRecords[dateKey] ?: emptyList()
if (dailyDetails.isNotEmpty()) {
val distinctBooks = dailyDetails.map { it.bookName }.distinct()
val distinctBooks = dailyDetails.map { it.bookName to it.bookAuthor }.distinct()
val dailyTime = dailyDetails.sumOf { it.readTime }
ReadingSummaryCard(
@@ -321,7 +404,7 @@ fun SummarySection(
title = "累计阅读成就",
bookCount = allBooksCount,
totalTimeMillis = totalTime,
bookNamesForCover = state.latestRecords.take(5).map { it.bookName },
bookNamesForCover = state.latestRecords.take(5).map { it.bookName to it.bookAuthor },
viewModel = viewModel,
onClick = { }
)
@@ -333,16 +416,18 @@ fun LazyListScope.renderListByMode(
displayMode: DisplayMode,
state: ReadRecordUiState,
viewModel: ReadRecordViewModel,
onBookClick: (String) -> Unit,
onConfirmDelete: (() -> Unit) -> Unit
onBookClick: (String, String) -> Unit,
onConfirmDelete: (() -> Unit) -> Unit,
onMergeClick: (ReadRecord) -> Unit
) {
when (displayMode) {
DisplayMode.AGGREGATE -> {
state.groupedRecords.forEach { (date, details) ->
stickyHeader(key = "header_$date") {
DateHeader(date, details.sumOf { it.readTime })
}
items(items = details, key = { "${it.bookName}_${it.date}" }) { detail ->
items(items = details, key = { "${it.bookName}_${it.bookAuthor}_${it.date}" }) { detail ->
SwipeActionContainer(
modifier = Modifier.animateItem(),
startAction = SwipeAction(
@@ -356,7 +441,7 @@ fun LazyListScope.renderListByMode(
ReadRecordItem(
detail,
viewModel,
onClick = { onBookClick(detail.bookName) })
onClick = { onBookClick(detail.bookName, detail.bookAuthor) })
}
}
}
@@ -387,7 +472,7 @@ fun LazyListScope.renderListByMode(
}
DisplayMode.LATEST -> {
items(items = state.latestRecords, key = { it.bookName }) { record ->
items(items = state.latestRecords, key = { "${it.bookName}_${it.bookAuthor}" }) { record ->
SwipeActionContainer(
modifier = Modifier.animateItem(),
startAction = SwipeAction(
@@ -396,16 +481,23 @@ fun LazyListScope.renderListByMode(
onSwipe = {
onConfirmDelete { viewModel.deleteReadRecord(record) }
}
),
endAction = SwipeAction(
icon = Icons.Default.Merge,
background = MaterialTheme.colorScheme.primary,
onSwipe = {
onMergeClick(record)
}
)
) {
LatestReadItem(
record = record,
viewModel = viewModel,
onClick = { onBookClick(record.bookName) }
)
LatestReadItem(
record = record,
viewModel = viewModel,
onClick = { onBookClick(record.bookName, record.bookAuthor) }
)
}
}
}
}
}
}
@@ -418,8 +510,8 @@ fun LatestReadItem(
) {
var coverPath by remember { mutableStateOf<String?>(null) }
LaunchedEffect(record.bookName) {
coverPath = viewModel.getBookCover(record.bookName)
LaunchedEffect(record.bookName, record.bookAuthor) {
coverPath = viewModel.getBookCover(record.bookName, record.bookAuthor)
}
Row(
@@ -437,18 +529,36 @@ fun LatestReadItem(
Text(
text = record.bookName,
style = MaterialTheme.typography.titleMedium,
maxLines = 2
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "总时长: ${formatDuring(record.readTime)}",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "最后阅读: ${DateUtil.format(Date(record.lastRead), "yyyy-MM-dd HH:mm")}",
text = record.bookAuthor.ifBlank { "未知作者" },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
color = MaterialTheme.colorScheme.outline,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
Text(
modifier = Modifier
.basicMarquee(
iterations = Int.MAX_VALUE,
repeatDelayMillis = 2000,
initialDelayMillis = 1000
),
text = buildAnnotatedString {
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.outline)) {
append(formatDuring(record.readTime))
append("")
}
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {
append("${DateUtil.format(Date(record.lastRead), "yyyy-MM-dd HH:mm")}")
}
},
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@@ -458,15 +568,15 @@ fun LatestReadItem(
fun TimelineSessionItem(
item: TimelineItem,
viewModel: ReadRecordViewModel,
onBookClick: (String) -> Unit
onBookClick: (String, String) -> Unit
) {
val session = item.session
var coverPath by remember { mutableStateOf<String?>(null) }
var chapterTitle by remember { mutableStateOf<String?>("加载中...") }
LaunchedEffect(session.bookName) {
coverPath = viewModel.getBookCover(session.bookName)
val title = viewModel.getChapterTitle(session.bookName, session.words)
LaunchedEffect(session.bookName, session.bookAuthor) {
coverPath = viewModel.getBookCover(session.bookName, session.bookAuthor)
val title = viewModel.getChapterTitle(session.bookName, session.bookAuthor, session.words)
chapterTitle = title ?: "${session.words}"
}
@@ -483,7 +593,7 @@ fun TimelineSessionItem(
Box(
modifier = Modifier
.fillMaxWidth()
.clickable { onBookClick(session.bookName) }
.clickable { onBookClick(session.bookName, session.bookAuthor) }
.drawBehind {
val x = timelineX.toPx()
val h = size.height
@@ -528,14 +638,21 @@ fun TimelineSessionItem(
Text(
text = session.bookName,
style = MaterialTheme.typography.titleMedium,
maxLines = 2,
minLines = 2,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = chapterTitle.orEmpty(),
text = session.bookAuthor.ifBlank { "未知作者" },
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
color = MaterialTheme.colorScheme.outline,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = chapterTitle.orEmpty(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -554,8 +671,8 @@ fun ReadRecordItem(
) {
var coverPath by remember { mutableStateOf<String?>(null) }
LaunchedEffect(detail.bookName) {
coverPath = viewModel.getBookCover(detail.bookName)
LaunchedEffect(detail.bookName, detail.bookAuthor) {
coverPath = viewModel.getBookCover(detail.bookName, detail.bookAuthor)
}
Row(
@@ -573,13 +690,18 @@ fun ReadRecordItem(
Text(
text = detail.bookName,
style = MaterialTheme.typography.titleMedium,
maxLines = 2
maxLines = 1
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = detail.bookAuthor.ifBlank { "未知作者" },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "阅读时长: ${formatDuring(detail.readTime)}",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
color = MaterialTheme.colorScheme.outline,
style = MaterialTheme.typography.labelSmall
)
}
}
@@ -636,14 +758,14 @@ fun ReadingSummaryCard(
title: String,
bookCount: Int,
totalTimeMillis: Long,
bookNamesForCover: List<String>,
bookNamesForCover: List<Pair<String, String>>,
viewModel: ReadRecordViewModel,
onClick: () -> Unit
) {
val coverPaths by produceState(initialValue = emptyList(), key1 = bookNamesForCover) {
value = bookNamesForCover.map { name ->
viewModel.getBookCover(name)
value = bookNamesForCover.map { (name, author) ->
viewModel.getBookCover(name, author)
}
}
@@ -753,4 +875,4 @@ fun formatDuring(mss: Long): String {
val m = if (minutes > 0) "${minutes}分钟" else ""
val s = if (seconds > 0) "${seconds}" else ""
return if ("$d$h$m$s".isBlank()) "0秒" else "$d$h$m$s"
}
}
@@ -80,10 +80,9 @@ class ReadRecordViewModel(
.filter { session ->
val sDate = DateUtil.format(Date(session.startTime), "yyyy-MM-dd")
(dateStr == null || sDate == dateStr) &&
(searchKey.isEmpty() || session.bookName.contains(
searchKey,
ignoreCase = true
))
(searchKey.isEmpty() ||
session.bookName.contains(searchKey, ignoreCase = true) ||
session.bookAuthor.contains(searchKey, ignoreCase = true))
}
.groupBy { DateUtil.format(Date(it.startTime), "yyyy-MM-dd") }
.mapValues { (_, sessions) ->
@@ -139,7 +138,10 @@ class ReadRecordViewModel(
for (i in 1 until sessions.size) {
val current = sessions[i]
val last = mergedList.last()
if (current.bookName == last.bookName && (current.startTime - last.endTime) <= gapLimit) {
if (current.bookName == last.bookName &&
current.bookAuthor == last.bookAuthor &&
(current.startTime - last.endTime) <= gapLimit
) {
mergedList[mergedList.lastIndex] = last.copy(endTime = current.endTime)
} else {
mergedList.add(current.copy())
@@ -148,12 +150,23 @@ class ReadRecordViewModel(
return mergedList
}
suspend fun getChapterTitle(bookName: String, chapterIndexLong: Long): String? {
return bookRepository.getChapterTitle(bookName, chapterIndexLong.toInt())
suspend fun getChapterTitle(bookName: String, bookAuthor: String, chapterIndexLong: Long): String? {
return bookRepository.getChapterTitle(bookName, bookAuthor, chapterIndexLong.toInt())
}
suspend fun getBookCover(bookName: String): String? {
return bookRepository.getBookCoverByName(bookName)
suspend fun getBookCover(bookName: String, bookAuthor: String): String? {
return bookRepository.getBookCoverByNameAndAuthor(bookName, bookAuthor)
}
suspend fun getMergeCandidates(targetRecord: ReadRecord): List<ReadRecord> {
return repository.getMergeCandidates(targetRecord)
}
fun mergeReadRecords(targetRecord: ReadRecord, sourceRecords: List<ReadRecord>) {
if (sourceRecords.isEmpty()) return
viewModelScope.launch {
repository.mergeReadRecordInto(targetRecord, sourceRecords)
}
}
private data class LoadedData(
@@ -162,4 +175,4 @@ class ReadRecordViewModel(
val latestRecords: List<ReadRecord>,
val sessions: List<ReadRecordSession>
)
}
}
@@ -31,6 +31,7 @@ import androidx.compose.ui.unit.dp
fun SwipeActionContainer(
modifier: Modifier = Modifier,
startAction: SwipeAction? = null,
endAction: SwipeAction? = null,
content: @Composable () -> Unit
) {
val haptic = LocalHapticFeedback.current
@@ -39,8 +40,6 @@ fun SwipeActionContainer(
totalDistance * 0.6f
}
)
val isThresholdReached =
dismissState.progress > 0.5f && dismissState.targetValue == SwipeToDismissBoxValue.StartToEnd
LaunchedEffect(dismissState.targetValue) {
if (dismissState.targetValue == SwipeToDismissBoxValue.StartToEnd) {
@@ -51,9 +50,18 @@ fun SwipeActionContainer(
}
LaunchedEffect(dismissState.settledValue) {
if (dismissState.settledValue == SwipeToDismissBoxValue.StartToEnd) {
startAction?.onSwipe?.invoke()
dismissState.reset()
when (dismissState.settledValue) {
SwipeToDismissBoxValue.StartToEnd -> {
startAction?.onSwipe?.invoke()
dismissState.reset()
}
SwipeToDismissBoxValue.EndToStart -> {
endAction?.onSwipe?.invoke()
dismissState.reset()
}
else -> {}
}
}
@@ -61,44 +69,34 @@ fun SwipeActionContainer(
state = dismissState,
modifier = modifier,
enableDismissFromStartToEnd = startAction != null,
enableDismissFromEndToStart = false,
enableDismissFromEndToStart = endAction != null,
backgroundContent = {
val direction = dismissState.dismissDirection
val progress = dismissState.progress
if (direction == SwipeToDismissBoxValue.StartToEnd) {
val backgroundColor by animateColorAsState(
targetValue = if (isThresholdReached) startAction!!.background
else MaterialTheme.colorScheme.surfaceVariant,
label = "bgColor"
)
when (direction) {
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
.padding(horizontal = 24.dp),
contentAlignment = Alignment.CenterStart
) {
val iconScale by animateFloatAsState(
targetValue = if (isThresholdReached) 1.3f else progress.coerceIn(0.5f, 1f),
label = "iconScale"
)
Icon(
imageVector = startAction!!.icon,
contentDescription = null,
modifier = Modifier
.size(24.dp)
.graphicsLayer {
scaleX = iconScale
scaleY = iconScale
},
tint = if (isThresholdReached)
contentColorFor(startAction.background)
else MaterialTheme.colorScheme.onSurfaceVariant
)
SwipeToDismissBoxValue.StartToEnd -> {
startAction?.let { action ->
SwipeBackground(
action = action,
progress = progress,
alignStart = true
)
}
}
SwipeToDismissBoxValue.EndToStart -> {
endAction?.let { action ->
SwipeBackground(
action = action,
progress = progress,
alignStart = false
)
}
}
else -> {}
}
},
content = {
@@ -111,4 +109,53 @@ fun SwipeActionContainer(
}
}
)
}
@Composable
private fun SwipeBackground(
action: SwipeAction,
progress: Float,
alignStart: Boolean
) {
val isThresholdReached = progress > 0.6f
val backgroundColor by animateColorAsState(
targetValue = if (isThresholdReached)
action.background
else
MaterialTheme.colorScheme.surfaceVariant,
label = "bgColor"
)
val iconScale by animateFloatAsState(
targetValue = if (isThresholdReached) 1.3f
else progress.coerceIn(0.5f, 1f),
label = "iconScale"
)
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
.padding(horizontal = 24.dp),
contentAlignment = if (alignStart)
Alignment.CenterStart
else
Alignment.CenterEnd
) {
Icon(
imageVector = action.icon,
contentDescription = null,
modifier = Modifier
.size(24.dp)
.graphicsLayer {
scaleX = iconScale
scaleY = iconScale
},
tint = if (isThresholdReached)
contentColorFor(action.background)
else
MaterialTheme.colorScheme.onSurfaceVariant
)
}
}