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