From 24970eff4495d7da576b391a1c4a9d353dd87fe9 Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 19 Feb 2026 21:44:06 +0800 Subject: [PATCH 1/2] =?UTF-8?q?[=E4=BF=AE=E5=A4=8D]=20bookName=20=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E5=90=8C=E5=90=8D=E4=B8=8D=E5=90=8C=E4=BD=9C=E8=80=85?= =?UTF-8?q?=E7=9A=84=E4=B9=A6=E7=B1=8D=E4=BC=9A=E8=A2=AB=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E9=98=85=E8=AF=BB=E6=97=B6=E9=95=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../io.legado.app.data.AppDatabase/83.json | 2044 +++++++++++++++++ .../java/io/legado/app/data/AppDatabase.kt | 2 +- .../io/legado/app/data/DatabaseMigrations.kt | 129 +- .../io/legado/app/data/dao/ReadRecordDao.kt | 88 +- .../app/data/entities/ReadRecordShow.kt | 3 +- .../data/entities/readRecord/ReadRecord.kt | 4 +- .../entities/readRecord/ReadRecordDetail.kt | 4 +- .../entities/readRecord/ReadRecordSession.kt | 1 + .../app/data/repository/BookRepository.kt | 8 +- .../data/repository/ReadRecordRepository.kt | 166 +- .../io/legado/app/help/storage/Restore.kt | 4 +- .../main/java/io/legado/app/model/ReadBook.kt | 17 +- .../java/io/legado/app/model/ReadManga.kt | 15 +- .../ui/book/readRecord/ReadRecordActivity.kt | 4 +- .../ui/book/readRecord/ReadRecordScreen.kt | 153 +- .../ui/book/readRecord/ReadRecordViewModel.kt | 33 +- 16 files changed, 2556 insertions(+), 119 deletions(-) create mode 100644 app/schemas/io.legado.app.data.AppDatabase/83.json diff --git a/app/schemas/io.legado.app.data.AppDatabase/83.json b/app/schemas/io.legado.app.data.AppDatabase/83.json new file mode 100644 index 000000000..dfe518f50 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/83.json @@ -0,0 +1,2044 @@ +{ + "formatVersion": 1, + "database": { + "version": 83, + "identityHash": "698a30613a6ad9c2197b1ac9e55c6f8b", + "entities": [ + { + "tableName": "books", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `remark` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, `syncTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`bookUrl`))", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'loc_book'" + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT" + }, + { + "fieldPath": "customTag", + "columnName": "customTag", + "affinity": "TEXT" + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "customCoverUrl", + "columnName": "customCoverUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT" + }, + { + "fieldPath": "customIntro", + "columnName": "customIntro", + "affinity": "TEXT" + }, + { + "fieldPath": "remark", + "columnName": "remark", + "affinity": "TEXT" + }, + { + "fieldPath": "charset", + "columnName": "charset", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "latestChapterTime", + "columnName": "latestChapterTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastCheckTime", + "columnName": "lastCheckTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastCheckCount", + "columnName": "lastCheckCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "totalChapterNum", + "columnName": "totalChapterNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "durChapterTitle", + "columnName": "durChapterTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "durChapterIndex", + "columnName": "durChapterIndex", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "durChapterPos", + "columnName": "durChapterPos", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "durChapterTime", + "columnName": "durChapterTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT" + }, + { + "fieldPath": "canUpdate", + "columnName": "canUpdate", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT" + }, + { + "fieldPath": "readConfig", + "columnName": "readConfig", + "affinity": "TEXT" + }, + { + "fieldPath": "syncTime", + "columnName": "syncTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "bookUrl" + ] + }, + "indices": [ + { + "name": "index_books_name_author", + "unique": false, + "columnNames": [ + "name", + "author" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)" + } + ] + }, + { + "tableName": "book_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `cover` TEXT, `order` INTEGER NOT NULL, `enableRefresh` INTEGER NOT NULL DEFAULT 1, `show` INTEGER NOT NULL DEFAULT 1, `bookSort` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupName", + "columnName": "groupName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cover", + "columnName": "cover", + "affinity": "TEXT" + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enableRefresh", + "columnName": "enableRefresh", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "show", + "columnName": "show", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "bookSort", + "columnName": "bookSort", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "groupId" + ] + } + }, + { + "tableName": "book_sources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceUrl` TEXT NOT NULL, `bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL DEFAULT 0, `enabled` INTEGER NOT NULL DEFAULT 1, `enabledExplore` INTEGER NOT NULL DEFAULT 1, `jsLib` TEXT, `enabledCookieJar` INTEGER DEFAULT 0, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `coverDecodeJs` TEXT, `bookSourceComment` TEXT, `variableComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `respondTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `exploreScreen` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, `ruleReview` TEXT, `eventListener` INTEGER NOT NULL DEFAULT 0, `customButton` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`bookSourceUrl`))", + "fields": [ + { + "fieldPath": "bookSourceUrl", + "columnName": "bookSourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceName", + "columnName": "bookSourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookSourceGroup", + "columnName": "bookSourceGroup", + "affinity": "TEXT" + }, + { + "fieldPath": "bookSourceType", + "columnName": "bookSourceType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookUrlPattern", + "columnName": "bookUrlPattern", + "affinity": "TEXT" + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "enabledExplore", + "columnName": "enabledExplore", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "jsLib", + "columnName": "jsLib", + "affinity": "TEXT" + }, + { + "fieldPath": "enabledCookieJar", + "columnName": "enabledCookieJar", + "affinity": "INTEGER", + "defaultValue": "0" + }, + { + "fieldPath": "concurrentRate", + "columnName": "concurrentRate", + "affinity": "TEXT" + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT" + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "loginUi", + "columnName": "loginUi", + "affinity": "TEXT" + }, + { + "fieldPath": "loginCheckJs", + "columnName": "loginCheckJs", + "affinity": "TEXT" + }, + { + "fieldPath": "coverDecodeJs", + "columnName": "coverDecodeJs", + "affinity": "TEXT" + }, + { + "fieldPath": "bookSourceComment", + "columnName": "bookSourceComment", + "affinity": "TEXT" + }, + { + "fieldPath": "variableComment", + "columnName": "variableComment", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "respondTime", + "columnName": "respondTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exploreUrl", + "columnName": "exploreUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "exploreScreen", + "columnName": "exploreScreen", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleExplore", + "columnName": "ruleExplore", + "affinity": "TEXT" + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleSearch", + "columnName": "ruleSearch", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleBookInfo", + "columnName": "ruleBookInfo", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleToc", + "columnName": "ruleToc", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleReview", + "columnName": "ruleReview", + "affinity": "TEXT" + }, + { + "fieldPath": "eventListener", + "columnName": "eventListener", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "customButton", + "columnName": "customButton", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "bookSourceUrl" + ] + }, + "indices": [ + { + "name": "index_book_sources_bookSourceUrl", + "unique": false, + "columnNames": [ + "bookSourceUrl" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)" + } + ] + }, + { + "tableName": "chapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `isVolume` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `isVip` INTEGER NOT NULL, `isPay` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `wordCount` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` TEXT, `reviewImg` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isVolume", + "columnName": "isVolume", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isVip", + "columnName": "isVip", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPay", + "columnName": "isPay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resourceUrl", + "columnName": "resourceUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT" + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT" + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER" + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER" + }, + { + "fieldPath": "startFragmentId", + "columnName": "startFragmentId", + "affinity": "TEXT" + }, + { + "fieldPath": "endFragmentId", + "columnName": "endFragmentId", + "affinity": "TEXT" + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT" + }, + { + "fieldPath": "reviewImg", + "columnName": "reviewImg", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "url", + "bookUrl" + ] + }, + "indices": [ + { + "name": "index_chapters_bookUrl", + "unique": false, + "columnNames": [ + "bookUrl" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_chapters_bookUrl_index", + "unique": true, + "columnNames": [ + "bookUrl", + "index" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)" + } + ], + "foreignKeys": [ + { + "table": "books", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookUrl" + ], + "referencedColumns": [ + "bookUrl" + ] + } + ] + }, + { + "tableName": "replace_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL DEFAULT '', `group` TEXT, `pattern` TEXT NOT NULL DEFAULT '', `replacement` TEXT NOT NULL DEFAULT '', `scope` TEXT, `scopeTitle` INTEGER NOT NULL DEFAULT 0, `scopeContent` INTEGER NOT NULL DEFAULT 1, `excludeScope` TEXT, `isEnabled` INTEGER NOT NULL DEFAULT 1, `isRegex` INTEGER NOT NULL DEFAULT 1, `timeoutMillisecond` INTEGER NOT NULL DEFAULT 3000, `sortOrder` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT" + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT" + }, + { + "fieldPath": "scopeTitle", + "columnName": "scopeTitle", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "scopeContent", + "columnName": "scopeContent", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "excludeScope", + "columnName": "excludeScope", + "affinity": "TEXT" + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "timeoutMillisecond", + "columnName": "timeoutMillisecond", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "3000" + }, + { + "fieldPath": "order", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_replace_rules_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)" + } + ] + }, + { + "tableName": "searchBooks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, `chapterWordCountText` TEXT, `chapterWordCount` INTEGER NOT NULL DEFAULT -1, `respondTime` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bookUrl", + "columnName": "bookUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originName", + "columnName": "originName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT" + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "intro", + "columnName": "intro", + "affinity": "TEXT" + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "TEXT" + }, + { + "fieldPath": "latestChapterTitle", + "columnName": "latestChapterTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "tocUrl", + "columnName": "tocUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT" + }, + { + "fieldPath": "originOrder", + "columnName": "originOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterWordCountText", + "columnName": "chapterWordCountText", + "affinity": "TEXT" + }, + { + "fieldPath": "chapterWordCount", + "columnName": "chapterWordCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "respondTime", + "columnName": "respondTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "bookUrl" + ] + }, + "indices": [ + { + "name": "index_searchBooks_bookUrl", + "unique": true, + "columnNames": [ + "bookUrl" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)" + }, + { + "name": "index_searchBooks_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)" + } + ], + "foreignKeys": [ + { + "table": "book_sources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "origin" + ], + "referencedColumns": [ + "bookSourceUrl" + ] + } + ] + }, + { + "tableName": "search_keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))", + "fields": [ + { + "fieldPath": "word", + "columnName": "word", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usage", + "columnName": "usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUseTime", + "columnName": "lastUseTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "word" + ] + }, + "indices": [ + { + "name": "index_search_keywords_word", + "unique": true, + "columnNames": [ + "word" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)" + } + ] + }, + { + "tableName": "cookies", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cookie", + "columnName": "cookie", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "url" + ] + }, + "indices": [ + { + "name": "index_cookies_url", + "unique": true, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)" + } + ] + }, + { + "tableName": "rssSources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `sourceComment` TEXT, `enabled` INTEGER NOT NULL, `variableComment` TEXT, `jsLib` TEXT, `enabledCookieJar` INTEGER DEFAULT 0, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `coverDecodeJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL DEFAULT 0, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `contentWhitelist` TEXT, `contentBlacklist` TEXT, `shouldOverrideUrlLoading` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL DEFAULT 1, `loadWithBaseUrl` INTEGER NOT NULL DEFAULT 1, `injectJs` TEXT, `lastUpdateTime` INTEGER NOT NULL DEFAULT 0, `customOrder` INTEGER NOT NULL DEFAULT 0, `redirectPolicy` TEXT NOT NULL DEFAULT 'ASK_CROSS_ORIGIN', PRIMARY KEY(`sourceUrl`))", + "fields": [ + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceName", + "columnName": "sourceName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceIcon", + "columnName": "sourceIcon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceGroup", + "columnName": "sourceGroup", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceComment", + "columnName": "sourceComment", + "affinity": "TEXT" + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variableComment", + "columnName": "variableComment", + "affinity": "TEXT" + }, + { + "fieldPath": "jsLib", + "columnName": "jsLib", + "affinity": "TEXT" + }, + { + "fieldPath": "enabledCookieJar", + "columnName": "enabledCookieJar", + "affinity": "INTEGER", + "defaultValue": "0" + }, + { + "fieldPath": "concurrentRate", + "columnName": "concurrentRate", + "affinity": "TEXT" + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT" + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "loginUi", + "columnName": "loginUi", + "affinity": "TEXT" + }, + { + "fieldPath": "loginCheckJs", + "columnName": "loginCheckJs", + "affinity": "TEXT" + }, + { + "fieldPath": "coverDecodeJs", + "columnName": "coverDecodeJs", + "affinity": "TEXT" + }, + { + "fieldPath": "sortUrl", + "columnName": "sortUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "singleUrl", + "columnName": "singleUrl", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "articleStyle", + "columnName": "articleStyle", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "ruleArticles", + "columnName": "ruleArticles", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleNextPage", + "columnName": "ruleNextPage", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleTitle", + "columnName": "ruleTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "rulePubDate", + "columnName": "rulePubDate", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleDescription", + "columnName": "ruleDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleImage", + "columnName": "ruleImage", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleLink", + "columnName": "ruleLink", + "affinity": "TEXT" + }, + { + "fieldPath": "ruleContent", + "columnName": "ruleContent", + "affinity": "TEXT" + }, + { + "fieldPath": "contentWhitelist", + "columnName": "contentWhitelist", + "affinity": "TEXT" + }, + { + "fieldPath": "contentBlacklist", + "columnName": "contentBlacklist", + "affinity": "TEXT" + }, + { + "fieldPath": "shouldOverrideUrlLoading", + "columnName": "shouldOverrideUrlLoading", + "affinity": "TEXT" + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT" + }, + { + "fieldPath": "enableJs", + "columnName": "enableJs", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "loadWithBaseUrl", + "columnName": "loadWithBaseUrl", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "injectJs", + "columnName": "injectJs", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "redirectPolicy", + "columnName": "redirectPolicy", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'ASK_CROSS_ORIGIN'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourceUrl" + ] + }, + "indices": [ + { + "name": "index_rssSources_sourceUrl", + "unique": false, + "columnNames": [ + "sourceUrl" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)" + } + ] + }, + { + "tableName": "bookmarks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `bookText` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))", + "fields": [ + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterIndex", + "columnName": "chapterIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterPos", + "columnName": "chapterPos", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chapterName", + "columnName": "chapterName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookText", + "columnName": "bookText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "time" + ] + }, + "indices": [ + { + "name": "index_bookmarks_bookName_bookAuthor", + "unique": false, + "columnNames": [ + "bookName", + "bookAuthor" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`)" + } + ] + }, + { + "tableName": "rssArticles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `group` TEXT NOT NULL DEFAULT '默认分组', `read` INTEGER NOT NULL, `variable` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`origin`, `link`, `sort`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT" + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT" + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'默认分组'" + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "durPos", + "columnName": "durPos", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "origin", + "link", + "sort" + ] + } + }, + { + "tableName": "rssReadRecords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `title` TEXT, `readTime` INTEGER, `read` INTEGER NOT NULL, `origin` TEXT NOT NULL DEFAULT '', `sort` TEXT NOT NULL DEFAULT '', `image` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, `pubDate` TEXT, PRIMARY KEY(`record`))", + "fields": [ + { + "fieldPath": "record", + "columnName": "record", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "durPos", + "columnName": "durPos", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "record" + ] + }, + "indices": [ + { + "name": "index_rssReadRecords_origin", + "unique": false, + "columnNames": [ + "origin" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rssReadRecords_origin` ON `${TABLE_NAME}` (`origin`)" + } + ] + }, + { + "tableName": "readRecordDetail", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`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`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "readWords", + "columnName": "readWords", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "firstReadTime", + "columnName": "firstReadTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastReadTime", + "columnName": "lastReadTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "bookName", + "bookAuthor", + "date" + ] + } + }, + { + "tableName": "readRecordSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `startTime` INTEGER NOT NULL, `endTime` INTEGER NOT NULL, `words` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startTime", + "columnName": "startTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endTime", + "columnName": "endTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "words", + "columnName": "words", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "rssStars", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `group` TEXT NOT NULL DEFAULT '默认分组', `variable` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`origin`, `link`))", + "fields": [ + { + "fieldPath": "origin", + "columnName": "origin", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "starTime", + "columnName": "starTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubDate", + "columnName": "pubDate", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT" + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT" + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'默认分组'" + }, + { + "fieldPath": "variable", + "columnName": "variable", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "durPos", + "columnName": "durPos", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "origin", + "link" + ] + } + }, + { + "tableName": "txtTocRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `example` TEXT, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rule", + "columnName": "rule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "example", + "columnName": "example", + "affinity": "TEXT" + }, + { + "fieldPath": "serialNumber", + "columnName": "serialNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enable", + "columnName": "enable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "readRecord", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`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`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "readTime", + "columnName": "readTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastRead", + "columnName": "lastRead", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId", + "bookName", + "bookAuthor" + ] + } + }, + { + "tableName": "httpTTS", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `contentType` TEXT, `concurrentRate` TEXT DEFAULT '0', `loginUrl` TEXT, `loginUi` TEXT, `header` TEXT, `jsLib` TEXT, `enabledCookieJar` INTEGER DEFAULT 0, `loginCheckJs` TEXT, `lastUpdateTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT" + }, + { + "fieldPath": "concurrentRate", + "columnName": "concurrentRate", + "affinity": "TEXT", + "defaultValue": "'0'" + }, + { + "fieldPath": "loginUrl", + "columnName": "loginUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "loginUi", + "columnName": "loginUi", + "affinity": "TEXT" + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "TEXT" + }, + { + "fieldPath": "jsLib", + "columnName": "jsLib", + "affinity": "TEXT" + }, + { + "fieldPath": "enabledCookieJar", + "columnName": "enabledCookieJar", + "affinity": "INTEGER", + "defaultValue": "0" + }, + { + "fieldPath": "loginCheckJs", + "columnName": "loginCheckJs", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "caches", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT" + }, + { + "fieldPath": "deadline", + "columnName": "deadline", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [ + { + "name": "index_caches_key", + "unique": true, + "columnNames": [ + "key" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)" + } + ] + }, + { + "tableName": "ruleSubs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, `autoUpdate` INTEGER NOT NULL, `update` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "autoUpdate", + "columnName": "autoUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "update", + "columnName": "update", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "dictRules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `urlRule` TEXT NOT NULL, `showRule` TEXT NOT NULL, `enabled` INTEGER NOT NULL DEFAULT 1, `sortNumber` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "urlRule", + "columnName": "urlRule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "showRule", + "columnName": "showRule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "sortNumber", + "columnName": "sortNumber", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + } + }, + { + "tableName": "keyboardAssists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` INTEGER NOT NULL DEFAULT 0, `key` TEXT NOT NULL DEFAULT '', `value` TEXT NOT NULL DEFAULT '', `serialNo` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`type`, `key`))", + "fields": [ + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "serialNo", + "columnName": "serialNo", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "type", + "key" + ] + } + }, + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `config` TEXT, `sortNumber` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT" + }, + { + "fieldPath": "sortNumber", + "columnName": "sortNumber", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "views": [ + { + "viewName": "book_sources_part", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS select bookSourceUrl, bookSourceName, bookSourceGroup, customOrder, enabled, enabledExplore, \n (loginUrl is not null and trim(loginUrl) <> '') hasLoginUrl, lastUpdateTime, respondTime, weight, \n (exploreUrl is not null and trim(exploreUrl) <> '') hasExploreUrl \n from book_sources" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '698a30613a6ad9c2197b1ac9e55c6f8b')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/AppDatabase.kt b/app/src/main/java/io/legado/app/data/AppDatabase.kt index c46c8067f..7a2b25c0a 100644 --- a/app/src/main/java/io/legado/app/data/AppDatabase.kt +++ b/app/src/main/java/io/legado/app/data/AppDatabase.kt @@ -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, diff --git a/app/src/main/java/io/legado/app/data/DatabaseMigrations.kt b/app/src/main/java/io/legado/app/data/DatabaseMigrations.kt index 3dc41d31e..f0745618e 100644 --- a/app/src/main/java/io/legado/app/data/DatabaseMigrations.kt +++ b/app/src/main/java/io/legado/app/data/DatabaseMigrations.kt @@ -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 -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt b/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt index edbf02d20..7ef0e7f9f 100644 --- a/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt +++ b/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt @@ -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 @@ -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 @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 // 清除每天的统计记录 - @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> /** 搜索 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> + @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 + /** * 获取某一天某一本书的所有会话记录 */ @@ -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 + suspend fun getSessionsByBookAndDate( + deviceId: String, + bookName: String, + bookAuthor: String, + date: String + ): List /** * 获取某一天所有书籍的会话记录 @@ -149,24 +162,24 @@ interface ReadRecordDao { """) suspend fun getSessionsByDate(deviceId: String, date: String): List - @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 // 清除会话记录 - @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> - @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> @Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId ORDER BY startTime ASC") fun getAllSessions(deviceId: String): Flow> - @Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId AND bookName = :bookName") - suspend fun getSessionsByBook(deviceId: String, bookName: String): List + @Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor") + suspend fun getSessionsByBook(deviceId: String, bookName: String, bookAuthor: String): List @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) -} \ No newline at end of file + @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 +} diff --git a/app/src/main/java/io/legado/app/data/entities/ReadRecordShow.kt b/app/src/main/java/io/legado/app/data/entities/ReadRecordShow.kt index 293270276..7db51d188 100644 --- a/app/src/main/java/io/legado/app/data/entities/ReadRecordShow.kt +++ b/app/src/main/java/io/legado/app/data/entities/ReadRecordShow.kt @@ -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 -) \ No newline at end of file +) diff --git a/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecord.kt b/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecord.kt index 7e9d3cf0e..fa9a57cd7 100644 --- a/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecord.kt +++ b/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecord.kt @@ -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") diff --git a/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordDetail.kt b/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordDetail.kt index 11e673271..ca929e2a2 100644 --- a/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordDetail.kt +++ b/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordDetail.kt @@ -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 = "", // 当天阅读总时长 diff --git a/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordSession.kt b/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordSession.kt index 9c1389ac8..1e4bde3af 100644 --- a/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordSession.kt +++ b/app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordSession.kt @@ -10,6 +10,7 @@ data class ReadRecordSession( val deviceId: String = "", val bookName: String = "", + val bookAuthor: String = "", // 一次阅读的开始/结束 val startTime: Long = 0, diff --git a/app/src/main/java/io/legado/app/data/repository/BookRepository.kt b/app/src/main/java/io/legado/app/data/repository/BookRepository.kt index e0f3d5b35..ec400b120 100644 --- a/app/src/main/java/io/legado/app/data/repository/BookRepository.kt +++ b/app/src/main/java/io/legado/app/data/repository/BookRepository.kt @@ -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 diff --git a/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt b/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt index 144c8ed5c..4bdc8858a 100644 --- a/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt +++ b/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt @@ -51,6 +51,14 @@ class ReadRecordRepository( return dao.getAllSessions(getCurrentDeviceId()) } + suspend fun getMergeCandidates(targetRecord: ReadRecord): List { + 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,83 @@ 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) } -} \ No newline at end of file + @Transaction + suspend fun mergeReadRecordInto(targetRecord: ReadRecord, sourceRecords: List) { + sourceRecords.forEach { sourceRecord -> + mergeSingleReadRecordInto(targetRecord, sourceRecord) + } + } + + 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) + } + +} diff --git a/app/src/main/java/io/legado/app/help/storage/Restore.kt b/app/src/main/java/io/legado/app/help/storage/Restore.kt index 8a454ad9a..3cc79fe4c 100644 --- a/app/src/main/java/io/legado/app/help/storage/Restore.kt +++ b/app/src/main/java/io/legado/app/help/storage/Restore.kt @@ -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 } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/model/ReadBook.kt b/app/src/main/java/io/legado/app/model/ReadBook.kt index 98806123f..3893bb4f2 100644 --- a/app/src/main/java/io/legado/app/model/ReadBook.kt +++ b/app/src/main/java/io/legado/app/model/ReadBook.kt @@ -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() } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/model/ReadManga.kt b/app/src/main/java/io/legado/app/model/ReadManga.kt index a4c29c940..ed7260b64 100644 --- a/app/src/main/java/io/legado/app/model/ReadManga.kt +++ b/app/src/main/java/io/legado/app/model/ReadManga.kt @@ -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 } diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordActivity.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordActivity.kt index d942d780f..ea41367e4 100644 --- a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordActivity.kt @@ -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) } diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt index 533101251..0a477eaf0 100644 --- a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt @@ -85,7 +85,7 @@ import java.util.Date fun ReadRecordScreen( viewModel: ReadRecordViewModel = koinViewModel(), onBackClick: () -> Unit, - onBookClick: (String) -> Unit + onBookClick: (String, String) -> Unit ) { val state by viewModel.uiState.collectAsState() val displayMode by viewModel.displayMode.collectAsState() @@ -96,6 +96,7 @@ fun ReadRecordScreen( var skipDeleteConfirm by remember { mutableStateOf(false) } var pendingDeleteAction by remember { mutableStateOf<(() -> Unit)?>(null) } + var mergeTargetRecord by remember { mutableStateOf(null) } val onConfirmDelete: (() -> Unit) -> Unit = { action -> if (skipDeleteConfirm) { action() @@ -226,7 +227,8 @@ fun ReadRecordScreen( state = state, viewModel = viewModel, onBookClick = onBookClick, - onConfirmDelete = onConfirmDelete + onConfirmDelete = onConfirmDelete, + onMergeClick = { mergeTargetRecord = it } ) } } @@ -286,6 +288,76 @@ fun ReadRecordScreen( } ) } + + mergeTargetRecord?.let { targetRecord -> + val candidates by produceState( + initialValue = emptyList(), + key1 = targetRecord.bookName, + key2 = targetRecord.bookAuthor + ) { + value = viewModel.getMergeCandidates(targetRecord) + } + var selectedAuthors by remember(targetRecord.bookName, targetRecord.bookAuthor, candidates) { + mutableStateOf(candidates.map { it.bookAuthor }.toSet()) + } + + AlertDialog( + onDismissRequest = { mergeTargetRecord = null }, + title = { Text("合并阅读记录") }, + text = { + if (candidates.isEmpty()) { + Text("没有可合并的同名记录") + } else { + 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 = { + if (candidates.isNotEmpty()) { + viewModel.mergeReadRecords( + targetRecord, + candidates.filter { selectedAuthors.contains(it.bookAuthor) } + ) + } + mergeTargetRecord = null + } + ) { + Text("合并") + } + }, + dismissButton = { + TextButton(onClick = { mergeTargetRecord = null }) { + Text("取消") + } + } + ) + } } @Composable @@ -300,7 +372,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 +393,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,8 +405,9 @@ 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 -> { @@ -342,7 +415,7 @@ fun LazyListScope.renderListByMode( 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 +429,7 @@ fun LazyListScope.renderListByMode( ReadRecordItem( detail, viewModel, - onClick = { onBookClick(detail.bookName) }) + onClick = { onBookClick(detail.bookName, detail.bookAuthor) }) } } } @@ -387,7 +460,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( @@ -398,14 +471,15 @@ fun LazyListScope.renderListByMode( } ) ) { - LatestReadItem( - record = record, - viewModel = viewModel, - onClick = { onBookClick(record.bookName) } - ) + LatestReadItem( + record = record, + viewModel = viewModel, + onClick = { onBookClick(record.bookName, record.bookAuthor) }, + onMergeClick = { onMergeClick(record) } + ) + } } } - } } } @@ -414,12 +488,13 @@ fun LatestReadItem( record: ReadRecord, viewModel: ReadRecordViewModel, onClick: () -> Unit, + onMergeClick: () -> Unit, modifier: Modifier = Modifier ) { var coverPath by remember { mutableStateOf(null) } - LaunchedEffect(record.bookName) { - coverPath = viewModel.getBookCover(record.bookName) + LaunchedEffect(record.bookName, record.bookAuthor) { + coverPath = viewModel.getBookCover(record.bookName, record.bookAuthor) } Row( @@ -439,6 +514,13 @@ fun LatestReadItem( style = MaterialTheme.typography.titleMedium, maxLines = 2 ) + Text( + text = record.bookAuthor.ifBlank { "未知作者" }, + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) Spacer(modifier = Modifier.height(4.dp)) Text( text = "总时长: ${formatDuring(record.readTime)}", @@ -451,6 +533,9 @@ fun LatestReadItem( color = MaterialTheme.colorScheme.primary ) } + TextButton(onClick = onMergeClick) { + Text("合并") + } } } @@ -458,15 +543,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(null) } var chapterTitle by remember { mutableStateOf("加载中...") } - 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 +568,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 @@ -532,6 +617,13 @@ fun TimelineSessionItem( minLines = 2, overflow = TextOverflow.Ellipsis ) + Text( + text = session.bookAuthor.ifBlank { "未知作者" }, + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) Text( text = chapterTitle.orEmpty(), style = MaterialTheme.typography.bodySmall, @@ -554,8 +646,8 @@ fun ReadRecordItem( ) { var coverPath by remember { mutableStateOf(null) } - LaunchedEffect(detail.bookName) { - coverPath = viewModel.getBookCover(detail.bookName) + LaunchedEffect(detail.bookName, detail.bookAuthor) { + coverPath = viewModel.getBookCover(detail.bookName, detail.bookAuthor) } Row( @@ -575,6 +667,11 @@ fun ReadRecordItem( style = MaterialTheme.typography.titleMedium, maxLines = 2 ) + Text( + text = detail.bookAuthor.ifBlank { "未知作者" }, + style = MaterialTheme.typography.bodySmall, + color = Color.Gray + ) Spacer(modifier = Modifier.height(4.dp)) Text( text = "阅读时长: ${formatDuring(detail.readTime)}", @@ -636,14 +733,14 @@ fun ReadingSummaryCard( title: String, bookCount: Int, totalTimeMillis: Long, - bookNamesForCover: List, + bookNamesForCover: List>, 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 +850,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" -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt index d24528709..db39a451b 100644 --- a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt @@ -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 { + return repository.getMergeCandidates(targetRecord) + } + + fun mergeReadRecords(targetRecord: ReadRecord, sourceRecords: List) { + if (sourceRecords.isEmpty()) return + viewModelScope.launch { + repository.mergeReadRecordInto(targetRecord, sourceRecords) + } } private data class LoadedData( @@ -162,4 +175,4 @@ class ReadRecordViewModel( val latestRecords: List, val sessions: List ) -} \ No newline at end of file +} From 3c40135f12ec1bd68b48b71e8bdc24029963f67b Mon Sep 17 00:00:00 2001 From: HapeLee <1321903405@qq.com> Date: Sat, 21 Feb 2026 02:03:02 +0800 Subject: [PATCH 2/2] =?UTF-8?q?[=E4=BC=98=E5=8C=96]=20=E5=B7=A6=E6=BB=91?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=E5=90=88=E5=B9=B6=E6=93=8D=E4=BD=9C=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/repository/ReadRecordRepository.kt | 1 + .../ui/book/readRecord/ReadRecordScreen.kt | 173 ++++++++++-------- .../components/swipe/SwipeActionContainer.kt | 121 ++++++++---- 3 files changed, 184 insertions(+), 111 deletions(-) diff --git a/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt b/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt index 4bdc8858a..7fb86c548 100644 --- a/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt +++ b/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt @@ -212,6 +212,7 @@ class ReadRecordRepository( } } + @Transaction private suspend fun mergeSingleReadRecordInto(targetRecord: ReadRecord, sourceRecord: ReadRecord) { if (targetRecord == sourceRecord) return if (targetRecord.deviceId != sourceRecord.deviceId) return diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt index 0a477eaf0..afb3ee9d5 100644 --- a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt @@ -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 @@ -87,6 +96,9 @@ fun ReadRecordScreen( onBackClick: () -> 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,7 +108,7 @@ fun ReadRecordScreen( var skipDeleteConfirm by remember { mutableStateOf(false) } var pendingDeleteAction by remember { mutableStateOf<(() -> Unit)?>(null) } - var mergeTargetRecord by remember { mutableStateOf(null) } + var mergeDialogData by remember { mutableStateOf>?>(null) } val onConfirmDelete: (() -> Unit) -> Unit = { action -> if (skipDeleteConfirm) { action() @@ -113,6 +125,7 @@ fun ReadRecordScreen( Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { Column { GlassMediumFlexibleTopAppBar( @@ -228,7 +241,16 @@ fun ReadRecordScreen( viewModel = viewModel, onBookClick = onBookClick, onConfirmDelete = onConfirmDelete, - onMergeClick = { mergeTargetRecord = it } + onMergeClick = { record -> + scope.launch { + val candidates = viewModel.getMergeCandidates(record) + if (candidates.isEmpty()) { + snackbarHostState.showSnackbar("没有可合并的同名记录") + } else { + mergeDialogData = record to candidates + } + } + } ) } } @@ -289,49 +311,40 @@ fun ReadRecordScreen( ) } - mergeTargetRecord?.let { targetRecord -> - val candidates by produceState( - initialValue = emptyList(), - key1 = targetRecord.bookName, - key2 = targetRecord.bookAuthor - ) { - value = viewModel.getMergeCandidates(targetRecord) - } - var selectedAuthors by remember(targetRecord.bookName, targetRecord.bookAuthor, candidates) { + mergeDialogData?.let { (targetRecord, candidates) -> + + var selectedAuthors by remember(targetRecord, candidates) { mutableStateOf(candidates.map { it.bookAuthor }.toSet()) } AlertDialog( - onDismissRequest = { mergeTargetRecord = null }, + onDismissRequest = { mergeDialogData = null }, title = { Text("合并阅读记录") }, text = { - if (candidates.isEmpty()) { - Text("没有可合并的同名记录") - } else { - 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 - ) - } + 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 + ) } } } @@ -339,20 +352,18 @@ fun ReadRecordScreen( confirmButton = { TextButton( onClick = { - if (candidates.isNotEmpty()) { - viewModel.mergeReadRecords( - targetRecord, - candidates.filter { selectedAuthors.contains(it.bookAuthor) } - ) - } - mergeTargetRecord = null + viewModel.mergeReadRecords( + targetRecord, + candidates.filter { selectedAuthors.contains(it.bookAuthor) } + ) + mergeDialogData = null } ) { Text("合并") } }, dismissButton = { - TextButton(onClick = { mergeTargetRecord = null }) { + TextButton(onClick = { mergeDialogData = null }) { Text("取消") } } @@ -409,6 +420,7 @@ fun LazyListScope.renderListByMode( onConfirmDelete: (() -> Unit) -> Unit, onMergeClick: (ReadRecord) -> Unit ) { + when (displayMode) { DisplayMode.AGGREGATE -> { state.groupedRecords.forEach { (date, details) -> @@ -469,13 +481,19 @@ 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, record.bookAuthor) }, - onMergeClick = { onMergeClick(record) } + onClick = { onBookClick(record.bookName, record.bookAuthor) } ) } } @@ -488,7 +506,6 @@ fun LatestReadItem( record: ReadRecord, viewModel: ReadRecordViewModel, onClick: () -> Unit, - onMergeClick: () -> Unit, modifier: Modifier = Modifier ) { var coverPath by remember { mutableStateOf(null) } @@ -512,29 +529,37 @@ fun LatestReadItem( Text( text = record.bookName, style = MaterialTheme.typography.titleMedium, - maxLines = 2 + maxLines = 1, + overflow = TextOverflow.Ellipsis ) Text( text = record.bookAuthor.ifBlank { "未知作者" }, style = MaterialTheme.typography.bodySmall, - color = Color.Gray, + color = MaterialTheme.colorScheme.outline, maxLines = 1, overflow = TextOverflow.Ellipsis ) - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(8.dp)) Text( - text = "总时长: ${formatDuring(record.readTime)}", - style = MaterialTheme.typography.bodySmall, - color = Color.Gray + 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 ) - Text( - text = "最后阅读: ${DateUtil.format(Date(record.lastRead), "yyyy-MM-dd HH:mm")}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary - ) - } - TextButton(onClick = onMergeClick) { - Text("合并") } } } @@ -613,21 +638,21 @@ fun TimelineSessionItem( Text( text = session.bookName, style = MaterialTheme.typography.titleMedium, - maxLines = 2, - minLines = 2, + maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( 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.bodySmall, - color = Color.Gray, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -665,18 +690,18 @@ fun ReadRecordItem( Text( text = detail.bookName, style = MaterialTheme.typography.titleMedium, - maxLines = 2 + maxLines = 1 ) Text( text = detail.bookAuthor.ifBlank { "未知作者" }, style = MaterialTheme.typography.bodySmall, - color = Color.Gray + color = MaterialTheme.colorScheme.outline, ) - Spacer(modifier = Modifier.height(4.dp)) + 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 ) } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/swipe/SwipeActionContainer.kt b/app/src/main/java/io/legado/app/ui/widget/components/swipe/SwipeActionContainer.kt index c7934267b..5d76bffa2 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/swipe/SwipeActionContainer.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/swipe/SwipeActionContainer.kt @@ -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 + ) + } } \ No newline at end of file