From 890136f469e803493eedc4c10686df89845543f0 Mon Sep 17 00:00:00 2001 From: HapeLee <63206378+HapeLee@users.noreply.github.com> Date: Tue, 19 May 2026 01:55:44 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E7=95=8C=E9=9D=A2=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../legado/app/ui/about/CrashReportScreen.kt | 62 +++++++++++-------- 1 file changed, 36 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/io/legado/app/ui/about/CrashReportScreen.kt b/app/src/main/java/io/legado/app/ui/about/CrashReportScreen.kt index f8ce37111..44930d891 100644 --- a/app/src/main/java/io/legado/app/ui/about/CrashReportScreen.kt +++ b/app/src/main/java/io/legado/app/ui/about/CrashReportScreen.kt @@ -1,28 +1,31 @@ package io.legado.app.ui.about +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text +import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import io.legado.app.R import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.widget.components.AppScaffold +import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow import io.legado.app.ui.widget.components.icon.AppIcons import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar @@ -62,44 +65,51 @@ fun CrashReportScreen( .fillMaxSize() .padding(padding) .padding(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + verticalArrangement = Arrangement.spacedBy(16.dp) ) { + + Icon( + imageVector = AppIcons.BugReport, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = LegadoTheme.colorScheme.error + ) AppText( text = stringResource(R.string.crash_report_message), - style = LegadoTheme.typography.bodyMedium + style = LegadoTheme.typography.titleMedium ) + SelectionContainer( modifier = Modifier .weight(1f) .fillMaxWidth() + .background( + color = LegadoTheme.colorScheme.surfaceContainerLow, + shape = RoundedCornerShape(12.dp) + ) + .padding(12.dp) .verticalScroll(rememberScrollState()) + .horizontalScroll(rememberScrollState()) ) { AppText( text = displayText, - style = LegadoTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace + style = LegadoTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + letterSpacing = 0.sp, + lineHeight = 16.sp + ), + softWrap = false ) } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - OutlinedButton( - onClick = onRestart, - modifier = Modifier.weight(1f) - ) { - Text(text = stringResource(R.string.restart_app)) - } - Button( - onClick = onCopy, - enabled = errorText.isNotBlank(), - modifier = Modifier.weight(1f) - ) { - Text(text = stringResource(R.string.copy_text)) - } - } + ConfirmDismissButtonsRow( + onDismiss = onRestart, + onConfirm = onCopy, + dismissText = stringResource(R.string.restart_app), + confirmText = stringResource(R.string.copy_text), + confirmEnabled = errorText.isNotBlank() + ) } } } From 4f2a7168696d93072edf620a4fa7ca927ff16ae3 Mon Sep 17 00:00:00 2001 From: HapeLee <63206378+HapeLee@users.noreply.github.com> Date: Fri, 22 May 2026 01:40:23 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E4=B8=BB=E9=A1=B5=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 3 +- .../io.legado.app.data.AppDatabase/88.json | 2299 +++++++++++++++++ .../assets/web/help/md/homepageModulesHelp.md | 240 ++ .../java/io/legado/app/constant/PreferKey.kt | 5 + .../java/io/legado/app/data/AppDatabase.kt | 13 +- .../io/legado/app/data/DatabaseMigrations.kt | 1 - .../io/legado/app/data/dao/BookSourceDao.kt | 16 +- .../app/data/dao/HomepageCustomSetDao.kt | 30 + .../legado/app/data/dao/HomepageModuleDao.kt | 48 + .../io/legado/app/data/dao/SearchBookDao.kt | 7 +- .../io/legado/app/data/entities/BookSource.kt | 5 +- .../app/data/entities/HomepageCustomSet.kt | 12 + .../app/data/entities/HomepageModule.kt | 25 + .../app/data/repository/BookRepository.kt | 88 +- .../data/repository/BookSourceRepository.kt | 55 + .../data/repository/BookshelfRepository.kt | 51 + .../app/data/repository/ExploreRepository.kt | 14 - .../repository/HomepageModulesRepository.kt | 94 + .../app/data/repository/SearchRepository.kt | 17 +- .../io/legado/app/di/appDatabaseModule.kt | 26 +- .../main/java/io/legado/app/di/appModule.kt | 27 +- .../domain/gateway/HomepageModulesGateway.kt | 33 + .../legado/app/domain/model/HomepageModels.kt | 71 + .../app/domain/usecase/AddBookUseCase.kt | 71 + .../app/domain/usecase/ExploreBooksUseCase.kt | 71 + .../domain/usecase/ExportBookshelfUseCase.kt | 79 + .../domain/usecase/ImportBookshelfUseCase.kt | 109 + .../app/domain/usecase/RefreshTocUseCase.kt | 61 + .../domain/usecase/SaveSearchBooksUseCase.kt | 11 + .../ui/book/explore/ExploreShowViewModel.kt | 62 +- .../manage/BookshelfManageScreenViewModel.kt | 51 +- .../source/edit/BookSourceEditActivity.kt | 23 +- .../app/ui/config/themeConfig/ThemeConfig.kt | 2 + .../io/legado/app/ui/main/MainDestination.kt | 8 +- .../app/ui/main/MainFragmentInterface.kt | 7 - .../java/io/legado/app/ui/main/MainScreen.kt | 12 + .../ui/main/bookshelf/BookshelfViewModel.kt | 327 +-- .../app/ui/main/homepage/HomepageConfig.kt | 26 + .../app/ui/main/homepage/HomepageContract.kt | 76 + .../app/ui/main/homepage/HomepageEffect.kt | 17 + .../ui/main/homepage/HomepageLayoutSheet.kt | 51 + .../homepage/HomepageModuleManageSheet.kt | 1089 ++++++++ .../app/ui/main/homepage/HomepageScreen.kt | 617 +++++ .../homepage/HomepageSourceSelectSheet.kt | 92 + .../app/ui/main/homepage/HomepageViewModel.kt | 882 +++++++ .../ui/main/homepage/modules/BannerModule.kt | 57 + .../homepage/modules/ButtonGroupModule.kt | 171 ++ .../ui/main/homepage/modules/CardModule.kt | 101 + .../ui/main/homepage/modules/GridModule.kt | 60 + .../homepage/modules/GridRankingModule.kt | 161 ++ .../ui/main/homepage/modules/RankingModule.kt | 146 ++ .../main/homepage/modules/WaterfallModule.kt | 126 + .../io/legado/app/ui/theme/AdaptivePadding.kt | 18 + .../java/io/legado/app/ui/theme/FadingEdge.kt | 63 +- .../io/legado/app/ui/theme/LegadoTheme.kt | 3 +- .../app/ui/theme/ThemeColorSchemeOverride.kt | 3 +- .../io/legado/app/ui/theme/ThemeComponents.kt | 6 +- .../ui/widget/components/JsonConfigEditor.kt | 138 + .../app/ui/widget/components/JsonRawEditor.kt | 88 + .../widget/components/book/SearchBookItem.kt | 69 +- .../app/ui/widget/components/icon/AppIcons.kt | 16 +- .../components/image/cover/CoilBookCover.kt | 95 +- .../modalBottomSheet/AppModalBottomSheet.kt | 4 +- .../components/settingItem/ListSettingItem.kt | 4 +- app/src/main/res/values-zh-rCN/strings.xml | 3 + app/src/main/res/values-zh-rHK/strings.xml | 3 + app/src/main/res/values-zh-rTW/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + app/version.properties | 4 +- 69 files changed, 7798 insertions(+), 471 deletions(-) create mode 100644 app/schemas/io.legado.app.data.AppDatabase/88.json create mode 100644 app/src/main/assets/web/help/md/homepageModulesHelp.md create mode 100644 app/src/main/java/io/legado/app/data/dao/HomepageCustomSetDao.kt create mode 100644 app/src/main/java/io/legado/app/data/dao/HomepageModuleDao.kt create mode 100644 app/src/main/java/io/legado/app/data/entities/HomepageCustomSet.kt create mode 100644 app/src/main/java/io/legado/app/data/entities/HomepageModule.kt create mode 100644 app/src/main/java/io/legado/app/data/repository/BookSourceRepository.kt create mode 100644 app/src/main/java/io/legado/app/data/repository/BookshelfRepository.kt create mode 100644 app/src/main/java/io/legado/app/data/repository/HomepageModulesRepository.kt create mode 100644 app/src/main/java/io/legado/app/domain/gateway/HomepageModulesGateway.kt create mode 100644 app/src/main/java/io/legado/app/domain/model/HomepageModels.kt create mode 100644 app/src/main/java/io/legado/app/domain/usecase/AddBookUseCase.kt create mode 100644 app/src/main/java/io/legado/app/domain/usecase/ExploreBooksUseCase.kt create mode 100644 app/src/main/java/io/legado/app/domain/usecase/ExportBookshelfUseCase.kt create mode 100644 app/src/main/java/io/legado/app/domain/usecase/ImportBookshelfUseCase.kt create mode 100644 app/src/main/java/io/legado/app/domain/usecase/RefreshTocUseCase.kt create mode 100644 app/src/main/java/io/legado/app/domain/usecase/SaveSearchBooksUseCase.kt delete mode 100644 app/src/main/java/io/legado/app/ui/main/MainFragmentInterface.kt create mode 100644 app/src/main/java/io/legado/app/ui/main/homepage/HomepageConfig.kt create mode 100644 app/src/main/java/io/legado/app/ui/main/homepage/HomepageContract.kt create mode 100644 app/src/main/java/io/legado/app/ui/main/homepage/HomepageEffect.kt create mode 100644 app/src/main/java/io/legado/app/ui/main/homepage/HomepageLayoutSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleManageSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt create mode 100644 app/src/main/java/io/legado/app/ui/main/homepage/HomepageSourceSelectSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt create mode 100644 app/src/main/java/io/legado/app/ui/main/homepage/modules/BannerModule.kt create mode 100644 app/src/main/java/io/legado/app/ui/main/homepage/modules/ButtonGroupModule.kt create mode 100644 app/src/main/java/io/legado/app/ui/main/homepage/modules/CardModule.kt create mode 100644 app/src/main/java/io/legado/app/ui/main/homepage/modules/GridModule.kt create mode 100644 app/src/main/java/io/legado/app/ui/main/homepage/modules/GridRankingModule.kt create mode 100644 app/src/main/java/io/legado/app/ui/main/homepage/modules/RankingModule.kt create mode 100644 app/src/main/java/io/legado/app/ui/main/homepage/modules/WaterfallModule.kt create mode 100644 app/src/main/java/io/legado/app/ui/widget/components/JsonConfigEditor.kt create mode 100644 app/src/main/java/io/legado/app/ui/widget/components/JsonRawEditor.kt diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 279d0ab39..017c01579 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -101,7 +101,8 @@ "Read(//c/Users/**)", "Bash(Get-ChildItem -Path \"D:\\\\AndroidPrj\\\\legado-with-MD3\" -Directory -Depth 0)", "Bash(Select-Object Name)", - "PowerShell(Get-ChildItem -Path \"D:\\\\AndroidPrj\\\\legado-with-MD3\\\\app\\\\src\\\\main\\\\java\\\\io\\\\legado\\\\app\" -Directory -Depth 1 | ForEach-Object { $_.FullName.Replace\\(\"D:\\\\AndroidPrj\\\\legado-with-MD3\\\\app\\\\src\\\\main\\\\java\\\\io\\\\legado\\\\app\\\\\", \"\"\\) })" + "PowerShell(Get-ChildItem -Path \"D:\\\\AndroidPrj\\\\legado-with-MD3\\\\app\\\\src\\\\main\\\\java\\\\io\\\\legado\\\\app\" -Directory -Depth 1 | ForEach-Object { $_.FullName.Replace\\(\"D:\\\\AndroidPrj\\\\legado-with-MD3\\\\app\\\\src\\\\main\\\\java\\\\io\\\\legado\\\\app\\\\\", \"\"\\) })", + "Bash(powershell *)" ] } } diff --git a/app/schemas/io.legado.app.data.AppDatabase/88.json b/app/schemas/io.legado.app.data.AppDatabase/88.json new file mode 100644 index 000000000..470299b25 --- /dev/null +++ b/app/schemas/io.legado.app.data.AppDatabase/88.json @@ -0,0 +1,2299 @@ +{ + "formatVersion": 1, + "database": { + "version": 88, + "identityHash": "7cbef4b2b125c9742578e51c4f8b5a09", + "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`)" + }, + { + "name": "index_books_durChapterTime", + "unique": false, + "columnNames": [ + "durChapterTime" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_books_durChapterTime` ON `${TABLE_NAME}` (`durChapterTime`)" + } + ] + }, + { + "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, `homepageModules` TEXT, 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" + }, + { + "fieldPath": "homepageModules", + "columnName": "homepageModules", + "affinity": "TEXT" + } + ], + "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, `preloadJs` TEXT, `startHtml` TEXT, `startStyle` TEXT, `startJs` TEXT, `showWebLog` INTEGER NOT NULL DEFAULT 0, `lastUpdateTime` INTEGER NOT NULL DEFAULT 0, `customOrder` INTEGER NOT NULL DEFAULT 0, `type` INTEGER NOT NULL DEFAULT 0, `preload` INTEGER NOT NULL DEFAULT 0, `cacheFirst` INTEGER NOT NULL DEFAULT 0, `searchUrl` TEXT, `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": "preloadJs", + "columnName": "preloadJs", + "affinity": "TEXT" + }, + { + "fieldPath": "startHtml", + "columnName": "startHtml", + "affinity": "TEXT" + }, + { + "fieldPath": "startStyle", + "columnName": "startStyle", + "affinity": "TEXT" + }, + { + "fieldPath": "startJs", + "columnName": "startJs", + "affinity": "TEXT" + }, + { + "fieldPath": "showWebLog", + "columnName": "showWebLog", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "customOrder", + "columnName": "customOrder", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "preload", + "columnName": "preload", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "cacheFirst", + "columnName": "cacheFirst", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "searchUrl", + "columnName": "searchUrl", + "affinity": "TEXT" + }, + { + "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 DEFAULT '', `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, + "defaultValue": "''" + }, + { + "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 DEFAULT '', `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, + "defaultValue": "''" + }, + { + "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" + ] + } + }, + { + "tableName": "search_content_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookName` TEXT DEFAULT '', `bookAuthor` TEXT DEFAULT '', `query` TEXT NOT NULL, `time` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookName", + "columnName": "bookName", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "bookAuthor", + "columnName": "bookAuthor", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_search_content_history_bookName_bookAuthor_query", + "unique": true, + "columnNames": [ + "bookName", + "bookAuthor", + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_content_history_bookName_bookAuthor_query` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`, `query`)" + } + ] + }, + { + "tableName": "homepage_modules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `sourceUrl` TEXT NOT NULL, `moduleKey` TEXT NOT NULL, `type` TEXT NOT NULL, `title` TEXT NOT NULL, `args` TEXT, `layoutConfig` TEXT, `url` TEXT, `isEnabled` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `customSetId` TEXT, `isUserCreated` INTEGER NOT NULL, `customTitle` TEXT, `customSetTitle` TEXT, `sourceJsonHash` TEXT, `syncedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceUrl", + "columnName": "sourceUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "moduleKey", + "columnName": "moduleKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "args", + "columnName": "args", + "affinity": "TEXT" + }, + { + "fieldPath": "layoutConfig", + "columnName": "layoutConfig", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customSetId", + "columnName": "customSetId", + "affinity": "TEXT" + }, + { + "fieldPath": "isUserCreated", + "columnName": "isUserCreated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customTitle", + "columnName": "customTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "customSetTitle", + "columnName": "customSetTitle", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceJsonHash", + "columnName": "sourceJsonHash", + "affinity": "TEXT" + }, + { + "fieldPath": "syncedAt", + "columnName": "syncedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "homepage_custom_sets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sortOrder", + "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, '7cbef4b2b125c9742578e51c4f8b5a09')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/assets/web/help/md/homepageModulesHelp.md b/app/src/main/assets/web/help/md/homepageModulesHelp.md new file mode 100644 index 000000000..0d4d4aa54 --- /dev/null +++ b/app/src/main/assets/web/help/md/homepageModulesHelp.md @@ -0,0 +1,240 @@ +# 首页模块 (homepageModules) + +在书源编辑页的「发现」Tab 中,可以配置 `首页模块 (JSON)` 字段,声明该书源为首页提供哪些内容模块。 + +## 概述 + +- 每个模块代表首页上的一个内容区块(如轮播图、排行榜、网格书架等) +- 一个书源可以声明多个模块(例如同时提供"热门推荐"Banner 和"周榜"排行榜) +- 用户在首页可以自由拖拽排序、隐藏/显示各个模块 +- 模块内容来自书源的**发现(explore)**接口,通过 `kindTitle` 匹配分类URL + +## JSON 格式 + +`homepageModules` 是一个 JSON 数组,每个元素定义一个模块: + +```json +[ + { + "key": "模块唯一标识", + "type": "模块类型", + "title": "模块标题", + "kindTitle": "匹配的分类标题(可选)", + "url": "覆盖分类URL(可选)", + "args": "特殊参数(可选)", + "layoutConfig": { + "columns": 2, + "rows": 3 + } + } +] +``` + +### 字段说明 + +| 字段 | 类型 | 必填 | 说明 | +|----------------|-----------|----|-------------------------------------------------------------------------------------------------------| +| `key` | `String` | 是 | 模块在书源内的唯一标识,用于关联用户偏好。建议使用英文,如 `"hot"`, `"rank_week"` | +| `type` | `String` | 是 | 模块类型,可选值:`"banner"`, `"ranking"`, `"gridRanking"`, `"grid"`, `"card"`, `"waterfall"`, `"buttonGroup"` | +| `title` | `String` | 是 | 模块标题,会在首页模块头部展示 | +| `kindTitle` | `String?` | 否 | 用于匹配该书源「发现」中的分类标题。不填则使用默认 `exploreUrl` | +| `url` | `String?` | 否 | 显式指定该模块的 URL,优先级高于 `kindTitle` 匹配到的 URL | +| `args` | `String?` | 否 | 模块特有参数。在 `buttonGroup` 中为包含分类标题的 JSON 数组字符串 | +| `layoutConfig` | `Object?` | 否 | 布局配置对象。支持 `columns` (列数), `rows` (行数) | + +## 模块类型 + +### banner — 横滑轮播图 + +适合展示热门推荐、本周强推等内容。以大图封面横向滑动展示。 + +```json +{ + "key": "hot_banner", + "type": "banner", + "title": "热门推荐", + "kindTitle": "热门", + "displayCount": 6 +} +``` + +### ranking — 排行榜 + +带排名序号的列表。前三名高亮为橙色,其余为灰色。默认折叠显示前 5 本,末尾有"展开更多"按钮。 + +```json +{ + "key": "week_rank", + "type": "ranking", + "title": "周排行榜", + "kindTitle": "周榜", + "displayCount": 10 +} +``` + +### gridRanking — 网格排行 + +4×4 列式排列(可通过 `layoutConfig.rows` 修改行数),每页多本书,可横向翻页。封面较小,仅显示书名和作者。适合作为首页入口展示大量书籍。 + +```json +{ + "key": "all_grid", + "type": "gridRanking", + "title": "全部分类", + "kindTitle": "全部分类", + "layoutConfig": { + "rows": 4 + } +} +``` + +### grid — 网格书架 + +网格布局,适合展示分类书单。支持自定义行列。 + +- `columns`: 默认 3 +- `rows`: 默认 2。若设置为 `0`,则显示为平铺列表并支持下拉加载更多。 + +```json +{ + "key": "scifi_grid", + "type": "grid", + "title": "科幻精选", + "kindTitle": "科幻", + "layoutConfig": { + "columns": 3, + "rows": 2 + } +} +``` + +### card — 推荐卡片 + +大图横向滑动卡片,展示封面 + 书名 + 简介。适合需要更多信息展示的场景。 + +```json +{ + "key": "editor_pick", + "type": "card", + "title": "编辑推荐", + "kindTitle": "编辑推荐" +} +``` + +### waterfall — 错位瀑布流 + +瀑布流布局,展示封面 + 书名 + 简介。支持 `layoutConfig.columns` 自定义列数(默认 2)。支持无限加载更多。 + +```json +{ + "key": "hot_wf", + "type": "waterfall", + "title": "大家都在看", + "kindTitle": "热门", + "layoutConfig": { + "columns": 2 + } +} +``` + +### buttonGroup — 按钮组 + +显示为网格排列的分类按钮。适合放置常用的分类或动作入口。 + +- **布局特性**:自动平衡每行按钮数量(例如 6 个按钮显示为 3+3,8 个显示为 4+4)。每行最多 5 个。 +- **args**: 可选。由分类标题组成的 JSON 数组字符串,如 `["排行", "分类", "完本"]`。若不填则默认显示该书源前 + 5 个分类。 +- **layoutConfig**: + - `icon`: 全局默认图标。支持网络图片 URL。 + - `icons`: 图标映射对象。以分类标题为键,网络图片 URL 为值。 + +```json +{ + "key": "entry_buttons", + "type": "buttonGroup", + "title": "快捷入口", + "args": "[\"排行\", \"分类\", \"我的\"]", + "layoutConfig": { + "icon": "https://example.com/default.png", + "icons": { + "排行": "https://example.com/rank.png", + "我的": "https://example.com/my.png" + } + } +} +``` + +## kindTitle 匹配规则 + +模块加载时,系统会根据 `kindTitle` 在书源的「发现」分类列表中进行匹配: + +1. 在 `exploreKinds()`(即发现页的分类列表)中查找 `title` 等于 `kindTitle` 的分类 +2. 如果匹配成功,使用该分类的 `url` 来加载数据 +3. 如果匹配失败,或 `kindTitle` 为空,则使用书源的默认 `exploreUrl` +4. 如果两个 URL 都没有,该模块将显示加载错误 + +**建议**:确保 `kindTitle` 的值与发现页分类的标题**完全一致**(包括大小写和标点)。 + +## 完整示例 + +以下是一个书源配置了多种模块的完整 JSON: + +```json +[ + { + "key": "hot", + "type": "banner", + "title": "热门推荐", + "kindTitle": "热门", + "displayCount": 6 + }, + { + "key": "rank_week", + "type": "ranking", + "title": "周榜", + "kindTitle": "周排行", + "displayCount": 10 + }, + { + "key": "rank_month", + "type": "ranking", + "title": "月榜", + "kindTitle": "月排行", + "displayCount": 10 + }, + { + "key": "scifi", + "type": "grid", + "title": "科幻精选", + "kindTitle": "科幻", + "displayCount": 6 + }, + { + "key": "new_book", + "type": "card", + "title": "新书上架", + "kindTitle": "新书", + "displayCount": 8 + } +] +``` + +每个 `key` 必须唯一,系统会按 JSON 数组中的声明顺序创建模块标识。 + +## 用户体验 + +- **发现模块**:首页会自动出现配置了 `homepageModules` 的已启用书源所声明的模块 +- **拖拽排序**:长按模块标题旁的拖拽手柄可调整顺序 +- **隐藏模块**:编辑模式下可隐藏不需要的模块 +- **下拉刷新**:刷新后所有模块重新加载数据 +- **点击书籍**:跳转到书籍详情页 +- **点击模块标题**:跳转到该书源的完整发现页 + +## 注意事项 + +- JSON 格式必须**严格有效**,建议使用在线 JSON 校验工具检查 +- 如果 JSON 解析失败,该书源的所有模块将被静默跳过 +- 模块使用的图片从书籍封面(`coverUrl`)获取,请确保发现规则正确提取了封面 +- `homepageModules` 为空的旧书源不会报错,也不会有首页模块展示 +- 同一书源可配置多个同类型模块(如多个排行榜),只需 `key` 不同即可 +- 模块标识全局唯一 ID 格式为 `"{setId}::{书源URL}::{key}"`,其中 `setId` 用于区分模块所属的集(书源集或自定义集)。 diff --git a/app/src/main/java/io/legado/app/constant/PreferKey.kt b/app/src/main/java/io/legado/app/constant/PreferKey.kt index 050a0fb05..864c82ac9 100644 --- a/app/src/main/java/io/legado/app/constant/PreferKey.kt +++ b/app/src/main/java/io/legado/app/constant/PreferKey.kt @@ -319,4 +319,9 @@ object PreferKey { const val navIconExplore = "navIconExplore" const val navIconRss = "navIconRss" const val navIconMy = "navIconMy" + + const val homepageModuleOrder = "homepageModuleOrder" + const val homepageModuleHidden = "homepageModuleHidden" + const val homepageLayoutMode = "homepageLayoutMode" + const val navIconHome = "navIconHome" } 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 4df0965e6..632cf4d76 100644 --- a/app/src/main/java/io/legado/app/data/AppDatabase.kt +++ b/app/src/main/java/io/legado/app/data/AppDatabase.kt @@ -15,6 +15,8 @@ import io.legado.app.data.dao.BookmarkDao import io.legado.app.data.dao.CacheDao import io.legado.app.data.dao.CookieDao import io.legado.app.data.dao.DictRuleDao +import io.legado.app.data.dao.HomepageCustomSetDao +import io.legado.app.data.dao.HomepageModuleDao import io.legado.app.data.dao.HttpTTSDao import io.legado.app.data.dao.KeyboardAssistsDao import io.legado.app.data.dao.ReadRecordDao @@ -38,6 +40,8 @@ import io.legado.app.data.entities.Bookmark import io.legado.app.data.entities.Cache import io.legado.app.data.entities.Cookie import io.legado.app.data.entities.DictRule +import io.legado.app.data.entities.HomepageCustomSet +import io.legado.app.data.entities.HomepageModule import io.legado.app.data.entities.HttpTTS import io.legado.app.data.entities.KeyboardAssist import io.legado.app.data.entities.ReplaceRule @@ -69,7 +73,7 @@ val appDb by lazy { } @Database( - version = 87, + version = 88, exportSchema = true, entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class, ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class, @@ -77,7 +81,7 @@ val appDb by lazy { RssReadRecord::class, ReadRecordDetail::class, ReadRecordSession::class, RssStar::class, TxtTocRule::class, ReadRecord::class, HttpTTS::class, Cache::class, RuleSub::class, DictRule::class, KeyboardAssist::class, Server::class, - SearchContentHistory::class], + SearchContentHistory::class, HomepageModule::class, HomepageCustomSet::class], views = [BookSourcePart::class], autoMigrations = [ AutoMigration(from = 43, to = 44), @@ -123,7 +127,8 @@ val appDb by lazy { AutoMigration(from = 83, to = 84), AutoMigration(from = 84, to = 85), AutoMigration(from = 85, to = 86), - AutoMigration(from = 86, to = 87) + AutoMigration(from = 86, to = 87), + AutoMigration(from = 87, to = 88) ] ) abstract class AppDatabase : RoomDatabase() { @@ -150,6 +155,8 @@ abstract class AppDatabase : RoomDatabase() { abstract val keyboardAssistsDao: KeyboardAssistsDao abstract val serverDao: ServerDao abstract val searchContentHistoryDao: SearchContentHistoryDao + abstract val homepageModuleDao: HomepageModuleDao + abstract val homepageCustomSetDao: HomepageCustomSetDao companion object { 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 f0745618e..d74bbdb09 100644 --- a/app/src/main/java/io/legado/app/data/DatabaseMigrations.kt +++ b/app/src/main/java/io/legado/app/data/DatabaseMigrations.kt @@ -497,5 +497,4 @@ object DatabaseMigrations { columnName = "enabledReview" ) class Migration_64_65 : AutoMigrationSpec - } diff --git a/app/src/main/java/io/legado/app/data/dao/BookSourceDao.kt b/app/src/main/java/io/legado/app/data/dao/BookSourceDao.kt index 2fa505f47..28f8c9321 100644 --- a/app/src/main/java/io/legado/app/data/dao/BookSourceDao.kt +++ b/app/src/main/java/io/legado/app/data/dao/BookSourceDao.kt @@ -83,7 +83,21 @@ interface BookSourceDao { fun flowDisabled(): Flow> @Query( - """select * from book_sources_part + """select * from book_sources + where enabled = 1 and enabledExplore = 1 and homepageModules is not null + order by customOrder asc""" + ) + fun flowHomepageModules(): Flow> + + @Query( + """select * from book_sources + where enabled = 1 and enabledExplore = 1 + order by customOrder asc""" + ) + fun flowExploreSources(): Flow> + + @Query( + """select * from book_sources_part where enabledExplore = 1 and hasExploreUrl = 1 order by customOrder asc""" ) fun flowExplore(): Flow> diff --git a/app/src/main/java/io/legado/app/data/dao/HomepageCustomSetDao.kt b/app/src/main/java/io/legado/app/data/dao/HomepageCustomSetDao.kt new file mode 100644 index 000000000..c127084b5 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/dao/HomepageCustomSetDao.kt @@ -0,0 +1,30 @@ +package io.legado.app.data.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import io.legado.app.data.entities.HomepageCustomSet +import kotlinx.coroutines.flow.Flow + +@Dao +interface HomepageCustomSetDao { + + @Query("SELECT * FROM homepage_custom_sets ORDER BY sortOrder ASC") + fun flowAll(): Flow> + + @Query("SELECT * FROM homepage_custom_sets WHERE id = :id") + suspend fun getById(id: String): HomepageCustomSet? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(customSet: HomepageCustomSet) + + @Query("UPDATE homepage_custom_sets SET name = :name WHERE id = :id") + suspend fun rename(id: String, name: String) + + @Query("UPDATE homepage_custom_sets SET sortOrder = :order WHERE id = :id") + suspend fun setSortOrder(id: String, order: Int) + + @Query("DELETE FROM homepage_custom_sets WHERE id = :id") + suspend fun delete(id: String) +} diff --git a/app/src/main/java/io/legado/app/data/dao/HomepageModuleDao.kt b/app/src/main/java/io/legado/app/data/dao/HomepageModuleDao.kt new file mode 100644 index 000000000..a35af7897 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/dao/HomepageModuleDao.kt @@ -0,0 +1,48 @@ +package io.legado.app.data.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import io.legado.app.data.entities.HomepageModule +import kotlinx.coroutines.flow.Flow + +@Dao +interface HomepageModuleDao { + + @Query("SELECT * FROM homepage_modules WHERE isEnabled = 1 ORDER BY sortOrder ASC") + fun flowEnabled(): Flow> + + @Query("SELECT * FROM homepage_modules WHERE sourceUrl = :sourceUrl ORDER BY sortOrder ASC") + fun flowBySource(sourceUrl: String): Flow> + + @Query("SELECT * FROM homepage_modules WHERE id = :id") + suspend fun getById(id: String): HomepageModule? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertAll(modules: List) + + @Query("UPDATE homepage_modules SET isEnabled = :enabled WHERE id = :id") + suspend fun setEnabled(id: String, enabled: Boolean) + + @Query("UPDATE homepage_modules SET sortOrder = :order WHERE id = :id") + suspend fun setSortOrder(id: String, order: Int) + + @Query("UPDATE homepage_modules SET customSetTitle = :title WHERE id = :id") + suspend fun setCustomSetTitle(id: String, title: String?) + + @Query("UPDATE homepage_modules SET customSetId = :setId WHERE id = :id") + suspend fun setCustomSetId(id: String, setId: String?) + + @Query("DELETE FROM homepage_modules WHERE id = :id") + suspend fun delete(id: String) + + @Query("DELETE FROM homepage_modules WHERE customSetId = :setId") + suspend fun deleteByCustomSetId(setId: String) + + @Query("DELETE FROM homepage_modules WHERE sourceUrl = :sourceUrl AND isUserCreated = 0 AND id NOT IN (:currentIds)") + suspend fun deleteStale(sourceUrl: String, currentIds: List) + + @Query("SELECT * FROM homepage_modules ORDER BY sortOrder ASC") + fun flowAll(): Flow> +} diff --git a/app/src/main/java/io/legado/app/data/dao/SearchBookDao.kt b/app/src/main/java/io/legado/app/data/dao/SearchBookDao.kt index 55e820c4a..6bd218939 100644 --- a/app/src/main/java/io/legado/app/data/dao/SearchBookDao.kt +++ b/app/src/main/java/io/legado/app/data/dao/SearchBookDao.kt @@ -1,6 +1,11 @@ package io.legado.app.data.dao -import androidx.room.* +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update import io.legado.app.data.entities.SearchBook @Dao diff --git a/app/src/main/java/io/legado/app/data/entities/BookSource.kt b/app/src/main/java/io/legado/app/data/entities/BookSource.kt index 88e5b561a..6901412f4 100644 --- a/app/src/main/java/io/legado/app/data/entities/BookSource.kt +++ b/app/src/main/java/io/legado/app/data/entities/BookSource.kt @@ -98,7 +98,9 @@ data class BookSource( @ColumnInfo(defaultValue = "0") var eventListener: Boolean = false, // 是否监听事件来执行回调规则 @ColumnInfo(defaultValue = "0") - var customButton: Boolean = false //由书源控制的自定义按钮 + var customButton: Boolean = false, //由书源控制的自定义按钮 + // 首页模块定义,JSON数组。每个元素: key, type(banner/ranking/grid/card/filter), title, args?, url? + var homepageModules: String? = null ) : Parcelable, BaseSource { override fun getTag(): String { @@ -254,6 +256,7 @@ data class BookSource( && equal(loginUi, source.loginUi) && equal(loginCheckJs, source.loginCheckJs) && equal(coverDecodeJs, source.coverDecodeJs) + && equal(homepageModules, source.homepageModules) && equal(exploreUrl, source.exploreUrl) && equal(searchUrl, source.searchUrl) && getSearchRule() == source.getSearchRule() diff --git a/app/src/main/java/io/legado/app/data/entities/HomepageCustomSet.kt b/app/src/main/java/io/legado/app/data/entities/HomepageCustomSet.kt new file mode 100644 index 000000000..1bfb139a0 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/HomepageCustomSet.kt @@ -0,0 +1,12 @@ +package io.legado.app.data.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "homepage_custom_sets") +data class HomepageCustomSet( + @PrimaryKey + var id: String = "", + var name: String = "", + var sortOrder: Int = 0, +) diff --git a/app/src/main/java/io/legado/app/data/entities/HomepageModule.kt b/app/src/main/java/io/legado/app/data/entities/HomepageModule.kt new file mode 100644 index 000000000..6d676a46f --- /dev/null +++ b/app/src/main/java/io/legado/app/data/entities/HomepageModule.kt @@ -0,0 +1,25 @@ +package io.legado.app.data.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "homepage_modules") +data class HomepageModule( + @PrimaryKey + var id: String = "", + var sourceUrl: String = "", + var moduleKey: String = "", + var type: String = "", + var title: String = "", + var args: String? = null, + var layoutConfig: String? = null, + var url: String? = null, + var isEnabled: Boolean = true, + var sortOrder: Int = 0, + var customSetId: String? = null, + var isUserCreated: Boolean = false, + var customTitle: String? = null, + var customSetTitle: String? = null, + var sourceJsonHash: String? = null, + var syncedAt: 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 ff8bf67bd..358e5005c 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 @@ -1,9 +1,11 @@ package io.legado.app.data.repository -import io.legado.app.data.appDb import io.legado.app.data.dao.BookChapterDao import io.legado.app.data.dao.BookDao +import io.legado.app.data.dao.GroupBookCount import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.ui.main.bookshelf.BookShelfItem import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext @@ -33,7 +35,89 @@ class BookRepository( } suspend fun getBook(bookUrl: String): Book? { - return appDb.bookDao.getBook(bookUrl) + return withContext(Dispatchers.IO) { + bookDao.getBook(bookUrl) + } + } + + suspend fun getBook(name: String, author: String): Book? { + return withContext(Dispatchers.IO) { + bookDao.getBook(name, author) + } + } + + fun flowBookShelfByGroup(groupId: Long): Flow> { + return bookDao.flowBookShelfByGroup(groupId) + } + + fun flowSystemGroupCounts(): Flow> { + return bookDao.flowSystemGroupCounts() + } + + fun flowAllBookShelfCount(): Flow { + return bookDao.flowAllBookShelfCount() + } + + fun flowUserGroupBookCount(groupId: Long): Flow { + return bookDao.flowUserGroupBookCount(groupId) + } + + fun flowGroupPreview(groupId: Long): Flow> { + return bookDao.flowGroupPreview(groupId) + } + + suspend fun getChapterCount(bookUrl: String): Int { + return withContext(Dispatchers.IO) { + bookChapterDao.getChapterCount(bookUrl) + } + } + + suspend fun getVolumeCount(bookUrl: String): Int { + return withContext(Dispatchers.IO) { + bookChapterDao.getVolumeCount(bookUrl) + } + } + + suspend fun update(vararg book: Book) { + withContext(Dispatchers.IO) { + bookDao.update(*book) + } + } + + suspend fun getMinOrder(): Int { + return withContext(Dispatchers.IO) { + bookDao.minOrder + } + } + + suspend fun insert(book: Book) { + withContext(Dispatchers.IO) { + bookDao.insert(book) + } + } + + suspend fun insertChapters(vararg chapters: BookChapter) { + withContext(Dispatchers.IO) { + bookChapterDao.insert(*chapters) + } + } + + suspend fun getHasUpdateBooks(): List { + return withContext(Dispatchers.IO) { + bookDao.hasUpdateBooks + } + } + + suspend fun replace(oldBook: Book, newBook: Book) { + withContext(Dispatchers.IO) { + bookDao.replace(oldBook, newBook) + } + } + + suspend fun deleteChaptersByBook(bookUrl: String) { + withContext(Dispatchers.IO) { + bookChapterDao.delByBook(bookUrl) + } } } diff --git a/app/src/main/java/io/legado/app/data/repository/BookSourceRepository.kt b/app/src/main/java/io/legado/app/data/repository/BookSourceRepository.kt new file mode 100644 index 000000000..c3fa08406 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/repository/BookSourceRepository.kt @@ -0,0 +1,55 @@ +package io.legado.app.data.repository + +import io.legado.app.data.dao.BookSourceDao +import io.legado.app.data.entities.BookSource +import io.legado.app.data.entities.BookSourcePart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext + +class BookSourceRepository(private val bookSourceDao: BookSourceDao) { + + fun flowAll(): Flow> { + return bookSourceDao.flowAll() + } + + fun flowEnabled(): Flow> { + return bookSourceDao.flowEnabled() + } + + fun flowHomepageModules(): Flow> { + return bookSourceDao.flowHomepageModules() + } + + fun flowExploreSources(): Flow> { + return bookSourceDao.flowExploreSources() + } + + suspend fun getBookSource(sourceUrl: String): BookSource? { + return withContext(Dispatchers.IO) { + bookSourceDao.getBookSource(sourceUrl) + } + } + + fun getBookSourceSync(sourceUrl: String): BookSource? { + return bookSourceDao.getBookSource(sourceUrl) + } + + suspend fun getBookSourceAddBook(baseUrl: String): BookSource? { + return withContext(Dispatchers.IO) { + bookSourceDao.getBookSourceAddBook(baseUrl) + } + } + + suspend fun getHasBookUrlPattern(): List { + return withContext(Dispatchers.IO) { + bookSourceDao.hasBookUrlPattern + } + } + + suspend fun getAllEnabledPart(): List { + return withContext(Dispatchers.IO) { + bookSourceDao.allEnabledPart + } + } +} diff --git a/app/src/main/java/io/legado/app/data/repository/BookshelfRepository.kt b/app/src/main/java/io/legado/app/data/repository/BookshelfRepository.kt new file mode 100644 index 000000000..8435a1b4e --- /dev/null +++ b/app/src/main/java/io/legado/app/data/repository/BookshelfRepository.kt @@ -0,0 +1,51 @@ +package io.legado.app.data.repository + +import io.legado.app.data.entities.BookGroup +import io.legado.app.ui.main.bookshelf.BookShelfItem +import io.legado.app.utils.cnCompare +import kotlin.math.max + +class BookshelfRepository { + fun sortBooks( + list: List, + group: BookGroup?, + sort: Int, + sortOrder: Int + ): List { + val bookSort = if (group != null && group.bookSort >= 0) { + group.bookSort + } else { + sort + } + val isDescending = sortOrder == 1 + + return when (bookSort) { + 1 -> if (isDescending) list.sortedByDescending { it.latestChapterTime } + else list.sortedBy { it.latestChapterTime } + + 2 -> if (isDescending) + list.sortedWith { o1, o2 -> o2.name.cnCompare(o1.name) } + else + list.sortedWith { o1, o2 -> o1.name.cnCompare(o2.name) } + + 3 -> if (isDescending) list.sortedByDescending { it.order } + else list.sortedBy { it.order } + + 4 -> if (isDescending) list.sortedByDescending { + max( + it.latestChapterTime, + it.durChapterTime + ) + } + else list.sortedBy { max(it.latestChapterTime, it.durChapterTime) } + + 5 -> if (isDescending) + list.sortedWith { o1, o2 -> o2.author.cnCompare(o1.author) } + else + list.sortedWith { o1, o2 -> o1.author.cnCompare(o2.author) } + + else -> if (isDescending) list.sortedByDescending { it.durChapterTime } + else list.sortedBy { it.durChapterTime } + } + } +} diff --git a/app/src/main/java/io/legado/app/data/repository/ExploreRepository.kt b/app/src/main/java/io/legado/app/data/repository/ExploreRepository.kt index 3e0b21888..595ee6a66 100644 --- a/app/src/main/java/io/legado/app/data/repository/ExploreRepository.kt +++ b/app/src/main/java/io/legado/app/data/repository/ExploreRepository.kt @@ -7,7 +7,6 @@ import io.legado.app.data.entities.SearchBook import io.legado.app.data.entities.rule.ExploreKind import io.legado.app.help.source.SourceHelp import io.legado.app.help.source.exploreKinds -import io.legado.app.model.webBook.WebBook import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -18,7 +17,6 @@ interface ExploreRepository { fun getExploreGroups(): Flow> fun getExploreSources(query: String, selectedGroup: String): Flow> suspend fun getBookSource(url: String): BookSource? - suspend fun exploreBook(source: BookSource, url: String, page: Int): Result> suspend fun saveSearchBooks(books: List) suspend fun getSourceExploreKinds(sourceUrl: String): List suspend fun topSource(bookSource: BookSourcePart) @@ -77,18 +75,6 @@ class ExploreRepositoryImpl( return appDb.bookSourceDao.getBookSource(url) } - override suspend fun exploreBook(source: BookSource, url: String, page: Int): Result> { - return withContext(IO) { - try { - val books = WebBook.exploreBookSuspend(source, url, page) - Result.success(books) - } catch (e: Exception) { - e.printStackTrace() - Result.failure(e) - } - } - } - override suspend fun getSourceExploreKinds(sourceUrl: String): List = withContext(IO) { val source = appDb.bookSourceDao.getBookSource(sourceUrl) return@withContext source?.exploreKinds() ?: emptyList() diff --git a/app/src/main/java/io/legado/app/data/repository/HomepageModulesRepository.kt b/app/src/main/java/io/legado/app/data/repository/HomepageModulesRepository.kt new file mode 100644 index 000000000..4f7c0e905 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/repository/HomepageModulesRepository.kt @@ -0,0 +1,94 @@ +package io.legado.app.data.repository + +import io.legado.app.data.dao.HomepageCustomSetDao +import io.legado.app.data.dao.HomepageModuleDao +import io.legado.app.data.entities.HomepageCustomSet +import io.legado.app.data.entities.HomepageModule +import io.legado.app.domain.gateway.HomepageModulesGateway +import io.legado.app.domain.model.CustomSetItem +import io.legado.app.domain.model.ModuleItem +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class HomepageModulesRepository( + private val moduleDao: HomepageModuleDao, + private val customSetDao: HomepageCustomSetDao, +) : HomepageModulesGateway { + + override fun flowEnabled(): Flow> = + moduleDao.flowEnabled().map { list -> list.map { it.toModuleItem() } } + + override fun flowAll(): Flow> = + moduleDao.flowAll().map { list -> list.map { it.toModuleItem() } } + + override fun flowBySource(sourceUrl: String): Flow> = + moduleDao.flowBySource(sourceUrl).map { list -> list.map { it.toModuleItem() } } + + override suspend fun getById(id: String): ModuleItem? = + moduleDao.getById(id)?.toModuleItem() + + override suspend fun upsertAll(modules: List) = + moduleDao.upsertAll(modules.map { it.toModuleEntity() }) + + override suspend fun setEnabled(id: String, enabled: Boolean) = + moduleDao.setEnabled(id, enabled) + + override suspend fun setSortOrder(id: String, order: Int) = moduleDao.setSortOrder(id, order) + override suspend fun setCustomSetId(id: String, setId: String?) = + moduleDao.setCustomSetId(id, setId) + + override suspend fun setCustomSetTitle(id: String, title: String?) = + moduleDao.setCustomSetTitle(id, title) + + override suspend fun delete(id: String) = moduleDao.delete(id) + override suspend fun deleteStale(sourceUrl: String, currentIds: List) = + moduleDao.deleteStale(sourceUrl, currentIds) + + override fun flowCustomSets(): Flow> = + customSetDao.flowAll().map { list -> list.map { it.toCustomSetItem() } } + + override suspend fun getCustomSetById(id: String): CustomSetItem? = + customSetDao.getById(id)?.toCustomSetItem() + + override suspend fun upsertCustomSet(set: CustomSetItem) = + customSetDao.upsert(set.toCustomSetEntity()) + + override suspend fun setCustomSetSortOrder(id: String, order: Int) = + customSetDao.setSortOrder(id, order) + + override suspend fun createCustomSet(name: String): CustomSetItem { + val entity = HomepageCustomSet( + id = "cs_${System.currentTimeMillis()}", name = name + ) + customSetDao.upsert(entity) + return entity.toCustomSetItem() + } + + override suspend fun renameCustomSet(id: String, name: String) = customSetDao.rename(id, name) + override suspend fun deleteCustomSet(id: String) { + moduleDao.deleteByCustomSetId(id) + customSetDao.delete(id) + } + + private fun HomepageModule.toModuleItem() = ModuleItem( + id = id, sourceUrl = sourceUrl, moduleKey = moduleKey, type = type, + title = title, customTitle = customTitle, customSetTitle = customSetTitle, + args = args, layoutConfig = layoutConfig, url = url, isEnabled = isEnabled, + customSetId = customSetId, isUserCreated = isUserCreated, + sortOrder = sortOrder, sourceJsonHash = sourceJsonHash, syncedAt = syncedAt, + ) + + private fun ModuleItem.toModuleEntity() = HomepageModule( + id = id, sourceUrl = sourceUrl, moduleKey = moduleKey, type = type, + title = title, customTitle = customTitle, customSetTitle = customSetTitle, + args = args, layoutConfig = layoutConfig, url = url, isEnabled = isEnabled, + customSetId = customSetId, isUserCreated = isUserCreated, + sortOrder = sortOrder, sourceJsonHash = sourceJsonHash, syncedAt = syncedAt, + ) + + private fun HomepageCustomSet.toCustomSetItem() = + CustomSetItem(id = id, name = name, sortOrder = sortOrder) + + private fun CustomSetItem.toCustomSetEntity() = + HomepageCustomSet(id = id, name = name, sortOrder = sortOrder) +} diff --git a/app/src/main/java/io/legado/app/data/repository/SearchRepository.kt b/app/src/main/java/io/legado/app/data/repository/SearchRepository.kt index c9f05c375..7919f9579 100644 --- a/app/src/main/java/io/legado/app/data/repository/SearchRepository.kt +++ b/app/src/main/java/io/legado/app/data/repository/SearchRepository.kt @@ -7,7 +7,6 @@ import io.legado.app.data.entities.SearchKeyword import io.legado.app.domain.gateway.BookSearchGateway import io.legado.app.domain.model.BookSearchScope import io.legado.app.domain.usecase.BookShelfKey -import io.legado.app.help.book.isNotShelf import io.legado.app.ui.main.bookshelf.BookShelfItem import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -26,6 +25,8 @@ interface SearchRepository { suspend fun saveSearchKeyword(keyword: String) suspend fun deleteSearchKeyword(item: SearchKeyword) suspend fun clearSearchKeywords() + suspend fun saveSearchBooks(books: List) + suspend fun saveSearchBook(book: SearchBook) } class SearchRepositoryImpl( @@ -59,7 +60,7 @@ class SearchRepositoryImpl( } } - override suspend fun saveSearchKeyword(keyword: String) = withContext(Dispatchers.IO) { + override suspend fun saveSearchKeyword(keyword: String): Unit = withContext(Dispatchers.IO) { val key = keyword.trim() if (key.isBlank()) return@withContext @@ -70,11 +71,12 @@ class SearchRepositoryImpl( } ?: appDb.searchKeywordDao.insert(SearchKeyword(word = key, usage = 1)) } - override suspend fun deleteSearchKeyword(item: SearchKeyword) = withContext(Dispatchers.IO) { + override suspend fun deleteSearchKeyword(item: SearchKeyword): Unit = + withContext(Dispatchers.IO) { appDb.searchKeywordDao.delete(item) } - override suspend fun clearSearchKeywords() = withContext(Dispatchers.IO) { + override suspend fun clearSearchKeywords(): Unit = withContext(Dispatchers.IO) { appDb.searchKeywordDao.deleteAll() } @@ -103,9 +105,14 @@ class SearchRepositoryImpl( appDb.bookSourceDao.getBookSource(sourceUrl) } - override suspend fun saveSearchBooks(books: List) = withContext(Dispatchers.IO) { + override suspend fun saveSearchBooks(books: List): Unit = + withContext(Dispatchers.IO) { if (books.isNotEmpty()) { appDb.searchBookDao.insert(books) } } + + override suspend fun saveSearchBook(book: SearchBook): Unit = withContext(Dispatchers.IO) { + appDb.searchBookDao.insert(book) + } } diff --git a/app/src/main/java/io/legado/app/di/appDatabaseModule.kt b/app/src/main/java/io/legado/app/di/appDatabaseModule.kt index be1038b47..f5dffb8c2 100644 --- a/app/src/main/java/io/legado/app/di/appDatabaseModule.kt +++ b/app/src/main/java/io/legado/app/di/appDatabaseModule.kt @@ -2,7 +2,29 @@ package io.legado.app.di import io.legado.app.data.AppDatabase import io.legado.app.data.appDb -import io.legado.app.data.dao.* +import io.legado.app.data.dao.BookChapterDao +import io.legado.app.data.dao.BookDao +import io.legado.app.data.dao.BookGroupDao +import io.legado.app.data.dao.BookSourceDao +import io.legado.app.data.dao.BookmarkDao +import io.legado.app.data.dao.CacheDao +import io.legado.app.data.dao.CookieDao +import io.legado.app.data.dao.DictRuleDao +import io.legado.app.data.dao.HomepageCustomSetDao +import io.legado.app.data.dao.HomepageModuleDao +import io.legado.app.data.dao.HttpTTSDao +import io.legado.app.data.dao.KeyboardAssistsDao +import io.legado.app.data.dao.ReadRecordDao +import io.legado.app.data.dao.ReplaceRuleDao +import io.legado.app.data.dao.RssArticleDao +import io.legado.app.data.dao.RssReadRecordDao +import io.legado.app.data.dao.RssSourceDao +import io.legado.app.data.dao.RssStarDao +import io.legado.app.data.dao.RuleSubDao +import io.legado.app.data.dao.SearchBookDao +import io.legado.app.data.dao.SearchKeywordDao +import io.legado.app.data.dao.ServerDao +import io.legado.app.data.dao.TxtTocRuleDao import org.koin.dsl.module /** @@ -36,4 +58,6 @@ val appDatabaseModule = module { factory { get().dictRuleDao } factory { get().keyboardAssistsDao } factory { get().serverDao } + factory { get().homepageModuleDao } + factory { get().homepageCustomSetDao } } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/di/appModule.kt b/app/src/main/java/io/legado/app/di/appModule.kt index be8e0339c..8bb21516a 100644 --- a/app/src/main/java/io/legado/app/di/appModule.kt +++ b/app/src/main/java/io/legado/app/di/appModule.kt @@ -12,12 +12,15 @@ import io.legado.app.data.repository.BookDomainRepositoryImpl import io.legado.app.data.repository.BookGroupRepository import io.legado.app.data.repository.BookRepository import io.legado.app.data.repository.BookSourceCallbackRepository +import io.legado.app.data.repository.BookSourceRepository +import io.legado.app.data.repository.BookshelfRepository import io.legado.app.data.repository.CacheBookDownloadRepository import io.legado.app.data.repository.DatabaseMaintenanceRepository import io.legado.app.data.repository.DictRuleRepository import io.legado.app.data.repository.DirectLinkUploadRepository import io.legado.app.data.repository.ExploreRepository import io.legado.app.data.repository.ExploreRepositoryImpl +import io.legado.app.data.repository.HomepageModulesRepository import io.legado.app.data.repository.LocalBookRepository import io.legado.app.data.repository.ReadRecordRepository import io.legado.app.data.repository.RemoteBookRepository @@ -35,20 +38,27 @@ import io.legado.app.domain.gateway.BookCacheDownloadGateway import io.legado.app.domain.gateway.BookSearchGateway import io.legado.app.domain.gateway.BookSourceCallbackGateway import io.legado.app.domain.gateway.DatabaseMaintenanceGateway +import io.legado.app.domain.gateway.HomepageModulesGateway import io.legado.app.domain.gateway.LocalBookGateway import io.legado.app.domain.gateway.ReadingProgressGateway import io.legado.app.domain.gateway.WebDavBackupGateway import io.legado.app.domain.repository.BookDomainRepository +import io.legado.app.domain.usecase.AddBookUseCase import io.legado.app.domain.usecase.AppStartupMaintenanceUseCase import io.legado.app.domain.usecase.BatchCacheDownloadUseCase import io.legado.app.domain.usecase.CacheBookChaptersUseCase import io.legado.app.domain.usecase.ChangeBookSourceUseCase import io.legado.app.domain.usecase.ClearBookCacheUseCase import io.legado.app.domain.usecase.DeleteBooksUseCase +import io.legado.app.domain.usecase.ExploreBooksUseCase import io.legado.app.domain.usecase.ExploreKindUiUseCase +import io.legado.app.domain.usecase.ExportBookshelfUseCase import io.legado.app.domain.usecase.GetReadingProgressUseCase +import io.legado.app.domain.usecase.ImportBookshelfUseCase +import io.legado.app.domain.usecase.RefreshTocUseCase import io.legado.app.domain.usecase.RemoveBookGroupAssignmentUseCase import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase +import io.legado.app.domain.usecase.SaveSearchBooksUseCase import io.legado.app.domain.usecase.SearchBooksUseCase import io.legado.app.domain.usecase.ShrinkDatabaseUseCase import io.legado.app.domain.usecase.UpdateBooksGroupUseCase @@ -93,6 +103,7 @@ import io.legado.app.ui.dict.rule.DictRuleViewModel import io.legado.app.ui.main.MainViewModel import io.legado.app.ui.main.bookshelf.BookshelfViewModel import io.legado.app.ui.main.explore.ExploreViewModel +import io.legado.app.ui.main.homepage.HomepageViewModel import io.legado.app.ui.main.my.MyViewModel import io.legado.app.ui.main.rss.RssViewModel import io.legado.app.ui.replace.ReplaceEditRoute @@ -120,11 +131,15 @@ val appModule = module { singleOf(::ReadRecordRepository) singleOf(::BookRepository) singleOf(::BookGroupRepository) + singleOf(::BookSourceRepository) + singleOf(::BookshelfRepository) singleOf(::DictRuleRepository) singleOf(::SearchContentRepository) singleOf(::RemoteBookRepository) singleOf(::SettingsRepository) + singleOf(::ExploreBooksUseCase) singleOf(::ExploreKindUiUseCase) + singleOf(::SaveSearchBooksUseCase) singleOf(::AppStartupMaintenanceUseCase) singleOf(::BatchCacheDownloadUseCase) singleOf(::CacheBookChaptersUseCase) @@ -136,6 +151,10 @@ val appModule = module { singleOf(::UpdateBooksGroupUseCase) singleOf(::UploadReadingProgressUseCase) singleOf(::ResolveBookShelfStateUseCase) + singleOf(::RefreshTocUseCase) + singleOf(::AddBookUseCase) + singleOf(::ImportBookshelfUseCase) + singleOf(::ExportBookshelfUseCase) factory { GetReadRecordOverviewUseCase() } singleOf(::ShrinkDatabaseUseCase) singleOf(::WebDavBackupUseCase) @@ -150,6 +169,7 @@ val appModule = module { single { DatabaseMaintenanceRepository(get()) } single { WebDavBackupRepository() } single { WebDavReadingProgressRepository() } + single { HomepageModulesRepository(get(), get()) } single { BookDomainRepositoryImpl(get(), get()) } single { ExploreRepositoryImpl(get()) } singleOf(::RssRepository) @@ -190,6 +210,7 @@ val appModule = module { viewModelOf(::MyViewModel) viewModelOf(::BookshelfViewModel) viewModelOf(::MainViewModel) + viewModelOf(::HomepageViewModel) viewModelOf(::AboutViewModel) viewModelOf(::GroupViewModel) viewModelOf(::ReplaceRuleViewModel) @@ -219,9 +240,9 @@ val appModule = module { viewModel { BookshelfManageScreenViewModel( application = get(), - bookDao = get(), - bookGroupDao = get(), - bookChapterDao = get(), + bookRepository = get(), + bookGroupRepository = get(), + searchRepository = get(), bookshelfManageScreenConfig = get(), batchCacheDownloadUseCase = get(), cacheBookChaptersUseCase = get(), diff --git a/app/src/main/java/io/legado/app/domain/gateway/HomepageModulesGateway.kt b/app/src/main/java/io/legado/app/domain/gateway/HomepageModulesGateway.kt new file mode 100644 index 000000000..0cdd8baa4 --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/gateway/HomepageModulesGateway.kt @@ -0,0 +1,33 @@ +package io.legado.app.domain.gateway + +import io.legado.app.domain.model.CustomSetItem +import io.legado.app.domain.model.ModuleItem +import kotlinx.coroutines.flow.Flow + +interface HomepageModulesGateway { + // Module queries + fun flowEnabled(): Flow> + fun flowAll(): Flow> + fun flowBySource(sourceUrl: String): Flow> + suspend fun getById(id: String): ModuleItem? + + // Module mutations + suspend fun upsertAll(modules: List) + suspend fun setEnabled(id: String, enabled: Boolean) + suspend fun setSortOrder(id: String, order: Int) + suspend fun setCustomSetId(id: String, setId: String?) + suspend fun setCustomSetTitle(id: String, title: String?) + suspend fun delete(id: String) + suspend fun deleteStale(sourceUrl: String, currentIds: List) + + // Custom set queries + fun flowCustomSets(): Flow> + suspend fun getCustomSetById(id: String): CustomSetItem? + + // Custom set mutations + suspend fun upsertCustomSet(set: CustomSetItem) + suspend fun setCustomSetSortOrder(id: String, order: Int) + suspend fun createCustomSet(name: String): CustomSetItem + suspend fun renameCustomSet(id: String, name: String) + suspend fun deleteCustomSet(id: String) +} diff --git a/app/src/main/java/io/legado/app/domain/model/HomepageModels.kt b/app/src/main/java/io/legado/app/domain/model/HomepageModels.kt new file mode 100644 index 000000000..263a7833a --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/model/HomepageModels.kt @@ -0,0 +1,71 @@ +package io.legado.app.domain.model + +import androidx.compose.runtime.Immutable + +/** 供 Gateway 和 ViewModel 使用的不可变模块模型 */ +@Immutable +data class ModuleItem( + val id: String = "", + val sourceUrl: String = "", + val moduleKey: String = "", + val type: String = "", + val title: String = "", + val customTitle: String? = null, + val customSetTitle: String? = null, + val args: String? = null, + val layoutConfig: String? = null, + val url: String? = null, + val isEnabled: Boolean = true, + val customSetId: String? = null, + val isUserCreated: Boolean = false, + val sortOrder: Int = 0, + val sourceJsonHash: String? = null, + val syncedAt: Long = 0, +) { + val displayTitle: String get() = customTitle ?: title +} + +@Immutable +data class CustomSetItem( + val id: String = "", + val name: String = "", + val sortOrder: Int = 0, +) + +/** 模块定义(来自书源 JSON 解析或用户手动添加) */ +data class ModuleDef( + val key: String = "", + val type: String = "", + val title: String = "", + val args: String? = null, + val layoutConfig: String? = null, + val url: String? = null, + val sourceUrl: String = "", +) { + val globalId: String get() = globalIdOf(sourceUrl, key) + + companion object { + fun globalIdOf(sourceUrl: String, key: String, setId: String? = null): String { + val targetSetId = setId ?: "src_$sourceUrl" + return "$targetSetId::$sourceUrl::$key" + } + } +} + +/** 首页模块类型枚举 — 定义在 Domain 层以便 UseCase 和 ViewModel 共享 */ +enum class HomepageModuleType(val key: String, val title: String) { + Banner("banner", "横滑轮播"), + Ranking("ranking", "排行榜"), + GridRanking("gridRanking", "网格排行榜"), + Grid("grid", "网格"), + Card("card", "推荐卡片"), + InfiniteGrid("infiniteGrid", "无限网格"), + ButtonGroup("buttonGroup", "按钮组"), + Waterfall("waterfall", "错位瀑布流"), + Unknown("", "未知"); + + companion object { + fun fromKey(key: String?): HomepageModuleType = + entries.find { it.key == key } ?: Unknown + } +} diff --git a/app/src/main/java/io/legado/app/domain/usecase/AddBookUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/AddBookUseCase.kt new file mode 100644 index 000000000..d6af61224 --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/usecase/AddBookUseCase.kt @@ -0,0 +1,71 @@ +package io.legado.app.domain.usecase + +import io.legado.app.data.entities.Book +import io.legado.app.data.repository.BookRepository +import io.legado.app.data.repository.BookSourceRepository +import io.legado.app.model.webBook.WebBook +import io.legado.app.utils.NetworkUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class AddBookUseCase( + private val bookRepository: BookRepository, + private val bookSourceRepository: BookSourceRepository +) { + suspend fun execute( + bookUrls: String, + onProgress: suspend (Int) -> Unit = {} + ): Int = withContext(Dispatchers.IO) { + var successCount = 0 + val urls = bookUrls.split("\n") + val hasBookUrlPattern = bookSourceRepository.getHasBookUrlPattern() + + for (url in urls) { + val bookUrl = url.trim() + if (bookUrl.isEmpty()) continue + if (bookRepository.getBook(bookUrl) != null) { + successCount++ + onProgress(successCount) + continue + } + val baseUrl = NetworkUtils.getBaseUrl(bookUrl) ?: continue + var source = bookSourceRepository.getBookSourceAddBook(baseUrl) + if (source == null) { + for (bookSourcePart in hasBookUrlPattern) { + try { + val bs = bookSourcePart.getBookSource()!! + if (bookUrl.matches(bs.bookUrlPattern!!.toRegex())) { + source = bs + break + } + } catch (_: Exception) { + } + } + } + val bookSource = source ?: continue + val book = Book( + bookUrl = bookUrl, + origin = bookSource.bookSourceUrl, + originName = bookSource.bookSourceName + ) + + kotlin.runCatching { + WebBook.getBookInfoAwait(bookSource, book) + }.onSuccess { + val dbBook = bookRepository.getBook(it.name, it.author) + if (dbBook != null) { + val toc = WebBook.getChapterListAwait(bookSource, it).getOrThrow() + dbBook.migrateTo(it, toc) + bookRepository.insert(it) + bookRepository.insertChapters(*toc.toTypedArray()) + } else { + it.order = bookRepository.getMinOrder() - 1 + bookRepository.insert(it) + } + successCount++ + onProgress(successCount) + } + } + successCount + } +} diff --git a/app/src/main/java/io/legado/app/domain/usecase/ExploreBooksUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/ExploreBooksUseCase.kt new file mode 100644 index 000000000..427a8775a --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/usecase/ExploreBooksUseCase.kt @@ -0,0 +1,71 @@ +package io.legado.app.domain.usecase + +import io.legado.app.data.entities.SearchBook +import io.legado.app.data.repository.BookSourceRepository +import io.legado.app.model.webBook.WebBook + +class ExploreBooksUseCase( + private val bookSourceRepository: BookSourceRepository, +) { + companion object { + /** 排名类模块自动加载的最大书本数 */ + const val MAX_RANKING_BOOKS = 20 + + /** 排名类模块自动加载的最大页数 */ + const val MAX_RANKING_PAGES = 3 + } + + suspend fun execute( + sourceUrl: String, + moduleUrl: String?, + args: String?, + page: Int = 1 + ): ExploreResult { + val base = bookSourceRepository.getBookSource(sourceUrl) + ?: throw SourceNotFound(sourceUrl) + val source = args?.let { base.copy().also { s -> s.setVariable(it) } } ?: base + val resolvedUrl = moduleUrl ?: source.exploreUrl + ?: throw NoExploreUrl(sourceUrl) + if (!resolvedUrl.startsWith("http", ignoreCase = true) + && !resolvedUrl.startsWith("data:", ignoreCase = true) + && !resolvedUrl.startsWith("{{") + ) { + throw InvalidUrl(resolvedUrl) + } + val books = WebBook.exploreBookSuspend(source, resolvedUrl, page) + return ExploreResult(resolvedUrl, books) + } + + suspend fun executeForRanking( + sourceUrl: String, + moduleUrl: String?, + args: String? + ): List { + val result = execute(sourceUrl, moduleUrl, args) + var books = result.books + var page = 1 + while (books.size < MAX_RANKING_BOOKS && page < MAX_RANKING_PAGES) { + page++ + val next = try { + WebBook.exploreBookSuspend( + bookSourceRepository.getBookSource(sourceUrl) + ?.let { s -> args?.let { s.copy().also { x -> x.setVariable(it) } } ?: s } + ?: return books.take(MAX_RANKING_BOOKS), + result.resolvedUrl, + page, + ) + } catch (_: Exception) { + emptyList() + } + if (next.isEmpty()) break + books = (books + next) + } + return books.take(MAX_RANKING_BOOKS) + } + + data class ExploreResult(val resolvedUrl: String, val books: List) + + class SourceNotFound(url: String) : Exception("Source not found: ${url.take(60)}") + class NoExploreUrl(url: String) : Exception("No explore URL for source: ${url.take(60)}") + class InvalidUrl(url: String) : Exception("Invalid explore URL: ${url.take(80)}") +} diff --git a/app/src/main/java/io/legado/app/domain/usecase/ExportBookshelfUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/ExportBookshelfUseCase.kt new file mode 100644 index 000000000..8a65cb786 --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/usecase/ExportBookshelfUseCase.kt @@ -0,0 +1,79 @@ +package io.legado.app.domain.usecase + +import android.content.Context +import android.net.Uri +import com.google.gson.stream.JsonWriter +import io.legado.app.data.repository.BookRepository +import io.legado.app.ui.main.bookshelf.BookUiItem +import io.legado.app.utils.FileUtils +import io.legado.app.utils.GSON +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.io.OutputStreamWriter + +class ExportBookshelfUseCase( + private val context: Context, + private val bookRepository: BookRepository +) { + suspend fun exportToUri(uri: Uri, items: List): Result = + withContext(Dispatchers.IO) { + kotlin.runCatching { + context.contentResolver.openOutputStream(uri)?.use { out -> + val writer = JsonWriter(OutputStreamWriter(out, "UTF-8")) + writer.setIndent(" ") + writer.beginArray() + items.forEach { + val bookMap = hashMapOf() + bookMap["name"] = it.book.name + bookMap["author"] = it.book.author + val fullBook = bookRepository.getBook(it.book.bookUrl) + bookMap["intro"] = fullBook?.getDisplayIntro() + GSON.toJson(bookMap, bookMap::class.java, writer) + } + writer.endArray() + writer.close() + } ?: throw Exception("Failed to open output stream") + } + } + + suspend fun exportToFile(items: List): Result = withContext(Dispatchers.IO) { + kotlin.runCatching { + val path = "${context.filesDir}/books.json" + FileUtils.delete(path) + val file = FileUtils.createFileWithReplace(path) + FileOutputStream(file).use { out -> + val writer = JsonWriter(OutputStreamWriter(out, "UTF-8")) + writer.setIndent(" ") + writer.beginArray() + items.forEach { + val bookMap = hashMapOf() + bookMap["name"] = it.book.name + bookMap["author"] = it.book.author + val fullBook = bookRepository.getBook(it.book.bookUrl) + bookMap["intro"] = fullBook?.getDisplayIntro() + GSON.toJson(bookMap, bookMap::class.java, writer) + } + writer.endArray() + writer.close() + } + file + } + } + + suspend fun exportToJson(items: List): Result = + withContext(Dispatchers.IO) { + kotlin.runCatching { + val list = items.map { + val bookMap = hashMapOf() + bookMap["name"] = it.book.name + bookMap["author"] = it.book.author + val fullBook = bookRepository.getBook(it.book.bookUrl) + bookMap["intro"] = fullBook?.getDisplayIntro() + bookMap + } + GSON.toJson(list) + } + } +} diff --git a/app/src/main/java/io/legado/app/domain/usecase/ImportBookshelfUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/ImportBookshelfUseCase.kt new file mode 100644 index 000000000..179e9d25b --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/usecase/ImportBookshelfUseCase.kt @@ -0,0 +1,109 @@ +package io.legado.app.domain.usecase + +import android.content.Context +import android.net.Uri +import io.legado.app.data.entities.Book +import io.legado.app.data.repository.BookRepository +import io.legado.app.data.repository.BookSourceRepository +import io.legado.app.exception.NoStackTraceException +import io.legado.app.help.config.AppConfig +import io.legado.app.help.http.decompressed +import io.legado.app.help.http.newCallResponseBody +import io.legado.app.help.http.okHttpClient +import io.legado.app.help.http.text +import io.legado.app.model.webBook.WebBook +import io.legado.app.utils.GSON +import io.legado.app.utils.fromJsonArray +import io.legado.app.utils.isAbsUrl +import io.legado.app.utils.isJsonArray +import io.legado.app.utils.readText +import io.legado.app.utils.toastOnUi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext + +class ImportBookshelfUseCase( + private val context: Context, + private val bookRepository: BookRepository, + private val bookSourceRepository: BookSourceRepository +) { + suspend fun import( + str: String, + groupId: Long, + onProgress: suspend (String) -> Unit = {} + ): Result = kotlin.runCatching { + val text = str.trim() + when { + text.isAbsUrl() -> { + val downloadedText = okHttpClient.newCallResponseBody { + url(text) + }.decompressed().text() + import(downloadedText, groupId, onProgress).getOrThrow() + } + + text.isJsonArray() -> { + importByJson(text, groupId, onProgress) + } + + else -> { + throw NoStackTraceException("格式不对") + } + } + } + + suspend fun import( + uri: Uri, + groupId: Long, + onProgress: suspend (String) -> Unit = {} + ): Result = kotlin.runCatching { + val text = uri.readText(context) + import(text, groupId, onProgress).getOrThrow() + } + + private suspend fun importByJson( + json: String, + groupId: Long, + onProgress: suspend (String) -> Unit + ) { + onProgress("导入中...") + val bookSourceParts = bookSourceRepository.getAllEnabledPart() + val semaphore = Semaphore(AppConfig.threadCount) + val books = GSON.fromJsonArray>(json).getOrThrow() + + withContext(Dispatchers.IO) { + books.forEach { bookInfo -> + val name = bookInfo["name"] ?: "" + val author = bookInfo["author"] ?: "" + if (name.isEmpty() || bookRepository.getBook(name, author) != null) { + return@forEach + } + semaphore.withPermit { + var foundBook: Book? = null + for (s in bookSourceParts) { + ensureActive() + val source = s.getBookSource() ?: continue + foundBook = WebBook.preciseSearchAwait(source, name, author).getOrNull() + if (foundBook != null) break + } + if (foundBook != null) { + val book = foundBook + if (groupId > 0) { + book.group = groupId + } + if (bookRepository.getBook(book.bookUrl) != null) { + bookRepository.update(book) + } else { + bookRepository.insert(book) + } + } else { + withContext(Dispatchers.Main) { + context.toastOnUi("没有搜索到<$name>$author") + } + } + } + } + } + } +} diff --git a/app/src/main/java/io/legado/app/domain/usecase/RefreshTocUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/RefreshTocUseCase.kt new file mode 100644 index 000000000..8ec8131cd --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/usecase/RefreshTocUseCase.kt @@ -0,0 +1,61 @@ +package io.legado.app.domain.usecase + +import io.legado.app.constant.BookType +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookSource +import io.legado.app.data.repository.BookRepository +import io.legado.app.data.repository.BookSourceRepository +import io.legado.app.help.book.BookHelp +import io.legado.app.help.book.addType +import io.legado.app.help.book.isUpError +import io.legado.app.help.book.removeType +import io.legado.app.help.book.sync +import io.legado.app.model.ReadBook +import io.legado.app.model.webBook.WebBook +import kotlinx.coroutines.ensureActive + +class RefreshTocUseCase( + private val bookRepository: BookRepository, + private val bookSourceRepository: BookSourceRepository +) { + suspend fun execute( + bookUrl: String, + onSuccess: suspend (BookSource, Book) -> Unit = { _, _ -> } + ): Result = kotlin.runCatching { + val book = bookRepository.getBook(bookUrl) ?: throw Exception("Book not found") + val source = bookSourceRepository.getBookSource(book.origin) + if (source == null) { + if (!book.isUpError) { + book.addType(BookType.updateError) + bookRepository.update(book) + } + throw Exception("Source not found") + } + + val oldBook = book.copy() + if (book.tocUrl.isBlank()) { + WebBook.getBookInfoAwait(source, book) + } else { + WebBook.runPreUpdateJs(source, book) + } + val toc = WebBook.getChapterListAwait(source, book).getOrThrow() + book.sync(oldBook) + book.removeType(BookType.updateError) + if (book.bookUrl == bookUrl) { + bookRepository.update(book) + } else { + bookRepository.replace(oldBook, book) + BookHelp.updateCacheFolder(oldBook, book) + } + bookRepository.deleteChaptersByBook(bookUrl) + bookRepository.insertChapters(*toc.toTypedArray()) + ReadBook.onChapterListUpdated(book) + onSuccess(source, book) + }.onFailure { + kotlin.coroutines.coroutineContext.ensureActive() + bookRepository.getBook(bookUrl)?.let { book -> + book.addType(BookType.updateError) + bookRepository.update(book) + } + } +} diff --git a/app/src/main/java/io/legado/app/domain/usecase/SaveSearchBooksUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/SaveSearchBooksUseCase.kt new file mode 100644 index 000000000..51c45d883 --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/usecase/SaveSearchBooksUseCase.kt @@ -0,0 +1,11 @@ +package io.legado.app.domain.usecase + +import io.legado.app.data.entities.SearchBook +import io.legado.app.data.repository.SearchRepository + +class SaveSearchBooksUseCase( + private val searchRepository: SearchRepository, +) { + suspend fun save(book: SearchBook) = save(listOf(book)) + suspend fun save(books: List) = searchRepository.saveSearchBooks(books) +} diff --git a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt index 4fbcf9438..431aa69d7 100644 --- a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt @@ -6,10 +6,12 @@ import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.SearchBook import io.legado.app.data.entities.rule.ExploreKind import io.legado.app.data.repository.ExploreRepository -import io.legado.app.domain.usecase.BookShelfKey -import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase -import io.legado.app.help.config.AppConfig import io.legado.app.domain.model.BookShelfState +import io.legado.app.domain.usecase.BookShelfKey +import io.legado.app.domain.usecase.ExploreBooksUseCase +import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase +import io.legado.app.domain.usecase.SaveSearchBooksUseCase +import io.legado.app.help.config.AppConfig import io.legado.app.utils.exploreLayoutGrid import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -50,7 +52,9 @@ data class ExploreBookItemUi( class ExploreShowViewModel( private val repository: ExploreRepository, - private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase + private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase, + private val exploreBooksUseCase: ExploreBooksUseCase, + private val saveSearchBooksUseCase: SaveSearchBooksUseCase, ) : ViewModel() { private val _rawBooks = MutableStateFlow>(emptyList()) @@ -124,13 +128,13 @@ class ExploreShowViewModel( } fun initData(incomingSourceUrl: String?, incomingExploreUrl: String?) { + // 允许 incomingExploreUrl 为空,此时加载书源默认发现页 if (sourceUrl == incomingSourceUrl && exploreUrl == incomingExploreUrl && bookSource != null) { return } sourceUrl = incomingSourceUrl exploreUrl = incomingExploreUrl page = 1 - bookSource = null _rawBooks.value = emptyList() _isEndStateFlow.value = false _errorMsg.value = null @@ -139,8 +143,13 @@ class ExploreShowViewModel( viewModelScope.launch { if (bookSource == null && incomingSourceUrl != null) { bookSource = repository.getBookSource(incomingSourceUrl) - loadKinds(incomingSourceUrl) } + + // 如果仍然没有发现 URL,且书源已加载,尝试使用书源的默认发现页 + if (exploreUrl == null && bookSource != null) { + loadKinds(incomingSourceUrl!!) + } + loadMore(isRefresh = true) } } @@ -175,7 +184,7 @@ class ExploreShowViewModel( fun loadMore(isRefresh: Boolean = false) { val source = bookSource - val url = exploreUrl + val url = exploreUrl ?: source?.exploreUrl if (source == null || url == null || _isLoading.value || (_isEndStateFlow.value && !isRefresh)) return viewModelScope.launch { @@ -188,29 +197,30 @@ class ExploreShowViewModel( _rawBooks.value = emptyList() } - repository.exploreBook(source, url, page) - .onSuccess { newBooks -> - if (newBooks.isEmpty()) { + kotlin.runCatching { + exploreBooksUseCase.execute(source.bookSourceUrl, url, args = null, page) + }.onSuccess { result -> + if (result.books.isEmpty()) { + _isEndStateFlow.value = true + } else { + saveSearchBooksUseCase.save(result.books) + + val currentList = _rawBooks.value + val existingUrls = currentList.map { it.bookUrl }.toSet() + + val uniqueNewBooks = result.books + .filter { it.bookUrl !in existingUrls } + .distinctBy { it.bookUrl } + + if (uniqueNewBooks.isEmpty()) { _isEndStateFlow.value = true } else { - repository.saveSearchBooks(newBooks) - - val currentList = _rawBooks.value - val existingUrls = currentList.map { it.bookUrl }.toSet() - - val uniqueNewBooks = newBooks - .filter { it.bookUrl !in existingUrls } - .distinctBy { it.bookUrl } - - if (uniqueNewBooks.isEmpty()) { - _isEndStateFlow.value = true - } else { - _rawBooks.value = currentList + uniqueNewBooks - page++ - _isEndStateFlow.value = false - } + _rawBooks.value = currentList + uniqueNewBooks + page++ + _isEndStateFlow.value = false } } + } .onFailure { _errorMsg.value = it.localizedMessage } diff --git a/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreenViewModel.kt b/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreenViewModel.kt index b4fab0fd1..eec89690e 100644 --- a/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreenViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreenViewModel.kt @@ -4,14 +4,13 @@ import android.app.Application import androidx.lifecycle.viewModelScope import io.legado.app.base.BaseViewModel import io.legado.app.constant.BookType -import io.legado.app.data.appDb -import io.legado.app.data.dao.BookChapterDao -import io.legado.app.data.dao.BookDao -import io.legado.app.data.dao.BookGroupDao import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookGroup import io.legado.app.data.entities.BookSource +import io.legado.app.data.repository.BookGroupRepository +import io.legado.app.data.repository.BookRepository +import io.legado.app.data.repository.SearchRepository import io.legado.app.domain.usecase.BatchCacheDownloadUseCase import io.legado.app.domain.usecase.BatchChangeSourceCandidate import io.legado.app.domain.usecase.BatchChangeSourcePreviewItem @@ -145,9 +144,9 @@ sealed interface BookshelfManageScreenEffect { class BookshelfManageScreenViewModel( application: Application, - private val bookDao: BookDao, - private val bookGroupDao: BookGroupDao, - private val bookChapterDao: BookChapterDao, + private val bookRepository: BookRepository, + private val bookGroupRepository: BookGroupRepository, + private val searchRepository: SearchRepository, val bookshelfManageScreenConfig: BookshelfManageScreenConfig, private val batchCacheDownloadUseCase: BatchCacheDownloadUseCase, private val cacheBookChaptersUseCase: CacheBookChaptersUseCase, @@ -337,7 +336,7 @@ class BookshelfManageScreenViewModel( private fun observeGroups() { groupsJob?.cancel() groupsJob = viewModelScope.launch { - bookGroupDao.flowAll().collect { groups -> + bookGroupRepository.flowAll().collect { groups -> _uiState.update { it.copy(groupList = groups) } } } @@ -346,7 +345,7 @@ class BookshelfManageScreenViewModel( private fun observeBooks(groupId: Long) { booksJob?.cancel() booksJob = viewModelScope.launch { - bookDao.flowBookShelfByGroup(groupId).map { books -> + bookRepository.flowBookShelfByGroup(groupId).map { books -> val booksDownload = books.filter { !it.isAudio }.map { it.toLightBook() } val bookSort = bookshelfManageScreenConfig.getBookSortByGroupId(groupId) val isDescending = bookshelfManageScreenConfig.bookshelfSortOrder == 1 @@ -475,7 +474,7 @@ class BookshelfManageScreenViewModel( private fun refreshGroupName(groupId: Long) { execute { - val title = bookGroupDao.getByID(groupId)?.groupName + val title = bookGroupRepository.getByID(groupId)?.groupName title ?: context.getString(io.legado.app.R.string.no_group) }.onSuccess { groupName -> _uiState.update { it.copy(groupName = groupName) } @@ -532,7 +531,7 @@ class BookshelfManageScreenViewModel( val visibleBookUrls = uiState.value.books.mapTo(hashSetOf()) { it.bookUrl } cacheRefreshBookUrls.forEach { bookUrl -> if (visibleBookUrls.contains(bookUrl)) { - bookDao.getBook(bookUrl)?.let { book -> + bookRepository.getBook(bookUrl)?.let { book -> cacheCounts[bookUrl] = calculateCacheCount(book) changedBookUrls.add(bookUrl) } @@ -549,12 +548,12 @@ class BookshelfManageScreenViewModel( emitBooksChanged(changedBookUrls) } - private fun calculateCacheCount(book: Book): Int { + private suspend fun calculateCacheCount(book: Book): Int { val cacheNames = BookHelp.getChapterFiles(book) if (cacheNames.isEmpty()) return 0 - val totalCount = bookChapterDao.getChapterCount(book.bookUrl) + val totalCount = bookRepository.getChapterCount(book.bookUrl) val cachedFileCount = cacheNames.count { it.endsWith(".nb") } - return min(cachedFileCount + bookChapterDao.getVolumeCount(book.bookUrl), totalCount) + return min(cachedFileCount + bookRepository.getVolumeCount(book.bookUrl), totalCount) } private fun Int?.orZero(): Int = this ?: 0 @@ -661,7 +660,7 @@ class BookshelfManageScreenViewModel( ) } execute { - bookDao.update(*reorderedBooks.toTypedArray()) + bookRepository.update(*reorderedBooks.toTypedArray()) }.onError { _effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("排序保存失败\n${it.localizedMessage}")) } @@ -696,7 +695,7 @@ class BookshelfManageScreenViewModel( options: ChangeSourceMigrationOptions, ) { execute { - val oldBook = bookDao.getBook(oldBookUrl) ?: return@execute null + val oldBook = bookRepository.getBook(oldBookUrl) ?: return@execute null changeBookSourceUseCase.changeTo(oldBook, book, chapters, options) }.onSuccess { result -> result ?: return@onSuccess @@ -734,7 +733,7 @@ class BookshelfManageScreenViewModel( batchChangePreviewItems = emptyList() ) } - val books = bookUrls.mapNotNull { bookDao.getBook(it) } + val books = bookUrls.mapNotNull { bookRepository.getBook(it) } changeBookSourceUseCase.prepareBatchChange( books = books, sources = sources, @@ -786,7 +785,7 @@ class BookshelfManageScreenViewModel( } ?: return val candidate = item.selectedCandidate ?: return execute { - val oldBook = bookDao.getBook(oldBookUrl) ?: item.oldBook + val oldBook = bookRepository.getBook(oldBookUrl) ?: item.oldBook val chapters = changeBookSourceUseCase.loadCandidateChapters( candidate.source, candidate.book @@ -885,10 +884,10 @@ class BookshelfManageScreenViewModel( ) ?: error("获取目录失败") candidate.book.removeType(BookType.notShelf) if (candidate.book.order == 0) { - candidate.book.order = bookDao.minOrder - 1 + candidate.book.order = bookRepository.getMinOrder() - 1 } - bookDao.insert(candidate.book) - bookChapterDao.insert(*chapters.toTypedArray()) + bookRepository.insert(candidate.book) + bookRepository.insertChapters(*chapters.toTypedArray()) candidate.book }.onSuccess { _effects.tryEmit(BookshelfManageScreenEffect.ShowMessage("已添加到书架")) @@ -900,7 +899,7 @@ class BookshelfManageScreenViewModel( private fun openBookInfoPreview(book: Book, inBookshelf: Boolean) { execute { if (!inBookshelf) { - appDb.searchBookDao.insert(book.toSearchBook()) + searchRepository.saveSearchBooks(listOf(book.toSearchBook())) } book }.onSuccess { @@ -931,7 +930,7 @@ class BookshelfManageScreenViewModel( it.copy(changeSourceProgress = "${index + 1} / ${items.size} ${item.oldBook.name}") } val candidate = item.selectedCandidate ?: return@forEachIndexed - val oldBook = bookDao.getBook(item.oldBook.bookUrl) ?: item.oldBook + val oldBook = bookRepository.getBook(item.oldBook.bookUrl) ?: item.oldBook val chapters = changeBookSourceUseCase.loadCandidateChapters( candidate.source, candidate.book @@ -978,10 +977,10 @@ class BookshelfManageScreenViewModel( ) ?: return@forEachIndexed candidate.book.removeType(BookType.notShelf) if (candidate.book.order == 0) { - candidate.book.order = bookDao.minOrder - 1 + candidate.book.order = bookRepository.getMinOrder() - 1 } - bookDao.insert(candidate.book) - bookChapterDao.insert(*chapters.toTypedArray()) + bookRepository.insert(candidate.book) + bookRepository.insertChapters(*chapters.toTypedArray()) } }.onSuccess { _uiState.update { it.copy(batchChangePreviewItems = emptyList()) } diff --git a/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditActivity.kt b/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditActivity.kt index c24dd2eef..71b24a03d 100644 --- a/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditActivity.kt @@ -68,6 +68,7 @@ class BookSourceEditActivity : private val sourceEntities: ArrayList = ArrayList() private val searchEntities: ArrayList = ArrayList() private val exploreEntities: ArrayList = ArrayList() + private val homepageEntities: ArrayList = ArrayList() private val infoEntities: ArrayList = ArrayList() private val tocEntities: ArrayList = ArrayList() private val contentEntities: ArrayList = ArrayList() @@ -174,6 +175,9 @@ class BookSourceEditActivity : binding.tabLayout.addTab(binding.tabLayout.newTab().apply { setText(R.string.source_tab_find) }) + binding.tabLayout.addTab(binding.tabLayout.newTab().apply { + setText(R.string.source_tab_homepage) + }) binding.tabLayout.addTab(binding.tabLayout.newTab().apply { setText(R.string.source_tab_info) }) @@ -234,10 +238,10 @@ class BookSourceEditActivity : adapter.editEntities = when (tabPosition) { 1 -> searchEntities 2 -> exploreEntities - 3 -> infoEntities - 4 -> tocEntities - 5 -> contentEntities -// 6 -> reviewEntities + 3 -> homepageEntities + 4 -> infoEntities + 5 -> tocEntities + 6 -> contentEntities else -> sourceEntities } binding.recyclerView.scrollToPosition(0) @@ -306,6 +310,11 @@ class BookSourceEditActivity : add(EditEntity("coverUrl", er.coverUrl, R.string.rule_cover_url)) add(EditEntity("bookUrl", er.bookUrl, R.string.r_book_url)) } + // 主页模块 + homepageEntities.clear() + homepageEntities.apply { + add(EditEntity("homepageModules", bs.homepageModules, R.string.homepage_modules)) + } // 详情页 val ir = bs.getBookInfoRule() infoEntities.clear() @@ -473,6 +482,12 @@ class BookSourceEditActivity : viewModel.ruleComplete(it.value, exploreRule.bookList, 2) } } + homepageEntities.forEach { + it.value = it.value?.takeIf { s -> s.isNotBlank() } + when (it.key) { + "homepageModules" -> source.homepageModules = it.value + } + } infoEntities.forEach { it.value = it.value?.takeIf { s -> s.isNotBlank() } when (it.key) { diff --git a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt index a1213281f..654d4723a 100644 --- a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt +++ b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt @@ -170,6 +170,8 @@ object ThemeConfig { var autoCheckNewBackup by prefDelegate(PreferKey.autoCheckNewBackup, true) + var navIconHome by prefDelegate(PreferKey.navIconHome, "") + var navIconBookshelf by prefDelegate(PreferKey.navIconBookshelf, "") var navIconExplore by prefDelegate(PreferKey.navIconExplore, "") diff --git a/app/src/main/java/io/legado/app/ui/main/MainDestination.kt b/app/src/main/java/io/legado/app/ui/main/MainDestination.kt index e271d2e83..01ee8a9a1 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainDestination.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainDestination.kt @@ -9,6 +9,11 @@ sealed class MainDestination( val route: String, @StringRes val labelId: Int ) { + object Home : MainDestination( + route = "home", + labelId = R.string.home + ) + object Bookshelf : MainDestination( route = "bookshelf", labelId = R.string.bookshelf @@ -30,12 +35,13 @@ sealed class MainDestination( ) companion object { - val mainDestinations = persistentListOf(Bookshelf, Explore, Rss, My) + val mainDestinations = persistentListOf(Home, Bookshelf, Explore, Rss, My) } } val MainDestination.customIconPath: String get() = when (this) { + MainDestination.Home -> ThemeConfig.navIconHome MainDestination.Bookshelf -> ThemeConfig.navIconBookshelf MainDestination.Explore -> ThemeConfig.navIconExplore MainDestination.Rss -> ThemeConfig.navIconRss diff --git a/app/src/main/java/io/legado/app/ui/main/MainFragmentInterface.kt b/app/src/main/java/io/legado/app/ui/main/MainFragmentInterface.kt deleted file mode 100644 index 6b11b08fc..000000000 --- a/app/src/main/java/io/legado/app/ui/main/MainFragmentInterface.kt +++ /dev/null @@ -1,7 +0,0 @@ -package io.legado.app.ui.main - -interface MainFragmentInterface { - - val position: Int? - -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt b/app/src/main/java/io/legado/app/ui/main/MainScreen.kt index da618926e..0aa926247 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainScreen.kt @@ -71,6 +71,7 @@ import io.legado.app.ui.config.themeConfig.ThemeConfig import io.legado.app.ui.main.bookshelf.BookshelfScreen import io.legado.app.ui.main.bookshelf.BookshelfViewModel import io.legado.app.ui.main.explore.ExploreScreen +import io.legado.app.ui.main.homepage.HomepageScreen import io.legado.app.ui.main.my.MyScreen import io.legado.app.ui.main.my.PrefClickEvent import io.legado.app.ui.main.rss.RssScreen @@ -366,6 +367,17 @@ fun MainScreen( ) { page -> val destination = destinations.getOrNull(page) ?: return@HorizontalPager when (destination) { + MainDestination.Home -> HomepageScreen( + onBookClick = { name, author, bookUrl -> + onNavigateToBookInfo(name ?: "", author ?: "", bookUrl) + }, + onModuleHeaderClick = { title, sourceUrl, exploreUrl -> + onNavigateToExploreShow(title, sourceUrl, exploreUrl) + }, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) + MainDestination.Bookshelf -> BookshelfScreen( onBookClick = { book -> context.startActivityForBook(book) diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt index e99f87c84..17b877585 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt @@ -4,54 +4,38 @@ import android.app.Application import android.net.Uri import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.viewModelScope -import com.google.gson.stream.JsonWriter import io.legado.app.R import io.legado.app.base.BaseRuleEvent import io.legado.app.base.BaseViewModel import io.legado.app.constant.AppConst import io.legado.app.constant.AppLog -import io.legado.app.constant.BookType import io.legado.app.constant.EventBus import io.legado.app.data.appDb import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookGroup import io.legado.app.data.entities.BookSource -import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.repository.BookGroupRepository +import io.legado.app.data.repository.BookRepository +import io.legado.app.data.repository.BookSourceRepository +import io.legado.app.data.repository.BookshelfRepository import io.legado.app.data.repository.UploadRepository +import io.legado.app.domain.usecase.AddBookUseCase import io.legado.app.domain.usecase.BatchCacheDownloadUseCase +import io.legado.app.domain.usecase.ExportBookshelfUseCase +import io.legado.app.domain.usecase.ImportBookshelfUseCase +import io.legado.app.domain.usecase.RefreshTocUseCase import io.legado.app.domain.usecase.UpdateBooksGroupUseCase import io.legado.app.exception.NoStackTraceException -import io.legado.app.help.book.BookHelp -import io.legado.app.help.book.addType -import io.legado.app.help.book.isUpError -import io.legado.app.help.book.removeType -import io.legado.app.help.book.sync import io.legado.app.help.config.AppConfig import io.legado.app.help.coroutine.Coroutine -import io.legado.app.help.http.decompressed -import io.legado.app.help.http.newCallResponseBody -import io.legado.app.help.http.okHttpClient -import io.legado.app.help.http.text import io.legado.app.model.CacheBook -import io.legado.app.model.ReadBook import io.legado.app.model.SourceCallBack -import io.legado.app.model.webBook.WebBook import io.legado.app.service.CacheBookService import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig -import io.legado.app.utils.FileUtils -import io.legado.app.utils.GSON -import io.legado.app.utils.NetworkUtils -import io.legado.app.utils.cnCompare import io.legado.app.utils.eventBus.FlowEventBus -import io.legado.app.utils.fromJsonArray -import io.legado.app.utils.isAbsUrl -import io.legado.app.utils.isJsonArray import io.legado.app.utils.move import io.legado.app.utils.onEachParallel import io.legado.app.utils.postEvent -import io.legado.app.utils.printOnDebug -import io.legado.app.utils.readText import io.legado.app.utils.toastOnUi import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap @@ -64,9 +48,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay -import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -87,23 +69,24 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit -import kotlinx.coroutines.withContext import java.io.File -import java.io.FileOutputStream -import java.io.OutputStreamWriter import java.util.LinkedList import java.util.concurrent.ConcurrentHashMap -import kotlin.math.max import kotlin.math.min class BookshelfViewModel( application: Application, + private val bookRepository: BookRepository, private val bookGroupRepository: BookGroupRepository, + private val bookSourceRepository: BookSourceRepository, + private val bookshelfRepository: BookshelfRepository, private val uploadRepository: UploadRepository, private val batchCacheDownloadUseCase: BatchCacheDownloadUseCase, - private val updateBooksGroupUseCase: UpdateBooksGroupUseCase + private val updateBooksGroupUseCase: UpdateBooksGroupUseCase, + private val refreshTocUseCase: RefreshTocUseCase, + private val addBookUseCase: AddBookUseCase, + private val importBookshelfUseCase: ImportBookshelfUseCase, + private val exportBookshelfUseCase: ExportBookshelfUseCase ) : BaseViewModel(application) { private var addBookJob: Coroutine<*>? = null @@ -199,14 +182,15 @@ class BookshelfViewModel( val booksFlow: Flow> = groupIdFlow .flatMapLatest { groupId -> combine( - appDb.bookDao.flowBookShelfByGroup(groupId), + bookRepository.flowBookShelfByGroup(groupId), groupsFlow, sortConfigFlow ) { list, groups, sortConfig -> - sortBooks( + bookshelfRepository.sortBooks( list, groups.find { it.groupId == groupId }, - sortConfig + sortConfig.sort, + sortConfig.sortOrder ).map { it.toUiItem() } } }.distinctUntilChanged().flowOn(Dispatchers.Default) @@ -221,8 +205,13 @@ class BookshelfViewModel( flowOf(emptyMap()) } else { val flows = groups.map { group -> - appDb.bookDao.flowBookShelfByGroup(group.groupId).map { books -> - group.groupId to sortBooks(books, group, sortConfig).map { it.toUiItem() } + bookRepository.flowBookShelfByGroup(group.groupId).map { books -> + group.groupId to bookshelfRepository.sortBooks( + books, + group, + sortConfig.sort, + sortConfig.sortOrder + ).map { it.toUiItem() } } } combine(flows) { it.toMap() } @@ -269,8 +258,8 @@ class BookshelfViewModel( private val groupPreviewsFlow = combine( groupsFlow, bookGroupStyleFlow, - appDb.bookDao.flowSystemGroupCounts(), - appDb.bookDao.flowAllBookShelfCount() + bookRepository.flowSystemGroupCounts(), + bookRepository.flowAllBookShelfCount() ) { groups, bookGroupStyle, systemCounts, totalCount -> DataForPreviews( groups, @@ -291,11 +280,11 @@ class BookshelfViewModel( } else { val groupFlows = groups.map { group -> val countFlow: Flow = if (group.groupId > 0) { - appDb.bookDao.flowUserGroupBookCount(group.groupId) + bookRepository.flowUserGroupBookCount(group.groupId) } else { flowOf(systemCountsMap[group.groupId] ?: 0) } - val previewFlow = appDb.bookDao.flowGroupPreview(group.groupId) + val previewFlow = bookRepository.flowGroupPreview(group.groupId) combine(countFlow, previewFlow) { count, preview -> Triple(group.groupId, count, preview.map { it.toUiItem() }) } @@ -572,48 +561,6 @@ class BookshelfViewModel( } } - private fun sortBooks( - list: List, - group: BookGroup?, - sortConfig: BookshelfSortConfig - ): List { - val bookSort = if (group != null && group.bookSort >= 0) { - group.bookSort - } else { - sortConfig.sort - } - val isDescending = sortConfig.sortOrder == 1 - - return when (bookSort) { - 1 -> if (isDescending) list.sortedByDescending { it.latestChapterTime } - else list.sortedBy { it.latestChapterTime } - - 2 -> if (isDescending) - list.sortedWith { o1, o2 -> o2.name.cnCompare(o1.name) } - else - list.sortedWith { o1, o2 -> o1.name.cnCompare(o2.name) } - - 3 -> if (isDescending) list.sortedByDescending { it.order } - else list.sortedBy { it.order } - - 4 -> if (isDescending) list.sortedByDescending { - max( - it.latestChapterTime, - it.durChapterTime - ) - } - else list.sortedBy { max(it.latestChapterTime, it.durChapterTime) } - - 5 -> if (isDescending) - list.sortedWith { o1, o2 -> o2.author.cnCompare(o1.author) } - else - list.sortedWith { o1, o2 -> o1.author.cnCompare(o2.author) } - - else -> if (isDescending) list.sortedByDescending { it.durChapterTime } - else list.sortedBy { it.durChapterTime } - } - } - private fun buildTitle( bookGroupStyle: Int, isInFolderRoot: Boolean, @@ -824,8 +771,6 @@ class BookshelfViewModel( fun gotoTop() { scrollTrigger.tryEmit(Unit) } - - // 更新逻辑移入 fun upAllBookToc() { execute { addToWaitUp(appDb.bookDao.hasUpdateBooks) @@ -951,51 +896,8 @@ class BookshelfViewModel( } private suspend fun updateToc(bookUrl: String) { - val book = appDb.bookDao.getBook(bookUrl) ?: return - val source = appDb.bookSourceDao.getBookSource(book.origin) - if (source == null) { - if (!book.isUpError) { - book.addType(BookType.updateError) - appDb.bookDao.update(book) - } - return - } - if (source.eventListener) { - if (eventListenerSource.putIfAbsent(source, true) == null) { - SourceCallBack.callBackSource( - viewModelScope, - SourceCallBack.START_SHELF_REFRESH, - source - ) - } - } - kotlin.runCatching { - val oldBook = book.copy() - if (book.tocUrl.isBlank()) { - WebBook.getBookInfoAwait(source, book) - } else { - WebBook.runPreUpdateJs(source, book) - } - val toc = WebBook.getChapterListAwait(source, book).getOrThrow() - book.sync(oldBook) - book.removeType(BookType.updateError) - if (book.bookUrl == bookUrl) { - appDb.bookDao.update(book) - } else { - appDb.bookDao.replace(oldBook, book) - BookHelp.updateCacheFolder(oldBook, book) - } - appDb.bookChapterDao.delByBook(bookUrl) - appDb.bookChapterDao.insert(*toc.toTypedArray()) - ReadBook.onChapterListUpdated(book) + refreshTocUseCase.execute(bookUrl) { source, book -> addDownload(source, book) - }.onFailure { - currentCoroutineContext().ensureActive() - AppLog.put("${book.name} 更新目录失败\n${it.localizedMessage}", it) - appDb.bookDao.getBook(book.bookUrl)?.let { book -> - book.addType(BookType.updateError) - appDb.bookDao.update(book) - } } } @@ -1045,58 +947,11 @@ class BookshelfViewModel( } fun addBookByUrl(bookUrls: String) { - var successCount = 0 loadingTextFlow.value = "添加中..." addBookJob = execute { - val hasBookUrlPattern: List by lazy { - appDb.bookSourceDao.hasBookUrlPattern + val successCount = addBookUseCase.execute(bookUrls) { + loadingTextFlow.value = "添加中... ($it)" } - val urls = bookUrls.split("\n") - for (url in urls) { - val bookUrl = url.trim() - if (bookUrl.isEmpty()) continue - if (appDb.bookDao.getBook(bookUrl) != null) { - successCount++ - continue - } - val baseUrl = NetworkUtils.getBaseUrl(bookUrl) ?: continue - var source = appDb.bookSourceDao.getBookSourceAddBook(baseUrl) - if (source == null) { - for (bookSource in hasBookUrlPattern) { - try { - val bs = bookSource.getBookSource()!! - if (bookUrl.matches(bs.bookUrlPattern!!.toRegex())) { - source = bs - break - } - } catch (_: Exception) { - } - } - } - val bookSource = source ?: continue - val book = Book( - bookUrl = bookUrl, - origin = bookSource.bookSourceUrl, - originName = bookSource.bookSourceName - ) - kotlin.runCatching { - WebBook.getBookInfoAwait(bookSource, book) - }.onSuccess { - val dbBook = appDb.bookDao.getBook(it.name, it.author) - if (dbBook != null) { - val toc = WebBook.getChapterListAwait(bookSource, it).getOrThrow() - dbBook.migrateTo(it, toc) - appDb.bookDao.insert(it) - appDb.bookChapterDao.insert(*toc.toTypedArray()) - } else { - it.order = appDb.bookDao.minOrder - 1 - it.save() - } - successCount++ - loadingTextFlow.value = "添加中... ($successCount)" - } - } - }.onSuccess { if (successCount > 0) { context.toastOnUi(R.string.success) } else { @@ -1111,23 +966,7 @@ class BookshelfViewModel( fun exportToUri(uri: Uri, items: List) { execute { - context.contentResolver.openOutputStream(uri)?.use { out -> - val writer = JsonWriter(OutputStreamWriter(out, "UTF-8")) - writer.setIndent(" ") - writer.beginArray() - items.forEach { - val bookMap = hashMapOf() - bookMap["name"] = it.book.name - bookMap["author"] = it.book.author - // intro is not in BookShelfItem, fetch from DB if needed or skip - // For now, let's keep it simple and skip intro or fetch it - val fullBook = appDb.bookDao.getBook(it.book.bookUrl) - bookMap["intro"] = fullBook?.getDisplayIntro() - GSON.toJson(bookMap, bookMap::class.java, writer) - } - writer.endArray() - writer.close() - } + exportBookshelfUseCase.exportToUri(uri, items).getOrThrow() }.onSuccess { _eventChannel.trySend(BaseRuleEvent.ShowSnackbar("导出成功")) }.onError { @@ -1137,17 +976,7 @@ class BookshelfViewModel( fun uploadBookshelf(items: List) { execute { - val json = withContext(Dispatchers.Default) { - val list = items.map { - val bookMap = hashMapOf() - bookMap["name"] = it.book.name - bookMap["author"] = it.book.author - val fullBook = appDb.bookDao.getBook(it.book.bookUrl) - bookMap["intro"] = fullBook?.getDisplayIntro() - bookMap - } - GSON.toJson(list) - } + val json = exportBookshelfUseCase.exportToJson(items).getOrThrow() uploadRepository.upload( fileName = "bookshelf.json", file = json, @@ -1172,27 +1001,8 @@ class BookshelfViewModel( fun exportBookshelf(items: List?, success: (file: File) -> Unit) { execute { - items?.let { - val path = "${context.filesDir}/books.json" - FileUtils.delete(path) - val file = FileUtils.createFileWithReplace(path) - FileOutputStream(file).use { out -> - val writer = JsonWriter(OutputStreamWriter(out, "UTF-8")) - writer.setIndent(" ") - writer.beginArray() - items.forEach { - val bookMap = hashMapOf() - bookMap["name"] = it.book.name - bookMap["author"] = it.book.author - val fullBook = appDb.bookDao.getBook(it.book.bookUrl) - bookMap["intro"] = fullBook?.getDisplayIntro() - GSON.toJson(bookMap, bookMap::class.java, writer) - } - writer.endArray() - writer.close() - } - file - } ?: throw NoStackTraceException("书籍不能为空") + items ?: throw NoStackTraceException("书籍不能为空") + exportBookshelfUseCase.exportToFile(items).getOrThrow() }.onSuccess { success(it) }.onError { @@ -1202,70 +1012,29 @@ class BookshelfViewModel( fun importBookshelf(str: String, groupId: Long) { execute { - val text = str.trim() - when { - text.isAbsUrl() -> { - okHttpClient.newCallResponseBody { - url(text) - }.decompressed().text().let { - importBookshelf(it, groupId) - } - } - - text.isJsonArray() -> { - importBookshelfByJson(text, groupId) - } - - else -> { - throw NoStackTraceException("格式不对") - } - } + importBookshelfUseCase.import(str, groupId) { + loadingTextFlow.value = it + }.getOrThrow() + }.onSuccess { + context.toastOnUi(R.string.success) }.onError { context.toastOnUi(it.localizedMessage ?: "ERROR") + }.onFinally { + loadingTextFlow.value = null } } fun importBookshelf(uri: Uri, groupId: Long) { execute { - uri.readText(context) + importBookshelfUseCase.import(uri, groupId) { + loadingTextFlow.value = it + }.getOrThrow() }.onSuccess { - importBookshelf(it, groupId) + context.toastOnUi(R.string.success) }.onError { context.toastOnUi(it.localizedMessage ?: "ERROR") - } - } - - private fun importBookshelfByJson(json: String, groupId: Long) { - loadingTextFlow.value = "导入中..." - execute { - val bookSourceParts = appDb.bookSourceDao.allEnabledPart - val semaphore = Semaphore(AppConfig.threadCount) - GSON.fromJsonArray>(json).getOrThrow().forEach { bookInfo -> - val name = bookInfo["name"] ?: "" - val author = bookInfo["author"] ?: "" - if (name.isEmpty() || appDb.bookDao.has(name, author)) { - return@forEach - } - semaphore.withPermit { - WebBook.preciseSearch( - this, bookSourceParts, name, author, - semaphore = semaphore - ).onSuccess { - val book = it.first - if (groupId > 0) { - book.group = groupId - } - book.save() - }.onError { e -> - context.toastOnUi(e.localizedMessage) - } - } - } - }.onError { - it.printOnDebug() }.onFinally { loadingTextFlow.value = null - context.toastOnUi(R.string.success) } } diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageConfig.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageConfig.kt new file mode 100644 index 000000000..9118a50cd --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageConfig.kt @@ -0,0 +1,26 @@ +package io.legado.app.ui.main.homepage + +import androidx.compose.runtime.State +import io.legado.app.constant.PreferKey +import io.legado.app.ui.config.prefStateDelegate + +/** + * 首页配置 — 仅保留无法入库或无需入库的顶层 UI 设置。 + * 模块级配置(开关、排序、自定义集)已迁移到 homepage_modules 表。 + */ +object HomepageConfig { + + /** + * 首页布局模式 0: 混合列表 1: 分源Tab + */ + private val _homepageLayoutMode = prefStateDelegate(PreferKey.homepageLayoutMode, 0) + var homepageLayoutMode by _homepageLayoutMode + val homepageLayoutModeState: State get() = _homepageLayoutMode.state + + /** + * 首页书源隐藏 + */ + private val _homepageSourceHidden = prefStateDelegate("homepageSourceHidden", "") + var homepageSourceHidden by _homepageSourceHidden + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageContract.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageContract.kt new file mode 100644 index 000000000..77dad74b8 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageContract.kt @@ -0,0 +1,76 @@ +package io.legado.app.ui.main.homepage + +import androidx.compose.runtime.Stable +import io.legado.app.data.entities.SearchBook +import io.legado.app.data.entities.rule.ExploreKind +import io.legado.app.domain.model.HomepageModuleType +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Stable +data class HomepageUiState( + val modules: ImmutableList = persistentListOf(), + val isManageMode: Boolean = false, + val isConfigMode: Boolean = false, + val isRefreshing: Boolean = false, +) + +@Stable +data class HomepageSourceManageUi( + val sourceUrl: String, + val sourceName: String, + val sourceGroup: String?, + val isSelected: Boolean = false, + val moduleCount: Int = 0, + val isCustomSet: Boolean = false, +) + +@Stable +data class HomepageModuleManageUi( + val id: String, + val sourceUrl: String, + val moduleKey: String, + val title: String, + val customSetTitle: String? = null, + val customSetId: String? = null, + val isVisible: Boolean, + val type: String = "card", + val url: String? = null, + val args: String? = null, + val layoutConfig: String? = null, + val originalTitle: String = "", +) + +@Stable +data class HomepageModuleUi( + val sourceUrl: String, + val setName: String, + val globalId: String, + val type: HomepageModuleType, + val title: String, + val exploreUrl: String? = null, + val customSetId: String? = null, + val layoutConfig: String? = null, + val state: ModuleLoadState = ModuleLoadState.Loading, + val config: Map = emptyMap() +) + +@Stable +sealed interface ModuleLoadState { + @Stable + data object Loading : ModuleLoadState + + @Stable + data class Loaded( + val books: ImmutableList, + val hasMore: Boolean = false, + val isLoadingMore: Boolean = false, + val page: Int = 1 + ) : ModuleLoadState + + @Stable + data class Buttons(val kinds: ImmutableList) : ModuleLoadState + + @Stable + data class Error(val message: String) : ModuleLoadState +} diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageEffect.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageEffect.kt new file mode 100644 index 000000000..5df9ae961 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageEffect.kt @@ -0,0 +1,17 @@ +package io.legado.app.ui.main.homepage + +sealed interface HomepageEffect { + data class NavigateToBookInfo( + val name: String?, + val author: String?, + val bookUrl: String, + ) : HomepageEffect + + data class NavigateToExploreShow( + val title: String?, + val sourceUrl: String, + val exploreUrl: String?, + ) : HomepageEffect + + data class ShowSnackbar(val message: String) : HomepageEffect +} diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageLayoutSheet.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageLayoutSheet.kt new file mode 100644 index 000000000..d25dab285 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageLayoutSheet.kt @@ -0,0 +1,51 @@ +package io.legado.app.ui.main.homepage + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet +import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem + +@Composable +fun HomepageLayoutSheet( + show: Boolean, + onDismissRequest: () -> Unit, + layoutMode: Int, + onLayoutModeChange: (Int) -> Unit, +) { + HomepageLayoutSheet( + data = if (show) Unit else null, + onDismissRequest = onDismissRequest, + layoutMode = layoutMode, + onLayoutModeChange = onLayoutModeChange, + ) +} + +@Composable +fun HomepageLayoutSheet( + data: T?, + onDismissRequest: () -> Unit, + layoutMode: Int, + onLayoutModeChange: (Int) -> Unit, +) { + AppModalBottomSheet( + data = data, + onDismissRequest = onDismissRequest, + title = "布局设置", + ) { + Column { + DropdownListSettingItem( + title = "首页布局模式", + selectedValue = layoutMode.toString(), + displayEntries = arrayOf("混合列表", "分源Tab"), + entryValues = arrayOf("0", "1"), + onValueChange = { onLayoutModeChange(it.toInt()) } + ) + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleManageSheet.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleManageSheet.kt new file mode 100644 index 000000000..ceb0bb2ab --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleManageSheet.kt @@ -0,0 +1,1089 @@ +package io.legado.app.ui.main.homepage + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.DriveFileRenameOutline +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.google.gson.JsonParser +import io.legado.app.domain.model.HomepageModuleType +import io.legado.app.domain.model.ModuleDef +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.widget.components.AppTextField +import io.legado.app.ui.widget.components.JsonConfigEditor +import io.legado.app.ui.widget.components.JsonKeyEditorConfig +import io.legado.app.ui.widget.components.JsonRawEditor +import io.legado.app.ui.widget.components.alert.AppAlertDialog +import io.legado.app.ui.widget.components.button.SecondaryButton +import io.legado.app.ui.widget.components.button.SmallIconButton +import io.legado.app.ui.widget.components.card.ReorderableSelectionItem +import io.legado.app.ui.widget.components.card.SelectionItemCard +import io.legado.app.ui.widget.components.divider.PillDivider +import io.legado.app.ui.widget.components.icon.AppIcon +import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu +import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem +import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet +import io.legado.app.ui.widget.components.settingItem.CompactClickableSettingItem +import io.legado.app.ui.widget.components.settingItem.CompactDropdownSettingItem +import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem +import io.legado.app.ui.widget.components.tabRow.AppTabRow +import io.legado.app.ui.widget.components.text.AppText +import io.legado.app.utils.move +import sh.calvin.reorderable.rememberReorderableLazyListState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomepageModuleManageSheet( + data: T?, + onDismissRequest: () -> Unit, + sets: List, + browseSources: List, + onToggleSet: (String, Boolean) -> Unit, + onGetModulesInSet: (String) -> List, + onGetSourceModules: (String, String?) -> List, + onToggleModule: (String, Boolean) -> Unit, + onJoinModule: (String, String?, ModuleDef) -> Unit, + onAddCustomModule: (String, String?, ModuleDef) -> Unit, + onAddButtonGroupFromKinds: (String, String?, String, List) -> Unit, + onGetExploreKinds: (String) -> List>, + onUpdateModule: (String, ModuleDef) -> Unit, + onDeleteModule: (String) -> Unit, + onReorderModules: (List) -> Unit, + onReorderSets: (List) -> Unit = {}, + onSetCustomSetTitle: (String, String?) -> Unit, + onCreateCustomSet: (String) -> Unit, + onRenameCustomSet: (String, String) -> Unit, + onDeleteCustomSet: (String) -> Unit, + onGetAllModulesGroupedBySource: () -> Map> = { emptyMap() }, + onGetSourceName: (String) -> String = { it }, + onAssignModuleToCustomSet: (String, String?) -> Unit = { _, _ -> }, +) { + var selectingSetUrl by remember(data != null) { mutableStateOf(null) } + var browsingSourceUrl by remember(data != null) { mutableStateOf(null) } + var showSourceBrowser by remember(data != null) { mutableStateOf(false) } + var renameSetId by remember(data != null) { mutableStateOf(null) } + var showCreateSetDialog by remember(data != null) { mutableStateOf(false) } + var addDialogPrefill by remember(data != null) { mutableStateOf(null) } + var editingModule by remember(data != null) { mutableStateOf(null) } + var deleteConfirmId by remember(data != null) { mutableStateOf(null) } + var deleteSetConfirmId by remember(data != null) { mutableStateOf(null) } + var customSetTitleEdit: Pair? by remember(data != null) { mutableStateOf(null) } + + var groupFilter by remember(data != null) { mutableStateOf(null) } + var browsingDetail by remember(data != null) { mutableStateOf(false) } + var browseTab by remember(data != null) { mutableIntStateOf(0) } + var browseModuleType by remember(data != null) { mutableStateOf("card") } + var selectedKindTitles by remember(data != null) { mutableStateOf>(emptySet()) } + var showCustomSetAddModules by remember(data != null) { mutableStateOf(false) } + var showAddButtonGroupDialog by remember(data != null) { mutableStateOf(false) } + var tempButtonGroupTitle by remember(data != null) { mutableStateOf("快捷操作") } + + val currentTargetSetId = remember(selectingSetUrl) { + selectingSetUrl?.let { HomepageViewModel.customSetIdFromUrl(it) } + } + + val allGroups = remember(browseSources) { + browseSources.flatMap { it.sourceGroup?.split(",") ?: emptyList() } + .filter { it.isNotBlank() }.distinct().sorted() + } + + val filteredBrowseSources = remember(browseSources, groupFilter) { + if (groupFilter == null) browseSources + else browseSources.filter { it.sourceGroup?.split(",")?.contains(groupFilter) == true } + } + + AppModalBottomSheet( + data = data, + onDismissRequest = { + onDismissRequest() + selectingSetUrl = null + browsingSourceUrl = null + showSourceBrowser = false + groupFilter = null + }, + title = when { + showCustomSetAddModules -> "添加模块" + browsingSourceUrl != null && browsingDetail -> + browseSources.find { it.sourceUrl == browsingSourceUrl }?.sourceName ?: "模块列表" + + showSourceBrowser || browsingSourceUrl != null -> "浏览书源模块" + selectingSetUrl != null && HomepageViewModel.isCustomSetUrl(selectingSetUrl!!) -> + (sets.find { it.sourceUrl == selectingSetUrl }?.sourceName ?: "集详情") + + else -> "首页模块管理" + }, + startAction = { + if (showCustomSetAddModules) { + SmallIconButton( + onClick = { showCustomSetAddModules = false }, + imageVector = Icons.AutoMirrored.Filled.ArrowBack + ) + } else if (browsingSourceUrl != null || showSourceBrowser) { + SmallIconButton( + onClick = { + if (browsingDetail) browsingDetail = false + else if (showSourceBrowser) showSourceBrowser = false + else browsingSourceUrl = null + }, + imageVector = Icons.AutoMirrored.Filled.ArrowBack + ) + } else if (selectingSetUrl != null) { + SmallIconButton( + onClick = { selectingSetUrl = null }, + imageVector = Icons.AutoMirrored.Filled.ArrowBack + ) + } + }, + endAction = { + if (browsingDetail && browseTab == 2 && browseModuleType == "buttonGroup" && selectedKindTitles.isNotEmpty()) { + SmallIconButton( + onClick = { showAddButtonGroupDialog = true }, + imageVector = Icons.Default.Check + ) + } else if ((showSourceBrowser || browsingSourceUrl != null) && !browsingDetail) { + var expanded by remember { mutableStateOf(false) } + Box { + SmallIconButton( + onClick = { expanded = true }, + imageVector = Icons.Default.FilterList + ) + RoundDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }) { + RoundDropdownMenuItem( + text = "全部分组", + onClick = { groupFilter = null; expanded = false }, + trailingIcon = if (groupFilter == null) { + { AppIcon(Icons.Default.Check, null, Modifier.size(18.dp)) } + } else null + ) + allGroups.forEach { group -> + RoundDropdownMenuItem( + text = group, + onClick = { groupFilter = group; expanded = false }, + trailingIcon = if (groupFilter == group) { + { AppIcon(Icons.Default.Check, null, Modifier.size(18.dp)) } + } else null + ) + } + } + } + } + } + ) { + val setUrl = selectingSetUrl + val browseUrl = browsingSourceUrl + val isBrowsing = showSourceBrowser || browseUrl != null + when { + browseUrl != null && browsingDetail -> { + // 三级:浏览书源的模块列表(已加入 / 书源模块 / 发现) + val displaySetUrl = + selectingSetUrl ?: HomepageViewModel.customSetUrl("src_$browseUrl") + val currentSetId = HomepageViewModel.customSetIdFromUrl(displaySetUrl) + val joinedModules = onGetModulesInSet(displaySetUrl) + + val standardModules = + joinedModules.filter { !HomepageViewModel.isInfinite(it.type, it.layoutConfig) } + val infiniteModules = + joinedModules.filter { HomepageViewModel.isInfinite(it.type, it.layoutConfig) } + + val joinedKeys = joinedModules.map { it.moduleKey }.toSet() + val sourceModules = onGetSourceModules(browseUrl, currentSetId) + val exploreKinds = remember(browseUrl) { onGetExploreKinds(browseUrl) } + + Column { + AppTabRow( + tabTitles = listOf("已加入", "书源模块", "发现"), + selectedTabIndex = browseTab, + onTabSelected = { browseTab = it } + ) + when (browseTab) { + 0 -> { + if (joinedModules.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + AppText("暂无已加入的模块") + } + } else { + var listData by remember(displaySetUrl) { + mutableStateOf( + standardModules + ) + } + val listState = rememberLazyListState() + val reorderableState = + rememberReorderableLazyListState(listState) { from, to -> + listData = listData.toMutableList().apply { + val fromIndex = (from.index - 1).coerceIn(0, lastIndex) + val toIndex = (to.index - 1).coerceIn(0, lastIndex) + move(fromIndex, toIndex) + } + } + LaunchedEffect(standardModules) { + if (!reorderableState.isAnyItemDragging) listData = + standardModules.distinctBy { it.id } + } + LaunchedEffect(reorderableState.isAnyItemDragging) { + if (!reorderableState.isAnyItemDragging) { + val orderedIds = + listData.map { it.id } + infiniteModules.map { it.id } + if (orderedIds != joinedModules.map { it.id }) { + onReorderModules(orderedIds) + } + } + } + LazyColumn( + state = listState, + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + item(key = "header_standard") { + AppText( + text = "标准模块 (可拖拽排序)", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding( + horizontal = 16.dp, + vertical = 4.dp + ) + ) + } + + items(listData, key = { it.id }) { module -> + ReorderableSelectionItem( + state = reorderableState, + key = module.id, + title = module.title, + subtitle = HomepageModuleType.fromKey(module.type).title, + isEnabled = module.isVisible, + containerColor = LegadoTheme.colorScheme.onSheetContent, + onEnabledChange = { enabled -> + onToggleModule(module.id, enabled) + listData = listData.map { + if (it.id == module.id) it.copy(isVisible = enabled) else it + } + }, + trailingAction = { + SmallIconButton( + onClick = { editingModule = module }, + imageVector = Icons.Default.Edit + ) + SmallIconButton( + onClick = { deleteConfirmId = module.id }, + imageVector = Icons.Default.Delete + ) + }, + modifier = Modifier.padding(horizontal = 4.dp) + ) + } + + if (infiniteModules.isNotEmpty()) { + item(key = "header_infinite") { + PillDivider( + modifier = Modifier.padding( + vertical = 8.dp, + horizontal = 16.dp + ) + ) + AppText( + text = "底栏无限模块", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding( + horizontal = 16.dp, + vertical = 4.dp + ) + ) + } + + items(infiniteModules, key = { it.id }) { module -> + val isEffective = + infiniteModules.firstOrNull() == module + SelectionItemCard( + title = module.title, + subtitle = HomepageModuleType.fromKey(module.type).title + if (isEffective) " · 当前生效" else " · 已被屏蔽", + isEnabled = module.isVisible, + containerColor = if (isEffective) LegadoTheme.colorScheme.surfaceContainerHigh else LegadoTheme.colorScheme.onSheetContent, + onEnabledChange = { onToggleModule(module.id, it) }, + trailingAction = { + SmallIconButton( + onClick = { editingModule = module }, + imageVector = Icons.Default.Edit + ) + SmallIconButton( + onClick = { deleteConfirmId = module.id }, + imageVector = Icons.Default.Delete + ) + }, + modifier = Modifier.padding(horizontal = 4.dp) + ) + } + } + } + } + } + + 1 -> { + if (sourceModules.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + AppText("该书源的 homepageModules JSON 为空") + } + } else { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items( + sourceModules.distinctBy { it.id }, + key = { it.id }) { module -> + val isJoined = joinedKeys.contains(module.moduleKey) + SelectionItemCard( + title = module.title, + subtitle = module.moduleKey + if (isJoined) " · 已加入" else "", + containerColor = LegadoTheme.colorScheme.onSheetContent, + isSelected = isJoined, + inSelectionMode = true, + onToggleSelection = { + if (!isJoined) onJoinModule( + browseUrl, currentTargetSetId, ModuleDef( + key = module.moduleKey, + type = module.type, + title = module.title, + sourceUrl = browseUrl, + ) + ) + }, + modifier = Modifier.padding(horizontal = 4.dp) + ) + } + } + } + } + + 2 -> { + val isButtonGroup = browseModuleType == "buttonGroup" + val selectableKinds = exploreKinds + Column { + val typeList = remember { + HomepageModuleType.entries.filter { it != HomepageModuleType.Unknown } + } + CompactDropdownSettingItem( + title = "模块类型", + selectedValue = browseModuleType, + displayEntries = typeList.map { it.title }.toTypedArray(), + entryValues = typeList.map { it.key }.toTypedArray(), + onValueChange = { + browseModuleType = it; selectedKindTitles = emptySet() + } + ) + if (selectableKinds.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + AppText( + "该书源暂无发现项", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + AppText( + "选择项", + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding( + horizontal = 16.dp, + vertical = 4.dp + ) + ) + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items( + selectableKinds.distinctBy { it.first + it.second }, + key = { it.first + it.second }) { (kindTitle, kindUrl) -> + if (isButtonGroup) { + val isSelected = kindTitle in selectedKindTitles + SelectionItemCard( + title = kindTitle, + subtitle = kindUrl.take(60), + containerColor = LegadoTheme.colorScheme.onSheetContent, + isSelected = isSelected, + inSelectionMode = true, + onToggleSelection = { + selectedKindTitles = + if (isSelected) selectedKindTitles - kindTitle + else selectedKindTitles + kindTitle + }, + modifier = Modifier.padding(horizontal = 4.dp) + ) + } else { + val isJoined = joinedKeys.contains(kindTitle) + SelectionItemCard( + title = kindTitle, + subtitle = kindUrl.take(60) + if (isJoined) " · 已加入" else "", + containerColor = LegadoTheme.colorScheme.onSheetContent, + isSelected = isJoined, + inSelectionMode = true, + onToggleSelection = { + if (!isJoined) addDialogPrefill = + AddDialogPrefill( + kindTitle, + kindUrl, + browseModuleType + ) + }, + modifier = Modifier.padding(horizontal = 4.dp) + ) + } + } + } + } + Spacer(modifier = Modifier.height(12.dp)) + SecondaryButton( + text = "+ 手动添加", + onClick = { + addDialogPrefill = AddDialogPrefill(type = browseModuleType) + }, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } + } + + showCustomSetAddModules -> { + // 自定义集添加模块 + val currentSetUrl = selectingSetUrl!! + val currentSetId = HomepageViewModel.customSetIdFromUrl(currentSetUrl) + val initialJoined = onGetModulesInSet(currentSetUrl) + .associateBy({ it.moduleKey }, { it.id }) + var joinedInCurrent by remember(initialJoined) { mutableStateOf(initialJoined) } + + val grouped = onGetAllModulesGroupedBySource() + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + grouped.forEach { (sourceUrl, modules) -> + item(key = "header_$sourceUrl") { + AppText( + text = onGetSourceName(sourceUrl), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + items(modules, key = { it.sourceUrl + it.moduleKey }) { module -> + val instanceIdInCurrentSet = joinedInCurrent[module.moduleKey] + val inCurrentSet = instanceIdInCurrentSet != null + SelectionItemCard( + title = module.title, + subtitle = module.moduleKey, + containerColor = LegadoTheme.colorScheme.onSheetContent, + isSelected = inCurrentSet, + inSelectionMode = true, + onToggleSelection = { + if (inCurrentSet) { + onDeleteModule(instanceIdInCurrentSet!!) + joinedInCurrent = joinedInCurrent - module.moduleKey + } else { + onAssignModuleToCustomSet(module.id, currentSetId) + joinedInCurrent = + joinedInCurrent + (module.moduleKey to "temp_${module.id}") + } + }, + modifier = Modifier.padding(horizontal = 4.dp) + ) + } + } + } + } + + isBrowsing -> { + // 二级:浏览书源列表 + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(filteredBrowseSources, key = { it.sourceUrl }) { source -> + val moduleCount = onGetSourceModules(source.sourceUrl, null).size + SelectionItemCard( + title = source.sourceName, + subtitle = "$moduleCount 个模块", + containerColor = LegadoTheme.colorScheme.onSheetContent, + onToggleSelection = { + browsingSourceUrl = source.sourceUrl + browsingDetail = true + }, + modifier = Modifier.padding(horizontal = 4.dp) + ) + } + } + } + + setUrl != null && HomepageViewModel.isCustomSetUrl(setUrl) -> { + // 二级:集详情 + val setId = HomepageViewModel.customSetIdFromUrl(setUrl) + val modules = onGetModulesInSet(setUrl) + + val standardModules = + modules.filter { !HomepageViewModel.isInfinite(it.type, it.layoutConfig) } + val infiniteModules = + modules.filter { HomepageViewModel.isInfinite(it.type, it.layoutConfig) } + + if (modules.isEmpty()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + AppText("暂无模块") + SecondaryButton( + text = "浏览书源模块添加", + onClick = { + if (setId.startsWith("src_")) { + browsingSourceUrl = setId.removePrefix("src_") + browsingDetail = true + } else { + showCustomSetAddModules = true + } + } + ) + } + } else { + var listData by remember(setUrl) { mutableStateOf(standardModules) } + val listState = rememberLazyListState() + val reorderableState = rememberReorderableLazyListState(listState) { from, to -> + listData = listData.toMutableList().apply { + val fromIndex = (from.index - 1).coerceIn(0, lastIndex) + val toIndex = (to.index - 1).coerceIn(0, lastIndex) + move(fromIndex, toIndex) + } + } + + LaunchedEffect(standardModules) { + if (!reorderableState.isAnyItemDragging) listData = + standardModules.distinctBy { it.id } + } + LaunchedEffect(reorderableState.isAnyItemDragging) { + if (!reorderableState.isAnyItemDragging) { + val orderedIds = listData.map { it.id } + infiniteModules.map { it.id } + if (orderedIds != modules.map { it.id }) onReorderModules(orderedIds) + } + } + + LazyColumn( + state = listState, + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (listData.isNotEmpty()) { + item(key = "header_std_detail") { + AppText( + text = "标准模块", + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + ) + } + items(listData, key = { it.id }) { module -> + ReorderableSelectionItem( + state = reorderableState, + key = module.id, + title = module.title, + subtitle = HomepageModuleType.fromKey(module.type).title, + isEnabled = module.isVisible, + containerColor = LegadoTheme.colorScheme.onSheetContent, + onEnabledChange = { enabled -> + onToggleModule(module.id, enabled) + listData = listData.map { + if (it.id == module.id) it.copy(isVisible = enabled) else it + } + }, + trailingAction = { + SmallIconButton( + onClick = { editingModule = module }, + imageVector = Icons.Default.Edit + ) + SmallIconButton( + onClick = { deleteConfirmId = module.id }, + imageVector = Icons.Default.Delete + ) + }, + modifier = Modifier.padding(horizontal = 4.dp) + ) + } + } + + if (infiniteModules.isNotEmpty()) { + item(key = "header_inf_detail") { + HorizontalDivider( + modifier = Modifier.padding( + vertical = 8.dp, + horizontal = 16.dp + ) + ) + AppText( + text = "无限模块槽位", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + ) + } + items(infiniteModules, key = { it.id }) { module -> + val isEffective = infiniteModules.firstOrNull() == module + SelectionItemCard( + title = module.title, + subtitle = HomepageModuleType.fromKey(module.type).title, + isEnabled = module.isVisible, + containerColor = if (isEffective) LegadoTheme.colorScheme.surfaceContainerHigh else LegadoTheme.colorScheme.onSheetContent, + onEnabledChange = { onToggleModule(module.id, it) }, + trailingAction = { + SmallIconButton( + onClick = { editingModule = module }, + imageVector = Icons.Default.Edit + ) + SmallIconButton( + onClick = { deleteConfirmId = module.id }, + imageVector = Icons.Default.Delete + ) + }, + modifier = Modifier.padding(horizontal = 4.dp) + ) + } + } + + item(key = "browse_from_set") { + SecondaryButton( + text = "浏览书源模块", + onClick = { + if (setId.startsWith("src_")) { + browsingSourceUrl = setId.removePrefix("src_") + browsingDetail = true + } else { + showCustomSetAddModules = true + } + }, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } + + else -> { + // 一级:集列表 + var localSets by remember(data != null) { mutableStateOf(sets) } + val setsListState = rememberLazyListState() + val setsReorderableState = + rememberReorderableLazyListState(setsListState) { from, to -> + localSets = localSets.toMutableList().apply { + val fromIndex = from.index.coerceIn(0, lastIndex) + val toIndex = to.index.coerceIn(0, lastIndex) + move(fromIndex, toIndex) + } + } + + LaunchedEffect(sets) { + if (!setsReorderableState.isAnyItemDragging) localSets = sets + } + LaunchedEffect(setsReorderableState.isAnyItemDragging) { + if (!setsReorderableState.isAnyItemDragging) { + val orderedUrls = localSets.map { it.sourceUrl } + if (orderedUrls != sets.map { it.sourceUrl }) onReorderSets(orderedUrls) + } + } + + LazyColumn( + state = setsListState, + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(localSets, key = { it.sourceUrl }) { set -> + ReorderableSelectionItem( + state = setsReorderableState, + key = set.sourceUrl, + title = set.sourceName, + subtitle = "${set.moduleCount} 个模块", + containerColor = LegadoTheme.colorScheme.onSheetContent, + isEnabled = set.isSelected, + onToggleSelection = { selectingSetUrl = set.sourceUrl }, + onEnabledChange = { enabled -> + onToggleSet(set.sourceUrl, enabled) + localSets = localSets.map { + if (it.sourceUrl == set.sourceUrl) it.copy(isSelected = enabled) else it + } + }, + trailingAction = { + SmallIconButton( + onClick = { renameSetId = set.sourceUrl }, + imageVector = Icons.Default.DriveFileRenameOutline + ) + SmallIconButton( + onClick = { deleteSetConfirmId = set.sourceUrl }, + imageVector = Icons.Default.Delete + ) + }, + modifier = Modifier.padding(horizontal = 4.dp) + ) + } + item(key = "create_set") { + SecondaryButton( + text = "+ 新建自定义集", + onClick = { showCreateSetDialog = true }, + modifier = Modifier.fillMaxWidth() + ) + } + item(key = "browse_sources") { + SecondaryButton( + text = "浏览书源模块", + onClick = { showSourceBrowser = true }, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } + } + + var tempName by remember { mutableStateOf("") } + AppAlertDialog( + data = renameSetId, + onDismissRequest = { renameSetId = null }, + title = "重命名自定义集", + content = { setId -> + val currentName = + remember(setId) { sets.find { it.sourceUrl == setId }?.sourceName ?: "" } + LaunchedEffect(setId) { tempName = currentName } + AppTextField( + value = tempName, + onValueChange = { tempName = it }, + label = "名称", + modifier = Modifier.fillMaxWidth() + ) + }, + onConfirm = { setId -> + if (tempName.isNotBlank()) onRenameCustomSet( + HomepageViewModel.customSetIdFromUrl(setId), + tempName + ) + renameSetId = null + }, + confirmText = "确定", + dismissText = "取消", + onDismiss = { renameSetId = null } + ) + + AppAlertDialog( + data = if (showCreateSetDialog) Unit else null, + onDismissRequest = { showCreateSetDialog = false }, + title = "新建自定义集", + content = { + LaunchedEffect(Unit) { tempName = "" } + AppTextField( + value = tempName, + onValueChange = { tempName = it }, + label = "名称", + modifier = Modifier.fillMaxWidth() + ) + }, + onConfirm = { + if (tempName.isNotBlank()) onCreateCustomSet(tempName) + showCreateSetDialog = false + }, + confirmText = "确定", + dismissText = "取消", + onDismiss = { showCreateSetDialog = false } + ) + + AppAlertDialog( + data = deleteSetConfirmId, + onDismissRequest = { deleteSetConfirmId = null }, + title = "删除自定义集", + text = "确定要删除该集及其包含的所有模块副本吗?", + onConfirm = { setId -> + onDeleteCustomSet(HomepageViewModel.customSetIdFromUrl(setId)) + deleteSetConfirmId = null + }, + confirmText = "删除", + dismissText = "取消", + onDismiss = { deleteSetConfirmId = null } + ) + + AppAlertDialog( + data = deleteConfirmId, + onDismissRequest = { deleteConfirmId = null }, + title = "移除模块", + text = "确定要从当前集中移除该模块吗?", + onConfirm = { id -> + onDeleteModule(id) + deleteConfirmId = null + }, + confirmText = "移除", + dismissText = "取消", + onDismiss = { deleteConfirmId = null } + ) + + AddCustomModuleDialog( + data = addDialogPrefill, + sourceUrl = browsingSourceUrl ?: "", + targetSetId = currentTargetSetId ?: "", + prefillTitle = addDialogPrefill?.title ?: "", + prefillUrl = addDialogPrefill?.url ?: "", + prefillType = addDialogPrefill?.type ?: "card", + onDismissRequest = { addDialogPrefill = null }, + onConfirm = { def -> + onAddCustomModule(browsingSourceUrl!!, currentTargetSetId, def) + addDialogPrefill = null + } + ) + + AddCustomModuleDialog( + data = editingModule, + sourceUrl = editingModule?.sourceUrl ?: "", + targetSetId = editingModule?.customSetId ?: "", + prefillTitle = editingModule?.title ?: "", + prefillUrl = editingModule?.url ?: "", + prefillType = editingModule?.type ?: "card", + prefillArgs = editingModule?.args ?: "", + prefillLayoutConfig = editingModule?.layoutConfig ?: "", + onDismissRequest = { editingModule = null }, + onConfirm = { def -> + onUpdateModule(editingModule!!.id, def) + editingModule = null + } + ) + + var titleState by remember { mutableStateOf("") } + AppAlertDialog( + data = if (showAddButtonGroupDialog) Unit else null, + onDismissRequest = { showAddButtonGroupDialog = false }, + title = "添加按钮组", + content = { + LaunchedEffect(Unit) { tempButtonGroupTitle = "快捷操作" } + AppTextField( + value = tempButtonGroupTitle, + onValueChange = { tempButtonGroupTitle = it }, + label = "模块标题", + modifier = Modifier.fillMaxWidth() + ) + }, + onConfirm = { + onAddButtonGroupFromKinds( + browsingSourceUrl!!, + currentTargetSetId, + tempButtonGroupTitle, + selectedKindTitles.toList() + ) + selectedKindTitles = emptySet() + showAddButtonGroupDialog = false + }, + confirmText = "确定", + dismissText = "取消", + onDismiss = { showAddButtonGroupDialog = false } + ) + + AppAlertDialog( + data = customSetTitleEdit, + onDismissRequest = { customSetTitleEdit = null }, + title = "自定义标题", + content = { (_, title) -> + LaunchedEffect(title) { titleState = title } + AppTextField( + value = titleState, + onValueChange = { titleState = it }, + label = "标题", + modifier = Modifier.fillMaxWidth() + ) + }, + onConfirm = { (id, _) -> + onSetCustomSetTitle(id, titleState.takeIf { it.isNotBlank() }) + customSetTitleEdit = null + }, + confirmText = "确定", + dismissText = "取消", + onDismiss = { customSetTitleEdit = null } + ) +} + +data class AddDialogPrefill( + val title: String = "", + val url: String = "", + val type: String = "card" +) + +@Composable +fun AddCustomModuleDialog( + data: T?, + sourceUrl: String = "", + targetSetId: String = "", + prefillTitle: String = "", + prefillUrl: String = "", + prefillType: String = "card", + prefillArgs: String = "", + prefillLayoutConfig: String = "", + onDismissRequest: () -> Unit, + onConfirm: (ModuleDef) -> Unit, +) { + var title by remember(data) { mutableStateOf(prefillTitle) } + var url by remember(data) { mutableStateOf(prefillUrl) } + var type by remember(data) { mutableStateOf(prefillType) } + var args by remember(data) { mutableStateOf(prefillArgs) } + var layoutConfig by remember(data) { mutableStateOf(prefillLayoutConfig) } + var showRawLayoutConfig by remember(data) { mutableStateOf(false) } + + val layoutKeyConfigs = remember { + mapOf( + "fullWidth" to JsonKeyEditorConfig.Switch, + "showTitle" to JsonKeyEditorConfig.Switch, + "showMore" to JsonKeyEditorConfig.Switch, + "isInfinite" to JsonKeyEditorConfig.Switch, + "aspectRatio" to JsonKeyEditorConfig.Dropdown( + displayEntries = arrayOf("默认", "1:1", "3:4", "2:3", "16:9"), + entryValues = arrayOf("", "1:1", "3:4", "2:3", "16:9") + ) + ) + } + + val hasVisualizableKeys = remember(layoutConfig) { + runCatching { + val jsonObject = JsonParser.parseString(layoutConfig).asJsonObject + jsonObject.keySet().any { key -> + key == "columns" || key == "rows" || layoutKeyConfigs.containsKey(key) + } + }.getOrElse { false } + } + + AppAlertDialog( + data = data, + onDismissRequest = onDismissRequest, + title = if (prefillTitle.isEmpty()) "添加模块" else "编辑模块", + content = { + Column( + modifier = Modifier + .fillMaxWidth() + .height(400.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + AppTextField( + value = title, + onValueChange = { title = it }, + label = "标题", + modifier = Modifier.fillMaxWidth() + ) + AppTextField( + value = url, + onValueChange = { url = it }, + label = "URL", + modifier = Modifier.fillMaxWidth() + ) + val typeList = remember { + HomepageModuleType.entries.filter { it != HomepageModuleType.Unknown } + } + DropdownListSettingItem( + title = "类型", + selectedValue = type, + displayEntries = typeList.map { it.title }.toTypedArray(), + entryValues = typeList.map { it.key }.toTypedArray(), + onValueChange = { type = it } + ) + AppTextField( + value = args, + onValueChange = { args = it }, + label = "Args (JSON)", + modifier = Modifier.fillMaxWidth() + ) + AppText( + text = "布局配置", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 16.dp, bottom = 4.dp) + ) + if (hasVisualizableKeys) { + JsonConfigEditor( + jsonString = layoutConfig, + onJsonStringChange = { layoutConfig = it }, + keyConfigs = layoutKeyConfigs, + modifier = Modifier.fillMaxWidth() + ) + CompactClickableSettingItem( + title = "编辑原始 JSON (LayoutConfig)", + onClick = { showRawLayoutConfig = !showRawLayoutConfig } + ) + if (showRawLayoutConfig) { + JsonRawEditor( + value = layoutConfig, + onValueChange = { layoutConfig = it }, + label = "LayoutConfig (JSON) RAW", + modifier = Modifier.fillMaxWidth() + ) + } + } else { + JsonRawEditor( + value = layoutConfig, + onValueChange = { layoutConfig = it }, + label = "LayoutConfig (JSON)", + modifier = Modifier.fillMaxWidth() + ) + } + } + }, + onConfirm = { + onConfirm( + ModuleDef( + title = title, + url = url, + type = type, + args = args, + layoutConfig = layoutConfig + ) + ) + }, + confirmText = "确定", + dismissText = "取消", + onDismiss = onDismissRequest + ) +} diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt new file mode 100644 index 000000000..eabaa9403 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt @@ -0,0 +1,617 @@ +package io.legado.app.ui.main.homepage + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan +import androidx.compose.foundation.lazy.staggeredgrid.items +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.GridView +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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 +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.legado.app.domain.model.HomepageModuleType +import io.legado.app.ui.main.bookCoverSharedElementKey +import io.legado.app.ui.main.homepage.modules.BannerModule +import io.legado.app.ui.main.homepage.modules.ButtonGroupModule +import io.legado.app.ui.main.homepage.modules.CardModule +import io.legado.app.ui.main.homepage.modules.GridModule +import io.legado.app.ui.main.homepage.modules.GridRankingModule +import io.legado.app.ui.main.homepage.modules.RankingModule +import io.legado.app.ui.main.homepage.modules.WaterfallItem +import io.legado.app.ui.widget.components.AppPullToRefresh +import io.legado.app.ui.widget.components.AppScaffold +import io.legado.app.ui.widget.components.alert.AppAlertDialog +import io.legado.app.ui.widget.components.book.SearchBookGridItem +import io.legado.app.ui.widget.components.button.SecondaryButton +import io.legado.app.ui.widget.components.button.SmallTonalIconButton +import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator +import io.legado.app.ui.widget.components.tabRow.AppTabRow +import io.legado.app.ui.widget.components.text.AppText +import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar +import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults +import io.legado.app.ui.widget.components.topbar.TopBarActionButton +import io.legado.app.utils.sendToClip +import kotlinx.coroutines.launch +import org.koin.androidx.compose.koinViewModel + +@OptIn( + ExperimentalMaterial3Api::class, + ExperimentalMaterial3ExpressiveApi::class, + ExperimentalSharedTransitionApi::class +) +@Composable +fun HomepageScreen( + viewModel: HomepageViewModel = koinViewModel(), + onBookClick: (name: String?, author: String?, bookUrl: String) -> Unit, + onModuleHeaderClick: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val allSets by viewModel.setsFlow.collectAsStateWithLifecycle() + val browseSources by viewModel.browseSourcesFlow.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + val context = LocalContext.current + var errorMsg by remember { mutableStateOf(null) } + + val layoutMode = HomepageConfig.homepageLayoutModeState.value + + val selectedSets = remember(allSets) { allSets.filter { it.isSelected } } + val pagerState = rememberPagerState(pageCount = { + if (layoutMode == 1) selectedSets.size.coerceAtLeast(1) else 1 + }) + + val mixedGridState = rememberLazyStaggeredGridState() + val currentTitle by remember( + layoutMode, + pagerState.currentPage, + selectedSets, + uiState.modules + ) { + derivedStateOf { + if (layoutMode == 1) { + "首页" + } else { + val firstHeader = mixedGridState.layoutInfo.visibleItemsInfo.firstOrNull { + (it.key as? String)?.startsWith("header_") == true + } + if (firstHeader != null) { + val id = (firstHeader.key as? String).orEmpty().substringAfter("header_", "") + uiState.modules.find { it.globalId == id }?.setName ?: "首页" + } else { + "首页" + } + } + } + } + + BackHandler(enabled = uiState.isManageMode || uiState.isConfigMode) { + if (uiState.isManageMode) viewModel.toggleManageMode() + else viewModel.toggleConfigMode() + } + + LaunchedEffect(viewModel) { + viewModel.effects.collect { effect -> + when (effect) { + is HomepageEffect.NavigateToBookInfo -> + onBookClick(effect.name, effect.author, effect.bookUrl) + + is HomepageEffect.NavigateToExploreShow -> + onModuleHeaderClick(effect.title, effect.sourceUrl, effect.exploreUrl) + + is HomepageEffect.ShowSnackbar -> {} + } + } + } + + val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior() + AppScaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + contentWindowInsets = WindowInsets(0), + topBar = { + GlassMediumFlexibleTopAppBar( + title = currentTitle, + scrollBehavior = scrollBehavior, + actions = { + TopBarActionButton( + onClick = { viewModel.toggleConfigMode() }, + imageVector = Icons.Default.GridView, + contentDescription = "Layout Settings", + ) + TopBarActionButton( + onClick = { viewModel.toggleManageMode() }, + imageVector = Icons.Default.Settings, + contentDescription = "Manage Modules", + ) + }, + bottomContent = { + if (layoutMode == 1 && selectedSets.isNotEmpty()) { + AppTabRow( + tabTitles = selectedSets.map { it.sourceName }, + selectedTabIndex = pagerState.currentPage, + onTabSelected = { index -> + scope.launch { pagerState.animateScrollToPage(index) } + } + ) + } + } + ) + }, + ) { paddingValues -> + AppPullToRefresh( + isRefreshing = uiState.isRefreshing, + onRefresh = { viewModel.onRefresh() }, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + if (layoutMode == 0) { + ModuleList( + modules = uiState.modules, + viewModel = viewModel, + gridState = mixedGridState, + modifier = Modifier.fillMaxSize(), + onErrorClick = { errorMsg = it }, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) + } else { + if (selectedSets.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + AppText("未选择任何书源集") + } + } else { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + key = { index -> selectedSets.getOrNull(index)?.sourceUrl ?: index } + ) { pageIndex -> + val source = selectedSets.getOrNull(pageIndex) + val sourceModules = remember(uiState.modules, source) { + uiState.modules.filter { module -> + if (source?.isCustomSet == true) { + val setId = + HomepageViewModel.customSetIdFromUrl(source.sourceUrl) + module.customSetId == setId + } else { + module.sourceUrl == source?.sourceUrl + } + } + } + ModuleList( + modules = sourceModules, + viewModel = viewModel, + modifier = Modifier.fillMaxSize(), + onErrorClick = { errorMsg = it }, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) + } + } + } + } + + AppAlertDialog( + data = errorMsg, + onDismissRequest = { errorMsg = null }, + title = "模块错误", + confirmText = "复制", + onConfirm = { + context.sendToClip(it) + errorMsg = null + }, + dismissText = "关闭", + onDismiss = { errorMsg = null } + ) + + HomepageModuleManageSheet( + data = if (uiState.isManageMode) Unit else null, + onDismissRequest = { viewModel.toggleManageMode() }, + sets = allSets, + browseSources = browseSources, + onToggleSet = { url, isEnabled -> viewModel.toggleSourceFilter(url, isEnabled) }, + onGetModulesInSet = { viewModel.getJoinedModules(it) }, + onGetSourceModules = { url, setId -> viewModel.getSourceModules(url, setId) }, + onToggleModule = { id, visible -> viewModel.setModuleVisible(id, visible) }, + onJoinModule = { sourceUrl, targetSetId, def -> + viewModel.joinModule( + sourceUrl, + targetSetId, + def + ) + }, + onAddCustomModule = { sourceUrl, targetSetId, def -> + viewModel.addCustomModule( + sourceUrl, + targetSetId, + def + ) + }, + onAddButtonGroupFromKinds = { sourceUrl, targetSetId, title, kinds -> + viewModel.addButtonGroupFromKinds( + sourceUrl, + targetSetId, + title, + kinds + ) + }, + onGetExploreKinds = { viewModel.getSourceExploreKinds(it) }, + onUpdateModule = { globalId, def -> viewModel.updateModule(globalId, def) }, + onDeleteModule = { viewModel.deleteModule(it) }, + onReorderModules = { ids -> viewModel.reorderJoinedModules(ids) }, + onReorderSets = { urls -> viewModel.reorderCustomSets(urls) }, + onSetCustomSetTitle = { id, title -> viewModel.setModuleCustomSetTitle(id, title) }, + onCreateCustomSet = { viewModel.createCustomSet(it) }, + onRenameCustomSet = { id, name -> viewModel.renameCustomSet(id, name) }, + onDeleteCustomSet = { viewModel.deleteCustomSet(it) }, + onGetAllModulesGroupedBySource = { viewModel.getAllModulesGroupedBySource() }, + onGetSourceName = { viewModel.getSourceName(it) }, + onAssignModuleToCustomSet = { id, setId -> + viewModel.assignModuleToCustomSet( + id, + setId + ) + } + ) + + HomepageLayoutSheet( + data = if (uiState.isConfigMode) Unit else null, + onDismissRequest = { viewModel.toggleConfigMode() }, + layoutMode = layoutMode, + onLayoutModeChange = { viewModel.setLayoutMode(it) }, + ) + } +} + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +private fun ModuleList( + modules: List, + viewModel: HomepageViewModel, + modifier: Modifier = Modifier, + gridState: LazyStaggeredGridState = rememberLazyStaggeredGridState(), + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, + onErrorClick: (String) -> Unit +) { + if (modules.isEmpty()) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + AppText("请在书源中添加首页模块定义") + } + } else { + // 1. 过滤和重排模块:每个集只能有一个无限流模块,且必须在最下面 + val processedModules = remember(modules) { + fun isInfinite(m: HomepageModuleUi): Boolean { + return m.type == HomepageModuleType.Waterfall || + m.type == HomepageModuleType.InfiniteGrid + } + + val infinite = modules.firstOrNull { isInfinite(it) } + val others = modules.filter { !isInfinite(it) } + if (infinite != null) others + infinite else others + } + + val gridColumns = remember(processedModules) { + val infiniteModule = processedModules.find { m -> + m.type == HomepageModuleType.Waterfall || + m.type == HomepageModuleType.InfiniteGrid + } + infiniteModule?.config?.get("layout_columns")?.toIntOrNull() ?: 2 + } + + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Fixed(gridColumns), + state = gridState, + modifier = modifier, + verticalItemSpacing = 16.dp, + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 80.dp), + ) { + processedModules.forEach { moduleUi -> + // 1. 头部 (全宽) + item(key = "header_${moduleUi.globalId}", span = StaggeredGridItemSpan.FullLine) { + ModuleHeader( + title = moduleUi.title, + onNavigate = { + viewModel.onModuleHeaderClick( + moduleUi.sourceUrl, + moduleUi.exploreUrl, + moduleUi.title, + ) + }, + ) + } + + // 2. 内容正文 + when (val state = moduleUi.state) { + is ModuleLoadState.Loading -> { + item( + key = "loading_${moduleUi.globalId}", + span = StaggeredGridItemSpan.FullLine + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(120.dp), + contentAlignment = Alignment.Center, + ) { + AppCircularProgressIndicator() + } + } + } + + is ModuleLoadState.Error -> { + item( + key = "error_${moduleUi.globalId}", + span = StaggeredGridItemSpan.FullLine + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .height(80.dp) + .clickable { onErrorClick(state.message) }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + AppText( + text = state.message, + color = MaterialTheme.colorScheme.error, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 16.dp) + ) + Spacer(modifier = Modifier.height(4.dp)) + SecondaryButton( + text = "重试", + onClick = { + viewModel.retryModule(moduleUi.globalId) + } + ) + } + } + } + + is ModuleLoadState.Buttons -> { + item( + key = "buttons_${moduleUi.globalId}", + span = StaggeredGridItemSpan.FullLine + ) { + ButtonGroupModule( + kinds = state.kinds, + sourceUrl = moduleUi.sourceUrl, + globalId = moduleUi.globalId, + viewModel = viewModel, + modifier = Modifier.fillMaxWidth(), + layoutConfig = moduleUi.layoutConfig + ) + } + } + + is ModuleLoadState.Loaded -> { + val config = moduleUi.config + when (moduleUi.type) { + HomepageModuleType.Waterfall -> { + items( + state.books, + key = { "wf_${moduleUi.globalId}_${it.bookUrl}" }) { book -> + WaterfallItem( + book = book, + onClick = { viewModel.onBookClick(book) }, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) + } + + if (state.hasMore) { + item( + key = "wf_more_${moduleUi.globalId}", + span = StaggeredGridItemSpan.FullLine + ) { + LaunchedEffect(state.books.size) { + viewModel.loadMoreModule(moduleUi.globalId) + } + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + AppCircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } + } + } + + HomepageModuleType.InfiniteGrid -> { + items( + state.books, + key = { "inf_grid_${moduleUi.globalId}_${it.bookUrl}" }) { book -> + SearchBookGridItem( + book = book, + shelfState = io.legado.app.domain.model.BookShelfState.NOT_IN_SHELF, + onClick = { viewModel.onBookClick(book) }, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + sharedCoverKey = bookCoverSharedElementKey(book.bookUrl) + ) + } + + if (state.hasMore) { + item( + key = "inf_grid_more_${moduleUi.globalId}", + span = StaggeredGridItemSpan.FullLine + ) { + LaunchedEffect(state.books.size) { + viewModel.loadMoreModule(moduleUi.globalId) + } + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + AppCircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } + } + } + + HomepageModuleType.Grid -> { + val rows = config["layout_rows"]?.toIntOrNull() ?: 2 + val columns = config["layout_columns"]?.toIntOrNull() ?: 3 + item( + key = "content_${moduleUi.globalId}", + span = StaggeredGridItemSpan.FullLine + ) { + GridModule( + books = state.books, + onClick = { viewModel.onBookClick(it) }, + modifier = Modifier.fillMaxWidth(), + columns = columns, + maxRows = rows, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) + } + } + + else -> { + when (moduleUi.type) { + HomepageModuleType.Banner -> { + item( + key = "content_${moduleUi.globalId}", + span = StaggeredGridItemSpan.FullLine + ) { + BannerModule( + books = state.books, + onClick = { viewModel.onBookClick(it) }, + modifier = Modifier.fillMaxWidth(), + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) + } + } + + HomepageModuleType.Ranking -> { + item( + key = "content_${moduleUi.globalId}", + span = StaggeredGridItemSpan.FullLine + ) { + RankingModule( + books = state.books, + onClick = { viewModel.onBookClick(it) }, + modifier = Modifier.fillMaxWidth(), + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) + } + } + + HomepageModuleType.GridRanking -> { + item( + key = "content_${moduleUi.globalId}", + span = StaggeredGridItemSpan.FullLine + ) { + GridRankingModule( + books = state.books, + onClick = { viewModel.onBookClick(it) }, + modifier = Modifier.fillMaxWidth(), + rows = config["layout_rows"]?.toIntOrNull() ?: 4, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) + } + } + + HomepageModuleType.Card -> { + item( + key = "content_${moduleUi.globalId}", + span = StaggeredGridItemSpan.FullLine + ) { + CardModule( + books = state.books, + onClick = { viewModel.onBookClick(it) }, + modifier = Modifier.fillMaxWidth(), + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) + } + } + + else -> {} + } + } + } + } + } + } + } + } +} + +@Composable +private fun ModuleHeader( + title: String, + onNavigate: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AppText( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + SmallTonalIconButton( + onClick = onNavigate, + imageVector = Icons.AutoMirrored.Filled.ArrowForward + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageSourceSelectSheet.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageSourceSelectSheet.kt new file mode 100644 index 000000000..58bb0e5dc --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageSourceSelectSheet.kt @@ -0,0 +1,92 @@ +package io.legado.app.ui.main.homepage + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.legado.app.R +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.widget.components.SearchBar +import io.legado.app.ui.widget.components.card.SelectionItemCard +import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet + +@Composable +fun HomepageSourceSelectSheet( + show: Boolean, + onDismissRequest: () -> Unit, + sources: List, + onToggleSource: (String) -> Unit, + onSelectAll: () -> Unit, +) { + var filterText by remember(show) { mutableStateOf("") } + + val filteredSources = remember(sources, filterText) { + if (filterText.isBlank()) sources else sources.filter { + it.sourceName.contains(filterText, ignoreCase = true) || + it.sourceGroup?.contains(filterText, ignoreCase = true) == true + } + } + + val isAllSelected = remember(sources) { + sources.all { it.isSelected } || sources.none { it.isSelected } + } + + AppModalBottomSheet( + show = show, + onDismissRequest = onDismissRequest, + title = "筛选书源", + ) { + Column { + SearchBar( + query = filterText, + onQueryChange = { filterText = it }, + placeholder = stringResource(R.string.screen), + autoFocus = false + ) + + Spacer(modifier = Modifier.height(8.dp)) + + SelectionItemCard( + title = stringResource(R.string.all_source), + isSelected = isAllSelected, + containerColor = LegadoTheme.colorScheme.onSheetContent, + inSelectionMode = true, + onToggleSelection = { + onSelectAll() + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + LazyColumn( + modifier = Modifier.heightIn(max = 480.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(filteredSources, key = { it.sourceUrl }) { source -> + SelectionItemCard( + title = source.sourceName, + subtitle = source.sourceGroup?.takeIf { it.isNotBlank() }, + containerColor = LegadoTheme.colorScheme.onSheetContent, + isSelected = source.isSelected, + inSelectionMode = true, + onToggleSelection = { + onToggleSource(source.sourceUrl) + } + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt new file mode 100644 index 000000000..8cc1edf18 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt @@ -0,0 +1,882 @@ +package io.legado.app.ui.main.homepage + +import android.app.Application +import androidx.lifecycle.viewModelScope +import io.legado.app.base.BaseViewModel +import io.legado.app.data.entities.BookSource +import io.legado.app.data.entities.SearchBook +import io.legado.app.data.repository.BookSourceRepository +import io.legado.app.domain.gateway.HomepageModulesGateway +import io.legado.app.domain.model.CustomSetItem +import io.legado.app.domain.model.HomepageModuleType +import io.legado.app.domain.model.ModuleDef +import io.legado.app.domain.model.ModuleItem +import io.legado.app.domain.usecase.ExploreBooksUseCase +import io.legado.app.domain.usecase.SaveSearchBooksUseCase +import io.legado.app.help.source.exploreKinds +import io.legado.app.utils.GSON +import io.legado.app.utils.fromJsonArray +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.security.MessageDigest + +class HomepageViewModel( + application: Application, + private val bookSourceRepository: BookSourceRepository, + private val gateway: HomepageModulesGateway, + private val exploreBooksUseCase: ExploreBooksUseCase, + private val saveSearchBooksUseCase: SaveSearchBooksUseCase, +) : BaseViewModel(application) { + + companion object { + private const val CUSTOM_SET_URL_PREFIX = "custom://" + private const val HOMEPAGE_DEFAULT_GRID_ROWS = 2 + private const val HOMEPAGE_MAX_BUTTON_GROUP_KINDS = 5 + + fun customSetUrl(id: String) = "$CUSTOM_SET_URL_PREFIX$id" + fun isCustomSetUrl(url: String) = url.startsWith(CUSTOM_SET_URL_PREFIX) + fun customSetIdFromUrl(url: String): String = url.removePrefix(CUSTOM_SET_URL_PREFIX) + + fun isInfinite(type: String?, layoutConfig: String?): Boolean { + return type == HomepageModuleType.Waterfall.key + || type == HomepageModuleType.InfiniteGrid.key + } + + private fun parseModuleDefs(source: BookSource, json: String): List = + GSON.fromJsonArray(json).getOrDefault(emptyList()) + .map { it.copy(sourceUrl = source.bookSourceUrl) } + + private fun jsonHash(json: String): String { + val digest = MessageDigest.getInstance("MD5").digest(json.toByteArray(Charsets.UTF_8)) + return digest.joinToString("") { "%02x".format(it) } + } + + private fun List.groupBySourceOrdered(): Map> { + val result = linkedMapOf>() + for (module in this) { + val key = module.customSetId?.let { customSetUrl(it) } ?: module.sourceUrl + result.getOrPut(key) { mutableListOf() }.add(module) + } + return result + } + } + + private val _effects = MutableSharedFlow(extraBufferCapacity = 8) + val effects = _effects.asSharedFlow() + + private val loadJobs = mutableMapOf() + private val initModulesSyncFlow = bookSourceRepository.flowHomepageModules() + private val exploreSourcesFlow = bookSourceRepository.flowExploreSources() + + private val _isRefreshing = MutableStateFlow(false) + private val _isManageMode = MutableStateFlow(false) + private val _isConfigMode = MutableStateFlow(false) + private val _configVersion = MutableStateFlow(0L) + private val _moduleContentStates = MutableStateFlow>(emptyMap()) + + private val localModulesFlow = gateway.flowEnabled() + private val _bookSourcesCache = MutableStateFlow>(emptyMap()) + + val allModulesCache = gateway.flowAll() + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + val customSetsFlow = gateway.flowCustomSets() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + private val orderedModuleDefsFlow = combine(localModulesFlow, _configVersion) { modules, _ -> + modules.groupBySourceOrdered() + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) + + private val uiFlagsFlow = + combine(_isRefreshing, _isManageMode, _isConfigMode) { refreshing, manage, config -> + HomepageUiFlags(refreshing, manage, config) + } + + val uiState: StateFlow = combine( + orderedModuleDefsFlow, + _moduleContentStates, + uiFlagsFlow, + _bookSourcesCache, + customSetsFlow + ) { grouped, contentStates, flags, sourcesCache, customSets -> + val setNames = customSets.associate { it.id to it.name } + val sortedSetIds = customSets.sortedBy { it.sortOrder }.map { it.id } + + // 按照集排序设置来排布模块 + val displayModules = sortedSetIds.flatMap { setId -> + val setUrl = customSetUrl(setId) + val mods = grouped[setUrl] ?: emptyList() + mods.map { module -> + val source = sourcesCache[module.sourceUrl] + val sourceName = source?.bookSourceName ?: module.sourceUrl + val setName = module.customSetId?.let { setNames[it] } ?: sourceName + val exploreUrl = module.url ?: source?.exploreUrl + + val configMap = mutableMapOf() + module.layoutConfig?.let { configStr -> + try { + val json = GSON.fromJson(configStr, Map::class.java) + json?.forEach { (k, v) -> + configMap["layout_$k"] = v.toString() + } + } catch (_: Exception) { + } + } + + HomepageModuleUi( + sourceUrl = module.sourceUrl, + setName = setName, + globalId = module.id, + type = HomepageModuleType.fromKey(module.type), + title = module.displayTitle, + exploreUrl = exploreUrl, + customSetId = module.customSetId, + layoutConfig = module.layoutConfig, + state = contentStates[module.id] ?: ModuleLoadState.Loading, + config = configMap + ) + } + } + HomepageUiState( + modules = displayModules.toImmutableList(), + isRefreshing = flags.isRefreshing, + isManageMode = flags.isManageMode, + isConfigMode = flags.isConfigMode, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HomepageUiState()) + + val setsFlow = combine( + localModulesFlow, + allModulesCache, + customSetsFlow, + _configVersion + ) { _, allModules, customSets, _ -> + val hiddenSourceUrls = GSON.fromJsonArray(HomepageConfig.homepageSourceHidden) + .getOrDefault(emptyList()).toSet() + val moduleCountsBySet = + allModules.mapNotNull { it.customSetId }.groupBy { it }.mapValues { it.value.size } + + val list = mutableListOf() + customSets.sortedBy { it.sortOrder }.forEach { set -> + list.add( + HomepageSourceManageUi( + sourceUrl = customSetUrl(set.id), + sourceName = set.name, + sourceGroup = null, + isSelected = customSetUrl(set.id) !in hiddenSourceUrls, + moduleCount = moduleCountsBySet[set.id] ?: 0, + isCustomSet = true, + ) + ) + } + list.toImmutableList() + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentListOf()) + + /** 用于「浏览书源模块」:列出有 homepageModules 的书源 */ + val browseSourcesFlow = exploreSourcesFlow.map { sources -> + sources.map { source -> + HomepageSourceManageUi( + sourceUrl = source.bookSourceUrl, + sourceName = source.bookSourceName, + sourceGroup = source.bookSourceGroup, + ) + }.toImmutableList() + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentListOf()) + + private val _exploreKindsCache = + MutableStateFlow>>>(emptyMap()) + private val _pendingEnabled = MutableStateFlow>(emptyMap()) + private val _pendingUserModules = MutableStateFlow>(emptyList()) + + init { + // sync: 只处理有 homepageModules 的书源 + viewModelScope.launch { + initModulesSyncFlow.collect { sources -> + sources.forEach { source -> syncModulesFromSource(source) } + } + } + + // cache: 所有启用发现的书源(包括无 homepageModules 的) + viewModelScope.launch { + exploreSourcesFlow.collect { sources -> + _bookSourcesCache.value = sources.associateBy { it.bookSourceUrl } + val kindsCache = mutableMapOf>>() + for (source in sources) { + kindsCache[source.bookSourceUrl] = try { + withContext(Dispatchers.IO) { + source.exploreKinds().map { it.title to (it.url ?: "") } + } + } catch (_: Exception) { + emptyList() + } + } + _exploreKindsCache.value = kindsCache + } + } + + viewModelScope.launch { + uiState.map { it.modules }.collect { modules -> + modules.forEach { ui -> + if (ui.state is ModuleLoadState.Loading && loadJobs[ui.globalId]?.isActive != true) { + val module = gateway.getById(ui.globalId) + if (module != null) loadModule(module) + } + } + } + } + + // 清理 _pendingUserModules 中已入库的条目 + viewModelScope.launch { + allModulesCache.collect { modules -> + val dbIds = modules.map { it.id }.toSet() + _pendingUserModules.update { pending -> pending.filter { it.id !in dbIds } } + } + } + + // 一次性迁移:将 customSetId=null 的存量模块归属到书源集,并确保所有集存在 + viewModelScope.launch { + val allModules = allModulesCache.first() + val orphans = allModules.filter { it.customSetId == null } + if (orphans.isNotEmpty()) { + orphans.groupBy { it.sourceUrl }.forEach { (sourceUrl, modules) -> + val source = bookSourceRepository.getBookSource(sourceUrl) ?: return@forEach + ensureSetForSource(sourceUrl, source.bookSourceName) + modules.forEach { m -> + gateway.setCustomSetId(m.id, "src_$sourceUrl") + } + } + } + // 确保所有 customSetId 对应的集都存在 + allModules.mapNotNull { it.customSetId }.distinct().forEach { setId -> + val isSrcSet = setId.startsWith("src_") + if (isSrcSet && gateway.getCustomSetById(setId) == null) { + val sourceUrl = setId.removePrefix("src_") + val source = bookSourceRepository.getBookSource(sourceUrl) + if (source != null) { + ensureSetForSource(sourceUrl, source.bookSourceName) + } + } + } + } + } + + private suspend fun syncModulesFromSource(source: BookSource) { + val json = source.homepageModules ?: return + ensureSetForSource(source.bookSourceUrl, source.bookSourceName) + val parsedDefs = parseModuleDefs(source, json) + val newHash = jsonHash(json) + + val existingModules = gateway.flowBySource(source.bookSourceUrl).first() + val existingById = existingModules.associateBy { it.id } + val parsedIds = parsedDefs.map { it.globalId }.toSet() + + val toUpsert = mutableListOf() + + for (i in parsedDefs.indices) { + val def = parsedDefs[i] + val existing = existingById[def.globalId] + if (existing != null) { + // 用户编辑过的模块不被 JSON 覆盖 + if (existing.isUserCreated) continue + if (existing.sourceJsonHash == newHash) continue + toUpsert.add( + existing.copy( + type = def.type, + title = def.title, + args = def.args, + url = def.url, + sourceJsonHash = newHash, + syncedAt = System.currentTimeMillis(), + ) + ) + } else { + toUpsert.add( + ModuleItem( + id = def.globalId, + sourceUrl = source.bookSourceUrl, + moduleKey = def.key, + type = def.type, + title = def.title, + args = def.args, + url = def.url, + isEnabled = true, + customSetId = "src_${source.bookSourceUrl}", + sortOrder = i, + sourceJsonHash = newHash, + syncedAt = System.currentTimeMillis(), + ) + ) + } + } + + if (toUpsert.isNotEmpty()) { + gateway.upsertAll(toUpsert) + } + + if (parsedIds.isNotEmpty()) { + gateway.deleteStale(source.bookSourceUrl, parsedIds.toList()) + } + } + + private fun loadModule(module: ModuleItem) { + loadJobs[module.id]?.cancel() + if (module.type == HomepageModuleType.ButtonGroup.key) { + loadJobs[module.id] = viewModelScope.launch { + kotlin.runCatching { + val source = bookSourceRepository.getBookSource(module.sourceUrl) + ?: throw Exception("Source not found") + val allKinds = withContext(Dispatchers.IO) { source.exploreKinds() } + + val selectedTitles = module.args?.let { argsStr -> + GSON.fromJsonArray(argsStr).getOrNull() + } + + if (selectedTitles.isNullOrEmpty()) { + allKinds.take(HOMEPAGE_MAX_BUTTON_GROUP_KINDS) + } else { + selectedTitles.mapNotNull { t -> allKinds.find { it.title == t } } + } + }.onSuccess { kinds -> + _moduleContentStates.update { it + (module.id to ModuleLoadState.Buttons(kinds.toImmutableList())) } + }.onFailure { e -> + _moduleContentStates.update { + it + (module.id to ModuleLoadState.Error( + e.message ?: "Unknown error" + )) + } + } + }.also { + it.invokeOnCompletion { loadJobs.remove(module.id) } + } + return + } + loadJobs[module.id] = viewModelScope.launch { + kotlin.runCatching { + val isRanking = module.type == HomepageModuleType.Ranking.key + || module.type == HomepageModuleType.GridRanking.key + val books = if (isRanking) { + exploreBooksUseCase.executeForRanking(module.sourceUrl, module.url, module.args) + } else { + exploreBooksUseCase.execute(module.sourceUrl, module.url, module.args).books + } + val layout = try { + GSON.fromJson(module.layoutConfig, Map::class.java) + } catch (_: Exception) { + null + } + val rows = (layout?.get("rows") as? Number)?.toInt() ?: HOMEPAGE_DEFAULT_GRID_ROWS + val hasMore = isInfinite(module.type, module.layoutConfig) && books.isNotEmpty() + books to hasMore + }.onSuccess { (books, hasMore) -> + _moduleContentStates.update { + it + (module.id to ModuleLoadState.Loaded( + books.toImmutableList(), + hasMore = hasMore, + page = 1 + )) + } + }.onFailure { e -> + _moduleContentStates.update { + it + (module.id to ModuleLoadState.Error( + e.message ?: "Unknown error" + )) + } + } + }.also { + it.invokeOnCompletion { loadJobs.remove(module.id) } + } + } + + fun loadMoreModule(globalId: String) { + val currentState = _moduleContentStates.value[globalId] as? ModuleLoadState.Loaded ?: return + if (currentState.isLoadingMore || !currentState.hasMore) return + + val nextPage = currentState.page + 1 + _moduleContentStates.update { it + (globalId to currentState.copy(isLoadingMore = true)) } + + viewModelScope.launch { + kotlin.runCatching { + val module = gateway.getById(globalId) ?: throw Exception("Module not found") + exploreBooksUseCase.execute( + module.sourceUrl, + module.url, + module.args, + page = nextPage + ) + }.onSuccess { result -> + val newBooks = result.books + _moduleContentStates.update { states -> + val lastState = + states[globalId] as? ModuleLoadState.Loaded ?: return@update states + val existingUrls = lastState.books.map { it.bookUrl }.toSet() + val deduped = newBooks.filter { it.bookUrl !in existingUrls } + val combinedBooks = (lastState.books + deduped).toImmutableList() + states + (globalId to ModuleLoadState.Loaded( + books = combinedBooks, + hasMore = deduped.isNotEmpty(), + isLoadingMore = false, + page = nextPage + )) + } + }.onFailure { e -> + _moduleContentStates.update { states -> + val lastState = + states[globalId] as? ModuleLoadState.Loaded ?: return@update states + states + (globalId to lastState.copy(isLoadingMore = false)) + } + _effects.tryEmit(HomepageEffect.ShowSnackbar("加载更多失败: ${e.message}")) + } + } + } + + fun refreshButtonGroup(globalId: String) { + viewModelScope.launch { + val module = gateway.getById(globalId) ?: return@launch + loadModule(module) + } + } + + fun onKindUrlClick(sourceUrl: String, url: String, title: String) = + _effects.tryEmit(HomepageEffect.NavigateToExploreShow(title, sourceUrl, url)) + + fun onRefresh() { + viewModelScope.launch { + _isRefreshing.value = true + loadJobs.values.forEach { it.cancel() } + loadJobs.clear() + _moduleContentStates.value = emptyMap() + uiState.map { it.modules }.first { modules -> + modules.all { it.state !is ModuleLoadState.Loading } + } + _isRefreshing.value = false + } + } + + fun retryModule(globalId: String) { + _moduleContentStates.update { it + (globalId to ModuleLoadState.Loading) } + } + + fun toggleManageMode() = _isManageMode.update { !it } + fun toggleConfigMode() = _isConfigMode.update { !it } + + fun setModuleVisible(id: String, visible: Boolean) { + _pendingEnabled.update { it + (id to visible) } + viewModelScope.launch { + val existing = gateway.getById(id) + if (existing != null) { + gateway.setEnabled(id, visible) + } else { + // 如果模块尚未入库(虚拟状态),则根据 ID 规则解析并入库 + val parts = id.split("::") + if (parts.size >= 3) { + val setId = parts[0] + val sourceUrl = parts[1] + val key = parts.subList(2, parts.size).joinToString("::") + ensureModuleInDb(sourceUrl, key, id, setId) + gateway.setEnabled(id, visible) + } + } + _pendingEnabled.update { it - id } + notifyConfigChanged() + } + } + + fun toggleSourceFilter(sourceUrl: String, isEnabled: Boolean) { + val hidden = GSON.fromJsonArray(HomepageConfig.homepageSourceHidden) + .getOrDefault(emptyList()).toMutableSet() + if (isEnabled) hidden.remove(sourceUrl) else hidden.add(sourceUrl) + HomepageConfig.homepageSourceHidden = GSON.toJson(hidden.toList()) + notifyConfigChanged() + } + + fun setLayoutMode(mode: Int) { + HomepageConfig.homepageLayoutMode = mode + notifyConfigChanged() + } + + private suspend fun ensureSetForSource(sourceUrl: String, sourceName: String): String { + val setId = "src_$sourceUrl" + if (gateway.getCustomSetById(setId) == null) { + gateway.upsertCustomSet(CustomSetItem(id = setId, name = sourceName)) + } + return setId + } + + fun addCustomModule(sourceUrl: String, targetSetId: String?, def: ModuleDef) { + val key = def.key.ifBlank { def.title } + val setId = targetSetId ?: "src_$sourceUrl" + + if (isInfinite(def.type, def.layoutConfig)) { + val hasInfinite = allModulesCache.value.any { + it.customSetId == setId && isInfinite( + it.type, + it.layoutConfig + ) + } + if (hasInfinite) { + viewModelScope.launch { + _effects.emit(HomepageEffect.ShowSnackbar("该分组已存在无限加载模块")) + } + return + } + } + + val id = ModuleDef.globalIdOf(sourceUrl, key, setId) + val module = ModuleItem( + id = id, + sourceUrl = sourceUrl, + moduleKey = key, + type = def.type, + title = def.title, + args = def.args, + layoutConfig = def.layoutConfig, + url = def.url, + isEnabled = true, + isUserCreated = true, + customSetId = setId, + syncedAt = System.currentTimeMillis(), + ) + viewModelScope.launch { + val source = bookSourceRepository.getBookSource(sourceUrl) + if (source != null) ensureSetForSource(sourceUrl, source.bookSourceName) + gateway.upsertAll(listOf(module)) + _pendingUserModules.update { list -> if (list.any { it.id == id }) list else list + module } + notifyConfigChanged() + } + } + + fun getSourceExploreKinds(sourceUrl: String): List> { + return _exploreKindsCache.value[sourceUrl].orEmpty() + } + + fun updateModule(globalId: String, def: ModuleDef) { + viewModelScope.launch { + val existing = gateway.getById(globalId) ?: return@launch + gateway.upsertAll( + listOf( + existing.copy( + customTitle = def.title.takeIf { it != existing.title }, + type = def.type, + url = def.url, + args = def.args, + layoutConfig = def.layoutConfig, + isUserCreated = true, // 标记为用户编辑,阻止 JSON 同步覆盖 + syncedAt = System.currentTimeMillis(), + ) + ) + ) + notifyConfigChanged() + } + } + + fun setModuleCustomSetTitle(globalId: String, customSetTitle: String?) { + viewModelScope.launch { + gateway.setCustomSetTitle(globalId, customSetTitle) + notifyConfigChanged() + } + } + + fun deleteModule(globalId: String) { + viewModelScope.launch { + gateway.delete(globalId) + _moduleContentStates.update { it - globalId } + loadJobs.remove(globalId)?.cancel() + _pendingEnabled.update { it - globalId } + _pendingUserModules.update { it.filter { m -> m.id != globalId } } + notifyConfigChanged() + } + } + + fun reorderJoinedModules(orderedIds: List) { + viewModelScope.launch { + orderedIds.forEachIndexed { index, id -> + gateway.setSortOrder(id, index) + } + notifyConfigChanged() + } + } + + fun reorderCustomSets(orderedUrls: List) { + viewModelScope.launch { + orderedUrls.forEachIndexed { index, url -> + val id = customSetIdFromUrl(url) + gateway.setCustomSetSortOrder(id, index) + } + notifyConfigChanged() + } + } + + /** 获取指定集内的模块(sourceUrl 可以是书源 URL 或 custom://xxx) */ + fun getJoinedModules(sourceUrl: String): List { + val isSet = isCustomSetUrl(sourceUrl) + val setId = if (isSet) customSetIdFromUrl(sourceUrl) else null + val dbModules = if (isSet) { + allModulesCache.value.filter { it.customSetId == setId } + } else { + allModulesCache.value.filter { it.sourceUrl == sourceUrl } + } + val dbIds = dbModules.map { it.id }.toSet() + val pendingModules = _pendingUserModules.value.filter { pending -> + val matches = + if (isSet) pending.customSetId == setId else pending.sourceUrl == sourceUrl + matches && pending.id !in dbIds + } + return (dbModules + pendingModules).map { uiFromModule(it) } + } + + /** 所有已添加的模块,按书源分组(用于自定义集添加模块) */ + fun getAllModulesGroupedBySource(): Map> { + return allModulesCache.value + .distinctBy { it.sourceUrl to it.moduleKey } + .map { uiFromModule(it) } + .groupBy { it.sourceUrl } + } + + fun getSourceName(sourceUrl: String): String { + return _bookSourcesCache.value[sourceUrl]?.bookSourceName ?: sourceUrl + } + + fun assignModuleToCustomSet(moduleId: String, customSetId: String?) { + viewModelScope.launch { + val existing = gateway.getById(moduleId) ?: return@launch + if (customSetId == null) { + // 如果是取消分配,且它不是归属于书源默认集的,则直接删除该副本 + if (existing.customSetId != "src_${existing.sourceUrl}") { + gateway.delete(moduleId) + } + } else { + // 核心逻辑:分配 = 复制。生成带新 setId 的 ID + val newId = + ModuleDef.globalIdOf(existing.sourceUrl, existing.moduleKey, customSetId) + val newModule = existing.copy( + id = newId, + customSetId = customSetId, + isEnabled = true, // 分配到新集时默认开启 + isUserCreated = true // 标记为用户创建,避免被同步清理 + ) + gateway.upsertAll(listOf(newModule)) + } + notifyConfigChanged() + } + } + + /** 「书源模块」tab:仅 JSON,纯参考 */ + fun getSourceModules( + sourceUrl: String, + targetSetId: String? = null + ): List { + val source = resolveBookSource(sourceUrl) ?: return emptyList() + val json = source.homepageModules ?: return emptyList() + val jsonDefs = parseBookSourceModules(source, json) + + val effectiveSetId = targetSetId ?: "src_$sourceUrl" + val joinedKeys = allModulesCache.value + .filter { it.sourceUrl == sourceUrl && it.customSetId == effectiveSetId } + .map { it.moduleKey }.toSet() + + return jsonDefs.map { def -> + val id = ModuleDef.globalIdOf(sourceUrl, def.key, effectiveSetId) + HomepageModuleManageUi( + id = id, + sourceUrl = def.sourceUrl, + moduleKey = def.key, + title = def.title, + isVisible = joinedKeys.contains(def.key), + customSetId = if (joinedKeys.contains(def.key)) effectiveSetId else null, + originalTitle = def.title, + type = def.type, + url = def.url, + args = def.args, + ) + } + } + + /** 从书源模块「加入」→ 写入 DB,自动归属到该书源的集 */ + /** 从发现页 Kind 创建一个 ButtonGroup 模块,args 存储选中 Kind 标题的 JSON 数组 */ + fun addButtonGroupFromKinds( + sourceUrl: String, + targetSetId: String?, + title: String, + kindTitles: List + ) { + val key = kindTitles.firstOrNull() ?: title + val setId = targetSetId ?: "src_$sourceUrl" + val id = ModuleDef.globalIdOf(sourceUrl, key, setId) + val module = ModuleItem( + id = id, + sourceUrl = sourceUrl, + moduleKey = key, + type = "buttonGroup", + title = title, + args = GSON.toJson(kindTitles), + isEnabled = true, + isUserCreated = true, + customSetId = setId, + syncedAt = System.currentTimeMillis(), + ) + viewModelScope.launch { + val source = bookSourceRepository.getBookSource(sourceUrl) + if (source != null) ensureSetForSource(sourceUrl, source.bookSourceName) + gateway.upsertAll(listOf(module)) + _pendingUserModules.update { list -> if (list.any { it.id == id }) list else list + module } + notifyConfigChanged() + } + } + + fun joinModule(sourceUrl: String, targetSetId: String?, def: ModuleDef) { + val setId = targetSetId ?: "src_$sourceUrl" + + if (isInfinite(def.type, def.layoutConfig)) { + val hasInfinite = allModulesCache.value.any { + it.customSetId == setId && isInfinite( + it.type, + it.layoutConfig + ) + } + if (hasInfinite) { + viewModelScope.launch { + _effects.emit(HomepageEffect.ShowSnackbar("该分组已存在无限加载模块")) + } + return + } + } + + val id = ModuleDef.globalIdOf(sourceUrl, def.key, setId) + val module = ModuleItem( + id = id, + sourceUrl = sourceUrl, + moduleKey = def.key, + type = def.type, + title = def.title, + args = def.args, + layoutConfig = def.layoutConfig, + url = def.url, + isEnabled = true, + customSetId = setId, + syncedAt = System.currentTimeMillis(), + ) + viewModelScope.launch { + val source = bookSourceRepository.getBookSource(sourceUrl) + if (source != null) ensureSetForSource(sourceUrl, source.bookSourceName) + gateway.upsertAll(listOf(module)) + _pendingUserModules.update { list -> if (list.any { it.id == id }) list else list + module } + notifyConfigChanged() + } + } + + private fun uiFromModule(module: ModuleItem) = HomepageModuleManageUi( + id = module.id, + sourceUrl = module.sourceUrl, + moduleKey = module.moduleKey, + title = module.displayTitle, + customSetTitle = module.customSetTitle, + customSetId = module.customSetId, + isVisible = _pendingEnabled.value[module.id] ?: module.isEnabled, + type = module.type, + url = module.url, + args = module.args, + layoutConfig = module.layoutConfig, + originalTitle = module.title, + ) + + fun createCustomSet(name: String) { + viewModelScope.launch { + gateway.createCustomSet(name) + notifyConfigChanged() + } + } + + fun renameCustomSet(id: String, name: String) { + viewModelScope.launch { + gateway.renameCustomSet(id, name) + notifyConfigChanged() + } + } + + fun deleteCustomSet(id: String) { + viewModelScope.launch { + val moduleIds = allModulesCache.value.filter { it.customSetId == id }.map { it.id } + gateway.deleteCustomSet(id) + moduleIds.forEach { mid -> + _moduleContentStates.update { it - mid } + loadJobs.remove(mid)?.cancel() + _pendingEnabled.update { it - mid } + } + notifyConfigChanged() + } + } + + fun onBookClick(book: SearchBook) { + viewModelScope.launch { + saveSearchBooksUseCase.save(book) + _effects.emit(HomepageEffect.NavigateToBookInfo(book.name, book.author, book.bookUrl)) + } + } + + fun onModuleHeaderClick(sourceUrl: String, exploreUrl: String?, title: String?) { + viewModelScope.launch { + _effects.emit(HomepageEffect.NavigateToExploreShow(title, sourceUrl, exploreUrl)) + } + } + + private fun resolveBookSource(sourceUrl: String): BookSource? { + return _bookSourcesCache.value[sourceUrl] + ?: bookSourceRepository.getBookSourceSync(sourceUrl) + } + + private suspend fun ensureModuleInDb( + sourceUrl: String, + moduleKey: String, + id: String, + setId: String + ) { + if (gateway.getById(id) != null) return + val source = resolveBookSource(sourceUrl) ?: return + val json = source.homepageModules ?: return + val defs = parseBookSourceModules(source, json) + val def = defs.find { it.key == moduleKey } ?: return + gateway.upsertAll( + listOf( + ModuleItem( + id = id, + sourceUrl = sourceUrl, + moduleKey = moduleKey, + type = def.type, + title = def.title, + args = def.args, + url = def.url, + isEnabled = true, + customSetId = setId, + ) + ) + ) + } + + private fun notifyConfigChanged() { + _configVersion.update { it + 1 } + } + + private fun parseBookSourceModules(source: BookSource, json: String): List = + parseModuleDefs(source, json) + +} + +private data class HomepageUiFlags( + val isRefreshing: Boolean, + val isManageMode: Boolean, + val isConfigMode: Boolean +) \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/modules/BannerModule.kt b/app/src/main/java/io/legado/app/ui/main/homepage/modules/BannerModule.kt new file mode 100644 index 000000000..b4cfa2dcd --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/homepage/modules/BannerModule.kt @@ -0,0 +1,57 @@ +package io.legado.app.ui.main.homepage.modules + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import io.legado.app.data.entities.SearchBook +import io.legado.app.ui.main.bookCoverSharedElementKey +import io.legado.app.ui.theme.fadingEdge +import io.legado.app.ui.widget.components.image.cover.CoilBookCover +import kotlinx.collections.immutable.ImmutableList + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun BannerModule( + books: ImmutableList, + onClick: (SearchBook) -> Unit, + modifier: Modifier = Modifier, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, +) { + if (books.isEmpty()) return + + val lazyListState = rememberLazyListState() + LazyRow( + state = lazyListState, + modifier = modifier + .fillMaxWidth() + .fadingEdge(lazyListState, gradientWidth = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(books) { book -> + CoilBookCover( + name = book.name, + author = book.author, + path = book.coverUrl, + radius = 12.dp, + sourceOrigin = book.origin, + modifier = Modifier + .width(96.dp) + .clickable { onClick(book) }, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + sharedCoverKey = bookCoverSharedElementKey(book.bookUrl) + ) + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/modules/ButtonGroupModule.kt b/app/src/main/java/io/legado/app/ui/main/homepage/modules/ButtonGroupModule.kt new file mode 100644 index 000000000..4eab8e64a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/homepage/modules/ButtonGroupModule.kt @@ -0,0 +1,171 @@ +package io.legado.app.ui.main.homepage.modules + +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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 +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.legado.app.data.entities.rule.ExploreKind +import io.legado.app.domain.usecase.ExploreKindUiUseCase +import io.legado.app.help.source.getExploreInfoMap +import io.legado.app.ui.main.homepage.HomepageViewModel +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.widget.components.card.GlassCard +import io.legado.app.ui.widget.components.image.sourceIcon.SourceIcon +import io.legado.app.ui.widget.components.text.AppText +import io.legado.app.utils.GSON +import kotlinx.coroutines.launch +import org.koin.compose.koinInject + +@Composable +fun ButtonGroupModule( + kinds: List, + sourceUrl: String, + globalId: String, + viewModel: HomepageViewModel, + modifier: Modifier = Modifier, + icon: String? = null, + layoutConfig: String? = null, +) { + if (kinds.isEmpty()) return + + val context = LocalContext.current + val activity = context as? AppCompatActivity + val useCase: ExploreKindUiUseCase = koinInject() + val scope = rememberCoroutineScope() + val infoMap = remember(sourceUrl) { + sourceUrl.takeIf { it.isNotBlank() }?.let { getExploreInfoMap(it) } + } + + // 解析图标映射表和默认图标 + val (iconMap, defaultIcon) = remember(layoutConfig) { + layoutConfig?.let { + try { + val json = GSON.fromJson(it, Map::class.java) + + @Suppress("UNCHECKED_CAST") + val icons = json["icons"] as? Map + val singleIcon = json["icon"] as? String + (icons ?: emptyMap()) to (singleIcon ?: icon) + } catch (_: Exception) { + emptyMap() to icon + } + } ?: (emptyMap() to icon) + } + + // --- 动态布局计算逻辑 --- + val maxColumns = 5 + val total = kinds.size + val numRows = (total + maxColumns - 1) / maxColumns + val actualColumns = (total + numRows - 1) / numRows + // ----------------------- + + Column( + modifier = modifier + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + kinds.chunked(actualColumns).forEach { rowKinds -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + rowKinds.forEach { kind -> + var displayName by remember(kind.title) { mutableStateOf(kind.title) } + + LaunchedEffect(kind, sourceUrl, infoMap) { + displayName = useCase.resolveDisplayName(kind, sourceUrl, infoMap) + } + + val buttonIcon = iconMap[kind.title] ?: defaultIcon + val hasIcon = !buttonIcon.isNullOrBlank() + + GlassCard( + onClick = { + when (kind.type) { + ExploreKind.Type.url -> { + kind.url?.takeIf { it.isNotBlank() }?.let { + viewModel.onKindUrlClick(sourceUrl, it, kind.title) + } + } + + ExploreKind.Type.button -> { + scope.launch { + useCase.executeAction( + action = kind.action, + title = kind.title, + sourceUrl = sourceUrl, + infoMap = infoMap, + activity = activity, + onRefreshKinds = { + viewModel.refreshButtonGroup(globalId) + } + ) + } + } + } + }, + cornerRadius = 8.dp, + containerColor = LegadoTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.weight(1f) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxSize() + .padding(vertical = 12.dp, horizontal = 4.dp) + ) { + if (hasIcon) { + SourceIcon( + path = buttonIcon, + modifier = Modifier.size(20.dp), + placeholderIcon = { + + } + ) + Spacer(modifier = Modifier.height(4.dp)) + } + + AppText( + text = displayName, + style = LegadoTheme.typography.labelMedium, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Clip, + modifier = Modifier + .padding(horizontal = 4.dp) + .basicMarquee() + ) + } + } + } + + if (rowKinds.size < actualColumns) { + repeat(actualColumns - rowKinds.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/modules/CardModule.kt b/app/src/main/java/io/legado/app/ui/main/homepage/modules/CardModule.kt new file mode 100644 index 000000000..861e4c188 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/homepage/modules/CardModule.kt @@ -0,0 +1,101 @@ +package io.legado.app.ui.main.homepage.modules + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.legado.app.data.entities.SearchBook +import io.legado.app.ui.main.bookCoverSharedElementKey +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.theme.fadingEdge +import io.legado.app.ui.widget.components.image.cover.CoilBookCover +import io.legado.app.ui.widget.components.text.AppText +import kotlinx.collections.immutable.ImmutableList + +/** + * 卡片模块:横向滚动的推荐卡片 + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun CardModule( + books: ImmutableList, + onClick: (SearchBook) -> Unit, + modifier: Modifier = Modifier, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, +) { + if (books.isEmpty()) return + val lazyListState = rememberLazyListState() + LazyRow( + state = lazyListState, + modifier = modifier + .fillMaxWidth() + .fadingEdge(lazyListState, gradientWidth = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(books, key = { it.bookUrl }) { book -> + Column( + modifier = Modifier + .width(120.dp) + .clip(RoundedCornerShape(16.dp)) + .background(LegadoTheme.colorScheme.surfaceContainerLow) + .clickable { onClick(book) } + ) { + CoilBookCover( + name = book.name, + author = book.author, + path = book.coverUrl, + radius = 16.dp, + sourceOrigin = book.origin, + modifier = Modifier + .wrapContentWidth(), + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + sharedCoverKey = bookCoverSharedElementKey(book.bookUrl) + ) + + AppText( + text = book.name, + style = LegadoTheme.typography.labelLargeEmphasized, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding( + start = 8.dp, + end = 8.dp, + top = 8.dp, + bottom = 2.dp + ), + ) + + val intro = book.intro?.takeIf { it.isNotBlank() } + ?.replace("\\s+".toRegex(), " ") + if (intro != null) { + AppText( + text = intro, + style = LegadoTheme.typography.bodySmall, + color = LegadoTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 12.dp), + ) + } + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/modules/GridModule.kt b/app/src/main/java/io/legado/app/ui/main/homepage/modules/GridModule.kt new file mode 100644 index 000000000..55004f808 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/homepage/modules/GridModule.kt @@ -0,0 +1,60 @@ +package io.legado.app.ui.main.homepage.modules + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import io.legado.app.data.entities.SearchBook +import io.legado.app.domain.model.BookShelfState +import io.legado.app.ui.main.bookCoverSharedElementKey +import io.legado.app.ui.widget.components.book.SearchBookGridItem +import kotlinx.collections.immutable.ImmutableList + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun GridModule( + books: ImmutableList, + onClick: (SearchBook) -> Unit, + modifier: Modifier = Modifier, + columns: Int = 3, + maxRows: Int? = null, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, +) { + if (books.isEmpty()) return + var rows = books.toList().chunked(columns) + if (maxRows != null) { + rows = rows.take(maxRows) + } + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + for (row in rows) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + for (book in row) { + SearchBookGridItem( + book = book, + shelfState = BookShelfState.NOT_IN_SHELF, + onClick = { onClick(book) }, + modifier = Modifier.weight(1f), + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + sharedCoverKey = bookCoverSharedElementKey(book.bookUrl) + ) + } + repeat(columns - row.size) { Spacer(Modifier.weight(1f)) } + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/modules/GridRankingModule.kt b/app/src/main/java/io/legado/app/ui/main/homepage/modules/GridRankingModule.kt new file mode 100644 index 000000000..af567717b --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/homepage/modules/GridRankingModule.kt @@ -0,0 +1,161 @@ +package io.legado.app.ui.main.homepage.modules + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.legado.app.data.entities.SearchBook +import io.legado.app.ui.main.bookCoverSharedElementKey +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.theme.fadingEdge +import io.legado.app.ui.widget.components.card.GlassCard +import io.legado.app.ui.widget.components.image.cover.CoilBookCover +import io.legado.app.ui.widget.components.text.AppText +import kotlinx.collections.immutable.ImmutableList + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun GridRankingModule( + books: ImmutableList, + onClick: (SearchBook) -> Unit, + modifier: Modifier = Modifier, + rows: Int = 4, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, +) { + if (books.isEmpty()) return + // 限制最多显示 20 项 + val limitedBooks = books.take(20) + val pages = limitedBooks.chunked(rows) + val pagerState = rememberPagerState(pageCount = { pages.size }) + + HorizontalPager( + state = pagerState, + // 由于父容器已经有 16.dp padding,这里 start 设为 0 + contentPadding = PaddingValues(start = 0.dp, end = 100.dp), + pageSpacing = 12.dp, + modifier = modifier + .fillMaxWidth() + .fadingEdge(pagerState, gradientWidth = 16.dp), + ) { pageIndex -> + val page = pages[pageIndex] + GlassCard( + modifier = Modifier.fillMaxWidth(), + // 使用 MD3 标准容器色,增加微妙的深度感 + containerColor = LegadoTheme.colorScheme.surfaceContainerLow, + cornerRadius = 20.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp, horizontal = 12.dp) + ) { + for (book in page) { + GridRankingItem( + rank = pages.flatten().indexOf(book) + 1, + book = book, + onClick = { onClick(book) }, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) + } + // 占位逻辑 + repeat(rows - page.size) { + Spacer(modifier = Modifier.height(76.dp)) + } + } + } + } +} + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +private fun GridRankingItem( + rank: Int, + book: SearchBook, + onClick: () -> Unit, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onClick) + .padding(vertical = 4.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // 1. 封面 + CoilBookCover( + name = book.name, + author = book.author, + path = book.coverUrl, + sourceOrigin = book.origin, + modifier = Modifier.width(48.dp), + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + sharedCoverKey = bookCoverSharedElementKey(book.bookUrl) + ) + + // 2. 排名 + AppText( + text = "$rank", + style = LegadoTheme.typography.titleMedium, + fontWeight = FontWeight.Black, + fontStyle = if (rank <= 3) FontStyle.Italic else FontStyle.Normal, + color = if (rank <= 3) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.outline, + modifier = Modifier.width(32.dp), + textAlign = TextAlign.Center + ) + + // 3. 文字信息 + Column( + modifier = Modifier + .padding(start = 4.dp) + .weight(1f) + ) { + AppText( + text = book.name, + style = LegadoTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + val subTitle = buildString { + append(book.kind?.split(",")?.firstOrNull() ?: "") + if (book.author.isNotBlank()) { + if (isNotEmpty()) append(" · ") + append(book.author) + } + } + AppText( + text = subTitle, + style = LegadoTheme.typography.labelSmall, + color = LegadoTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp) + ) + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/modules/RankingModule.kt b/app/src/main/java/io/legado/app/ui/main/homepage/modules/RankingModule.kt new file mode 100644 index 000000000..549ab4996 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/homepage/modules/RankingModule.kt @@ -0,0 +1,146 @@ +package io.legado.app.ui.main.homepage.modules + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.legado.app.data.entities.SearchBook +import io.legado.app.domain.model.BookShelfState +import io.legado.app.ui.main.bookCoverSharedElementKey +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.widget.components.book.SearchBookListItem +import io.legado.app.ui.widget.components.card.GlassCard +import io.legado.app.ui.widget.components.icon.AppIcon +import io.legado.app.ui.widget.components.text.AppText +import kotlinx.collections.immutable.ImmutableList + +private const val INITIAL_COUNT = 5 +private const val MAX_COUNT = 20 + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun RankingModule( + books: ImmutableList, + onClick: (SearchBook) -> Unit, + modifier: Modifier = Modifier, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, +) { + var visibleCount by rememberSaveable { mutableIntStateOf(INITIAL_COUNT) } + val displayBooks = books.take(visibleCount) + + GlassCard( + modifier = modifier + .fillMaxWidth(), + containerColor = LegadoTheme.colorScheme.surfaceContainerLow, + cornerRadius = 16.dp + ) { + Column( + modifier = Modifier + .padding(top = 12.dp) + .animateContentSize() + ) { + // 显示书籍列表 + displayBooks.forEachIndexed { index, book -> + RankingItem( + rank = index + 1, + book = book, + onClick = onClick, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) + } + + if (books.size > INITIAL_COUNT) { + Box( + modifier = Modifier + .fillMaxWidth() + .clickable { + visibleCount = + if (visibleCount == INITIAL_COUNT) MAX_COUNT else INITIAL_COUNT + } + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + val isExpanded = visibleCount > INITIAL_COUNT + AppIcon( + imageVector = if (isExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.ExpandMore, + contentDescription = null, + tint = if (isExpanded) LegadoTheme.colorScheme.outline else LegadoTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + AppText( + text = if (isExpanded) "收起" else "显示全部", + style = LegadoTheme.typography.labelMediumEmphasized, + color = if (isExpanded) LegadoTheme.colorScheme.outline else LegadoTheme.colorScheme.primary, + modifier = Modifier.padding(start = 4.dp) + ) + } + } + } + } + } +} + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +private fun RankingItem( + rank: Int, + book: SearchBook, + onClick: (SearchBook) -> Unit, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick(book) } + .padding(vertical = 4.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AppText( + text = "$rank", + style = LegadoTheme.typography.titleLarge, + fontWeight = FontWeight.Black, + fontStyle = if (rank <= 3) FontStyle.Italic else FontStyle.Normal, + color = if (rank <= 3) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.outline, + textAlign = TextAlign.Center, + modifier = Modifier + .width(42.dp) + .padding(start = 2.dp, end = 10.dp), + ) + SearchBookListItem( + book = book, + shelfState = BookShelfState.NOT_IN_SHELF, + onClick = null, + showPadding = false, + modifier = Modifier.weight(1f), + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + sharedCoverKey = bookCoverSharedElementKey(book.bookUrl) + ) + } +} diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/modules/WaterfallModule.kt b/app/src/main/java/io/legado/app/ui/main/homepage/modules/WaterfallModule.kt new file mode 100644 index 000000000..5601ee729 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/homepage/modules/WaterfallModule.kt @@ -0,0 +1,126 @@ +package io.legado.app.ui.main.homepage.modules + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.legado.app.data.entities.SearchBook +import io.legado.app.ui.main.bookCoverSharedElementKey +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.widget.components.book.SearchBookTagChip +import io.legado.app.ui.widget.components.card.GlassCard +import io.legado.app.ui.widget.components.image.cover.CoilBookCover +import io.legado.app.ui.widget.components.text.AppText + +/** + * 瀑布流单项组件 + * 建议直接在 LazyVerticalStaggeredGrid 的 items 中使用,以获得最佳回收性能 + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun WaterfallItem( + book: SearchBook, + onClick: () -> Unit, + modifier: Modifier = Modifier, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, +) { + GlassCard( + containerColor = LegadoTheme.colorScheme.surfaceContainerLow + ) { + Column( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + ) { + CoilBookCover( + name = book.name, + author = book.author, + path = book.coverUrl, + radius = 16.dp, + sourceOrigin = book.origin, + modifier = Modifier + .fillMaxWidth(), + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + sharedCoverKey = bookCoverSharedElementKey(book.bookUrl) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .padding(bottom = 8.dp) + ) { + AppText( + text = book.name, + style = LegadoTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + val subTitle = buildString { + if (book.author.isNotBlank()) append(book.author) + val kind = book.kind?.split(",")?.firstOrNull() + if (!kind.isNullOrBlank()) { + if (isNotEmpty()) append(" · ") + append(kind) + } + } + if (subTitle.isNotBlank()) { + AppText( + text = subTitle, + style = LegadoTheme.typography.labelSmall, + color = LegadoTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp) + ) + } + + val intro = book.intro?.replace("\\s+".toRegex(), " ") + if (!intro.isNullOrBlank()) { + AppText( + text = intro, + style = LegadoTheme.typography.labelSmallEmphasized, + color = LegadoTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp) + ) + } + + val kinds = book.getKindList() + if (kinds.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + kinds.forEach { kind -> + SearchBookTagChip( + text = kind, + color = LegadoTheme.colorScheme.surfaceContainerHigh + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/theme/AdaptivePadding.kt b/app/src/main/java/io/legado/app/ui/theme/AdaptivePadding.kt index 504b0634a..df59a4bdf 100644 --- a/app/src/main/java/io/legado/app/ui/theme/AdaptivePadding.kt +++ b/app/src/main/java/io/legado/app/ui/theme/AdaptivePadding.kt @@ -64,6 +64,24 @@ fun adaptiveContentPadding( ) } +@Composable +fun adaptiveContentPadding( + top: Dp, + bottom: Dp, + miuixHorizontal: Dp, + m3Horizontal: Dp +): PaddingValues { + val horizontal = + if (ThemeResolver.isMiuixEngine(composeEngine)) miuixHorizontal else m3Horizontal + val adjustedTop = if (ThemeResolver.isMiuixEngine(composeEngine)) top + 12.dp else top + 16.dp + return PaddingValues( + top = adjustedTop, + bottom = bottom, + start = horizontal, + end = horizontal + ) +} + @Composable fun adaptiveContentPadding( top: Dp, diff --git a/app/src/main/java/io/legado/app/ui/theme/FadingEdge.kt b/app/src/main/java/io/legado/app/ui/theme/FadingEdge.kt index 6c40259d7..9e6ef7568 100644 --- a/app/src/main/java/io/legado/app/ui/theme/FadingEdge.kt +++ b/app/src/main/java/io/legado/app/ui/theme/FadingEdge.kt @@ -2,11 +2,11 @@ package io.legado.app.ui.theme import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.pager.PagerState import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.BlendMode @@ -52,29 +52,62 @@ fun Modifier.fadingEdge( /** * Convenience overload that derives fade alphas from a [LazyListState]. - * Left fade appears when scrolled past the start; right fade appears when more content is available. */ @Composable fun Modifier.fadingEdge( listState: LazyListState, gradientWidth: Dp = 24.dp ): Modifier { - val showLeft by remember { - derivedStateOf { - listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 - } - } - val showRight by remember { - derivedStateOf { listState.canScrollForward } - } val leftAlpha by animateFloatAsState( - targetValue = if (showLeft) 1f else 0f, - animationSpec = tween(200), + targetValue = if (listState.canScrollBackward) 1f else 0f, + animationSpec = tween(300), label = "LeftFadeAlpha" ) val rightAlpha by animateFloatAsState( - targetValue = if (showRight) 1f else 0f, - animationSpec = tween(200), + targetValue = if (listState.canScrollForward) 1f else 0f, + animationSpec = tween(300), + label = "RightFadeAlpha" + ) + return fadingEdge(leftAlpha, rightAlpha, gradientWidth) +} + +/** + * Convenience overload that derives fade alphas from a [PagerState]. + */ +@Composable +fun Modifier.fadingEdge( + pagerState: PagerState, + gradientWidth: Dp = 24.dp +): Modifier { + val leftAlpha by animateFloatAsState( + targetValue = if (pagerState.canScrollBackward) 1f else 0f, + animationSpec = tween(300), + label = "LeftFadeAlpha" + ) + val rightAlpha by animateFloatAsState( + targetValue = if (pagerState.canScrollForward) 1f else 0f, + animationSpec = tween(300), + label = "RightFadeAlpha" + ) + return fadingEdge(leftAlpha, rightAlpha, gradientWidth) +} + +/** + * Convenience overload that derives fade alphas from a [ScrollState]. + */ +@Composable +fun Modifier.fadingEdge( + scrollState: ScrollState, + gradientWidth: Dp = 24.dp +): Modifier { + val leftAlpha by animateFloatAsState( + targetValue = if (scrollState.canScrollBackward) 1f else 0f, + animationSpec = tween(300), + label = "LeftFadeAlpha" + ) + val rightAlpha by animateFloatAsState( + targetValue = if (scrollState.canScrollForward) 1f else 0f, + animationSpec = tween(300), label = "RightFadeAlpha" ) return fadingEdge(leftAlpha, rightAlpha, gradientWidth) diff --git a/app/src/main/java/io/legado/app/ui/theme/LegadoTheme.kt b/app/src/main/java/io/legado/app/ui/theme/LegadoTheme.kt index 9d6cba526..af5a39cea 100644 --- a/app/src/main/java/io/legado/app/ui/theme/LegadoTheme.kt +++ b/app/src/main/java/io/legado/app/ui/theme/LegadoTheme.kt @@ -74,7 +74,8 @@ data class LegadoColorScheme( val cardContainer: Color, val onCardContainer: Color, - val onSheetContent: Color + val onSheetContent: Color, + val cardPrimaryContainer: Color ) data class LegadoTypography( diff --git a/app/src/main/java/io/legado/app/ui/theme/ThemeColorSchemeOverride.kt b/app/src/main/java/io/legado/app/ui/theme/ThemeColorSchemeOverride.kt index c950b9929..b164d5175 100644 --- a/app/src/main/java/io/legado/app/ui/theme/ThemeColorSchemeOverride.kt +++ b/app/src/main/java/io/legado/app/ui/theme/ThemeColorSchemeOverride.kt @@ -74,7 +74,8 @@ fun ColorScheme.toLegadoColorScheme( onTertiaryFixedVariant = onTertiaryFixedVariant, cardContainer = primaryContainer.copy(alpha = 0.5f), onCardContainer = primary, - onSheetContent = surface + onSheetContent = surface, + cardPrimaryContainer = primaryContainer ) } diff --git a/app/src/main/java/io/legado/app/ui/theme/ThemeComponents.kt b/app/src/main/java/io/legado/app/ui/theme/ThemeComponents.kt index 9b7811ef0..447476427 100644 --- a/app/src/main/java/io/legado/app/ui/theme/ThemeComponents.kt +++ b/app/src/main/java/io/legado/app/ui/theme/ThemeComponents.kt @@ -12,12 +12,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily import io.legado.app.ui.config.themeConfig.ThemeConfig import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.theme.ThemeController -import top.yukonga.miuix.kmp.theme.TextStyles @Composable fun rememberCustomFont(fontPath: String?): FontFamily? { @@ -180,7 +180,9 @@ fun MiuixThemeWrapper( cardContainer = miuixColorScheme.surfaceContainer, onCardContainer = miuixColorScheme.onSurface, - onSheetContent = miuixColorScheme.surface.copy(alpha = 0.5f) + onSheetContent = miuixColorScheme.surface.copy(alpha = 0.5f), + cardPrimaryContainer = miuixColorScheme.primary.copy(alpha = 0.1f) + .compositeOver(miuixColorScheme.surface) ) } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/JsonConfigEditor.kt b/app/src/main/java/io/legado/app/ui/widget/components/JsonConfigEditor.kt new file mode 100644 index 000000000..3cf2f2153 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/JsonConfigEditor.kt @@ -0,0 +1,138 @@ +package io.legado.app.ui.widget.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import io.legado.app.ui.widget.components.settingItem.CompactDropdownSettingItem +import io.legado.app.ui.widget.components.settingItem.CompactSliderSettingItem +import io.legado.app.ui.widget.components.settingItem.CompactSwitchSettingItem +import io.legado.app.utils.GSON + +sealed class JsonKeyEditorConfig { + data class Slider(val range: ClosedFloatingPointRange, val steps: Int = 0) : + JsonKeyEditorConfig() + + data class Dropdown(val displayEntries: Array, val entryValues: Array) : + JsonKeyEditorConfig() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Dropdown) return false + return displayEntries.contentEquals(other.displayEntries) && entryValues.contentEquals( + other.entryValues + ) + } + + override fun hashCode(): Int { + return 31 * displayEntries.contentHashCode() + entryValues.contentHashCode() + } + } + + object Switch : JsonKeyEditorConfig() +} + +@Composable +fun JsonConfigEditor( + jsonString: String, + onJsonStringChange: (String) -> Unit, + modifier: Modifier = Modifier, + keyConfigs: Map = emptyMap() +) { + val jsonObject = remember(jsonString) { + runCatching { + JsonParser.parseString(jsonString).asJsonObject + }.getOrElse { JsonObject() } + } + + Column(modifier = modifier) { + jsonObject.entrySet().forEach { entry -> + val key = entry.key + val value = entry.value + val config = keyConfigs[key] + + val displayTitle = when (key) { + "columns" -> "列数 (Columns)" + "rows" -> "行数 (Rows)" + else -> key + } + + when { + (config is JsonKeyEditorConfig.Slider || key.contains("columns") || key.contains("rows")) && + value.isJsonPrimitive && value.asJsonPrimitive.isNumber -> { + val range = (config as? JsonKeyEditorConfig.Slider)?.range ?: 0f..10f + val steps = (config as? JsonKeyEditorConfig.Slider)?.steps ?: 10 + CompactSliderSettingItem( + title = displayTitle, + value = value.asFloat, + valueRange = range, + steps = steps, + onValueChange = { + val newObj = jsonObject.deepCopy() + if (it == it.toInt().toFloat()) { + newObj.addProperty(key, it.toInt()) + } else { + newObj.addProperty(key, it) + } + onJsonStringChange(GSON.toJson(newObj)) + } + ) + } + + config is JsonKeyEditorConfig.Dropdown -> { + CompactDropdownSettingItem( + title = displayTitle, + selectedValue = value.asString, + displayEntries = config.displayEntries, + entryValues = config.entryValues, + onValueChange = { + val newObj = jsonObject.deepCopy() + newObj.addProperty(key, it) + onJsonStringChange(GSON.toJson(newObj)) + } + ) + } + + (config is JsonKeyEditorConfig.Switch || (value.isJsonPrimitive && value.asJsonPrimitive.isBoolean)) -> { + CompactSwitchSettingItem( + title = displayTitle, + checked = value.asBoolean, + onCheckedChange = { + val newObj = jsonObject.deepCopy() + newObj.addProperty(key, it) + onJsonStringChange(GSON.toJson(newObj)) + } + ) + } + + else -> { + JsonRawEditor( + value = if (value.isJsonPrimitive) value.asString else GSON.toJson(value), + onValueChange = { + val newObj = jsonObject.deepCopy() + if (it.toLongOrNull() != null) { + newObj.addProperty(key, it.toLong()) + } else if (it.toDoubleOrNull() != null) { + newObj.addProperty(key, it.toDouble()) + } else if (it == "true" || it == "false") { + newObj.addProperty(key, it.toBoolean()) + } else { + try { + val element = JsonParser.parseString(it) + newObj.add(key, element) + } catch (_: Exception) { + newObj.addProperty(key, it) + } + } + onJsonStringChange(GSON.toJson(newObj)) + }, + label = displayTitle, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/JsonRawEditor.kt b/app/src/main/java/io/legado/app/ui/widget/components/JsonRawEditor.kt new file mode 100644 index 000000000..4c7e6959d --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/JsonRawEditor.kt @@ -0,0 +1,88 @@ +package io.legado.app.ui.widget.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AutoFixHigh +import androidx.compose.material.icons.filled.Compress +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import com.google.gson.GsonBuilder +import com.google.gson.JsonParser +import io.legado.app.ui.widget.components.button.SmallIconButton +import io.legado.app.ui.widget.components.text.AppText +import io.legado.app.utils.GSON + +@Composable +fun JsonRawEditor( + value: String, + onValueChange: (String) -> Unit, + label: String, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + AppText( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) + Row { + SmallIconButton( + onClick = { + runCatching { + val jsonElement = JsonParser.parseString(value) + onValueChange(GSON.toJson(jsonElement)) + } + }, + imageVector = Icons.Default.AutoFixHigh, + contentDescription = "格式化" + ) + SmallIconButton( + onClick = { + runCatching { + val jsonElement = JsonParser.parseString(value) + val compactGson = GsonBuilder().create() + onValueChange(compactGson.toJson(jsonElement)) + } + }, + imageVector = Icons.Default.Compress, + contentDescription = "压缩" + ) + } + } + + TextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 150.dp, max = 400.dp), + textStyle = TextStyle( + fontFamily = FontFamily.Monospace, + fontSize = MaterialTheme.typography.bodySmall.fontSize + ), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + ), + maxLines = 1000 + ) + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookItem.kt b/app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookItem.kt index 3d0ff56b1..f189d3dbe 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookItem.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookItem.kt @@ -4,7 +4,6 @@ import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize @@ -16,15 +15,17 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Shuffle -import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -32,6 +33,9 @@ import androidx.compose.ui.unit.dp import io.legado.app.data.entities.SearchBook import io.legado.app.domain.model.BookShelfState import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.theme.adaptiveHorizontalPadding +import io.legado.app.ui.theme.fadingEdge +import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.card.TextCard import io.legado.app.ui.widget.components.image.cover.CoilBookCover import io.legado.app.ui.widget.components.text.AppText @@ -41,8 +45,9 @@ import io.legado.app.ui.widget.components.text.AppText fun SearchBookListItem( book: SearchBook, shelfState: BookShelfState, - onClick: () -> Unit, + onClick: (() -> Unit)?, modifier: Modifier = Modifier, + showPadding: Boolean = true, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, sharedCoverKey: String? = null, @@ -50,8 +55,8 @@ fun SearchBookListItem( Row( modifier = modifier .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 8.dp) + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .then(if (showPadding) Modifier.adaptiveHorizontalPadding(vertical = 8.dp) else Modifier) ) { Box(modifier = Modifier .width(72.dp) @@ -113,13 +118,14 @@ fun SearchBookListItem( AppText( text = " • ", style = LegadoTheme.typography.bodySmall, - color = Color.Gray, + color = LegadoTheme.colorScheme.onSurfaceVariant, maxLines = 1, ) AppText( text = "最新: $latestChapter", style = LegadoTheme.typography.bodySmall, + color = LegadoTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -133,7 +139,7 @@ fun SearchBookListItem( AppText( text = intro, style = LegadoTheme.typography.labelSmall, - color = Color.Gray, + color = LegadoTheme.colorScheme.onSurfaceVariant, maxLines = 2, minLines = 2, overflow = TextOverflow.Ellipsis, @@ -143,8 +149,14 @@ fun SearchBookListItem( val kinds = book.getKindList() if (kinds.isNotEmpty()) { Spacer(modifier = Modifier.height(4.dp)) - Row(modifier = Modifier.horizontalScroll(rememberScrollState())) { - kinds.forEach { kind -> + val lazyListState = rememberLazyListState() + LazyRow( + state = lazyListState, + modifier = Modifier + .fillMaxWidth() + .fadingEdge(lazyListState, gradientWidth = 8.dp) + ) { + items(kinds) { kind -> SearchBookTagChip(text = kind) Spacer(modifier = Modifier.width(6.dp)) } @@ -168,8 +180,8 @@ fun SearchBookGridItem( Column( modifier = modifier .width(IntrinsicSize.Min) + .clip(RoundedCornerShape(4.dp)) .clickable(onClick = onClick) - .padding(4.dp) ) { Box( modifier = Modifier @@ -207,28 +219,35 @@ fun SearchBookGridItem( } } - Spacer(modifier = Modifier.height(4.dp)) - - AppText( - text = book.name, - style = LegadoTheme.typography.bodySmall, - fontWeight = FontWeight.Bold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 4.dp) + ) { + AppText( + text = book.name, + style = LegadoTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } } } @Composable -private fun SearchBookTagChip(text: String) { - Surface( - color = LegadoTheme.colorScheme.cardContainer, - shape = RoundedCornerShape(4.dp), +fun SearchBookTagChip( + text: String, + color: Color = LegadoTheme.colorScheme.surfaceContainerHigh +) { + GlassCard( + containerColor = color, + cornerRadius = 4.dp ) { AppText( text = text, modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), - style = LegadoTheme.typography.labelSmall, + style = LegadoTheme.typography.labelSmallEmphasized, color = LegadoTheme.colorScheme.onCardContainer, ) } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/icon/AppIcons.kt b/app/src/main/java/io/legado/app/ui/widget/components/icon/AppIcons.kt index 5afd32b94..8ffa9d51c 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/icon/AppIcons.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/icon/AppIcons.kt @@ -4,12 +4,14 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.LibraryBooks import androidx.compose.material.icons.automirrored.outlined.LibraryBooks +import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Explore import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.LocationSearching import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MyLocation @@ -19,6 +21,7 @@ import androidx.compose.material.icons.filled.RssFeed import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.outlined.Explore +import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.RssFeed import androidx.compose.runtime.Composable @@ -37,6 +40,7 @@ import top.yukonga.miuix.kmp.icon.extended.Edit import top.yukonga.miuix.kmp.icon.extended.Favorites import top.yukonga.miuix.kmp.icon.extended.Filter import top.yukonga.miuix.kmp.icon.extended.More +import top.yukonga.miuix.kmp.icon.extended.Notes import top.yukonga.miuix.kmp.icon.extended.Pin import top.yukonga.miuix.kmp.icon.extended.Refresh import top.yukonga.miuix.kmp.icon.extended.Settings @@ -81,6 +85,10 @@ object AppIcons { @Composable get() = if (isMiuix) MiuixIcons.Settings else Icons.Default.Settings + val BugReport: ImageVector + @Composable + get() = Icons.Default.BugReport + val PrecisionSearch: ImageVector @Composable get() = if (isMiuix) MiuixIcons.Pin else Icons.Default.MyLocation @@ -100,8 +108,14 @@ object AppIcons { @Composable fun mainDestination(destination: MainDestination, selected: Boolean): ImageVector { return when (destination) { - MainDestination.Bookshelf -> if (isMiuix) { + MainDestination.Home -> if (isMiuix) { if (selected) MiuixIcons.Regular.ContactsBook else MiuixIcons.Regular.ContactsBook + } else { + if (selected) Icons.Default.Home else Icons.Outlined.Home + } + + MainDestination.Bookshelf -> if (isMiuix) { + if (selected) MiuixIcons.Regular.Notes else MiuixIcons.Regular.Notes } else { if (selected) Icons.AutoMirrored.Filled.LibraryBooks else Icons.AutoMirrored.Outlined.LibraryBooks } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt b/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt index eea7a5477..bea491e26 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt @@ -24,6 +24,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -37,6 +38,7 @@ import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.core.graphics.withSave import coil.compose.AsyncImage @@ -51,6 +53,7 @@ fun CoilBookCover( name: String?, author: String?, path: String?, + radius: Dp = 4.dp, modifier: Modifier = Modifier.width(64.dp), sourceOrigin: String? = null, onLoadFinish: (() -> Unit)? = null, @@ -75,7 +78,7 @@ fun CoilBookCover( val hasCustomDefault = !randomPath.isNullOrBlank() var isOnlineCoverLoaded by remember(path) { - mutableStateOf(sharedCoverKey != null && finalPath != null) + mutableStateOf(false) } Box( @@ -94,61 +97,65 @@ fun CoilBookCover( ) .then( if (CoverConfig.coverShowShadow) { - Modifier.shadow(4.dp, RoundedCornerShape(4.dp)) + Modifier.shadow(4.dp, RoundedCornerShape(radius)) } else Modifier ) .background( if (!hasCustomDefault && !isOnlineCoverLoaded) { LegadoTheme.colorScheme.surfaceContainerLow } else Color.Transparent, - RoundedCornerShape(4.dp) + RoundedCornerShape(radius) ) - .clip(RoundedCornerShape(4.dp)) + .clip(RoundedCornerShape(radius)) ) { if (hasCustomDefault && !isOnlineCoverLoaded) { - AsyncImage( - model = buildCoverImageRequest( - context = context, - data = randomPath, - sourceOrigin = null, - loadOnlyWifi = false, - crossfade = showLoadingPlaceholder, - memoryCacheKey = randomPath, - ), - contentDescription = null, - imageLoader = koinInject(), - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxSize() - .clip(RoundedCornerShape(4.dp)) - ) + key(randomPath) { + AsyncImage( + model = buildCoverImageRequest( + context = context, + data = randomPath, + sourceOrigin = null, + loadOnlyWifi = false, + crossfade = showLoadingPlaceholder, + memoryCacheKey = randomPath, + ), + contentDescription = null, + imageLoader = koinInject(), + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(radius)) + ) + } } if (finalPath != null) { - AsyncImage( - model = buildCoverImageRequest( - context = context, - data = finalPath, - sourceOrigin = sourceOrigin, - loadOnlyWifi = CoverConfig.loadCoverOnlyWifi, - crossfade = showLoadingPlaceholder, - memoryCacheKey = finalPath, - ), - contentDescription = null, - imageLoader = koinInject(), - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxSize() - .clip(RoundedCornerShape(4.dp)), - onSuccess = { - isOnlineCoverLoaded = true - onLoadFinish?.invoke() - }, - onError = { - isOnlineCoverLoaded = false - onLoadFinish?.invoke() - } - ) + key(finalPath) { + AsyncImage( + model = buildCoverImageRequest( + context = context, + data = finalPath, + sourceOrigin = sourceOrigin, + loadOnlyWifi = CoverConfig.loadCoverOnlyWifi, + crossfade = showLoadingPlaceholder, + memoryCacheKey = finalPath, + ), + contentDescription = null, + imageLoader = koinInject(), + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(4.dp)), + onSuccess = { + isOnlineCoverLoaded = true + onLoadFinish?.invoke() + }, + onError = { + isOnlineCoverLoaded = false + onLoadFinish?.invoke() + } + ) + } } else { LaunchedEffect(Unit) { onLoadFinish?.invoke() diff --git a/app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/AppModalBottomSheet.kt b/app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/AppModalBottomSheet.kt index 26d1645cb..4340d45c3 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/AppModalBottomSheet.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/AppModalBottomSheet.kt @@ -81,7 +81,6 @@ fun AppModalBottomSheet( backgroundColor = sheetContainerColor, dragHandleColor = sheetDragHandleColor, onDismissRequest = onDismissRequest, - onDismissFinished = onDismissRequest, enableWindowDim = true, allowDismiss = true ) { @@ -90,6 +89,7 @@ fun AppModalBottomSheet( Column( modifier = Modifier .fillMaxWidth() + .padding(bottom = 24.dp) .animateContentSize(), content = content ) @@ -120,7 +120,7 @@ fun AppModalBottomSheet( Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp) + .padding(start = 16.dp, end = 16.dp, bottom = 24.dp) .heightIn(max = maxHeight) .animateContentSize() .then(modifier) diff --git a/app/src/main/java/io/legado/app/ui/widget/components/settingItem/ListSettingItem.kt b/app/src/main/java/io/legado/app/ui/widget/components/settingItem/ListSettingItem.kt index 0011e4821..073b5d01d 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/settingItem/ListSettingItem.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/settingItem/ListSettingItem.kt @@ -12,7 +12,7 @@ import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.ThemeResolver import io.legado.app.ui.widget.components.SplicedColumnDivider import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem -import top.yukonga.miuix.kmp.basic.SpinnerEntry +import top.yukonga.miuix.kmp.basic.DropdownItem import top.yukonga.miuix.kmp.preference.OverlaySpinnerPreference @Composable @@ -31,7 +31,7 @@ fun DropdownListSettingItem( if (ThemeResolver.isMiuixEngine(composeEngine)) { val selectedIndex = entryValues.indexOf(selectedValue).coerceAtLeast(0) val spinnerItems = displayEntries.map { display -> - SpinnerEntry(title = display) + DropdownItem(title = display) } OverlaySpinnerPreference( diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 2fea33bc7..f9ed4b244 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -63,6 +63,8 @@ 启用 搜索 书架 + 首页 + 首页模块 (JSON) 收藏夹 收藏 已收藏 @@ -583,6 +585,7 @@ 基本 搜索 发现 + 主页 详情 目录 正文 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 853b67b87..f6cbf1a74 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -37,6 +37,8 @@ 替換淨化-搜尋 自定義Epub導出章節 書架 + 首頁 + 首頁模塊 (JSON) 收藏夾 收藏 已收藏 @@ -546,6 +548,7 @@ 基本 搜索 發現 + 主頁 詳情 目錄 正文 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 0e080f5ac..cfe62e79a 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -39,6 +39,8 @@ 啟用 取代淨化-搜尋 書架 + 首頁 + 首頁模組 (JSON) 收藏夾 收藏 已收藏 @@ -555,6 +557,7 @@ 基本 搜尋 發現 + 主頁 詳情 目錄 正文 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1977edb8c..9a1047ae5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -64,6 +64,8 @@ Enable Search Replacement Bookshelf + Home + Homepage Modules (JSON) Favorites Favorite in Favorites @@ -604,6 +606,7 @@ Basic Search Discovery + Homepage Information Chapters Content diff --git a/app/version.properties b/app/version.properties index d0bcfc9a8..ec8ec3805 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,5 +1,5 @@ VERSION_MAJOR=3 VERSION_MINOR=26 -VERSION_PATCH=12 -VERSION_SUFFIX=0 +VERSION_PATCH=13 +VERSION_SUFFIX=1 # 1 = Pre, 0 = Release \ No newline at end of file From 7e35177bac39c162d626505c0278359654510a3d Mon Sep 17 00:00:00 2001 From: HapeLee <63206378+HapeLee@users.noreply.github.com> Date: Fri, 22 May 2026 02:47:40 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 3 +- .../assets/web/help/md/homepageModulesHelp.md | 319 ++++++------------ .../app/data/dao/HomepageCustomSetDao.kt | 5 + .../legado/app/data/dao/HomepageModuleDao.kt | 5 + .../repository/HomepageModulesRepository.kt | 4 + .../domain/gateway/HomepageModulesGateway.kt | 2 + .../app/domain/usecase/AddBookUseCase.kt | 20 +- .../ui/main/homepage/HomepageLayoutSheet.kt | 11 +- .../homepage/HomepageModuleManageSheet.kt | 185 +++++----- .../app/ui/main/homepage/HomepageScreen.kt | 21 +- .../homepage/HomepageSourceSelectSheet.kt | 2 +- .../app/ui/main/homepage/HomepageViewModel.kt | 74 ++-- .../homepage/modules/ButtonGroupModule.kt | 6 +- .../ui/main/homepage/modules/RankingModule.kt | 6 +- .../components/image/cover/CoilBookCover.kt | 4 +- app/src/main/res/values-zh-rCN/strings.xml | 59 ++++ app/src/main/res/values-zh-rHK/strings.xml | 59 ++++ app/src/main/res/values-zh-rTW/strings.xml | 59 ++++ app/src/main/res/values/strings.xml | 59 ++++ 19 files changed, 532 insertions(+), 371 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 017c01579..279d0ab39 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -101,8 +101,7 @@ "Read(//c/Users/**)", "Bash(Get-ChildItem -Path \"D:\\\\AndroidPrj\\\\legado-with-MD3\" -Directory -Depth 0)", "Bash(Select-Object Name)", - "PowerShell(Get-ChildItem -Path \"D:\\\\AndroidPrj\\\\legado-with-MD3\\\\app\\\\src\\\\main\\\\java\\\\io\\\\legado\\\\app\" -Directory -Depth 1 | ForEach-Object { $_.FullName.Replace\\(\"D:\\\\AndroidPrj\\\\legado-with-MD3\\\\app\\\\src\\\\main\\\\java\\\\io\\\\legado\\\\app\\\\\", \"\"\\) })", - "Bash(powershell *)" + "PowerShell(Get-ChildItem -Path \"D:\\\\AndroidPrj\\\\legado-with-MD3\\\\app\\\\src\\\\main\\\\java\\\\io\\\\legado\\\\app\" -Directory -Depth 1 | ForEach-Object { $_.FullName.Replace\\(\"D:\\\\AndroidPrj\\\\legado-with-MD3\\\\app\\\\src\\\\main\\\\java\\\\io\\\\legado\\\\app\\\\\", \"\"\\) })" ] } } diff --git a/app/src/main/assets/web/help/md/homepageModulesHelp.md b/app/src/main/assets/web/help/md/homepageModulesHelp.md index 0d4d4aa54..76d6347e5 100644 --- a/app/src/main/assets/web/help/md/homepageModulesHelp.md +++ b/app/src/main/assets/web/help/md/homepageModulesHelp.md @@ -1,240 +1,115 @@ -# 首页模块 (homepageModules) +# 首页模块 (Homepage Modules) 配置规范 -在书源编辑页的「发现」Tab 中,可以配置 `首页模块 (JSON)` 字段,声明该书源为首页提供哪些内容模块。 +书源的 `homepageModules` 字段允许开发者声明该书源在首页展示的内容模块。这些模块通过 JSON +数组进行定义,支持高度自定义的布局和数据来源。 -## 概述 +--- -- 每个模块代表首页上的一个内容区块(如轮播图、排行榜、网格书架等) -- 一个书源可以声明多个模块(例如同时提供"热门推荐"Banner 和"周榜"排行榜) -- 用户在首页可以自由拖拽排序、隐藏/显示各个模块 -- 模块内容来自书源的**发现(explore)**接口,通过 `kindTitle` 匹配分类URL +## 1. 数据结构 (Data Structure) -## JSON 格式 +`homepageModules` 是一个包含多个模块定义对象的 JSON 数组。 -`homepageModules` 是一个 JSON 数组,每个元素定义一个模块: +### 模块通用字段 + +| 字段 | 类型 | 必须 | 说明 | +|:-----------------|:---------|:---|:------------------------------------------------------| +| **key** | `String` | 是 | 模块唯一标识。建议使用 `[a-z0-9_]` 字符。用于保存用户的排序/显隐设置。 | +| **type** | `Enum` | 是 | 模块类型。定义了渲染方式和交互逻辑。详见 [模块类型](#2-模块类型-module-types)。 | +| **title** | `String` | 是 | 模块默认标题。用户可在本地自定义覆盖。 | +| **kindTitle** | `String` | 否 | 用于匹配书源「发现」规则中的分类标题。匹配成功后自动继承其 URL 和规则。 | +| **url** | `String` | 否 | 显式指定数据接口 URL。优先级高于 `kindTitle`。支持变量替换。 | +| **args** | `String` | 否 | 附加参数。在 `buttonGroup` 类型中为 JSON 数组字符串。 | +| **layoutConfig** | `Object` | 否 | 布局配置对象,用于调整列数、行数、图标等。详见 [布局配置](#3-布局配置-layoutconfig)。 | + +--- + +## 2. 模块类型 (Module Types) + +### 列表与轮播类 + +| 类型 (Type) | 描述 | 特点 | +|:----------|:------|:-----------------------| +| `banner` | 横滑轮播图 | 适合展示高权重的精品推荐,使用大图封面。 | +| `ranking` | 排行榜列表 | 垂直列表展示,带排名序号。 | +| `card` | 推荐卡片 | 横向滑动的卡片流,同时显示封面、标题及简介。 | + +### 网格类 + +| 类型 (Type) | 描述 | 特点 | +|:---------------|:------|:------------------| +| `grid` | 标准网格 | 最常用的展示形式。支持自定义行列。 | +| `gridRanking` | 网格排行榜 | 多行多列的排行展示。横向翻页。 | +| `infiniteGrid` | 无限网格 | 垂直滚动的网格流。无限加载。 | +| `waterfall` | 错位瀑布流 | 垂直错位排列的书架流。无限加载。 | + +### 功能类 + +| 类型 (Type) | 描述 | 特点 | +|:--------------|:------|:--------------------------------------------| +| `buttonGroup` | 快捷按钮组 | 渲染为一组圆形/图标按钮,支持自动填充宽度与自动分列。通常用于放置常用分类或功能入口。 | + +--- + +## 3. 布局配置 (LayoutConfig) + +通过 `layoutConfig` 对象,可以精细化控制模块的表现。 + +| 属性 (Property) | 类型 | 适用类型 | 默认值 | 说明 | +|:--------------|:---------|:------------------------------------|:----|:-----------------------------------------| +| `columns` | `Int` | `grid`, `waterfall`, `infiniteGrid` | 3 | 每行显示的列数。 | +| `icon` | `String` | `buttonGroup` | - | 按钮组的默认统一图标 URL。 | +| `icons` | `Object` | `buttonGroup` | - | 图标映射表。例:`{"排行": "http://path/to/icon"}`。 | + +--- + +## 4. 数据绑定逻辑 (Data Binding) + +1. **自动匹配**:如果提供了 `kindTitle`,系统会遍历书源 `exploreKinds()` 返回的列表。如果某个分类的 + `title` 与之完全一致,该模块将自动使用该分类的 `url`。 +2. **静态指定**:如果提供了 `url`,系统将直接请求该 URL。 +3. **降级逻辑**:若 `kindTitle` 未匹配且无 `url`,模块将回退至书源的主 `exploreUrl`。 + +--- + +## 5. 完整 JSON 示例 ```json [ { - "key": "模块唯一标识", - "type": "模块类型", - "title": "模块标题", - "kindTitle": "匹配的分类标题(可选)", - "url": "覆盖分类URL(可选)", - "args": "特殊参数(可选)", - "layoutConfig": { - "columns": 2, - "rows": 3 - } - } -] -``` - -### 字段说明 - -| 字段 | 类型 | 必填 | 说明 | -|----------------|-----------|----|-------------------------------------------------------------------------------------------------------| -| `key` | `String` | 是 | 模块在书源内的唯一标识,用于关联用户偏好。建议使用英文,如 `"hot"`, `"rank_week"` | -| `type` | `String` | 是 | 模块类型,可选值:`"banner"`, `"ranking"`, `"gridRanking"`, `"grid"`, `"card"`, `"waterfall"`, `"buttonGroup"` | -| `title` | `String` | 是 | 模块标题,会在首页模块头部展示 | -| `kindTitle` | `String?` | 否 | 用于匹配该书源「发现」中的分类标题。不填则使用默认 `exploreUrl` | -| `url` | `String?` | 否 | 显式指定该模块的 URL,优先级高于 `kindTitle` 匹配到的 URL | -| `args` | `String?` | 否 | 模块特有参数。在 `buttonGroup` 中为包含分类标题的 JSON 数组字符串 | -| `layoutConfig` | `Object?` | 否 | 布局配置对象。支持 `columns` (列数), `rows` (行数) | - -## 模块类型 - -### banner — 横滑轮播图 - -适合展示热门推荐、本周强推等内容。以大图封面横向滑动展示。 - -```json -{ - "key": "hot_banner", - "type": "banner", - "title": "热门推荐", - "kindTitle": "热门", - "displayCount": 6 -} -``` - -### ranking — 排行榜 - -带排名序号的列表。前三名高亮为橙色,其余为灰色。默认折叠显示前 5 本,末尾有"展开更多"按钮。 - -```json -{ - "key": "week_rank", - "type": "ranking", - "title": "周排行榜", - "kindTitle": "周榜", - "displayCount": 10 -} -``` - -### gridRanking — 网格排行 - -4×4 列式排列(可通过 `layoutConfig.rows` 修改行数),每页多本书,可横向翻页。封面较小,仅显示书名和作者。适合作为首页入口展示大量书籍。 - -```json -{ - "key": "all_grid", - "type": "gridRanking", - "title": "全部分类", - "kindTitle": "全部分类", - "layoutConfig": { - "rows": 4 - } -} -``` - -### grid — 网格书架 - -网格布局,适合展示分类书单。支持自定义行列。 - -- `columns`: 默认 3 -- `rows`: 默认 2。若设置为 `0`,则显示为平铺列表并支持下拉加载更多。 - -```json -{ - "key": "scifi_grid", - "type": "grid", - "title": "科幻精选", - "kindTitle": "科幻", - "layoutConfig": { - "columns": 3, - "rows": 2 - } -} -``` - -### card — 推荐卡片 - -大图横向滑动卡片,展示封面 + 书名 + 简介。适合需要更多信息展示的场景。 - -```json -{ - "key": "editor_pick", - "type": "card", - "title": "编辑推荐", - "kindTitle": "编辑推荐" -} -``` - -### waterfall — 错位瀑布流 - -瀑布流布局,展示封面 + 书名 + 简介。支持 `layoutConfig.columns` 自定义列数(默认 2)。支持无限加载更多。 - -```json -{ - "key": "hot_wf", - "type": "waterfall", - "title": "大家都在看", - "kindTitle": "热门", - "layoutConfig": { - "columns": 2 - } -} -``` - -### buttonGroup — 按钮组 - -显示为网格排列的分类按钮。适合放置常用的分类或动作入口。 - -- **布局特性**:自动平衡每行按钮数量(例如 6 个按钮显示为 3+3,8 个显示为 4+4)。每行最多 5 个。 -- **args**: 可选。由分类标题组成的 JSON 数组字符串,如 `["排行", "分类", "完本"]`。若不填则默认显示该书源前 - 5 个分类。 -- **layoutConfig**: - - `icon`: 全局默认图标。支持网络图片 URL。 - - `icons`: 图标映射对象。以分类标题为键,网络图片 URL 为值。 - -```json -{ - "key": "entry_buttons", - "type": "buttonGroup", - "title": "快捷入口", - "args": "[\"排行\", \"分类\", \"我的\"]", - "layoutConfig": { - "icon": "https://example.com/default.png", - "icons": { - "排行": "https://example.com/rank.png", - "我的": "https://example.com/my.png" - } - } -} -``` - -## kindTitle 匹配规则 - -模块加载时,系统会根据 `kindTitle` 在书源的「发现」分类列表中进行匹配: - -1. 在 `exploreKinds()`(即发现页的分类列表)中查找 `title` 等于 `kindTitle` 的分类 -2. 如果匹配成功,使用该分类的 `url` 来加载数据 -3. 如果匹配失败,或 `kindTitle` 为空,则使用书源的默认 `exploreUrl` -4. 如果两个 URL 都没有,该模块将显示加载错误 - -**建议**:确保 `kindTitle` 的值与发现页分类的标题**完全一致**(包括大小写和标点)。 - -## 完整示例 - -以下是一个书源配置了多种模块的完整 JSON: - -```json -[ - { - "key": "hot", + "key": "top_banner", "type": "banner", - "title": "热门推荐", - "kindTitle": "热门", - "displayCount": 6 + "title": "精品强推", + "kindTitle": "首页推荐" }, { - "key": "rank_week", + "key": "quick_nav", + "type": "buttonGroup", + "title": "分类导航", + "args": "[\"武侠\", \"仙侠\", \"都市\", \"历史\"]", + "layoutConfig": { + "icon": "https://example.com/icons/default.png", + "icons": { + "武侠": "https://example.com/icons/wuxia.png" + } + } + }, + { + "key": "hot_rank", "type": "ranking", - "title": "周榜", - "kindTitle": "周排行", - "displayCount": 10 + "title": "热门榜单", + "kindTitle": "排行榜", + "layoutConfig": { + "rows": 5 + } }, { - "key": "rank_month", - "type": "ranking", - "title": "月榜", - "kindTitle": "月排行", - "displayCount": 10 - }, - { - "key": "scifi", - "type": "grid", - "title": "科幻精选", - "kindTitle": "科幻", - "displayCount": 6 - }, - { - "key": "new_book", - "type": "card", - "title": "新书上架", - "kindTitle": "新书", - "displayCount": 8 + "key": "explore_waterfall", + "type": "waterfall", + "title": "发现更多", + "kindTitle": "全部", + "layoutConfig": { + "columns": 2 + } } ] ``` - -每个 `key` 必须唯一,系统会按 JSON 数组中的声明顺序创建模块标识。 - -## 用户体验 - -- **发现模块**:首页会自动出现配置了 `homepageModules` 的已启用书源所声明的模块 -- **拖拽排序**:长按模块标题旁的拖拽手柄可调整顺序 -- **隐藏模块**:编辑模式下可隐藏不需要的模块 -- **下拉刷新**:刷新后所有模块重新加载数据 -- **点击书籍**:跳转到书籍详情页 -- **点击模块标题**:跳转到该书源的完整发现页 - -## 注意事项 - -- JSON 格式必须**严格有效**,建议使用在线 JSON 校验工具检查 -- 如果 JSON 解析失败,该书源的所有模块将被静默跳过 -- 模块使用的图片从书籍封面(`coverUrl`)获取,请确保发现规则正确提取了封面 -- `homepageModules` 为空的旧书源不会报错,也不会有首页模块展示 -- 同一书源可配置多个同类型模块(如多个排行榜),只需 `key` 不同即可 -- 模块标识全局唯一 ID 格式为 `"{setId}::{书源URL}::{key}"`,其中 `setId` 用于区分模块所属的集(书源集或自定义集)。 diff --git a/app/src/main/java/io/legado/app/data/dao/HomepageCustomSetDao.kt b/app/src/main/java/io/legado/app/data/dao/HomepageCustomSetDao.kt index c127084b5..5eee9645d 100644 --- a/app/src/main/java/io/legado/app/data/dao/HomepageCustomSetDao.kt +++ b/app/src/main/java/io/legado/app/data/dao/HomepageCustomSetDao.kt @@ -25,6 +25,11 @@ interface HomepageCustomSetDao { @Query("UPDATE homepage_custom_sets SET sortOrder = :order WHERE id = :id") suspend fun setSortOrder(id: String, order: Int) + @androidx.room.Transaction + suspend fun batchSetSortOrders(orders: Map) { + orders.forEach { (id, order) -> setSortOrder(id, order) } + } + @Query("DELETE FROM homepage_custom_sets WHERE id = :id") suspend fun delete(id: String) } diff --git a/app/src/main/java/io/legado/app/data/dao/HomepageModuleDao.kt b/app/src/main/java/io/legado/app/data/dao/HomepageModuleDao.kt index a35af7897..3e4f84132 100644 --- a/app/src/main/java/io/legado/app/data/dao/HomepageModuleDao.kt +++ b/app/src/main/java/io/legado/app/data/dao/HomepageModuleDao.kt @@ -28,6 +28,11 @@ interface HomepageModuleDao { @Query("UPDATE homepage_modules SET sortOrder = :order WHERE id = :id") suspend fun setSortOrder(id: String, order: Int) + @androidx.room.Transaction + suspend fun batchSetSortOrders(orders: Map) { + orders.forEach { (id, order) -> setSortOrder(id, order) } + } + @Query("UPDATE homepage_modules SET customSetTitle = :title WHERE id = :id") suspend fun setCustomSetTitle(id: String, title: String?) diff --git a/app/src/main/java/io/legado/app/data/repository/HomepageModulesRepository.kt b/app/src/main/java/io/legado/app/data/repository/HomepageModulesRepository.kt index 4f7c0e905..555f5e992 100644 --- a/app/src/main/java/io/legado/app/data/repository/HomepageModulesRepository.kt +++ b/app/src/main/java/io/legado/app/data/repository/HomepageModulesRepository.kt @@ -34,6 +34,8 @@ class HomepageModulesRepository( moduleDao.setEnabled(id, enabled) override suspend fun setSortOrder(id: String, order: Int) = moduleDao.setSortOrder(id, order) + override suspend fun batchSetSortOrders(orders: Map) = + moduleDao.batchSetSortOrders(orders) override suspend fun setCustomSetId(id: String, setId: String?) = moduleDao.setCustomSetId(id, setId) @@ -55,6 +57,8 @@ class HomepageModulesRepository( override suspend fun setCustomSetSortOrder(id: String, order: Int) = customSetDao.setSortOrder(id, order) + override suspend fun batchSetCustomSetSortOrders(orders: Map) = + customSetDao.batchSetSortOrders(orders) override suspend fun createCustomSet(name: String): CustomSetItem { val entity = HomepageCustomSet( diff --git a/app/src/main/java/io/legado/app/domain/gateway/HomepageModulesGateway.kt b/app/src/main/java/io/legado/app/domain/gateway/HomepageModulesGateway.kt index 0cdd8baa4..7b03db6f2 100644 --- a/app/src/main/java/io/legado/app/domain/gateway/HomepageModulesGateway.kt +++ b/app/src/main/java/io/legado/app/domain/gateway/HomepageModulesGateway.kt @@ -15,6 +15,7 @@ interface HomepageModulesGateway { suspend fun upsertAll(modules: List) suspend fun setEnabled(id: String, enabled: Boolean) suspend fun setSortOrder(id: String, order: Int) + suspend fun batchSetSortOrders(orders: Map) suspend fun setCustomSetId(id: String, setId: String?) suspend fun setCustomSetTitle(id: String, title: String?) suspend fun delete(id: String) @@ -27,6 +28,7 @@ interface HomepageModulesGateway { // Custom set mutations suspend fun upsertCustomSet(set: CustomSetItem) suspend fun setCustomSetSortOrder(id: String, order: Int) + suspend fun batchSetCustomSetSortOrders(orders: Map) suspend fun createCustomSet(name: String): CustomSetItem suspend fun renameCustomSet(id: String, name: String) suspend fun deleteCustomSet(id: String) diff --git a/app/src/main/java/io/legado/app/domain/usecase/AddBookUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/AddBookUseCase.kt index d6af61224..4c2e59ed8 100644 --- a/app/src/main/java/io/legado/app/domain/usecase/AddBookUseCase.kt +++ b/app/src/main/java/io/legado/app/domain/usecase/AddBookUseCase.kt @@ -33,8 +33,9 @@ class AddBookUseCase( if (source == null) { for (bookSourcePart in hasBookUrlPattern) { try { - val bs = bookSourcePart.getBookSource()!! - if (bookUrl.matches(bs.bookUrlPattern!!.toRegex())) { + val bs = bookSourcePart.getBookSource() ?: continue + val pattern = bs.bookUrlPattern ?: continue + if (bookUrl.matches(pattern.toRegex())) { source = bs break } @@ -50,17 +51,16 @@ class AddBookUseCase( ) kotlin.runCatching { - WebBook.getBookInfoAwait(bookSource, book) - }.onSuccess { - val dbBook = bookRepository.getBook(it.name, it.author) + val bookInfo = WebBook.getBookInfoAwait(bookSource, book) + val dbBook = bookRepository.getBook(bookInfo.name, bookInfo.author) if (dbBook != null) { - val toc = WebBook.getChapterListAwait(bookSource, it).getOrThrow() - dbBook.migrateTo(it, toc) - bookRepository.insert(it) + val toc = WebBook.getChapterListAwait(bookSource, bookInfo).getOrThrow() + dbBook.migrateTo(bookInfo, toc) + bookRepository.insert(bookInfo) bookRepository.insertChapters(*toc.toTypedArray()) } else { - it.order = bookRepository.getMinOrder() - 1 - bookRepository.insert(it) + bookInfo.order = bookRepository.getMinOrder() - 1 + bookRepository.insert(bookInfo) } successCount++ onProgress(successCount) diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageLayoutSheet.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageLayoutSheet.kt index d25dab285..79dbe6104 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageLayoutSheet.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageLayoutSheet.kt @@ -5,7 +5,9 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import io.legado.app.R import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem @@ -34,13 +36,16 @@ fun HomepageLayoutSheet( AppModalBottomSheet( data = data, onDismissRequest = onDismissRequest, - title = "布局设置", + title = stringResource(R.string.homepage_layout_settings), ) { Column { DropdownListSettingItem( - title = "首页布局模式", + title = stringResource(R.string.homepage_layout_mode), selectedValue = layoutMode.toString(), - displayEntries = arrayOf("混合列表", "分源Tab"), + displayEntries = arrayOf( + stringResource(R.string.homepage_layout_mixed), + stringResource(R.string.homepage_layout_tabs) + ), entryValues = arrayOf("0", "1"), onValueChange = { onLayoutModeChange(it.toInt()) } ) diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleManageSheet.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleManageSheet.kt index ceb0bb2ab..ac998e93a 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleManageSheet.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleManageSheet.kt @@ -21,8 +21,6 @@ import androidx.compose.material.icons.filled.DriveFileRenameOutline import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.FilterList import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -32,14 +30,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.google.gson.JsonParser +import io.legado.app.R import io.legado.app.domain.model.HomepageModuleType import io.legado.app.domain.model.ModuleDef import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.widget.components.AppTextField import io.legado.app.ui.widget.components.JsonConfigEditor -import io.legado.app.ui.widget.components.JsonKeyEditorConfig import io.legado.app.ui.widget.components.JsonRawEditor import io.legado.app.ui.widget.components.alert.AppAlertDialog import io.legado.app.ui.widget.components.button.SecondaryButton @@ -104,7 +103,8 @@ fun HomepageModuleManageSheet( var selectedKindTitles by remember(data != null) { mutableStateOf>(emptySet()) } var showCustomSetAddModules by remember(data != null) { mutableStateOf(false) } var showAddButtonGroupDialog by remember(data != null) { mutableStateOf(false) } - var tempButtonGroupTitle by remember(data != null) { mutableStateOf("快捷操作") } + val defaultQuickActionsTitle = stringResource(R.string.homepage_quick_actions) + var tempButtonGroupTitle by remember(data != null) { mutableStateOf(defaultQuickActionsTitle) } val currentTargetSetId = remember(selectingSetUrl) { selectingSetUrl?.let { HomepageViewModel.customSetIdFromUrl(it) } @@ -130,15 +130,17 @@ fun HomepageModuleManageSheet( groupFilter = null }, title = when { - showCustomSetAddModules -> "添加模块" + showCustomSetAddModules -> stringResource(R.string.homepage_add_module) browsingSourceUrl != null && browsingDetail -> - browseSources.find { it.sourceUrl == browsingSourceUrl }?.sourceName ?: "模块列表" + browseSources.find { it.sourceUrl == browsingSourceUrl }?.sourceName + ?: stringResource(R.string.homepage_module_list) - showSourceBrowser || browsingSourceUrl != null -> "浏览书源模块" + showSourceBrowser || browsingSourceUrl != null -> stringResource(R.string.homepage_browse_source_modules) selectingSetUrl != null && HomepageViewModel.isCustomSetUrl(selectingSetUrl!!) -> - (sets.find { it.sourceUrl == selectingSetUrl }?.sourceName ?: "集详情") + (sets.find { it.sourceUrl == selectingSetUrl }?.sourceName + ?: stringResource(R.string.homepage_set_detail)) - else -> "首页模块管理" + else -> stringResource(R.string.homepage_module_manage) }, startAction = { if (showCustomSetAddModules) { @@ -179,7 +181,7 @@ fun HomepageModuleManageSheet( expanded = expanded, onDismissRequest = { expanded = false }) { RoundDropdownMenuItem( - text = "全部分组", + text = stringResource(R.string.homepage_all_groups), onClick = { groupFilter = null; expanded = false }, trailingIcon = if (groupFilter == null) { { AppIcon(Icons.Default.Check, null, Modifier.size(18.dp)) } @@ -221,7 +223,11 @@ fun HomepageModuleManageSheet( Column { AppTabRow( - tabTitles = listOf("已加入", "书源模块", "发现"), + tabTitles = listOf( + stringResource(R.string.homepage_tab_joined), + stringResource(R.string.homepage_tab_source_modules), + stringResource(R.string.homepage_tab_discover) + ), selectedTabIndex = browseTab, onTabSelected = { browseTab = it } ) @@ -234,7 +240,7 @@ fun HomepageModuleManageSheet( .padding(24.dp), contentAlignment = Alignment.Center ) { - AppText("暂无已加入的模块") + AppText(stringResource(R.string.homepage_no_joined_modules)) } } else { var listData by remember(displaySetUrl) { @@ -271,9 +277,9 @@ fun HomepageModuleManageSheet( ) { item(key = "header_standard") { AppText( - text = "标准模块 (可拖拽排序)", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, + text = stringResource(R.string.homepage_standard_modules_sortable), + style = LegadoTheme.typography.labelMedium, + color = LegadoTheme.colorScheme.primary, modifier = Modifier.padding( horizontal = 16.dp, vertical = 4.dp @@ -318,9 +324,8 @@ fun HomepageModuleManageSheet( ) ) AppText( - text = "底栏无限模块", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.secondary, + text = stringResource(R.string.homepage_infinite_module_bottom), + style = LegadoTheme.typography.labelMedium, modifier = Modifier.padding( horizontal = 16.dp, vertical = 4.dp @@ -333,7 +338,9 @@ fun HomepageModuleManageSheet( infiniteModules.firstOrNull() == module SelectionItemCard( title = module.title, - subtitle = HomepageModuleType.fromKey(module.type).title + if (isEffective) " · 当前生效" else " · 已被屏蔽", + subtitle = HomepageModuleType.fromKey(module.type).title + if (isEffective) stringResource( + R.string.homepage_status_in_effect + ) else stringResource(R.string.homepage_status_blocked), isEnabled = module.isVisible, containerColor = if (isEffective) LegadoTheme.colorScheme.surfaceContainerHigh else LegadoTheme.colorScheme.onSheetContent, onEnabledChange = { onToggleModule(module.id, it) }, @@ -363,7 +370,7 @@ fun HomepageModuleManageSheet( .padding(24.dp), contentAlignment = Alignment.Center ) { - AppText("该书源的 homepageModules JSON 为空") + AppText(stringResource(R.string.homepage_source_json_empty)) } } else { LazyColumn( @@ -376,7 +383,9 @@ fun HomepageModuleManageSheet( val isJoined = joinedKeys.contains(module.moduleKey) SelectionItemCard( title = module.title, - subtitle = module.moduleKey + if (isJoined) " · 已加入" else "", + subtitle = module.moduleKey + if (isJoined) stringResource( + R.string.homepage_status_joined + ) else "", containerColor = LegadoTheme.colorScheme.onSheetContent, isSelected = isJoined, inSelectionMode = true, @@ -405,7 +414,7 @@ fun HomepageModuleManageSheet( HomepageModuleType.entries.filter { it != HomepageModuleType.Unknown } } CompactDropdownSettingItem( - title = "模块类型", + title = stringResource(R.string.homepage_module_type), selectedValue = browseModuleType, displayEntries = typeList.map { it.title }.toTypedArray(), entryValues = typeList.map { it.key }.toTypedArray(), @@ -421,14 +430,14 @@ fun HomepageModuleManageSheet( contentAlignment = Alignment.Center ) { AppText( - "该书源暂无发现项", - color = MaterialTheme.colorScheme.onSurfaceVariant + stringResource(R.string.homepage_source_no_discover), + color = LegadoTheme.colorScheme.onSurfaceVariant ) } } else { AppText( - "选择项", - style = MaterialTheme.typography.labelMedium, + stringResource(R.string.homepage_select_items), + style = LegadoTheme.typography.labelMedium, modifier = Modifier.padding( horizontal = 16.dp, vertical = 4.dp @@ -462,7 +471,9 @@ fun HomepageModuleManageSheet( val isJoined = joinedKeys.contains(kindTitle) SelectionItemCard( title = kindTitle, - subtitle = kindUrl.take(60) + if (isJoined) " · 已加入" else "", + subtitle = kindUrl.take(60) + if (isJoined) stringResource( + R.string.homepage_status_joined + ) else "", containerColor = LegadoTheme.colorScheme.onSheetContent, isSelected = isJoined, inSelectionMode = true, @@ -482,7 +493,7 @@ fun HomepageModuleManageSheet( } Spacer(modifier = Modifier.height(12.dp)) SecondaryButton( - text = "+ 手动添加", + text = stringResource(R.string.homepage_manual_add), onClick = { addDialogPrefill = AddDialogPrefill(type = browseModuleType) }, @@ -511,7 +522,7 @@ fun HomepageModuleManageSheet( item(key = "header_$sourceUrl") { AppText( text = onGetSourceName(sourceUrl), - style = MaterialTheme.typography.labelLarge, + style = LegadoTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) ) } @@ -551,7 +562,7 @@ fun HomepageModuleManageSheet( val moduleCount = onGetSourceModules(source.sourceUrl, null).size SelectionItemCard( title = source.sourceName, - subtitle = "$moduleCount 个模块", + subtitle = stringResource(R.string.homepage_n_modules, moduleCount), containerColor = LegadoTheme.colorScheme.onSheetContent, onToggleSelection = { browsingSourceUrl = source.sourceUrl @@ -580,9 +591,9 @@ fun HomepageModuleManageSheet( .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - AppText("暂无模块") + AppText(stringResource(R.string.homepage_no_modules)) SecondaryButton( - text = "浏览书源模块添加", + text = stringResource(R.string.homepage_browse_to_add), onClick = { if (setId.startsWith("src_")) { browsingSourceUrl = setId.removePrefix("src_") @@ -623,8 +634,8 @@ fun HomepageModuleManageSheet( if (listData.isNotEmpty()) { item(key = "header_std_detail") { AppText( - text = "标准模块", - style = MaterialTheme.typography.labelMedium, + text = stringResource(R.string.homepage_standard_module), + style = LegadoTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) ) } @@ -659,16 +670,10 @@ fun HomepageModuleManageSheet( if (infiniteModules.isNotEmpty()) { item(key = "header_inf_detail") { - HorizontalDivider( - modifier = Modifier.padding( - vertical = 8.dp, - horizontal = 16.dp - ) - ) + PillDivider() AppText( - text = "无限模块槽位", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.secondary, + text = stringResource(R.string.homepage_infinite_module_slot), + style = LegadoTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) ) } @@ -697,7 +702,7 @@ fun HomepageModuleManageSheet( item(key = "browse_from_set") { SecondaryButton( - text = "浏览书源模块", + text = stringResource(R.string.homepage_browse_source_modules), onClick = { if (setId.startsWith("src_")) { browsingSourceUrl = setId.removePrefix("src_") @@ -746,7 +751,7 @@ fun HomepageModuleManageSheet( state = setsReorderableState, key = set.sourceUrl, title = set.sourceName, - subtitle = "${set.moduleCount} 个模块", + subtitle = stringResource(R.string.homepage_n_modules, set.moduleCount), containerColor = LegadoTheme.colorScheme.onSheetContent, isEnabled = set.isSelected, onToggleSelection = { selectingSetUrl = set.sourceUrl }, @@ -771,14 +776,14 @@ fun HomepageModuleManageSheet( } item(key = "create_set") { SecondaryButton( - text = "+ 新建自定义集", + text = stringResource(R.string.homepage_new_custom_set), onClick = { showCreateSetDialog = true }, modifier = Modifier.fillMaxWidth() ) } item(key = "browse_sources") { SecondaryButton( - text = "浏览书源模块", + text = stringResource(R.string.homepage_browse_source_modules), onClick = { showSourceBrowser = true }, modifier = Modifier.fillMaxWidth() ) @@ -792,7 +797,7 @@ fun HomepageModuleManageSheet( AppAlertDialog( data = renameSetId, onDismissRequest = { renameSetId = null }, - title = "重命名自定义集", + title = stringResource(R.string.homepage_rename_custom_set), content = { setId -> val currentName = remember(setId) { sets.find { it.sourceUrl == setId }?.sourceName ?: "" } @@ -800,7 +805,7 @@ fun HomepageModuleManageSheet( AppTextField( value = tempName, onValueChange = { tempName = it }, - label = "名称", + label = stringResource(R.string.homepage_name_label), modifier = Modifier.fillMaxWidth() ) }, @@ -811,21 +816,21 @@ fun HomepageModuleManageSheet( ) renameSetId = null }, - confirmText = "确定", - dismissText = "取消", + confirmText = stringResource(R.string.dialog_confirm), + dismissText = stringResource(R.string.dialog_cancel), onDismiss = { renameSetId = null } ) AppAlertDialog( data = if (showCreateSetDialog) Unit else null, onDismissRequest = { showCreateSetDialog = false }, - title = "新建自定义集", + title = stringResource(R.string.homepage_new_custom_set_title), content = { LaunchedEffect(Unit) { tempName = "" } AppTextField( value = tempName, onValueChange = { tempName = it }, - label = "名称", + label = stringResource(R.string.homepage_name_label), modifier = Modifier.fillMaxWidth() ) }, @@ -833,36 +838,36 @@ fun HomepageModuleManageSheet( if (tempName.isNotBlank()) onCreateCustomSet(tempName) showCreateSetDialog = false }, - confirmText = "确定", - dismissText = "取消", + confirmText = stringResource(R.string.dialog_confirm), + dismissText = stringResource(R.string.dialog_cancel), onDismiss = { showCreateSetDialog = false } ) AppAlertDialog( data = deleteSetConfirmId, onDismissRequest = { deleteSetConfirmId = null }, - title = "删除自定义集", - text = "确定要删除该集及其包含的所有模块副本吗?", + title = stringResource(R.string.homepage_delete_custom_set), + text = stringResource(R.string.homepage_delete_custom_set_confirm), onConfirm = { setId -> onDeleteCustomSet(HomepageViewModel.customSetIdFromUrl(setId)) deleteSetConfirmId = null }, - confirmText = "删除", - dismissText = "取消", + confirmText = stringResource(R.string.delete), + dismissText = stringResource(R.string.dialog_cancel), onDismiss = { deleteSetConfirmId = null } ) AppAlertDialog( data = deleteConfirmId, onDismissRequest = { deleteConfirmId = null }, - title = "移除模块", - text = "确定要从当前集中移除该模块吗?", + title = stringResource(R.string.homepage_remove_module), + text = stringResource(R.string.homepage_remove_module_confirm), onConfirm = { id -> onDeleteModule(id) deleteConfirmId = null }, - confirmText = "移除", - dismissText = "取消", + confirmText = stringResource(R.string.remove), + dismissText = stringResource(R.string.dialog_cancel), onDismiss = { deleteConfirmId = null } ) @@ -900,13 +905,14 @@ fun HomepageModuleManageSheet( AppAlertDialog( data = if (showAddButtonGroupDialog) Unit else null, onDismissRequest = { showAddButtonGroupDialog = false }, - title = "添加按钮组", + title = stringResource(R.string.homepage_add_button_group), content = { - LaunchedEffect(Unit) { tempButtonGroupTitle = "快捷操作" } + val quickActionsLabel = stringResource(R.string.homepage_quick_actions) + LaunchedEffect(Unit) { tempButtonGroupTitle = quickActionsLabel } AppTextField( value = tempButtonGroupTitle, onValueChange = { tempButtonGroupTitle = it }, - label = "模块标题", + label = stringResource(R.string.homepage_module_title_label), modifier = Modifier.fillMaxWidth() ) }, @@ -920,21 +926,21 @@ fun HomepageModuleManageSheet( selectedKindTitles = emptySet() showAddButtonGroupDialog = false }, - confirmText = "确定", - dismissText = "取消", + confirmText = stringResource(R.string.dialog_confirm), + dismissText = stringResource(R.string.dialog_cancel), onDismiss = { showAddButtonGroupDialog = false } ) AppAlertDialog( data = customSetTitleEdit, onDismissRequest = { customSetTitleEdit = null }, - title = "自定义标题", + title = stringResource(R.string.homepage_custom_title), content = { (_, title) -> LaunchedEffect(title) { titleState = title } AppTextField( value = titleState, onValueChange = { titleState = it }, - label = "标题", + label = stringResource(R.string.homepage_title_label), modifier = Modifier.fillMaxWidth() ) }, @@ -942,8 +948,8 @@ fun HomepageModuleManageSheet( onSetCustomSetTitle(id, titleState.takeIf { it.isNotBlank() }) customSetTitleEdit = null }, - confirmText = "确定", - dismissText = "取消", + confirmText = stringResource(R.string.dialog_confirm), + dismissText = stringResource(R.string.dialog_cancel), onDismiss = { customSetTitleEdit = null } ) } @@ -974,24 +980,11 @@ fun AddCustomModuleDialog( var layoutConfig by remember(data) { mutableStateOf(prefillLayoutConfig) } var showRawLayoutConfig by remember(data) { mutableStateOf(false) } - val layoutKeyConfigs = remember { - mapOf( - "fullWidth" to JsonKeyEditorConfig.Switch, - "showTitle" to JsonKeyEditorConfig.Switch, - "showMore" to JsonKeyEditorConfig.Switch, - "isInfinite" to JsonKeyEditorConfig.Switch, - "aspectRatio" to JsonKeyEditorConfig.Dropdown( - displayEntries = arrayOf("默认", "1:1", "3:4", "2:3", "16:9"), - entryValues = arrayOf("", "1:1", "3:4", "2:3", "16:9") - ) - ) - } - val hasVisualizableKeys = remember(layoutConfig) { runCatching { val jsonObject = JsonParser.parseString(layoutConfig).asJsonObject jsonObject.keySet().any { key -> - key == "columns" || key == "rows" || layoutKeyConfigs.containsKey(key) + key == "columns" || key == "rows" } }.getOrElse { false } } @@ -999,7 +992,9 @@ fun AddCustomModuleDialog( AppAlertDialog( data = data, onDismissRequest = onDismissRequest, - title = if (prefillTitle.isEmpty()) "添加模块" else "编辑模块", + title = if (prefillTitle.isEmpty()) stringResource(R.string.homepage_add_module) else stringResource( + R.string.homepage_edit_module + ), content = { Column( modifier = Modifier @@ -1011,7 +1006,7 @@ fun AddCustomModuleDialog( AppTextField( value = title, onValueChange = { title = it }, - label = "标题", + label = stringResource(R.string.homepage_title_label), modifier = Modifier.fillMaxWidth() ) AppTextField( @@ -1024,7 +1019,7 @@ fun AddCustomModuleDialog( HomepageModuleType.entries.filter { it != HomepageModuleType.Unknown } } DropdownListSettingItem( - title = "类型", + title = stringResource(R.string.homepage_type_label), selectedValue = type, displayEntries = typeList.map { it.title }.toTypedArray(), entryValues = typeList.map { it.key }.toTypedArray(), @@ -1037,20 +1032,18 @@ fun AddCustomModuleDialog( modifier = Modifier.fillMaxWidth() ) AppText( - text = "布局配置", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, + text = stringResource(R.string.homepage_layout_config_label), + style = LegadoTheme.typography.labelMedium, modifier = Modifier.padding(top = 16.dp, bottom = 4.dp) ) if (hasVisualizableKeys) { JsonConfigEditor( jsonString = layoutConfig, onJsonStringChange = { layoutConfig = it }, - keyConfigs = layoutKeyConfigs, modifier = Modifier.fillMaxWidth() ) CompactClickableSettingItem( - title = "编辑原始 JSON (LayoutConfig)", + title = stringResource(R.string.homepage_edit_raw_json), onClick = { showRawLayoutConfig = !showRawLayoutConfig } ) if (showRawLayoutConfig) { @@ -1082,8 +1075,8 @@ fun AddCustomModuleDialog( ) ) }, - confirmText = "确定", - dismissText = "取消", + confirmText = stringResource(R.string.dialog_confirm), + dismissText = stringResource(R.string.dialog_cancel), onDismiss = onDismissRequest ) } diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt index eabaa9403..987ba600a 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt @@ -44,11 +44,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.legado.app.R import io.legado.app.domain.model.HomepageModuleType import io.legado.app.ui.main.bookCoverSharedElementKey import io.legado.app.ui.main.homepage.modules.BannerModule @@ -102,6 +104,7 @@ fun HomepageScreen( }) val mixedGridState = rememberLazyStaggeredGridState() + val homeString = stringResource(R.string.home) val currentTitle by remember( layoutMode, pagerState.currentPage, @@ -110,16 +113,16 @@ fun HomepageScreen( ) { derivedStateOf { if (layoutMode == 1) { - "首页" + homeString } else { val firstHeader = mixedGridState.layoutInfo.visibleItemsInfo.firstOrNull { (it.key as? String)?.startsWith("header_") == true } if (firstHeader != null) { val id = (firstHeader.key as? String).orEmpty().substringAfter("header_", "") - uiState.modules.find { it.globalId == id }?.setName ?: "首页" + uiState.modules.find { it.globalId == id }?.setName ?: homeString } else { - "首页" + homeString } } } @@ -198,7 +201,7 @@ fun HomepageScreen( } else { if (selectedSets.isEmpty()) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - AppText("未选择任何书源集") + AppText(stringResource(R.string.homepage_no_source_sets_selected)) } } else { HorizontalPager( @@ -234,13 +237,13 @@ fun HomepageScreen( AppAlertDialog( data = errorMsg, onDismissRequest = { errorMsg = null }, - title = "模块错误", - confirmText = "复制", + title = stringResource(R.string.homepage_module_error), + confirmText = stringResource(R.string.copy_text), onConfirm = { context.sendToClip(it) errorMsg = null }, - dismissText = "关闭", + dismissText = stringResource(R.string.close), onDismiss = { errorMsg = null } ) @@ -316,7 +319,7 @@ private fun ModuleList( ) { if (modules.isEmpty()) { Box(modifier = modifier, contentAlignment = Alignment.Center) { - AppText("请在书源中添加首页模块定义") + AppText(stringResource(R.string.homepage_add_module_definition)) } } else { // 1. 过滤和重排模块:每个集只能有一个无限流模块,且必须在最下面 @@ -403,7 +406,7 @@ private fun ModuleList( ) Spacer(modifier = Modifier.height(4.dp)) SecondaryButton( - text = "重试", + text = stringResource(R.string.retry), onClick = { viewModel.retryModule(moduleUi.globalId) } diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageSourceSelectSheet.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageSourceSelectSheet.kt index 58bb0e5dc..520264995 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageSourceSelectSheet.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageSourceSelectSheet.kt @@ -45,7 +45,7 @@ fun HomepageSourceSelectSheet( AppModalBottomSheet( show = show, onDismissRequest = onDismissRequest, - title = "筛选书源", + title = stringResource(R.string.homepage_filter_sources), ) { Column { SearchBar( diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt index 8cc1edf18..8bdf63423 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt @@ -2,6 +2,7 @@ package io.legado.app.ui.main.homepage import android.app.Application import androidx.lifecycle.viewModelScope +import io.legado.app.R import io.legado.app.base.BaseViewModel import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.SearchBook @@ -90,6 +91,7 @@ class HomepageViewModel( private val localModulesFlow = gateway.flowEnabled() private val _bookSourcesCache = MutableStateFlow>(emptyMap()) + private val _layoutConfigCache = MutableStateFlow>>(emptyMap()) val allModulesCache = gateway.flowAll() .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) @@ -115,8 +117,8 @@ class HomepageViewModel( ) { grouped, contentStates, flags, sourcesCache, customSets -> val setNames = customSets.associate { it.id to it.name } val sortedSetIds = customSets.sortedBy { it.sortOrder }.map { it.id } + val configCache = _layoutConfigCache.value - // 按照集排序设置来排布模块 val displayModules = sortedSetIds.flatMap { setId -> val setUrl = customSetUrl(setId) val mods = grouped[setUrl] ?: emptyList() @@ -125,17 +127,7 @@ class HomepageViewModel( val sourceName = source?.bookSourceName ?: module.sourceUrl val setName = module.customSetId?.let { setNames[it] } ?: sourceName val exploreUrl = module.url ?: source?.exploreUrl - - val configMap = mutableMapOf() - module.layoutConfig?.let { configStr -> - try { - val json = GSON.fromJson(configStr, Map::class.java) - json?.forEach { (k, v) -> - configMap["layout_$k"] = v.toString() - } - } catch (_: Exception) { - } - } + val configMap = configCache[module.id] ?: emptyMap() HomepageModuleUi( sourceUrl = module.sourceUrl, @@ -203,6 +195,26 @@ class HomepageViewModel( private val _pendingUserModules = MutableStateFlow>(emptyList()) init { + // 解析并缓存模块 layoutConfig,避免在 combine 中重复解析 + viewModelScope.launch { + localModulesFlow.collect { modules -> + val cache = mutableMapOf>() + for (module in modules) { + val configStr = module.layoutConfig ?: continue + try { + val json = GSON.fromJson(configStr, Map::class.java) + if (json != null) { + val map = mutableMapOf() + json.forEach { (k, v) -> map["layout_$k"] = v.toString() } + cache[module.id] = map + } + } catch (_: Exception) { + } + } + _layoutConfigCache.value = cache + } + } + // sync: 只处理有 homepageModules 的书源 viewModelScope.launch { initModulesSyncFlow.collect { sources -> @@ -438,7 +450,14 @@ class HomepageViewModel( states[globalId] as? ModuleLoadState.Loaded ?: return@update states states + (globalId to lastState.copy(isLoadingMore = false)) } - _effects.tryEmit(HomepageEffect.ShowSnackbar("加载更多失败: ${e.message}")) + _effects.tryEmit( + HomepageEffect.ShowSnackbar( + getApplication().getString( + R.string.homepage_load_more_failed, + e.message ?: "" + ) + ) + ) } } } @@ -529,7 +548,13 @@ class HomepageViewModel( } if (hasInfinite) { viewModelScope.launch { - _effects.emit(HomepageEffect.ShowSnackbar("该分组已存在无限加载模块")) + _effects.emit( + HomepageEffect.ShowSnackbar( + getApplication().getString( + R.string.homepage_module_duplicate_infinite + ) + ) + ) } return } @@ -603,19 +628,18 @@ class HomepageViewModel( fun reorderJoinedModules(orderedIds: List) { viewModelScope.launch { - orderedIds.forEachIndexed { index, id -> - gateway.setSortOrder(id, index) - } + val orders = orderedIds.mapIndexed { index, id -> id to index }.toMap() + gateway.batchSetSortOrders(orders) notifyConfigChanged() } } fun reorderCustomSets(orderedUrls: List) { viewModelScope.launch { - orderedUrls.forEachIndexed { index, url -> - val id = customSetIdFromUrl(url) - gateway.setCustomSetSortOrder(id, index) - } + val orders = orderedUrls.mapIndexed { index, url -> + customSetIdFromUrl(url) to index + }.toMap() + gateway.batchSetCustomSetSortOrders(orders) notifyConfigChanged() } } @@ -749,7 +773,13 @@ class HomepageViewModel( } if (hasInfinite) { viewModelScope.launch { - _effects.emit(HomepageEffect.ShowSnackbar("该分组已存在无限加载模块")) + _effects.emit( + HomepageEffect.ShowSnackbar( + getApplication().getString( + R.string.homepage_module_duplicate_infinite + ) + ) + ) } return } diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/modules/ButtonGroupModule.kt b/app/src/main/java/io/legado/app/ui/main/homepage/modules/ButtonGroupModule.kt index 4eab8e64a..940a3faaf 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/modules/ButtonGroupModule.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/modules/ButtonGroupModule.kt @@ -133,7 +133,7 @@ fun ButtonGroupModule( verticalArrangement = Arrangement.Center, modifier = Modifier .fillMaxSize() - .padding(vertical = 12.dp, horizontal = 4.dp) + .padding(vertical = 8.dp, horizontal = 4.dp) ) { if (hasIcon) { SourceIcon( @@ -143,12 +143,12 @@ fun ButtonGroupModule( } ) - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(6.dp)) } AppText( text = displayName, - style = LegadoTheme.typography.labelMedium, + style = LegadoTheme.typography.labelSmallEmphasized, textAlign = TextAlign.Center, maxLines = 1, overflow = TextOverflow.Clip, diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/modules/RankingModule.kt b/app/src/main/java/io/legado/app/ui/main/homepage/modules/RankingModule.kt index 549ab4996..33d47075d 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/modules/RankingModule.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/modules/RankingModule.kt @@ -22,10 +22,12 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import io.legado.app.R import io.legado.app.data.entities.SearchBook import io.legado.app.domain.model.BookShelfState import io.legado.app.ui.main.bookCoverSharedElementKey @@ -93,7 +95,9 @@ fun RankingModule( modifier = Modifier.size(20.dp) ) AppText( - text = if (isExpanded) "收起" else "显示全部", + text = if (isExpanded) stringResource(R.string.homepage_collapse) else stringResource( + R.string.homepage_show_all + ), style = LegadoTheme.typography.labelMediumEmphasized, color = if (isExpanded) LegadoTheme.colorScheme.outline else LegadoTheme.colorScheme.primary, modifier = Modifier.padding(start = 4.dp) diff --git a/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt b/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt index bea491e26..1b42add4d 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt @@ -77,8 +77,8 @@ fun CoilBookCover( } val hasCustomDefault = !randomPath.isNullOrBlank() - var isOnlineCoverLoaded by remember(path) { - mutableStateOf(false) + var isOnlineCoverLoaded by remember(path, sharedCoverKey, finalPath) { + mutableStateOf(sharedCoverKey != null && finalPath != null) } Box( diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index f9ed4b244..2cd79fc46 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1532,4 +1532,63 @@ 显示WebView日志 优先缓存 无限制 + 加载更多失败: %1$s + 该分组已存在无限加载模块 + 快捷操作 + 添加模块 + 模块列表 + 浏览书源模块 + 集详情 + 首页模块管理 + 全部分组 + 已加入 + 书源模块 + 发现 + 暂无已加入的模块 + 标准模块 (可拖拽排序) + 底栏无限模块 + 当前生效 + 已被屏蔽 + 该书源的 homepageModules JSON 为空 + 已加入 + 模块类型 + 该书源暂无发现项 + 选择项 + + 手动添加 + %1$d 个模块 + 暂无模块 + 浏览书源模块添加 + 标准模块 + 无限模块槽位 + + 新建自定义集 + 重命名自定义集 + 名称 + 新建自定义集 + 删除自定义集 + 确定要删除该集及其包含的所有模块副本吗? + 移除模块 + 确定要从当前集中移除该模块吗? + 添加按钮组 + 模块标题 + 自定义标题 + 标题 + 编辑模块 + 类型 + 布局配置 + 编辑原始 JSON (LayoutConfig) + 布局设置 + 首页布局模式 + 混合列表 + 分源Tab + 筛选书源 + 未选择任何书源集 + 模块错误 + 请在书源中添加首页模块定义 + 收起 + 显示全部 + 默认 + 移除 + · 当前生效 + · 已被屏蔽 + · 已加入 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index f6cbf1a74..5e1ef53e6 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -1312,4 +1312,63 @@ 优先缓存 无限制 %1$d 本 + 載入更多失敗: %1$s + 該分組已存在無限載入模塊 + 快捷操作 + 添加模塊 + 模塊列表 + 瀏覽書源模塊 + 集詳情 + 首頁模塊管理 + 全部分組 + 已加入 + 書源模塊 + 發現 + 暫無已加入的模塊 + 標準模塊 (可拖拽排序) + 底欄無限模塊 + 當前生效 + 已被屏蔽 + 該書源的 homepageModules JSON 為空 + 已加入 + 模塊類型 + 該書源暫無發現項 + 選擇項 + + 手動添加 + %1$d 個模塊 + 暫無模塊 + 瀏覽書源模塊添加 + 標準模塊 + 無限模塊槽位 + + 新建自定義集 + 重命名自定義集 + 名稱 + 新建自定義集 + 刪除自定義集 + 確定要刪除該集及其包含的所有模塊副本嗎? + 移除模塊 + 確定要從當前集中移除該模塊嗎? + 添加按鈕組 + 模塊標題 + 自定義標題 + 標題 + 編輯模塊 + 類型 + 佈局配置 + 編輯原始 JSON (LayoutConfig) + 佈局設置 + 首頁佈局模式 + 混合列表 + 分源Tab + 篩選書源 + 未選擇任何書源集 + 模塊錯誤 + 請在書源中添加首頁模塊定義 + 收起 + 顯示全部 + 預設 + 移除 + · 當前生效 + · 已被屏蔽 + · 已加入 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index cfe62e79a..47c18be04 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1315,4 +1315,63 @@ 优先缓存 无限制 %1$d 本 + 載入更多失敗: %1$s + 該分組已存在無限載入模組 + 快捷操作 + 添加模組 + 模組列表 + 瀏覽書源模組 + 集詳情 + 首頁模組管理 + 全部分組 + 已加入 + 書源模組 + 發現 + 暫無已加入的模組 + 標準模組 (可拖拽排序) + 底欄無限模組 + 當前生效 + 已被屏蔽 + 該書源的 homepageModules JSON 為空 + 已加入 + 模組類型 + 該書源暫無發現項 + 選擇項 + + 手動添加 + %1$d 個模組 + 暫無模組 + 瀏覽書源模組添加 + 標準模組 + 無限模組槽位 + + 新建自定義集 + 重命名自定義集 + 名稱 + 新建自定義集 + 刪除自定義集 + 確定要刪除該集及其包含的所有模組副本嗎? + 移除模組 + 確定要從當前集中移除該模組嗎? + 添加按鈕組 + 模組標題 + 自定義標題 + 標題 + 編輯模組 + 類型 + 佈局配置 + 編輯原始 JSON (LayoutConfig) + 佈局設置 + 首頁佈局模式 + 混合列表 + 分源Tab + 篩選書源 + 未選擇任何書源集 + 模組錯誤 + 請在書源中添加首頁模組定義 + 收起 + 顯示全部 + 預設 + 移除 + · 目前生效 + · 已被封鎖 + · 已加入 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9a1047ae5..c3ec19176 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1538,4 +1538,63 @@ Download Image Cache Network + Failed to load more: %1$s + This group already has an infinite loading module + Quick Actions + Add Module + Module List + Browse Source Modules + Set Detail + Homepage Module Manager + All Groups + Joined + Source Modules + Discover + No joined modules + Standard Modules (Drag to reorder) + Bottom Infinite Module + In effect + Blocked + The source\'s homepageModules JSON is empty + Already joined + Module Type + This source has no discover items + Select Items + + Manual Add + %1$d modules + No modules + Browse Source Modules to Add + Standard Module + Infinite Module Slot + + New Custom Set + Rename Custom Set + Name + New Custom Set + Delete Custom Set + Are you sure you want to delete this set and all its module copies? + Remove Module + Are you sure you want to remove this module from the current set? + Add Button Group + Module Title + Custom Title + Title + Edit Module + Type + Layout Config + Edit Raw JSON (LayoutConfig) + Layout Settings + Homepage Layout Mode + Mixed List + Tabs by Source + Filter Sources + No source sets selected + Module Error + Please add homepage module definitions in the book source + Collapse + Show All + Default + Remove + · In effect + · Blocked + · Already joined From f6ee759fc2c73978aa983b0a345cadb1d3ae2609 Mon Sep 17 00:00:00 2001 From: HapeLee <63206378+HapeLee@users.noreply.github.com> Date: Sat, 23 May 2026 01:34:53 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E5=85=83=E7=B4=A0=E5=8A=A8=E7=94=BB=E9=A6=96=E5=B8=A7=E4=B8=BA?= =?UTF-8?q?=E4=B8=8A=E4=B8=80=E4=B8=AA=E4=B9=A6=E7=B1=8D=E5=B0=81=E9=9D=A2?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/image/cover/CoilBookCover.kt | 233 +++++++++++------- 1 file changed, 142 insertions(+), 91 deletions(-) diff --git a/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt b/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt index 1b42add4d..85d469fa0 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt @@ -7,8 +7,10 @@ import android.text.StaticLayout import android.text.TextPaint import android.text.TextUtils import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.EnterExitState import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.core.animateFloat import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme @@ -25,6 +27,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -47,6 +50,9 @@ import io.legado.app.ui.theme.LegadoTheme import org.koin.compose.koinInject import io.legado.app.model.BookCover as BookCoverModel +private const val SharedCoverRadiusCacheMaxSize = 256 +private val sharedCoverRadiusCache = mutableStateMapOf() + @OptIn(ExperimentalSharedTransitionApi::class) @Composable fun CoilBookCover( @@ -81,107 +87,152 @@ fun CoilBookCover( mutableStateOf(sharedCoverKey != null && finalPath != null) } - Box( - modifier = modifier - .aspectRatio(5f / 7f) - .then( - with(sharedTransitionScope) { - if (this != null && animatedVisibilityScope != null && sharedCoverKey != null) { - Modifier.sharedElement( - sharedContentState = rememberSharedContentState(sharedCoverKey), - animatedVisibilityScope = animatedVisibilityScope, - renderInOverlayDuringTransition = true - ) - } else Modifier - } - ) - .then( - if (CoverConfig.coverShowShadow) { - Modifier.shadow(4.dp, RoundedCornerShape(radius)) - } else Modifier - ) - .background( - if (!hasCustomDefault && !isOnlineCoverLoaded) { - LegadoTheme.colorScheme.surfaceContainerLow - } else Color.Transparent, - RoundedCornerShape(radius) - ) - .clip(RoundedCornerShape(radius)) - ) { - if (hasCustomDefault && !isOnlineCoverLoaded) { - key(randomPath) { - AsyncImage( - model = buildCoverImageRequest( - context = context, - data = randomPath, - sourceOrigin = null, - loadOnlyWifi = false, - crossfade = showLoadingPlaceholder, - memoryCacheKey = randomPath, - ), - contentDescription = null, - imageLoader = koinInject(), - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxSize() - .clip(RoundedCornerShape(radius)) - ) - } - } + val transitionRadius = rememberSharedCoverTransitionRadius( + sharedCoverKey = sharedCoverKey, + radius = radius, + animatedVisibilityScope = animatedVisibilityScope + ) + val shape = remember(transitionRadius) { RoundedCornerShape(transitionRadius) } - if (finalPath != null) { - key(finalPath) { - AsyncImage( - model = buildCoverImageRequest( - context = context, - data = finalPath, - sourceOrigin = sourceOrigin, - loadOnlyWifi = CoverConfig.loadCoverOnlyWifi, - crossfade = showLoadingPlaceholder, - memoryCacheKey = finalPath, - ), - contentDescription = null, - imageLoader = koinInject(), - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxSize() - .clip(RoundedCornerShape(4.dp)), - onSuccess = { - isOnlineCoverLoaded = true - onLoadFinish?.invoke() - }, - onError = { - isOnlineCoverLoaded = false - onLoadFinish?.invoke() + key(path, sharedCoverKey) { + Box( + modifier = modifier + .aspectRatio(5f / 7f) + .then( + with(sharedTransitionScope) { + if (this != null && animatedVisibilityScope != null && sharedCoverKey != null) { + Modifier.sharedElement( + sharedContentState = rememberSharedContentState(sharedCoverKey), + animatedVisibilityScope = animatedVisibilityScope, + clipInOverlayDuringTransition = OverlayClip(shape) + ) + } else Modifier } ) - } - } else { - LaunchedEffect(Unit) { - onLoadFinish?.invoke() - } - } + .then( + if (CoverConfig.coverShowShadow) { + Modifier.shadow(4.dp, shape) + } else Modifier + ) + .background( + if (!hasCustomDefault && !isOnlineCoverLoaded) { + LegadoTheme.colorScheme.surfaceContainerLow + } else Color.Transparent, + shape + ) + .clip(shape) + ) { + Box(modifier = Modifier.fillMaxSize()) { + if (hasCustomDefault && !isOnlineCoverLoaded) { + AsyncImage( + model = buildCoverImageRequest( + context = context, + data = randomPath, + sourceOrigin = null, + loadOnlyWifi = false, + crossfade = showLoadingPlaceholder, + memoryCacheKey = randomPath, + ), + contentDescription = null, + imageLoader = koinInject(), + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + ) + } - if (showLoadingPlaceholder && !isOnlineCoverLoaded) { - if (!hasCustomDefault) { - Icon( - Icons.Default.Book, - contentDescription = null, - tint = LegadoTheme.colorScheme.secondary, - modifier = Modifier - .fillMaxSize(0.35f) - .align(Alignment.Center) + if (finalPath != null) { + AsyncImage( + model = buildCoverImageRequest( + context = context, + data = finalPath, + sourceOrigin = sourceOrigin, + loadOnlyWifi = CoverConfig.loadCoverOnlyWifi, + crossfade = showLoadingPlaceholder, + memoryCacheKey = finalPath, + ), + contentDescription = null, + imageLoader = koinInject(), + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + onSuccess = { + isOnlineCoverLoaded = true + onLoadFinish?.invoke() + }, + onError = { + isOnlineCoverLoaded = false + onLoadFinish?.invoke() + } + ) + } else { + LaunchedEffect(Unit) { + onLoadFinish?.invoke() + } + } + } + + if (showLoadingPlaceholder && !isOnlineCoverLoaded) { + if (!hasCustomDefault) { + Icon( + Icons.Default.Book, + contentDescription = null, + tint = LegadoTheme.colorScheme.secondary, + modifier = Modifier + .fillMaxSize(0.35f) + .align(Alignment.Center) + ) + } + CoverTextOverlay( + name = name, + author = author, + isNight = isNight ) } - CoverTextOverlay( - name = name, - author = author, - isNight = isNight - ) } } } +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +private fun rememberSharedCoverTransitionRadius( + sharedCoverKey: String?, + radius: Dp, + animatedVisibilityScope: AnimatedVisibilityScope? +): Dp { + if (sharedCoverKey == null || animatedVisibilityScope == null) { + return radius + } + + val transition = animatedVisibilityScope.transition + val startRadius = sharedCoverRadiusCache[sharedCoverKey] ?: radius + val animatedRadiusValue by transition.animateFloat( + label = "book-cover-corner-radius" + ) { state -> + if (state == EnterExitState.Visible) radius.value else startRadius.value + } + + LaunchedEffect( + sharedCoverKey, + radius, + transition.currentState, + transition.targetState + ) { + if ( + transition.currentState == EnterExitState.Visible && + transition.targetState == EnterExitState.Visible + ) { + sharedCoverRadiusCache[sharedCoverKey] = radius + if (sharedCoverRadiusCache.size > SharedCoverRadiusCacheMaxSize) { + sharedCoverRadiusCache.keys + .firstOrNull { it != sharedCoverKey } + ?.let(sharedCoverRadiusCache::remove) + } + } + } + + return animatedRadiusValue.dp +} + @Composable private fun CoverTextOverlay( name: String?, From 4afff284b3079c99892966449188938f2d3137d5 Mon Sep 17 00:00:00 2001 From: HapeLee <63206378+HapeLee@users.noreply.github.com> Date: Sat, 23 May 2026 02:52:28 +0800 Subject: [PATCH 5/6] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/io/legado/app/constant/PreferKey.kt | 1 + .../local/preferences/LocalPreferences.kt | 13 + .../preferences/LocalPreferencesRepository.kt | 35 ++ .../main/java/io/legado/app/di/appModule.kt | 2 + .../app/domain/usecase/ExploreBooksUseCase.kt | 12 +- .../domain/usecase/ExploreKindUiUseCase.kt | 7 +- .../io/legado/app/help/config/AppConfig.kt | 3 + .../app/help/config/ThemeImportExport.kt | 7 +- .../ui/association/AddToBookshelfDialog.kt | 4 +- .../app/ui/book/explore/ExploreShowScreen.kt | 177 ++---- .../ui/book/explore/ExploreShowViewModel.kt | 3 +- .../app/ui/book/info/BookInfoRouteScreen.kt | 14 +- .../app/ui/book/info/BookInfoViewModel.kt | 64 +- .../app/ui/book/search/SearchActivity.kt | 6 +- .../app/ui/book/search/SearchContract.kt | 5 +- .../legado/app/ui/book/search/SearchScreen.kt | 26 +- .../app/ui/book/search/SearchViewModel.kt | 7 +- .../app/ui/config/themeConfig/ThemeConfig.kt | 2 + .../config/themeConfig/ThemeConfigScreen.kt | 89 +-- .../themeConfig/ThemeConfigViewModel.kt | 23 +- .../ui/config/themeManage/EditThemeSheet.kt | 6 +- .../app/ui/main/BookCoverSharedElement.kt | 5 +- .../io/legado/app/ui/main/MainActivity.kt | 7 +- .../java/io/legado/app/ui/main/MainIntent.kt | 8 +- .../io/legado/app/ui/main/MainNavGraph.kt | 29 +- .../java/io/legado/app/ui/main/MainNavKey.kt | 3 + .../io/legado/app/ui/main/MainNavigator.kt | 4 +- .../java/io/legado/app/ui/main/MainScreen.kt | 24 +- .../io/legado/app/ui/main/MainViewModel.kt | 3 + .../app/ui/main/bookshelf/BookshelfScreen.kt | 16 +- .../app/ui/main/homepage/HomepageEffect.kt | 3 + .../homepage/HomepageModuleManageSheet.kt | 201 +++--- .../app/ui/main/homepage/HomepageScreen.kt | 244 +++++--- .../app/ui/main/homepage/HomepageViewModel.kt | 55 +- .../ui/main/homepage/modules/BannerModule.kt | 15 +- .../homepage/modules/ButtonGroupModule.kt | 133 ++-- .../ui/main/homepage/modules/CardModule.kt | 20 +- .../ui/main/homepage/modules/GridModule.kt | 16 +- .../homepage/modules/GridRankingModule.kt | 16 +- .../ui/main/homepage/modules/RankingModule.kt | 14 +- .../main/homepage/modules/WaterfallModule.kt | 4 +- .../app/ui/rss/article/RssArticlesCompose.kt | 44 +- .../java/io/legado/app/ui/theme/Typography.kt | 3 +- .../components/AppFloatingActionButton.kt | 5 +- .../app/ui/widget/components/JsonRawEditor.kt | 22 +- .../ui/widget/components/LoadMoreFooter.kt | 251 ++++++++ .../widget/components/alert/AppAlertDialog.kt | 24 +- .../components/button/SmallTextButton.kt | 26 +- .../components/explore/ExploreKindItem.kt | 30 +- .../explore/ExploreKindItemState.kt | 85 +++ .../explore/ExploreKindMultiTypeItem.kt | 572 ++++++++++-------- .../explore/ExploreKindSelectSheet.kt | 156 +++++ .../explore/ExploreKindTextField.kt | 69 +++ app/src/main/res/values-zh-rCN/strings.xml | 6 + app/src/main/res/values-zh-rHK/strings.xml | 1 + app/src/main/res/values-zh-rTW/strings.xml | 1 + app/src/main/res/values/strings.xml | 6 + 57 files changed, 1735 insertions(+), 892 deletions(-) create mode 100644 app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt create mode 100644 app/src/main/java/io/legado/app/data/local/preferences/LocalPreferencesRepository.kt create mode 100644 app/src/main/java/io/legado/app/ui/widget/components/LoadMoreFooter.kt create mode 100644 app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindItemState.kt create mode 100644 app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindSelectSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindTextField.kt diff --git a/app/src/main/java/io/legado/app/constant/PreferKey.kt b/app/src/main/java/io/legado/app/constant/PreferKey.kt index 864c82ac9..2f1ab8941 100644 --- a/app/src/main/java/io/legado/app/constant/PreferKey.kt +++ b/app/src/main/java/io/legado/app/constant/PreferKey.kt @@ -45,6 +45,7 @@ object PreferKey { const val prevKeys = "prevKeyCodes" const val nextKeys = "nextKeyCodes" const val showDiscovery = "showDiscovery" + const val showHome = "showHome" const val enableReview = "enableReview" const val showRss = "showRss" const val showStatusBar = "showStatusBar" diff --git a/app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt b/app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt new file mode 100644 index 000000000..669b335fa --- /dev/null +++ b/app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt @@ -0,0 +1,13 @@ +package io.legado.app.data.local.preferences + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.preferencesDataStore + +val Context.localDataStore: DataStore by preferencesDataStore(name = "local_ui_status") + +object LocalPreferencesKeys { + val SHOW_THEME_REFACTOR_TIP = booleanPreferencesKey("show_theme_refactor_tip") +} diff --git a/app/src/main/java/io/legado/app/data/local/preferences/LocalPreferencesRepository.kt b/app/src/main/java/io/legado/app/data/local/preferences/LocalPreferencesRepository.kt new file mode 100644 index 000000000..6c3641fd3 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/local/preferences/LocalPreferencesRepository.kt @@ -0,0 +1,35 @@ +package io.legado.app.data.local.preferences + +import android.content.Context +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import java.io.IOException + +class LocalPreferencesRepository(private val context: Context) { + + private val dataStore = context.localDataStore + + fun getPreference(key: Preferences.Key, defaultValue: T): Flow { + return dataStore.data + .catch { exception -> + if (exception is IOException) { + emit(emptyPreferences()) + } else { + throw exception + } + } + .map { preferences -> + preferences[key] ?: defaultValue + } + } + + suspend fun updatePreference(key: Preferences.Key, value: T) { + dataStore.edit { preferences -> + preferences[key] = value + } + } +} diff --git a/app/src/main/java/io/legado/app/di/appModule.kt b/app/src/main/java/io/legado/app/di/appModule.kt index 8bb21516a..e487f516b 100644 --- a/app/src/main/java/io/legado/app/di/appModule.kt +++ b/app/src/main/java/io/legado/app/di/appModule.kt @@ -6,6 +6,7 @@ import coil.decode.GifDecoder import coil.decode.ImageDecoderDecoder import coil.decode.SvgDecoder import io.legado.app.data.AppDatabase +import io.legado.app.data.local.preferences.LocalPreferencesRepository import io.legado.app.data.repository.AppStartupRepository import io.legado.app.data.repository.BookCacheCleanupRepository import io.legado.app.data.repository.BookDomainRepositoryImpl @@ -137,6 +138,7 @@ val appModule = module { singleOf(::SearchContentRepository) singleOf(::RemoteBookRepository) singleOf(::SettingsRepository) + singleOf(::LocalPreferencesRepository) singleOf(::ExploreBooksUseCase) singleOf(::ExploreKindUiUseCase) singleOf(::SaveSearchBooksUseCase) diff --git a/app/src/main/java/io/legado/app/domain/usecase/ExploreBooksUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/ExploreBooksUseCase.kt index 427a8775a..d97b9bb3e 100644 --- a/app/src/main/java/io/legado/app/domain/usecase/ExploreBooksUseCase.kt +++ b/app/src/main/java/io/legado/app/domain/usecase/ExploreBooksUseCase.kt @@ -3,6 +3,8 @@ package io.legado.app.domain.usecase import io.legado.app.data.entities.SearchBook import io.legado.app.data.repository.BookSourceRepository import io.legado.app.model.webBook.WebBook +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext class ExploreBooksUseCase( private val bookSourceRepository: BookSourceRepository, @@ -20,7 +22,7 @@ class ExploreBooksUseCase( moduleUrl: String?, args: String?, page: Int = 1 - ): ExploreResult { + ): ExploreResult = withContext(Dispatchers.IO) { val base = bookSourceRepository.getBookSource(sourceUrl) ?: throw SourceNotFound(sourceUrl) val source = args?.let { base.copy().also { s -> s.setVariable(it) } } ?: base @@ -33,14 +35,14 @@ class ExploreBooksUseCase( throw InvalidUrl(resolvedUrl) } val books = WebBook.exploreBookSuspend(source, resolvedUrl, page) - return ExploreResult(resolvedUrl, books) + ExploreResult(resolvedUrl, books) } suspend fun executeForRanking( sourceUrl: String, moduleUrl: String?, args: String? - ): List { + ): List = withContext(Dispatchers.IO) { val result = execute(sourceUrl, moduleUrl, args) var books = result.books var page = 1 @@ -50,7 +52,7 @@ class ExploreBooksUseCase( WebBook.exploreBookSuspend( bookSourceRepository.getBookSource(sourceUrl) ?.let { s -> args?.let { s.copy().also { x -> x.setVariable(it) } } ?: s } - ?: return books.take(MAX_RANKING_BOOKS), + ?: return@withContext books.take(MAX_RANKING_BOOKS), result.resolvedUrl, page, ) @@ -60,7 +62,7 @@ class ExploreBooksUseCase( if (next.isEmpty()) break books = (books + next) } - return books.take(MAX_RANKING_BOOKS) + books.take(MAX_RANKING_BOOKS) } data class ExploreResult(val resolvedUrl: String, val books: List) diff --git a/app/src/main/java/io/legado/app/domain/usecase/ExploreKindUiUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/ExploreKindUiUseCase.kt index 3d90ca26b..d19da8344 100644 --- a/app/src/main/java/io/legado/app/domain/usecase/ExploreKindUiUseCase.kt +++ b/app/src/main/java/io/legado/app/domain/usecase/ExploreKindUiUseCase.kt @@ -99,9 +99,10 @@ class ExploreKindUiUseCase( } } - private suspend fun evalUiJs(jsStr: String, sourceUrl: String, infoMap: InfoMap): String? { - val source = getOrLoadBookSource(sourceUrl) ?: return null - return runScriptWithContext { + private suspend fun evalUiJs(jsStr: String, sourceUrl: String, infoMap: InfoMap): String? = + withContext(Dispatchers.IO) { + val source = getOrLoadBookSource(sourceUrl) ?: return@withContext null + runScriptWithContext { source.evalJS(jsStr) { put("infoMap", infoMap) }?.toString() diff --git a/app/src/main/java/io/legado/app/help/config/AppConfig.kt b/app/src/main/java/io/legado/app/help/config/AppConfig.kt index 8476d4395..95f1d542c 100644 --- a/app/src/main/java/io/legado/app/help/config/AppConfig.kt +++ b/app/src/main/java/io/legado/app/help/config/AppConfig.kt @@ -306,6 +306,9 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener { val showDiscovery: Boolean get() = appCtx.getPrefBoolean(PreferKey.showDiscovery, true) + val showHome: Boolean + get() = appCtx.getPrefBoolean(PreferKey.showHome, true) + val showRSS: Boolean get() = appCtx.getPrefBoolean(PreferKey.showRss, true) diff --git a/app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt b/app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt index 6473737d3..f0d19980f 100644 --- a/app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt +++ b/app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt @@ -3,12 +3,8 @@ package io.legado.app.help.config import android.content.Context import android.net.Uri import com.google.gson.GsonBuilder -import com.google.gson.JsonObject -import com.google.gson.JsonParser import io.legado.app.ui.config.themeConfig.ThemeConfig import io.legado.app.utils.GSON -import io.legado.app.utils.inputStream -import io.legado.app.utils.outputStream import splitties.init.appCtx import java.io.File @@ -175,6 +171,7 @@ object ThemeImportExport { customTagColorsJson = ThemeConfig.customTagColorsJson, // 主界面设置 + showHome = ThemeConfig.showHome, showDiscovery = ThemeConfig.showDiscovery, showRss = ThemeConfig.showRss, showStatusBar = ThemeConfig.showStatusBar, @@ -258,6 +255,7 @@ object ThemeImportExport { ThemeConfig.customTagColorsJson = data.customTagColorsJson // 主界面设置 + ThemeConfig.showHome = data.showHome ThemeConfig.showDiscovery = data.showDiscovery ThemeConfig.showRss = data.showRss ThemeConfig.showStatusBar = data.showStatusBar @@ -394,6 +392,7 @@ data class ThemeExportData( val customTagColorsJson: String? = null, // 主界面设置 + val showHome: Boolean = true, val showDiscovery: Boolean = true, val showRss: Boolean = true, val showStatusBar: Boolean = true, diff --git a/app/src/main/java/io/legado/app/ui/association/AddToBookshelfDialog.kt b/app/src/main/java/io/legado/app/ui/association/AddToBookshelfDialog.kt index 5496dcd38..10a4f87af 100644 --- a/app/src/main/java/io/legado/app/ui/association/AddToBookshelfDialog.kt +++ b/app/src/main/java/io/legado/app/ui/association/AddToBookshelfDialog.kt @@ -86,7 +86,9 @@ class AddToBookshelfDialog() : BaseDialogFragment(R.layout.dialog_add_to_bookshe context = requireContext(), name = it.name, author = it.author, - bookUrl = it.bookUrl + bookUrl = it.bookUrl, + origin = it.origin, + coverPath = it.coverUrl ) ) dismiss() diff --git a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt index e0db349ce..79a7357de 100644 --- a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt @@ -1,8 +1,6 @@ package io.legado.app.ui.book.explore import android.annotation.SuppressLint -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.Crossfade @@ -13,8 +11,6 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -27,9 +23,9 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.FormatListBulleted @@ -52,9 +48,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import dev.chrisbanes.haze.HazeState @@ -69,13 +63,11 @@ import io.legado.app.ui.theme.responsiveHazeEffect import io.legado.app.ui.theme.responsiveHazeSource import io.legado.app.ui.widget.components.AppPullToRefresh import io.legado.app.ui.widget.components.AppScaffold -import io.legado.app.ui.widget.components.SearchBar +import io.legado.app.ui.widget.components.LoadMoreFooter import io.legado.app.ui.widget.components.book.SearchBookGridItem import io.legado.app.ui.widget.components.book.SearchBookListItem -import io.legado.app.ui.widget.components.button.AnimatedTextButton import io.legado.app.ui.widget.components.card.TextCard -import io.legado.app.ui.widget.components.explore.ExploreKindMultiTypeItem -import io.legado.app.ui.widget.components.explore.calculateExploreKindRows +import io.legado.app.ui.widget.components.explore.ExploreKindSelectSheet import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet @@ -98,7 +90,7 @@ fun ExploreShowScreen( sourceUrl: String?, exploreUrl: String?, onBack: () -> Unit, - onBookClick: (SearchBook) -> Unit, + onBookClick: (SearchBook, String?) -> Unit, viewModel: ExploreShowViewModel = koinViewModel(), sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, @@ -111,7 +103,6 @@ fun ExploreShowScreen( val books by viewModel.uiBooks.collectAsState() val isBookEnd by viewModel.isEnd.collectAsState() val shouldTriggerAutoLoad by viewModel.shouldTriggerAutoLoad.collectAsState() - val kinds by viewModel.kinds.collectAsState() val isLoading by viewModel.isLoading.collectAsState() val errorMsg by viewModel.errorMsg.collectAsState() val filterState by viewModel.filterState.collectAsState() @@ -125,8 +116,6 @@ fun ExploreShowScreen( var showGridCountSheet by remember { mutableStateOf(false) } val gridColumnCount by viewModel.gridCount.collectAsState() val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine) - val context = LocalContext.current - val activity = context as? AppCompatActivity val exploreKindUseCase: ExploreKindUiUseCase = koinInject() LaunchedEffect(sourceUrl) { @@ -233,73 +222,16 @@ fun ExploreShowScreen( } - AppModalBottomSheet( + ExploreKindSelectSheet( show = showKindSheet, - onDismissRequest = { showKindSheet = false } - ) { - - var kindQuery by remember { mutableStateOf("") } - - SearchBar( - query = kindQuery, - backgroundColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.5f), - onQueryChange = { kindQuery = it }, - placeholder = "选择或搜索分类", - ) - - val filteredKinds = remember(kindQuery, kinds) { - if (kindQuery.isBlank()) kinds - else kinds.filter { kind -> - kind.title.contains(kindQuery, ignoreCase = true) || - (kind.url?.contains(kindQuery, ignoreCase = true) == true) + onDismissRequest = { showKindSheet = false }, + sourceUrl = sourceUrl, + onSelected = { selectedKinds -> + selectedKinds.firstOrNull()?.let { kind -> + viewModel.switchExploreUrl(kind) } } - val kindRows = remember(filteredKinds) { - calculateExploreKindRows(filteredKinds, 6) - } - - LazyColumn( - contentPadding = PaddingValues(vertical = 16.dp), - modifier = Modifier.weight(1f, fill = false) - ) { - items(kindRows) { rowItems -> - Row( - modifier = Modifier - .fillMaxWidth() - .animateItem() - .padding(vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - rowItems.forEach { (kind, span) -> - ExploreKindMultiTypeItem( - modifier = Modifier - .weight(span.toFloat()) - .animateItem(), - kind = kind, - sourceUrl = sourceUrl, - activity = activity, - onOpenUrl = { url -> - showKindSheet = false - viewModel.switchExploreUrl(kind.copy(url = url)) - }, - onRefreshKinds = viewModel::refreshKinds, - backgroundColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.5f), - isMiuix = isMiuix, - useCase = exploreKindUseCase - ) - } - - val totalSpan = rowItems.sumOf { it.second } - if (totalSpan < 6) { - Spacer( - modifier = Modifier.weight((6 - totalSpan).toFloat()) - ) - } - } - } - } - } - + ) AppScaffold( modifier = Modifier @@ -433,17 +365,22 @@ fun ExploreShowScreen( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - items( + itemsIndexed( items = books, - key = { it.book.bookUrl } - ) { item -> + key = { index, item -> "${item.book.bookUrl}:$index" } + ) { index, item -> + val sharedCoverKey = bookCoverSharedElementKey( + item.book.bookUrl, + "explore:grid:$index" + ) ExploreBookGridItem( book = item.book, shelfState = item.shelfState, - onClick = { onBookClick(item.book) }, + onClick = { onBookClick(item.book, sharedCoverKey) }, modifier = Modifier.animateItem(), sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, + sharedCoverKey = sharedCoverKey, ) } @@ -467,17 +404,22 @@ fun ExploreShowScreen( bottom = paddingValues.calculateBottomPadding() + 16.dp ) ) { - items( + itemsIndexed( items = books, - key = { it.book.bookUrl } - ) { item -> + key = { index, item -> "${item.book.bookUrl}:$index" } + ) { index, item -> + val sharedCoverKey = bookCoverSharedElementKey( + item.book.bookUrl, + "explore:list:$index" + ) ExploreBookItem( book = item.book, shelfState = item.shelfState, - onClick = { onBookClick(item.book) }, + onClick = { onBookClick(item.book, sharedCoverKey) }, modifier = Modifier.animateItem(), sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, + sharedCoverKey = sharedCoverKey, ) } @@ -506,6 +448,7 @@ fun ExploreBookItem( modifier: Modifier = Modifier, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, + sharedCoverKey: String? = null, ) { SearchBookListItem( book = book, @@ -514,7 +457,7 @@ fun ExploreBookItem( modifier = modifier, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, - sharedCoverKey = bookCoverSharedElementKey(book.bookUrl) + sharedCoverKey = sharedCoverKey ) } @@ -527,6 +470,7 @@ fun ExploreBookGridItem( modifier: Modifier = Modifier, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, + sharedCoverKey: String? = null, ) { SearchBookGridItem( book = book, @@ -535,60 +479,7 @@ fun ExploreBookGridItem( modifier = modifier, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, - sharedCoverKey = bookCoverSharedElementKey(book.bookUrl) + sharedCoverKey = sharedCoverKey ) } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -fun LoadMoreFooter( - isLoading: Boolean, - errorMsg: String?, - isEnd: Boolean, - onRetry: () -> Unit -) { - - LaunchedEffect(isLoading, errorMsg, isEnd) { - if (!isLoading && errorMsg == null && !isEnd) { - onRetry() - } - } - - Box( - modifier = Modifier - .fillMaxWidth() - .padding(32.dp), - contentAlignment = Alignment.Center - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - - AnimatedContent( - targetState = when { - isLoading -> "加载中…" - errorMsg != null -> "加载失败: $errorMsg" - isEnd -> "已经到底了~" - else -> "我爱你" - }, - label = "FooterTextChange" - ) { text -> - AppText( - text = text, - color = when { - errorMsg != null -> Color.Red - else -> Color.Gray - }, - style = LegadoTheme.typography.bodySmall - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - AnimatedTextButton( - isLoading = isLoading, - onClick = onRetry, - text = if (errorMsg != null) "重试" else "再试一次", - modifier = Modifier.padding(top = 4.dp) - ) - } - } -} diff --git a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt index 431aa69d7..87955d925 100644 --- a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt @@ -13,6 +13,7 @@ import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase import io.legado.app.domain.usecase.SaveSearchBooksUseCase import io.legado.app.help.config.AppConfig import io.legado.app.utils.exploreLayoutGrid +import io.legado.app.utils.stackTraceStr import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -222,7 +223,7 @@ class ExploreShowViewModel( } } .onFailure { - _errorMsg.value = it.localizedMessage + _errorMsg.value = it.stackTraceStr } _isLoading.value = false diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt index 8cd29b95c..38cf6ec70 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt @@ -40,6 +40,10 @@ import kotlinx.coroutines.flow.collectLatest @Composable fun BookInfoRouteScreen( bookUrl: String, + name: String? = null, + author: String? = null, + origin: String? = null, + coverPath: String? = null, viewModel: BookInfoViewModel, onBack: () -> Unit, onFinish: (resultCode: Int?, afterTransition: Boolean) -> Unit, @@ -80,8 +84,14 @@ fun BookInfoRouteScreen( viewModel.onReaderResult(it.resultCode) } - LaunchedEffect(bookUrl, viewModel) { - viewModel.initData(bookUrl) + LaunchedEffect(bookUrl, name, author, origin, coverPath, viewModel) { + viewModel.initData( + bookUrl = bookUrl, + name = name, + author = author, + origin = origin, + coverPath = coverPath + ) } DisposableEffect(viewModel) { diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt index b53a9091e..cb05a74bd 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt @@ -50,6 +50,7 @@ import io.legado.app.model.analyzeRule.AnalyzeUrl import io.legado.app.model.localBook.LocalBook import io.legado.app.model.webBook.WebBook import io.legado.app.ui.config.coverConfig.CoverConfig +import io.legado.app.ui.main.MainIntent import io.legado.app.ui.widget.components.image.cover.buildCoverImageRequest import io.legado.app.utils.ArchiveUtils import io.legado.app.utils.GSON @@ -113,12 +114,37 @@ class BookInfoViewModel( private var readRecordObserveJob: Job? = null fun initData(intent: Intent) { - initData(intent.getStringExtra("bookUrl") ?: "") + initData( + bookUrl = intent.getStringExtra(MainIntent.EXTRA_BOOK_URL) ?: "", + name = intent.getStringExtra(MainIntent.EXTRA_BOOK_NAME), + author = intent.getStringExtra(MainIntent.EXTRA_BOOK_AUTHOR), + origin = intent.getStringExtra(MainIntent.EXTRA_BOOK_ORIGIN), + coverPath = intent.getStringExtra(MainIntent.EXTRA_BOOK_COVER) + ) } - fun initData(bookUrl: String) { + fun initData( + bookUrl: String, + name: String? = null, + author: String? = null, + origin: String? = null, + coverPath: String? = null + ) { if (currentBook?.bookUrl == bookUrl) return - currentBook = null + _uiState.value = BookInfoUiState() // 立即重置 UI 状态 + currentBook = if (!name.isNullOrBlank() && !author.isNullOrBlank()) { + Book( + bookUrl = bookUrl, + name = name, + author = author, + origin = origin ?: BookType.localTag, + coverUrl = coverPath + ).apply { + addType(BookType.notShelf) + } + } else { + null + } currentChapterList = emptyList() currentWebFiles = emptyList() currentKindLabels = emptyList() @@ -128,24 +154,32 @@ class BookInfoViewModel( bookSource = null chapterChanged = false clearReadRecordObserve() - _uiState.value = BookInfoUiState() + syncUiState() execute { - val book = appDb.bookDao.getBook(bookUrl)?.let { - inBookshelf = !it.isNotShelf - it - } ?: appDb.searchBookDao.getSearchBook(bookUrl)?.toBook()?.let { - inBookshelf = false - it - } ?: throw NoStackTraceException("未找到书籍") - + val dbBook = appDb.bookDao.getBook(bookUrl) + if (dbBook != null) { + inBookshelf = !dbBook.isNotShelf + dbBook + } else { + val searchBook = appDb.searchBookDao.getSearchBook(bookUrl)?.toBook() + if (searchBook != null) { + inBookshelf = false + searchBook + } else { + currentBook ?: throw NoStackTraceException("未找到书籍") + } + } + }.onSuccess { book -> + // 如果从数据库/搜索中拿到的书没有封面,但我们有传入的封面,则保留传入的封面 + if (book.coverUrl.isNullOrBlank() && !coverPath.isNullOrBlank()) { + book.coverUrl = coverPath + } val source = if (book.isLocal) { null } else { appDb.bookSourceDao.getBookSource(book.origin) } - book to source - }.onSuccess { - upBook(it.first, it.second) + upBook(book, source) }.onError { context.toastOnUi(it.localizedMessage ?: "未找到书籍") emitEffect(BookInfoEffect.Finish(afterTransition = true)) diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt index 461a78df4..01d004271 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt @@ -40,13 +40,15 @@ class SearchActivity : BaseComposeActivity() { SearchScreen( viewModel = viewModel, onBack = { finish() }, - onOpenBookInfo = { name, author, bookUrl -> + onOpenBookInfo = { name, author, bookUrl, origin, coverPath, _ -> startActivity( MainActivity.createBookInfoIntent( context = this, name = name, author = author, - bookUrl = bookUrl + bookUrl = bookUrl, + origin = origin, + coverPath = coverPath ) ) }, diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt index 36efba3eb..429bef7aa 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt @@ -58,7 +58,7 @@ sealed interface SearchIntent { data object PauseEngine : SearchIntent data object ResumeEngine : SearchIntent data class UseHistoryKeyword(val keyword: String) : SearchIntent - data class OpenSearchBook(val book: SearchBook) : SearchIntent + data class OpenSearchBook(val book: SearchBook, val sharedCoverKey: String?) : SearchIntent data class OpenBookshelfBook(val book: BookShelfItem) : SearchIntent data class DeleteHistory(val item: SearchKeyword) : SearchIntent data class SetClearHistoryDialogVisible(val visible: Boolean) : SearchIntent @@ -82,6 +82,9 @@ sealed interface SearchEffect { val name: String, val author: String, val bookUrl: String, + val origin: String? = null, + val coverPath: String? = null, + val sharedCoverKey: String?, ) : SearchEffect data object OpenSourceManage : SearchEffect diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt index 59f54dd80..8da472196 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt @@ -90,7 +90,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged fun SearchScreen( viewModel: SearchViewModel, onBack: () -> Unit, - onOpenBookInfo: (name: String, author: String, bookUrl: String) -> Unit, + onOpenBookInfo: (name: String, author: String, bookUrl: String, origin: String?, coverPath: String?, sharedCoverKey: String?) -> Unit, onOpenSourceManage: () -> Unit, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, @@ -160,7 +160,14 @@ fun SearchScreen( viewModel.effects.collect { effect -> when (effect) { is SearchEffect.OpenBookInfo -> { - onOpenBookInfo(effect.name, effect.author, effect.bookUrl) + onOpenBookInfo( + effect.name, + effect.author, + effect.bookUrl, + effect.origin, + effect.coverPath, + effect.sharedCoverKey + ) } SearchEffect.OpenSourceManage -> onOpenSourceManage() @@ -394,16 +401,25 @@ fun SearchScreen( itemsIndexed( items = state.results, key = { index, item -> "${item.book.origin}:${item.book.bookUrl}:$index" } - ) { _, item -> + ) { index, item -> + val sharedCoverKey = bookCoverSharedElementKey( + item.book.bookUrl, + "search:${item.book.origin}:$index" + ) SearchBookListItem( book = item.book, shelfState = item.shelfState, onClick = { - viewModel.onIntent(SearchIntent.OpenSearchBook(item.book)) + viewModel.onIntent( + SearchIntent.OpenSearchBook( + item.book, + sharedCoverKey + ) + ) }, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, - sharedCoverKey = bookCoverSharedElementKey(item.book.bookUrl) + sharedCoverKey = sharedCoverKey ) } diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt index 8213e876f..d5e01a647 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt @@ -15,7 +15,6 @@ import io.legado.app.domain.usecase.SearchBooksUseCase import io.legado.app.domain.usecase.SearchRunEvent import io.legado.app.help.config.AppConfig import io.legado.app.ui.config.otherConfig.OtherConfig -import io.legado.app.ui.main.bookshelf.BookShelfItem import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.putPrefBoolean import kotlinx.coroutines.CancellationException @@ -107,6 +106,9 @@ class SearchViewModel( name = intent.book.name, author = intent.book.author, bookUrl = intent.book.bookUrl, + origin = intent.book.origin, + coverPath = intent.book.coverUrl, + sharedCoverKey = intent.sharedCoverKey, ) ) } @@ -117,6 +119,9 @@ class SearchViewModel( name = intent.book.name, author = intent.book.author, bookUrl = intent.book.bookUrl, + origin = intent.book.origin, + coverPath = intent.book.getDisplayCover(), + sharedCoverKey = null, ) ) } diff --git a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt index 654d4723a..b9a400816 100644 --- a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt +++ b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt @@ -141,6 +141,8 @@ object ThemeConfig { var showDiscovery by prefDelegate(PreferKey.showDiscovery, true) + var showHome by prefDelegate(PreferKey.showHome, true) + var showRss by prefDelegate(PreferKey.showRss, true) var showStatusBar by prefDelegate(PreferKey.showStatusBar, true) diff --git a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt index 989d01199..7da43bcbe 100644 --- a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt +++ b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt @@ -20,7 +20,6 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -47,7 +46,6 @@ import androidx.compose.material3.ButtonGroupDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -57,7 +55,6 @@ import androidx.compose.material3.ToggleButtonDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -73,31 +70,29 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView import androidx.constraintlayout.compose.ConstraintLayout -import com.google.android.material.color.DynamicColors -import com.google.android.material.color.DynamicColorsOptions +import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.legado.app.R import io.legado.app.base.AppContextWrapper -import io.legado.app.constant.PreferKey import io.legado.app.constant.EventBus +import io.legado.app.constant.PreferKey import io.legado.app.help.LauncherIconHelp -import io.legado.app.help.loadFontFiles import io.legado.app.help.config.AppConfig import io.legado.app.help.config.OldThemeConfig -import io.legado.app.lib.theme.ThemeStore -import io.legado.app.lib.theme.primaryColor +import io.legado.app.help.loadFontFiles import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.ThemeEngine import io.legado.app.ui.theme.ThemeResolver import io.legado.app.ui.theme.adaptiveContentPadding import io.legado.app.ui.widget.components.AppScaffold -import io.legado.app.ui.widget.components.AppTextField import io.legado.app.ui.widget.components.SplicedColumnGroup -import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet import io.legado.app.ui.widget.components.alert.AppAlertDialog -import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton import io.legado.app.ui.widget.components.button.SmallIconButton +import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.dialog.ColorPickerSheet +import io.legado.app.ui.widget.components.icon.AppIcons +import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem import io.legado.app.ui.widget.components.settingItem.SliderSettingItem @@ -105,7 +100,7 @@ import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults -import io.legado.app.utils.FileDoc +import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton import io.legado.app.utils.getPrefString import io.legado.app.utils.postEvent import io.legado.app.utils.putPrefString @@ -113,10 +108,6 @@ import io.legado.app.utils.restart import io.legado.app.utils.takePersistablePermissionSafely import io.legado.app.utils.toastOnUi import org.koin.androidx.compose.koinViewModel -import top.yukonga.miuix.kmp.theme.MiuixTheme -import top.yukonga.miuix.kmp.basic.Card as MiuixCard -import top.yukonga.miuix.kmp.basic.CardDefaults as MiuixCardDefaults -import top.yukonga.miuix.kmp.basic.Text as MiuixText @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable @@ -138,16 +129,16 @@ fun ThemeConfigScreen( var showBorderColorPicker by remember { mutableStateOf(false) } var showNavIconSheet by remember { mutableStateOf(false) } var showFontSheet by remember { mutableStateOf(false) } - var fontItems by remember { mutableStateOf>(emptyList()) } - var fontFolderUri by remember { mutableStateOf(null) } + val showThemeRefactorTip by viewModel.showThemeRefactorTip.collectAsStateWithLifecycle() - fun loadFonts() { - fontItems = loadFontFiles(context, fontFolderUri) + var fontFolderUri by remember { + mutableStateOf( + context.getPrefString(PreferKey.fontFolder)?.let { Uri.parse(it) } + ) } - remember { - val saved = context.getPrefString(PreferKey.fontFolder) - if (!saved.isNullOrEmpty()) fontFolderUri = Uri.parse(saved) - loadFonts() + + val fontItems = remember(fontFolderUri) { + loadFontFiles(context, fontFolderUri) } val fontFolderLauncher = rememberLauncherForActivityResult( @@ -157,7 +148,6 @@ fun ThemeConfigScreen( fontFolderUri = uri uri.takePersistablePermissionSafely(context, Intent.FLAG_GRANT_READ_URI_PERMISSION) context.putPrefString(PreferKey.fontFolder, uri.toString()) - loadFonts() } } @@ -218,16 +208,28 @@ fun ThemeConfigScreen( themeItems.zip(themeValues).toList() } - if (isMiuixEngine) { - MiuixCard( + AnimatedVisibility(visible = showThemeRefactorTip) { + GlassCard( cornerRadius = 16.dp, - insideMargin = PaddingValues(16.dp), - colors = MiuixCardDefaults.defaultColors( - color = MiuixTheme.colorScheme.primaryVariant, - contentColor = MiuixTheme.colorScheme.onPrimary - ) + modifier = Modifier.padding(bottom = 16.dp) ) { - MiuixText("Miuix 目前为测试主题,且不对基于View的界面生效!") + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(16.dp) + ) { + AppText( + text = "仍有部分界面未用Compose重构,这些界面会与大部分界面有较大差异。", + style = LegadoTheme.typography.labelLargeEmphasized, + modifier = Modifier.weight(1f) + ) + SmallIconButton( + imageVector = AppIcons.Close, + contentDescription = "关闭", + onClick = { + viewModel.setShowThemeRefactorTip(false) + } + ) + } } } @@ -376,6 +378,11 @@ fun ThemeConfigScreen( } SplicedColumnGroup(title = stringResource(R.string.main_activity)) { + SwitchSettingItem( + title = stringResource(R.string.show_home), + checked = ThemeConfig.showHome, + onCheckedChange = { ThemeConfig.showHome = it } + ) SwitchSettingItem( title = stringResource(R.string.show_discovery), checked = ThemeConfig.showDiscovery, @@ -648,7 +655,11 @@ fun ThemeConfigScreen( .size(28.dp) .clip(CircleShape) .background(Color(ThemeConfig.itemDividerColor)) - .border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape) + .border( + 1.dp, + MaterialTheme.colorScheme.outlineVariant, + CircleShape + ) ) } } @@ -767,7 +778,9 @@ fun ThemeConfigScreen( content = { if (fontItems.isEmpty()) { Box( - modifier = Modifier.fillMaxWidth().height(120.dp), + modifier = Modifier + .fillMaxWidth() + .height(120.dp), contentAlignment = Alignment.Center ) { Text( @@ -785,7 +798,9 @@ fun ThemeConfigScreen( fontItems.forEach { fontDoc -> item { Card( - modifier = Modifier.fillMaxWidth().height(100.dp), + modifier = Modifier + .fillMaxWidth() + .height(100.dp), onClick = { ThemeConfig.appFontPath = fontDoc.uri.toString() showFontSheet = false diff --git a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigViewModel.kt b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigViewModel.kt index 32aa996cc..92c9bba21 100644 --- a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigViewModel.kt @@ -2,19 +2,40 @@ package io.legado.app.ui.config.themeConfig import android.net.Uri import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import io.legado.app.constant.PreferKey +import io.legado.app.data.local.preferences.LocalPreferencesKeys +import io.legado.app.data.local.preferences.LocalPreferencesRepository import io.legado.app.utils.FileDoc import io.legado.app.utils.FileUtils import io.legado.app.utils.MD5Utils import io.legado.app.utils.externalFiles import io.legado.app.utils.inputStream import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import splitties.init.appCtx import java.io.File import java.io.FileOutputStream -class ThemeConfigViewModel : ViewModel() { +class ThemeConfigViewModel( + private val localPreferencesRepository: LocalPreferencesRepository +) : ViewModel() { + + val showThemeRefactorTip = localPreferencesRepository + .getPreference(LocalPreferencesKeys.SHOW_THEME_REFACTOR_TIP, true) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true) + + fun setShowThemeRefactorTip(show: Boolean) { + viewModelScope.launch { + localPreferencesRepository.updatePreference( + LocalPreferencesKeys.SHOW_THEME_REFACTOR_TIP, + show + ) + } + } /** * 设置背景图片 diff --git a/app/src/main/java/io/legado/app/ui/config/themeManage/EditThemeSheet.kt b/app/src/main/java/io/legado/app/ui/config/themeManage/EditThemeSheet.kt index 888ee8f30..de668d6b9 100644 --- a/app/src/main/java/io/legado/app/ui/config/themeManage/EditThemeSheet.kt +++ b/app/src/main/java/io/legado/app/ui/config/themeManage/EditThemeSheet.kt @@ -30,7 +30,6 @@ import androidx.compose.ui.unit.dp import io.legado.app.R import io.legado.app.help.config.ThemeExportData import io.legado.app.ui.widget.components.AppTextField -import io.legado.app.ui.widget.components.SearchBar import io.legado.app.ui.widget.components.button.MediumIconButton import io.legado.app.ui.widget.components.dialog.ColorPickerSheet import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet @@ -163,6 +162,11 @@ fun EditThemeSheet( // Interface layout SectionTitle("界面布局") + CompactSwitchSettingItem( + title = "首页", + checked = data.showHome, + onCheckedChange = { data = data.copy(showHome = it) } + ) CompactSwitchSettingItem( title = "发现", checked = data.showDiscovery, diff --git a/app/src/main/java/io/legado/app/ui/main/BookCoverSharedElement.kt b/app/src/main/java/io/legado/app/ui/main/BookCoverSharedElement.kt index bf3a36879..fa97c8688 100644 --- a/app/src/main/java/io/legado/app/ui/main/BookCoverSharedElement.kt +++ b/app/src/main/java/io/legado/app/ui/main/BookCoverSharedElement.kt @@ -1,3 +1,6 @@ package io.legado.app.ui.main -fun bookCoverSharedElementKey(bookUrl: String): String = "book-cover:$bookUrl" +fun bookCoverSharedElementKey(bookUrl: String, sourceId: String? = null): String { + val source = sourceId?.takeIf { it.isNotBlank() } ?: return "book-cover:$bookUrl" + return "book-cover:$source:$bookUrl" +} diff --git a/app/src/main/java/io/legado/app/ui/main/MainActivity.kt b/app/src/main/java/io/legado/app/ui/main/MainActivity.kt index 3c7bee3b6..b44cd923c 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainActivity.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainActivity.kt @@ -100,8 +100,11 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback { context: Context, name: String? = null, author: String? = null, - bookUrl: String - ): Intent = MainIntent.createBookInfoIntent(context, name, author, bookUrl) + bookUrl: String, + origin: String? = null, + coverPath: String? = null + ): Intent = + MainIntent.createBookInfoIntent(context, name, author, bookUrl, origin, coverPath) fun createExploreShowIntent( context: Context, diff --git a/app/src/main/java/io/legado/app/ui/main/MainIntent.kt b/app/src/main/java/io/legado/app/ui/main/MainIntent.kt index 24e769d93..0f70a3c8b 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainIntent.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainIntent.kt @@ -12,6 +12,8 @@ object MainIntent { const val EXTRA_BOOK_NAME = "name" const val EXTRA_BOOK_AUTHOR = "author" const val EXTRA_BOOK_URL = "bookUrl" + const val EXTRA_BOOK_ORIGIN = "origin" + const val EXTRA_BOOK_COVER = "coverPath" const val EXTRA_EXPLORE_NAME = "exploreName" const val EXTRA_SOURCE_URL = "sourceUrl" const val EXTRA_EXPLORE_URL = "exploreUrl" @@ -114,13 +116,17 @@ object MainIntent { context: Context, name: String? = null, author: String? = null, - bookUrl: String + bookUrl: String, + origin: String? = null, + coverPath: String? = null ): Intent { return createLauncherIntent(context).apply { putExtra(EXTRA_START_ROUTE, MainRouteConst.ROUTE_BOOK_INFO) putExtra(EXTRA_BOOK_NAME, name) putExtra(EXTRA_BOOK_AUTHOR, author) putExtra(EXTRA_BOOK_URL, bookUrl) + putExtra(EXTRA_BOOK_ORIGIN, origin) + putExtra(EXTRA_BOOK_COVER, coverPath) } } diff --git a/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt b/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt index 9af1db32a..0aa0db1ef 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt @@ -90,12 +90,15 @@ fun MainActivity.mainEntryProvider( onNavigateToBookCacheManage = { onNavigateToRoute(MainRouteBookCacheManage) }, - onNavigateToBookInfo = { name, author, bookUrl -> + onNavigateToBookInfo = { name, author, bookUrl, origin, coverPath, sharedCoverKey -> onNavigateToRoute( MainRouteBookInfo( name = name, author = author, - bookUrl = bookUrl + bookUrl = bookUrl, + origin = origin, + coverPath = coverPath, + sharedCoverKey = sharedCoverKey ) ) }, @@ -246,12 +249,15 @@ fun MainActivity.mainEntryProvider( searchViewModel.onIntent(SearchIntent.ClearSearchResults) onNavigateBack() }, - onOpenBookInfo = { name, author, bookUrl -> + onOpenBookInfo = { name, author, bookUrl, origin, coverPath, sharedCoverKey -> onNavigateToRoute( MainRouteBookInfo( name = name, author = author, - bookUrl = bookUrl + bookUrl = bookUrl, + origin = origin, + coverPath = coverPath, + sharedCoverKey = sharedCoverKey ) ) }, @@ -395,9 +401,13 @@ fun MainActivity.mainEntryProvider( } else null } ) { route -> - val bookInfoViewModel = koinViewModel() + val bookInfoViewModel = koinViewModel(key = route.bookUrl) BookInfoRouteScreen( bookUrl = route.bookUrl, + name = route.name, + author = route.author, + origin = route.origin, + coverPath = route.coverPath, viewModel = bookInfoViewModel, onBack = { onNavigateBack() }, onFinish = { _, _ -> onNavigateBack() }, @@ -406,7 +416,7 @@ fun MainActivity.mainEntryProvider( }, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = LocalNavAnimatedContentScope.current, - sharedCoverKey = bookCoverSharedElementKey(route.bookUrl), + sharedCoverKey = route.sharedCoverKey ?: bookCoverSharedElementKey(route.bookUrl), onRegisterVariableSetter = { setter -> onRegisterVariableSetter(setter) } @@ -419,12 +429,15 @@ fun MainActivity.mainEntryProvider( sourceUrl = route.sourceUrl, exploreUrl = route.exploreUrl, onBack = { onNavigateBack() }, - onBookClick = { book -> + onBookClick = { book, sharedCoverKey -> onNavigateToRoute( MainRouteBookInfo( name = book.name, author = book.author, - bookUrl = book.bookUrl + bookUrl = book.bookUrl, + origin = book.origin, + coverPath = book.coverUrl, + sharedCoverKey = sharedCoverKey ) ) }, diff --git a/app/src/main/java/io/legado/app/ui/main/MainNavKey.kt b/app/src/main/java/io/legado/app/ui/main/MainNavKey.kt index ee8a3c2db..c08763921 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainNavKey.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainNavKey.kt @@ -65,6 +65,9 @@ data class MainRouteBookInfo( val name: String?, val author: String?, val bookUrl: String, + val origin: String? = null, + val coverPath: String? = null, + val sharedCoverKey: String? = null, ) : MainRoute @Serializable diff --git a/app/src/main/java/io/legado/app/ui/main/MainNavigator.kt b/app/src/main/java/io/legado/app/ui/main/MainNavigator.kt index ca7786091..fc349eee0 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainNavigator.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainNavigator.kt @@ -254,7 +254,9 @@ object MainNavigator { MainRouteBookInfo( name = intent.getStringExtra(MainIntent.EXTRA_BOOK_NAME), author = intent.getStringExtra(MainIntent.EXTRA_BOOK_AUTHOR), - bookUrl = bookUrl + bookUrl = bookUrl, + origin = intent.getStringExtra(MainIntent.EXTRA_BOOK_ORIGIN), + coverPath = intent.getStringExtra(MainIntent.EXTRA_BOOK_COVER) ) } ?: MainRouteHome diff --git a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt b/app/src/main/java/io/legado/app/ui/main/MainScreen.kt index 0aa926247..5e0bf4a9e 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainScreen.kt @@ -110,7 +110,7 @@ fun MainScreen( onNavigateToLocalImport: () -> Unit, onNavigateToCache: (Long) -> Unit, onNavigateToBookCacheManage: () -> Unit, - onNavigateToBookInfo: (name: String, author: String, bookUrl: String) -> Unit, + onNavigateToBookInfo: (name: String, author: String, bookUrl: String, origin: String?, coverPath: String?, sharedCoverKey: String?) -> Unit, onNavigateToExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit, onNavigateToRssSort: (sourceUrl: String, sortUrl: String?, key: String?) -> Unit, onNavigateToRssRead: (title: String?, origin: String, link: String?, openUrl: String?) -> Unit, @@ -368,8 +368,15 @@ fun MainScreen( val destination = destinations.getOrNull(page) ?: return@HorizontalPager when (destination) { MainDestination.Home -> HomepageScreen( - onBookClick = { name, author, bookUrl -> - onNavigateToBookInfo(name ?: "", author ?: "", bookUrl) + onBookClick = { name, author, bookUrl, origin, coverPath, sharedCoverKey -> + onNavigateToBookInfo( + name ?: "", + author ?: "", + bookUrl, + origin, + coverPath, + sharedCoverKey + ) }, onModuleHeaderClick = { title, sourceUrl, exploreUrl -> onNavigateToExploreShow(title, sourceUrl, exploreUrl) @@ -382,8 +389,15 @@ fun MainScreen( onBookClick = { book -> context.startActivityForBook(book) }, - onBookLongClick = { book -> - onNavigateToBookInfo(book.name, book.author, book.bookUrl) + onBookLongClick = { book, sharedCoverKey -> + onNavigateToBookInfo( + book.name, + book.author, + book.bookUrl, + book.origin, + book.getDisplayCover(), + sharedCoverKey + ) }, onNavigateToSearch = { query -> onNavigateToSearch(query) }, onNavigateToRemoteImport = onNavigateToRemoteImport, diff --git a/app/src/main/java/io/legado/app/ui/main/MainViewModel.kt b/app/src/main/java/io/legado/app/ui/main/MainViewModel.kt index ac5e3297e..1855a0a5d 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainViewModel.kt @@ -31,6 +31,7 @@ class MainViewModel( private val prefs = context.defaultSharedPreferences private val mainPreferenceKeys = setOf( PreferKey.showDiscovery, + PreferKey.showHome, PreferKey.showRss, PreferKey.showBottomView, PreferKey.useFloatingBottomBar, @@ -152,10 +153,12 @@ private const val NAV_EXTENDED_KEY = "navExtended" private fun MainViewModel.readMainUiState(): MainUiState { val showDiscovery = context.getPrefBoolean(PreferKey.showDiscovery, true) + val showHome = context.getPrefBoolean(PreferKey.showHome, true) val showRss = context.getPrefBoolean(PreferKey.showRss, true) val destinations = MainDestination.mainDestinations.filter { when (it) { MainDestination.Explore -> showDiscovery + MainDestination.Home -> showHome MainDestination.Rss -> showRss else -> true } diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt index 88c7a98e2..21bfad879 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt @@ -153,7 +153,7 @@ import sh.calvin.reorderable.rememberReorderableLazyGridState fun BookshelfScreen( viewModel: BookshelfViewModel = koinViewModel(), onBookClick: (BookShelfItem) -> Unit, - onBookLongClick: (BookShelfItem) -> Unit, + onBookLongClick: (book: BookShelfItem, sharedCoverKey: String?) -> Unit, onNavigateToSearch: (String) -> Unit, onNavigateToRemoteImport: () -> Unit, onNavigateToLocalImport: () -> Unit, @@ -1253,7 +1253,7 @@ fun BookshelfPage( onDragFinished: () -> Unit, onGlobalSearch: () -> Unit, onBookClick: (BookShelfItem) -> Unit, - onBookLongClick: (BookShelfItem) -> Unit, + onBookLongClick: (BookShelfItem, String?) -> Unit, isCurrentPage: Boolean = true, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, @@ -1369,6 +1369,14 @@ fun BookshelfPage( ) { items(displayBooks, key = { it.book.bookUrl }) { bookUi -> val isSelected = selectedBookUrls.contains(bookUi.book.bookUrl) + val sharedCoverKey = if (isCurrentPage) { + bookCoverSharedElementKey( + bookUi.book.bookUrl, + "bookshelf:${uiState.selectedGroupId}" + ) + } else { + null + } ReorderableItem( state = reorderableState, key = bookUi.book.bookUrl, @@ -1408,7 +1416,7 @@ fun BookshelfPage( searchKey = uiState.searchKey, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, - sharedCoverKey = if (isCurrentPage) bookCoverSharedElementKey(bookUi.book.bookUrl) else null, + sharedCoverKey = sharedCoverKey, onClick = { if (uiState.isEditMode) { onToggleBookSelection(bookUi) @@ -1423,7 +1431,7 @@ fun BookshelfPage( if (uiState.isEditMode) { onToggleBookSelection(bookUi) } else { - onBookLongClick(bookUi.book) + onBookLongClick(bookUi.book, sharedCoverKey) } } } diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageEffect.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageEffect.kt index 5df9ae961..48452096b 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageEffect.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageEffect.kt @@ -5,6 +5,9 @@ sealed interface HomepageEffect { val name: String?, val author: String?, val bookUrl: String, + val origin: String? = null, + val coverPath: String? = null, + val sharedCoverKey: String?, ) : HomepageEffect data class NavigateToExploreShow( diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleManageSheet.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleManageSheet.kt index ac998e93a..4f90b9e9d 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleManageSheet.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleManageSheet.kt @@ -43,9 +43,12 @@ import io.legado.app.ui.widget.components.JsonRawEditor import io.legado.app.ui.widget.components.alert.AppAlertDialog import io.legado.app.ui.widget.components.button.SecondaryButton import io.legado.app.ui.widget.components.button.SmallIconButton +import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.card.ReorderableSelectionItem import io.legado.app.ui.widget.components.card.SelectionItemCard import io.legado.app.ui.widget.components.divider.PillDivider +import io.legado.app.ui.widget.components.divider.PillHeaderDivider +import io.legado.app.ui.widget.components.explore.ExploreKindSelectSheet import io.legado.app.ui.widget.components.icon.AppIcon import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem @@ -102,6 +105,7 @@ fun HomepageModuleManageSheet( var browseModuleType by remember(data != null) { mutableStateOf("card") } var selectedKindTitles by remember(data != null) { mutableStateOf>(emptySet()) } var showCustomSetAddModules by remember(data != null) { mutableStateOf(false) } + var showKindSelect by remember(data != null) { mutableStateOf(false) } var showAddButtonGroupDialog by remember(data != null) { mutableStateOf(false) } val defaultQuickActionsTitle = stringResource(R.string.homepage_quick_actions) var tempButtonGroupTitle by remember(data != null) { mutableStateOf(defaultQuickActionsTitle) } @@ -310,8 +314,7 @@ fun HomepageModuleManageSheet( onClick = { deleteConfirmId = module.id }, imageVector = Icons.Default.Delete ) - }, - modifier = Modifier.padding(horizontal = 4.dp) + } ) } @@ -353,8 +356,7 @@ fun HomepageModuleManageSheet( onClick = { deleteConfirmId = module.id }, imageVector = Icons.Default.Delete ) - }, - modifier = Modifier.padding(horizontal = 4.dp) + } ) } } @@ -398,8 +400,7 @@ fun HomepageModuleManageSheet( sourceUrl = browseUrl, ) ) - }, - modifier = Modifier.padding(horizontal = 4.dp) + } ) } } @@ -408,90 +409,76 @@ fun HomepageModuleManageSheet( 2 -> { val isButtonGroup = browseModuleType == "buttonGroup" - val selectableKinds = exploreKinds Column { val typeList = remember { HomepageModuleType.entries.filter { it != HomepageModuleType.Unknown } } - CompactDropdownSettingItem( - title = stringResource(R.string.homepage_module_type), - selectedValue = browseModuleType, - displayEntries = typeList.map { it.title }.toTypedArray(), - entryValues = typeList.map { it.key }.toTypedArray(), - onValueChange = { - browseModuleType = it; selectedKindTitles = emptySet() + + GlassCard( + containerColor = LegadoTheme.colorScheme.onSheetContent, + cornerRadius = 12.dp + ) { + CompactDropdownSettingItem( + title = stringResource(R.string.homepage_module_type), + selectedValue = browseModuleType, + displayEntries = typeList.map { it.title }.toTypedArray(), + entryValues = typeList.map { it.key }.toTypedArray(), + onValueChange = { + browseModuleType = it; selectedKindTitles = emptySet() + } + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + SelectionItemCard( + title = stringResource(R.string.homepage_select_from_kinds), + subtitle = if (isButtonGroup) { + if (selectedKindTitles.isEmpty()) stringResource(R.string.homepage_select_multiple_kinds) + else stringResource( + R.string.homepage_n_selected, + selectedKindTitles.size + ) + } else { + stringResource(R.string.homepage_select_one_kind) + }, + containerColor = LegadoTheme.colorScheme.onSheetContent, + onToggleSelection = { showKindSelect = true }, + trailingAction = { + if (isButtonGroup && selectedKindTitles.isNotEmpty()) { + SmallIconButton( + onClick = { showAddButtonGroupDialog = true }, + imageVector = Icons.Default.Check + ) + } } ) - if (selectableKinds.isEmpty()) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - contentAlignment = Alignment.Center - ) { - AppText( - stringResource(R.string.homepage_source_no_discover), - color = LegadoTheme.colorScheme.onSurfaceVariant - ) - } - } else { - AppText( - stringResource(R.string.homepage_select_items), - style = LegadoTheme.typography.labelMedium, - modifier = Modifier.padding( - horizontal = 16.dp, - vertical = 4.dp - ) - ) - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - items( - selectableKinds.distinctBy { it.first + it.second }, - key = { it.first + it.second }) { (kindTitle, kindUrl) -> - if (isButtonGroup) { - val isSelected = kindTitle in selectedKindTitles - SelectionItemCard( - title = kindTitle, - subtitle = kindUrl.take(60), - containerColor = LegadoTheme.colorScheme.onSheetContent, - isSelected = isSelected, - inSelectionMode = true, - onToggleSelection = { - selectedKindTitles = - if (isSelected) selectedKindTitles - kindTitle - else selectedKindTitles + kindTitle - }, - modifier = Modifier.padding(horizontal = 4.dp) - ) - } else { - val isJoined = joinedKeys.contains(kindTitle) - SelectionItemCard( - title = kindTitle, - subtitle = kindUrl.take(60) + if (isJoined) stringResource( - R.string.homepage_status_joined - ) else "", - containerColor = LegadoTheme.colorScheme.onSheetContent, - isSelected = isJoined, - inSelectionMode = true, - onToggleSelection = { - if (!isJoined) addDialogPrefill = - AddDialogPrefill( - kindTitle, - kindUrl, - browseModuleType - ) - }, - modifier = Modifier.padding(horizontal = 4.dp) + + ExploreKindSelectSheet( + show = showKindSelect, + onDismissRequest = { showKindSelect = false }, + sourceUrl = browseUrl, + multiple = isButtonGroup, + initialSelectedTitles = selectedKindTitles.toList(), + onSelected = { kinds -> + if (isButtonGroup) { + selectedKindTitles = kinds.map { it.title }.toSet() + } else { + kinds.firstOrNull()?.let { kind -> + addDialogPrefill = AddDialogPrefill( + title = kind.title, + url = kind.url ?: "", + type = browseModuleType ) } } } - } - Spacer(modifier = Modifier.height(12.dp)) + ) + + PillDivider( + modifier = Modifier.padding(vertical = 12.dp) + ) + SecondaryButton( text = stringResource(R.string.homepage_manual_add), onClick = { @@ -544,8 +531,7 @@ fun HomepageModuleManageSheet( joinedInCurrent = joinedInCurrent + (module.moduleKey to "temp_${module.id}") } - }, - modifier = Modifier.padding(horizontal = 4.dp) + } ) } } @@ -567,8 +553,7 @@ fun HomepageModuleManageSheet( onToggleSelection = { browsingSourceUrl = source.sourceUrl browsingDetail = true - }, - modifier = Modifier.padding(horizontal = 4.dp) + } ) } } @@ -662,8 +647,7 @@ fun HomepageModuleManageSheet( onClick = { deleteConfirmId = module.id }, imageVector = Icons.Default.Delete ) - }, - modifier = Modifier.padding(horizontal = 4.dp) + } ) } } @@ -694,8 +678,7 @@ fun HomepageModuleManageSheet( onClick = { deleteConfirmId = module.id }, imageVector = Icons.Default.Delete ) - }, - modifier = Modifier.padding(horizontal = 4.dp) + } ) } } @@ -770,8 +753,12 @@ fun HomepageModuleManageSheet( onClick = { deleteSetConfirmId = set.sourceUrl }, imageVector = Icons.Default.Delete ) - }, - modifier = Modifier.padding(horizontal = 4.dp) + } + ) + } + item { + PillDivider( + modifier = Modifier.padding(vertical = 12.dp) ) } item(key = "create_set") { @@ -1001,41 +988,51 @@ fun AddCustomModuleDialog( .fillMaxWidth() .height(400.dp) .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(4.dp) + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { AppTextField( value = title, onValueChange = { title = it }, + backgroundColor = LegadoTheme.colorScheme.onSheetContent, label = stringResource(R.string.homepage_title_label), modifier = Modifier.fillMaxWidth() ) AppTextField( value = url, onValueChange = { url = it }, + backgroundColor = LegadoTheme.colorScheme.onSheetContent, label = "URL", modifier = Modifier.fillMaxWidth() ) val typeList = remember { HomepageModuleType.entries.filter { it != HomepageModuleType.Unknown } } - DropdownListSettingItem( - title = stringResource(R.string.homepage_type_label), - selectedValue = type, - displayEntries = typeList.map { it.title }.toTypedArray(), - entryValues = typeList.map { it.key }.toTypedArray(), - onValueChange = { type = it } - ) + + GlassCard( + containerColor = LegadoTheme.colorScheme.onSheetContent + ) { + DropdownListSettingItem( + title = stringResource(R.string.homepage_type_label), + selectedValue = type, + displayEntries = typeList.map { it.title }.toTypedArray(), + entryValues = typeList.map { it.key }.toTypedArray(), + onValueChange = { type = it } + ) + } + AppTextField( value = args, onValueChange = { args = it }, + backgroundColor = LegadoTheme.colorScheme.onSheetContent, label = "Args (JSON)", modifier = Modifier.fillMaxWidth() ) - AppText( - text = stringResource(R.string.homepage_layout_config_label), - style = LegadoTheme.typography.labelMedium, - modifier = Modifier.padding(top = 16.dp, bottom = 4.dp) + + PillHeaderDivider( + title = stringResource(R.string.homepage_layout_config_label) ) + if (hasVisualizableKeys) { JsonConfigEditor( jsonString = layoutConfig, diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt index 987ba600a..52bfaaa27 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt @@ -10,27 +10,28 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan -import androidx.compose.foundation.lazy.staggeredgrid.items +import androidx.compose.foundation.lazy.staggeredgrid.itemsIndexed import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.GridView +import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -46,7 +47,6 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -60,12 +60,15 @@ import io.legado.app.ui.main.homepage.modules.GridModule import io.legado.app.ui.main.homepage.modules.GridRankingModule import io.legado.app.ui.main.homepage.modules.RankingModule import io.legado.app.ui.main.homepage.modules.WaterfallItem +import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.widget.components.AppPullToRefresh import io.legado.app.ui.widget.components.AppScaffold +import io.legado.app.ui.widget.components.LoadMoreFooter import io.legado.app.ui.widget.components.alert.AppAlertDialog import io.legado.app.ui.widget.components.book.SearchBookGridItem -import io.legado.app.ui.widget.components.button.SecondaryButton import io.legado.app.ui.widget.components.button.SmallTonalIconButton +import io.legado.app.ui.widget.components.card.GlassCard +import io.legado.app.ui.widget.components.icon.AppIcon import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator import io.legado.app.ui.widget.components.tabRow.AppTabRow import io.legado.app.ui.widget.components.text.AppText @@ -84,7 +87,7 @@ import org.koin.androidx.compose.koinViewModel @Composable fun HomepageScreen( viewModel: HomepageViewModel = koinViewModel(), - onBookClick: (name: String?, author: String?, bookUrl: String) -> Unit, + onBookClick: (name: String?, author: String?, bookUrl: String, origin: String?, coverPath: String?, sharedCoverKey: String?) -> Unit, onModuleHeaderClick: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, @@ -137,7 +140,14 @@ fun HomepageScreen( viewModel.effects.collect { effect -> when (effect) { is HomepageEffect.NavigateToBookInfo -> - onBookClick(effect.name, effect.author, effect.bookUrl) + onBookClick( + effect.name, + effect.author, + effect.bookUrl, + effect.origin, + effect.coverPath, + effect.sharedCoverKey + ) is HomepageEffect.NavigateToExploreShow -> onModuleHeaderClick(effect.title, effect.sourceUrl, effect.exploreUrl) @@ -238,6 +248,7 @@ fun HomepageScreen( data = errorMsg, onDismissRequest = { errorMsg = null }, title = stringResource(R.string.homepage_module_error), + text = errorMsg, confirmText = stringResource(R.string.copy_text), onConfirm = { context.sendToClip(it) @@ -355,12 +366,14 @@ private fun ModuleList( item(key = "header_${moduleUi.globalId}", span = StaggeredGridItemSpan.FullLine) { ModuleHeader( title = moduleUi.title, - onNavigate = { - viewModel.onModuleHeaderClick( - moduleUi.sourceUrl, - moduleUi.exploreUrl, - moduleUi.title, - ) + onNavigate = if (moduleUi.type == HomepageModuleType.ButtonGroup) null else { + { + viewModel.onModuleHeaderClick( + moduleUi.sourceUrl, + moduleUi.exploreUrl, + moduleUi.title, + ) + } }, ) } @@ -390,27 +403,78 @@ private fun ModuleList( ) { Column( modifier = Modifier - .fillMaxWidth() - .height(80.dp) - .clickable { onErrorClick(state.message) }, - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally ) { - AppText( - text = state.message, - color = MaterialTheme.colorScheme.error, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 16.dp) - ) - Spacer(modifier = Modifier.height(4.dp)) - SecondaryButton( - text = stringResource(R.string.retry), - onClick = { - viewModel.retryModule(moduleUi.globalId) + GlassCard( + onClick = { onErrorClick(state.message) }, + containerColor = LegadoTheme.colorScheme.errorContainer.copy( + alpha = 0.6f + ), + ) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = 16.dp, + vertical = 16.dp + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + + AppIcon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + tint = LegadoTheme.colorScheme.error + ) + + AppText( + text = state.message, + color = LegadoTheme.colorScheme.error, + style = LegadoTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + HorizontalDivider( + color = LegadoTheme.colorScheme.error.copy(alpha = 0.3f) + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .clickable { + viewModel.retryModule(moduleUi.globalId) + } + .padding(vertical = 10.dp), + contentAlignment = Alignment.Center + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AppIcon( + imageVector = Icons.Default.Refresh, + contentDescription = null, + tint = LegadoTheme.colorScheme.error + ) + + AppText( + text = "重试", + color = LegadoTheme.colorScheme.error, + style = LegadoTheme.typography.labelMedium + ) + } + } } - ) + } } } } @@ -435,68 +499,63 @@ private fun ModuleList( val config = moduleUi.config when (moduleUi.type) { HomepageModuleType.Waterfall -> { - items( + itemsIndexed( state.books, - key = { "wf_${moduleUi.globalId}_${it.bookUrl}" }) { book -> + key = { index, book -> "wf_${moduleUi.globalId}_${book.bookUrl}_$index" }) { index, book -> + val sharedCoverKey = bookCoverSharedElementKey( + book.bookUrl, + "home:${moduleUi.globalId}:waterfall:$index" + ) WaterfallItem( book = book, - onClick = { viewModel.onBookClick(book) }, + onClick = { viewModel.onBookClick(book, sharedCoverKey) }, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, + sharedCoverKey = sharedCoverKey, ) } - if (state.hasMore) { - item( - key = "wf_more_${moduleUi.globalId}", - span = StaggeredGridItemSpan.FullLine - ) { - LaunchedEffect(state.books.size) { - viewModel.loadMoreModule(moduleUi.globalId) - } - Box( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - contentAlignment = Alignment.Center - ) { - AppCircularProgressIndicator(modifier = Modifier.size(24.dp)) - } - } + item( + key = "wf_more_${moduleUi.globalId}", + span = StaggeredGridItemSpan.FullLine + ) { + LoadMoreFooter( + isLoading = state.isLoadingMore, + errorMsg = null, + isEnd = !state.hasMore, + onRetry = { viewModel.loadMoreModule(moduleUi.globalId) } + ) } } HomepageModuleType.InfiniteGrid -> { - items( + itemsIndexed( state.books, - key = { "inf_grid_${moduleUi.globalId}_${it.bookUrl}" }) { book -> + key = { index, book -> "inf_grid_${moduleUi.globalId}_${book.bookUrl}_$index" }) { index, book -> + val sharedCoverKey = bookCoverSharedElementKey( + book.bookUrl, + "home:${moduleUi.globalId}:infinite:$index" + ) SearchBookGridItem( book = book, shelfState = io.legado.app.domain.model.BookShelfState.NOT_IN_SHELF, - onClick = { viewModel.onBookClick(book) }, + onClick = { viewModel.onBookClick(book, sharedCoverKey) }, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, - sharedCoverKey = bookCoverSharedElementKey(book.bookUrl) + sharedCoverKey = sharedCoverKey ) } - if (state.hasMore) { - item( - key = "inf_grid_more_${moduleUi.globalId}", - span = StaggeredGridItemSpan.FullLine - ) { - LaunchedEffect(state.books.size) { - viewModel.loadMoreModule(moduleUi.globalId) - } - Box( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - contentAlignment = Alignment.Center - ) { - AppCircularProgressIndicator(modifier = Modifier.size(24.dp)) - } - } + item( + key = "inf_grid_more_${moduleUi.globalId}", + span = StaggeredGridItemSpan.FullLine + ) { + LoadMoreFooter( + isLoading = state.isLoadingMore, + errorMsg = null, + isEnd = !state.hasMore, + onRetry = { viewModel.loadMoreModule(moduleUi.globalId) } + ) } } @@ -509,12 +568,15 @@ private fun ModuleList( ) { GridModule( books = state.books, - onClick = { viewModel.onBookClick(it) }, + onClick = { book, sharedCoverKey -> + viewModel.onBookClick(book, sharedCoverKey) + }, modifier = Modifier.fillMaxWidth(), columns = columns, maxRows = rows, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, + sharedCoverKeySourceId = "home:${moduleUi.globalId}:grid", ) } } @@ -528,10 +590,13 @@ private fun ModuleList( ) { BannerModule( books = state.books, - onClick = { viewModel.onBookClick(it) }, + onClick = { book, sharedCoverKey -> + viewModel.onBookClick(book, sharedCoverKey) + }, modifier = Modifier.fillMaxWidth(), sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, + sharedCoverKeySourceId = "home:${moduleUi.globalId}:banner", ) } } @@ -543,10 +608,13 @@ private fun ModuleList( ) { RankingModule( books = state.books, - onClick = { viewModel.onBookClick(it) }, + onClick = { book, sharedCoverKey -> + viewModel.onBookClick(book, sharedCoverKey) + }, modifier = Modifier.fillMaxWidth(), sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, + sharedCoverKeySourceId = "home:${moduleUi.globalId}:ranking", ) } } @@ -558,11 +626,14 @@ private fun ModuleList( ) { GridRankingModule( books = state.books, - onClick = { viewModel.onBookClick(it) }, + onClick = { book, sharedCoverKey -> + viewModel.onBookClick(book, sharedCoverKey) + }, modifier = Modifier.fillMaxWidth(), rows = config["layout_rows"]?.toIntOrNull() ?: 4, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, + sharedCoverKeySourceId = "home:${moduleUi.globalId}:grid-ranking", ) } } @@ -574,10 +645,13 @@ private fun ModuleList( ) { CardModule( books = state.books, - onClick = { viewModel.onBookClick(it) }, + onClick = { book, sharedCoverKey -> + viewModel.onBookClick(book, sharedCoverKey) + }, modifier = Modifier.fillMaxWidth(), sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, + sharedCoverKeySourceId = "home:${moduleUi.globalId}:card", ) } } @@ -596,7 +670,7 @@ private fun ModuleList( @Composable private fun ModuleHeader( title: String, - onNavigate: () -> Unit, + onNavigate: (() -> Unit)? = null, ) { Row( modifier = Modifier @@ -612,9 +686,11 @@ private fun ModuleHeader( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) - SmallTonalIconButton( - onClick = onNavigate, - imageVector = Icons.AutoMirrored.Filled.ArrowForward - ) + if (onNavigate != null) { + SmallTonalIconButton( + onClick = onNavigate, + imageVector = Icons.AutoMirrored.Filled.ArrowForward + ) + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt index 8bdf63423..f3f618cec 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt @@ -17,6 +17,7 @@ import io.legado.app.domain.usecase.SaveSearchBooksUseCase import io.legado.app.help.source.exploreKinds import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonArray +import io.legado.app.utils.stackTraceStr import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers @@ -215,28 +216,10 @@ class HomepageViewModel( } } - // sync: 只处理有 homepageModules 的书源 - viewModelScope.launch { - initModulesSyncFlow.collect { sources -> - sources.forEach { source -> syncModulesFromSource(source) } - } - } - - // cache: 所有启用发现的书源(包括无 homepageModules 的) + // 清理 _pendingUserModules 中已入库的条目 viewModelScope.launch { exploreSourcesFlow.collect { sources -> _bookSourcesCache.value = sources.associateBy { it.bookSourceUrl } - val kindsCache = mutableMapOf>>() - for (source in sources) { - kindsCache[source.bookSourceUrl] = try { - withContext(Dispatchers.IO) { - source.exploreKinds().map { it.title to (it.url ?: "") } - } - } catch (_: Exception) { - emptyList() - } - } - _exploreKindsCache.value = kindsCache } } @@ -367,7 +350,7 @@ class HomepageViewModel( }.onFailure { e -> _moduleContentStates.update { it + (module.id to ModuleLoadState.Error( - e.message ?: "Unknown error" + e.stackTraceStr )) } } @@ -404,7 +387,7 @@ class HomepageViewModel( }.onFailure { e -> _moduleContentStates.update { it + (module.id to ModuleLoadState.Error( - e.message ?: "Unknown error" + e.stackTraceStr )) } } @@ -454,7 +437,7 @@ class HomepageViewModel( HomepageEffect.ShowSnackbar( getApplication().getString( R.string.homepage_load_more_failed, - e.message ?: "" + e.stackTraceStr ) ) ) @@ -477,6 +460,13 @@ class HomepageViewModel( _isRefreshing.value = true loadJobs.values.forEach { it.cancel() } loadJobs.clear() + + // 刷新时同步当前已启用模块所属书源的定义 + val activeSourceUrls = uiState.value.modules.map { it.sourceUrl }.distinct() + activeSourceUrls.forEach { url -> + resolveBookSource(url)?.let { syncModulesFromSource(it) } + } + _moduleContentStates.value = emptyMap() uiState.map { it.modules }.first { modules -> modules.all { it.state !is ModuleLoadState.Loading } @@ -704,6 +694,12 @@ class HomepageViewModel( targetSetId: String? = null ): List { val source = resolveBookSource(sourceUrl) ?: return emptyList() + + // 按需同步:只有进入该源的管理页才同步其 JSON 定义 + viewModelScope.launch { + syncModulesFromSource(source) + } + val json = source.homepageModules ?: return emptyList() val jsonDefs = parseBookSourceModules(source, json) @@ -850,10 +846,19 @@ class HomepageViewModel( } } - fun onBookClick(book: SearchBook) { + fun onBookClick(book: SearchBook, sharedCoverKey: String?) { viewModelScope.launch { saveSearchBooksUseCase.save(book) - _effects.emit(HomepageEffect.NavigateToBookInfo(book.name, book.author, book.bookUrl)) + _effects.emit( + HomepageEffect.NavigateToBookInfo( + name = book.name, + author = book.author, + bookUrl = book.bookUrl, + origin = book.origin, + coverPath = book.coverUrl, + sharedCoverKey = sharedCoverKey + ) + ) } } @@ -909,4 +914,4 @@ private data class HomepageUiFlags( val isRefreshing: Boolean, val isManageMode: Boolean, val isConfigMode: Boolean -) \ No newline at end of file +) diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/modules/BannerModule.kt b/app/src/main/java/io/legado/app/ui/main/homepage/modules/BannerModule.kt index b4cfa2dcd..aa6f1dde3 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/modules/BannerModule.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/modules/BannerModule.kt @@ -8,7 +8,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -23,10 +23,11 @@ import kotlinx.collections.immutable.ImmutableList @Composable fun BannerModule( books: ImmutableList, - onClick: (SearchBook) -> Unit, + onClick: (SearchBook, String?) -> Unit, modifier: Modifier = Modifier, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, + sharedCoverKeySourceId: String? = null, ) { if (books.isEmpty()) return @@ -38,7 +39,11 @@ fun BannerModule( .fadingEdge(lazyListState, gradientWidth = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - items(books) { book -> + itemsIndexed(books, key = { index, book -> "${book.bookUrl}:$index" }) { index, book -> + val sharedCoverKey = bookCoverSharedElementKey( + book.bookUrl, + sharedCoverKeySourceId?.let { "$it:$index" } + ) CoilBookCover( name = book.name, author = book.author, @@ -47,10 +52,10 @@ fun BannerModule( sourceOrigin = book.origin, modifier = Modifier .width(96.dp) - .clickable { onClick(book) }, + .clickable { onClick(book, sharedCoverKey) }, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, - sharedCoverKey = bookCoverSharedElementKey(book.bookUrl) + sharedCoverKey = sharedCoverKey ) } } diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/modules/ButtonGroupModule.kt b/app/src/main/java/io/legado/app/ui/main/homepage/modules/ButtonGroupModule.kt index 940a3faaf..392fe4e9d 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/modules/ButtonGroupModule.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/modules/ButtonGroupModule.kt @@ -3,6 +3,7 @@ package io.legado.app.ui.main.homepage.modules import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -12,12 +13,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf 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 import androidx.compose.ui.platform.LocalContext @@ -26,14 +22,14 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.legado.app.data.entities.rule.ExploreKind import io.legado.app.domain.usecase.ExploreKindUiUseCase -import io.legado.app.help.source.getExploreInfoMap import io.legado.app.ui.main.homepage.HomepageViewModel import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.theme.ThemeResolver import io.legado.app.ui.widget.components.card.GlassCard +import io.legado.app.ui.widget.components.explore.ExploreKindMultiTypeItem import io.legado.app.ui.widget.components.image.sourceIcon.SourceIcon import io.legado.app.ui.widget.components.text.AppText import io.legado.app.utils.GSON -import kotlinx.coroutines.launch import org.koin.compose.koinInject @Composable @@ -51,10 +47,7 @@ fun ButtonGroupModule( val context = LocalContext.current val activity = context as? AppCompatActivity val useCase: ExploreKindUiUseCase = koinInject() - val scope = rememberCoroutineScope() - val infoMap = remember(sourceUrl) { - sourceUrl.takeIf { it.isNotBlank() }?.let { getExploreInfoMap(it) } - } + val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine) // 解析图标映射表和默认图标 val (iconMap, defaultIcon) = remember(layoutConfig) { @@ -90,74 +83,76 @@ fun ButtonGroupModule( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { rowKinds.forEach { kind -> - var displayName by remember(kind.title) { mutableStateOf(kind.title) } - - LaunchedEffect(kind, sourceUrl, infoMap) { - displayName = useCase.resolveDisplayName(kind, sourceUrl, infoMap) - } - val buttonIcon = iconMap[kind.title] ?: defaultIcon val hasIcon = !buttonIcon.isNullOrBlank() - GlassCard( - onClick = { - when (kind.type) { - ExploreKind.Type.url -> { - kind.url?.takeIf { it.isNotBlank() }?.let { - viewModel.onKindUrlClick(sourceUrl, it, kind.title) - } - } + ExploreKindMultiTypeItem( + kind = kind, + sourceUrl = sourceUrl, + activity = activity, + onOpenUrl = { url -> + viewModel.onKindUrlClick(sourceUrl, url, kind.title) + }, + onRefreshKinds = { + viewModel.refreshButtonGroup(globalId) + }, + useCase = useCase, + isMiuix = isMiuix, + modifier = Modifier.weight(1f), + content = { displayName, isSelected, onClick, trailingIcon -> + GlassCard( + onClick = onClick, + cornerRadius = 8.dp, + containerColor = if (isSelected) LegadoTheme.colorScheme.primaryContainer else LegadoTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth() + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxSize() + .padding(vertical = 8.dp, horizontal = 4.dp) + ) { + if (hasIcon) { + SourceIcon( + path = buttonIcon!!, + modifier = Modifier.size(20.dp), + placeholderIcon = { - ExploreKind.Type.button -> { - scope.launch { - useCase.executeAction( - action = kind.action, - title = kind.title, - sourceUrl = sourceUrl, - infoMap = infoMap, - activity = activity, - onRefreshKinds = { - viewModel.refreshButtonGroup(globalId) } ) + Spacer(modifier = Modifier.height(6.dp)) + } + + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + AppText( + text = displayName, + style = LegadoTheme.typography.labelSmallEmphasized, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Clip, + modifier = Modifier + .padding(horizontal = 4.dp) + .basicMarquee() + ) + + if (trailingIcon != null) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(end = 2.dp) + ) { + trailingIcon() + } + } } } } - }, - cornerRadius = 8.dp, - containerColor = LegadoTheme.colorScheme.surfaceContainerLow, - modifier = Modifier.weight(1f) - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier - .fillMaxSize() - .padding(vertical = 8.dp, horizontal = 4.dp) - ) { - if (hasIcon) { - SourceIcon( - path = buttonIcon, - modifier = Modifier.size(20.dp), - placeholderIcon = { - - } - ) - Spacer(modifier = Modifier.height(6.dp)) - } - - AppText( - text = displayName, - style = LegadoTheme.typography.labelSmallEmphasized, - textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Clip, - modifier = Modifier - .padding(horizontal = 4.dp) - .basicMarquee() - ) } - } + ) } if (rowKinds.size < actualColumns) { diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/modules/CardModule.kt b/app/src/main/java/io/legado/app/ui/main/homepage/modules/CardModule.kt index 861e4c188..a4c4365da 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/modules/CardModule.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/modules/CardModule.kt @@ -12,7 +12,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable @@ -35,10 +35,11 @@ import kotlinx.collections.immutable.ImmutableList @Composable fun CardModule( books: ImmutableList, - onClick: (SearchBook) -> Unit, + onClick: (SearchBook, String?) -> Unit, modifier: Modifier = Modifier, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, + sharedCoverKeySourceId: String? = null, ) { if (books.isEmpty()) return val lazyListState = rememberLazyListState() @@ -49,13 +50,17 @@ fun CardModule( .fadingEdge(lazyListState, gradientWidth = 8.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - items(books, key = { it.bookUrl }) { book -> + itemsIndexed(books, key = { index, book -> "${book.bookUrl}:$index" }) { index, book -> + val sharedCoverKey = bookCoverSharedElementKey( + book.bookUrl, + sharedCoverKeySourceId?.let { "$it:$index" } + ) Column( modifier = Modifier .width(120.dp) .clip(RoundedCornerShape(16.dp)) .background(LegadoTheme.colorScheme.surfaceContainerLow) - .clickable { onClick(book) } + .clickable { onClick(book, sharedCoverKey) } ) { CoilBookCover( name = book.name, @@ -67,19 +72,20 @@ fun CardModule( .wrapContentWidth(), sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, - sharedCoverKey = bookCoverSharedElementKey(book.bookUrl) + sharedCoverKey = sharedCoverKey ) AppText( text = book.name, style = LegadoTheme.typography.labelLargeEmphasized, maxLines = 2, + minLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding( start = 8.dp, end = 8.dp, top = 8.dp, - bottom = 2.dp + bottom = 8.dp ), ) @@ -88,7 +94,7 @@ fun CardModule( if (intro != null) { AppText( text = intro, - style = LegadoTheme.typography.bodySmall, + style = LegadoTheme.typography.labelSmallEmphasized, color = LegadoTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis, diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/modules/GridModule.kt b/app/src/main/java/io/legado/app/ui/main/homepage/modules/GridModule.kt index 55004f808..7423b3f87 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/modules/GridModule.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/modules/GridModule.kt @@ -21,12 +21,13 @@ import kotlinx.collections.immutable.ImmutableList @Composable fun GridModule( books: ImmutableList, - onClick: (SearchBook) -> Unit, + onClick: (SearchBook, String?) -> Unit, modifier: Modifier = Modifier, columns: Int = 3, maxRows: Int? = null, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, + sharedCoverKeySourceId: String? = null, ) { if (books.isEmpty()) return var rows = books.toList().chunked(columns) @@ -37,20 +38,25 @@ fun GridModule( modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - for (row in rows) { + for ((rowIndex, row) in rows.withIndex()) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - for (book in row) { + for ((columnIndex, book) in row.withIndex()) { + val itemIndex = rowIndex * columns + columnIndex + val sharedCoverKey = bookCoverSharedElementKey( + book.bookUrl, + sharedCoverKeySourceId?.let { "$it:$itemIndex" } + ) SearchBookGridItem( book = book, shelfState = BookShelfState.NOT_IN_SHELF, - onClick = { onClick(book) }, + onClick = { onClick(book, sharedCoverKey) }, modifier = Modifier.weight(1f), sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, - sharedCoverKey = bookCoverSharedElementKey(book.bookUrl) + sharedCoverKey = sharedCoverKey ) } repeat(columns - row.size) { Spacer(Modifier.weight(1f)) } diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/modules/GridRankingModule.kt b/app/src/main/java/io/legado/app/ui/main/homepage/modules/GridRankingModule.kt index af567717b..098423c1f 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/modules/GridRankingModule.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/modules/GridRankingModule.kt @@ -37,11 +37,12 @@ import kotlinx.collections.immutable.ImmutableList @Composable fun GridRankingModule( books: ImmutableList, - onClick: (SearchBook) -> Unit, + onClick: (SearchBook, String?) -> Unit, modifier: Modifier = Modifier, rows: Int = 4, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, + sharedCoverKeySourceId: String? = null, ) { if (books.isEmpty()) return // 限制最多显示 20 项 @@ -70,13 +71,19 @@ fun GridRankingModule( .fillMaxWidth() .padding(vertical = 12.dp, horizontal = 12.dp) ) { - for (book in page) { + for ((rowIndex, book) in page.withIndex()) { + val itemIndex = pageIndex * rows + rowIndex + val sharedCoverKey = bookCoverSharedElementKey( + book.bookUrl, + sharedCoverKeySourceId?.let { "$it:$itemIndex" } + ) GridRankingItem( rank = pages.flatten().indexOf(book) + 1, book = book, - onClick = { onClick(book) }, + onClick = { onClick(book, sharedCoverKey) }, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, + sharedCoverKey = sharedCoverKey, ) } // 占位逻辑 @@ -96,6 +103,7 @@ private fun GridRankingItem( onClick: () -> Unit, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, + sharedCoverKey: String? = null, ) { Row( modifier = Modifier @@ -114,7 +122,7 @@ private fun GridRankingItem( modifier = Modifier.width(48.dp), sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, - sharedCoverKey = bookCoverSharedElementKey(book.bookUrl) + sharedCoverKey = sharedCoverKey ) // 2. 排名 diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/modules/RankingModule.kt b/app/src/main/java/io/legado/app/ui/main/homepage/modules/RankingModule.kt index 33d47075d..9c99cb0e2 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/modules/RankingModule.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/modules/RankingModule.kt @@ -45,10 +45,11 @@ private const val MAX_COUNT = 20 @Composable fun RankingModule( books: ImmutableList, - onClick: (SearchBook) -> Unit, + onClick: (SearchBook, String?) -> Unit, modifier: Modifier = Modifier, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, + sharedCoverKeySourceId: String? = null, ) { var visibleCount by rememberSaveable { mutableIntStateOf(INITIAL_COUNT) } val displayBooks = books.take(visibleCount) @@ -72,6 +73,10 @@ fun RankingModule( onClick = onClick, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, + sharedCoverKey = bookCoverSharedElementKey( + book.bookUrl, + sharedCoverKeySourceId?.let { "$it:$index" } + ) ) } @@ -114,14 +119,15 @@ fun RankingModule( private fun RankingItem( rank: Int, book: SearchBook, - onClick: (SearchBook) -> Unit, + onClick: (SearchBook, String?) -> Unit, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, + sharedCoverKey: String? = null, ) { Row( modifier = Modifier .fillMaxWidth() - .clickable { onClick(book) } + .clickable { onClick(book, sharedCoverKey) } .padding(vertical = 4.dp, horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -144,7 +150,7 @@ private fun RankingItem( modifier = Modifier.weight(1f), sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, - sharedCoverKey = bookCoverSharedElementKey(book.bookUrl) + sharedCoverKey = sharedCoverKey ) } } diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/modules/WaterfallModule.kt b/app/src/main/java/io/legado/app/ui/main/homepage/modules/WaterfallModule.kt index 5601ee729..5b27a1061 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/modules/WaterfallModule.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/modules/WaterfallModule.kt @@ -17,7 +17,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.legado.app.data.entities.SearchBook -import io.legado.app.ui.main.bookCoverSharedElementKey import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.widget.components.book.SearchBookTagChip import io.legado.app.ui.widget.components.card.GlassCard @@ -36,6 +35,7 @@ fun WaterfallItem( modifier: Modifier = Modifier, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, + sharedCoverKey: String? = null, ) { GlassCard( containerColor = LegadoTheme.colorScheme.surfaceContainerLow @@ -55,7 +55,7 @@ fun WaterfallItem( .fillMaxWidth(), sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, - sharedCoverKey = bookCoverSharedElementKey(book.bookUrl) + sharedCoverKey = sharedCoverKey ) Spacer(modifier = Modifier.height(8.dp)) diff --git a/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesCompose.kt b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesCompose.kt index ba1ef0be5..4e41f5a76 100644 --- a/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesCompose.kt +++ b/app/src/main/java/io/legado/app/ui/rss/article/RssArticlesCompose.kt @@ -1,7 +1,6 @@ package io.legado.app.ui.rss.article import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -59,7 +58,7 @@ import io.legado.app.data.entities.RssSource import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.adaptiveContentPadding import io.legado.app.ui.widget.components.AppPullToRefresh -import io.legado.app.ui.widget.components.EmptyMessage +import io.legado.app.ui.widget.components.LoadMoreFooter import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.image.cover.buildCoverImageRequest import io.legado.app.utils.toastOnUi @@ -163,7 +162,9 @@ fun RssArticlesPage( } item { LoadMoreFooter( - state = loadState, + isLoading = loadState.isRefreshing || loadState.isLoadingMore, + errorMsg = loadState.errorMessage, + isEnd = !loadState.hasMore, onRetry = { rssSource?.let(viewModel::loadMore) } ) } @@ -198,7 +199,9 @@ fun RssArticlesPage( } item(span = { GridItemSpan(maxLineSpan) }) { LoadMoreFooter( - state = loadState, + isLoading = loadState.isRefreshing || loadState.isLoadingMore, + errorMsg = loadState.errorMessage, + isEnd = !loadState.hasMore, onRetry = { rssSource?.let(viewModel::loadMore) } ) } @@ -233,7 +236,9 @@ fun RssArticlesPage( } item(span = StaggeredGridItemSpan.FullLine) { LoadMoreFooter( - state = loadState, + isLoading = loadState.isRefreshing || loadState.isLoadingMore, + errorMsg = loadState.errorMessage, + isEnd = !loadState.hasMore, onRetry = { rssSource?.let(viewModel::loadMore) } ) } @@ -297,35 +302,6 @@ private fun StaggeredLoadMoreDetector( } } -@Composable -private fun LoadMoreFooter( - state: RssArticlesLoadState, - onRetry: () -> Unit -) { - val text = when { - state.isRefreshing || state.isLoadingMore -> "加载中..." - !state.hasMore -> "没有更多了" - state.errorMessage != null -> "加载失败,点击重试" - else -> "上拉加载更多" - } - val contentModifier = if (state.errorMessage != null) { - Modifier.clickable(onClick = onRetry) - } else { - Modifier - } - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 12.dp), - contentAlignment = Alignment.Center - ) { - EmptyMessage( - message = text, - isLoading = state.isRefreshing || state.isLoadingMore, - modifier = contentModifier - ) - } -} @Composable private fun RssArticleItem( diff --git a/app/src/main/java/io/legado/app/ui/theme/Typography.kt b/app/src/main/java/io/legado/app/ui/theme/Typography.kt index d27ecd7e0..2ce502872 100644 --- a/app/src/main/java/io/legado/app/ui/theme/Typography.kt +++ b/app/src/main/java/io/legado/app/ui/theme/Typography.kt @@ -2,6 +2,7 @@ package io.legado.app.ui.theme import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Typography +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp import top.yukonga.miuix.kmp.theme.TextStyles @@ -66,7 +67,7 @@ fun Typography.toLegadoTypography(): LegadoTypography { ) } -fun LegadoTypography.withFont(fontFamily: androidx.compose.ui.text.font.FontFamily?): LegadoTypography { +fun LegadoTypography.withFont(fontFamily: FontFamily?): LegadoTypography { if (fontFamily == null) return this return copy( headlineLarge = headlineLarge.copy(fontFamily = fontFamily), diff --git a/app/src/main/java/io/legado/app/ui/widget/components/AppFloatingActionButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/AppFloatingActionButton.kt index 249cba946..57bd37761 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/AppFloatingActionButton.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/AppFloatingActionButton.kt @@ -69,7 +69,7 @@ fun AppFloatingActionButton( MiuixIcon( imageVector = icon, contentDescription = tooltipText, - tint = Color.White + tint = containerColor ) } else { Icon( @@ -87,7 +87,8 @@ fun AppFloatingActionButton( MiuixFloatingActionButton( onClick = onClick, modifier = modifier, - content = fabContent + content = fabContent, + containerColor = LegadoTheme.colorScheme.surfaceContainer ) } else { if (tooltipText != null) { diff --git a/app/src/main/java/io/legado/app/ui/widget/components/JsonRawEditor.kt b/app/src/main/java/io/legado/app/ui/widget/components/JsonRawEditor.kt index 4c7e6959d..3f039e9ba 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/JsonRawEditor.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/JsonRawEditor.kt @@ -8,18 +8,13 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AutoFixHigh import androidx.compose.material.icons.filled.Compress -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import com.google.gson.GsonBuilder import com.google.gson.JsonParser +import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.widget.components.button.SmallIconButton import io.legado.app.ui.widget.components.text.AppText import io.legado.app.utils.GSON @@ -39,8 +34,7 @@ fun JsonRawEditor( ) { AppText( text = label, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary + style = LegadoTheme.typography.labelMediumEmphasized ) Row { SmallIconButton( @@ -67,21 +61,13 @@ fun JsonRawEditor( } } - TextField( + AppTextField( value = value, onValueChange = onValueChange, modifier = Modifier .fillMaxWidth() .heightIn(min = 150.dp, max = 400.dp), - textStyle = TextStyle( - fontFamily = FontFamily.Monospace, - fontSize = MaterialTheme.typography.bodySmall.fontSize - ), - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - ), + backgroundColor = LegadoTheme.colorScheme.onSheetContent, maxLines = 1000 ) } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/LoadMoreFooter.kt b/app/src/main/java/io/legado/app/ui/widget/components/LoadMoreFooter.kt new file mode 100644 index 000000000..e457690a7 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/LoadMoreFooter.kt @@ -0,0 +1,251 @@ +package io.legado.app.ui.widget.components + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.theme.adaptiveHorizontalPadding +import io.legado.app.ui.widget.components.alert.AppAlertDialog +import io.legado.app.ui.widget.components.button.AnimatedTextButton +import io.legado.app.ui.widget.components.card.GlassCard +import io.legado.app.ui.widget.components.icon.AppIcon +import io.legado.app.ui.widget.components.progressIndicator.AppContainedLoadingIndicator +import io.legado.app.ui.widget.components.text.AppText +import io.legado.app.utils.sendToClip + +@Composable +fun LoadMoreFooter( + isLoading: Boolean, + errorMsg: String?, + isEnd: Boolean, + onRetry: () -> Unit +) { + val context = LocalContext.current + var showFullError by remember { mutableStateOf(null) } + + LaunchedEffect(isLoading, errorMsg, isEnd) { + if (!isLoading && errorMsg == null && !isEnd) { + onRetry() + } + } + + AppAlertDialog( + data = showFullError, + onDismissRequest = { showFullError = null }, + title = "错误详情", + textProvider = { this }, + confirmText = "复制", + onConfirm = { error -> + context.sendToClip(error) + showFullError = null + }, + dismissText = "关闭", + onDismiss = { showFullError = null } + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .adaptiveHorizontalPadding(vertical = 8.dp), + contentAlignment = Alignment.Center + ) { + + AnimatedContent( + targetState = Triple(isLoading, errorMsg, isEnd), + label = "LoadMoreFooter" + ) { (loading, error, end) -> + + when { + error != null -> { + + GlassCard( + onClick = { showFullError = error }, + containerColor = LegadoTheme.colorScheme.errorContainer.copy(alpha = 0.6f), + ) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + + // 信息区域 + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + all = 16.dp + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + + AppIcon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + tint = LegadoTheme.colorScheme.error + ) + + AppText( + text = error, + color = LegadoTheme.colorScheme.error, + style = LegadoTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + HorizontalDivider( + color = LegadoTheme.colorScheme.outlineVariant.copy(alpha = 0.3f) + ) + + // 操作区域 + Box( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onRetry) + .padding(vertical = 10.dp), + contentAlignment = Alignment.Center + ) { + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + + AppIcon( + imageVector = Icons.Default.Refresh, + contentDescription = null, + tint = LegadoTheme.colorScheme.error + ) + + AppText( + text = "重新加载", + color = LegadoTheme.colorScheme.error, + style = LegadoTheme.typography.labelMedium + ) + } + } + } + } + } + + loading -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + + AppContainedLoadingIndicator() + + AppText( + text = "正在加载更多内容…", + color = LegadoTheme.colorScheme.outline, + style = LegadoTheme.typography.bodySmall + ) + } + } + + end -> { + GlassCard( + modifier = Modifier + .fillMaxWidth(), + containerColor = LegadoTheme.colorScheme.surfaceContainer, + ) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + all = 16.dp + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + + AppIcon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + tint = LegadoTheme.colorScheme.onSurface + ) + + AppText( + text = "已经到底了~", + color = LegadoTheme.colorScheme.onSurface, + style = LegadoTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + + else -> { + GlassCard( + modifier = Modifier + .fillMaxWidth(), + containerColor = LegadoTheme.colorScheme.surfaceContainer, + onClick = onRetry + ) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + all = 16.dp + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + + AppIcon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + tint = LegadoTheme.colorScheme.onSurface + ) + + AppText( + text = "加载更多", + color = LegadoTheme.colorScheme.onSurface, + style = LegadoTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + AnimatedTextButton( + isLoading = false, + onClick = onRetry, + text = "尝试加载更多" + ) + } + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/alert/AppAlertDialog.kt b/app/src/main/java/io/legado/app/ui/widget/components/alert/AppAlertDialog.kt index b0d049a72..7bb04b265 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/alert/AppAlertDialog.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/alert/AppAlertDialog.kt @@ -5,6 +5,9 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -98,12 +101,16 @@ fun AppAlertDialog( tonalElevation = AlertDialogDefaults.TonalElevation, title = title?.let { { Text(text = it) } }, text = { - Column { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { if (text != null) { - Text( - text = text, - modifier = Modifier.padding(bottom = if (content != null) 16.dp else 0.dp) - ) + SelectionContainer { + Text( + text = text, + modifier = Modifier.padding(bottom = if (content != null) 16.dp else 0.dp) + ) + } } if (content != null) { content() @@ -161,16 +168,17 @@ fun AppAlertDialog( val currentData = cachedData if (currentData != null) { val currentText = text ?: textProvider?.invoke(currentData) - var cachedText by remember { mutableStateOf(currentText) } + var lastValidText by remember { mutableStateOf(currentText) } + if (currentText != null) { - cachedText = currentText + lastValidText = currentText } AppAlertDialog( show = data != null, onDismissRequest = onDismissRequest, title = title, - text = currentText ?: cachedText, + text = currentText ?: lastValidText, modifier = modifier, confirmText = confirmText, onConfirm = onConfirm?.let { { it(currentData) } }, diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/SmallTextButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/SmallTextButton.kt index 3aa34c830..3fd6a42e3 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/button/SmallTextButton.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/button/SmallTextButton.kt @@ -82,7 +82,7 @@ fun SmallTextButton( @Composable fun SmallTonalTextButton( text: String? = null, - imageVector: ImageVector, + imageVector: ImageVector? = null, modifier: Modifier = Modifier, onClick: () -> Unit ) { @@ -102,11 +102,13 @@ fun SmallTonalTextButton( horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically ) { - MiuixIcon( - imageVector = imageVector, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) + if (imageVector != null) { + MiuixIcon( + imageVector = imageVector, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } if (text != null) { AppText( text = text, @@ -121,11 +123,13 @@ fun SmallTonalTextButton( modifier = modifier, contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp) ) { - Icon( - imageVector = imageVector, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) + if (imageVector != null) { + Icon( + imageVector = imageVector, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } Spacer(Modifier.width(4.dp)) if (text != null) { AppText( diff --git a/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindItem.kt b/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindItem.kt index 449040ab1..4e41e5346 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindItem.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindItem.kt @@ -3,10 +3,8 @@ package io.legado.app.ui.widget.components.explore import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.LocalMinimumInteractiveComponentSize -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment @@ -16,11 +14,9 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import io.legado.app.data.entities.rule.ExploreKind -import io.legado.app.ui.config.themeConfig.ThemeConfig import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.text.AppText -import top.yukonga.miuix.kmp.theme.MiuixTheme @Composable fun ExploreKindItem( @@ -31,6 +27,7 @@ fun ExploreKindItem( isMiuix: Boolean, backgroundColor: androidx.compose.ui.graphics.Color = LegadoTheme.colorScheme.surfaceContainer, displayText: String = kind.title, + isSelected: Boolean = false, trailingIcon: (@Composable () -> Unit)? = null ) { CompositionLocalProvider( @@ -38,31 +35,45 @@ fun ExploreKindItem( ) { val cornerRadius = 12.dp + val containerColor = if (isSelected) { + LegadoTheme.colorScheme.primaryContainer + } else { + backgroundColor + } + val contentColor = if (isSelected) { + LegadoTheme.colorScheme.onPrimaryContainer + } else if (isClickable) { + LegadoTheme.colorScheme.onSurface + } else { + LegadoTheme.colorScheme.primary + } if (isClickable) { GlassCard( onClick = onClick, cornerRadius = cornerRadius, - containerColor = backgroundColor, - contentColor = LegadoTheme.colorScheme.onSurface, + containerColor = containerColor, + contentColor = contentColor, modifier = modifier, ) { KindText( text = displayText, isClickable = true, + contentColor = contentColor, trailingIcon = trailingIcon ) } } else { GlassCard( cornerRadius = cornerRadius, - containerColor = backgroundColor, - contentColor = LegadoTheme.colorScheme.primary, + containerColor = containerColor, + contentColor = contentColor, modifier = modifier, ) { KindText( text = displayText, isClickable = false, + contentColor = contentColor, trailingIcon = trailingIcon ) } @@ -75,6 +86,7 @@ fun ExploreKindItem( private fun KindText( text: String, isClickable: Boolean, + contentColor: androidx.compose.ui.graphics.Color = LegadoTheme.colorScheme.onSurface, trailingIcon: (@Composable () -> Unit)? = null ) { Box( @@ -84,7 +96,7 @@ private fun KindText( ) { AppText( text = text, - color = if (isClickable) LegadoTheme.colorScheme.onSurface else LegadoTheme.colorScheme.primary, + color = contentColor, modifier = Modifier .fillMaxWidth() .padding(end = if (trailingIcon == null) 0.dp else 18.dp), diff --git a/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindItemState.kt b/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindItemState.kt new file mode 100644 index 000000000..9f31909d6 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindItemState.kt @@ -0,0 +1,85 @@ +package io.legado.app.ui.widget.components.explore + +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import io.legado.app.data.entities.rule.ExploreKind +import io.legado.app.domain.usecase.ExploreKindUiUseCase +import io.legado.app.help.source.getExploreInfoMap +import io.legado.app.utils.InfoMap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.launch + +/** + * 封装 ExploreKind 的业务状态与交互逻辑 + */ +@Stable +class ExploreKindItemState( + val kind: ExploreKind, + val sourceUrl: String?, + private val useCase: ExploreKindUiUseCase?, + private val scope: CoroutineScope, + private val activity: AppCompatActivity?, + private val onRefreshKinds: () -> Unit +) { + val infoMap: InfoMap? = if (useCase == null) null else sourceUrl?.takeIf { it.isNotBlank() } + ?.let(::getExploreInfoMap) + var displayName by mutableStateOf(kind.title) + internal set + + fun executeAction(action: String?) { + if (action.isNullOrBlank()) return + val useCase = useCase ?: return + scope.launch(IO) { + useCase.executeAction( + action = action, + title = kind.title, + sourceUrl = sourceUrl, + infoMap = infoMap, + activity = activity, + onRefreshKinds = onRefreshKinds + ) + } + } + + fun updateValue(value: String, onValueChange: ((String) -> Unit)?) { + if (onValueChange != null) { + onValueChange(value) + } else { + infoMap?.let { + it[kind.title] = value + it.saveNow() + } + } + } + + @Composable + fun ResolveDisplayName(override: String?) { + LaunchedEffect(override, sourceUrl, kind.title, kind.viewName, useCase) { + displayName = override + ?: useCase?.resolveDisplayName(kind, sourceUrl, infoMap) + ?: kind.title + } + } +} + +@Composable +fun rememberExploreKindItemState( + kind: ExploreKind, + sourceUrl: String?, + useCase: ExploreKindUiUseCase?, + activity: AppCompatActivity?, + onRefreshKinds: () -> Unit +): ExploreKindItemState { + val scope = rememberCoroutineScope() + return remember(kind, sourceUrl, useCase, activity) { + ExploreKindItemState(kind, sourceUrl, useCase, scope, activity, onRefreshKinds) + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindMultiTypeItem.kt b/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindMultiTypeItem.kt index 5e7033038..9efed4f4a 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindMultiTypeItem.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindMultiTypeItem.kt @@ -1,14 +1,9 @@ package io.legado.app.ui.widget.components.explore import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.background -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Refresh @@ -22,24 +17,18 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import io.legado.app.data.entities.rule.ExploreKind import io.legado.app.domain.usecase.ExploreKindUiUseCase -import io.legado.app.help.source.getExploreInfoMap import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.widget.components.icon.AppIcon import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem -import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.dialog.TextDialog import io.legado.app.utils.showDialogFragment -import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -56,295 +45,362 @@ fun ExploreKindMultiTypeItem( isMiuix: Boolean, displayNameOverride: String? = null, valueOverride: String? = null, + isSelected: Boolean = false, onValueChange: ((String) -> Unit)? = null, onRunAction: (() -> Unit)? = null, - useCase: ExploreKindUiUseCase? = null + useCase: ExploreKindUiUseCase? = null, + onClick: (() -> Unit)? = null, + content: (@Composable (displayName: String, isSelected: Boolean, onClick: () -> Unit, trailingIcon: @Composable (() -> Unit)?) -> Unit)? = null ) { - val scope = rememberCoroutineScope() - val infoMap = remember(sourceUrl, useCase) { - if (useCase == null) null else sourceUrl?.takeIf { it.isNotBlank() }?.let(::getExploreInfoMap) - } - var displayName by remember(sourceUrl, kind.title, kind.viewName) { mutableStateOf(kind.title) } + val state = rememberExploreKindItemState(kind, sourceUrl, useCase, activity, onRefreshKinds) + state.ResolveDisplayName(displayNameOverride) - LaunchedEffect(displayNameOverride, sourceUrl, kind.title, kind.viewName, useCase) { - displayName = displayNameOverride - ?: useCase?.resolveDisplayName(kind, sourceUrl, infoMap) - ?: kind.title - } + val trailingIcon = rememberTrailingIcon(kind.type, isSelected) - fun runAction(action: String?) { - if (action.isNullOrBlank()) return - if (onRunAction != null) { - onRunAction() + if (onClick != null) { + if (content != null) { + content(state.displayName, isSelected, onClick, trailingIcon) } else { - val useCase = useCase ?: return - scope.launch(IO) { - useCase.executeAction( - action = action, - title = kind.title, - sourceUrl = sourceUrl, - infoMap = infoMap, - activity = activity, - onRefreshKinds = onRefreshKinds - ) - } - } - } - - fun updateValue(value: String) { - if (onValueChange != null) { - onValueChange(value) - } else { - infoMap?.let { - it[kind.title] = value - it.saveNow() - } + ExploreKindItem( + kind = kind, + isClickable = true, + onClick = onClick, + modifier = modifier, + backgroundColor = backgroundColor, + isMiuix = isMiuix, + displayText = state.displayName, + isSelected = isSelected, + trailingIcon = trailingIcon + ) } + return } when (kind.type) { ExploreKind.Type.url -> { val url = kind.url?.takeIf { it.isNotBlank() } - ExploreKindItem( - kind = kind, - isClickable = !url.isNullOrBlank(), - onClick = { - if (url.isNullOrBlank()) return@ExploreKindItem + val internalOnClick = { + if (!url.isNullOrBlank()) { if (kind.title.startsWith("ERROR:")) { activity?.showDialogFragment(TextDialog("ERROR", url)) } else { onOpenUrl(url) } - }, - modifier = modifier, - backgroundColor = backgroundColor, - isMiuix = isMiuix, - displayText = displayName - ) + } + } + if (content != null) { + content(state.displayName, isSelected, internalOnClick, trailingIcon) + } else { + ExploreKindItem( + kind = kind, + isClickable = !url.isNullOrBlank(), + onClick = internalOnClick, + modifier = modifier, + backgroundColor = backgroundColor, + isMiuix = isMiuix, + displayText = state.displayName, + isSelected = isSelected + ) + } } ExploreKind.Type.button -> { - ExploreKindItem( - kind = kind, - isClickable = !kind.action.isNullOrBlank(), - onClick = { runAction(kind.action) }, - modifier = modifier, - backgroundColor = backgroundColor, - isMiuix = isMiuix, - displayText = displayName, - trailingIcon = { - CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { - AppIcon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - modifier = Modifier.height(14.dp), - tint = LegadoTheme.colorScheme.outlineVariant - ) - } - } - ) + val internalOnClick = { + if (onRunAction != null) onRunAction() + else state.executeAction(kind.action) + } + if (content != null) { + content(state.displayName, isSelected, internalOnClick, trailingIcon) + } else { + ExploreKindItem( + kind = kind, + isClickable = !kind.action.isNullOrBlank(), + onClick = internalOnClick, + modifier = modifier, + backgroundColor = backgroundColor, + isMiuix = isMiuix, + displayText = state.displayName, + isSelected = isSelected, + trailingIcon = trailingIcon + ) + } } ExploreKind.Type.text -> { - var value by remember(sourceUrl, kind.title) { - mutableStateOf(valueOverride ?: infoMap?.get(kind.title).orEmpty()) + if (content != null) { + content(state.displayName, isSelected, {}, null) + } else { + TextTypeItem( + kind, + sourceUrl, + state, + valueOverride, + onValueChange, + modifier, + backgroundColor + ) } - LaunchedEffect(valueOverride) { - if (valueOverride != null) { - value = valueOverride - } - } - var actionJob by remember(sourceUrl, kind.title) { mutableStateOf(null) } - ExploreKindCompactTextField( - value = value, - onValueChange = { newValue -> - value = newValue - updateValue(newValue) - if (!kind.action.isNullOrBlank()) { - actionJob?.cancel() - actionJob = scope.launch { - delay(600) - runAction(kind.action) - } - } - }, - placeholder = displayName, - modifier = modifier, - backgroundColor = backgroundColor, - isMiuix = isMiuix - ) } ExploreKind.Type.toggle -> { - val chars = remember(kind.chars) { - kind.chars?.filterNotNull().takeUnless { it.isNullOrEmpty() } ?: listOf("chars", "is null") - } - val left = kind.style().layout_justifySelf != "right" - var char by remember(sourceUrl, kind.title, kind.default, kind.chars) { - mutableStateOf( - valueOverride - ?: infoMap?.get(kind.title) - ?.takeUnless { it.isEmpty() } - ?: (kind.default ?: chars.first()).also { - infoMap?.let { map -> - map[kind.title] = it - map.saveNow() - } - } - ) - } - LaunchedEffect(valueOverride) { - if (valueOverride != null) { - char = valueOverride - } - } - val text = if (left) "$char$displayName" else "$displayName$char" - ExploreKindItem( - kind = kind, - isClickable = true, - onClick = { - val currentIndex = chars.indexOf(char) - val nextIndex = if (currentIndex < 0) 0 else (currentIndex + 1) % chars.size - char = chars.getOrElse(nextIndex) { "" } - updateValue(char) - runAction(kind.action) - }, - modifier = modifier, - backgroundColor = backgroundColor, - isMiuix = isMiuix, - displayText = text, - trailingIcon = { - AppIcon( - imageVector = Icons.Default.Refresh, - contentDescription = null, - modifier = Modifier.height(14.dp), - tint = LegadoTheme.colorScheme.outlineVariant - ) - } + ToggleTypeItem( + kind, + sourceUrl, + state, + valueOverride, + onValueChange, + isSelected, + modifier, + backgroundColor, + isMiuix, + trailingIcon, + content ) } ExploreKind.Type.select -> { - val chars = remember(kind.chars) { - kind.chars?.filterNotNull().takeUnless { it.isNullOrEmpty() } ?: listOf("chars", "is null") - } - var selected by remember(sourceUrl, kind.title, kind.default, kind.chars) { - mutableStateOf( - valueOverride - ?: infoMap?.get(kind.title) - ?.takeUnless { it.isEmpty() } - ?: (kind.default ?: chars.first()).also { - infoMap?.let { map -> - map[kind.title] = it - map.saveNow() - } - } - ) - } - LaunchedEffect(valueOverride) { - if (valueOverride != null) { - selected = valueOverride - } - } - var showSelector by remember(sourceUrl, kind.title) { mutableStateOf(false) } - Box(modifier = modifier) { - ExploreKindItem( - kind = kind, - isClickable = chars.isNotEmpty(), - onClick = { showSelector = true }, - modifier = Modifier.fillMaxWidth(), - backgroundColor = backgroundColor, - isMiuix = isMiuix, - displayText = "$displayName $selected", - trailingIcon = { - AppIcon( - imageVector = Icons.Default.UnfoldMore, - contentDescription = null, - modifier = Modifier.height(14.dp), - tint = LegadoTheme.colorScheme.outlineVariant - ) - } - ) - RoundDropdownMenu( - expanded = showSelector, - onDismissRequest = { showSelector = false } - ) { - chars.forEach { option -> - RoundDropdownMenuItem( - text = option, - onClick = { - showSelector = false - if (selected != option) { - selected = option - updateValue(option) - runAction(kind.action) - } - } - ) - } - } - } + SelectTypeItem( + kind, + sourceUrl, + state, + valueOverride, + onValueChange, + isSelected, + modifier, + backgroundColor, + isMiuix, + trailingIcon, + content + ) } else -> { - ExploreKindItem( - kind = kind, - isClickable = false, - onClick = {}, - modifier = modifier, - backgroundColor = backgroundColor, - isMiuix = isMiuix, - displayText = displayName - ) + if (content != null) { + content(state.displayName, isSelected, {}, null) + } else { + ExploreKindItem( + kind = kind, + isClickable = false, + onClick = {}, + modifier = modifier, + backgroundColor = backgroundColor, + isMiuix = isMiuix, + displayText = state.displayName + ) + } } } } @Composable -private fun ExploreKindCompactTextField( - value: String, - onValueChange: (String) -> Unit, - placeholder: String, - modifier: Modifier = Modifier, - backgroundColor: Color = LegadoTheme.colorScheme.surfaceContainer, - isMiuix: Boolean +private fun TextTypeItem( + kind: ExploreKind, + sourceUrl: String?, + state: ExploreKindItemState, + valueOverride: String?, + onValueChange: ((String) -> Unit)?, + modifier: Modifier, + backgroundColor: Color ) { - val interactionSource = remember { MutableInteractionSource() } - val shape = RoundedCornerShape(10.dp) - - BasicTextField( + val scope = rememberCoroutineScope() + var value by remember(sourceUrl, kind.title) { + mutableStateOf(valueOverride ?: state.infoMap?.get(kind.title).orEmpty()) + } + LaunchedEffect(valueOverride) { + if (valueOverride != null) value = valueOverride + } + var actionJob by remember(sourceUrl, kind.title) { mutableStateOf(null) } + ExploreKindCompactTextField( value = value, - onValueChange = onValueChange, - singleLine = true, - textStyle = LegadoTheme.typography.bodySmall.copy(color = LegadoTheme.colorScheme.onSurface), - cursorBrush = SolidColor(LegadoTheme.colorScheme.primary), - interactionSource = interactionSource, - modifier = modifier - .height(34.dp) - .clip(shape) - .background(backgroundColor), - decorationBox = { innerTextField -> - Box( - modifier = Modifier - .fillMaxWidth() - .height(34.dp) - .padding(horizontal = 10.dp), - contentAlignment = androidx.compose.ui.Alignment.CenterStart - ) { - if (value.isEmpty()) { - AppText( - text = placeholder, - color = LegadoTheme.colorScheme.outline, - style = LegadoTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth() - ) - } - Box(modifier = Modifier.fillMaxWidth()) { - innerTextField() + onValueChange = { newValue -> + value = newValue + state.updateValue(newValue, onValueChange) + if (!kind.action.isNullOrBlank()) { + actionJob?.cancel() + actionJob = scope.launch { + delay(600) + state.executeAction(kind.action) } } - } + }, + placeholder = state.displayName, + modifier = modifier, + backgroundColor = backgroundColor ) } + +@Composable +private fun ToggleTypeItem( + kind: ExploreKind, + sourceUrl: String?, + state: ExploreKindItemState, + valueOverride: String?, + onValueChange: ((String) -> Unit)?, + isSelected: Boolean, + modifier: Modifier, + backgroundColor: Color, + isMiuix: Boolean, + trailingIcon: @Composable (() -> Unit)?, + content: (@Composable (displayName: String, isSelected: Boolean, onClick: () -> Unit, trailingIcon: @Composable (() -> Unit)?) -> Unit)? +) { + val chars = remember(kind.chars) { + kind.chars?.filterNotNull().takeUnless { it.isNullOrEmpty() } ?: listOf("chars", "is null") + } + val left = kind.style().layout_justifySelf != "right" + var char by remember(sourceUrl, kind.title, kind.default, kind.chars) { + mutableStateOf( + valueOverride + ?: state.infoMap?.get(kind.title) + ?.takeUnless { it.isEmpty() } + ?: (kind.default ?: chars.first()).also { + state.updateValue(it, onValueChange) + } + ) + } + LaunchedEffect(valueOverride) { + if (valueOverride != null) char = valueOverride + } + val text = if (left) "$char${state.displayName}" else "${state.displayName}$char" + val internalOnClick = { + val currentIndex = chars.indexOf(char) + val nextIndex = if (currentIndex < 0) 0 else (currentIndex + 1) % chars.size + char = chars.getOrElse(nextIndex) { "" } + state.updateValue(char, onValueChange) + state.executeAction(kind.action) + } + + if (content != null) { + content(text, isSelected, internalOnClick, trailingIcon) + } else { + ExploreKindItem( + kind = kind, + isClickable = true, + onClick = internalOnClick, + modifier = modifier, + backgroundColor = backgroundColor, + isMiuix = isMiuix, + displayText = text, + isSelected = isSelected, + trailingIcon = trailingIcon + ) + } +} + +@Composable +private fun SelectTypeItem( + kind: ExploreKind, + sourceUrl: String?, + state: ExploreKindItemState, + valueOverride: String?, + onValueChange: ((String) -> Unit)?, + isSelected: Boolean, + modifier: Modifier, + backgroundColor: Color, + isMiuix: Boolean, + trailingIcon: @Composable (() -> Unit)?, + content: (@Composable (displayName: String, isSelected: Boolean, onClick: () -> Unit, trailingIcon: @Composable (() -> Unit)?) -> Unit)? +) { + val chars = remember(kind.chars) { + kind.chars?.filterNotNull().takeUnless { it.isNullOrEmpty() } ?: listOf("chars", "is null") + } + var selected by remember(sourceUrl, kind.title, kind.default, kind.chars) { + mutableStateOf( + valueOverride + ?: state.infoMap?.get(kind.title) + ?.takeUnless { it.isEmpty() } + ?: (kind.default ?: chars.first()).also { + state.updateValue(it, onValueChange) + } + ) + } + LaunchedEffect(valueOverride) { + if (valueOverride != null) selected = valueOverride + } + var showSelector by remember(sourceUrl, kind.title) { mutableStateOf(false) } + + Box(modifier = modifier) { + val internalOnClick = { showSelector = true } + val displayText = "${state.displayName} $selected" + + if (content != null) { + content(displayText, isSelected, internalOnClick, trailingIcon) + } else { + ExploreKindItem( + kind = kind, + isClickable = chars.isNotEmpty(), + onClick = internalOnClick, + modifier = Modifier.fillMaxWidth(), + backgroundColor = backgroundColor, + isMiuix = isMiuix, + displayText = displayText, + isSelected = isSelected, + trailingIcon = trailingIcon + ) + } + + RoundDropdownMenu( + expanded = showSelector, + onDismissRequest = { showSelector = false } + ) { + chars.forEach { option -> + RoundDropdownMenuItem( + text = option, + onClick = { + showSelector = false + if (selected != option) { + selected = option + state.updateValue(option, onValueChange) + state.executeAction(kind.action) + } + } + ) + } + } + } +} + +@Composable +private fun rememberTrailingIcon(type: String, isSelected: Boolean): @Composable (() -> Unit)? { + return remember(type, isSelected) { + when (type) { + ExploreKind.Type.button -> { + { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + AppIcon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.height(14.dp), + tint = if (isSelected) LegadoTheme.colorScheme.onPrimaryContainer.copy( + alpha = 0.7f + ) else LegadoTheme.colorScheme.outlineVariant + ) + } + } + } + + ExploreKind.Type.toggle -> { + { + AppIcon( + imageVector = Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.height(14.dp), + tint = if (isSelected) LegadoTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) else LegadoTheme.colorScheme.outlineVariant + ) + } + } + + ExploreKind.Type.select -> { + { + AppIcon( + imageVector = Icons.Default.UnfoldMore, + contentDescription = null, + modifier = Modifier.height(14.dp), + tint = if (isSelected) LegadoTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) else LegadoTheme.colorScheme.outlineVariant + ) + } + } + + else -> null + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindSelectSheet.kt b/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindSelectSheet.kt new file mode 100644 index 000000000..32ae3e0f7 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindSelectSheet.kt @@ -0,0 +1,156 @@ +package io.legado.app.ui.widget.components.explore + +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import io.legado.app.data.entities.rule.ExploreKind +import io.legado.app.data.repository.ExploreRepository +import io.legado.app.domain.usecase.ExploreKindUiUseCase +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.theme.ThemeResolver +import io.legado.app.ui.widget.components.SearchBar +import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet +import io.legado.app.ui.widget.components.topbar.TopBarActionButton +import org.koin.compose.koinInject + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExploreKindSelectSheet( + show: Boolean, + onDismissRequest: () -> Unit, + sourceUrl: String?, + onSelected: (List) -> Unit, + multiple: Boolean = false, + initialSelectedTitles: List = emptyList(), + repository: ExploreRepository = koinInject(), + useCase: ExploreKindUiUseCase = koinInject() +) { + var kinds by remember { mutableStateOf>(emptyList()) } + var selectedTitles by remember(initialSelectedTitles, show) { + mutableStateOf(initialSelectedTitles.toSet()) + } + var query by remember { mutableStateOf("") } + val context = LocalContext.current + val activity = context as? AppCompatActivity + val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine) + + LaunchedEffect(show, sourceUrl) { + if (show && !sourceUrl.isNullOrBlank()) { + kinds = repository.getSourceExploreKinds(sourceUrl) + } + } + + val filteredKinds = remember(query, kinds) { + if (query.isBlank()) kinds + else kinds.filter { kind -> + kind.title.contains(query, ignoreCase = true) || + (kind.url?.contains(query, ignoreCase = true) == true) + } + } + val kindRows = remember(filteredKinds) { + calculateExploreKindRows(filteredKinds, 6) + } + + AppModalBottomSheet( + show = show, + onDismissRequest = onDismissRequest, + endAction = { + if (multiple && selectedTitles.isNotEmpty()) { + TopBarActionButton( + onClick = { + val selectedKinds = kinds.filter { it.title in selectedTitles } + onSelected(selectedKinds) + onDismissRequest() + }, + imageVector = Icons.Default.Check, + contentDescription = "Confirm" + ) + } + } + ) { + Column { + SearchBar( + query = query, + backgroundColor = LegadoTheme.colorScheme.onSheetContent, + onQueryChange = { query = it }, + placeholder = "选择或搜索分类", + autoFocus = false + ) + + LazyColumn( + contentPadding = PaddingValues(vertical = 16.dp), + modifier = Modifier.weight(1f, fill = false) + ) { + items(kindRows) { rowItems -> + Row( + modifier = Modifier + .fillMaxWidth() + .animateItem() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + rowItems.forEach { (kind, span) -> + val isSelected = kind.title in selectedTitles + ExploreKindMultiTypeItem( + modifier = Modifier + .weight(span.toFloat()) + .animateItem(), + kind = kind, + sourceUrl = sourceUrl, + activity = activity, + onOpenUrl = { url -> + if (!multiple) { + onSelected(listOf(kind.copy(url = url))) + onDismissRequest() + } + }, + isSelected = isSelected, + onClick = { + if (multiple) { + selectedTitles = if (isSelected) { + selectedTitles - kind.title + } else { + selectedTitles + kind.title + } + } else { + onSelected(listOf(kind)) + onDismissRequest() + } + }, + backgroundColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.5f), + isMiuix = isMiuix, + useCase = useCase + ) + } + + val totalSpan = rowItems.sumOf { it.second } + if (totalSpan < 6) { + Spacer( + modifier = Modifier.weight((6 - totalSpan).toFloat()) + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindTextField.kt b/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindTextField.kt new file mode 100644 index 000000000..70b6bf71e --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindTextField.kt @@ -0,0 +1,69 @@ +package io.legado.app.ui.widget.components.explore + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.widget.components.text.AppText + +@Composable +fun ExploreKindCompactTextField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier, + backgroundColor: Color = LegadoTheme.colorScheme.surfaceContainer, +) { + val interactionSource = remember { MutableInteractionSource() } + val shape = RoundedCornerShape(10.dp) + + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + textStyle = LegadoTheme.typography.bodySmall.copy(color = LegadoTheme.colorScheme.onSurface), + cursorBrush = SolidColor(LegadoTheme.colorScheme.primary), + interactionSource = interactionSource, + modifier = modifier + .height(34.dp) + .clip(shape) + .background(backgroundColor), + decorationBox = { innerTextField -> + Box( + modifier = Modifier + .fillMaxWidth() + .height(34.dp) + .padding(horizontal = 10.dp), + contentAlignment = Alignment.CenterStart + ) { + if (value.isEmpty()) { + AppText( + text = placeholder, + color = LegadoTheme.colorScheme.outline, + style = LegadoTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth() + ) + } + Box(modifier = Modifier.fillMaxWidth()) { + innerTextField() + } + } + } + ) +} diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 2cd79fc46..8ecce06d5 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -925,6 +925,7 @@ 状态栏显示时隐藏 反转目录 显示发现 + 显示首页 样式 分组样式 导出文件名 @@ -1568,6 +1569,11 @@ 确定要删除该集及其包含的所有模块副本吗? 移除模块 确定要从当前集中移除该模块吗? + 从发现分类选择 + 选择多个分类 + 已选择 %1$d 个 + 选择一个分类 + 添加按钮组 模块标题 自定义标题 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 5e1ef53e6..1d32aac8b 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -890,6 +890,7 @@ 輸入自訂源分組名稱 反轉目錄 顯示發現 + 顯示首頁 樣式 分組樣式 導出文件名 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 47c18be04..b199f18a2 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -893,6 +893,7 @@ 狀態欄顯示時隱藏 反轉目錄 顯示發現 + 顯示首頁 樣式 分組樣式 匯出檔案名 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c3ec19176..88cce76ed 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -955,6 +955,7 @@ Hide when status bar show Reverse toc Show Discovery + Show Homepage Style Group style Export file name @@ -1574,6 +1575,11 @@ Are you sure you want to delete this set and all its module copies? Remove Module Are you sure you want to remove this module from the current set? + Select from Kinds + Select multiple kinds + %1$d selected + Select one kind + OR Add Button Group Module Title Custom Title From 47705a76e328537bb69200267bdc21ec468c7482 Mon Sep 17 00:00:00 2001 From: HapeLee <63206378+HapeLee@users.noreply.github.com> Date: Sat, 23 May 2026 03:35:04 +0800 Subject: [PATCH 6/6] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 3 +- .../app/ui/main/explore/ExploreScreen.kt | 4 +- .../homepage/HomepageModuleManageSheet.kt | 52 +++++++++++++------ .../app/ui/main/homepage/HomepageScreen.kt | 1 + .../app/ui/main/homepage/HomepageViewModel.kt | 14 ++--- .../io/legado/app/ui/main/rss/RssScreen.kt | 4 +- .../ui/widget/components/LoadMoreFooter.kt | 8 +-- .../ui/widget/components/list/ListScaffold.kt | 6 ++- 8 files changed, 60 insertions(+), 32 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 279d0ab39..2fb5ef806 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -101,7 +101,8 @@ "Read(//c/Users/**)", "Bash(Get-ChildItem -Path \"D:\\\\AndroidPrj\\\\legado-with-MD3\" -Directory -Depth 0)", "Bash(Select-Object Name)", - "PowerShell(Get-ChildItem -Path \"D:\\\\AndroidPrj\\\\legado-with-MD3\\\\app\\\\src\\\\main\\\\java\\\\io\\\\legado\\\\app\" -Directory -Depth 1 | ForEach-Object { $_.FullName.Replace\\(\"D:\\\\AndroidPrj\\\\legado-with-MD3\\\\app\\\\src\\\\main\\\\java\\\\io\\\\legado\\\\app\\\\\", \"\"\\) })" + "PowerShell(Get-ChildItem -Path \"D:\\\\AndroidPrj\\\\legado-with-MD3\\\\app\\\\src\\\\main\\\\java\\\\io\\\\legado\\\\app\" -Directory -Depth 1 | ForEach-Object { $_.FullName.Replace\\(\"D:\\\\AndroidPrj\\\\legado-with-MD3\\\\app\\\\src\\\\main\\\\java\\\\io\\\\legado\\\\app\\\\\", \"\"\\) })", + "Bash(gh pr *)" ] } } diff --git a/app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt b/app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt index ae9eea454..63848142d 100644 --- a/app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -151,7 +152,8 @@ fun ExploreScreen( onClick = { viewModel.setGroup(group); dismiss() } ) } - } + }, + contentWindowInsets = WindowInsets(0) ) { paddingValues -> Box(modifier = Modifier.fillMaxSize()) { if (uiState.items.isEmpty()) { diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleManageSheet.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleManageSheet.kt index 4f90b9e9d..8a19c31bb 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleManageSheet.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleManageSheet.kt @@ -87,6 +87,7 @@ fun HomepageModuleManageSheet( onGetAllModulesGroupedBySource: () -> Map> = { emptyMap() }, onGetSourceName: (String) -> String = { it }, onAssignModuleToCustomSet: (String, String?) -> Unit = { _, _ -> }, + onSyncSourceModules: (String) -> Unit = {}, ) { var selectingSetUrl by remember(data != null) { mutableStateOf(null) } var browsingSourceUrl by remember(data != null) { mutableStateOf(null) } @@ -208,18 +209,27 @@ fun HomepageModuleManageSheet( val setUrl = selectingSetUrl val browseUrl = browsingSourceUrl val isBrowsing = showSourceBrowser || browseUrl != null + + LaunchedEffect(browseUrl) { + browseUrl?.let { onSyncSourceModules(it) } + } + when { browseUrl != null && browsingDetail -> { // 三级:浏览书源的模块列表(已加入 / 书源模块 / 发现) val displaySetUrl = selectingSetUrl ?: HomepageViewModel.customSetUrl("src_$browseUrl") val currentSetId = HomepageViewModel.customSetIdFromUrl(displaySetUrl) - val joinedModules = onGetModulesInSet(displaySetUrl) + val joinedModules = remember(displaySetUrl, sets, browseSources) { + onGetModulesInSet(displaySetUrl).distinctBy { it.id } + } - val standardModules = + val standardModules = remember(joinedModules) { joinedModules.filter { !HomepageViewModel.isInfinite(it.type, it.layoutConfig) } - val infiniteModules = + } + val infiniteModules = remember(joinedModules) { joinedModules.filter { HomepageViewModel.isInfinite(it.type, it.layoutConfig) } + } val joinedKeys = joinedModules.map { it.moduleKey }.toSet() val sourceModules = onGetSourceModules(browseUrl, currentSetId) @@ -247,9 +257,9 @@ fun HomepageModuleManageSheet( AppText(stringResource(R.string.homepage_no_joined_modules)) } } else { - var listData by remember(displaySetUrl) { + var listData by remember(displaySetUrl, standardModules) { mutableStateOf( - standardModules + standardModules.distinctBy { it.id } ) } val listState = rememberLazyListState() @@ -268,7 +278,7 @@ fun HomepageModuleManageSheet( LaunchedEffect(reorderableState.isAnyItemDragging) { if (!reorderableState.isAnyItemDragging) { val orderedIds = - listData.map { it.id } + infiniteModules.map { it.id } + (listData.map { it.id } + infiniteModules.map { it.id }).distinct() if (orderedIds != joinedModules.map { it.id }) { onReorderModules(orderedIds) } @@ -513,7 +523,7 @@ fun HomepageModuleManageSheet( modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) ) } - items(modules, key = { it.sourceUrl + it.moduleKey }) { module -> + items(modules, key = { it.id }) { module -> val instanceIdInCurrentSet = joinedInCurrent[module.moduleKey] val inCurrentSet = instanceIdInCurrentSet != null SelectionItemCard( @@ -540,11 +550,14 @@ fun HomepageModuleManageSheet( isBrowsing -> { // 二级:浏览书源列表 + val sources = remember(filteredBrowseSources) { + filteredBrowseSources.distinctBy { it.sourceUrl } + } LazyColumn( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - items(filteredBrowseSources, key = { it.sourceUrl }) { source -> + items(sources, key = { it.sourceUrl }) { source -> val moduleCount = onGetSourceModules(source.sourceUrl, null).size SelectionItemCard( title = source.sourceName, @@ -562,12 +575,16 @@ fun HomepageModuleManageSheet( setUrl != null && HomepageViewModel.isCustomSetUrl(setUrl) -> { // 二级:集详情 val setId = HomepageViewModel.customSetIdFromUrl(setUrl) - val modules = onGetModulesInSet(setUrl) + val modules = remember(setUrl, sets) { + onGetModulesInSet(setUrl).distinctBy { it.id } + } - val standardModules = + val standardModules = remember(modules) { modules.filter { !HomepageViewModel.isInfinite(it.type, it.layoutConfig) } - val infiniteModules = + } + val infiniteModules = remember(modules) { modules.filter { HomepageViewModel.isInfinite(it.type, it.layoutConfig) } + } if (modules.isEmpty()) { Column( @@ -590,7 +607,9 @@ fun HomepageModuleManageSheet( ) } } else { - var listData by remember(setUrl) { mutableStateOf(standardModules) } + var listData by remember(setUrl, standardModules) { + mutableStateOf(standardModules) + } val listState = rememberLazyListState() val reorderableState = rememberReorderableLazyListState(listState) { from, to -> listData = listData.toMutableList().apply { @@ -602,11 +621,12 @@ fun HomepageModuleManageSheet( LaunchedEffect(standardModules) { if (!reorderableState.isAnyItemDragging) listData = - standardModules.distinctBy { it.id } + standardModules } LaunchedEffect(reorderableState.isAnyItemDragging) { if (!reorderableState.isAnyItemDragging) { - val orderedIds = listData.map { it.id } + infiniteModules.map { it.id } + val orderedIds = + (listData.map { it.id } + infiniteModules.map { it.id }).distinct() if (orderedIds != modules.map { it.id }) onReorderModules(orderedIds) } } @@ -703,7 +723,9 @@ fun HomepageModuleManageSheet( else -> { // 一级:集列表 - var localSets by remember(data != null) { mutableStateOf(sets) } + var localSets by remember(data != null, sets) { + mutableStateOf(sets.distinctBy { it.sourceUrl }) + } val setsListState = rememberLazyListState() val setsReorderableState = rememberReorderableLazyListState(setsListState) { from, to -> diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt index 52bfaaa27..49ad107f4 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt @@ -266,6 +266,7 @@ fun HomepageScreen( onToggleSet = { url, isEnabled -> viewModel.toggleSourceFilter(url, isEnabled) }, onGetModulesInSet = { viewModel.getJoinedModules(it) }, onGetSourceModules = { url, setId -> viewModel.getSourceModules(url, setId) }, + onSyncSourceModules = { viewModel.syncSourceModules(it) }, onToggleModule = { id, visible -> viewModel.setModuleVisible(id, visible) }, onJoinModule = { sourceUrl, targetSetId, def -> viewModel.joinModule( diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt index f3f618cec..726e3238a 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt @@ -35,6 +35,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap class HomepageViewModel( application: Application, @@ -80,7 +81,7 @@ class HomepageViewModel( private val _effects = MutableSharedFlow(extraBufferCapacity = 8) val effects = _effects.asSharedFlow() - private val loadJobs = mutableMapOf() + private val loadJobs = ConcurrentHashMap() private val initModulesSyncFlow = bookSourceRepository.flowHomepageModules() private val exploreSourcesFlow = bookSourceRepository.flowExploreSources() @@ -688,6 +689,12 @@ class HomepageViewModel( } } + fun syncSourceModules(sourceUrl: String) { + viewModelScope.launch { + resolveBookSource(sourceUrl)?.let { syncModulesFromSource(it) } + } + } + /** 「书源模块」tab:仅 JSON,纯参考 */ fun getSourceModules( sourceUrl: String, @@ -695,11 +702,6 @@ class HomepageViewModel( ): List { val source = resolveBookSource(sourceUrl) ?: return emptyList() - // 按需同步:只有进入该源的管理页才同步其 JSON 定义 - viewModelScope.launch { - syncModulesFromSource(source) - } - val json = source.homepageModules ?: return emptyList() val jsonDefs = parseBookSourceModules(source, json) diff --git a/app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt b/app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt index 5ae05fdfb..a46da6b17 100644 --- a/app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -165,7 +166,8 @@ fun RssScreen( } ) } - } + }, + contentWindowInsets = WindowInsets(0) ) { paddingValues -> LazyVerticalGrid( columns = GridCells.Adaptive(minSize = 72.dp), diff --git a/app/src/main/java/io/legado/app/ui/widget/components/LoadMoreFooter.kt b/app/src/main/java/io/legado/app/ui/widget/components/LoadMoreFooter.kt index e457690a7..d1950c69a 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/LoadMoreFooter.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/LoadMoreFooter.kt @@ -26,7 +26,6 @@ import androidx.compose.ui.unit.dp import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.adaptiveHorizontalPadding import io.legado.app.ui.widget.components.alert.AppAlertDialog -import io.legado.app.ui.widget.components.button.AnimatedTextButton import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.icon.AppIcon import io.legado.app.ui.widget.components.progressIndicator.AppContainedLoadingIndicator @@ -157,7 +156,7 @@ fun LoadMoreFooter( AppContainedLoadingIndicator() AppText( - text = "正在加载更多内容…", + text = "正在加载…", color = LegadoTheme.colorScheme.outline, style = LegadoTheme.typography.bodySmall ) @@ -239,11 +238,6 @@ fun LoadMoreFooter( } } } - AnimatedTextButton( - isLoading = false, - onClick = onRetry, - text = "尝试加载更多" - ) } } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/list/ListScaffold.kt b/app/src/main/java/io/legado/app/ui/widget/components/list/ListScaffold.kt index 3fb74f0d9..e2421ce43 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/list/ListScaffold.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/list/ListScaffold.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons @@ -16,6 +17,7 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FloatingToolbarDefaults.ScreenOffset +import androidx.compose.material3.ScaffoldDefaults import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.animateFloatingActionButton @@ -69,6 +71,7 @@ fun ListScaffold( } }, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, content: @Composable (PaddingValues) -> Unit ) { val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior() @@ -102,7 +105,8 @@ fun ListScaffold( bottomContent = bottomContent ) }, - floatingActionButton = floatingActionButton + floatingActionButton = floatingActionButton, + contentWindowInsets = contentWindowInsets ) { paddingValues -> Box( modifier = Modifier.fillMaxSize()