Эх сурвалжийг харах

Merge origin/main into feature/private-group

Resolve conflict in BookInfoSheets.kt: keep MediumPlainButton style from main
while preserving showAddGroup logic from PR for correct new group creation.
HapeLee 5 сар өмнө
parent
commit
6a6a0c837f
100 өөрчлөгдсөн 3424 нэмэгдсэн , 1908 устгасан
  1. 44 0
      .github/workflows/docs.yml
  2. 222 1
      CLAUDE.md
  3. 68 14
      app/src/main/assets/disclaimer.md
  4. 3 0
      app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt
  5. 3 2
      app/src/main/java/io/legado/app/data/repository/ExploreRepository.kt
  6. 6 1
      app/src/main/java/io/legado/app/domain/gateway/ExploreBooksGateway.kt
  7. 10 0
      app/src/main/java/io/legado/app/domain/model/MatchMode.kt
  8. 7 4
      app/src/main/java/io/legado/app/domain/usecase/ExploreBooksUseCase.kt
  9. 14 9
      app/src/main/java/io/legado/app/domain/usecase/SearchBooksUseCase.kt
  10. 7 2
      app/src/main/java/io/legado/app/model/webBook/WebBook.kt
  11. 11 11
      app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageScreen.kt
  12. 65 0
      app/src/main/java/io/legado/app/ui/book/explore/ExploreShowContract.kt
  13. 109 115
      app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt
  14. 134 115
      app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt
  15. 5 5
      app/src/main/java/io/legado/app/ui/book/group/GroupEditSheet.kt
  16. 6 6
      app/src/main/java/io/legado/app/ui/book/import/local/ImportBookScreen.kt
  17. 13 13
      app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookScreen.kt
  18. 3 3
      app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt
  19. 20 20
      app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt
  20. 11 11
      app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt
  21. 35 36
      app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt
  22. 5 5
      app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewScreen.kt
  23. 4 5
      app/src/main/java/io/legado/app/ui/book/search/ScopeSelectSheet.kt
  24. 1 1
      app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt
  25. 26 11
      app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt
  26. 312 103
      app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt
  27. 111 0
      app/src/main/java/io/legado/app/ui/book/search/SearchSourceSection.kt
  28. 149 25
      app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt
  29. 15 14
      app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt
  30. 5 3
      app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt
  31. 3 13
      app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt
  32. 5 5
      app/src/main/java/io/legado/app/ui/config/coverConfig/CoverRuleConfigSheet.kt
  33. 5 10
      app/src/main/java/io/legado/app/ui/config/otherConfig/DirectLinkUploadBottomSheet.kt
  34. 3 3
      app/src/main/java/io/legado/app/ui/config/themeConfig/BackgroundImageManageSheet.kt
  35. 14 14
      app/src/main/java/io/legado/app/ui/config/themeConfig/LabelColorManageSheet.kt
  36. 3 3
      app/src/main/java/io/legado/app/ui/config/themeConfig/NavIconManageSheet.kt
  37. 7 7
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt
  38. 3 3
      app/src/main/java/io/legado/app/ui/config/themeManage/EditThemeSheet.kt
  39. 7 7
      app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageScreen.kt
  40. 0 168
      app/src/main/java/io/legado/app/ui/dict/rule/DictRuleEditSheet.kt
  41. 4 9
      app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt
  42. 1 1
      app/src/main/java/io/legado/app/ui/main/MainActivity.kt
  43. 1 1
      app/src/main/java/io/legado/app/ui/main/MainIntent.kt
  44. 12 3
      app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt
  45. 1 1
      app/src/main/java/io/legado/app/ui/main/MainNavigator.kt
  46. 10 6
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt
  47. 47 74
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt
  48. 4 4
      app/src/main/java/io/legado/app/ui/main/bookshelf/GroupManageSheet.kt
  49. 7 4
      app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt
  50. 2 5
      app/src/main/java/io/legado/app/ui/main/explore/ExploreViewModel.kt
  51. 11 11
      app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleManageSheet.kt
  52. 5 4
      app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt
  53. 6 0
      app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt
  54. 9 9
      app/src/main/java/io/legado/app/ui/main/homepage/manage/SetDetailPage.kt
  55. 5 5
      app/src/main/java/io/legado/app/ui/main/homepage/manage/SetListPage.kt
  56. 11 11
      app/src/main/java/io/legado/app/ui/main/homepage/manage/SourceBrowseDetailPage.kt
  57. 15 16
      app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt
  58. 8 27
      app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditScreen.kt
  59. 13 14
      app/src/main/java/io/legado/app/ui/rss/article/RssSortScreen.kt
  60. 3 3
      app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesScreen.kt
  61. 3 3
      app/src/main/java/io/legado/app/ui/rss/read/RssReadRouteScreen.kt
  62. 3 7
      app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceScreen.kt
  63. 6 4
      app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubScreen.kt
  64. 8 0
      app/src/main/java/io/legado/app/ui/theme/AdaptivePadding.kt
  65. 41 0
      app/src/main/java/io/legado/app/ui/widget/components/AppSlider.kt
  66. 4 4
      app/src/main/java/io/legado/app/ui/widget/components/EmptyMessage.kt
  67. 5 5
      app/src/main/java/io/legado/app/ui/widget/components/GroupManageBottomSheet.kt
  68. 5 5
      app/src/main/java/io/legado/app/ui/widget/components/JsonRawEditor.kt
  69. 5 5
      app/src/main/java/io/legado/app/ui/widget/components/ValueStepper.kt
  70. 0 43
      app/src/main/java/io/legado/app/ui/widget/components/button/AnimatedTextButton.kt
  71. 0 217
      app/src/main/java/io/legado/app/ui/widget/components/button/MediumIconButton.kt
  72. 0 66
      app/src/main/java/io/legado/app/ui/widget/components/button/MediumTextButton.kt
  73. 0 356
      app/src/main/java/io/legado/app/ui/widget/components/button/SmallIconButton.kt
  74. 0 142
      app/src/main/java/io/legado/app/ui/widget/components/button/SmallTextButton.kt
  75. 1 1
      app/src/main/java/io/legado/app/ui/widget/components/button/series/AnimatedActionButtonCore.kt
  76. 1 1
      app/src/main/java/io/legado/app/ui/widget/components/button/series/AnimatedIcon.kt
  77. 173 0
      app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumAnimatedButton.kt
  78. 78 0
      app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumOutlinedButton.kt
  79. 121 0
      app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumPlainButton.kt
  80. 152 0
      app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumToggleButton.kt
  81. 74 0
      app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumTonalButton.kt
  82. 176 0
      app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallAnimatedButton.kt
  83. 93 0
      app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallOutlinedButton.kt
  84. 161 0
      app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallPlainButton.kt
  85. 178 0
      app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallToggleButton.kt
  86. 91 0
      app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallTonalButton.kt
  87. 5 11
      app/src/main/java/io/legado/app/ui/widget/components/card/SelectionItemCard.kt
  88. 7 8
      app/src/main/java/io/legado/app/ui/widget/components/heatmap/HeatmapCalendarComponents.kt
  89. 7 7
      app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt
  90. 3 3
      app/src/main/java/io/legado/app/ui/widget/components/log/AppLogSheet.kt
  91. 1 3
      app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/OptionSheet.kt
  92. 5 15
      app/src/main/java/io/legado/app/ui/widget/components/rules/RuleEditSheet.kt
  93. 25 4
      app/src/main/java/io/legado/app/ui/widget/components/topbar/GlassMediumFlexibleTopAppBar.kt
  94. 7 14
      app/src/main/java/io/legado/app/ui/widget/components/topbar/TopBarButton.kt
  95. 30 6
      app/src/main/java/io/legado/app/web/socket/BookSearchWebSocket.kt
  96. 4 2
      app/src/main/res/values-zh-rCN/strings.xml
  97. 2 0
      app/src/main/res/values/strings.xml
  98. 3 0
      docs/.gitignore
  99. 168 0
      docs/.vitepress/config.ts
  100. 129 0
      docs/dev/authentication.md

+ 44 - 0
.github/workflows/docs.yml

@@ -0,0 +1,44 @@
+name: Deploy Docs
+
+on:
+  push:
+    branches: [ main ]
+    paths: [ 'docs/**' ]
+  workflow_dispatch:
+
+permissions:
+  contents: read
+  pages: write
+  id-token: write
+
+concurrency:
+  group: pages
+  cancel-in-progress: false
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+      - uses: actions/setup-node@v4
+        with:
+          node-version: 20
+          cache: npm
+          cache-dependency-path: docs/package-lock.json
+      - run: cd docs && npm ci
+      - run: cd docs && npx vitepress build
+      - uses: actions/upload-pages-artifact@v3
+        with:
+          path: docs/.vitepress/dist
+
+  deploy:
+    needs: build
+    runs-on: ubuntu-latest
+    environment:
+      name: github-pages
+      url: ${{ steps.deployment.outputs.page_url }}
+    steps:
+      - id: deployment
+        uses: actions/deploy-pages@v4

+ 222 - 1
CLAUDE.md

@@ -138,7 +138,228 @@ Legacy View-based theme still exists in `lib/theme/` (used by non-migrated scree
 
 The app is mid-migration from Views to Compose. View-based screens (reader, book info, source management) coexist with Compose screens (main tabs, settings, search, RSS, cache management). XML layouts, `viewBinding`, and traditional Activities are still heavily used. The `viewBinding` build feature is enabled but Compose screens are the target.
 
-Compose code follows **MVI/UDF**: ViewModel owns `StateFlow`/`SharedFlow` state, Screen observes and emits user actions, no business logic in composables. For detailed Compose review conventions and migration patterns, see `.claude/skills/legado-compose-review/`.
+## Jetpack Compose Requirements (new screens MUST follow)
+
+All **new** UI screens must be implemented in Jetpack Compose following the patterns below. Do **not
+** create new View-based Activities/Fragments/XML layouts. Existing View-based screens can remain
+until migrated.
+
+### MVI/UDF Architecture
+
+Every Compose screen follows a strict **Model-View-Intent** pattern with three artifacts defined in
+a `*Contract.kt` file:
+
+```
+ui/{feature}/
+├── XxxContract.kt      // UiState, Intent, Effect (and optionally Sheet/Dialog)
+├── XxxViewModel.kt     // ViewModel
+├── XxxScreen.kt        // Screen composable
+└── XxxRouteScreen.kt   // (optional) outer wrapper for activity results / lifecycle
+```
+
+**Contract definitions:**
+
+```kotlin
+// @Stable data class — all screen state in one place
+@Stable
+data class XxxUiState(
+    val loading: Boolean = false,
+    val items: ImmutableList<ItemUi> = persistentListOf(),
+    val activeSheet: XxxSheet? = null,
+    val activeDialog: XxxDialog? = null,
+)
+
+// sealed interface — every user action is an Intent
+sealed interface XxxIntent {
+    data class LoadData(val id: Long) : XxxIntent
+    data object Refresh : XxxIntent
+}
+
+// sealed interface — one-shot side effects (navigation, toast, etc.)
+sealed interface XxxEffect {
+    data class ShowToast(val message: String) : XxxEffect
+    data class NavigateTo(val route: MainRoute) : XxxEffect
+}
+
+// (optional) sealed interface for multi-sheet/dialog scenarios
+sealed interface XxxSheet { data object Filter : XxxSheet }
+sealed interface XxxDialog { data class Confirm(val msg: String) : XxxDialog }
+```
+
+**Naming rules:**
+
+- State: `{Feature}UiState` — `@Stable data class`
+- Intent: `{Feature}Intent` — `sealed interface` with `data class` / `data object` members
+- Effect: `{Feature}Effect` — `sealed interface`
+- Sheet/Dialog: `{Feature}Sheet`, `{Feature}Dialog` — `sealed interfaces` stored in UiState
+
+### ViewModel
+
+```kotlin
+class XxxViewModel(/* injected dependencies */) : ViewModel() {
+
+    private val _uiState = MutableStateFlow(XxxUiState())
+    val uiState = _uiState.asStateFlow()
+
+    private val _effects = MutableSharedFlow<XxxEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
+
+    fun onIntent(intent: XxxIntent) {
+        when (intent) {
+            is XxxIntent.LoadData -> loadData(intent.id)
+            is XxxIntent.Refresh -> refresh()
+        }
+    }
+
+    private fun loadData(id: Long) {
+        // Use viewModelScope, update _uiState via update { it.copy(...) }
+    }
+}
+```
+
+Key rules:
+
+- Extend `ViewModel()` directly (not `BaseViewModel`).
+- `_uiState` is `MutableStateFlow`, exposed as `StateFlow` via `.asStateFlow()`.
+- `_effects` is `MutableSharedFlow(extraBufferCapacity = 16)`, exposed via `.asSharedFlow()`.
+- Emit effects via `_effects.tryEmit(...)`.
+- Single `onIntent()` entry point, dispatched via `when`.
+
+### Screen Composable
+
+```kotlin
+// Stateless screen — ViewModel wired in entry provider or RouteScreen
+@Composable
+fun XxxScreen(
+    state: XxxUiState,
+    onIntent: (XxxIntent) -> Unit,
+    effects: Flow<XxxEffect>,                   // one-shot effects from ViewModel
+    onBack: () -> Unit,
+    onNavigateToYyy: (YyyRoute) -> Unit,
+) {
+    // Collect effects
+    LaunchedEffect(Unit) {
+        effects.collectLatest { effect ->
+            when (effect) {
+                is XxxEffect.ShowToast -> { /* ... */ }
+                is XxxEffect.NavigateTo -> onNavigateToYyy(effect.route)
+            }
+        }
+    }
+
+    AppScaffold(
+        topBar = {
+            GlassMediumFlexibleTopAppBar(
+                title = { Text("Title") },
+                scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior(),
+                navigationButton = { TopBarNavigationButton(onBack) },
+            )
+        },
+    ) { contentPadding ->
+        // UI content, no business logic here
+    }
+}
+```
+
+Key rules:
+
+- Screen is **stateless** — receives `state`, `onIntent`, `effects`, never accesses ViewModel
+  directly.
+- Effects collected in `LaunchedEffect(Unit) { ... }` using `collectLatest`.
+- Alternatively, effects can be collected in the outer `RouteScreen` or entry provider if the screen
+  doesn't need them directly.
+- Use project custom widgets: `AppScaffold`, `AppText`, `AppIcon`, `AppIcons`, `AppAlertDialog`,
+  `AppModalBottomSheet`, `NormalCard`, `GlassMediumFlexibleTopAppBar`, `TopBarNavigationButton`,
+  `TopBarActionButton`, etc.
+- No business logic, no direct DB/network calls in composables.
+
+Two input patterns are acceptable:
+
+- **Stateless (preferred for new screens):** `state: XxxUiState` + `onIntent: (XxxIntent) -> Unit` —
+  ViewModel wired in entry provider or RouteScreen.
+- **ViewModel as default param:** `viewModel: XxxViewModel = koinViewModel()` — simpler for
+  standalone screens.
+
+### Stability
+
+- All `UiState` and UI item data classes **must** be annotated with `@Stable`.
+- Use `ImmutableList` (from `kotlinx.collections.immutable`) for list properties in state classes,
+  not `List` or `MutableList`.
+- Prefer `persistentListOf()` / `toImmutableList()` for default values.
+
+### Navigation
+
+Uses **Navigation 3** (`androidx.navigation3`). Routes are `@Serializable` sealed interfaces:
+
+```kotlin
+// In MainNavKey.kt
+@Serializable
+data class MainRouteXxx(val id: Long) : MainRoute
+```
+
+Entry registered in `MainNavGraph.kt`:
+
+```kotlin
+entry<MainRouteXxx> { route ->
+    val viewModel = koinViewModel<XxxViewModel>()
+    XxxScreen(
+        state = viewModel.uiState.collectAsStateWithLifecycle().value,
+        onIntent = viewModel::onIntent,
+        onBack = { onNavigateBack() },
+        onNavigateToYyy = { onNavigateToRoute(it) },
+    )
+}
+```
+
+Key rules:
+
+- Screens **never** reference the navigator directly — receive `onBack`, `onNavigateToXxx` lambdas.
+- Navigation is callback-based, wired by the entry provider.
+- New routes added to the `MainRoute` sealed interface in `MainNavKey.kt`.
+
+### Koin DI
+
+- Register ViewModels in `di/appModule.kt` with `viewModelOf(::XxxViewModel)`.
+- Inject in Compose via `koinViewModel()` (default param or explicit in entry provider).
+- For keyed ViewModels (e.g. per-book): `koinViewModel<XxxViewModel>(key = route.bookUrl)`.
+- Repositories/gateways/use cases registered as `singleOf(::...)`.
+
+### Activity Base Class
+
+New standalone Compose activities extend `BaseComposeActivity`:
+
+```kotlin
+class XxxActivity : BaseComposeActivity() {
+    @Composable
+    override fun Content() {
+        // Screen content — AppTheme is already applied by the base class
+    }
+}
+```
+
+### RouteScreen Wrapper
+
+For screens needing activity result handling, lifecycle observation, or permission requests, use a
+two-layer pattern:
+
+- Outer `XxxRouteScreen`: handles `ActivityResultLauncher`, lifecycle callbacks, file pickers,
+  permission requests. Wires ViewModel.
+- Inner `XxxScreen`: pure UI, stateless with `state` + `onIntent`.
+
+### Material 3 vs Miuix
+
+The project supports two Compose theme engines. If a screen needs engine-specific UI, branch on:
+
+```kotlin
+if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) {
+    // Miuix implementation
+} else {
+    // Material 3 implementation
+}
+```
+
+For detailed Compose review conventions and migration patterns, see
+`.claude/skills/legado-compose-review/`.
 
 ## Rhino JavaScript Engine
 

+ 68 - 14
app/src/main/assets/disclaimer.md

@@ -1,14 +1,68 @@
-* 阅读是一款解析指定规则并获取内容的工具,为广大网络文学爱好者提供一种方便、快捷舒适的试读体验。
-* 当您搜索一本书的时,阅读会您所使用的规则将该书的书名以关键词的形式提交到各个第三方网络文学网站。
-各第三方网站返回的内容与阅读无关,阅读对其概不负责,亦不承担任何法律责任。
-任何通过使用阅读而链接到的第三方网页均系他人制作或提供,您可能从第三方网页上获得其他服务,阅读对其合法性概不负责,亦不承担任何法律责任。
-第三方搜索引擎结果根据您提交的书名自动搜索获得并提供试读,不代表阅读赞成或被搜索链接到的第三方网页上的内容或立场。
-您应该对使用搜索引擎的结果自行承担风险。
-* 阅读不做任何形式的保证:不保证第三方搜索引擎的搜索结果满足您的要求,不保证搜索服务不中断,不保证搜索结果的安全性、正确性、及时性、合法性。
-因网络状况、通讯线路、第三方网站等任何原因而导致您不能正常使用阅读,阅读不承担任何法律责任。
-阅读尊重并保护所有使用阅读用户的个人隐私权,您注册的用户名、电子邮件地址等个人资料,非经您亲自许可或根据相关法律、法规的强制性规定,阅读不会主动地泄露给第三方。
-* 阅读致力于最大程度地减少网络文学阅读者在自行搜寻过程中的无意义的时间浪费,通过专业搜索展示不同网站中网络文学的最新章节。
-阅读在为广大小说爱好者提供方便、快捷舒适的试读体验的同时,也使优秀网络文学得以迅速、更广泛的传播,从而达到了在一定程度促进网络文学充分繁荣发展之目的。
-阅读鼓励广大小说爱好者通过阅读发现优秀网络小说及其提供商,并建议阅读正版图书。
-任何单位或个人认为通过阅读搜索链接到的第三方网页内容可能涉嫌侵犯其信息网络传播权,应该及时向阅读提出书面权力通知,并提供身份证明、权属证明及详细侵权情况证明。
-阅读在收到上述法律文件后,将会依法尽快断开相关链接内容。
+---
+
+# 用户协议与免责声明
+
+## 【特别提示】
+
+在下载、安装或使用本软件前,请您务必仔细阅读并充分理解本协议及免责声明的全部内容。您一旦下载、安装或使用本软件,即视为已阅读、理解并同意接受本声明的全部内容。
+
+---
+
+# 一、软件性质说明
+
+1. 本软件是一款用户可配置的本地化网页内容浏览工具,提供网页访问、内容解析、文本提取、阅读排版与数据管理等技术功能。
+
+2. 本软件默认状态下不预置、不内置、不提供任何第三方网站内容、数据资源或解析规则。
+
+3. 本软件开发者不提供任何内容运营、内容存储、内容发布或内容传播服务。
+
+4. 用户可基于自身需求,自行配置或导入第三方规则,以实现对公开网页内容的个性化浏览与处理。
+
+---
+
+# 二、用户行为与规则说明
+
+1. 用户可自行创建、编辑、导入或使用第三方分享的解析规则(以下简称“规则”)。
+
+2. 相关规则仅用于定义网页内容的获取、提取与显示方式,其来源、合法性、准确性与适用性均由用户自行判断并承担责任。
+
+3. 用户使用规则访问第三方网站时,相关网络请求由用户设备直接向目标网站发起并接收数据。本软件仅提供本地解析与展示能力,不对第三方网站内容进行修改、编辑或再分发。
+
+4. 用户应遵守所在地法律法规、网络安全要求以及相关网站的服务协议与版权规范,不得利用本软件从事侵犯知识产权、非法传播、未授权数据获取、破坏网络服务或其他违法违规行为。
+
+---
+
+# 三、第三方内容与社区说明
+
+1. 任何由第三方建立或维护的规则分享平台、论坛、交流群组、网站或其他社区,均属于独立运营的第三方平台,与本软件开发者无隶属关系。
+
+2. 开发者不参与第三方规则、内容或社区的制作、发布、运营、维护与传播行为,亦不对相关内容承担主动审查义务。
+
+3. 用户因使用第三方规则或访问第三方网站而产生的风险,包括但不限于版权纠纷、数据安全风险、网络访问风险或其他法律风险,应由相关行为主体依法承担相应责任。
+
+---
+
+# 四、隐私与数据说明
+
+1. 本软件主要功能运行于用户本地设备,不设立用于提供网页内容服务的自有内容服务器。
+
+2. 本软件不会主动收集、上传或存储用户的阅读内容、规则列表、浏览记录或其他个人隐私数据。
+
+3. 为提升软件稳定性与兼容性,本软件可能集成第三方统计或崩溃分析服务(如 Firebase Crashlytics
+   等),用于收集匿名化的崩溃日志、性能信息及基础设备信息。
+
+4. 部分网络、存储或同步权限,仅用于实现用户主动启用的本地备份、WebDAV 同步或跨设备数据同步等功能。
+
+---
+
+# 五、知识产权保护
+
+1. 开发者尊重并保护知识产权权利人的合法权益,并反对任何侵犯著作权、商标权或其他合法权益的行为。
+
+2. 用户应确保其使用本软件获取、处理或访问相关内容的行为符合适用法律法规及权利要求。
+
+3. 若权利人认为某些第三方规则存在涉嫌侵权情形,可依法向相关内容实际托管方主张权利。
+
+4. 权利人亦可向开发者提交包含身份证明、权属证明、具体规则信息及相关说明的有效通知。开发者将在合理技术能力范围内,对涉嫌违规规则采取必要处理措施。
+
+---

+ 3 - 0
app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt

@@ -4,10 +4,13 @@ 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.core.intPreferencesKey
 import androidx.datastore.preferences.preferencesDataStore
 
 val Context.localDataStore: DataStore<Preferences> by preferencesDataStore(name = "local_ui_status")
 
 object LocalPreferencesKeys {
     val SHOW_THEME_REFACTOR_TIP = booleanPreferencesKey("show_theme_refactor_tip")
+    val SEARCH_LAYOUT_MODE = intPreferencesKey("search_layout_mode")
+    val MATCH_MODE = intPreferencesKey("match_mode")
 }

+ 3 - 2
app/src/main/java/io/legado/app/data/repository/ExploreRepository.kt

@@ -80,9 +80,10 @@ class ExploreRepositoryImpl(
     override suspend fun exploreBooks(
         bookSource: BookSource,
         url: String,
-        page: Int
+        page: Int,
+        key: String?
     ): List<SearchBook> {
-        return WebBook.exploreBookSuspend(bookSource, url, page)
+        return WebBook.exploreBookSuspend(bookSource, url, page, key = key, isSearch = key != null)
     }
 
     override suspend fun getSourceExploreKinds(sourceUrl: String): List<ExploreKind> = withContext(IO) {

+ 6 - 1
app/src/main/java/io/legado/app/domain/gateway/ExploreBooksGateway.kt

@@ -5,5 +5,10 @@ import io.legado.app.data.entities.SearchBook
 
 interface ExploreBooksGateway {
     suspend fun getBookSource(sourceUrl: String): BookSource?
-    suspend fun exploreBooks(bookSource: BookSource, url: String, page: Int): List<SearchBook>
+    suspend fun exploreBooks(
+        bookSource: BookSource,
+        url: String,
+        page: Int,
+        key: String? = null
+    ): List<SearchBook>
 }

+ 10 - 0
app/src/main/java/io/legado/app/domain/model/MatchMode.kt

@@ -0,0 +1,10 @@
+package io.legado.app.domain.model
+
+enum class MatchMode(val value: Int) {
+    DEFAULT(0),
+    EXACT(1);
+
+    companion object {
+        fun of(value: Int) = entries.getOrElse(value) { DEFAULT }
+    }
+}

+ 7 - 4
app/src/main/java/io/legado/app/domain/usecase/ExploreBooksUseCase.kt

@@ -20,14 +20,17 @@ class ExploreBooksUseCase(
         sourceUrl: String,
         moduleUrl: String?,
         args: String?,
-        page: Int = 1
+        page: Int = 1,
+        key: String? = null,
     ): ExploreResult = withContext(Dispatchers.IO) {
         val base = gateway.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)
-        val books = gateway.exploreBooks(source, resolvedUrl, page)
+        val resolvedUrl = moduleUrl
+            ?: (if (key != null) source.searchUrl else null)
+            ?: source.exploreUrl
+            ?: throw NoExploreUrl(sourceUrl)
+        val books = gateway.exploreBooks(source, resolvedUrl, page, key = key)
         ExploreResult(resolvedUrl, books)
     }
 

+ 14 - 9
app/src/main/java/io/legado/app/domain/usecase/SearchBooksUseCase.kt

@@ -6,6 +6,7 @@ import io.legado.app.data.entities.BookSourcePart
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.domain.gateway.BookSearchGateway
 import io.legado.app.domain.model.BookSearchScope
+import io.legado.app.domain.model.MatchMode
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.model.webBook.WebBook
 import kotlinx.coroutines.CancellationException
@@ -30,7 +31,7 @@ data class BookSearchRequest(
     val keyword: String,
     val page: Int,
     val scope: BookSearchScope,
-    val precision: Boolean,
+    val matchMode: MatchMode,
     val concurrency: Int,
     val types: Set<Int>? = null,
 )
@@ -103,7 +104,7 @@ class SearchBooksUseCase(
             throw NoStackTraceException("可搜索书源为空")
         }
 
-        val merger = SearchResultMerger(keyword, request.precision)
+        val merger = SearchResultMerger(keyword, request.matchMode)
         val concurrency = request.concurrency.coerceAtLeast(1)
         var hasMore = false
         var processedSources = 0
@@ -116,7 +117,7 @@ class SearchBooksUseCase(
             .flatMapMerge(concurrency) { searchableSource ->
                 flow {
                     control.awaitResumed()
-                    emit(searchSource(searchableSource, keyword, request.page, request.precision))
+                    emit(searchSource(searchableSource, keyword, request.page, request.matchMode))
                 }.flowOn(Dispatchers.IO)
             }
             .collect { result ->
@@ -177,7 +178,7 @@ class SearchBooksUseCase(
         searchableSource: SearchableSource,
         keyword: String,
         page: Int,
-        precision: Boolean,
+        matchMode: MatchMode,
     ): SourceSearchResult {
         return try {
             val source = searchableSource.source
@@ -191,7 +192,7 @@ class SearchBooksUseCase(
                     keyword,
                     page,
                     filter = { name, author ->
-                        !precision ||
+                        matchMode == MatchMode.DEFAULT ||
                             name.contains(keyword, ignoreCase = true) ||
                             author.contains(keyword, ignoreCase = true)
                     }
@@ -222,7 +223,7 @@ class SearchBooksUseCase(
 
     private class SearchResultMerger(
         private val keyword: String,
-        private val precision: Boolean,
+        private val matchMode: MatchMode,
     ) {
         private companion object {
             const val MAX_RETAINED_SEARCH_RESULTS = 1000
@@ -267,9 +268,13 @@ class SearchBooksUseCase(
                 book.name.equals(keyword, ignoreCase = true) ||
                     book.author.equals(keyword, ignoreCase = true) -> equalBooks
                 book.name.contains(keyword, ignoreCase = true) ||
-                    book.author.contains(keyword, ignoreCase = true) -> containsBooks
-                !precision -> otherBooks
-                else -> null
+                        book.author.contains(
+                            keyword,
+                            ignoreCase = true
+                        ) -> if (matchMode == MatchMode.EXACT) null else containsBooks
+
+                matchMode != MatchMode.DEFAULT -> null
+                else -> otherBooks
             }
         }
 

+ 7 - 2
app/src/main/java/io/legado/app/model/webBook/WebBook.kt

@@ -109,9 +109,12 @@ object WebBook {
         url: String,
         page: Int? = 1,
         ruleData: RuleDataInterface = RuleData(),
+        key: String? = null,
+        isSearch: Boolean = false,
     ): ArrayList<SearchBook> {
         val analyzeUrl = AnalyzeUrl(
             mUrl = url,
+            key = key,
             page = page,
             baseUrl = bookSource.bookSourceUrl,
             source = bookSource,
@@ -137,7 +140,7 @@ object WebBook {
             analyzeUrl = analyzeUrl,
             baseUrl = res.url,
             body = res.body,
-            isSearch = false
+            isSearch = isSearch
         )
     }
 
@@ -146,8 +149,10 @@ object WebBook {
         url: String,
         page: Int? = 1,
         ruleData: RuleDataInterface = RuleData(),
+        key: String? = null,
+        isSearch: Boolean = false,
     ): List<SearchBook> {
-        return exploreBookAwait(bookSource, url, page, ruleData)
+        return exploreBookAwait(bookSource, url, page, ruleData, key, isSearch)
     }
 
     suspend fun exploreBookWithResolvedUrl(

+ 11 - 11
app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageScreen.kt

@@ -40,7 +40,7 @@ import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.widget.components.AppFloatingActionButton
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
-import io.legado.app.ui.widget.components.button.SmallTonalIconButton
+import io.legado.app.ui.widget.components.button.series.SmallTonalButton
 import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.icon.AppIcon
@@ -347,7 +347,7 @@ private fun BookCacheBookCard(
                     color = LegadoTheme.colorScheme.onSurfaceVariant
                 )
                 if (item.hasDownloadTask || item.cachedCount < item.totalCount) {
-                    SmallTonalIconButton(
+                    SmallTonalButton(
                         onClick = {
                             if (item.hasActiveDownload) {
                                 onIntent(BookCacheManageIntent.StopBookDownload(item.bookUrl))
@@ -355,7 +355,7 @@ private fun BookCacheBookCard(
                                 onIntent(BookCacheManageIntent.StartBookDownload(item.bookUrl))
                             }
                         },
-                        imageVector = if (item.hasActiveDownload) Icons.Default.Stop else Icons.Default.PlayArrow,
+                        icon = if (item.hasActiveDownload) Icons.Default.Stop else Icons.Default.PlayArrow,
                         contentDescription = when {
                             item.hasActiveDownload -> "暂停本书下载"
                             item.isPaused -> "继续本书下载"
@@ -363,9 +363,9 @@ private fun BookCacheBookCard(
                         }
                     )
                 }
-                SmallTonalIconButton(
+                SmallTonalButton(
                     onClick = { onDeleteBook(item) },
-                    imageVector = Icons.Default.Delete,
+                    icon = Icons.Default.Delete,
                     contentDescription = null
                 )
             }
@@ -412,21 +412,21 @@ private fun BookCacheChapterRow(
             )
         }
         if (item.isWaiting || item.isDownloading) {
-            SmallTonalIconButton(
+            SmallTonalButton(
                 onClick = onStop,
-                imageVector = Icons.Default.Stop,
+                icon = Icons.Default.Stop,
                 contentDescription = "暂停章节下载"
             )
         } else if (item.isPaused || !item.isCached) {
-            SmallTonalIconButton(
+            SmallTonalButton(
                 onClick = onDownload,
-                imageVector = Icons.Default.Download,
+                icon = Icons.Default.Download,
                 contentDescription = if (item.isPaused) "继续章节下载" else "下载章节"
             )
         }
-        SmallTonalIconButton(
+        SmallTonalButton(
             onClick = onDelete,
-            imageVector = Icons.Default.Delete,
+            icon = Icons.Default.Delete,
             contentDescription = null
         )
     }

+ 65 - 0
app/src/main/java/io/legado/app/ui/book/explore/ExploreShowContract.kt

@@ -0,0 +1,65 @@
+package io.legado.app.ui.book.explore
+
+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.BookShelfState
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+@Stable
+data class ExploreShowUiState(
+    val sourceUrl: String? = null,
+    val books: ImmutableList<ExploreBookItemUi> = persistentListOf(),
+    val kinds: ImmutableList<ExploreKind> = persistentListOf(),
+    val selectedKindTitle: String? = null,
+    val layoutState: Int = 0,
+    val gridCount: Int = 3,
+    val isLoading: Boolean = false,
+    val isRefreshing: Boolean = false,
+    val isEnd: Boolean = false,
+    val errorMsg: String? = null,
+    val sheet: ExploreShowSheet = ExploreShowSheet.None,
+)
+
+@Stable
+data class ExploreBookItemUi(
+    val book: SearchBook,
+    val shelfState: BookShelfState = BookShelfState.NOT_IN_SHELF,
+)
+
+sealed interface ExploreShowSheet {
+    data object None : ExploreShowSheet
+    data object KindSelect : ExploreShowSheet
+    data object GridCount : ExploreShowSheet
+}
+
+sealed interface ExploreShowIntent {
+    data class InitData(
+        val sourceUrl: String?,
+        val exploreUrl: String?,
+    ) : ExploreShowIntent
+
+    data object LoadMore : ExploreShowIntent
+    data object Refresh : ExploreShowIntent
+    data class SwitchKind(val kind: ExploreKind) : ExploreShowIntent
+    data object ToggleLayout : ExploreShowIntent
+    data class SaveGridCount(val count: Int) : ExploreShowIntent
+    data class ShowSheet(val sheet: ExploreShowSheet) : ExploreShowIntent
+    data object DismissSheet : ExploreShowIntent
+    data class OpenBook(val book: SearchBook, val sharedCoverKey: String?) : ExploreShowIntent
+    data class AddToShelf(val book: SearchBook) : ExploreShowIntent
+}
+
+sealed interface ExploreShowEffect {
+    data class OpenBookInfo(
+        val name: String,
+        val author: String,
+        val bookUrl: String,
+        val origin: String?,
+        val coverPath: String?,
+        val sharedCoverKey: String?,
+    ) : ExploreShowEffect
+
+    data class ShowMessage(val message: String) : ExploreShowEffect
+}

+ 109 - 115
app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt

@@ -6,7 +6,6 @@ import androidx.compose.animation.AnimatedVisibilityScope
 import androidx.compose.animation.Crossfade
 import androidx.compose.animation.ExperimentalSharedTransitionApi
 import androidx.compose.animation.SharedTransitionScope
-import androidx.compose.animation.animateContentSize
 import androidx.compose.animation.core.tween
 import androidx.compose.animation.fadeIn
 import androidx.compose.animation.fadeOut
@@ -33,11 +32,8 @@ import androidx.compose.material.icons.filled.GridView
 import androidx.compose.material.icons.outlined.FilterAlt
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
-import androidx.compose.material3.OutlinedButton
-import androidx.compose.material3.Slider
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
@@ -48,19 +44,19 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import dev.chrisbanes.haze.HazeState
 import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.domain.model.BookShelfState
-import io.legado.app.domain.usecase.ExploreKindUiUseCase
 import io.legado.app.ui.config.coverConfig.CoverConfig
 import io.legado.app.ui.main.bookCoverSharedElementKey
 import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.theme.ThemeResolver
 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.AppSlider
 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
@@ -74,7 +70,17 @@ import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
 import io.legado.app.ui.widget.components.topbar.TopBarActionButton
 import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import org.koin.androidx.compose.koinViewModel
-import org.koin.compose.koinInject
+
+private enum class BookFilterState(val id: Int) {
+    SHOW_ALL(0),
+    HIDE_IN_SHELF(1),
+    HIDE_SAME_NAME_AUTHOR(2),
+    SHOW_NOT_IN_SHELF_ONLY(3);
+
+    companion object {
+        fun fromId(id: Int) = entries.getOrElse(id) { SHOW_ALL }
+    }
+}
 
 @SuppressLint("LocalContextConfigurationRead", "ConfigurationScreenWidthHeight")
 @OptIn(
@@ -83,55 +89,51 @@ import org.koin.compose.koinInject
 )
 @Composable
 fun ExploreShowScreen(
-    title: String,
-    sourceUrl: String?,
-    exploreUrl: String?,
+    viewModel: ExploreShowViewModel = koinViewModel(),
+    title: String = "",
     onBack: () -> Unit,
     onBookClick: (SearchBook, String?) -> Unit,
-    viewModel: ExploreShowViewModel = koinViewModel(),
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
 ) {
-    var previewBook by remember { mutableStateOf<SearchBook?>(null) }
-    var previewSharedCoverKey by remember { mutableStateOf<String?>(null) }
+    val state by viewModel.uiState.collectAsStateWithLifecycle()
+
+    LaunchedEffect(viewModel) {
+        viewModel.effects.collect { effect ->
+            when (effect) {
+                is ExploreShowEffect.OpenBookInfo -> onBookClick(
+                    SearchBook(
+                        name = effect.name,
+                        author = effect.author,
+                        bookUrl = effect.bookUrl,
+                        origin = effect.origin ?: "",
+                        coverUrl = effect.coverPath,
+                    ),
+                    effect.sharedCoverKey,
+                )
 
-    LaunchedEffect(sourceUrl, exploreUrl, viewModel) {
-        viewModel.initData(sourceUrl, exploreUrl)
+                is ExploreShowEffect.ShowMessage -> {}
+            }
+        }
     }
 
-    val rawBooks by viewModel.uiBooks.collectAsState()
+    var previewBook by remember { mutableStateOf<SearchBook?>(null) }
+    var previewSharedCoverKey by remember { mutableStateOf<String?>(null) }
+
     val filterStateId = CoverConfig.exploreFilterState
-    val books = remember(rawBooks, filterStateId) {
+    val books = remember(state.books, filterStateId) {
         val filter = BookFilterState.fromId(filterStateId)
         when (filter) {
-            BookFilterState.SHOW_ALL -> rawBooks
-            BookFilterState.HIDE_IN_SHELF -> rawBooks.filter { it.shelfState != BookShelfState.IN_SHELF }
-            BookFilterState.HIDE_SAME_NAME_AUTHOR -> rawBooks.filter { it.shelfState != BookShelfState.SAME_NAME_AUTHOR }
-            BookFilterState.SHOW_NOT_IN_SHELF_ONLY -> rawBooks.filter { it.shelfState == BookShelfState.NOT_IN_SHELF }
+            BookFilterState.SHOW_ALL -> state.books
+            BookFilterState.HIDE_IN_SHELF -> state.books.filter { it.shelfState != BookShelfState.IN_SHELF }
+            BookFilterState.HIDE_SAME_NAME_AUTHOR -> state.books.filter { it.shelfState != BookShelfState.SAME_NAME_AUTHOR }
+            BookFilterState.SHOW_NOT_IN_SHELF_ONLY -> state.books.filter { it.shelfState == BookShelfState.NOT_IN_SHELF }
         }
     }
-    val isBookEnd by viewModel.isEnd.collectAsState()
-    val shouldTriggerAutoLoad by viewModel.shouldTriggerAutoLoad.collectAsState()
-    val isLoading by viewModel.isLoading.collectAsState()
-    val errorMsg by viewModel.errorMsg.collectAsState()
-    val selectedTitle by viewModel.selectedKindTitle.collectAsState()
     val listState = rememberLazyListState()
     val gridState = rememberLazyGridState()
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
-    var showKindSheet by remember { mutableStateOf(false) }
-    val layoutState by viewModel.layoutState.collectAsState()
-    val isGridMode = layoutState == 1
-    var showGridCountSheet by remember { mutableStateOf(false) }
-    val gridColumnCount by viewModel.gridCount.collectAsState()
-    val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
-    val exploreKindUseCase: ExploreKindUiUseCase = koinInject()
-
-    LaunchedEffect(sourceUrl) {
-        exploreKindUseCase.warmUp(sourceUrl)
-    }
-
-    val isRefreshing by viewModel.isRefreshing.collectAsState()
-
+    val isGridMode = state.layoutState == 1
     val hazeState = remember { HazeState() }
     val shouldLoadMoreList = remember {
         derivedStateOf {
@@ -150,17 +152,11 @@ fun ExploreShowScreen(
     }
 
     LaunchedEffect(shouldLoadMoreList.value, isGridMode) {
-        if (!isGridMode && shouldLoadMoreList.value) viewModel.loadMore()
+        if (!isGridMode && shouldLoadMoreList.value) viewModel.onIntent(ExploreShowIntent.LoadMore)
     }
 
     LaunchedEffect(shouldLoadMoreGrid.value, isGridMode) {
-        if (isGridMode && shouldLoadMoreGrid.value) viewModel.loadMore()
-    }
-
-    LaunchedEffect(shouldTriggerAutoLoad) {
-        if (shouldTriggerAutoLoad) {
-            viewModel.loadMore()
-        }
+        if (isGridMode && shouldLoadMoreGrid.value) viewModel.onIntent(ExploreShowIntent.LoadMore)
     }
 
     LaunchedEffect(isGridMode) {
@@ -175,17 +171,14 @@ fun ExploreShowScreen(
         }
     }
 
-
     AppModalBottomSheet(
-        show = showGridCountSheet,
-        modifier = Modifier
-            .padding(16.dp),
-        onDismissRequest = { showGridCountSheet = false }
+        show = state.sheet == ExploreShowSheet.GridCount,
+        onDismissRequest = { viewModel.onIntent(ExploreShowIntent.DismissSheet) }
     ) {
         Row(
             modifier = Modifier
                 .fillMaxWidth()
-                .padding(bottom = 16.dp),
+                .padding(bottom = 32.dp),
             verticalAlignment = Alignment.CenterVertically,
             horizontalArrangement = Arrangement.Center
         ) {
@@ -193,49 +186,38 @@ fun ExploreShowScreen(
                 text = "布局列数",
                 style = LegadoTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold)
             )
-
             Spacer(modifier = Modifier.width(12.dp))
-
             TextCard(
-                text = "$gridColumnCount 列",
+                text = "${state.gridCount} 列",
                 textStyle = LegadoTheme.typography.titleSmall,
+                backgroundColor = LegadoTheme.colorScheme.onSheetContent,
                 verticalPadding = 4.dp,
                 horizontalPadding = 12.dp,
                 cornerRadius = 12.dp
             )
         }
 
-        Slider(
-            value = gridColumnCount.toFloat(),
+        AppSlider(
+            value = state.gridCount.toFloat(),
             onValueChange = {
-                val col = it.toInt().coerceIn(1, 10)
-                viewModel.saveGridCount(col)
+                viewModel.onIntent(ExploreShowIntent.SaveGridCount(it.toInt().coerceIn(1, 10)))
             },
             valueRange = 1f..10f,
             steps = 8,
             modifier = Modifier.padding(horizontal = 20.dp)
         )
 
-        Spacer(Modifier.height(24.dp))
+        Spacer(modifier = Modifier.height(32.dp))
 
-        OutlinedButton(
-            onClick = { showGridCountSheet = false },
-            modifier = Modifier
-                .fillMaxWidth()
-                .padding(horizontal = 16.dp)
-        ) {
-            AppText("完成")
-        }
     }
 
-
     ExploreKindSelectSheet(
-        show = showKindSheet,
-        onDismissRequest = { showKindSheet = false },
-        sourceUrl = sourceUrl,
+        show = state.sheet == ExploreShowSheet.KindSelect,
+        onDismissRequest = { viewModel.onIntent(ExploreShowIntent.DismissSheet) },
+        sourceUrl = state.sourceUrl,
         onSelected = { selectedKinds ->
             selectedKinds.firstOrNull()?.let { kind ->
-                viewModel.switchExploreUrl(kind)
+                viewModel.onIntent(ExploreShowIntent.SwitchKind(kind))
             }
         }
     )
@@ -245,39 +227,39 @@ fun ExploreShowScreen(
             .nestedScroll(scrollBehavior.nestedScrollConnection),
         topBar = {
             GlassMediumFlexibleTopAppBar(
-                modifier = Modifier.responsiveHazeEffect(
-                    state = hazeState
-                ),
-                title = selectedTitle ?: title,
+                modifier = Modifier.responsiveHazeEffect(state = hazeState),
+                title = state.selectedKindTitle ?: title,
                 navigationIcon = {
                     TopBarNavigationButton(onClick = onBack)
                 },
                 actions = {
-                    Row(
-                        horizontalArrangement = Arrangement.End,
-                        modifier = Modifier.animateContentSize(tween(300))
+
+                    AnimatedVisibility(
+                        visible = isGridMode,
+                        enter = fadeIn(tween(300)),
+                        exit = fadeOut(tween(300))
                     ) {
                         TopBarActionButton(
-                            onClick = { showKindSheet = true },
-                            imageVector = Icons.Outlined.FilterAlt,
-                            contentDescription = "分类"
+                            onClick = {
+                                viewModel.onIntent(
+                                    ExploreShowIntent.ShowSheet(
+                                        ExploreShowSheet.GridCount
+                                    )
+                                )
+                            },
+                            imageVector = Icons.AutoMirrored.Outlined.FormatListBulleted,
+                            contentDescription = "列数设置"
                         )
-
-                        AnimatedVisibility(
-                            visible = isGridMode,
-                            enter = fadeIn(tween(300)),
-                            exit = fadeOut(tween(300))
-                        ) {
-                            TopBarActionButton(
-                                onClick = { showGridCountSheet = true },
-                                imageVector = Icons.AutoMirrored.Outlined.FormatListBulleted,
-                                contentDescription = "列数设置"
-                            )
-                        }
                     }
 
                     TopBarActionButton(
-                        onClick = { viewModel.setLayout() },
+                        onClick = { viewModel.onIntent(ExploreShowIntent.ShowSheet(ExploreShowSheet.KindSelect)) },
+                        imageVector = Icons.Outlined.FilterAlt,
+                        contentDescription = "分类"
+                    )
+
+                    TopBarActionButton(
+                        onClick = { viewModel.onIntent(ExploreShowIntent.ToggleLayout) },
                         imageVector = if (!isGridMode) Icons.AutoMirrored.Outlined.FormatListBulleted else Icons.Default.GridView,
                         contentDescription = "切换布局"
                     )
@@ -288,8 +270,8 @@ fun ExploreShowScreen(
     ) { paddingValues ->
         AppPullToRefresh(
             modifier = Modifier.fillMaxSize(),
-            isRefreshing = isRefreshing,
-            onRefresh = { viewModel.loadMore(isRefresh = true) },
+            isRefreshing = state.isRefreshing,
+            onRefresh = { viewModel.onIntent(ExploreShowIntent.Refresh) },
             topPadding = paddingValues.calculateTopPadding()
         ) {
             Crossfade(
@@ -303,7 +285,7 @@ fun ExploreShowScreen(
                         modifier = Modifier
                             .fillMaxSize()
                             .responsiveHazeSource(hazeState),
-                        columns = GridCells.Fixed(gridColumnCount),
+                        columns = GridCells.Fixed(state.gridCount),
                         contentPadding = PaddingValues(
                             top = paddingValues.calculateTopPadding() + 12.dp,
                             bottom = paddingValues.calculateBottomPadding() + 12.dp,
@@ -324,7 +306,14 @@ fun ExploreShowScreen(
                             ExploreBookGridItem(
                                 book = item.book,
                                 shelfState = item.shelfState,
-                                onClick = { onBookClick(item.book, sharedCoverKey) },
+                                onClick = {
+                                    viewModel.onIntent(
+                                        ExploreShowIntent.OpenBook(
+                                            item.book,
+                                            sharedCoverKey
+                                        )
+                                    )
+                                },
                                 onLongClick = { book, coverKey ->
                                     previewBook = book
                                     previewSharedCoverKey = coverKey
@@ -338,10 +327,10 @@ fun ExploreShowScreen(
 
                         item(span = { GridItemSpan(maxLineSpan) }) {
                             LoadMoreFooter(
-                                isLoading = isLoading,
-                                errorMsg = errorMsg,
-                                isEnd = isBookEnd,
-                                onRetry = viewModel::loadMore
+                                isLoading = state.isLoading,
+                                errorMsg = state.errorMsg,
+                                isEnd = state.isEnd,
+                                onRetry = { viewModel.onIntent(ExploreShowIntent.LoadMore) }
                             )
                         }
                     }
@@ -367,7 +356,14 @@ fun ExploreShowScreen(
                             ExploreBookItem(
                                 book = item.book,
                                 shelfState = item.shelfState,
-                                onClick = { onBookClick(item.book, sharedCoverKey) },
+                                onClick = {
+                                    viewModel.onIntent(
+                                        ExploreShowIntent.OpenBook(
+                                            item.book,
+                                            sharedCoverKey
+                                        )
+                                    )
+                                },
                                 onLongClick = { book, coverKey ->
                                     previewBook = book
                                     previewSharedCoverKey = coverKey
@@ -381,15 +377,14 @@ fun ExploreShowScreen(
 
                         item {
                             LoadMoreFooter(
-                                isLoading = isLoading,
-                                errorMsg = errorMsg,
-                                isEnd = isBookEnd,
-                                onRetry = viewModel::loadMore
+                                isLoading = state.isLoading,
+                                errorMsg = state.errorMsg,
+                                isEnd = state.isEnd,
+                                onRetry = { viewModel.onIntent(ExploreShowIntent.LoadMore) }
                             )
                         }
                     }
                 }
-
             }
         }
     }
@@ -408,7 +403,7 @@ fun ExploreShowScreen(
             onBookClick(book, sharedCoverKey)
         },
         onAddToShelf = { book ->
-            viewModel.onAddToShelf(book)
+            viewModel.onIntent(ExploreShowIntent.AddToShelf(book))
         },
     )
 }
@@ -454,10 +449,9 @@ fun ExploreBookGridItem(
         shelfState = shelfState,
         onClick = onClick,
         onLongClick = onLongClick,
-        modifier = modifier,
+        modifier = modifier.padding(4.dp),
         sharedTransitionScope = sharedTransitionScope,
         animatedVisibilityScope = animatedVisibilityScope,
         sharedCoverKey = sharedCoverKey
     )
 }
-

+ 134 - 115
app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt

@@ -6,7 +6,6 @@ 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.model.BookShelfState
 import io.legado.app.domain.usecase.AddToBookshelfUseCase
 import io.legado.app.domain.usecase.BookShelfKey
 import io.legado.app.domain.usecase.ExploreBooksUseCase
@@ -15,43 +14,16 @@ 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.collections.immutable.toImmutableList
+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.asStateFlow
 import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import splitties.init.appCtx
 
-sealed class BookFilterState(val id: Int) {
-    data object SHOW_ALL : BookFilterState(0)
-    data object HIDE_IN_SHELF : BookFilterState(1)
-    data object HIDE_SAME_NAME_AUTHOR : BookFilterState(2)
-    data object SHOW_NOT_IN_SHELF_ONLY : BookFilterState(3)
-
-    companion object {
-        fun fromId(id: Int): BookFilterState = when (id) {
-            1 -> HIDE_IN_SHELF
-            2 -> HIDE_SAME_NAME_AUTHOR
-            3 -> SHOW_NOT_IN_SHELF_ONLY
-            else -> SHOW_ALL
-        }
-    }
-}
-
-sealed class UiState<out T> {
-    data object Loading : UiState<Nothing>()
-    data class Success<T>(val data: T) : UiState<T>()
-    data class Error(val message: String) : UiState<Nothing>()
-    data object Empty : UiState<Nothing>()
-}
-
-data class ExploreBookItemUi(
-    val book: SearchBook,
-    val shelfState: BookShelfState = BookShelfState.NOT_IN_SHELF,
-)
-
 class ExploreShowViewModel(
     private val repository: ExploreRepository,
     private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase,
@@ -61,72 +33,128 @@ class ExploreShowViewModel(
 ) : ViewModel() {
 
     private val _rawBooks = MutableStateFlow<List<SearchBook>>(emptyList())
+    private val _bookshelf = MutableStateFlow<Set<BookShelfKey>>(emptySet())
     private val _isLoading = MutableStateFlow(false)
-    private val _isRefreshing = MutableStateFlow(false)
+    private val _isEnd = MutableStateFlow(false)
     private val _errorMsg = MutableStateFlow<String?>(null)
+    private val _kinds = MutableStateFlow<List<ExploreKind>>(emptyList())
+    private val _selectedKindTitle = MutableStateFlow<String?>(null)
+
     private var bookSource: BookSource? = null
     private var sourceUrl: String? = null
     private var exploreUrl: String? = null
     private var page = 1
-    private val _isEndStateFlow = MutableStateFlow(false)
-    private val _bookshelf = MutableStateFlow<Set<BookShelfKey>>(emptySet())
-    private val _kinds = MutableStateFlow<List<ExploreKind>>(emptyList())
-    val kinds = _kinds.asStateFlow()
-    private val _selectedKindTitle = MutableStateFlow<String?>(null)
-    val selectedKindTitle = _selectedKindTitle.asStateFlow()
-    private val _layoutState = MutableStateFlow(AppConfig.exploreLayoutState) // 0=列表, 1=网格
-    val layoutState: StateFlow<Int> = _layoutState.asStateFlow()
-    private val _gridCount = MutableStateFlow(appCtx.exploreLayoutGrid)
-    val gridCount = _gridCount.asStateFlow()
-    val isEnd: StateFlow<Boolean> = _isEndStateFlow.asStateFlow()
-
-    val isRefreshing = _isRefreshing.asStateFlow()
-
-    fun saveGridCount(value: Int) {
-        appCtx.exploreLayoutGrid = value
-        _gridCount.value = value
+
+    private val _uiState = MutableStateFlow(
+        ExploreShowUiState(
+            layoutState = AppConfig.exploreLayoutState,
+            gridCount = appCtx.exploreLayoutGrid,
+        )
+    )
+    val uiState = _uiState.asStateFlow()
+
+    private val _effects = MutableSharedFlow<ExploreShowEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
+
+    init {
+        observeBookshelf()
+        combineUiState()
     }
 
-    val uiBooks = combine(
-        _rawBooks,
-        _bookshelf
-    ) { books, bookshelf ->
-        books.map { item ->
-            ExploreBookItemUi(
-                book = item,
-                shelfState = resolveBookShelfStateUseCase.execute(
-                    name = item.name,
-                    author = item.author,
-                    url = item.bookUrl,
-                    shelf = bookshelf
+    fun onIntent(intent: ExploreShowIntent) {
+        when (intent) {
+            is ExploreShowIntent.InitData -> initData(intent.sourceUrl, intent.exploreUrl)
+            ExploreShowIntent.LoadMore -> loadMore()
+            ExploreShowIntent.Refresh -> loadMore(isRefresh = true)
+            is ExploreShowIntent.SwitchKind -> switchKind(intent.kind)
+            ExploreShowIntent.ToggleLayout -> toggleLayout()
+            is ExploreShowIntent.SaveGridCount -> saveGridCount(intent.count)
+            is ExploreShowIntent.ShowSheet -> _uiState.update { it.copy(sheet = intent.sheet) }
+            ExploreShowIntent.DismissSheet -> _uiState.update { it.copy(sheet = ExploreShowSheet.None) }
+            is ExploreShowIntent.OpenBook -> emitEffect(
+                ExploreShowEffect.OpenBookInfo(
+                    name = intent.book.name,
+                    author = intent.book.author,
+                    bookUrl = intent.book.bookUrl,
+                    origin = intent.book.origin,
+                    coverPath = intent.book.coverUrl,
+                    sharedCoverKey = intent.sharedCoverKey,
                 )
             )
-        }
-    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
-
-    val shouldTriggerAutoLoad = combine(
-        _isLoading,
-        uiBooks,
-        _rawBooks,
-        _isEndStateFlow
-    ) { loading, uiBooks, rawBooks, isEnd ->
-        !loading && uiBooks.isEmpty() && rawBooks.isNotEmpty() && !isEnd
-    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
 
-    val isLoading = _isLoading.asStateFlow()
-    val errorMsg = _errorMsg.asStateFlow()
+            is ExploreShowIntent.AddToShelf -> viewModelScope.launch {
+                addToBookshelfUseCase.execute(intent.book)
+            }
+        }
+    }
 
-    init {
+    private fun observeBookshelf() {
         viewModelScope.launch {
             repository.getBookshelfItems().collect { list ->
-                val keys = list.map { BookShelfKey(it.name, it.author, it.bookUrl) }.toSet()
-                _bookshelf.value = keys
+                _bookshelf.value = list.map {
+                    BookShelfKey(it.name, it.author, it.bookUrl)
+                }.toSet()
+            }
+        }
+    }
+
+    private fun combineUiState() {
+        viewModelScope.launch {
+            combine(
+                _rawBooks,
+                _bookshelf,
+                _isLoading,
+                _isEnd,
+                _errorMsg,
+                _kinds,
+                _selectedKindTitle,
+            ) { values ->
+                @Suppress("UNCHECKED_CAST")
+                val rawBooks = values[0] as List<SearchBook>
+                val bookshelf = values[1] as Set<BookShelfKey>
+                val isLoading = values[2] as Boolean
+                val isEnd = values[3] as Boolean
+                val errorMsg = values[4] as String?
+                val kinds = values[5] as List<ExploreKind>
+                val selectedKindTitle = values[6] as String?
+
+                val books = rawBooks.map { item ->
+                    ExploreBookItemUi(
+                        book = item,
+                        shelfState = resolveBookShelfStateUseCase.execute(
+                            name = item.name,
+                            author = item.author,
+                            url = item.bookUrl,
+                            shelf = bookshelf,
+                        )
+                    )
+                }
+
+                ExploreShowUiState(
+                    sourceUrl = sourceUrl,
+                    books = books.toImmutableList(),
+                    kinds = kinds.toImmutableList(),
+                    selectedKindTitle = selectedKindTitle,
+                    layoutState = _uiState.value.layoutState,
+                    gridCount = _uiState.value.gridCount,
+                    isLoading = isLoading,
+                    isRefreshing = isLoading && page == 1,
+                    isEnd = isEnd,
+                    errorMsg = errorMsg,
+                    sheet = _uiState.value.sheet,
+                )
+            }.collect { newState ->
+                val oldState = _uiState.value
+                _uiState.value = newState
+
+                if (newState.books.isEmpty() && _rawBooks.value.isNotEmpty() && !newState.isEnd && !newState.isLoading) {
+                    loadMore()
+                }
             }
         }
     }
 
-    fun initData(incomingSourceUrl: String?, incomingExploreUrl: String?) {
-        // 允许 incomingExploreUrl 为空,此时加载书源默认发现页
+    private fun initData(incomingSourceUrl: String?, incomingExploreUrl: String?) {
         if (sourceUrl == incomingSourceUrl && exploreUrl == incomingExploreUrl && bookSource != null) {
             return
         }
@@ -135,16 +163,16 @@ class ExploreShowViewModel(
         page = 1
         bookSource = null
         _rawBooks.value = emptyList()
-        _isEndStateFlow.value = false
+        _isEnd.value = false
         _errorMsg.value = null
         _selectedKindTitle.value = null
+        _kinds.value = emptyList()
 
         viewModelScope.launch {
             if (bookSource == null && incomingSourceUrl != null) {
                 bookSource = repository.getBookSource(incomingSourceUrl)
             }
 
-            // 如果仍然没有发现 URL,且书源已加载,尝试使用书源的默认发现页
             if (exploreUrl == null && bookSource != null) {
                 loadKinds(incomingSourceUrl!!)
             }
@@ -153,33 +181,36 @@ class ExploreShowViewModel(
         }
     }
 
-    fun loadKinds(sourceUrl: String) {
+    private fun loadKinds(sourceUrl: String) {
         viewModelScope.launch {
             _kinds.value = repository.getSourceExploreKinds(sourceUrl)
         }
     }
 
-    fun refreshKinds() {
-        sourceUrl?.let { loadKinds(it) }
-    }
-
-    fun switchExploreUrl(kind: ExploreKind) {
+    private fun switchKind(kind: ExploreKind) {
         _selectedKindTitle.value = kind.title
         exploreUrl = kind.url
-        _isEndStateFlow.value = false
+        _isEnd.value = false
         loadMore(isRefresh = true)
     }
 
-    fun setLayout() {
-        val newState = if (_layoutState.value == 0) 1 else 0
-        _layoutState.value = newState
-        AppConfig.exploreLayoutState = newState
+    private fun toggleLayout() {
+        _uiState.update {
+            val newState = if (it.layoutState == 0) 1 else 0
+            AppConfig.exploreLayoutState = newState
+            it.copy(layoutState = newState)
+        }
+    }
+
+    private fun saveGridCount(count: Int) {
+        appCtx.exploreLayoutGrid = count
+        _uiState.update { it.copy(gridCount = count) }
     }
 
-    fun loadMore(isRefresh: Boolean = false) {
+    private fun loadMore(isRefresh: Boolean = false) {
         val source = bookSource
         val url = exploreUrl ?: source?.exploreUrl
-        if (source == null || url == null || _isLoading.value || (_isEndStateFlow.value && !isRefresh)) return
+        if (source == null || url == null || _isLoading.value || (_isEnd.value && !isRefresh)) return
 
         viewModelScope.launch {
             _isLoading.value = true
@@ -187,7 +218,7 @@ class ExploreShowViewModel(
 
             if (isRefresh) {
                 page = 1
-                _isEndStateFlow.value = false
+                _isEnd.value = false
                 _rawBooks.value = emptyList()
             }
 
@@ -195,7 +226,7 @@ class ExploreShowViewModel(
                 exploreBooksUseCase.execute(source.bookSourceUrl, url, args = null, page)
             }.onSuccess { result ->
                 if (result.books.isEmpty()) {
-                    _isEndStateFlow.value = true
+                    _isEnd.value = true
                 } else {
                     saveSearchBooksUseCase.save(result.books)
 
@@ -207,34 +238,22 @@ class ExploreShowViewModel(
                         .distinctBy { it.bookUrl }
 
                     if (uniqueNewBooks.isEmpty()) {
-                        _isEndStateFlow.value = true
+                        _isEnd.value = true
                     } else {
                         _rawBooks.value = currentList + uniqueNewBooks
                         page++
-                        _isEndStateFlow.value = false
+                        _isEnd.value = false
                     }
                 }
+            }.onFailure {
+                _errorMsg.value = it.stackTraceStr
             }
-                .onFailure {
-                    _errorMsg.value = it.stackTraceStr
-                }
 
             _isLoading.value = false
         }
     }
 
-    fun getCurrentBookShelfState(item: SearchBook): BookShelfState {
-        return resolveBookShelfStateUseCase.execute(
-            name = item.name,
-            author = item.author,
-            url = item.bookUrl,
-            shelf = _bookshelf.value
-        )
-    }
-
-    fun onAddToShelf(book: SearchBook) {
-        viewModelScope.launch {
-            addToBookshelfUseCase.execute(book)
-        }
+    private fun emitEffect(effect: ExploreShowEffect) {
+        _effects.tryEmit(effect)
     }
 }

+ 5 - 5
app/src/main/java/io/legado/app/ui/book/group/GroupEditSheet.kt

@@ -34,7 +34,7 @@ import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
-import io.legado.app.ui.widget.components.button.MediumIconButton
+import io.legado.app.ui.widget.components.button.series.MediumPlainButton
 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.modalBottomSheet.AppModalBottomSheet
@@ -272,11 +272,11 @@ fun GroupDeleteAction(
 ) {
     var showDeleteDialog by remember { mutableStateOf(false) }
 
-    MediumIconButton(
+    MediumPlainButton(
         onClick = {
             showDeleteDialog = true
         },
-        imageVector = Icons.Default.Delete
+        icon = Icons.Default.Delete
     )
 
     AppAlertDialog(
@@ -304,7 +304,7 @@ fun GroupResetCoverAction(
     onCoverPathChange: (String?) -> Unit,
     viewModel: GroupViewModel = koinViewModel()
 ) {
-    MediumIconButton(
+    MediumPlainButton(
         onClick = {
             if (group != null) {
                 viewModel.clearCover(group) {
@@ -315,6 +315,6 @@ fun GroupResetCoverAction(
                 onCoverPathChange(null)
             }
         },
-        imageVector = Icons.Default.Restore
+        icon = Icons.Default.Restore
     )
 }

+ 6 - 6
app/src/main/java/io/legado/app/ui/book/import/local/ImportBookScreen.kt

@@ -61,8 +61,8 @@ import io.legado.app.ui.widget.components.AppPullToRefresh
 import io.legado.app.ui.widget.components.EmptyMessage
 import io.legado.app.ui.widget.components.SelectionActions
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
-import io.legado.app.ui.widget.components.button.SmallIconButton
-import io.legado.app.ui.widget.components.button.SmallTonalIconButton
+import io.legado.app.ui.widget.components.button.series.SmallPlainButton
+import io.legado.app.ui.widget.components.button.series.SmallTonalButton
 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.filePicker.FilePickerSheet
@@ -427,9 +427,9 @@ private fun ImportPathNavigationBar(
         }
 
         if (canGoBack) {
-            SmallTonalIconButton(
+            SmallTonalButton(
                 onClick = onNavigateBack,
-                imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+                icon = Icons.AutoMirrored.Filled.ArrowBack,
                 contentDescription = "back"
             )
         }
@@ -511,9 +511,9 @@ private fun ImportBookItem(
 
             if (!item.isDir && !item.isOnBookShelf) {
                 Spacer(modifier = Modifier.width(8.dp))
-                SmallIconButton(
+                SmallPlainButton(
                     onClick = onAddToBookshelf,
-                    imageVector = if (isSelected) Icons.Default.Check else Icons.Default.AddCircleOutline
+                    icon = if (isSelected) Icons.Default.Check else Icons.Default.AddCircleOutline
                 )
             }
         }

+ 13 - 13
app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookScreen.kt

@@ -76,9 +76,9 @@ import io.legado.app.ui.widget.components.EmptyMessage
 import io.legado.app.ui.widget.components.SelectionActions
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
-import io.legado.app.ui.widget.components.button.MediumIconButton
-import io.legado.app.ui.widget.components.button.SmallIconButton
-import io.legado.app.ui.widget.components.button.SmallTonalIconButton
+import io.legado.app.ui.widget.components.button.series.MediumPlainButton
+import io.legado.app.ui.widget.components.button.series.SmallPlainButton
+import io.legado.app.ui.widget.components.button.series.SmallTonalButton
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.card.SelectionItemCard
 import io.legado.app.ui.widget.components.card.TextCard
@@ -167,9 +167,9 @@ fun RemoteBookScreen(
         },
         endAction = if (showSheet is RemoteBookSheet.Servers) {
             {
-                MediumIconButton(
+                MediumPlainButton(
                     onClick = { showSheet = RemoteBookSheet.ServerConfig(null) },
-                    imageVector = Icons.Default.Add
+                    icon = Icons.Default.Add
                 )
             }
         } else {
@@ -430,16 +430,16 @@ private fun ServerItem(
             {
                 Row {
                     onEdit?.let {
-                        SmallIconButton(
+                        SmallPlainButton(
                             onClick = it,
-                            imageVector = Icons.Default.Edit,
+                            icon = Icons.Default.Edit,
                             contentDescription = "Edit"
                         )
                     }
                     onDelete?.let {
-                        SmallIconButton(
+                        SmallPlainButton(
                             onClick = it,
-                            imageVector = Icons.Default.Delete,
+                            icon = Icons.Default.Delete,
                             contentDescription = "Delete"
                         )
                     }
@@ -602,9 +602,9 @@ private fun PathNavigationBar(
         }
 
         if (canGoBack) {
-            SmallTonalIconButton(
+            SmallTonalButton(
                 onClick = onNavigateBack,
-                imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+                icon = Icons.AutoMirrored.Filled.ArrowBack,
                 contentDescription = "返回上级"
             )
         }
@@ -703,7 +703,7 @@ private fun RemoteBookItem(
             if (!book.isDir) {
                 Spacer(modifier = Modifier.width(8.dp))
 
-                SmallIconButton(
+                SmallPlainButton(
                     onClick = {
                         if (book.isOnBookShelf) {
                             onUpdateClick(book)
@@ -711,7 +711,7 @@ private fun RemoteBookItem(
                             onAddClick(book)
                         }
                     },
-                    imageVector = if (book.isOnBookShelf)
+                    icon = if (book.isOnBookShelf)
                         Icons.Outlined.CloudSync
                     else
                         Icons.Outlined.AddCircleOutline,

+ 3 - 3
app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt

@@ -89,7 +89,7 @@ import io.legado.app.ui.widget.components.AppPullToRefresh
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
-import io.legado.app.ui.widget.components.button.SmallTonalIconButton
+import io.legado.app.ui.widget.components.button.series.SmallTonalButton
 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.icon.AppIcon
@@ -1122,9 +1122,9 @@ private fun RelatedBooksBanner(
                     fontWeight = FontWeight.Bold,
                     modifier = Modifier.weight(1f),
                 )
-                SmallTonalIconButton(
+                SmallTonalButton(
                     onClick = onMoreClick,
-                    imageVector = Icons.AutoMirrored.Filled.ArrowForward,
+                    icon = Icons.AutoMirrored.Filled.ArrowForward,
                     contentDescription = "more",
                 )
             }

+ 20 - 20
app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt

@@ -72,8 +72,8 @@ import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.EmptyMessage
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
-import io.legado.app.ui.widget.components.button.MediumIconButton
-import io.legado.app.ui.widget.components.button.SmallIconButton
+import io.legado.app.ui.widget.components.button.series.MediumPlainButton
+import io.legado.app.ui.widget.components.button.series.SmallPlainButton
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.card.SelectionItemCard
 import io.legado.app.ui.widget.components.checkBox.AppCheckbox
@@ -145,15 +145,15 @@ fun GroupSelectSheet(
         onDismissRequest = onDismissRequest,
         title = stringResource(R.string.group_select),
         startAction = {
-            MediumIconButton(
+            MediumPlainButton(
                 onClick = { showAddGroup = true },
-                imageVector = Icons.Default.Add
+                icon = Icons.Default.Add
             )
         },
         endAction = {
-            MediumIconButton(
+            MediumPlainButton(
                 onClick = { onConfirm(selectedGroupId) },
-                imageVector = Icons.Default.Check
+                icon = Icons.Default.Check
             )
         }
     ) {
@@ -187,9 +187,9 @@ fun GroupSelectSheet(
                             )
                         },
                         trailingAction = {
-                            SmallIconButton(
+                            SmallPlainButton(
                                 onClick = { editingGroup = group },
-                                imageVector = Icons.Default.Edit
+                                icon = Icons.Default.Edit
                             )
                         },
                         containerColor = LegadoTheme.colorScheme.surfaceContainerLow
@@ -236,9 +236,9 @@ fun ChangeCoverSheet(
         onDismissRequest = onDismissRequest,
         title = stringResource(R.string.change_cover_source),
         endAction = {
-            MediumIconButton(
+            MediumPlainButton(
                 onClick = { viewModel.startOrStopSearch() },
-                imageVector = if (isSearching) Icons.Default.MoreVert else Icons.Default.Refresh
+                icon = if (isSearching) Icons.Default.MoreVert else Icons.Default.Refresh
             )
         }
     ) {
@@ -370,9 +370,9 @@ fun ChangeSourceSheet(
         startAction = {
             Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
                 Box {
-                    MediumIconButton(
+                    MediumPlainButton(
                         onClick = { showOptionsMenu = true },
-                        imageVector = Icons.Default.MoreVert
+                        icon = Icons.Default.MoreVert
                     )
                     RoundDropdownMenu(
                         expanded = showOptionsMenu,
@@ -419,21 +419,21 @@ fun ChangeSourceSheet(
                         )
                     }
                 }
-                MediumIconButton(
+                MediumPlainButton(
                     onClick = { showMigrationOptions = true },
-                    imageVector = Icons.Outlined.Settings
+                    icon = Icons.Outlined.Settings
                 )
             }
         },
         endAction = {
             Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
-                MediumIconButton(
+                MediumPlainButton(
                     onClick = { viewModel.startOrStopSearch() },
-                    imageVector = if (isSearching) Icons.Default.PauseCircleOutline else Icons.Default.Refresh,
+                    icon = if (isSearching) Icons.Default.PauseCircleOutline else Icons.Default.Refresh,
                 )
-                MediumIconButton(
+                MediumPlainButton(
                     onClick = { showFilterSheet = true },
-                    imageVector = Icons.Default.FilterList
+                    icon = Icons.Default.FilterList
                 )
             }
         }
@@ -484,11 +484,11 @@ fun ChangeSourceSheet(
                         containerColor = LegadoTheme.colorScheme.onSheetContent,
                         selectedContainerColor = LegadoTheme.colorScheme.primaryContainer.copy(alpha = 0.32f),
                         leadingContent = {
-                            MediumIconButton(
+                            MediumPlainButton(
                                 onClick = {
                                     viewModel.onBookScoreClick(item)
                                 },
-                                imageVector = Icons.Default.PushPin,
+                                icon = Icons.Default.PushPin,
                                 tint = if (bookScore > 0) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.outline,
                                 contentDescription = null
                             )

+ 11 - 11
app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt

@@ -59,7 +59,7 @@ import io.legado.app.ui.theme.fadingEdge
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
-import io.legado.app.ui.widget.components.button.MediumOutlinedIconButton
+import io.legado.app.ui.widget.components.button.series.MediumOutlinedButton
 import io.legado.app.ui.widget.components.image.cover.CoilBookCover
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
@@ -156,7 +156,7 @@ fun BookInfoEditContent(
                 horizontalAlignment = Alignment.CenterHorizontally
             ) {
                 Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
-                    MediumOutlinedIconButton(
+                    MediumOutlinedButton(
                         onClick = {
                             (context as? BookInfoEditActivity)?.showDialogFragment(
                                 ChangeCoverDialog(
@@ -165,15 +165,15 @@ fun BookInfoEditContent(
                                 )
                             )
                         },
-                        imageVector = Icons.Default.ImageSearch
+                        icon = Icons.Default.ImageSearch
                     )
-                    MediumOutlinedIconButton(
+                    MediumOutlinedButton(
                         onClick = { selectCover.launch() },
-                        imageVector = Icons.Default.FolderOpen
+                        icon = Icons.Default.FolderOpen
                     )
-                    MediumOutlinedIconButton(
+                    MediumOutlinedButton(
                         onClick = { viewModel.resetCover() },
-                        imageVector = Icons.Default.Replay
+                        icon = Icons.Default.Replay
                     )
                 }
                 Spacer(modifier = Modifier.height(4.dp))
@@ -371,19 +371,19 @@ fun KindEditor(
             }
         }
         Spacer(modifier = Modifier.width(8.dp))
-        MediumOutlinedIconButton(
+        MediumOutlinedButton(
             onClick = {
                 onReset()
             },
-            imageVector = Icons.Default.Replay
+            icon = Icons.Default.Replay
         )
         Spacer(modifier = Modifier.width(8.dp))
-        MediumOutlinedIconButton(
+        MediumOutlinedButton(
             onClick = {
                 editingIndex = -1
                 editText = ""
             },
-            imageVector = Icons.Default.Add
+            icon = Icons.Default.Add
         )
     }
 

+ 35 - 36
app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt

@@ -82,8 +82,7 @@ import io.legado.app.ui.widget.components.AppFloatingActionButtonMenu
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.FabMenuItem
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
-import io.legado.app.ui.widget.components.button.SmallTonalIconButton
-import io.legado.app.ui.widget.components.button.SmallTonalTextButton
+import io.legado.app.ui.widget.components.button.series.SmallTonalButton
 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
@@ -768,32 +767,32 @@ private fun BookshelfManageScreen(
                                             }
                                         )
                                     }
-                                    SmallTonalIconButton(
+                                    SmallTonalButton(
                                         onClick = {
                                             if (!book.isLocal) {
                                                 viewModel.dispatch(BookshelfManageScreenIntent.ToggleBookDownload(book))
                                             }
                                         },
-                                        imageVector = if (isDownloading) Icons.Default.Stop else Icons.Default.Download,
+                                        icon = if (isDownloading) Icons.Default.Stop else Icons.Default.Download,
                                         contentDescription = "download"
                                     )
-                                    SmallTonalIconButton(
+                                    SmallTonalButton(
                                         onClick = { exportBook(book) },
-                                        imageVector = Icons.Default.Upload,
+                                        icon = Icons.Default.Upload,
                                         contentDescription = "upload"
                                     )
-                                    SmallTonalIconButton(
+                                    SmallTonalButton(
                                         onClick = {
                                             pendingMoveGroupBookUrl = book.bookUrl
                                             groupPickerCurrentGroupId = book.group.coerceAtLeast(0L)
                                             showGroupSelectSheet = true
                                         },
-                                        imageVector = Icons.Default.Bookmarks,
+                                        icon = Icons.Default.Bookmarks,
                                         contentDescription = "group"
                                     )
-                                    SmallTonalIconButton(
+                                    SmallTonalButton(
                                         onClick = { moreMenuBookUrl = book.bookUrl },
-                                        imageVector = Icons.Default.MoreVert,
+                                        icon = Icons.Default.MoreVert,
                                         contentDescription = "more"
                                     )
                                 }
@@ -1288,12 +1287,12 @@ private fun BookSourcePickerSheet(
         onDismissRequest = onDismissRequest,
         title = title,
         endAction = {
-            SmallTonalTextButton(
-                text = stringResource(android.R.string.ok),
-                imageVector = Icons.Default.PlayArrow,
+            SmallTonalButton(
                 onClick = {
                     onConfirm(selectedSources.mapNotNull { it.getBookSource() })
-                }
+                },
+                icon = Icons.Default.PlayArrow,
+                text = stringResource(android.R.string.ok)
             )
         }
     ) {
@@ -1402,17 +1401,17 @@ private fun BatchChangePreviewSheet(
         onDismissRequest = onDismissRequest,
         title = "批量换源预览",
         startAction = {
-            SmallTonalTextButton(
-                text = "新增全部",
-                imageVector = Icons.Default.Add,
+            SmallTonalButton(
                 onClick = onAddAllToShelf,
+                icon = Icons.Default.Add,
+                text = "新增全部"
             )
         },
         endAction = {
-            SmallTonalTextButton(
-                text = "迁移全部",
-                imageVector = Icons.Default.PlayArrow,
+            SmallTonalButton(
                 onClick = onMigrateAll,
+                icon = Icons.Default.PlayArrow,
+                text = "迁移全部"
             )
         }
     ) { items ->
@@ -1498,30 +1497,30 @@ private fun BatchChangePreviewRow(
                 modifier = Modifier.fillMaxWidth(),
                 horizontalArrangement = Arrangement.SpaceBetween
             ) {
-                SmallTonalTextButton(
-                    imageVector = Icons.Default.Search,
+                SmallTonalButton(
                     onClick = { onManualSearch(item.oldBook) },
+                    icon = Icons.Default.Search
                 )
-                SmallTonalTextButton(
-                    text = "不迁移",
-                    imageVector = Icons.Default.Clear,
+                SmallTonalButton(
                     onClick = { onSkip(item.oldBook.bookUrl) },
+                    icon = Icons.Default.Clear,
+                    text = "不迁移"
                 )
-                SmallTonalTextButton(
-                    text = "迁移",
-                    imageVector = Icons.Default.PlayArrow,
+                SmallTonalButton(
                     onClick = { onMigrate(item.oldBook.bookUrl) },
+                    icon = Icons.Default.PlayArrow,
+                    text = "迁移"
                 )
-                SmallTonalTextButton(
-                    text = "新增",
-                    imageVector = Icons.Default.Add,
+                SmallTonalButton(
                     onClick = { onAddToShelf(item.oldBook.bookUrl) },
+                    icon = Icons.Default.Add,
+                    text = "新增"
                 )
                 if (item.candidates.size > 1) {
-                    SmallTonalTextButton(
-                        text = "查看其他源信息",
-                        imageVector = Icons.Default.Info,
+                    SmallTonalButton(
                         onClick = { onShowOtherSources(item) },
+                        icon = Icons.Default.Info,
+                        text = "查看其他源信息"
                     )
                 }
             }
@@ -1605,9 +1604,9 @@ private fun OtherSourceOptionsSheet(
                     isSelected = index == currentItem.selectedCandidateIndex,
                     onToggleSelection = { onSelect(currentItem.oldBook.bookUrl, index) },
                     trailingAction = {
-                        SmallTonalIconButton(
+                        SmallTonalButton(
                             onClick = { onOpenBook(candidate.book) },
-                            imageVector = Icons.Default.Info,
+                            icon = Icons.Default.Info,
                             contentDescription = null,
                         )
                     },

+ 5 - 5
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewScreen.kt

@@ -54,7 +54,7 @@ import io.legado.app.ui.book.readRecord.component.StatsGridCard
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.adaptiveHorizontalPadding
 import io.legado.app.ui.widget.components.AppScaffold
-import io.legado.app.ui.widget.components.button.MediumIconButton
+import io.legado.app.ui.widget.components.button.series.MediumPlainButton
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.heatmap.HeatmapMode
 import io.legado.app.ui.widget.components.image.cover.CoilBookCover
@@ -187,9 +187,9 @@ fun DateNavigator(
         horizontalArrangement = Arrangement.SpaceBetween,
         verticalAlignment = Alignment.CenterVertically
     ) {
-        MediumIconButton(
+        MediumPlainButton(
             onClick = onPrevClick,
-            imageVector = Icons.AutoMirrored.Filled.ArrowLeft
+            icon = Icons.AutoMirrored.Filled.ArrowLeft
         )
         AnimatedContent(
             targetState = referenceDate,
@@ -224,9 +224,9 @@ fun DateNavigator(
                 modifier = Modifier.padding(horizontal = 16.dp)
             )
         }
-        MediumIconButton(
+        MediumPlainButton(
             onClick = onNextClick,
-            imageVector = Icons.AutoMirrored.Filled.ArrowRight
+            icon = Icons.AutoMirrored.Filled.ArrowRight
         )
     }
 }

+ 4 - 5
app/src/main/java/io/legado/app/ui/book/search/ScopeSelectSheet.kt

@@ -9,8 +9,6 @@ import androidx.compose.foundation.layout.heightIn
 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.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
@@ -27,8 +25,9 @@ import io.legado.app.R
 import io.legado.app.data.entities.BookSourcePart
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.SearchBar
-import io.legado.app.ui.widget.components.button.MediumIconButton
+import io.legado.app.ui.widget.components.button.series.MediumPlainButton
 import io.legado.app.ui.widget.components.card.SelectionItemCard
+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.tabRow.AppTabRow
 
@@ -68,9 +67,9 @@ fun ScopeSelectSheet(
         title = title,
         endAction = onConfirm?.let {
             {
-                MediumIconButton(
+                MediumPlainButton(
                     onClick = it,
-                    imageVector = Icons.Default.Check
+                    icon = AppIcons.Settings
                 )
             }
         }

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt

@@ -54,7 +54,7 @@ class SearchActivity : BaseComposeActivity() {
             },
             onOpenSourceManage = {
                 startActivity<BookSourceActivity>()
-            }
+            },
         )
     }
 

+ 26 - 11
app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt

@@ -5,7 +5,10 @@ import io.legado.app.data.entities.BookSourcePart
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.SearchKeyword
 import io.legado.app.domain.model.BookShelfState
+import io.legado.app.domain.model.MatchMode
 import io.legado.app.ui.main.bookshelf.BookShelfItem
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
 
 @Stable
 data class SearchResultItemUi(
@@ -17,17 +20,17 @@ data class SearchResultItemUi(
 data class SearchUiState(
     val query: String = "",
     val committedQuery: String = "",
-    val results: List<SearchResultItemUi> = emptyList(),
-    val history: List<SearchKeyword> = emptyList(),
-    val bookshelfHints: List<BookShelfItem> = emptyList(),
-    val enabledGroups: List<String> = emptyList(),
-    val enabledSources: List<BookSourcePart> = emptyList(),
+    val results: ImmutableList<SearchResultItemUi> = persistentListOf(),
+    val history: ImmutableList<SearchKeyword> = persistentListOf(),
+    val bookshelfHints: ImmutableList<BookShelfItem> = persistentListOf(),
+    val enabledGroups: ImmutableList<String> = persistentListOf(),
+    val enabledSources: ImmutableList<BookSourcePart> = persistentListOf(),
     val scopeDisplay: String = "",
-    val scopeDisplayNames: List<String> = emptyList(),
+    val scopeDisplayNames: ImmutableList<String> = persistentListOf(),
     val selectedScopeSourceUrls: Set<String> = emptySet(),
     val isAllScope: Boolean = true,
     val isSourceScope: Boolean = false,
-    val isPrecisionSearch: Boolean = false,
+    val matchMode: MatchMode = MatchMode.DEFAULT,
     val isSearching: Boolean = false,
     val isManualStop: Boolean = false,
     val hasMore: Boolean = true,
@@ -35,17 +38,24 @@ data class SearchUiState(
     val totalSources: Int = 0,
     val selectedSourceTypes: Set<Int> = emptySet(),
     val showScopeSheet: Boolean = false,
-    val showTypeSheet: Boolean = false,
+    val showSettingsSheet: Boolean = false,
     val showClearHistoryDialog: Boolean = false,
     val showSuggestions: Boolean = true,
     val emptyScopeAction: SearchEmptyScopeAction? = null,
     val savedScrollIndex: Int = 0,
     val savedScrollOffset: Int = 0,
+    val expandedSourceUrl: String? = null,
+    val expandedSourceName: String? = null,
+    val expandedSourceBooks: ImmutableList<SearchBook> = persistentListOf(),
+    val expandedSourceLoading: Boolean = false,
+    val expandedSourceEnd: Boolean = false,
+    val expandedSourceError: String? = null,
+    val expandedSourcePage: Int = 1,
 )
 
 data class SearchEmptyScopeAction(
     val scopeDisplay: String,
-    val wasPrecisionSearch: Boolean,
+    val wasMatchMode: MatchMode,
 )
 
 sealed interface SearchIntent {
@@ -60,17 +70,22 @@ sealed interface SearchIntent {
     data class UseHistoryKeyword(val keyword: String) : SearchIntent
     data class OpenSearchBook(val book: SearchBook, val sharedCoverKey: String?) : SearchIntent
     data class OpenBookshelfBook(val book: BookShelfItem) : SearchIntent
+    data class ExpandSource(val sourceUrl: String, val sourceName: String) : SearchIntent
+    data object DismissExpandedSource : SearchIntent
+    data object LoadMoreExpandedSource : SearchIntent
+    data class OpenExpandedSourceBook(val book: SearchBook, val sharedCoverKey: String?) :
+        SearchIntent
     data class DeleteHistory(val item: SearchKeyword) : SearchIntent
     data class SetClearHistoryDialogVisible(val visible: Boolean) : SearchIntent
     data object ConfirmClearHistory : SearchIntent
     data class SetScopeSheetVisible(val visible: Boolean) : SearchIntent
-    data class SetTypeSheetVisible(val visible: Boolean) : SearchIntent
+    data class SetSettingsSheetVisible(val visible: Boolean) : SearchIntent
     data class ToggleSourceType(val type: Int) : SearchIntent
     data object SelectAllScope : SearchIntent
     data class ToggleScopeGroup(val groupName: String) : SearchIntent
     data class ToggleScopeSource(val source: BookSourcePart) : SearchIntent
     data class RemoveScopeItem(val scopeName: String) : SearchIntent
-    data class TogglePrecision(val enabled: Boolean) : SearchIntent
+    data class SetMatchMode(val mode: MatchMode) : SearchIntent
     data object ConfirmEmptyScopeAction : SearchIntent
     data object DismissEmptyScopeAction : SearchIntent
     data object OpenSourceManage : SearchIntent

+ 312 - 103
app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt

@@ -20,8 +20,10 @@ 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
 import androidx.compose.material.icons.filled.Book
 import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.GridView
 import androidx.compose.material.icons.filled.Layers
 import androidx.compose.material.icons.filled.PlayArrow
 import androidx.compose.material.icons.filled.Stop
@@ -54,6 +56,7 @@ import io.legado.app.R
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.SearchKeyword
 import io.legado.app.domain.model.BookShelfState
+import io.legado.app.domain.model.MatchMode
 import io.legado.app.ui.main.bookCoverSharedElementKey
 import io.legado.app.ui.main.bookshelf.BookShelfItem
 import io.legado.app.ui.theme.LegadoTheme
@@ -63,12 +66,12 @@ import io.legado.app.ui.theme.adaptiveContentPaddingOnlyVertical
 import io.legado.app.ui.theme.adaptiveHorizontalPadding
 import io.legado.app.ui.widget.components.AppFloatingActionButton
 import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.LoadMoreFooter
 import io.legado.app.ui.widget.components.SearchBar
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.book.SearchBookListItem
 import io.legado.app.ui.widget.components.book.SearchBookPreviewSheet
-import io.legado.app.ui.widget.components.button.SmallIconButton
-import io.legado.app.ui.widget.components.button.SmallTextButton
+import io.legado.app.ui.widget.components.button.series.SmallPlainButton
 import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.card.SelectionItemCard
 import io.legado.app.ui.widget.components.icon.AppIcon
@@ -76,11 +79,11 @@ import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.list.TopFloatingStickyItem
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
+import io.legado.app.ui.widget.components.settingItem.CompactDropdownSettingItem
 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.M3GlassScrollBehavior
-import io.legado.app.ui.widget.components.topbar.TopBarActionButton
 import io.legado.app.ui.widget.components.topbar.TopBarAnimatedActionButton
 import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import io.legado.app.utils.toastOnUi
@@ -100,6 +103,8 @@ fun SearchScreen(
 ) {
     val context = LocalContext.current
     val state by viewModel.uiState.collectAsStateWithLifecycle()
+    val searchLayoutMode by viewModel.searchLayoutMode.collectAsStateWithLifecycle()
+    val isSourceGroupedMode = searchLayoutMode == 1
     var previewBook by remember { mutableStateOf<SearchBook?>(null) }
     var previewSharedCoverKey by remember { mutableStateOf<String?>(null) }
     val listState = rememberLazyListState()
@@ -149,13 +154,15 @@ fun SearchScreen(
         state.hasMore,
         state.isManualStop,
         state.showSuggestions,
+        isSourceGroupedMode,
     ) {
         if (
             shouldLoadMore &&
             !state.isSearching &&
             state.hasMore &&
             !state.isManualStop &&
-            !state.showSuggestions
+            !state.showSuggestions &&
+            !isSourceGroupedMode
         ) {
             viewModel.onIntent(SearchIntent.LoadMore)
         }
@@ -261,32 +268,15 @@ fun SearchScreen(
                         )
                     },
                     actions = {
-                        TopBarActionButton(
-                            onClick = {
-                                viewModel.onIntent(SearchIntent.OpenSourceManage)
-                            },
-                            imageVector = AppIcons.Settings,
-                            contentDescription = stringResource(R.string.book_source_manage)
-                        )
-                        TopBarAnimatedActionButton(
-                            checked = state.isPrecisionSearch,
-                            onCheckedChange = { checked ->
-                                viewModel.onIntent(SearchIntent.TogglePrecision(checked))
-                            },
-                            iconChecked = AppIcons.PrecisionSearch,
-                            iconUnchecked = AppIcons.UnPrecisionSearch,
-                            activeText = stringResource(R.string.precision_search),
-                            inactiveText = stringResource(R.string.search),
-                        )
                         TopBarAnimatedActionButton(
-                            checked = state.selectedSourceTypes.isNotEmpty(),
+                            checked = isSourceGroupedMode || state.matchMode == MatchMode.EXACT || state.selectedSourceTypes.isNotEmpty(),
                             onCheckedChange = {
-                                viewModel.onIntent(SearchIntent.SetTypeSheetVisible(true))
+                                viewModel.onIntent(SearchIntent.SetSettingsSheetVisible(true))
                             },
-                            iconChecked = Icons.Default.Layers,
-                            iconUnchecked = Icons.Default.Layers,
-                            activeText = "搜素类型",
-                            inactiveText = "搜素类型",
+                            iconChecked = AppIcons.Settings,
+                            iconUnchecked = AppIcons.Settings,
+                            activeText = stringResource(R.string.setting),
+                            inactiveText = stringResource(R.string.setting),
                         )
                         TopBarAnimatedActionButton(
                             checked = !state.isAllScope,
@@ -296,7 +286,7 @@ fun SearchScreen(
                             iconChecked = AppIcons.Filter,
                             iconUnchecked = AppIcons.Filter,
                             activeText = stringResource(R.string.screen),
-                            inactiveText = stringResource(R.string.screen),
+                            inactiveText = stringResource(R.string.screen)
                         )
                     },
                     scrollBehavior = scrollBehavior
@@ -315,12 +305,13 @@ fun SearchScreen(
                         placeholder = searchLabel,
                         trailingIcon = {
                             if (queryInput.isNotEmpty()) {
-                                TopBarActionButton(
+                                SmallPlainButton(
+                                    modifier = Modifier.padding(horizontal = 8.dp),
                                     onClick = {
                                         queryInput = ""
                                         viewModel.onIntent(SearchIntent.UpdateQuery(""))
                                     },
-                                    imageVector = AppIcons.Close,
+                                    icon = AppIcons.Close,
                                     contentDescription = stringResource(R.string.clear)
                                 )
                             }
@@ -394,52 +385,124 @@ fun SearchScreen(
                         }
 
                         if (state.results.isNotEmpty()) {
-                            LazyColumn(
+                            val sourceGroupedResults = remember(state.results) {
+                                state.results
+                                    .groupBy { it.book.origin }
+                                    .map { (origin, books) ->
+                                        SourceGroup(
+                                            origin = origin,
+                                            sourceName = books.firstOrNull()?.book?.originName?.takeIf { it.isNotBlank() }
+                                                ?: origin,
+                                            items = books
+                                        )
+                                    }
+                            }
+
+                            AnimatedContent(
+                                targetState = isSourceGroupedMode,
+                                label = "SearchLayoutTransition",
                                 modifier = Modifier.fillMaxSize(),
-                                state = listState,
-                                contentPadding = adaptiveContentPaddingOnlyVertical(
-                                    top = 48.dp,
-                                    bottom = 8.dp
-                                ),
-                                verticalArrangement = Arrangement.spacedBy(6.dp)
-                            ) {
-                                itemsIndexed(
-                                    items = state.results,
-                                    key = { index, item -> "${item.book.origin}:${item.book.bookUrl}:$index" }
-                                ) { 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,
-                                                    sharedCoverKey
+                            ) { isSourceGrouped ->
+                                if (isSourceGrouped) {
+                                    LazyColumn(
+                                        modifier = Modifier.fillMaxSize(),
+                                        contentPadding = adaptiveContentPaddingOnlyVertical(
+                                            top = 48.dp,
+                                            bottom = 8.dp
+                                        ),
+                                        verticalArrangement = Arrangement.spacedBy(4.dp),
+                                    ) {
+                                        sourceGroupedResults.forEachIndexed { groupIndex, group ->
+                                            item(key = "header_${group.origin}") {
+                                                SearchSourceSection(
+                                                    sourceName = group.sourceName,
+                                                    items = group.items,
+                                                    onClickBook = { book, coverKey ->
+                                                        viewModel.onIntent(
+                                                            SearchIntent.OpenSearchBook(
+                                                                book,
+                                                                coverKey
+                                                            )
+                                                        )
+                                                    },
+                                                    onLongClickBook = { book, coverKey ->
+                                                        previewBook = book
+                                                        previewSharedCoverKey = coverKey
+                                                    },
+                                                    onViewAll = {
+                                                        viewModel.onIntent(
+                                                            SearchIntent.ExpandSource(
+                                                                group.origin,
+                                                                group.sourceName
+                                                            )
+                                                        )
+                                                    },
+                                                    sharedTransitionScope = sharedTransitionScope,
+                                                    animatedVisibilityScope = animatedVisibilityScope,
+                                                    sourceSectionIndex = groupIndex,
                                                 )
+                                            }
+                                        }
+
+                                        item {
+                                            SearchResultFooter(
+                                                isSearching = state.isSearching,
+                                                hasMore = state.hasMore,
+                                                hasResult = true,
+                                                committedQuery = state.committedQuery,
+                                                onLoadMore = { viewModel.onIntent(SearchIntent.LoadMore) },
                                             )
-                                        },
-                                        onLongClick = { book, coverKey ->
-                                            previewBook = book
-                                            previewSharedCoverKey = coverKey
-                                        },
-                                        sharedTransitionScope = sharedTransitionScope,
-                                        animatedVisibilityScope = animatedVisibilityScope,
-                                        sharedCoverKey = sharedCoverKey
-                                    )
-                                }
-
-                                item {
-                                    SearchResultFooter(
-                                        isSearching = state.isSearching,
-                                        hasMore = state.hasMore,
-                                        hasResult = true,
-                                        committedQuery = state.committedQuery,
-                                        onLoadMore = { viewModel.onIntent(SearchIntent.LoadMore) },
-                                    )
+                                        }
+                                    }
+                                } else {
+                                    LazyColumn(
+                                        modifier = Modifier.fillMaxSize(),
+                                        state = listState,
+                                        contentPadding = adaptiveContentPaddingOnlyVertical(
+                                            top = 48.dp,
+                                            bottom = 8.dp
+                                        ),
+                                        verticalArrangement = Arrangement.spacedBy(8.dp)
+                                    ) {
+                                        itemsIndexed(
+                                            items = state.results,
+                                            key = { index, item -> "${item.book.origin}:${item.book.bookUrl}:$index" }
+                                        ) { 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,
+                                                            sharedCoverKey
+                                                        )
+                                                    )
+                                                },
+                                                onLongClick = { book, coverKey ->
+                                                    previewBook = book
+                                                    previewSharedCoverKey = coverKey
+                                                },
+                                                sharedTransitionScope = sharedTransitionScope,
+                                                animatedVisibilityScope = animatedVisibilityScope,
+                                                sharedCoverKey = sharedCoverKey
+                                            )
+                                        }
+
+                                        item {
+                                            SearchResultFooter(
+                                                isSearching = state.isSearching,
+                                                hasMore = state.hasMore,
+                                                hasResult = true,
+                                                committedQuery = state.committedQuery,
+                                                onLoadMore = { viewModel.onIntent(SearchIntent.LoadMore) },
+                                            )
+                                        }
+                                    }
                                 }
                             }
                         }
@@ -502,7 +565,7 @@ fun SearchScreen(
         },
         title = stringResource(R.string.draw),
         textProvider = {
-            if (wasPrecisionSearch) {
+            if (wasMatchMode == MatchMode.EXACT) {
                 stringResource(R.string.search_empty_scope_disable_precision, scopeDisplay)
             } else {
                 stringResource(R.string.search_empty_scope_switch_all, scopeDisplay)
@@ -525,46 +588,101 @@ fun SearchScreen(
         sources = state.enabledSources,
         selectedSources = state.selectedScopeSourceUrls,
         onToggleSource = { viewModel.onIntent(SearchIntent.ToggleScopeSource(it)) },
-        isSourceScope = state.isSourceScope
+        isSourceScope = state.isSourceScope,
+        onConfirm = { viewModel.onIntent(SearchIntent.OpenSourceManage) },
     )
 
     AppModalBottomSheet(
-        show = state.showTypeSheet,
-        onDismissRequest = { viewModel.onIntent(SearchIntent.SetTypeSheetVisible(false)) },
-        title = "搜素类型",
+        show = state.showSettingsSheet,
+        onDismissRequest = { viewModel.onIntent(SearchIntent.SetSettingsSheetVisible(false)) },
+        title = stringResource(R.string.setting),
     ) {
-        Column {
-            SelectionItemCard(
-                title = stringResource(R.string.all),
-                isSelected = state.selectedSourceTypes.isEmpty(),
-                containerColor = LegadoTheme.colorScheme.onSheetContent,
-                inSelectionMode = true,
-                onToggleSelection = {
-                    if (state.selectedSourceTypes.isNotEmpty()) {
-                        state.selectedSourceTypes.forEach {
-                            viewModel.onIntent(SearchIntent.ToggleSourceType(it))
-                        }
+        Column(
+            verticalArrangement = Arrangement.spacedBy(8.dp)
+        ) {
+            CompactDropdownSettingItem(
+                title = stringResource(R.string.layout_mode),
+                selectedValue = searchLayoutMode.toString(),
+                displayEntries = arrayOf(
+                    stringResource(R.string.search_layout_source_grouped),
+                    stringResource(R.string.search_layout_list)
+                ),
+                entryValues = arrayOf("1", "0"),
+                imageVector = if (isSourceGroupedMode) Icons.Default.GridView else Icons.AutoMirrored.Outlined.FormatListBulleted,
+                onValueChange = { newValue ->
+                    if (newValue.toInt() != searchLayoutMode) {
+                        viewModel.toggleSearchLayout()
                     }
                 }
             )
 
-            Spacer(modifier = Modifier.height(8.dp))
+            CompactDropdownSettingItem(
+                title = stringResource(R.string.precision_search),
+                selectedValue = state.matchMode.value.toString(),
+                displayEntries = arrayOf(
+                    stringResource(R.string.precision_search),
+                    stringResource(R.string.search)
+                ),
+                entryValues = arrayOf(
+                    MatchMode.EXACT.value.toString(),
+                    MatchMode.DEFAULT.value.toString()
+                ),
+                imageVector = if (state.matchMode == MatchMode.EXACT) AppIcons.PrecisionSearch else AppIcons.UnPrecisionSearch,
+                onValueChange = { newValue ->
+                    val mode = MatchMode.of(newValue.toInt())
+                    if (mode != state.matchMode) {
+                        viewModel.onIntent(SearchIntent.SetMatchMode(mode))
+                    }
+                }
+            )
 
-            listOf(
-                0 to stringResource(R.string.noval),
-                2 to stringResource(R.string.manga),
-                1 to stringResource(R.string.audio),
-            ).forEach { (type, label) ->
+            Row(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .padding(vertical = 12.dp),
+                verticalAlignment = Alignment.CenterVertically
+            ) {
+                AppIcon(Icons.Default.Layers, contentDescription = null)
+                Spacer(modifier = Modifier.width(8.dp))
+                AppText(
+                    text = "搜索类型",
+                    style = LegadoTheme.typography.titleSmall
+                )
+            }
+
+            Column(
+                modifier = Modifier.padding(horizontal = 16.dp),
+                verticalArrangement = Arrangement.spacedBy(4.dp)
+            ) {
                 SelectionItemCard(
-                    title = label,
-                    isSelected = state.selectedSourceTypes.contains(type),
+                    title = stringResource(R.string.all),
+                    isSelected = state.selectedSourceTypes.isEmpty(),
                     containerColor = LegadoTheme.colorScheme.onSheetContent,
                     inSelectionMode = true,
                     onToggleSelection = {
-                        viewModel.onIntent(SearchIntent.ToggleSourceType(type))
+                        if (state.selectedSourceTypes.isNotEmpty()) {
+                            state.selectedSourceTypes.forEach {
+                                viewModel.onIntent(SearchIntent.ToggleSourceType(it))
+                            }
+                        }
                     }
                 )
-                Spacer(modifier = Modifier.height(4.dp))
+
+                listOf(
+                    0 to stringResource(R.string.noval),
+                    2 to stringResource(R.string.manga),
+                    1 to stringResource(R.string.audio),
+                ).forEach { (type, label) ->
+                    SelectionItemCard(
+                        title = label,
+                        isSelected = state.selectedSourceTypes.contains(type),
+                        containerColor = LegadoTheme.colorScheme.onSheetContent,
+                        inSelectionMode = true,
+                        onToggleSelection = {
+                            viewModel.onIntent(SearchIntent.ToggleSourceType(type))
+                        }
+                    )
+                }
             }
 
             Spacer(modifier = Modifier.height(20.dp))
@@ -588,6 +706,24 @@ fun SearchScreen(
             viewModel.onAddToShelf(book)
         },
     )
+
+    ExpandedSourceSheet(
+        show = state.expandedSourceUrl != null,
+        sourceName = state.expandedSourceName ?: "",
+        books = state.expandedSourceBooks,
+        isLoading = state.expandedSourceLoading,
+        isEnd = state.expandedSourceEnd,
+        errorMsg = state.expandedSourceError,
+        onDismiss = { viewModel.onIntent(SearchIntent.DismissExpandedSource) },
+        onLoadMore = { viewModel.onIntent(SearchIntent.LoadMoreExpandedSource) },
+        onBookClick = { book, coverKey ->
+            viewModel.onIntent(SearchIntent.OpenExpandedSourceBook(book, coverKey))
+        },
+        onBookLongClick = { book, coverKey ->
+            previewBook = book
+            previewSharedCoverKey = coverKey
+        },
+    )
 }
 
 private data class SearchFloatingSummary(
@@ -653,10 +789,10 @@ private fun SearchSuggestionPanel(
                 }
 
                 if (state.history.isNotEmpty()) {
-                    SmallTextButton(
+                    SmallPlainButton(
                         onClick = onClearHistory,
                         text = stringResource(R.string.clear_all),
-                        imageVector = Icons.Default.Close
+                        icon = Icons.Default.Close
                     )
                 }
             }
@@ -685,9 +821,9 @@ private fun SearchSuggestionPanel(
                     title = history.word,
                     onToggleSelection = { onUseHistory(history.word) },
                     trailingAction = {
-                        SmallIconButton(
+                        SmallPlainButton(
                             onClick = { onDeleteHistory(history) },
-                            imageVector = Icons.Default.Close,
+                            icon = Icons.Default.Close,
                             contentDescription = stringResource(R.string.delete)
                         )
                     }
@@ -747,3 +883,76 @@ private fun SearchResultFooter(
         }
     }
 }
+
+private data class SourceGroup(
+    val origin: String,
+    val sourceName: String,
+    val items: List<SearchResultItemUi>,
+)
+
+@Composable
+private fun ExpandedSourceSheet(
+    show: Boolean,
+    sourceName: String,
+    books: List<SearchBook>,
+    isLoading: Boolean,
+    isEnd: Boolean,
+    errorMsg: String?,
+    onDismiss: () -> Unit,
+    onLoadMore: () -> Unit,
+    onBookClick: (SearchBook, String?) -> Unit,
+    onBookLongClick: ((SearchBook, String?) -> Unit)? = null,
+) {
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismiss,
+        title = sourceName,
+    ) {
+        val listState = rememberLazyListState()
+
+        val shouldLoadMore by remember {
+            derivedStateOf {
+                val total = listState.layoutInfo.totalItemsCount
+                val last = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
+                total > 0 && last >= total - 3
+            }
+        }
+
+        LaunchedEffect(shouldLoadMore, isLoading, isEnd) {
+            if (shouldLoadMore && !isLoading && !isEnd) {
+                onLoadMore()
+            }
+        }
+
+        LazyColumn(
+            state = listState,
+            modifier = Modifier.fillMaxSize(),
+            contentPadding = adaptiveContentPaddingOnlyVertical(
+                top = 8.dp,
+                bottom = 16.dp
+            ),
+            verticalArrangement = Arrangement.spacedBy(8.dp),
+        ) {
+            items(
+                items = books,
+                key = { it.bookUrl },
+            ) { book ->
+                SearchBookListItem(
+                    book = book,
+                    shelfState = BookShelfState.NOT_IN_SHELF,
+                    onClick = { onBookClick(book, null) },
+                    onLongClick = onBookLongClick,
+                )
+            }
+
+            item {
+                LoadMoreFooter(
+                    isLoading = isLoading,
+                    errorMsg = errorMsg,
+                    isEnd = isEnd,
+                    onRetry = onLoadMore,
+                )
+            }
+        }
+    }
+}

+ 111 - 0
app/src/main/java/io/legado/app/ui/book/search/SearchSourceSection.kt

@@ -0,0 +1,111 @@
+package io.legado.app.ui.book.search
+
+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.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowForward
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+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.theme.adaptiveHorizonalPadding
+import io.legado.app.ui.theme.adaptiveHorizontalPadding
+import io.legado.app.ui.theme.fadingEdge
+import io.legado.app.ui.widget.components.book.SearchBookGridItem
+import io.legado.app.ui.widget.components.button.series.SmallTonalButton
+import io.legado.app.ui.widget.components.card.TextCard
+import io.legado.app.ui.widget.components.text.AppText
+
+@OptIn(ExperimentalSharedTransitionApi::class)
+@Composable
+fun SearchSourceSection(
+    sourceName: String,
+    items: List<SearchResultItemUi>,
+    onClickBook: (SearchBook, String?) -> Unit,
+    onLongClickBook: ((SearchBook, String?) -> Unit)? = null,
+    onViewAll: (() -> Unit)? = null,
+    modifier: Modifier = Modifier,
+    sharedTransitionScope: SharedTransitionScope? = null,
+    animatedVisibilityScope: AnimatedVisibilityScope? = null,
+    sourceSectionIndex: Int = 0,
+) {
+    if (items.isEmpty()) return
+
+    Column(modifier = modifier) {
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .adaptiveHorizontalPadding()
+                .padding(vertical = 12.dp),
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            AppText(
+                text = sourceName,
+                style = LegadoTheme.typography.titleSmall,
+                fontWeight = FontWeight.Bold,
+                maxLines = 1,
+                overflow = TextOverflow.Ellipsis,
+            )
+            TextCard(
+                text = "${items.size}",
+                modifier = Modifier
+                    .padding(start = 12.dp)
+            )
+
+            Spacer(modifier = Modifier.weight(1f))
+
+            if (onViewAll != null) {
+                SmallTonalButton(
+                    onClick = onViewAll,
+                    icon = Icons.AutoMirrored.Filled.ArrowForward
+                )
+            }
+        }
+
+        val lazyListState = rememberLazyListState()
+        LazyRow(
+            state = lazyListState,
+            modifier = Modifier
+                .fillMaxWidth()
+                .fadingEdge(lazyListState, gradientWidth = 16.dp),
+            horizontalArrangement = Arrangement.spacedBy(12.dp),
+            contentPadding = adaptiveHorizonalPadding(),
+        ) {
+            itemsIndexed(
+                items = items,
+                key = { index, item -> "${item.book.origin}:${item.book.bookUrl}:$index" }
+            ) { index, item ->
+                val sharedCoverKey = bookCoverSharedElementKey(
+                    item.book.bookUrl,
+                    "search_source:$sourceSectionIndex:$index"
+                )
+                SearchBookGridItem(
+                    book = item.book,
+                    shelfState = item.shelfState,
+                    onClick = { onClickBook(item.book, sharedCoverKey) },
+                    onLongClick = onLongClickBook,
+                    modifier = Modifier.width(100.dp),
+                    sharedTransitionScope = sharedTransitionScope,
+                    animatedVisibilityScope = animatedVisibilityScope,
+                    sharedCoverKey = sharedCoverKey,
+                )
+            }
+        }
+    }
+}

+ 149 - 25
app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt

@@ -2,50 +2,73 @@ package io.legado.app.ui.book.search
 
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
-import io.legado.app.constant.PreferKey
 import io.legado.app.data.entities.BookSourcePart
 import io.legado.app.data.entities.SearchBook
+import io.legado.app.data.local.preferences.LocalPreferencesKeys
+import io.legado.app.data.local.preferences.LocalPreferencesRepository
 import io.legado.app.data.repository.SearchRepository
 import io.legado.app.domain.model.BookSearchScope
+import io.legado.app.domain.model.MatchMode
 import io.legado.app.domain.usecase.AddToBookshelfUseCase
 import io.legado.app.domain.usecase.BookSearchControl
 import io.legado.app.domain.usecase.BookSearchRequest
 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.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.utils.getPrefBoolean
-import io.legado.app.utils.putPrefBoolean
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.catch
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.flatMapLatest
 import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
-import splitties.init.appCtx
 
 @OptIn(ExperimentalCoroutinesApi::class)
 class SearchViewModel(
     private val repository: SearchRepository,
     private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase,
     private val searchBooksUseCase: SearchBooksUseCase,
+    private val exploreBooksUseCase: ExploreBooksUseCase,
     private val addToBookshelfUseCase: AddToBookshelfUseCase,
+    private val localPreferencesRepository: LocalPreferencesRepository,
 ) : ViewModel() {
 
+    val searchLayoutMode = localPreferencesRepository
+        .getPreference(LocalPreferencesKeys.SEARCH_LAYOUT_MODE, 0)
+        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
+
+    fun toggleSearchLayout() {
+        viewModelScope.launch {
+            val newMode = if (searchLayoutMode.value == 0) 1 else 0
+            localPreferencesRepository.updatePreference(
+                LocalPreferencesKeys.SEARCH_LAYOUT_MODE, newMode
+            )
+        }
+    }
+
+    private val matchModeFlow = localPreferencesRepository
+        .getPreference(LocalPreferencesKeys.MATCH_MODE, MatchMode.DEFAULT.value)
+        .distinctUntilChanged()
+        .map { MatchMode.of(it) }
+
     private val _uiState = MutableStateFlow(
         SearchUiState(
-            isPrecisionSearch = appCtx.getPrefBoolean(PreferKey.precisionSearch),
             scopeDisplay = SearchScope(AppConfig.searchScope).display,
-            scopeDisplayNames = SearchScope(AppConfig.searchScope).displayNames,
+            scopeDisplayNames = SearchScope(AppConfig.searchScope).displayNames.toImmutableList(),
             isAllScope = SearchScope(AppConfig.searchScope).isAll(),
             isSourceScope = SearchScope(AppConfig.searchScope).isSource(),
         )
@@ -72,6 +95,7 @@ class SearchViewModel(
         observeBookshelf()
         observeQueryHistory()
         observeQueryBookshelfHints()
+        observeMatchMode()
     }
 
     fun onAddToShelf(book: SearchBook) {
@@ -153,8 +177,8 @@ class SearchViewModel(
                 _uiState.update { it.copy(showScopeSheet = intent.visible) }
             }
 
-            is SearchIntent.SetTypeSheetVisible -> {
-                _uiState.update { it.copy(showTypeSheet = intent.visible) }
+            is SearchIntent.SetSettingsSheetVisible -> {
+                _uiState.update { it.copy(showSettingsSheet = intent.visible) }
             }
 
             is SearchIntent.ToggleSourceType -> {
@@ -184,9 +208,12 @@ class SearchViewModel(
                 syncScopeState(restartSearch = true, oldScope = oldScope)
             }
 
-            is SearchIntent.TogglePrecision -> {
-                appCtx.putPrefBoolean(PreferKey.precisionSearch, intent.enabled)
-                _uiState.update { it.copy(isPrecisionSearch = intent.enabled) }
+            is SearchIntent.SetMatchMode -> {
+                viewModelScope.launch {
+                    localPreferencesRepository.updatePreference(
+                        LocalPreferencesKeys.MATCH_MODE, intent.mode.value
+                    )
+                }
                 restartCommittedSearchIfNeeded()
             }
 
@@ -197,6 +224,61 @@ class SearchViewModel(
 
             SearchIntent.OpenSourceManage -> emitEffect(SearchEffect.OpenSourceManage)
 
+            is SearchIntent.ExpandSource -> {
+                _uiState.update {
+                    it.copy(
+                        expandedSourceUrl = intent.sourceUrl,
+                        expandedSourceName = intent.sourceName,
+                        expandedSourceBooks = persistentListOf(),
+                        expandedSourceLoading = true,
+                        expandedSourceEnd = false,
+                        expandedSourceError = null,
+                        expandedSourcePage = 1,
+                    )
+                }
+                loadExpandedSourcePage(intent.sourceUrl, page = 1)
+            }
+
+            SearchIntent.DismissExpandedSource -> {
+                _uiState.update {
+                    it.copy(
+                        expandedSourceUrl = null,
+                        expandedSourceName = null,
+                        expandedSourceBooks = persistentListOf(),
+                        expandedSourceLoading = false,
+                        expandedSourceEnd = false,
+                        expandedSourceError = null,
+                        expandedSourcePage = 1,
+                    )
+                }
+            }
+
+            SearchIntent.LoadMoreExpandedSource -> {
+                val state = _uiState.value
+                val sourceUrl = state.expandedSourceUrl ?: return
+                if (state.expandedSourceLoading || state.expandedSourceEnd) return
+                _uiState.update {
+                    it.copy(
+                        expandedSourceLoading = true,
+                        expandedSourceError = null
+                    )
+                }
+                loadExpandedSourcePage(sourceUrl, page = state.expandedSourcePage)
+            }
+
+            is SearchIntent.OpenExpandedSourceBook -> {
+                emitEffect(
+                    SearchEffect.OpenBookInfo(
+                        name = intent.book.name,
+                        author = intent.book.author,
+                        bookUrl = intent.book.bookUrl,
+                        origin = intent.book.origin,
+                        coverPath = intent.book.coverUrl,
+                        sharedCoverKey = intent.sharedCoverKey,
+                    )
+                )
+            }
+
             is SearchIntent.SaveScrollState -> {
                 _uiState.update {
                     it.copy(
@@ -233,7 +315,7 @@ class SearchViewModel(
             repository.enabledGroups
                 .catch { emit(emptyList()) }
                 .collect { groups ->
-                    _uiState.update { it.copy(enabledGroups = groups) }
+                    _uiState.update { it.copy(enabledGroups = groups.toImmutableList()) }
                 }
         }
     }
@@ -243,7 +325,7 @@ class SearchViewModel(
             repository.enabledSources
                 .catch { emit(emptyList()) }
                 .collect { sources ->
-                    _uiState.update { it.copy(enabledSources = sources) }
+                    _uiState.update { it.copy(enabledSources = sources.toImmutableList()) }
                 }
         }
     }
@@ -255,7 +337,7 @@ class SearchViewModel(
                 .collect { keys ->
                     bookshelfKeys.value = keys
                     _uiState.update { state ->
-                        state.copy(results = state.results.withShelfState(keys))
+                        state.copy(results = state.results.withShelfState(keys).toImmutableList())
                     }
                 }
         }
@@ -269,7 +351,7 @@ class SearchViewModel(
                 .flatMapLatest { repository.searchHistory(it) }
                 .catch { emit(emptyList()) }
                 .collect { history ->
-                    _uiState.update { it.copy(history = history) }
+                    _uiState.update { it.copy(history = history.toImmutableList()) }
                 }
         }
     }
@@ -282,11 +364,19 @@ class SearchViewModel(
                 .flatMapLatest { repository.searchBookshelf(it) }
                 .catch { emit(emptyList()) }
                 .collect { books ->
-                    _uiState.update { it.copy(bookshelfHints = books) }
+                    _uiState.update { it.copy(bookshelfHints = books.toImmutableList()) }
                 }
         }
     }
 
+    private fun observeMatchMode() {
+        viewModelScope.launch {
+            matchModeFlow.collect { mode ->
+                _uiState.update { it.copy(matchMode = mode) }
+            }
+        }
+    }
+
     private fun updateQuery(query: String, showSuggestions: Boolean) {
         val currentState = _uiState.value
         val isSameQuery = currentState.query == query
@@ -319,7 +409,7 @@ class SearchViewModel(
         _uiState.update {
             it.copy(
                 committedQuery = keyword,
-                results = emptyList(),
+                results = persistentListOf(),
                 isManualStop = false,
                 hasMore = true,
                 processedSources = 0,
@@ -362,7 +452,7 @@ class SearchViewModel(
                             keyword = keyword,
                             page = page,
                             scope = BookSearchScope(searchScope.toString()),
-                            precision = _uiState.value.isPrecisionSearch,
+                            matchMode = _uiState.value.matchMode,
                             concurrency = OtherConfig.threadCount,
                             types = _uiState.value.selectedSourceTypes.takeIf { it.isNotEmpty() },
                         ),
@@ -395,7 +485,7 @@ class SearchViewModel(
                     it.copy(
                         results = buildSearchResultItems(
                             shelf = bookshelfKeys.value,
-                        ),
+                        ).toImmutableList(),
                         processedSources = event.processedSources,
                         totalSources = event.totalSources,
                     )
@@ -407,7 +497,7 @@ class SearchViewModel(
                     val emptyAction = if (searchResultBooks.isEmpty() && event.isEmpty && !searchScope.isAll()) {
                         SearchEmptyScopeAction(
                             scopeDisplay = searchScope.display,
-                            wasPrecisionSearch = state.isPrecisionSearch,
+                            wasMatchMode = state.matchMode,
                         )
                     } else {
                         null
@@ -441,7 +531,7 @@ class SearchViewModel(
             it.copy(
                 query = "",
                 committedQuery = "",
-                results = emptyList(),
+                results = persistentListOf(),
                 processedSources = 0,
                 totalSources = 0,
                 isSearching = false,
@@ -497,9 +587,12 @@ class SearchViewModel(
         val action = _uiState.value.emptyScopeAction ?: return
         _uiState.update { it.copy(emptyScopeAction = null) }
 
-        if (action.wasPrecisionSearch) {
-            appCtx.putPrefBoolean(PreferKey.precisionSearch, false)
-            _uiState.update { it.copy(isPrecisionSearch = false) }
+        if (action.wasMatchMode == MatchMode.EXACT) {
+            viewModelScope.launch {
+                localPreferencesRepository.updatePreference(
+                    LocalPreferencesKeys.MATCH_MODE, MatchMode.DEFAULT.value
+                )
+            }
         } else {
             searchScope.update("")
             syncScopeState()
@@ -523,7 +616,7 @@ class SearchViewModel(
         _uiState.update {
             it.copy(
                 scopeDisplay = searchScope.display,
-                scopeDisplayNames = searchScope.displayNames,
+                scopeDisplayNames = searchScope.displayNames.toImmutableList(),
                 selectedScopeSourceUrls = searchScope.sourceUrls.toSet(),
                 isAllScope = searchScope.isAll(),
                 isSourceScope = searchScope.isSource(),
@@ -571,6 +664,37 @@ class SearchViewModel(
         }
     }
 
+    private fun loadExpandedSourcePage(sourceUrl: String, page: Int) {
+        viewModelScope.launch {
+            val keyword = _uiState.value.committedQuery
+            try {
+                val result = exploreBooksUseCase.execute(
+                    sourceUrl = sourceUrl,
+                    moduleUrl = null,
+                    args = null,
+                    page = page,
+                    key = keyword,
+                )
+                val newBooks = result.books
+                _uiState.update {
+                    it.copy(
+                        expandedSourceBooks = (it.expandedSourceBooks + newBooks).toImmutableList(),
+                        expandedSourceLoading = false,
+                        expandedSourceEnd = newBooks.isEmpty(),
+                        expandedSourcePage = page + 1,
+                    )
+                }
+            } catch (e: Exception) {
+                _uiState.update {
+                    it.copy(
+                        expandedSourceLoading = false,
+                        expandedSourceError = e.message ?: "Unknown error",
+                    )
+                }
+            }
+        }
+    }
+
     private fun emitEffect(effect: SearchEffect) {
         _effects.tryEmit(effect)
     }

+ 15 - 14
app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt

@@ -34,11 +34,9 @@ import androidx.compose.material3.Card
 import androidx.compose.material3.CardDefaults
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
-import io.legado.app.ui.widget.components.AppFloatingActionButton
 import androidx.compose.material3.HorizontalDivider
 import androidx.compose.material3.Icon
 import androidx.compose.material3.ListItem
-import io.legado.app.ui.widget.components.progressIndicator.AppLinearProgressIndicator
 import androidx.compose.material3.ListItemDefaults
 import androidx.compose.material3.animateFloatingActionButton
 import androidx.compose.runtime.Composable
@@ -57,20 +55,23 @@ import androidx.compose.ui.unit.dp
 import io.legado.app.data.entities.SearchContentHistory
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.adaptiveHorizontalPadding
+import io.legado.app.ui.widget.components.AppFloatingActionButton
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.EmptyMessage
 import io.legado.app.ui.widget.components.SearchBar
-import io.legado.app.ui.widget.components.button.MediumOutlinedButton
-import io.legado.app.ui.widget.components.button.SmallAnimatedActionButton
-import io.legado.app.ui.widget.components.button.SmallIconButton
-import io.legado.app.ui.widget.components.topbar.TopBarAnimatedActionButton
-import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
+import io.legado.app.ui.widget.components.button.series.MediumOutlinedButton
+import io.legado.app.ui.widget.components.button.series.SmallPlainButton
+import io.legado.app.ui.widget.components.button.series.SmallToggleButton
+import io.legado.app.ui.widget.components.button.series.ToggleStyle
 import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
+import io.legado.app.ui.widget.components.progressIndicator.AppLinearProgressIndicator
 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.TopBarAnimatedActionButton
+import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import kotlinx.coroutines.launch
 import org.koin.androidx.compose.koinViewModel
 
@@ -302,14 +303,14 @@ fun SearchHistoryList(
                 color = LegadoTheme.colorScheme.primary,
                 modifier = Modifier.align(Alignment.Center)
             )
-            SmallAnimatedActionButton(
+            SmallToggleButton(
                 modifier = Modifier.align(Alignment.CenterEnd),
                 checked = onlyThisBook,
                 onCheckedChange = { onToggleScope() },
+                style = ToggleStyle.Tonal,
                 iconChecked = Icons.Default.Book,
-                iconUnchecked = Icons.Default.CollectionsBookmark,
-                activeText = "仅本书",
-                inactiveText = "所有记录"
+                icon = Icons.Default.CollectionsBookmark,
+                text = "仅本书"
             )
         }
 
@@ -338,9 +339,9 @@ fun SearchHistoryList(
                             Icon(Icons.Default.History, contentDescription = null)
                         },
                         trailingContent = {
-                            SmallIconButton(
+                            SmallPlainButton(
                                 onClick = { onDeleteHistory(item) },
-                                imageVector = Icons.Default.Close,
+                                icon = Icons.Default.Close,
                                 contentDescription = "删除"
                             )
                         },
@@ -361,7 +362,7 @@ fun SearchHistoryList(
                         MediumOutlinedButton(
                             onClick = onClearHistory,
                             modifier = Modifier.fillMaxWidth(0.6f),
-                            imageVector = Icons.Outlined.DeleteSweep,
+                            icon = Icons.Outlined.DeleteSweep,
                             text = "清除搜索历史"
                         )
                     }

+ 5 - 3
app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt

@@ -95,7 +95,8 @@ import io.legado.app.ui.widget.components.FabMenuItem
 import io.legado.app.ui.widget.components.SelectionBottomBar
 import io.legado.app.ui.widget.components.bookmark.BookmarkEditSheet
 import io.legado.app.ui.widget.components.bookmark.BookmarkItem
-import io.legado.app.ui.widget.components.button.SmallOutlinedIconToggleButton
+import io.legado.app.ui.widget.components.button.series.SmallToggleButton
+import io.legado.app.ui.widget.components.button.series.ToggleStyle
 import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.divider.PillDivider
@@ -476,10 +477,11 @@ fun TocScreen(
 
                         if (pagerState.currentPage == 0 && hasVolumes) {
                             Box {
-                                SmallOutlinedIconToggleButton(
+                                SmallToggleButton(
                                     checked = showVolumeMenu,
                                     onCheckedChange = { showVolumeMenu = it },
-                                    imageVector = Icons.AutoMirrored.Filled.FormatListBulleted,
+                                    style = ToggleStyle.Outlined,
+                                    icon = Icons.AutoMirrored.Filled.FormatListBulleted,
                                     contentDescription = stringResource(R.string.volume_management)
                                 )
                                 RoundDropdownMenu(

+ 3 - 13
app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt

@@ -5,24 +5,15 @@ import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Delete
-import androidx.compose.material.icons.filled.FileOpen
-import androidx.compose.material3.AlertDialog
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
-import androidx.compose.material3.Icon
-import androidx.compose.material3.OutlinedButton
 import androidx.compose.material3.SnackbarHostState
 import androidx.compose.material3.SnackbarResult
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
 import androidx.compose.material3.rememberModalBottomSheetState
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
@@ -48,7 +39,7 @@ import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.widget.components.ActionItem
 import io.legado.app.ui.widget.components.DraggableSelectionHandler
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
-import io.legado.app.ui.widget.components.button.SmallIconButton
+import io.legado.app.ui.widget.components.button.series.SmallPlainButton
 import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
 import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
 import io.legado.app.ui.widget.components.icon.AppIcons
@@ -59,7 +50,6 @@ import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.rules.RuleEditFields
 import io.legado.app.ui.widget.components.rules.RuleEditSheet
 import io.legado.app.ui.widget.components.rules.RuleListScaffold
-import io.legado.app.ui.widget.components.text.AppText
 import org.koin.androidx.compose.koinViewModel
 import sh.calvin.reorderable.rememberReorderableLazyListState
 
@@ -350,9 +340,9 @@ fun TxtRuleScreen(
                         onEnabledChange = { enabled -> viewModel.update(item.rule.copy(enable = enabled)) },
                         onClickEdit = { editingRule = item.rule; showEditSheet = true },
                         trailingAction = {
-                            SmallIconButton(
+                            SmallPlainButton(
                                 onClick = { showDeleteRuleDialog = item.rule },
-                                imageVector = AppIcons.Delete
+                                icon = AppIcons.Delete
                             )
                         }
                     )

+ 5 - 5
app/src/main/java/io/legado/app/ui/config/coverConfig/CoverRuleConfigSheet.kt

@@ -23,7 +23,7 @@ import io.legado.app.R
 import io.legado.app.help.DefaultData
 import io.legado.app.model.BookCover
 import io.legado.app.ui.widget.components.AppTextField
-import io.legado.app.ui.widget.components.button.MediumIconButton
+import io.legado.app.ui.widget.components.button.series.MediumPlainButton
 import io.legado.app.ui.widget.components.checkBox.CheckboxItem
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.utils.toastOnUi
@@ -59,8 +59,8 @@ fun CoverRuleConfigSheet(
         onDismissRequest = onDismissRequest,
         title = stringResource(R.string.cover_rule),
         startAction = {
-            MediumIconButton(
-                imageVector = Icons.Default.SettingsBackupRestore,
+            MediumPlainButton(
+                icon = Icons.Default.SettingsBackupRestore,
                 onClick = {
                     DefaultData.coverRule.let {
                         enable = it.enable
@@ -72,8 +72,8 @@ fun CoverRuleConfigSheet(
             )
         },
         endAction = {
-            MediumIconButton(
-                imageVector = Icons.Default.Save,
+            MediumPlainButton(
+                icon = Icons.Default.Save,
                 onClick = {
                     if (searchUrl.isBlank() || coverRule.isBlank()) {
                         appCtx.toastOnUi(R.string.cover_rule_fields_required)

+ 5 - 10
app/src/main/java/io/legado/app/ui/config/otherConfig/DirectLinkUploadBottomSheet.kt

@@ -2,7 +2,6 @@ package io.legado.app.ui.config.otherConfig
 
 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.fillMaxWidth
 import androidx.compose.foundation.layout.height
@@ -16,12 +15,8 @@ import androidx.compose.material.icons.filled.ContentCopy
 import androidx.compose.material.icons.filled.ContentPaste
 import androidx.compose.material.icons.filled.Download
 import androidx.compose.material.icons.filled.MoreVert
-import androidx.compose.material3.Button
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedButton
-import androidx.compose.material3.TextButton
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
@@ -39,7 +34,7 @@ import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
-import io.legado.app.ui.widget.components.button.MediumIconButton
+import io.legado.app.ui.widget.components.button.series.MediumPlainButton
 import io.legado.app.ui.widget.components.checkBox.CheckboxItem
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
@@ -70,18 +65,18 @@ fun DirectLinkUploadBottomSheet(
         show = show,
         title = stringResource(R.string.direct_link_upload_config),
         startAction = {
-            MediumIconButton(
+            MediumPlainButton(
                 onClick = {
                     viewModel.testRule { result -> showTestResult = result }
                 },
-                imageVector = Icons.Default.Checklist
+                icon = Icons.Default.Checklist
             )
         },
         endAction = {
             Box {
-                MediumIconButton(
+                MediumPlainButton(
                     onClick = { showMenu = true },
-                    imageVector = Icons.Default.MoreVert
+                    icon = Icons.Default.MoreVert
                 )
                 RoundDropdownMenu(
                     expanded = showMenu,

+ 3 - 3
app/src/main/java/io/legado/app/ui/config/themeConfig/BackgroundImageManageSheet.kt

@@ -27,7 +27,7 @@ import androidx.compose.ui.unit.dp
 import coil.compose.AsyncImage
 import io.legado.app.R
 import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.widget.components.button.SmallTonalIconButton
+import io.legado.app.ui.widget.components.button.series.SmallTonalButton
 import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
 import io.legado.app.ui.widget.components.icon.AppIcon
@@ -114,13 +114,13 @@ fun BackgroundImageManageSheet(
                             contentScale = ContentScale.Crop
                         )
                     }
-                    SmallTonalIconButton(
+                    SmallTonalButton(
                         onClick = { viewModel.removeBackground(isDark) },
                         modifier = Modifier
                             .align(Alignment.TopEnd)
                             .padding(8.dp)
                             .size(32.dp),
-                        imageVector = Icons.Default.Close
+                        icon = Icons.Default.Close
                     )
                 }
             }

+ 14 - 14
app/src/main/java/io/legado/app/ui/config/themeConfig/LabelColorManageSheet.kt

@@ -5,21 +5,19 @@ import androidx.compose.foundation.layout.Row
 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.Add
 import androidx.compose.material.icons.filled.AutoAwesome
 import androidx.compose.material.icons.filled.Delete
 import androidx.compose.material.icons.filled.Edit
 import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.MaterialTheme
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.toMutableStateList
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
+import androidx.compose.runtime.toMutableStateList
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
@@ -30,8 +28,8 @@ import androidx.core.graphics.ColorUtils
 import io.legado.app.R
 import io.legado.app.help.config.TagColorGenerator
 import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.widget.components.button.MediumOutlinedIconButton
-import io.legado.app.ui.widget.components.button.SmallIconButton
+import io.legado.app.ui.widget.components.button.series.MediumOutlinedButton
+import io.legado.app.ui.widget.components.button.series.SmallPlainButton
 import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
@@ -57,7 +55,7 @@ fun LabelColorManageSheet(
         onDismissRequest = onDismissRequest,
         title = stringResource(R.string.theme_config_manage_label_colors),
         startAction = {
-            MediumOutlinedIconButton(
+            MediumOutlinedButton(
                 onClick = {
                     val baseColor = if (themeColor != 0) Color(themeColor) else primaryColor
                     val generatedColors = TagColorGenerator.generateTagColors(baseColor)
@@ -65,23 +63,25 @@ fun LabelColorManageSheet(
                     tagColors.addAll(generatedColors)
                     ThemeConfig.saveCustomTagColors(tagColors)
                 },
-                imageVector = Icons.Default.AutoAwesome
+                icon = Icons.Default.AutoAwesome
             )
         },
         endAction = {
-            MediumOutlinedIconButton(
+            MediumOutlinedButton(
                 onClick = {
                     tagColors.add(TagColorPair(0, 0))
                     editingIndex = tagColors.size - 1
                     editingTextColor = 0
                     showColorPicker = true
                 },
-                imageVector = Icons.Default.Add
+                icon = Icons.Default.Add
             )
         }
     ) {
         LazyColumn(
-            modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp),
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(bottom = 24.dp),
             verticalArrangement = Arrangement.spacedBy(8.dp)
         ) {
             items(tagColors.size) { index ->
@@ -110,20 +110,20 @@ fun LabelColorManageSheet(
                         Row(
                             horizontalArrangement = Arrangement.spacedBy(4.dp)
                         ) {
-                            SmallIconButton(
+                            SmallPlainButton(
                                 onClick = {
                                     editingIndex = index
                                     editingTextColor = colorPair.textColor
                                     showColorPicker = true
                                 },
-                                imageVector = Icons.Default.Edit
+                                icon = Icons.Default.Edit
                             )
-                            SmallIconButton(
+                            SmallPlainButton(
                                 onClick = {
                                     tagColors.removeAt(index)
                                     ThemeConfig.saveCustomTagColors(tagColors)
                                 },
-                                imageVector = Icons.Default.Delete
+                                icon = Icons.Default.Delete
                             )
                         }
                     }

+ 3 - 3
app/src/main/java/io/legado/app/ui/config/themeConfig/NavIconManageSheet.kt

@@ -30,7 +30,7 @@ import androidx.compose.ui.unit.dp
 import coil.compose.AsyncImage
 import io.legado.app.R
 import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.widget.components.button.SmallTonalIconButton
+import io.legado.app.ui.widget.components.button.series.SmallTonalButton
 import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
@@ -123,13 +123,13 @@ fun NavIconManageSheet(
                                         modifier = Modifier.fillMaxSize(),
                                         contentScale = ContentScale.Fit
                                     )
-                                    SmallTonalIconButton(
+                                    SmallTonalButton(
                                         onClick = { dest.onSetPath("") },
                                         modifier = Modifier
                                             .align(Alignment.TopEnd)
                                             .padding(4.dp)
                                             .size(24.dp),
-                                        imageVector = Icons.Default.Close
+                                        icon = Icons.Default.Close
                                     )
                                 }
                             } else {

+ 7 - 7
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt

@@ -88,7 +88,7 @@ import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.SplicedColumnGroup
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
-import io.legado.app.ui.widget.components.button.SmallIconButton
+import io.legado.app.ui.widget.components.button.series.SmallPlainButton
 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
@@ -222,8 +222,8 @@ fun ThemeConfigScreen(
                                 style = LegadoTheme.typography.labelLargeEmphasized,
                                 modifier = Modifier.weight(1f)
                             )
-                            SmallIconButton(
-                                imageVector = AppIcons.Close,
+                            SmallPlainButton(
+                                icon = AppIcons.Close,
                                 contentDescription = "关闭",
                                 onClick = {
                                     viewModel.setShowThemeRefactorTip(false)
@@ -759,8 +759,8 @@ fun ThemeConfigScreen(
         onDismissRequest = { showFontSheet = false },
         title = stringResource(R.string.font_setting),
         startAction = {
-            SmallIconButton(
-                imageVector = Icons.Default.Delete,
+            SmallPlainButton(
+                icon = Icons.Default.Delete,
                 contentDescription = stringResource(R.string.clear),
                 onClick = {
                     ThemeConfig.appFontPath = null
@@ -769,8 +769,8 @@ fun ThemeConfigScreen(
             )
         },
         endAction = {
-            SmallIconButton(
-                imageVector = Icons.Default.Add,
+            SmallPlainButton(
+                icon = Icons.Default.Add,
                 contentDescription = stringResource(R.string.select_folder),
                 onClick = { fontFolderLauncher.launch(null) }
             )

+ 3 - 3
app/src/main/java/io/legado/app/ui/config/themeManage/EditThemeSheet.kt

@@ -31,7 +31,7 @@ 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.button.MediumIconButton
+import io.legado.app.ui.widget.components.button.series.MediumPlainButton
 import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.settingItem.CompactClickableSettingItem
@@ -60,13 +60,13 @@ fun EditThemeSheet(
         onDismissRequest = onDismissRequest,
         title = stringResource(R.string.theme_manage_edit_theme),
         endAction = {
-            MediumIconButton(
+            MediumPlainButton(
                 onClick = {
                     if (name.isNotBlank()) {
                         onSave(name, data)
                     }
                 },
-                imageVector = Icons.Default.Done,
+                icon = Icons.Default.Done,
                 contentDescription = "Save"
             )
         }

+ 7 - 7
app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageScreen.kt

@@ -47,7 +47,7 @@ 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.alert.AppAlertDialog
-import io.legado.app.ui.widget.components.button.SmallIconButton
+import io.legado.app.ui.widget.components.button.series.SmallPlainButton
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
 import io.legado.app.ui.widget.components.text.AppText
@@ -393,17 +393,17 @@ private fun SavedThemeItem(
                     horizontalArrangement = Arrangement.End,
                     verticalAlignment = Alignment.CenterVertically
                 ) {
-                    SmallIconButton(
+                    SmallPlainButton(
                         onClick = onEdit,
-                        imageVector = Icons.Default.Edit
+                        icon = Icons.Default.Edit
                     )
-                    SmallIconButton(
+                    SmallPlainButton(
                         onClick = onExport,
-                        imageVector = Icons.Default.Share
+                        icon = Icons.Default.Share
                     )
-                    SmallIconButton(
+                    SmallPlainButton(
                         onClick = onDelete,
-                        imageVector = Icons.Default.Delete
+                        icon = Icons.Default.Delete
                     )
                 }
             }

+ 0 - 168
app/src/main/java/io/legado/app/ui/dict/rule/DictRuleEditSheet.kt

@@ -1,168 +0,0 @@
-package io.legado.app.ui.dict.rule
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.NoteAdd
-import androidx.compose.material.icons.filled.Close
-import androidx.compose.material.icons.filled.ContentPaste
-import androidx.compose.material.icons.filled.MoreVert
-import androidx.compose.material.icons.filled.Save
-import androidx.compose.material3.CenterAlignedTopAppBar
-import androidx.compose.material3.DropdownMenu
-import androidx.compose.material3.DropdownMenuItem
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.TopAppBarDefaults
-import androidx.compose.runtime.Composable
-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.graphics.Color
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import io.legado.app.R
-import io.legado.app.data.entities.DictRule
-import io.legado.app.ui.widget.components.AppFloatingActionButton
-import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
-import io.legado.app.ui.widget.components.text.AppText
-import kotlinx.coroutines.launch
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun DictRuleEditSheet(
-    rule: DictRule?,
-    onDismissRequest: () -> Unit,
-    onSave: (DictRule) -> Unit,
-    onCopy: (DictRule) -> Unit,
-    onPaste: () -> DictRule?
-) {
-    val scope = rememberCoroutineScope()
-
-    var name by remember(rule) { mutableStateOf(rule?.name ?: "") }
-    var urlRule by remember(rule) { mutableStateOf(rule?.urlRule ?: "") }
-    var showRule by remember(rule) { mutableStateOf(rule?.showRule ?: "") }
-    var showMenu by remember { mutableStateOf(false) }
-
-    AppModalBottomSheet(
-        show = true,
-        onDismissRequest = onDismissRequest
-    ) {
-        Box(
-            modifier = Modifier
-                .fillMaxWidth()
-        ) {
-            Column(
-                modifier = Modifier
-                    .fillMaxWidth()
-                    .verticalScroll(rememberScrollState())
-            ) {
-                CenterAlignedTopAppBar(
-                    title = { AppText(stringResource(R.string.dict_rule)) },
-                    navigationIcon = {
-                        IconButton(onClick = onDismissRequest) {
-                            Icon(
-                                Icons.Default.Close,
-                                contentDescription = stringResource(R.string.cancel)
-                            )
-                        }
-                    },
-                    actions = {
-                        IconButton(onClick = { showMenu = true }) {
-                            Icon(Icons.Default.MoreVert, contentDescription = "More")
-                        }
-                        DropdownMenu(
-                            expanded = showMenu,
-                            onDismissRequest = { showMenu = false }
-                        ) {
-                            DropdownMenuItem(
-                                text = { AppText(stringResource(R.string.copy_rule)) },
-                                leadingIcon = { Icon(Icons.AutoMirrored.Filled.NoteAdd, null) },
-                                onClick = {
-                                    onCopy(
-                                        DictRule(
-                                            name,
-                                            urlRule,
-                                            showRule,
-                                            enabled = rule?.enabled ?: true
-                                        )
-                                    )
-                                    showMenu = false
-                                }
-                            )
-                            DropdownMenuItem(
-                                text = { AppText(stringResource(R.string.paste_rule)) },
-                                leadingIcon = { Icon(Icons.Default.ContentPaste, null) },
-                                onClick = {
-                                    scope.launch {
-                                        onPaste()?.let {
-                                            name = it.name
-                                            urlRule = it.urlRule
-                                            showRule = it.showRule
-                                        }
-                                    }
-                                    showMenu = false
-                                }
-                            )
-                        }
-                    },
-                    colors = TopAppBarDefaults.topAppBarColors(
-                        containerColor = Color.Transparent
-                    )
-                )
-
-                Column(
-                    modifier = Modifier
-                        .padding(horizontal = 16.dp)
-                        .padding(bottom = 96.dp),
-                    verticalArrangement = Arrangement.spacedBy(8.dp)
-                ) {
-                    OutlinedTextField(
-                        modifier = Modifier.fillMaxWidth(),
-                        value = name,
-                        onValueChange = { name = it },
-                        label = { AppText(stringResource(R.string.name)) },
-                        singleLine = true
-                    )
-                    OutlinedTextField(
-                        modifier = Modifier.fillMaxWidth(),
-                        value = urlRule,
-                        onValueChange = { urlRule = it },
-                        label = { AppText(stringResource(R.string.url_rule)) }
-                    )
-                    OutlinedTextField(
-                        modifier = Modifier.fillMaxWidth(),
-                        value = showRule,
-                        onValueChange = { showRule = it },
-                        label = { AppText(stringResource(R.string.show_rule)) },
-                        minLines = 3
-                    )
-                }
-            }
-
-            AppFloatingActionButton(
-                onClick = {
-                    onSave(
-                        rule?.copy(name = name, urlRule = urlRule, showRule = showRule)
-                            ?: DictRule(name = name, urlRule = urlRule, showRule = showRule)
-                    )
-                },
-                modifier = Modifier
-                    .align(Alignment.BottomEnd)
-                    .padding(16.dp),
-                icon = Icons.Default.Save
-            )
-        }
-    }
-}

+ 4 - 9
app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt

@@ -10,13 +10,10 @@ import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Delete
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.SnackbarHostState
 import androidx.compose.material3.SnackbarResult
-import androidx.compose.material3.rememberModalBottomSheetState
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.collectAsState
@@ -41,7 +38,7 @@ import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.widget.components.ActionItem
 import io.legado.app.ui.widget.components.DraggableSelectionHandler
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
-import io.legado.app.ui.widget.components.button.SmallIconButton
+import io.legado.app.ui.widget.components.button.series.SmallPlainButton
 import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
 import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
 import io.legado.app.ui.widget.components.icon.AppIcons
@@ -77,7 +74,6 @@ fun DictRuleScreen(
     var showDeleteRuleDialog by remember { mutableStateOf<DictRule?>(null) }
     var showUrlInput by remember { mutableStateOf(false) }
 
-    var showFilePickerSheet by remember { mutableStateOf(false) }
     var showImportSheet by remember { mutableStateOf(false) }
     var showExportSheet by remember { mutableStateOf(false) }
 
@@ -90,7 +86,6 @@ fun DictRuleScreen(
     val clipboardManager = LocalClipboard.current
     val snackbarHostState = remember { SnackbarHostState() }
     val importState by viewModel.importState.collectAsStateWithLifecycle()
-    val sheetState = rememberModalBottomSheetState()
 
     LaunchedEffect(Unit) {
         viewModel.events.collect { event ->
@@ -276,7 +271,7 @@ fun DictRuleScreen(
             }),
             ActionItem(
                 text = stringResource(R.string.export),
-                onClick = { showFilePickerSheet = true })
+                onClick = { showExportSheet = true })
         ),
         onDeleteSelected = { ids ->
             @Suppress("UNCHECKED_CAST")
@@ -320,9 +315,9 @@ fun DictRuleScreen(
                         onEnabledChange = { enabled -> viewModel.update(item.rule.copy(enabled = enabled)) },
                         onClickEdit = { editingRule = item.rule; showEditSheet = true },
                         trailingAction = {
-                            SmallIconButton(
+                            SmallPlainButton(
                                 onClick = { showDeleteRuleDialog = item.rule },
-                                imageVector = AppIcons.Delete
+                                icon = AppIcons.Delete
                             )
                         }
                     )

+ 1 - 1
app/src/main/java/io/legado/app/ui/main/MainActivity.kt

@@ -111,7 +111,7 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
             context: Context,
             exploreName: String? = null,
             sourceUrl: String,
-            exploreUrl: String? = null
+            exploreUrl: String? = null,
         ): Intent = MainIntent.createExploreShowIntent(context, exploreName, sourceUrl, exploreUrl)
     }
 

+ 1 - 1
app/src/main/java/io/legado/app/ui/main/MainIntent.kt

@@ -134,7 +134,7 @@ object MainIntent {
         context: Context,
         exploreName: String? = null,
         sourceUrl: String,
-        exploreUrl: String? = null
+        exploreUrl: String? = null,
     ): Intent {
         return createLauncherIntent(context).apply {
             putExtra(EXTRA_START_ROUTE, MainRouteConst.ROUTE_EXPLORE_SHOW)

+ 12 - 3
app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt

@@ -19,7 +19,9 @@ import io.legado.app.ui.about.AboutEffect
 import io.legado.app.ui.about.AboutScreen
 import io.legado.app.ui.about.AboutViewModel
 import io.legado.app.ui.book.cache.manage.BookCacheManageRouteScreen
+import io.legado.app.ui.book.explore.ExploreShowIntent
 import io.legado.app.ui.book.explore.ExploreShowScreen
+import io.legado.app.ui.book.explore.ExploreShowViewModel
 import io.legado.app.ui.book.import.local.ImportBookScreen
 import io.legado.app.ui.book.import.remote.RemoteBookScreen
 import io.legado.app.ui.book.info.BookInfoRouteScreen
@@ -39,8 +41,8 @@ import io.legado.app.ui.config.downloadCacheConfig.DownloadCacheConfigScreen
 import io.legado.app.ui.config.otherConfig.OtherConfigScreen
 import io.legado.app.ui.config.readConfig.ReadConfigScreen
 import io.legado.app.ui.config.themeConfig.ThemeConfigScreen
-import io.legado.app.ui.config.translation.TranslationConfigScreen
 import io.legado.app.ui.config.themeManage.ThemeManageScreen
+import io.legado.app.ui.config.translation.TranslationConfigScreen
 import io.legado.app.ui.rss.article.MainRouteRssSort
 import io.legado.app.ui.rss.article.RssSortRouteScreen
 import io.legado.app.ui.rss.favorites.RssFavoritesScreen
@@ -436,10 +438,17 @@ fun MainActivity.mainEntryProvider(
     }
 
     entry<MainRouteExploreShow> { route ->
+        val exploreViewModel = koinViewModel<ExploreShowViewModel>()
+
+        LaunchedEffect(route.sourceUrl, route.exploreUrl, exploreViewModel) {
+            exploreViewModel.onIntent(
+                ExploreShowIntent.InitData(route.sourceUrl, route.exploreUrl)
+            )
+        }
+
         ExploreShowScreen(
+            viewModel = exploreViewModel,
             title = route.title ?: "探索",
-            sourceUrl = route.sourceUrl,
-            exploreUrl = route.exploreUrl,
             onBack = { onNavigateBack() },
             onBookClick = { book, sharedCoverKey ->
                 onNavigateToRoute(

+ 1 - 1
app/src/main/java/io/legado/app/ui/main/MainNavigator.kt

@@ -268,7 +268,7 @@ object MainNavigator {
                     MainRouteExploreShow(
                         title = intent.getStringExtra(MainIntent.EXTRA_EXPLORE_NAME),
                         sourceUrl = sourceUrl,
-                        exploreUrl = intent.getStringExtra(MainIntent.EXTRA_EXPLORE_URL)
+                        exploreUrl = intent.getStringExtra(MainIntent.EXTRA_EXPLORE_URL),
                     )
                 } ?: MainRouteHome
 

+ 10 - 6
app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt

@@ -114,7 +114,9 @@ import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.EmptyMessage
 import io.legado.app.ui.widget.components.SearchBar
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
-import io.legado.app.ui.widget.components.button.SmallOutlinedIconToggleButton
+import io.legado.app.ui.widget.components.button.series.SmallPlainButton
+import io.legado.app.ui.widget.components.button.series.SmallToggleButton
+import io.legado.app.ui.widget.components.button.series.ToggleStyle
 import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.divider.PillHeaderDivider
@@ -557,7 +559,7 @@ fun BookshelfScreen(
                             val showExpandButton by BookshelfConfig.shouldShowExpandButtonState
                             if (showExpandButton) {
                                 Box(modifier = Modifier) {
-                                    SmallOutlinedIconToggleButton(
+                                    SmallToggleButton(
                                         checked = showGroupMenu,
                                         onCheckedChange = {
                                             if (it) {
@@ -566,7 +568,8 @@ fun BookshelfScreen(
                                                 viewModel.dismissOverlay()
                                             }
                                         },
-                                        imageVector = Icons.AutoMirrored.Filled.FormatListBulleted,
+                                        style = ToggleStyle.Outlined,
+                                        icon = Icons.AutoMirrored.Filled.FormatListBulleted,
                                         contentDescription = stringResource(R.string.group_manage)
                                     )
                                     RoundDropdownMenu(
@@ -837,7 +840,7 @@ fun BookshelfScreen(
                                         if (this != null) Modifier.skipToLookaheadSize() else Modifier
                                     }
                                 ),
-                            beyondViewportPageCount = 1,
+                            beyondViewportPageCount = 0,
                             key = { if (it < uiState.groups.size) uiState.groups[it].groupId else it }
                         ) { pageIndex ->
                             val group = uiState.groups.getOrNull(pageIndex)
@@ -1058,9 +1061,10 @@ private fun BookshelfTopBar(
                     },
                     trailingIcon = {
                         if (uiState.searchKey.isNotEmpty()) {
-                            TopBarActionButton(
+                            SmallPlainButton(
+                                modifier = Modifier.padding(start = 12.dp),
                                 onClick = onClearSearch,
-                                imageVector = AppIcons.Close,
+                                icon = AppIcons.Close,
                                 contentDescription = stringResource(R.string.clear)
                             )
                         }

+ 47 - 74
app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt

@@ -41,7 +41,6 @@ import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.ImmutableMap
 import kotlinx.collections.immutable.persistentMapOf
 import kotlinx.collections.immutable.toImmutableList
-import kotlinx.collections.immutable.toImmutableMap
 import kotlinx.collections.immutable.toImmutableSet
 import kotlinx.coroutines.CoroutineDispatcher
 import kotlinx.coroutines.Dispatchers
@@ -196,34 +195,31 @@ class BookshelfViewModel(
         }.distinctUntilChanged().flowOn(Dispatchers.Default)
 
     @OptIn(ExperimentalCoroutinesApi::class)
-    private val allGroupBooksFlow: StateFlow<Map<Long, List<BookUiItem>>> = combine(
-        groupsFlow, sortConfigFlow
-    ) { groups, sortConfig ->
-        groups to sortConfig
-    }.flatMapLatest { (groups, sortConfig) ->
-        if (groups.isEmpty()) {
-            flowOf(emptyMap())
-        } else {
-            val flows = groups.map { group ->
-                bookRepository.flowBookShelfByGroup(group.groupId).map { books ->
-                    group.groupId to bookshelfRepository.sortBooks(
-                        books,
-                        group,
-                        sortConfig.sort,
-                        sortConfig.sortOrder
-                    ).map { it.toUiItem() }
+    private val allGroupBooksImmutableFlow: StateFlow<ImmutableMap<Long, ImmutableList<BookUiItem>>> =
+        combine(groupsFlow, sortConfigFlow) { groups, sortConfig ->
+            groups to sortConfig
+        }.flatMapLatest { (groups, sortConfig) ->
+            if (groups.isEmpty()) {
+                flowOf(persistentMapOf())
+            } else {
+                val flows = groups.map { group ->
+                    bookRepository.flowBookShelfByGroup(group.groupId).map { books ->
+                        group.groupId to bookshelfRepository.sortBooks(
+                            books,
+                            group,
+                            sortConfig.sort,
+                            sortConfig.sortOrder
+                        ).map { it.toUiItem() }.toImmutableList()
+                    }
+                }
+                combine(flows) { results ->
+                    results.fold(persistentMapOf<Long, ImmutableList<BookUiItem>>()) { acc, (id, list) ->
+                        acc.put(id, list)
+                    }
                 }
             }
-            combine(flows) { it.toMap() }
-        }
-    }.distinctUntilChanged()
-        .flowOn(Dispatchers.Default)
-        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
-
-    private val allGroupBooksImmutableFlow: StateFlow<ImmutableMap<Long, ImmutableList<BookUiItem>>> =
-        allGroupBooksFlow.map { map ->
-            map.mapValues { it.value.toImmutableList() }.toImmutableMap()
-        }.flowOn(Dispatchers.Default)
+        }.distinctUntilChanged()
+            .flowOn(Dispatchers.Default)
             .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), persistentMapOf())
 
     private val visibleBooksFlow: Flow<List<BookUiItem>> = combine(
@@ -301,30 +297,21 @@ class BookshelfViewModel(
         }
     }.distinctUntilChanged().flowOn(Dispatchers.Default)
 
-    private val coreInternalStateFlow = combine(
+    private val internalStateFlow = combine(
         groupIdFlow,
         searchKeyFlow,
         searchModeFlow,
         loadingTextFlow,
-        updatingBooksFlow
-    ) { groupId, searchKey, isSearchMode, loadingText, updatingBooks ->
+        combine(updatingBooksFlow, upBooksCountFlow, sortConfigFlow) { a, b, c ->
+            Triple(a, b, c)
+        }
+    ) { groupId, searchKey, isSearchMode, loadingText, (updatingBooks, upBooksCount, sortConfig) ->
         InternalState(
             groupId = groupId,
             searchKey = searchKey,
             isSearchMode = isSearchMode,
             loadingText = loadingText,
             updatingBooks = updatingBooks,
-            upBooksCount = 0,
-            sortConfig = readSortConfig()
-        )
-    }
-
-    private val internalStateFlow = combine(
-        coreInternalStateFlow,
-        upBooksCountFlow,
-        sortConfigFlow
-    ) { core, upBooksCount, sortConfig ->
-        core.copy(
             upBooksCount = upBooksCount,
             sortConfig = sortConfig
         )
@@ -351,35 +338,29 @@ class BookshelfViewModel(
         val pendingSavedBooks: List<BookUiItem>?
     )
 
-    private val editStateFlow = combine(
+    private val interactionStateFlow = combine(
         activeOverlayFlow,
         isEditModeFlow,
         selectedVisibleBookUrlsFlow,
-        isInFolderRootFlow
-    ) { activeOverlay, isEditMode, selectedBookUrls, isInFolderRoot ->
-        EditState(activeOverlay, isEditMode, selectedBookUrls, isInFolderRoot)
-    }
-
-    private data class EditState(
-        val activeOverlay: BookshelfOverlay?,
-        val isEditMode: Boolean,
-        val selectedBookUrls: Set<String>,
-        val isInFolderRoot: Boolean
-    )
-
-    private val interactionStateFlow = combine(
-        editStateFlow,
-        isRefreshingFlow,
-        bookGroupStyleFlow,
-        draggingBooksFlow,
-        pendingSavedBooksFlow
-    ) { editState, isRefreshing, bookGroupStyle, draggingBooks, pendingSavedBooks ->
+        isInFolderRootFlow,
+        isRefreshingFlow
+    ) { activeOverlay, isEditMode, selectedBookUrls, isInFolderRoot, isRefreshing ->
         BookshelfInteractionState(
-            activeOverlay = editState.activeOverlay,
-            isEditMode = editState.isEditMode,
-            selectedBookUrls = editState.selectedBookUrls,
-            isInFolderRoot = editState.isInFolderRoot,
+            activeOverlay = activeOverlay,
+            isEditMode = isEditMode,
+            selectedBookUrls = selectedBookUrls,
+            isInFolderRoot = isInFolderRoot,
             isRefreshing = isRefreshing,
+            bookGroupStyle = 0,
+            draggingBooks = null,
+            pendingSavedBooks = null
+        )
+    }.combine(
+        combine(bookGroupStyleFlow, draggingBooksFlow, pendingSavedBooksFlow) { a, b, c ->
+            Triple(a, b, c)
+        }
+    ) { interaction, (bookGroupStyle, draggingBooks, pendingSavedBooks) ->
+        interaction.copy(
             bookGroupStyle = bookGroupStyle,
             draggingBooks = draggingBooks,
             pendingSavedBooks = pendingSavedBooks
@@ -390,11 +371,6 @@ class BookshelfViewModel(
         GroupPreviewState(persistentMapOf(), persistentMapOf(), 0)
     )
 
-    private val allGroupBooksStateFlow =
-        MutableStateFlow<ImmutableMap<Long, ImmutableList<BookUiItem>>>(
-        persistentMapOf()
-    )
-
     private val dataStateFlow = combine(
         booksFlow,
         groupsFlow,
@@ -403,7 +379,7 @@ class BookshelfViewModel(
         internalStateFlow
     ) { books, groups, allGroups, previews, internal ->
         BookshelfDataCore(books, groups, allGroups, previews, internal)
-    }.combine(allGroupBooksStateFlow) { core, allGroupBooks ->
+    }.combine(allGroupBooksImmutableFlow) { core, allGroupBooks ->
         BookshelfDataState(
             books = core.books,
             groups = core.groups.map { it.toBookGroupUi() },
@@ -529,9 +505,6 @@ class BookshelfViewModel(
         viewModelScope.launch {
             groupPreviewsFlow.collect { groupPreviewsStateFlow.value = it }
         }
-        viewModelScope.launch {
-            allGroupBooksImmutableFlow.collect { allGroupBooksStateFlow.value = it }
-        }
         viewModelScope.launch {
             combine(booksFlow, selectedGroupCanReorderFlow) { books, canReorderBooks ->
                 books to canReorderBooks

+ 4 - 4
app/src/main/java/io/legado/app/ui/main/bookshelf/GroupManageSheet.kt

@@ -26,12 +26,12 @@ import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
 import io.legado.app.data.entities.BookGroup
-import io.legado.app.ui.book.group.GroupEditContent
 import io.legado.app.ui.book.group.GroupDeleteAction
+import io.legado.app.ui.book.group.GroupEditContent
 import io.legado.app.ui.book.group.GroupResetCoverAction
 import io.legado.app.ui.book.group.GroupViewModel
 import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.widget.components.button.SmallIconButton
+import io.legado.app.ui.widget.components.button.series.SmallPlainButton
 import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.utils.move
@@ -95,13 +95,13 @@ fun GroupManageSheet(
         },
         endAction = {
             if (!isEditing) {
-                SmallIconButton(
+                SmallPlainButton(
                     onClick = {
                         editingGroup = null
                         coverPath = null
                         isEditing = true
                     },
-                    imageVector = Icons.Default.Add
+                    icon = Icons.Default.Add
                 )
             } else {
                 GroupResetCoverAction(

+ 7 - 4
app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt

@@ -92,6 +92,9 @@ fun ExploreScreen(
     val context = LocalContext.current
     val activity = context as? AppCompatActivity
     val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+    val listItems by remember(uiState.items, uiState.expandedId, uiState.exploreKinds) {
+        derivedStateOf { viewModel.buildExploreListItems(uiState) }
+    }
     var sourceToDeleteUrl by rememberSaveable { mutableStateOf<String?>(null) }
     val sourceToDelete = remember(sourceToDeleteUrl, uiState.items) {
         uiState.items.firstOrNull { it.bookSourceUrl == sourceToDeleteUrl }
@@ -118,10 +121,10 @@ fun ExploreScreen(
         }
     }
 
-    val stickyHeaderSource by remember(uiState.listItems, uiState.items) {
+    val stickyHeaderSource by remember(listItems, uiState.items) {
         derivedStateOf {
             val firstIndex = listState.firstVisibleItemIndex
-            val item = uiState.listItems.getOrNull(firstIndex)
+            val item = listItems.getOrNull(firstIndex)
             if (item is ExploreListItem.KindRow) {
                 uiState.items.find { it.bookSourceUrl == item.sourceUrl }
             } else {
@@ -178,7 +181,7 @@ fun ExploreScreen(
                 )
             ) {
                 items(
-                    items = uiState.listItems,
+                    items = listItems,
                     key = { it.key }
                 ) { listItem ->
                     when (listItem) {
@@ -268,7 +271,7 @@ fun ExploreScreen(
                     verticalPadding = 8.dp,
                     onClick = {
                         scope.launch {
-                            val index = uiState.listItems.indexOfFirst {
+                            val index = listItems.indexOfFirst {
                                 it is ExploreListItem.Header && it.source.bookSourceUrl == item.bookSourceUrl
                             }
                             if (index >= 0) listState.animateScrollToItem(index)

+ 2 - 5
app/src/main/java/io/legado/app/ui/main/explore/ExploreViewModel.kt

@@ -29,7 +29,6 @@ import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.flow.flowOn
-import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
@@ -42,7 +41,6 @@ class ExploreViewModel(
 
     private val _uiState = MutableStateFlow(ExploreUiState())
     val uiState: StateFlow<ExploreUiState> = _uiState
-        .map { state -> state.copy(listItems = buildExploreListItems(state)) }
         .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ExploreUiState())
     private val _effects = MutableSharedFlow<ExploreEffect>(extraBufferCapacity = 8)
     val effects = _effects.asSharedFlow()
@@ -200,11 +198,10 @@ class ExploreViewModel(
         val exploreKinds: ImmutableList<ExploreKind> = persistentListOf(),
         val kindDisplayNames: ImmutableMap<String, String> = persistentMapOf(),
         val kindValues: ImmutableMap<String, String> = persistentMapOf(),
-        val loadingKinds: Boolean = false,
-        val listItems: ImmutableList<ExploreListItem> = persistentListOf()
+        val loadingKinds: Boolean = false
     ) : ListUiState<BookSourcePart>
 
-    private fun buildExploreListItems(state: ExploreUiState): ImmutableList<ExploreListItem> {
+    fun buildExploreListItems(state: ExploreUiState): ImmutableList<ExploreListItem> {
         if (state.items.isEmpty()) return persistentListOf()
         val expandedId = state.expandedId
         val kindRows = if (expandedId != null) {

+ 11 - 11
app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleManageSheet.kt

@@ -37,7 +37,7 @@ import io.legado.app.ui.main.homepage.manage.SetListPage
 import io.legado.app.ui.main.homepage.manage.SourceBrowseDetailPage
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
-import io.legado.app.ui.widget.components.button.SmallIconButton
+import io.legado.app.ui.widget.components.button.series.SmallPlainButton
 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
@@ -127,38 +127,38 @@ fun <T> HomepageModuleManageSheet(
         },
         startAction = {
             if (showCustomSetAddModules) {
-                SmallIconButton(
+                SmallPlainButton(
                     onClick = { showCustomSetAddModules = false },
-                    imageVector = Icons.AutoMirrored.Filled.ArrowBack
+                    icon = Icons.AutoMirrored.Filled.ArrowBack
                 )
             } else if (browsingSourceUrl != null || showSourceBrowser) {
-                SmallIconButton(
+                SmallPlainButton(
                     onClick = {
                         if (browsingDetail) browsingDetail = false
                         else if (showSourceBrowser) showSourceBrowser = false
                         else browsingSourceUrl = null
                     },
-                    imageVector = Icons.AutoMirrored.Filled.ArrowBack
+                    icon = Icons.AutoMirrored.Filled.ArrowBack
                 )
             } else if (selectingSetUrl != null) {
-                SmallIconButton(
+                SmallPlainButton(
                     onClick = { selectingSetUrl = null },
-                    imageVector = Icons.AutoMirrored.Filled.ArrowBack
+                    icon = Icons.AutoMirrored.Filled.ArrowBack
                 )
             }
         },
         endAction = {
             if (browsingDetail && browseTab == 2 && browseModuleType == "buttonGroup" && selectedKindTitles.isNotEmpty()) {
-                SmallIconButton(
+                SmallPlainButton(
                     onClick = { showAddButtonGroupDialog = true },
-                    imageVector = Icons.Default.Check
+                    icon = Icons.Default.Check
                 )
             } else if ((showSourceBrowser || browsingSourceUrl != null) && !browsingDetail) {
                 var expanded by remember { mutableStateOf(false) }
                 Box {
-                    SmallIconButton(
+                    SmallPlainButton(
                         onClick = { expanded = true },
-                        imageVector = Icons.Default.FilterList
+                        icon = Icons.Default.FilterList
                     )
                     RoundDropdownMenu(
                         expanded = expanded,

+ 5 - 4
app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt

@@ -72,7 +72,7 @@ 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.book.SearchBookPreviewSheet
-import io.legado.app.ui.widget.components.button.SmallTonalIconButton
+import io.legado.app.ui.widget.components.button.series.SmallTonalButton
 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
@@ -82,6 +82,7 @@ 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.flow.collectLatest
 import kotlinx.coroutines.launch
 import org.koin.androidx.compose.koinViewModel
 
@@ -132,7 +133,7 @@ fun HomepageScreen(
     }
 
     LaunchedEffect(viewModel) {
-        viewModel.effects.collect { effect ->
+        viewModel.effects.collectLatest { effect ->
             when (effect) {
                 is HomepageEffect.NavigateToBookInfo ->
                     onBookClick(
@@ -717,9 +718,9 @@ private fun ModuleHeader(
             modifier = Modifier.weight(1f),
         )
         if (onNavigate != null) {
-            SmallTonalIconButton(
+            SmallTonalButton(
                 onClick = onNavigate,
-                imageVector = Icons.AutoMirrored.Filled.ArrowForward
+                icon = Icons.AutoMirrored.Filled.ArrowForward
             )
         }
     }

+ 6 - 0
app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt

@@ -338,6 +338,12 @@ class HomepageViewModel(
         }
     }
 
+    override fun onCleared() {
+        super.onCleared()
+        loadJobs.values.forEach { it.cancel() }
+        loadJobs.clear()
+    }
+
     private suspend fun syncModulesFromSource(source: BookSource) {
         val json = source.homepageModules ?: return
         ensureSetForSource(source.bookSourceUrl, source.bookSourceName)

+ 9 - 9
app/src/main/java/io/legado/app/ui/main/homepage/manage/SetDetailPage.kt

@@ -26,7 +26,7 @@ import io.legado.app.ui.main.homepage.HomepageModuleManageUi
 import io.legado.app.ui.main.homepage.HomepageViewModel
 import io.legado.app.ui.theme.LegadoTheme
 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.button.series.SmallPlainButton
 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
@@ -129,13 +129,13 @@ fun SetDetailPage(
                             }
                         },
                         trailingAction = {
-                            SmallIconButton(
+                            SmallPlainButton(
                                 onClick = { onEditModule(module) },
-                                imageVector = Icons.Default.Edit
+                                icon = Icons.Default.Edit
                             )
-                            SmallIconButton(
+                            SmallPlainButton(
                                 onClick = { onRequestDeleteModule(module.id) },
-                                imageVector = Icons.Default.Delete
+                                icon = Icons.Default.Delete
                             )
                         }
                     )
@@ -160,13 +160,13 @@ fun SetDetailPage(
                         containerColor = if (isEffective) LegadoTheme.colorScheme.surfaceContainerHigh else LegadoTheme.colorScheme.onSheetContent,
                         onEnabledChange = { onToggleModule(module.id, it) },
                         trailingAction = {
-                            SmallIconButton(
+                            SmallPlainButton(
                                 onClick = { onEditModule(module) },
-                                imageVector = Icons.Default.Edit
+                                icon = Icons.Default.Edit
                             )
-                            SmallIconButton(
+                            SmallPlainButton(
                                 onClick = { onRequestDeleteModule(module.id) },
-                                imageVector = Icons.Default.Delete
+                                icon = Icons.Default.Delete
                             )
                         }
                     )

+ 5 - 5
app/src/main/java/io/legado/app/ui/main/homepage/manage/SetListPage.kt

@@ -22,7 +22,7 @@ import io.legado.app.R
 import io.legado.app.ui.main.homepage.HomepageSourceManageUi
 import io.legado.app.ui.theme.LegadoTheme
 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.button.series.SmallPlainButton
 import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
 import io.legado.app.ui.widget.components.divider.PillDivider
 import io.legado.app.utils.move
@@ -85,13 +85,13 @@ fun SetListPage(
                     }
                 },
                 trailingAction = {
-                    SmallIconButton(
+                    SmallPlainButton(
                         onClick = { onRenameSet(set.sourceUrl) },
-                        imageVector = Icons.Default.DriveFileRenameOutline
+                        icon = Icons.Default.DriveFileRenameOutline
                     )
-                    SmallIconButton(
+                    SmallPlainButton(
                         onClick = { onDeleteSet(set.sourceUrl) },
-                        imageVector = Icons.Default.Delete
+                        icon = Icons.Default.Delete
                     )
                 }
             )

+ 11 - 11
app/src/main/java/io/legado/app/ui/main/homepage/manage/SourceBrowseDetailPage.kt

@@ -32,7 +32,7 @@ import io.legado.app.ui.main.homepage.HomepageModuleManageUi
 import io.legado.app.ui.main.homepage.HomepageViewModel
 import io.legado.app.ui.theme.LegadoTheme
 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.button.series.SmallPlainButton
 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
@@ -170,13 +170,13 @@ fun SourceBrowseDetailPage(
                                     }
                                 },
                                 trailingAction = {
-                                    SmallIconButton(
+                                    SmallPlainButton(
                                         onClick = { onEditModule(module) },
-                                        imageVector = Icons.Default.Edit
+                                        icon = Icons.Default.Edit
                                     )
-                                    SmallIconButton(
+                                    SmallPlainButton(
                                         onClick = { onRequestDeleteModule(module.id) },
-                                        imageVector = Icons.Default.Delete
+                                        icon = Icons.Default.Delete
                                     )
                                 }
                             )
@@ -212,13 +212,13 @@ fun SourceBrowseDetailPage(
                                     containerColor = if (isEffective) LegadoTheme.colorScheme.surfaceContainerHigh else LegadoTheme.colorScheme.onSheetContent,
                                     onEnabledChange = { onToggleModule(module.id, it) },
                                     trailingAction = {
-                                        SmallIconButton(
+                                        SmallPlainButton(
                                             onClick = { onEditModule(module) },
-                                            imageVector = Icons.Default.Edit
+                                            icon = Icons.Default.Edit
                                         )
-                                        SmallIconButton(
+                                        SmallPlainButton(
                                             onClick = { onRequestDeleteModule(module.id) },
-                                            imageVector = Icons.Default.Delete
+                                            icon = Icons.Default.Delete
                                         )
                                     }
                                 )
@@ -334,9 +334,9 @@ fun SourceBrowseDetailPage(
                         onToggleSelection = { showKindSelect = true },
                         trailingAction = {
                             if (isButtonGroup && selectedKindTitles.isNotEmpty()) {
-                                SmallIconButton(
+                                SmallPlainButton(
                                     onClick = { onShowAddButtonGroupDialog() },
-                                    imageVector = Icons.Default.Check
+                                    icon = Icons.Default.Check
                                 )
                             }
                         }

+ 15 - 16
app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt

@@ -35,8 +35,6 @@ import androidx.compose.material.icons.filled.Source
 import androidx.compose.material.icons.filled.Web
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.ui.Modifier
@@ -54,11 +52,12 @@ import io.legado.app.ui.replace.ReplaceRuleActivity
 import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.SplicedColumnGroup
-import io.legado.app.ui.widget.components.button.SmallTextButton
+import io.legado.app.ui.widget.components.button.series.SmallPlainButton
 import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
 import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
 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 org.koin.androidx.compose.koinViewModel
 
 
@@ -81,7 +80,7 @@ fun MyScreen(
             GlassMediumFlexibleTopAppBar(
                 title = stringResource(R.string.my),
                 actions = {
-                    IconButton(
+                    TopBarActionButton(
                         onClick = {
                             onNavigate(
                                 PrefClickEvent.ShowMd(
@@ -89,10 +88,10 @@ fun MyScreen(
                                     path = "appHelp"
                                 )
                             )
-                        }
-                    ) {Icon(
-                        Icons.AutoMirrored.Filled.HelpOutline, null)
-                    }
+                        },
+                        imageVector = Icons.AutoMirrored.Filled.HelpOutline,
+                        contentDescription = null
+                    )
                 },
                 scrollBehavior = scrollBehavior
             )
@@ -251,22 +250,22 @@ fun WebServiceSettingBlock(
                     .padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 12.dp),
                 horizontalArrangement = Arrangement.End
             ) {
-                SmallTextButton(
-                    text = stringResource(R.string.copy_url),
-                    imageVector = Icons.Default.ContentCopy,
+                SmallPlainButton(
                     onClick = {
                         onNavigate(PrefClickEvent.CopyUrl(uiState.webServiceAddress))
-                    }
+                    },
+                    icon = Icons.Default.ContentCopy,
+                    text = stringResource(R.string.copy_url)
                 )
 
                 Spacer(modifier = Modifier.width(12.dp))
 
-                SmallTextButton(
-                    text = stringResource(R.string.open_in_browser),
-                    imageVector = Icons.Default.OpenInBrowser,
+                SmallPlainButton(
                     onClick = {
                         onNavigate(PrefClickEvent.OpenUrl(uiState.webServiceAddress))
-                    }
+                    },
+                    icon = Icons.Default.OpenInBrowser,
+                    text = stringResource(R.string.open_in_browser)
                 )
             }
         }

+ 8 - 27
app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditScreen.kt

@@ -5,8 +5,6 @@ import androidx.compose.animation.fadeIn
 import androidx.compose.animation.fadeOut
 import androidx.compose.animation.slideInVertically
 import androidx.compose.animation.slideOutVertically
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
 import androidx.compose.foundation.horizontalScroll
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
@@ -19,71 +17,54 @@ import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.ime
-import androidx.compose.foundation.layout.imePadding
 import androidx.compose.foundation.layout.navigationBarsPadding
 import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Check
 import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.Save
 import androidx.compose.material.icons.filled.Settings
-import androidx.compose.material3.AlertDialog
 import androidx.compose.material3.AssistChip
 import androidx.compose.material3.BottomAppBar
-import androidx.compose.material3.Checkbox
-import androidx.compose.material3.DropdownMenu
-import androidx.compose.material3.DropdownMenuItem
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.ExposedDropdownMenuAnchorType
 import androidx.compose.material3.ExposedDropdownMenuBox
 import androidx.compose.material3.ExposedDropdownMenuDefaults
-import androidx.compose.ui.res.stringResource
-import io.legado.app.R
-import io.legado.app.ui.widget.components.AppFloatingActionButton
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
 import androidx.compose.material3.animateFloatingActionButton
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.State
 import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateMapOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberUpdatedState
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
 import androidx.compose.ui.focus.onFocusChanged
-import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.zIndex
+import io.legado.app.R
+import io.legado.app.ui.widget.components.AppFloatingActionButton
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
-import io.legado.app.ui.widget.components.button.MediumIconButton
 import io.legado.app.ui.widget.components.button.ToggleChip
-import io.legado.app.ui.widget.components.topbar.TopBarActionButton
-import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
+import io.legado.app.ui.widget.components.button.series.MediumPlainButton
 import io.legado.app.ui.widget.components.checkBox.CheckboxItem
-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.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.ui.widget.components.topbar.TopBarNavigationButton
 import org.koin.androidx.compose.koinViewModel
 
 @Composable
@@ -369,9 +350,9 @@ fun GroupSelector(
                 }
             }
         }
-        MediumIconButton(
+        MediumPlainButton(
             onClick = onManageClick,
-            imageVector = Icons.Default.Settings
+            icon = Icons.Default.Settings
         )
     }
 }

+ 13 - 14
app/src/main/java/io/legado/app/ui/rss/article/RssSortScreen.kt

@@ -1,7 +1,6 @@
 package io.legado.app.ui.rss.article
 
 import androidx.activity.compose.BackHandler
-import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.PaddingValues
@@ -11,13 +10,11 @@ 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.ui.input.nestedscroll.nestedScroll
-import androidx.compose.foundation.pager.HorizontalPager
-import androidx.compose.foundation.pager.rememberPagerState
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.items
+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.ArrowBack
 import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
 import androidx.compose.material.icons.automirrored.filled.Login
 import androidx.compose.material.icons.filled.Check
@@ -25,7 +22,6 @@ import androidx.compose.material.icons.filled.CleaningServices
 import androidx.compose.material.icons.filled.Dataset
 import androidx.compose.material.icons.filled.Edit
 import androidx.compose.material.icons.filled.History
-import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.Refresh
 import androidx.compose.material.icons.filled.Style
 import androidx.compose.material3.ExperimentalMaterial3Api
@@ -39,6 +35,7 @@ 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.res.stringResource
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
@@ -51,10 +48,9 @@ import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.adaptiveHorizontalPaddingTab
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.EmptyMessage
-import io.legado.app.ui.widget.components.button.SmallIconButton
-import io.legado.app.ui.widget.components.button.SmallOutlinedIconToggleButton
-import io.legado.app.ui.widget.components.topbar.TopBarActionButton
-import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
+import io.legado.app.ui.widget.components.button.series.SmallPlainButton
+import io.legado.app.ui.widget.components.button.series.SmallToggleButton
+import io.legado.app.ui.widget.components.button.series.ToggleStyle
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.menuItem.MenuItemIcon
@@ -65,6 +61,8 @@ 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.ui.widget.components.topbar.TopBarNavigationButton
 import kotlinx.coroutines.launch
 
 @OptIn(ExperimentalMaterial3Api::class)
@@ -242,10 +240,11 @@ fun RssSortScreen(
 
                             if (BookshelfConfig.shouldShowExpandButton) {
                                 Box {
-                                    SmallOutlinedIconToggleButton(
+                                    SmallToggleButton(
                                         checked = showGroupMenu,
                                         onCheckedChange = { showGroupMenu = it },
-                                        imageVector = Icons.AutoMirrored.Filled.FormatListBulleted,
+                                        style = ToggleStyle.Outlined,
+                                        icon = Icons.AutoMirrored.Filled.FormatListBulleted,
                                         contentDescription = stringResource(R.string.group_manage)
                                     )
                                     RoundDropdownMenu(
@@ -321,9 +320,9 @@ private fun RssReadRecordSheet(
         onDismissRequest = onDismissRequest,
         title = stringResource(R.string.read_record),
         endAction = {
-            SmallIconButton(
+            SmallPlainButton(
                 onClick = onClear,
-                imageVector = Icons.Default.CleaningServices,
+                icon = Icons.Default.CleaningServices,
                 contentDescription = stringResource(R.string.clear)
             )
         }

+ 3 - 3
app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesScreen.kt

@@ -33,7 +33,7 @@ import io.legado.app.data.entities.RssStar
 import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.widget.components.ActionItem
 import io.legado.app.ui.widget.components.EmptyMessage
-import io.legado.app.ui.widget.components.button.SmallIconButton
+import io.legado.app.ui.widget.components.button.series.SmallPlainButton
 import io.legado.app.ui.widget.components.card.SelectionItemCard
 import io.legado.app.ui.widget.components.dialog.TextListInputDialog
 import io.legado.app.ui.widget.components.divider.PillDivider
@@ -229,9 +229,9 @@ fun RssFavoritesScreen(
                             val openAction = {
                                 onOpenRead(rssStar.title, rssStar.origin, rssStar.link, null)
                             }
-                            SmallIconButton(
+                            SmallPlainButton(
                                 onClick = openAction,
-                                imageVector = Icons.AutoMirrored.Filled.OpenInNew,
+                                icon = Icons.AutoMirrored.Filled.OpenInNew,
                                 contentDescription = "Open"
                             )
                         },

+ 3 - 3
app/src/main/java/io/legado/app/ui/rss/read/RssReadRouteScreen.kt

@@ -58,7 +58,7 @@ import io.legado.app.ui.login.SourceLoginActivity
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
-import io.legado.app.ui.widget.components.button.SmallIconButton
+import io.legado.app.ui.widget.components.button.series.SmallPlainButton
 import io.legado.app.ui.widget.components.menuItem.MenuItemIcon
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
@@ -422,9 +422,9 @@ private fun FavoriteEditSheet(
         onDismissRequest = onDismissRequest,
         title = stringResource(R.string.favorite),
         endAction = {
-            SmallIconButton(
+            SmallPlainButton(
                 onClick = onDelete,
-                imageVector = Icons.Default.CleaningServices,
+                icon = Icons.Default.CleaningServices,
                 contentDescription = stringResource(R.string.delete)
             )
         }

+ 3 - 7
app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceScreen.kt

@@ -5,7 +5,6 @@ import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.width
@@ -13,13 +12,10 @@ import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Delete
-import androidx.compose.material3.AlertDialog
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
-import androidx.compose.material3.OutlinedButton
 import androidx.compose.material3.SnackbarHostState
 import androidx.compose.material3.SnackbarResult
-import androidx.compose.material3.TextButton
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.collectAsState
@@ -45,7 +41,7 @@ import io.legado.app.ui.widget.components.ActionItem
 import io.legado.app.ui.widget.components.DraggableSelectionHandler
 import io.legado.app.ui.widget.components.GroupManageBottomSheet
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
-import io.legado.app.ui.widget.components.button.SmallIconButton
+import io.legado.app.ui.widget.components.button.series.SmallPlainButton
 import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
 import io.legado.app.ui.widget.components.dialog.TextListInputDialog
 import io.legado.app.ui.widget.components.divider.PillDivider
@@ -364,9 +360,9 @@ fun RssSourceScreen(
                         onEnabledChange = { enabled -> viewModel.update(item.source.copy(enabled = enabled)) },
                         onClickEdit = { onEditSource(item.source) },
                         trailingAction = {
-                            SmallIconButton(
+                            SmallPlainButton(
                                 onClick = { showDeleteRuleDialog = item.source },
-                                imageVector = Icons.Default.Delete
+                                icon = Icons.Default.Delete
                             )
                         }
                     )

+ 6 - 4
app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubScreen.kt

@@ -17,7 +17,6 @@ import androidx.compose.material.icons.filled.Edit
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
 import androidx.compose.material3.SnackbarHostState
 import androidx.compose.material3.animateFloatingActionButton
 import androidx.compose.runtime.Composable
@@ -46,6 +45,7 @@ import io.legado.app.ui.widget.components.AppFloatingActionButton
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.EmptyMessage
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
+import io.legado.app.ui.widget.components.button.series.SmallPlainButton
 import io.legado.app.ui.widget.components.card.SelectionItemCard
 import io.legado.app.ui.widget.components.checkBox.CheckboxGroupContainer
 import io.legado.app.ui.widget.components.checkBox.CheckboxItem
@@ -165,9 +165,11 @@ fun RuleSubScreen(
                             }
                         },
                         trailingAction = {
-                            IconButton(onClick = { showEditDialog = ruleSub }) {
-                                Icon(Icons.Default.Edit, contentDescription = "Edit")
-                            }
+                            SmallPlainButton(
+                                onClick = { showEditDialog = ruleSub },
+                                icon = Icons.Default.Edit,
+                                contentDescription = "Edit"
+                            )
                         },
                         dropdownContent = { dismiss ->
                             RoundDropdownMenuItem(

+ 8 - 0
app/src/main/java/io/legado/app/ui/theme/AdaptivePadding.kt

@@ -35,6 +35,14 @@ fun Modifier.adaptiveVerticalPadding(): Modifier {
     return this.padding(horizontal = horizontal)
 }
 
+@Composable
+fun adaptiveHorizonalPadding(): PaddingValues {
+    val horizontal = if (ThemeResolver.isMiuixEngine(composeEngine)) 12.dp else 16.dp
+    return PaddingValues(
+        horizontal = horizontal
+    )
+}
+
 @Composable
 fun adaptiveContentPaddingOnlyVertical(
     top: Dp,

+ 41 - 0
app/src/main/java/io/legado/app/ui/widget/components/AppSlider.kt

@@ -0,0 +1,41 @@
+package io.legado.app.ui.widget.components
+
+import androidx.compose.material3.Slider
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import io.legado.app.ui.theme.LegadoTheme.composeEngine
+import io.legado.app.ui.theme.ThemeResolver
+import top.yukonga.miuix.kmp.basic.Slider as MiuixSlider
+
+@Composable
+fun AppSlider(
+    value: Float,
+    onValueChange: (Float) -> Unit,
+    modifier: Modifier = Modifier,
+    enabled: Boolean = true,
+    valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
+    steps: Int = 0,
+    onValueChangeFinished: (() -> Unit)? = null,
+) {
+    if (ThemeResolver.isMiuixEngine(composeEngine)) {
+        MiuixSlider(
+            value = value,
+            onValueChange = onValueChange,
+            modifier = modifier,
+            enabled = enabled,
+            valueRange = valueRange,
+            steps = steps,
+            onValueChangeFinished = onValueChangeFinished
+        )
+    } else {
+        Slider(
+            value = value,
+            onValueChange = onValueChange,
+            modifier = modifier,
+            enabled = enabled,
+            valueRange = valueRange,
+            steps = steps,
+            onValueChangeFinished = onValueChangeFinished
+        )
+    }
+}

+ 4 - 4
app/src/main/java/io/legado/app/ui/widget/components/EmptyMessage.kt

@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.widthIn
 import androidx.compose.foundation.layout.wrapContentSize
-import io.legado.app.ui.widget.components.progressIndicator.AppContainedLoadingIndicator
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
@@ -24,8 +23,9 @@ import androidx.compose.ui.unit.TextUnit
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
 import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.button.series.SmallTonalButton
 import io.legado.app.ui.widget.components.icon.AppIcons
-import io.legado.app.ui.widget.components.button.SmallTonalTextButton
+import io.legado.app.ui.widget.components.progressIndicator.AppContainedLoadingIndicator
 import io.legado.app.ui.widget.components.text.AnimatedTextLine
 
 @Composable
@@ -84,10 +84,10 @@ fun EmptyMessage(
 
         if (buttonText != null && onButtonClick != null) {
             Spacer(modifier = Modifier.height(8.dp))
-            SmallTonalTextButton(
+            SmallTonalButton(
                 onClick = onButtonClick,
                 text = buttonText,
-                imageVector = buttonImageVector
+                icon = buttonImageVector
             )
         }
     }

+ 5 - 5
app/src/main/java/io/legado/app/ui/widget/components/GroupManageBottomSheet.kt

@@ -26,7 +26,7 @@ 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.button.SmallTextButton
+import io.legado.app.ui.widget.components.button.series.SmallPlainButton
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.settingItem.SettingItem
 
@@ -118,13 +118,13 @@ private fun GroupItem(
                 modifier = Modifier.fillMaxWidth(),
                 horizontalArrangement = Arrangement.End
             ) {
-                SmallTextButton(
-                    text = stringResource(id = R.string.ok),
-                    imageVector = Icons.Default.Check,
+                SmallPlainButton(
                     onClick = {
                         onUpdateGroup(group, state.text.toString())
                         expanded = false
-                    }
+                    },
+                    icon = Icons.Default.Check,
+                    text = stringResource(id = R.string.ok)
                 )
             }
         }

+ 5 - 5
app/src/main/java/io/legado/app/ui/widget/components/JsonRawEditor.kt

@@ -15,7 +15,7 @@ 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.button.series.SmallPlainButton
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.utils.GSON
 
@@ -37,17 +37,17 @@ fun JsonRawEditor(
                 style = LegadoTheme.typography.labelMediumEmphasized
             )
             Row {
-                SmallIconButton(
+                SmallPlainButton(
                     onClick = {
                         runCatching {
                             val jsonElement = JsonParser.parseString(value)
                             onValueChange(GSON.toJson(jsonElement))
                         }
                     },
-                    imageVector = Icons.Default.AutoFixHigh,
+                    icon = Icons.Default.AutoFixHigh,
                     contentDescription = "格式化"
                 )
-                SmallIconButton(
+                SmallPlainButton(
                     onClick = {
                         runCatching {
                             val jsonElement = JsonParser.parseString(value)
@@ -55,7 +55,7 @@ fun JsonRawEditor(
                             onValueChange(compactGson.toJson(jsonElement))
                         }
                     },
-                    imageVector = Icons.Default.Compress,
+                    icon = Icons.Default.Compress,
                     contentDescription = "压缩"
                 )
             }

+ 5 - 5
app/src/main/java/io/legado/app/ui/widget/components/ValueStepper.kt

@@ -10,7 +10,7 @@ import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.unit.dp
 import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.widget.components.button.SmallOutlinedIconButton
+import io.legado.app.ui.widget.components.button.series.SmallOutlinedButton
 import io.legado.app.ui.widget.components.card.TextCard
 
 @Composable
@@ -26,12 +26,12 @@ fun ValueStepper(
         verticalAlignment = Alignment.CenterVertically,
         horizontalArrangement = Arrangement.spacedBy(8.dp)
     ) {
-        SmallOutlinedIconButton(
+        SmallOutlinedButton(
             onClick = {
                 val newValue = (value.toInt() - 1).toFloat().coerceIn(valueRange)
                 onValueChange(newValue)
             },
-            imageVector = Icons.Default.Remove
+            icon = Icons.Default.Remove
         )
         TextCard(
             cornerRadius = 8.dp,
@@ -41,12 +41,12 @@ fun ValueStepper(
             backgroundColor = LegadoTheme.colorScheme.surfaceContainer,
             contentColor = LegadoTheme.colorScheme.onSurface
         )
-        SmallOutlinedIconButton(
+        SmallOutlinedButton(
             onClick = {
                 val newValue = (value.toInt() + 1).toFloat().coerceIn(valueRange)
                 onValueChange(newValue)
             },
-            imageVector = Icons.Default.Add
+            icon = Icons.Default.Add
         )
     }
 }

+ 0 - 43
app/src/main/java/io/legado/app/ui/widget/components/button/AnimatedTextButton.kt

@@ -1,43 +0,0 @@
-package io.legado.app.ui.widget.components.button
-
-import androidx.compose.animation.AnimatedContent
-import androidx.compose.animation.animateContentSize
-import androidx.compose.animation.core.tween
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.togetherWith
-import androidx.compose.material3.TextButton
-import io.legado.app.ui.widget.components.progressIndicator.AppContainedLoadingIndicator
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import io.legado.app.ui.widget.components.text.AppText
-
-@Composable
-fun AnimatedTextButton(
-    isLoading: Boolean,
-    onClick: () -> Unit,
-    text: String,
-    modifier: Modifier = Modifier
-) {
-    TextButton(
-        onClick = onClick,
-        enabled = !isLoading,
-        modifier = modifier.animateContentSize()
-    ) {
-        AnimatedContent(
-            targetState = isLoading,
-            contentAlignment = Alignment.Center,
-            transitionSpec = {
-                fadeIn(tween(200)) togetherWith fadeOut(tween(200))
-            },
-            label = "ButtonLoading"
-        ) { loading ->
-            if (loading) {
-                AppContainedLoadingIndicator()
-            } else {
-                AppText(text)
-            }
-        }
-    }
-}

+ 0 - 217
app/src/main/java/io/legado/app/ui/widget/components/button/MediumIconButton.kt

@@ -1,217 +0,0 @@
-package io.legado.app.ui.widget.components.button
-
-import androidx.compose.animation.animateColorAsState
-import androidx.compose.animation.core.tween
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.OutlinedIconButton
-import androidx.compose.material3.Text
-import androidx.compose.material3.TonalToggleButton
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.unit.dp
-import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.theme.LegadoTheme.composeEngine
-import io.legado.app.ui.theme.ThemeResolver
-import top.yukonga.miuix.kmp.theme.MiuixTheme
-import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
-import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton
-import top.yukonga.miuix.kmp.basic.Text as MiuixText
-
-@Composable
-fun MediumIconButton(
-    onClick: () -> Unit,
-    imageVector: ImageVector,
-    tint: Color = LegadoTheme.colorScheme.onSurface,
-    modifier: Modifier = Modifier,
-    enabled: Boolean = true,
-    contentDescription: String? = null
-) {
-    if (ThemeResolver.isMiuixEngine(composeEngine)) {
-        MiuixIconButton(
-            onClick = onClick,
-            modifier = modifier,
-            enabled = enabled
-        ) {
-            MiuixIcon(
-                imageVector = imageVector,
-                contentDescription = contentDescription,
-                tint = tint
-            )
-        }
-    } else {
-        IconButton(
-            onClick = onClick,
-            modifier = modifier,
-            enabled = enabled
-        ) {
-            Icon(
-                imageVector = imageVector,
-                contentDescription = contentDescription,
-                tint = tint
-            )
-        }
-    }
-}
-
-@Composable
-fun MediumOutlinedIconButton(
-    onClick: () -> Unit,
-    imageVector: ImageVector,
-    modifier: Modifier = Modifier,
-    enabled: Boolean = true,
-    contentDescription: String? = null
-) {
-    if (ThemeResolver.isMiuixEngine(composeEngine)) {
-        MiuixIconButton(
-            onClick = onClick,
-            modifier = modifier,
-            enabled = enabled,
-            backgroundColor = LegadoTheme.colorScheme.surfaceContainerHigh
-        ) {
-            MiuixIcon(
-                imageVector = imageVector,
-                contentDescription = contentDescription
-            )
-        }
-    } else {
-        OutlinedIconButton(
-            onClick = onClick,
-            modifier = modifier,
-            enabled = enabled,
-            border = ButtonDefaults.outlinedButtonBorder()
-        ) {
-            Icon(
-                imageVector = imageVector,
-                contentDescription = contentDescription,
-                tint = LegadoTheme.colorScheme.onSurface
-            )
-        }
-    }
-}
-
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
-@Composable
-fun MediumAnimatedActionButton(
-    checked: Boolean,
-    onCheckedChange: (Boolean) -> Unit,
-    iconChecked: ImageVector,
-    iconUnchecked: ImageVector,
-    activeText: String,
-    inactiveText: String,
-    modifier: Modifier = Modifier
-) {
-    if (ThemeResolver.isMiuixEngine(composeEngine)) {
-        val containerColor by animateColorAsState(
-            targetValue = if (checked) MiuixTheme.colorScheme.primaryContainer else MiuixTheme.colorScheme.surfaceContainerHigh,
-            animationSpec = tween(150),
-            label = "MiuixActionButtonContainer"
-        )
-
-        val contentColor by animateColorAsState(
-            targetValue = if (checked) MiuixTheme.colorScheme.onPrimaryContainer else MiuixTheme.colorScheme.onSurface,
-            animationSpec = tween(150),
-            label = "MiuixActionButtonContent"
-        )
-
-        AnimatedActionButtonCore(
-            checked = checked,
-            onCheckedChange = onCheckedChange,
-            iconChecked = iconChecked,
-            iconUnchecked = iconUnchecked,
-            activeText = activeText,
-            inactiveText = inactiveText,
-            modifier = modifier,
-            iconSize = 24.dp,
-            textStyle = LegadoTheme.typography.labelMedium,
-            textStartPadding = 8.dp,
-            contentColor = contentColor,
-            button = { buttonModifier, onToggle, content ->
-                MiuixIconButton(
-                    onClick = { onToggle(!checked) },
-                    modifier = buttonModifier,
-                    backgroundColor = containerColor
-                ) {
-                    Row(
-                        verticalAlignment = Alignment.CenterVertically,
-                        horizontalArrangement = Arrangement.Center,
-                        modifier = Modifier.padding(horizontal = 8.dp),
-                        content = content
-                    )
-                }
-            },
-            icon = { imageVector, iconModifier, tint ->
-                MiuixIcon(
-                    imageVector = imageVector,
-                    contentDescription = null,
-                    modifier = iconModifier,
-                    tint = tint ?: Color.Unspecified
-                )
-            },
-            text = { label, textModifier, style, color ->
-                MiuixText(
-                    text = label,
-                    color = color ?: Color.Unspecified,
-                    style = style,
-                    modifier = textModifier,
-                    maxLines = 1,
-                    softWrap = false
-                )
-            }
-        )
-    } else {
-        AnimatedActionButtonCore(
-            checked = checked,
-            onCheckedChange = onCheckedChange,
-            iconChecked = iconChecked,
-            iconUnchecked = iconUnchecked,
-            activeText = activeText,
-            inactiveText = inactiveText,
-            modifier = modifier,
-            iconSize = 24.dp,
-            textStyle = LegadoTheme.typography.labelMedium,
-            textStartPadding = 8.dp,
-            button = { buttonModifier, onToggle, content ->
-                TonalToggleButton(
-                    checked = checked,
-                    onCheckedChange = onToggle,
-                    modifier = buttonModifier,
-                    contentPadding = PaddingValues(horizontal = 8.dp)
-                ) {
-                    Row(
-                        verticalAlignment = Alignment.CenterVertically,
-                        horizontalArrangement = Arrangement.Center,
-                        content = content
-                    )
-                }
-            },
-            icon = { imageVector, iconModifier, _ ->
-                AnimatedIcon(
-                    imageVector = imageVector,
-                    contentDescription = null,
-                    modifier = iconModifier
-                )
-            },
-            text = { label, textModifier, style, color ->
-                Text(
-                    text = label,
-                    modifier = textModifier,
-                    style = style,
-                    color = color ?: Color.Unspecified,
-                    maxLines = 1,
-                    softWrap = false
-                )
-            }
-        )
-    }
-}

+ 0 - 66
app/src/main/java/io/legado/app/ui/widget/components/button/MediumTextButton.kt

@@ -1,66 +0,0 @@
-package io.legado.app.ui.widget.components.button
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.Icon
-import androidx.compose.material3.OutlinedButton
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.unit.dp
-import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.theme.LegadoTheme.composeEngine
-import io.legado.app.ui.theme.ThemeResolver
-import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
-import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton
-import top.yukonga.miuix.kmp.basic.Text as MiuixText
-
-@Composable
-fun MediumOutlinedButton(
-    onClick: () -> Unit,
-    imageVector: ImageVector,
-    text: String,
-    modifier: Modifier = Modifier,
-    enabled: Boolean = true,
-    contentDescription: String? = null
-) {
-    if (ThemeResolver.isMiuixEngine(composeEngine)) {
-        MiuixIconButton(
-            onClick = onClick,
-            modifier = modifier,
-            enabled = enabled,
-            backgroundColor = LegadoTheme.colorScheme.surfaceContainerHigh
-        ) {
-            Row(
-                verticalAlignment = Alignment.CenterVertically,
-                horizontalArrangement = Arrangement.spacedBy(8.dp)
-            ) {
-                MiuixIcon(
-                    imageVector = imageVector,
-                    contentDescription = contentDescription
-                )
-                MiuixText(text = text)
-            }
-        }
-    } else {
-        OutlinedButton(
-            onClick = onClick,
-            modifier = modifier,
-            enabled = enabled,
-            border = ButtonDefaults.outlinedButtonBorder()
-        ) {
-            Icon(
-                imageVector = imageVector,
-                contentDescription = contentDescription
-            )
-            Text(
-                text = text,
-                modifier = Modifier.padding(start = 8.dp)
-            )
-        }
-    }
-}

+ 0 - 356
app/src/main/java/io/legado/app/ui/widget/components/button/SmallIconButton.kt

@@ -1,356 +0,0 @@
-package io.legado.app.ui.widget.components.button
-
-import androidx.compose.animation.animateColorAsState
-import androidx.compose.animation.core.tween
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
-import androidx.compose.material3.FilledTonalIconButton
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.IconButtonDefaults
-import androidx.compose.material3.IconToggleButtonShapes
-import androidx.compose.material3.LocalMinimumInteractiveComponentSize
-import androidx.compose.material3.OutlinedIconButton
-import androidx.compose.material3.OutlinedIconToggleButton
-import androidx.compose.material3.Text
-import androidx.compose.material3.TonalToggleButton
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.unit.dp
-import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.theme.LegadoTheme.composeEngine
-import io.legado.app.ui.theme.ThemeResolver
-import top.yukonga.miuix.kmp.theme.MiuixTheme
-import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
-import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton
-import top.yukonga.miuix.kmp.basic.Text as MiuixText
-
-private val SmallMiuixButtonSize = 32.dp
-private val SmallMiuixIconSize = 18.dp
-
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
-private fun smallContainerSize() = IconButtonDefaults.extraSmallContainerSize(
-    IconButtonDefaults.IconButtonWidthOption.Uniform
-)
-
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
-private val smallIconSize: androidx.compose.ui.unit.Dp
-    get() = IconButtonDefaults.extraSmallIconSize
-
-@Composable
-private fun SmallNoMinTouchTarget(content: @Composable () -> Unit) {
-    CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
-        content()
-    }
-}
-
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
-@Composable
-fun SmallIconButton(
-    onClick: () -> Unit,
-    imageVector: ImageVector,
-    modifier: Modifier = Modifier,
-    enabled: Boolean = true,
-    contentDescription: String? = null
-) {
-    if (ThemeResolver.isMiuixEngine(composeEngine)) {
-        MiuixIconButton(
-            onClick = onClick,
-            modifier = modifier,
-            enabled = enabled
-        ) {
-            MiuixIcon(
-                imageVector = imageVector,
-                contentDescription = contentDescription,
-                modifier = Modifier.size(16.dp),
-            )
-        }
-    } else {
-        SmallNoMinTouchTarget {
-            IconButton(
-                onClick = onClick,
-                modifier = modifier.size(smallContainerSize()),
-                enabled = enabled,
-                shape = IconButtonDefaults.extraSmallRoundShape,
-            ) {
-                Icon(
-                    imageVector = imageVector,
-                    contentDescription = contentDescription,
-                    modifier = Modifier.size(smallIconSize),
-                )
-            }
-        }
-    }
-}
-
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
-@Composable
-fun SmallOutlinedIconButton(
-    onClick: () -> Unit,
-    imageVector: ImageVector,
-    modifier: Modifier = Modifier,
-    enabled: Boolean = true,
-    contentDescription: String? = null
-) {
-    if (ThemeResolver.isMiuixEngine(composeEngine)) {
-        MiuixIconButton(
-            onClick = onClick,
-            modifier = modifier.size(SmallMiuixButtonSize),
-            enabled = enabled,
-            backgroundColor = LegadoTheme.colorScheme.surfaceContainer
-        ) {
-            MiuixIcon(
-                imageVector = imageVector,
-                contentDescription = contentDescription,
-                modifier = Modifier.size(SmallMiuixIconSize)
-            )
-        }
-    } else {
-        SmallNoMinTouchTarget {
-            OutlinedIconButton(
-                onClick = onClick,
-                modifier = modifier.size(smallContainerSize()),
-                enabled = enabled,
-                shapes = IconButtonDefaults.shapes(),
-                border = ButtonDefaults.outlinedButtonBorder()
-            ) {
-                Icon(
-                    imageVector = imageVector,
-                    contentDescription = contentDescription,
-                    modifier = Modifier.size(smallIconSize)
-                )
-            }
-        }
-    }
-}
-
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
-@Composable
-fun SmallTonalIconButton(
-    onClick: () -> Unit,
-    imageVector: ImageVector,
-    modifier: Modifier = Modifier,
-    enabled: Boolean = true,
-    contentDescription: String? = null
-) {
-    if (ThemeResolver.isMiuixEngine(composeEngine)) {
-        MiuixIconButton(
-            onClick = onClick,
-            modifier = modifier.size(SmallMiuixButtonSize),
-            enabled = enabled,
-            backgroundColor = LegadoTheme.colorScheme.surfaceContainer
-        ) {
-            MiuixIcon(
-                imageVector = imageVector,
-                contentDescription = contentDescription,
-                modifier = Modifier.size(SmallMiuixIconSize)
-            )
-        }
-    } else {
-        SmallNoMinTouchTarget {
-            FilledTonalIconButton(
-                onClick = onClick,
-                modifier = modifier.size(smallContainerSize()),
-                enabled = enabled,
-                shapes = IconButtonDefaults.shapes(),
-                colors = IconButtonDefaults.filledTonalIconButtonColors()
-            ) {
-                Icon(
-                    imageVector = imageVector,
-                    contentDescription = contentDescription,
-                    modifier = Modifier.size(smallIconSize)
-                )
-            }
-        }
-    }
-}
-
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
-@Composable
-fun SmallOutlinedIconToggleButton(
-    checked: Boolean,
-    onCheckedChange: (Boolean) -> Unit,
-    imageVector: ImageVector,
-    modifier: Modifier = Modifier,
-    enabled: Boolean = true,
-    contentDescription: String? = null
-) {
-    if (ThemeResolver.isMiuixEngine(composeEngine)) {
-        val containerColor by animateColorAsState(
-            targetValue = if (checked) LegadoTheme.colorScheme.primaryContainer else LegadoTheme.colorScheme.surfaceContainer,
-            animationSpec = tween(150),
-            label = "MiuixToggleContainerColor"
-        )
-
-        val iconTint by animateColorAsState(
-            targetValue = if (checked) LegadoTheme.colorScheme.onPrimaryContainer else LegadoTheme.colorScheme.onSurfaceVariant,
-            animationSpec = tween(150),
-            label = "MiuixToggleIconTint"
-        )
-
-        MiuixIconButton(
-            onClick = { onCheckedChange(!checked) },
-            modifier = modifier.size(SmallMiuixButtonSize),
-            enabled = enabled,
-            backgroundColor = containerColor
-        ) {
-            MiuixIcon(
-                imageVector = imageVector,
-                contentDescription = contentDescription,
-                tint = iconTint,
-                modifier = Modifier.size(SmallMiuixIconSize)
-            )
-        }
-    } else {
-        val defaultShape = IconButtonDefaults.extraSmallRoundShape
-        val pressedShape = IconButtonDefaults.extraSmallPressedShape
-        val checkedShape = IconButtonDefaults.extraSmallSelectedRoundShape
-
-        val toggleShapes = remember(defaultShape, checkedShape) {
-            IconToggleButtonShapes(
-                shape = defaultShape,
-                pressedShape = pressedShape,
-                checkedShape = checkedShape
-            )
-        }
-
-        SmallNoMinTouchTarget {
-            OutlinedIconToggleButton(
-                checked = checked,
-                onCheckedChange = onCheckedChange,
-                modifier = modifier.size(smallContainerSize()),
-                enabled = enabled,
-                shapes = toggleShapes
-            ) {
-                Icon(
-                    imageVector = imageVector,
-                    contentDescription = contentDescription,
-                    modifier = Modifier.size(smallIconSize),
-                )
-            }
-        }
-    }
-}
-
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
-@Composable
-fun SmallAnimatedActionButton(
-    checked: Boolean,
-    onCheckedChange: (Boolean) -> Unit,
-    iconChecked: ImageVector,
-    iconUnchecked: ImageVector,
-    activeText: String,
-    inactiveText: String,
-    modifier: Modifier = Modifier
-) {
-    if (ThemeResolver.isMiuixEngine(composeEngine)) {
-        val containerColor by animateColorAsState(
-            targetValue = if (checked) MiuixTheme.colorScheme.primaryContainer else MiuixTheme.colorScheme.surfaceContainer,
-            animationSpec = tween(150),
-            label = "MiuixActionButtonContainer"
-        )
-
-        AnimatedActionButtonCore(
-            checked = checked,
-            onCheckedChange = onCheckedChange,
-            iconChecked = iconChecked,
-            iconUnchecked = iconUnchecked,
-            activeText = activeText,
-            inactiveText = inactiveText,
-            modifier = modifier,
-            iconSize = 18.dp,
-            textStyle = LegadoTheme.typography.labelSmall,
-            textStartPadding = 6.dp,
-            button = { buttonModifier, onToggle, content ->
-                MiuixIconButton(
-                    onClick = { onToggle(!checked) },
-                    modifier = buttonModifier,
-                    backgroundColor = containerColor
-                ) {
-                    Row(
-                        verticalAlignment = Alignment.CenterVertically,
-                        horizontalArrangement = Arrangement.Center,
-                        modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
-                        content = content
-                    )
-                }
-            },
-            icon = { imageVector, iconModifier, tint ->
-                MiuixIcon(
-                    imageVector = imageVector,
-                    contentDescription = null,
-                    modifier = iconModifier,
-                    tint = tint ?: Color.Unspecified
-                )
-            },
-            text = { label, textModifier, style, color ->
-                MiuixText(
-                    text = label,
-                    style = style,
-                    color = color ?: Color.Unspecified,
-                    modifier = textModifier,
-                    maxLines = 1,
-                    softWrap = false
-                )
-            }
-        )
-    } else {
-        SmallNoMinTouchTarget {
-            AnimatedActionButtonCore(
-                checked = checked,
-                onCheckedChange = onCheckedChange,
-                iconChecked = iconChecked,
-                iconUnchecked = iconUnchecked,
-                activeText = activeText,
-                inactiveText = inactiveText,
-                modifier = modifier.height(36.dp),
-                iconSize = 16.dp,
-                textStyle = LegadoTheme.typography.labelSmall,
-                textStartPadding = 6.dp,
-                button = { buttonModifier, onToggle, content ->
-                    TonalToggleButton(
-                        checked = checked,
-                        onCheckedChange = onToggle,
-                        modifier = buttonModifier,
-                        contentPadding = PaddingValues(horizontal = 8.dp)
-                    ) {
-                        Row(
-                            verticalAlignment = Alignment.CenterVertically,
-                            horizontalArrangement = Arrangement.Center,
-                            content = content
-                        )
-                    }
-                },
-                icon = { imageVector, iconModifier, _ ->
-                    AnimatedIcon(
-                        imageVector = imageVector,
-                        contentDescription = null,
-                        modifier = iconModifier
-                    )
-                },
-                text = { label, textModifier, style, color ->
-                    Text(
-                        text = label,
-                        style = style,
-                        color = color ?: Color.Unspecified,
-                        modifier = textModifier,
-                        maxLines = 1,
-                        softWrap = false
-                    )
-                }
-            )
-        }
-    }
-}

+ 0 - 142
app/src/main/java/io/legado/app/ui/widget/components/button/SmallTextButton.kt

@@ -1,142 +0,0 @@
-package io.legado.app.ui.widget.components.button
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
-import androidx.compose.material3.FilledTonalButton
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.TextButton
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.unit.dp
-import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.theme.LegadoTheme.composeEngine
-import io.legado.app.ui.theme.ThemeResolver
-import io.legado.app.ui.widget.components.text.AppText
-import top.yukonga.miuix.kmp.basic.Card
-import top.yukonga.miuix.kmp.basic.CardDefaults
-import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
-
-@Composable
-fun SmallTextButton(
-    text: String,
-    imageVector: ImageVector,
-    modifier: Modifier = Modifier,
-    onClick: () -> Unit
-) {
-    if (ThemeResolver.isMiuixEngine(composeEngine)) {
-        Card(
-            onClick = onClick,
-            modifier = modifier,
-            showIndication = true,
-            colors = CardDefaults.defaultColors(
-                color = LegadoTheme.colorScheme.surfaceVariant,
-                contentColor = LegadoTheme.colorScheme.onSurfaceVariant
-            )
-        ) {
-            Row(
-                modifier = Modifier
-                    .padding(horizontal = 12.dp, vertical = 8.dp),
-                horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
-                verticalAlignment = Alignment.CenterVertically
-            ) {
-                MiuixIcon(
-                    imageVector = imageVector,
-                    contentDescription = null,
-                    modifier = Modifier.size(16.dp)
-                )
-                AppText(
-                    text = text,
-                    style = LegadoTheme.typography.labelMedium
-                )
-            }
-        }
-    } else {
-        TextButton(
-            onClick = onClick,
-            modifier = modifier,
-            contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
-            shape = MaterialTheme.shapes.small
-        ) {
-            Icon(
-                imageVector = imageVector,
-                contentDescription = null,
-                modifier = Modifier.size(16.dp)
-            )
-            Spacer(Modifier.width(4.dp))
-            AppText(
-                text = text,
-                style = LegadoTheme.typography.labelMedium
-            )
-        }
-    }
-}
-
-@Composable
-fun SmallTonalTextButton(
-    text: String? = null,
-    imageVector: ImageVector? = null,
-    modifier: Modifier = Modifier,
-    onClick: () -> Unit
-) {
-    if (ThemeResolver.isMiuixEngine(composeEngine)) {
-        Card(
-            onClick = onClick,
-            modifier = modifier,
-            showIndication = true,
-            colors = CardDefaults.defaultColors(
-                color = LegadoTheme.colorScheme.surfaceContainer,
-                contentColor = LegadoTheme.colorScheme.onSurfaceVariant
-            )
-        ) {
-            Row(
-                modifier = Modifier
-                    .padding(horizontal = 12.dp, vertical = 8.dp),
-                horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
-                verticalAlignment = Alignment.CenterVertically
-            ) {
-                if (imageVector != null) {
-                    MiuixIcon(
-                        imageVector = imageVector,
-                        contentDescription = null,
-                        modifier = Modifier.size(16.dp)
-                    )
-                }
-                if (text != null) {
-                    AppText(
-                        text = text,
-                        style = LegadoTheme.typography.labelMedium
-                    )
-                }
-            }
-        }
-    } else {
-        FilledTonalButton(
-            onClick = onClick,
-            modifier = modifier,
-            contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)
-        ) {
-            if (imageVector != null) {
-                Icon(
-                    imageVector = imageVector,
-                    contentDescription = null,
-                    modifier = Modifier.size(16.dp)
-                )
-            }
-            Spacer(Modifier.width(4.dp))
-            if (text != null) {
-                AppText(
-                    text = text,
-                    style = LegadoTheme.typography.labelMedium
-                )
-            }
-        }
-    }
-}

+ 1 - 1
app/src/main/java/io/legado/app/ui/widget/components/button/AnimatedActionButtonCore.kt → app/src/main/java/io/legado/app/ui/widget/components/button/series/AnimatedActionButtonCore.kt

@@ -1,4 +1,4 @@
-package io.legado.app.ui.widget.components.button
+package io.legado.app.ui.widget.components.button.series
 
 import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.foundation.layout.RowScope

+ 1 - 1
app/src/main/java/io/legado/app/ui/widget/components/button/AnimatedIcon.kt → app/src/main/java/io/legado/app/ui/widget/components/button/series/AnimatedIcon.kt

@@ -1,4 +1,4 @@
-package io.legado.app.ui.widget.components.button
+package io.legado.app.ui.widget.components.button.series
 
 import androidx.compose.animation.AnimatedContent
 import androidx.compose.animation.fadeIn

+ 173 - 0
app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumAnimatedButton.kt

@@ -0,0 +1,173 @@
+package io.legado.app.ui.widget.components.button.series
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.FilledTonalIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.material3.TonalToggleButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.LegadoTheme.composeEngine
+import io.legado.app.ui.theme.ThemeResolver
+import top.yukonga.miuix.kmp.theme.MiuixTheme
+import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
+import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton
+import top.yukonga.miuix.kmp.basic.Text as MiuixText
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun MediumAnimatedButton(
+    checked: Boolean,
+    onCheckedChange: (Boolean) -> Unit,
+    modifier: Modifier = Modifier,
+    enabled: Boolean = true,
+    icon: ImageVector? = null,
+    iconChecked: ImageVector? = null,
+    text: String? = null,
+    contentDescription: String? = null
+) {
+    if (ThemeResolver.isMiuixEngine(composeEngine)) {
+        val containerColor by animateColorAsState(
+            targetValue = if (checked) MiuixTheme.colorScheme.primaryContainer else MiuixTheme.colorScheme.surfaceContainer,
+            animationSpec = tween(150),
+            label = "MiuixAnimatedContainerColor"
+        )
+
+        val contentColor by animateColorAsState(
+            targetValue = if (checked) MiuixTheme.colorScheme.onPrimaryContainer else MiuixTheme.colorScheme.onSurface,
+            animationSpec = tween(150),
+            label = "MiuixAnimatedContentColor"
+        )
+
+        if (text != null) {
+            AnimatedActionButtonCore(
+                checked = checked,
+                onCheckedChange = onCheckedChange,
+                iconChecked = iconChecked ?: icon!!,
+                iconUnchecked = icon ?: iconChecked!!,
+                activeText = text,
+                inactiveText = text,
+                modifier = modifier,
+                iconSize = 24.dp,
+                textStyle = LegadoTheme.typography.labelMedium,
+                textStartPadding = 8.dp,
+                contentColor = contentColor,
+                button = { buttonModifier, onToggle, content ->
+                    MiuixIconButton(
+                        onClick = { onToggle(!checked) },
+                        modifier = buttonModifier,
+                        backgroundColor = containerColor
+                    ) {
+                        Row(
+                            verticalAlignment = Alignment.CenterVertically,
+                            horizontalArrangement = Arrangement.Center,
+                            modifier = Modifier.padding(horizontal = 8.dp),
+                            content = content
+                        )
+                    }
+                },
+                icon = { imageVector, iconModifier, tint ->
+                    MiuixIcon(
+                        tint = tint ?: Color.Unspecified,
+                        imageVector = imageVector,
+                        contentDescription = null,
+                        modifier = iconModifier
+                    )
+                },
+                text = { label, textModifier, textStyle, color ->
+                    MiuixText(
+                        text = label,
+                        color = color ?: Color.Unspecified,
+                        style = textStyle,
+                        modifier = textModifier,
+                        maxLines = 1,
+                        softWrap = false
+                    )
+                }
+            )
+        } else {
+            MiuixIconButton(
+                onClick = { onCheckedChange(!checked) },
+                modifier = modifier,
+                enabled = enabled,
+                backgroundColor = containerColor
+            ) {
+                MiuixIcon(
+                    imageVector = if (checked) (iconChecked ?: icon)!! else icon!!,
+                    contentDescription = contentDescription,
+                    tint = contentColor
+                )
+            }
+        }
+    } else {
+        if (text != null) {
+            AnimatedActionButtonCore(
+                checked = checked,
+                onCheckedChange = onCheckedChange,
+                iconChecked = iconChecked ?: icon!!,
+                iconUnchecked = icon ?: iconChecked!!,
+                activeText = text,
+                inactiveText = text,
+                modifier = modifier.height(36.dp),
+                iconSize = 20.dp,
+                textStyle = LegadoTheme.typography.labelMedium,
+                textStartPadding = 8.dp,
+                button = { buttonModifier, onToggle, content ->
+                    TonalToggleButton(
+                        modifier = buttonModifier,
+                        contentPadding = PaddingValues(horizontal = 8.dp),
+                        checked = checked,
+                        onCheckedChange = onToggle
+                    ) {
+                        Row(
+                            verticalAlignment = Alignment.CenterVertically,
+                            horizontalArrangement = Arrangement.Center,
+                            content = content
+                        )
+                    }
+                },
+                icon = { imageVector, iconModifier, _ ->
+                    AnimatedIcon(
+                        imageVector = imageVector,
+                        contentDescription = null,
+                        modifier = iconModifier
+                    )
+                },
+                text = { label, textModifier, textStyle, color ->
+                    Text(
+                        text = label,
+                        style = textStyle,
+                        color = color ?: Color.Unspecified,
+                        modifier = textModifier,
+                        maxLines = 1,
+                        softWrap = false
+                    )
+                }
+            )
+        } else {
+            FilledTonalIconButton(
+                onClick = { onCheckedChange(!checked) },
+                modifier = modifier,
+                enabled = enabled,
+            ) {
+                Icon(
+                    imageVector = if (checked) (iconChecked ?: icon)!! else icon!!,
+                    contentDescription = contentDescription,
+                )
+            }
+        }
+    }
+}

+ 78 - 0
app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumOutlinedButton.kt

@@ -0,0 +1,78 @@
+package io.legado.app.ui.widget.components.button.series
+
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedIconButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.LegadoTheme.composeEngine
+import io.legado.app.ui.theme.ThemeResolver
+import top.yukonga.miuix.kmp.basic.Card
+import top.yukonga.miuix.kmp.basic.CardDefaults
+import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
+import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton
+
+@Composable
+fun MediumOutlinedButton(
+    onClick: () -> Unit,
+    modifier: Modifier = Modifier,
+    enabled: Boolean = true,
+    icon: ImageVector? = null,
+    text: String? = null,
+    contentDescription: String? = null
+) {
+    if (ThemeResolver.isMiuixEngine(composeEngine)) {
+        if (icon != null && text == null) {
+            MiuixIconButton(
+                onClick = onClick,
+                modifier = modifier,
+                enabled = enabled,
+                backgroundColor = LegadoTheme.colorScheme.surfaceContainerHigh
+            ) {
+                MiuixIcon(
+                    imageVector = icon,
+                    contentDescription = contentDescription
+                )
+            }
+        } else {
+            Card(
+                onClick = onClick,
+                modifier = modifier,
+                showIndication = true,
+                colors = CardDefaults.defaultColors(
+                    color = LegadoTheme.colorScheme.surfaceContainerHigh,
+                    contentColor = LegadoTheme.colorScheme.onSurfaceVariant
+                )
+            ) {
+                MediumButtonContent(icon, text, contentDescription)
+            }
+        }
+    } else {
+        if (icon != null && text == null) {
+            OutlinedIconButton(
+                onClick = onClick,
+                modifier = modifier,
+                enabled = enabled,
+                border = ButtonDefaults.outlinedButtonBorder()
+            ) {
+                Icon(
+                    imageVector = icon,
+                    contentDescription = contentDescription,
+                    tint = LegadoTheme.colorScheme.onSurface
+                )
+            }
+        } else {
+            OutlinedButton(
+                onClick = onClick,
+                modifier = modifier,
+                enabled = enabled,
+                border = ButtonDefaults.outlinedButtonBorder()
+            ) {
+                MediumButtonContent(icon, text, contentDescription)
+            }
+        }
+    }
+}

+ 121 - 0
app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumPlainButton.kt

@@ -0,0 +1,121 @@
+package io.legado.app.ui.widget.components.button.series
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.LegadoTheme.composeEngine
+import io.legado.app.ui.theme.ThemeResolver
+import io.legado.app.ui.widget.components.text.AppText
+import top.yukonga.miuix.kmp.basic.Card
+import top.yukonga.miuix.kmp.basic.CardDefaults
+import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
+import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton
+import top.yukonga.miuix.kmp.basic.Text as MiuixText
+
+@Composable
+internal fun MediumButtonContent(
+    icon: ImageVector?,
+    text: String?,
+    contentDescription: String?
+) {
+    val isMiuix = ThemeResolver.isMiuixEngine(composeEngine)
+    Row(
+        modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
+        horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
+        verticalAlignment = Alignment.CenterVertically
+    ) {
+        if (icon != null) {
+            if (isMiuix) {
+                MiuixIcon(
+                    imageVector = icon,
+                    contentDescription = contentDescription
+                )
+            } else {
+                Icon(
+                    imageVector = icon,
+                    contentDescription = contentDescription
+                )
+            }
+        }
+        if (text != null) {
+            if (isMiuix) {
+                MiuixText(text = text)
+            } else {
+                AppText(text = text)
+            }
+        }
+    }
+}
+
+@Composable
+fun MediumPlainButton(
+    onClick: () -> Unit,
+    modifier: Modifier = Modifier,
+    enabled: Boolean = true,
+    icon: ImageVector? = null,
+    text: String? = null,
+    tint: androidx.compose.ui.graphics.Color = LegadoTheme.colorScheme.onSurface,
+    contentDescription: String? = null
+) {
+    if (ThemeResolver.isMiuixEngine(composeEngine)) {
+        if (icon != null && text == null) {
+            MiuixIconButton(
+                onClick = onClick,
+                modifier = modifier,
+                enabled = enabled
+            ) {
+                MiuixIcon(
+                    imageVector = icon,
+                    contentDescription = contentDescription,
+                    tint = tint
+                )
+            }
+        } else {
+            Card(
+                onClick = onClick,
+                modifier = modifier,
+                showIndication = true,
+                colors = CardDefaults.defaultColors(
+                    color = LegadoTheme.colorScheme.surfaceVariant,
+                    contentColor = LegadoTheme.colorScheme.onSurfaceVariant
+                )
+            ) {
+                MediumButtonContent(icon, text, contentDescription)
+            }
+        }
+    } else {
+        if (icon != null && text == null) {
+            IconButton(
+                onClick = onClick,
+                modifier = modifier,
+                enabled = enabled
+            ) {
+                Icon(
+                    imageVector = icon,
+                    contentDescription = contentDescription,
+                    tint = tint
+                )
+            }
+        } else {
+            Card(
+                onClick = onClick,
+                modifier = modifier,
+                showIndication = true,
+                colors = CardDefaults.defaultColors(
+                    color = LegadoTheme.colorScheme.surfaceVariant,
+                    contentColor = LegadoTheme.colorScheme.onSurfaceVariant
+                )
+            ) {
+                MediumButtonContent(icon, text, contentDescription)
+            }
+        }
+    }
+}

+ 152 - 0
app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumToggleButton.kt

@@ -0,0 +1,152 @@
+package io.legado.app.ui.widget.components.button.series
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.FilledTonalIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.material3.ToggleButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.LegadoTheme.composeEngine
+import io.legado.app.ui.theme.ThemeResolver
+import top.yukonga.miuix.kmp.theme.MiuixTheme
+import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
+import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton
+import top.yukonga.miuix.kmp.basic.Text as MiuixText
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun MediumToggleButton(
+    checked: Boolean,
+    onCheckedChange: (Boolean) -> Unit,
+    modifier: Modifier = Modifier,
+    enabled: Boolean = true,
+    style: ToggleStyle = ToggleStyle.Outlined,
+    icon: ImageVector? = null,
+    iconChecked: ImageVector? = null,
+    text: String? = null,
+    contentDescription: String? = null
+) {
+    if (ThemeResolver.isMiuixEngine(composeEngine)) {
+        val containerColor by animateColorAsState(
+            targetValue = if (checked) MiuixTheme.colorScheme.primaryContainer else MiuixTheme.colorScheme.surfaceContainerHigh,
+            animationSpec = tween(150),
+            label = "MiuixToggleContainerColor"
+        )
+
+        val contentColor by animateColorAsState(
+            targetValue = if (checked) MiuixTheme.colorScheme.onPrimaryContainer else MiuixTheme.colorScheme.onSurface,
+            animationSpec = tween(150),
+            label = "MiuixToggleContentColor"
+        )
+
+        if (text != null) {
+            MiuixIconButton(
+                onClick = { onCheckedChange(!checked) },
+                modifier = modifier,
+                enabled = enabled,
+                backgroundColor = containerColor
+            ) {
+                Row(
+                    verticalAlignment = Alignment.CenterVertically,
+                    horizontalArrangement = Arrangement.Center,
+                    modifier = Modifier.padding(horizontal = 8.dp)
+                ) {
+                    MiuixIcon(
+                        imageVector = if (checked) (iconChecked ?: icon)!! else icon!!,
+                        contentDescription = null,
+                        tint = contentColor
+                    )
+                    MiuixText(
+                        text = text,
+                        color = contentColor,
+                        style = LegadoTheme.typography.labelMedium,
+                        modifier = Modifier.padding(start = 8.dp),
+                        maxLines = 1,
+                        softWrap = false
+                    )
+                }
+            }
+        } else {
+            MiuixIconButton(
+                onClick = { onCheckedChange(!checked) },
+                modifier = modifier,
+                enabled = enabled,
+                backgroundColor = containerColor
+            ) {
+                MiuixIcon(
+                    imageVector = if (checked) (iconChecked ?: icon)!! else icon!!,
+                    contentDescription = contentDescription,
+                    tint = contentColor
+                )
+            }
+        }
+    } else {
+        if (text != null) {
+            ToggleButton(
+                checked = checked,
+                onCheckedChange = onCheckedChange,
+                modifier = modifier,
+                enabled = enabled,
+                contentPadding = PaddingValues(horizontal = 8.dp)
+            ) {
+                Row(
+                    verticalAlignment = Alignment.CenterVertically,
+                    horizontalArrangement = Arrangement.Center
+                ) {
+                    Icon(
+                        imageVector = if (checked) (iconChecked ?: icon)!! else icon!!,
+                        contentDescription = null
+                    )
+                    Text(
+                        text = text,
+                        style = LegadoTheme.typography.labelMedium,
+                        modifier = Modifier.padding(start = 8.dp),
+                        maxLines = 1,
+                        softWrap = false
+                    )
+                }
+            }
+        } else {
+            when (style) {
+                ToggleStyle.Outlined -> {
+                    FilledTonalButton(
+                        onClick = { onCheckedChange(!checked) },
+                        modifier = modifier,
+                        enabled = enabled,
+                    ) {
+                        Icon(
+                            imageVector = if (checked) (iconChecked ?: icon)!! else icon!!,
+                            contentDescription = contentDescription,
+                        )
+                    }
+                }
+
+                ToggleStyle.Tonal -> {
+                    FilledTonalIconButton(
+                        onClick = { onCheckedChange(!checked) },
+                        modifier = modifier,
+                        enabled = enabled,
+                    ) {
+                        Icon(
+                            imageVector = if (checked) (iconChecked ?: icon)!! else icon!!,
+                            contentDescription = contentDescription,
+                        )
+                    }
+                }
+            }
+        }
+    }
+}

+ 74 - 0
app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumTonalButton.kt

@@ -0,0 +1,74 @@
+package io.legado.app.ui.widget.components.button.series
+
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.FilledTonalIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.LegadoTheme.composeEngine
+import io.legado.app.ui.theme.ThemeResolver
+import top.yukonga.miuix.kmp.basic.Card
+import top.yukonga.miuix.kmp.basic.CardDefaults
+import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
+import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton
+
+@Composable
+fun MediumTonalButton(
+    onClick: () -> Unit,
+    modifier: Modifier = Modifier,
+    enabled: Boolean = true,
+    icon: ImageVector? = null,
+    text: String? = null,
+    contentDescription: String? = null
+) {
+    if (ThemeResolver.isMiuixEngine(composeEngine)) {
+        if (icon != null && text == null) {
+            MiuixIconButton(
+                onClick = onClick,
+                modifier = modifier,
+                enabled = enabled,
+                backgroundColor = LegadoTheme.colorScheme.surfaceContainer
+            ) {
+                MiuixIcon(
+                    imageVector = icon,
+                    contentDescription = contentDescription
+                )
+            }
+        } else {
+            Card(
+                onClick = onClick,
+                modifier = modifier,
+                showIndication = true,
+                colors = CardDefaults.defaultColors(
+                    color = LegadoTheme.colorScheme.surfaceContainer,
+                    contentColor = LegadoTheme.colorScheme.onSurfaceVariant
+                )
+            ) {
+                MediumButtonContent(icon, text, contentDescription)
+            }
+        }
+    } else {
+        if (icon != null && text == null) {
+            FilledTonalIconButton(
+                onClick = onClick,
+                modifier = modifier,
+                enabled = enabled
+            ) {
+                Icon(
+                    imageVector = icon,
+                    contentDescription = contentDescription
+                )
+            }
+        } else {
+            FilledTonalButton(
+                onClick = onClick,
+                modifier = modifier,
+                enabled = enabled
+            ) {
+                MediumButtonContent(icon, text, contentDescription)
+            }
+        }
+    }
+}

+ 176 - 0
app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallAnimatedButton.kt

@@ -0,0 +1,176 @@
+package io.legado.app.ui.widget.components.button.series
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.FilledTonalIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.TonalToggleButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.LegadoTheme.composeEngine
+import io.legado.app.ui.theme.ThemeResolver
+import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
+import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton
+import top.yukonga.miuix.kmp.basic.Text as MiuixText
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun SmallAnimatedButton(
+    checked: Boolean,
+    onCheckedChange: (Boolean) -> Unit,
+    modifier: Modifier = Modifier,
+    enabled: Boolean = true,
+    icon: ImageVector? = null,
+    iconChecked: ImageVector? = null,
+    text: String? = null,
+    contentDescription: String? = null
+) {
+    if (ThemeResolver.isMiuixEngine(composeEngine)) {
+        val containerColor by animateColorAsState(
+            targetValue = if (checked) LegadoTheme.colorScheme.primaryContainer else LegadoTheme.colorScheme.surfaceContainer,
+            animationSpec = tween(150),
+            label = "MiuixAnimatedContainerColor"
+        )
+
+        val iconTint by animateColorAsState(
+            targetValue = if (checked) LegadoTheme.colorScheme.onPrimaryContainer else LegadoTheme.colorScheme.onSurfaceVariant,
+            animationSpec = tween(150),
+            label = "MiuixAnimatedIconTint"
+        )
+
+        if (text != null) {
+            AnimatedActionButtonCore(
+                checked = checked,
+                onCheckedChange = onCheckedChange,
+                iconChecked = iconChecked ?: icon!!,
+                iconUnchecked = icon ?: iconChecked!!,
+                activeText = text,
+                inactiveText = text,
+                modifier = modifier,
+                iconSize = 18.dp,
+                textStyle = LegadoTheme.typography.labelSmall,
+                textStartPadding = 6.dp,
+                contentColor = iconTint,
+                button = { buttonModifier, onToggle, content ->
+                    MiuixIconButton(
+                        onClick = { onToggle(!checked) },
+                        modifier = buttonModifier,
+                        backgroundColor = containerColor
+                    ) {
+                        Row(
+                            verticalAlignment = Alignment.CenterVertically,
+                            horizontalArrangement = Arrangement.Center,
+                            modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
+                            content = content
+                        )
+                    }
+                },
+                icon = { imageVector, iconModifier, tint ->
+                    MiuixIcon(
+                        imageVector = imageVector,
+                        contentDescription = null,
+                        modifier = iconModifier,
+                        tint = tint ?: Color.Unspecified
+                    )
+                },
+                text = { label, textModifier, textStyle, color ->
+                    MiuixText(
+                        text = label,
+                        style = textStyle,
+                        color = color ?: Color.Unspecified,
+                        modifier = textModifier,
+                        maxLines = 1,
+                        softWrap = false
+                    )
+                }
+            )
+        } else {
+            MiuixIconButton(
+                onClick = { onCheckedChange(!checked) },
+                modifier = modifier.size(SmallMiuixButtonSize),
+                enabled = enabled,
+                backgroundColor = containerColor
+            ) {
+                MiuixIcon(
+                    imageVector = if (checked) (iconChecked ?: icon)!! else icon!!,
+                    contentDescription = contentDescription,
+                    tint = iconTint,
+                    modifier = Modifier.size(SmallMiuixIconSize)
+                )
+            }
+        }
+    } else {
+        SmallNoMinTouchTarget {
+            if (text != null) {
+                AnimatedActionButtonCore(
+                    checked = checked,
+                    onCheckedChange = onCheckedChange,
+                    iconChecked = iconChecked ?: icon!!,
+                    iconUnchecked = icon ?: iconChecked!!,
+                    activeText = text,
+                    inactiveText = text,
+                    modifier = modifier.height(36.dp),
+                    iconSize = 16.dp,
+                    textStyle = LegadoTheme.typography.labelSmall,
+                    textStartPadding = 6.dp,
+                    button = { buttonModifier, onToggle, content ->
+                        TonalToggleButton(
+                            checked = checked,
+                            onCheckedChange = onToggle,
+                            modifier = buttonModifier,
+                            contentPadding = PaddingValues(horizontal = 8.dp)
+                        ) {
+                            Row(
+                                verticalAlignment = Alignment.CenterVertically,
+                                horizontalArrangement = Arrangement.Center,
+                                content = content
+                            )
+                        }
+                    },
+                    icon = { imageVector, iconModifier, _ ->
+                        AnimatedIcon(
+                            imageVector = imageVector,
+                            contentDescription = null,
+                            modifier = iconModifier
+                        )
+                    },
+                    text = { label, textModifier, textStyle, color ->
+                        androidx.compose.material3.Text(
+                            text = label,
+                            style = textStyle,
+                            color = color ?: Color.Unspecified,
+                            modifier = textModifier,
+                            maxLines = 1,
+                            softWrap = false
+                        )
+                    }
+                )
+            } else {
+                FilledTonalIconButton(
+                    onClick = { onCheckedChange(!checked) },
+                    modifier = modifier.size(smallContainerSize()),
+                    enabled = enabled,
+                ) {
+                    Icon(
+                        imageVector = if (checked) (iconChecked ?: icon)!! else icon!!,
+                        contentDescription = contentDescription,
+                        modifier = Modifier.size(smallIconSize),
+                    )
+                }
+            }
+        }
+    }
+}

+ 93 - 0
app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallOutlinedButton.kt

@@ -0,0 +1,93 @@
+package io.legado.app.ui.widget.components.button.series
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedIconButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.LegadoTheme.composeEngine
+import io.legado.app.ui.theme.ThemeResolver
+import top.yukonga.miuix.kmp.basic.Card
+import top.yukonga.miuix.kmp.basic.CardDefaults
+import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
+import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun SmallOutlinedButton(
+    onClick: () -> Unit,
+    modifier: Modifier = Modifier,
+    enabled: Boolean = true,
+    icon: ImageVector? = null,
+    text: String? = null,
+    contentDescription: String? = null
+) {
+    if (ThemeResolver.isMiuixEngine(composeEngine)) {
+        if (icon != null && text == null) {
+            MiuixIconButton(
+                onClick = onClick,
+                modifier = modifier.size(SmallMiuixButtonSize),
+                enabled = enabled,
+                backgroundColor = LegadoTheme.colorScheme.surfaceContainer
+            ) {
+                MiuixIcon(
+                    imageVector = icon,
+                    contentDescription = contentDescription,
+                    modifier = Modifier.size(SmallMiuixIconSize)
+                )
+            }
+        } else {
+            Card(
+                onClick = onClick,
+                modifier = modifier,
+                showIndication = true,
+                colors = CardDefaults.defaultColors(
+                    color = LegadoTheme.colorScheme.surfaceContainer,
+                    contentColor = LegadoTheme.colorScheme.onSurfaceVariant
+                )
+            ) {
+                SmallButtonContent(icon, text, contentDescription)
+            }
+        }
+    } else {
+        SmallNoMinTouchTarget {
+            when {
+                icon != null && text == null -> {
+                    OutlinedIconButton(
+                        onClick = onClick,
+                        modifier = modifier.size(smallContainerSize()),
+                        enabled = enabled,
+                        shapes = IconButtonDefaults.shapes(),
+                        border = ButtonDefaults.outlinedButtonBorder()
+                    ) {
+                        Icon(
+                            imageVector = icon,
+                            contentDescription = contentDescription,
+                            modifier = Modifier.size(smallIconSize)
+                        )
+                    }
+                }
+
+                else -> {
+                    OutlinedButton(
+                        onClick = onClick,
+                        modifier = modifier,
+                        enabled = enabled,
+                        contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
+                        border = ButtonDefaults.outlinedButtonBorder()
+                    ) {
+                        SmallButtonContent(icon, text, contentDescription)
+                    }
+                }
+            }
+        }
+    }
+}

+ 161 - 0
app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallPlainButton.kt

@@ -0,0 +1,161 @@
+package io.legado.app.ui.widget.components.button.series
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.LocalMinimumInteractiveComponentSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.LegadoTheme.composeEngine
+import io.legado.app.ui.theme.ThemeResolver
+import io.legado.app.ui.widget.components.text.AppText
+import top.yukonga.miuix.kmp.basic.Card
+import top.yukonga.miuix.kmp.basic.CardDefaults
+import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
+import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton
+import top.yukonga.miuix.kmp.basic.Text as MiuixText
+
+internal val SmallMiuixButtonSize = 32.dp
+internal val SmallMiuixIconSize = 18.dp
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+internal fun smallContainerSize() = IconButtonDefaults.extraSmallContainerSize(
+    IconButtonDefaults.IconButtonWidthOption.Uniform
+)
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+internal val smallIconSize: Dp
+    get() = IconButtonDefaults.extraSmallIconSize
+
+@Composable
+internal fun SmallNoMinTouchTarget(content: @Composable () -> Unit) {
+    CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
+        content()
+    }
+}
+
+@Composable
+internal fun SmallButtonContent(
+    icon: ImageVector?,
+    text: String?,
+    contentDescription: String?
+) {
+    val isMiuix = ThemeResolver.isMiuixEngine(composeEngine)
+    Row(
+        modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
+        horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
+        verticalAlignment = Alignment.CenterVertically
+    ) {
+        if (icon != null) {
+            if (isMiuix) {
+                MiuixIcon(
+                    imageVector = icon,
+                    contentDescription = contentDescription,
+                    modifier = Modifier.size(16.dp)
+                )
+            } else {
+                Icon(
+                    imageVector = icon,
+                    contentDescription = contentDescription,
+                    modifier = Modifier.size(16.dp)
+                )
+            }
+        }
+        if (text != null) {
+            if (isMiuix) {
+                MiuixText(
+                    text = text,
+                    style = LegadoTheme.typography.labelMedium
+                )
+            } else {
+                AppText(
+                    text = text,
+                    style = LegadoTheme.typography.labelMedium
+                )
+            }
+        }
+    }
+}
+
+@Composable
+fun SmallPlainButton(
+    onClick: () -> Unit,
+    modifier: Modifier = Modifier,
+    enabled: Boolean = true,
+    icon: ImageVector? = null,
+    text: String? = null,
+    contentDescription: String? = null
+) {
+    if (ThemeResolver.isMiuixEngine(composeEngine)) {
+        if (icon != null && text == null) {
+            MiuixIconButton(
+                onClick = onClick,
+                modifier = modifier,
+                enabled = enabled
+            ) {
+                MiuixIcon(
+                    imageVector = icon,
+                    contentDescription = contentDescription,
+                    modifier = Modifier.size(SmallMiuixIconSize),
+                )
+            }
+        } else {
+            Card(
+                onClick = onClick,
+                modifier = modifier,
+                showIndication = true,
+                colors = CardDefaults.defaultColors(
+                    color = LegadoTheme.colorScheme.surfaceVariant,
+                    contentColor = LegadoTheme.colorScheme.onSurfaceVariant
+                )
+            ) {
+                SmallButtonContent(icon, text, contentDescription)
+            }
+        }
+    } else {
+        SmallNoMinTouchTarget {
+            when {
+                icon != null && text == null -> {
+                    IconButton(
+                        onClick = onClick,
+                        modifier = modifier.size(smallContainerSize()),
+                        enabled = enabled,
+                        shape = IconButtonDefaults.extraSmallRoundShape,
+                    ) {
+                        Icon(
+                            imageVector = icon,
+                            contentDescription = contentDescription,
+                            modifier = Modifier.size(smallIconSize),
+                        )
+                    }
+                }
+
+                else -> {
+                    TextButton(
+                        onClick = onClick,
+                        modifier = modifier,
+                        enabled = enabled,
+                        contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
+                        shape = MaterialTheme.shapes.small
+                    ) {
+                        SmallButtonContent(icon, text, contentDescription)
+                    }
+                }
+            }
+        }
+    }
+}

+ 178 - 0
app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallToggleButton.kt

@@ -0,0 +1,178 @@
+package io.legado.app.ui.widget.components.button.series
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.IconToggleButtonShapes
+import androidx.compose.material3.OutlinedIconToggleButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TonalToggleButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.LegadoTheme.composeEngine
+import io.legado.app.ui.theme.ThemeResolver
+import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
+import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton
+import top.yukonga.miuix.kmp.basic.Text as MiuixText
+
+enum class ToggleStyle { Outlined, Tonal }
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun SmallToggleButton(
+    checked: Boolean,
+    onCheckedChange: (Boolean) -> Unit,
+    modifier: Modifier = Modifier,
+    enabled: Boolean = true,
+    style: ToggleStyle = ToggleStyle.Outlined,
+    icon: ImageVector? = null,
+    iconChecked: ImageVector? = null,
+    text: String? = null,
+    contentDescription: String? = null
+) {
+    if (ThemeResolver.isMiuixEngine(composeEngine)) {
+        val containerColor by animateColorAsState(
+            targetValue = if (checked) LegadoTheme.colorScheme.primaryContainer else LegadoTheme.colorScheme.surfaceContainer,
+            animationSpec = tween(150),
+            label = "MiuixToggleContainerColor"
+        )
+
+        val iconTint by animateColorAsState(
+            targetValue = if (checked) LegadoTheme.colorScheme.onPrimaryContainer else LegadoTheme.colorScheme.onSurfaceVariant,
+            animationSpec = tween(150),
+            label = "MiuixToggleIconTint"
+        )
+
+        if (text != null) {
+            MiuixIconButton(
+                onClick = { onCheckedChange(!checked) },
+                modifier = modifier,
+                enabled = enabled,
+                backgroundColor = containerColor
+            ) {
+                Row(
+                    verticalAlignment = Alignment.CenterVertically,
+                    horizontalArrangement = Arrangement.Center,
+                    modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
+                ) {
+                    MiuixIcon(
+                        imageVector = if (checked) (iconChecked ?: icon)!! else icon!!,
+                        contentDescription = null,
+                        modifier = Modifier.size(16.dp),
+                        tint = iconTint
+                    )
+                    MiuixText(
+                        text = text,
+                        style = LegadoTheme.typography.labelSmall,
+                        color = iconTint,
+                        modifier = Modifier.padding(start = 6.dp),
+                        maxLines = 1,
+                        softWrap = false
+                    )
+                }
+            }
+        } else {
+            MiuixIconButton(
+                onClick = { onCheckedChange(!checked) },
+                modifier = modifier.size(SmallMiuixButtonSize),
+                enabled = enabled,
+                backgroundColor = containerColor
+            ) {
+                MiuixIcon(
+                    imageVector = if (checked) (iconChecked ?: icon)!! else icon!!,
+                    contentDescription = contentDescription,
+                    tint = iconTint,
+                    modifier = Modifier.size(SmallMiuixIconSize)
+                )
+            }
+        }
+    } else {
+        SmallNoMinTouchTarget {
+            if (text != null) {
+                TonalToggleButton(
+                    checked = checked,
+                    onCheckedChange = onCheckedChange,
+                    modifier = modifier,
+                    enabled = enabled,
+                    contentPadding = PaddingValues(horizontal = 8.dp)
+                ) {
+                    Row(
+                        verticalAlignment = Alignment.CenterVertically,
+                        horizontalArrangement = Arrangement.Center
+                    ) {
+                        Icon(
+                            imageVector = if (checked) (iconChecked ?: icon)!! else icon!!,
+                            contentDescription = null,
+                            modifier = Modifier.size(16.dp)
+                        )
+                        Text(
+                            text = text,
+                            style = LegadoTheme.typography.labelSmall,
+                            modifier = Modifier.padding(start = 6.dp),
+                            maxLines = 1,
+                            softWrap = false
+                        )
+                    }
+                }
+            } else {
+                when (style) {
+                    ToggleStyle.Outlined -> {
+                        val defaultShape = IconButtonDefaults.extraSmallRoundShape
+                        val pressedShape = IconButtonDefaults.extraSmallPressedShape
+                        val checkedShape = IconButtonDefaults.extraSmallSelectedRoundShape
+
+                        val toggleShapes = remember(defaultShape, checkedShape) {
+                            IconToggleButtonShapes(
+                                shape = defaultShape,
+                                pressedShape = pressedShape,
+                                checkedShape = checkedShape
+                            )
+                        }
+
+                        OutlinedIconToggleButton(
+                            checked = checked,
+                            onCheckedChange = onCheckedChange,
+                            modifier = modifier.size(smallContainerSize()),
+                            enabled = enabled,
+                            shapes = toggleShapes
+                        ) {
+                            Icon(
+                                imageVector = if (checked) (iconChecked ?: icon)!! else icon!!,
+                                contentDescription = contentDescription,
+                                modifier = Modifier.size(smallIconSize),
+                            )
+                        }
+                    }
+
+                    ToggleStyle.Tonal -> {
+                        FilledTonalButton(
+                            onClick = { onCheckedChange(!checked) },
+                            modifier = modifier.size(smallContainerSize()),
+                            enabled = enabled,
+                        ) {
+                            Icon(
+                                imageVector = if (checked) (iconChecked ?: icon)!! else icon!!,
+                                contentDescription = contentDescription,
+                                modifier = Modifier.size(smallIconSize),
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    }
+}

+ 91 - 0
app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallTonalButton.kt

@@ -0,0 +1,91 @@
+package io.legado.app.ui.widget.components.button.series
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.FilledTonalIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.LegadoTheme.composeEngine
+import io.legado.app.ui.theme.ThemeResolver
+import top.yukonga.miuix.kmp.basic.Card
+import top.yukonga.miuix.kmp.basic.CardDefaults
+import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
+import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun SmallTonalButton(
+    onClick: () -> Unit,
+    modifier: Modifier = Modifier,
+    enabled: Boolean = true,
+    icon: ImageVector? = null,
+    text: String? = null,
+    contentDescription: String? = null
+) {
+    if (ThemeResolver.isMiuixEngine(composeEngine)) {
+        if (icon != null && text == null) {
+            MiuixIconButton(
+                onClick = onClick,
+                modifier = modifier.size(SmallMiuixButtonSize),
+                enabled = enabled,
+                backgroundColor = LegadoTheme.colorScheme.surfaceContainer
+            ) {
+                MiuixIcon(
+                    imageVector = icon,
+                    contentDescription = contentDescription,
+                    modifier = Modifier.size(SmallMiuixIconSize)
+                )
+            }
+        } else {
+            Card(
+                onClick = onClick,
+                modifier = modifier,
+                showIndication = true,
+                colors = CardDefaults.defaultColors(
+                    color = LegadoTheme.colorScheme.surfaceContainer,
+                    contentColor = LegadoTheme.colorScheme.onSurfaceVariant
+                )
+            ) {
+                SmallButtonContent(icon, text, contentDescription)
+            }
+        }
+    } else {
+        SmallNoMinTouchTarget {
+            when {
+                icon != null && text == null -> {
+                    FilledTonalIconButton(
+                        onClick = onClick,
+                        modifier = modifier.size(smallContainerSize()),
+                        enabled = enabled,
+                        shapes = IconButtonDefaults.shapes(),
+                        colors = IconButtonDefaults.filledTonalIconButtonColors()
+                    ) {
+                        Icon(
+                            imageVector = icon,
+                            contentDescription = contentDescription,
+                            modifier = Modifier.size(smallIconSize)
+                        )
+                    }
+                }
+
+                else -> {
+                    FilledTonalButton(
+                        onClick = onClick,
+                        modifier = modifier,
+                        enabled = enabled,
+                        contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)
+                    ) {
+                        SmallButtonContent(icon, text, contentDescription)
+                    }
+                }
+            }
+        }
+    }
+}

+ 5 - 11
app/src/main/java/io/legado/app/ui/widget/components/card/SelectionItemCard.kt

@@ -16,13 +16,8 @@ import androidx.compose.foundation.layout.RowScope
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.lazy.LazyItemScope
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Edit
-import androidx.compose.material.icons.filled.MoreVert
-import androidx.compose.material3.Checkbox
 import androidx.compose.material3.ListItem
 import androidx.compose.material3.ListItemDefaults
-import androidx.compose.material3.MaterialTheme
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
@@ -42,7 +37,7 @@ import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme.composeEngine
 import io.legado.app.ui.theme.ThemeResolver
 import io.legado.app.ui.widget.components.AdaptiveSwitch
-import io.legado.app.ui.widget.components.button.SmallIconButton
+import io.legado.app.ui.widget.components.button.series.SmallPlainButton
 import io.legado.app.ui.widget.components.checkBox.AppCheckbox
 import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
@@ -50,7 +45,6 @@ import io.legado.app.ui.widget.components.text.AppText
 import sh.calvin.reorderable.ReorderableItem
 import sh.calvin.reorderable.ReorderableLazyListState
 import top.yukonga.miuix.kmp.basic.BasicComponent
-import top.yukonga.miuix.kmp.theme.MiuixTheme
 
 @Composable
 fun SelectionItemCard(
@@ -218,9 +212,9 @@ fun SelectionItemCardContent(
             }
 
             if (onClickEdit != null) {
-                SmallIconButton(
+                SmallPlainButton(
                     onClick = onClickEdit,
-                    imageVector = AppIcons.Edit,
+                    icon = AppIcons.Edit,
                     contentDescription = "Edit"
                 )
             }
@@ -231,9 +225,9 @@ fun SelectionItemCardContent(
 
             if (dropdownContent != null) {
                 Box {
-                    SmallIconButton(
+                    SmallPlainButton(
                         onClick = { showMenu = true },
-                        imageVector = AppIcons.MoreVert,
+                        icon = AppIcons.MoreVert,
                         contentDescription = "More"
                     )
                     RoundDropdownMenu(

+ 7 - 8
app/src/main/java/io/legado/app/ui/widget/components/heatmap/HeatmapCalendarComponents.kt

@@ -36,8 +36,8 @@ import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
 import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.widget.components.button.MediumAnimatedActionButton
-import io.legado.app.ui.widget.components.button.MediumOutlinedIconButton
+import io.legado.app.ui.widget.components.button.series.MediumOutlinedButton
+import io.legado.app.ui.widget.components.button.series.MediumToggleButton
 import io.legado.app.ui.widget.components.text.AppText
 import java.time.LocalDate
 
@@ -54,15 +54,14 @@ fun HeatmapCalendarStartAction(
     currentMode: HeatmapMode,
     onModeChanged: (HeatmapMode) -> Unit,
 ) {
-    MediumAnimatedActionButton(
+    MediumToggleButton(
         checked = currentMode == HeatmapMode.TIME,
         onCheckedChange = {
             onModeChanged(if (it) HeatmapMode.TIME else HeatmapMode.COUNT)
         },
+        icon = Icons.Default.FormatListNumbered,
         iconChecked = Icons.Default.AccessTime,
-        iconUnchecked = Icons.Default.FormatListNumbered,
-        activeText = "按时长",
-        inactiveText = "按次数"
+        text = "按时长"
     )
 }
 
@@ -73,9 +72,9 @@ fun HeatmapCalendarStartAction(
 fun HeatmapCalendarEndAction(
     onClearDate: () -> Unit
 ) {
-    MediumOutlinedIconButton(
+    MediumOutlinedButton(
         onClick = onClearDate,
-        imageVector = Icons.Outlined.Delete,
+        icon = Icons.Outlined.Delete,
     )
 }
 

+ 7 - 7
app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt

@@ -44,7 +44,7 @@ import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
-import io.legado.app.ui.widget.components.button.SmallIconButton
+import io.legado.app.ui.widget.components.button.series.SmallPlainButton
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.card.SelectionItemCard
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
@@ -183,9 +183,9 @@ fun <T> BatchImportDialog(
         title = sheetTitle,
         startAction = if (isEditing) {
             {
-                SmallIconButton(
+                SmallPlainButton(
                     onClick = { editingIndex = null },
-                    imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+                    icon = Icons.AutoMirrored.Filled.ArrowBack,
                     contentDescription = "返回"
                 )
             }
@@ -196,9 +196,9 @@ fun <T> BatchImportDialog(
             {
                 Row {
                     topBarActions()
-                    SmallIconButton(
+                    SmallPlainButton(
                         onClick = { onToggleAll(!allSelected) },
-                        imageVector = Icons.Default.SelectAll,
+                        icon = Icons.Default.SelectAll,
                         contentDescription = if (allSelected) "全不选" else "全选"
                     )
                 }
@@ -371,9 +371,9 @@ fun ImportItemRow(
                 modifier = Modifier.padding(end = 4.dp)
             )
 
-            SmallIconButton(
+            SmallPlainButton(
                 onClick = onInfoClick,
-                imageVector = Icons.Default.Info,
+                icon = Icons.Default.Info,
                 contentDescription = "详情"
             )
         }

+ 3 - 3
app/src/main/java/io/legado/app/ui/widget/components/log/AppLogSheet.kt

@@ -23,7 +23,7 @@ import io.legado.app.R
 import io.legado.app.constant.AppLog
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.EmptyMessage
-import io.legado.app.ui.widget.components.button.MediumIconButton
+import io.legado.app.ui.widget.components.button.series.MediumPlainButton
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.utils.LogUtils
@@ -43,12 +43,12 @@ fun AppLogSheet(
         onDismissRequest = onDismissRequest,
         title = stringResource(R.string.log),
         endAction = {
-            MediumIconButton(
+            MediumPlainButton(
                 onClick = {
                     AppLog.clear()
                     logs = emptyList()
                 },
-                imageVector = Icons.Default.DeleteSweep
+                icon = Icons.Default.DeleteSweep
             )
         }
     ) {

+ 1 - 3
app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/OptionSheet.kt

@@ -10,8 +10,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.size
 import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
@@ -65,7 +63,7 @@ fun RowScope.OptionCard(
             .weight(1f)
             .height(100.dp),
         cornerRadius = 12.dp,
-        containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
+        containerColor = LegadoTheme.colorScheme.onSheetContent,
         elevation = 2.dp
     ) {
         Column(

+ 5 - 15
app/src/main/java/io/legado/app/ui/widget/components/rules/RuleEditSheet.kt

@@ -13,15 +13,8 @@ import androidx.compose.material.icons.filled.Close
 import androidx.compose.material.icons.filled.ContentPaste
 import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.Save
-import androidx.compose.material3.CenterAlignedTopAppBar
-import androidx.compose.material3.DropdownMenu
-import androidx.compose.material3.DropdownMenuItem
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.TopAppBarDefaults
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
@@ -30,19 +23,16 @@ import androidx.compose.runtime.rememberCoroutineScope
 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.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.AppFloatingActionButton
 import io.legado.app.ui.widget.components.AppTextField
-import io.legado.app.ui.widget.components.button.MediumIconButton
-import io.legado.app.ui.widget.components.icon.AppIcon
+import io.legado.app.ui.widget.components.button.series.MediumPlainButton
 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.text.AppText
 import kotlinx.coroutines.launch
 
 /**
@@ -84,17 +74,17 @@ fun <T> RuleEditSheet(
     AppModalBottomSheet(
         title = title,
         startAction = {
-            MediumIconButton(
+            MediumPlainButton(
                 onClick = onDismissRequest,
-                imageVector = Icons.Default.Close,
+                icon = Icons.Default.Close,
                 contentDescription = "Close",
             )
         },
         endAction = {
             Box{
-                MediumIconButton(
+                MediumPlainButton(
                     onClick = { showMenu = true },
-                    imageVector = Icons.Default.MoreVert,
+                    icon = Icons.Default.MoreVert,
                     contentDescription = "More"
                 )
                 RoundDropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {

+ 25 - 4
app/src/main/java/io/legado/app/ui/widget/components/topbar/GlassMediumFlexibleTopAppBar.kt

@@ -1,9 +1,13 @@
 package io.legado.app.ui.widget.components.topbar
 
 import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.padding
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.MaterialTheme
@@ -16,6 +20,7 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.lerp
 import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
 import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LocalHazeState
@@ -81,7 +86,9 @@ fun GlassMediumFlexibleTopAppBar(
     }
 
     val finalModifier = if (hazeState != null) {
-        modifier.background(color = animatedColor).responsiveHazeEffect(state = hazeState)
+        modifier
+            .background(color = animatedColor)
+            .responsiveHazeEffect(state = hazeState)
     } else {
         modifier.background(color = animatedColor)
     }
@@ -116,7 +123,7 @@ fun GlassMediumFlexibleTopAppBar(
                             AdaptiveAnimatedText(
                                 text = title,
                                 useCharMode = useCharMode,
-                                maxLines = 1,
+                                maxLines = 2,
                                 overflow = TextOverflow.Ellipsis
                             )
                         },
@@ -126,7 +133,13 @@ fun GlassMediumFlexibleTopAppBar(
                             }
                         },
                         navigationIcon = navigationIcon,
-                        actions = actions,
+                        actions = {
+                            Box(modifier = Modifier.padding(end = 12.dp)) {
+                                Row(
+                                    horizontalArrangement = Arrangement.spacedBy(8.dp)
+                                ) { actions() }
+                            }
+                        },
                         scrollBehavior = (scrollBehavior as? M3GlassScrollBehavior)?.m3Behavior,
                         colors = transparentColors
                     )
@@ -153,7 +166,15 @@ fun GlassMediumFlexibleTopAppBar(
                             }
                         },
                         navigationIcon = navigationIcon,
-                        actions = actions,
+                        actions = {
+                            Box(modifier = Modifier.padding(end = 12.dp)) {
+                                Row(
+                                    horizontalArrangement = Arrangement.spacedBy(8.dp)
+                                ) {
+                                    actions()
+                                }
+                            }
+                        },
                         scrollBehavior = (scrollBehavior as? M3GlassScrollBehavior)?.m3Behavior,
                         colors = transparentColors
                     )

+ 7 - 14
app/src/main/java/io/legado/app/ui/widget/components/topbar/TopBarButton.kt

@@ -30,13 +30,13 @@ import io.legado.app.R
 import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.ThemeResolver
-import io.legado.app.ui.widget.components.button.AnimatedActionButtonCore
-import io.legado.app.ui.widget.components.button.AnimatedIcon
+import io.legado.app.ui.widget.components.button.series.AnimatedActionButtonCore
+import io.legado.app.ui.widget.components.button.series.AnimatedIcon
 import io.legado.app.ui.widget.components.icon.AppIcons
 import top.yukonga.miuix.kmp.theme.MiuixTheme
-import top.yukonga.miuix.kmp.basic.Text as MiuixText
 import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
 import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton
+import top.yukonga.miuix.kmp.basic.Text as MiuixText
 
 @Composable
 private fun TopBarButton(
@@ -112,7 +112,7 @@ fun TopBarActionButton(
                 onClick = onClick,
                 imageVector = imageVector,
                 contentDescription = contentDescription,
-                modifier = modifier.padding(end = 12.dp)
+                modifier = modifier
             )
         } else {
             IconButton(
@@ -140,16 +140,10 @@ fun TopBarAnimatedActionButton(
     modifier: Modifier = Modifier
 ) {
     if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) {
-        val containerColor by animateColorAsState(
-            targetValue = if (checked) MiuixTheme.colorScheme.primaryContainer else MiuixTheme.colorScheme.surfaceContainerHigh,
-            animationSpec = tween(150),
-            label = "MiuixActionButtonContainer"
-        )
-
         val contentColor by animateColorAsState(
-            targetValue = if (checked) MiuixTheme.colorScheme.onPrimaryContainer else MiuixTheme.colorScheme.onSurface,
+            targetValue = if (checked) MiuixTheme.colorScheme.primary else MiuixTheme.colorScheme.onSurface,
             animationSpec = tween(150),
-            label = "MiuixActionButtonContainer"
+            label = "MiuixActionButtonContent"
         )
 
         AnimatedActionButtonCore(
@@ -167,8 +161,7 @@ fun TopBarAnimatedActionButton(
             button = { buttonModifier, onToggle, content ->
                 MiuixIconButton(
                     onClick = { onToggle(!checked) },
-                    modifier = buttonModifier,
-                    backgroundColor = containerColor
+                    modifier = buttonModifier
                 ) {
                     Row(
                         verticalAlignment = Alignment.CenterVertically,

+ 30 - 6
app/src/main/java/io/legado/app/web/socket/BookSearchWebSocket.kt

@@ -1,24 +1,41 @@
 package io.legado.app.web.socket
 
-import io.ktor.server.websocket.*
-import io.ktor.websocket.*
+import io.ktor.server.websocket.DefaultWebSocketServerSession
+import io.ktor.websocket.CloseReason
+import io.ktor.websocket.Frame
+import io.ktor.websocket.close
+import io.ktor.websocket.readText
+import io.ktor.websocket.send
 import io.legado.app.R
-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.domain.model.BookSearchScope
+import io.legado.app.domain.model.MatchMode
 import io.legado.app.domain.usecase.BookSearchControl
 import io.legado.app.domain.usecase.BookSearchRequest
 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.utils.*
-import kotlinx.coroutines.*
+import io.legado.app.utils.GSON
+import io.legado.app.utils.fromJsonObject
+import io.legado.app.utils.isJson
+import io.legado.app.utils.printOnDebug
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
 import org.koin.core.context.GlobalContext
 import splitties.init.appCtx
 
 class BookSearchWebSocket(private val session: DefaultWebSocketServerSession) : CoroutineScope by session {
 
     private val searchBooksUseCase: SearchBooksUseCase by lazy { GlobalContext.get().get() }
+    private val localPreferencesRepository: LocalPreferencesRepository by lazy {
+        GlobalContext.get().get()
+    }
     private val searchControl = BookSearchControl()
     private val sentBookUrls = linkedSetOf<String>()
     private var searchJob: Job? = null
@@ -66,7 +83,14 @@ class BookSearchWebSocket(private val session: DefaultWebSocketServerSession) :
                             keyword = key,
                             page = 1,
                             scope = BookSearchScope(AppConfig.searchScope),
-                            precision = appCtx.getPrefBoolean(PreferKey.precisionSearch),
+                            matchMode = MatchMode.of(
+                                localPreferencesRepository
+                                    .getPreference(
+                                        LocalPreferencesKeys.MATCH_MODE,
+                                        MatchMode.DEFAULT.value
+                                    )
+                                    .first()
+                            ),
                             concurrency = OtherConfig.threadCount,
                         ),
                         searchControl

+ 4 - 2
app/src/main/res/values-zh-rCN/strings.xml

@@ -24,7 +24,7 @@
     <string name="dialog_setting">去设置</string>
     <string name="tip_cannot_jump_setting_page">无法跳转至设置界面</string>
 
-    <string name="manual_input">手动输入</string>
+    <string name="manual_input">输入URL</string>
     <string name="preferred_contrast">偏好对比度</string>
     <string name="contrast_source_system_summary">来源:系统对比度</string>
     <string name="contrast_source_manual_summary">来源:手动输入</string>
@@ -899,7 +899,7 @@
     <string name="body_title">正文标题</string>
     <string name="show_hide">显示/隐藏</string>
     <string name="header_footer">页眉与页脚</string>
-    <string name="rule_subscription">规则订阅</string>
+    <string name="rule_subscription">规则</string>
     <string name="rule_sub_empty_msg">添加大佬们提供的规则导入地址\n添加后点击可导入规则</string>
     <string name="get_book_progress">拉取云端进度</string>
     <string name="cover_book_progress">覆盖云端进度</string>
@@ -1648,6 +1648,8 @@
     <string name="search_source_progress"> · 进度 %1$d/%2$d</string>
     <string name="search_empty_scope_disable_precision">%1$s分组搜索结果为空,是否关闭精准搜索?</string>
     <string name="search_empty_scope_switch_all">%1$s分组搜索结果为空,是否切换到全部分组?</string>
+    <string name="search_layout_list">列表布局</string>
+    <string name="search_layout_source_grouped">来源分组</string>
     <string name="download_cache_config">下载与缓存</string>
     <string name="http_cache">HTTP缓存</string>
     <string name="cover_cache">封面缓存</string>

+ 2 - 0
app/src/main/res/values/strings.xml

@@ -1684,6 +1684,8 @@
     <string name="search_source_progress"> · Progress %1$d/%2$d</string>
     <string name="search_empty_scope_disable_precision">%1$s group search returned no results. Disable precise search?</string>
     <string name="search_empty_scope_switch_all">%1$s group search returned no results. Switch to all groups?</string>
+    <string name="search_layout_list">List layout</string>
+    <string name="search_layout_source_grouped">Source grouped</string>
     <string name="download_cache_config">Download &amp; Cache</string>
     <string name="http_cache">HTTP Cache</string>
     <string name="cover_cache">Cover Cache</string>

+ 3 - 0
docs/.gitignore

@@ -0,0 +1,3 @@
+node_modules/
+.vitepress/dist/
+.vitepress/cache/

+ 168 - 0
docs/.vitepress/config.ts

@@ -0,0 +1,168 @@
+import { defineConfig } from 'vitepress'
+
+export default defineConfig({
+  title: '阅读',
+  description: 'Legado-with-MD3 使用文档',
+  base: '/legado-with-MD3/',
+  head: [
+    ['link', { rel: 'icon', href: '/legado-with-MD3/favicon.ico' }]
+  ],
+  themeConfig: {
+    logo: '/logo.png',
+    siteTitle: 'Legado Docs',
+    nav: [
+      { text: '首页', link: '/' },
+      { text: '快速开始', link: '/guide/' },
+      { text: '开发文档', link: '/dev/' },
+    ],
+    sidebar: {
+      '/guide/': [
+        {
+          text: '使用指南',
+          items: [
+            { text: '帮助文档', link: '/guide/' },
+            { text: '阅读界面', link: '/guide/reading' },
+            { text: '导入源管理', link: '/guide/book-source' },
+            { text: '订阅源管理', link: '/guide/rss-source' },
+            { text: '替换规则', link: '/guide/replace-rule' },
+          ],
+        },
+        {
+          text: '教程',
+          items: [
+            { text: 'WebDAV 备份教程', link: '/tutorial/webdav-backup' },
+            { text: 'WebDAV 书籍同步', link: '/tutorial/webdav-book' },
+          ],
+        },
+      ],
+      '/tutorial/': [
+        {
+          text: '使用指南',
+          items: [
+            { text: '帮助文档', link: '/guide/' },
+            { text: '阅读界面', link: '/guide/reading' },
+            { text: '导入源管理', link: '/guide/book-source' },
+            { text: '订阅源管理', link: '/guide/rss-source' },
+            { text: '替换规则', link: '/guide/replace-rule' },
+          ],
+        },
+        {
+          text: '教程',
+          items: [
+            { text: 'WebDAV 备份教程', link: '/tutorial/webdav-backup' },
+            { text: 'WebDAV 书籍同步', link: '/tutorial/webdav-book' },
+          ],
+        },
+      ],
+      '/dev/': [
+        {
+          text: '入门',
+          items: [
+            { text: '规则语法详解', link: '/dev/syntax' },
+            { text: 'URL 参数详解', link: '/dev/url-options' },
+            { text: '源字段速查', link: '/dev/source-fields' },
+            { text: '源示例', link: '/dev/examples' },
+          ],
+        },
+        {
+          text: '参考',
+          items: [
+            { text: '源规则帮助', link: '/dev/rule' },
+            { text: 'JS 变量和函数', link: '/dev/js' },
+            { text: 'XPath 路径表达式', link: '/dev/xpath' },
+            { text: '正则表达式', link: '/dev/regex' },
+          ],
+        },
+        {
+          text: '配置规范',
+          items: [
+            { text: '请求头配置', link: '/dev/request-headers' },
+            { text: '认证与登录', link: '/dev/authentication' },
+            { text: '发现 URL 配置', link: '/dev/discovery-url' },
+            { text: '首页模块配置', link: '/spec/homepage-modules' },
+            { text: '关联书籍配置', link: '/spec/related-books' },
+          ],
+        },
+        {
+          text: '扩展功能',
+          items: [
+            { text: '源调试', link: '/dev/debug' },
+            { text: '字典规则', link: '/dev/dict-rule' },
+            { text: '在线朗读规则', link: '/dev/tts-rule' },
+            { text: 'TXT 目录正则', link: '/dev/txt-toc' },
+            { text: 'MIME 类型参考', link: '/spec/mime-types' },
+          ],
+        },
+      ],
+      '/spec/': [
+        {
+          text: '入门',
+          items: [
+            { text: '规则语法详解', link: '/dev/syntax' },
+            { text: 'URL 参数详解', link: '/dev/url-options' },
+            { text: '源字段速查', link: '/dev/source-fields' },
+            { text: '源示例', link: '/dev/examples' },
+          ],
+        },
+        {
+          text: '参考',
+          items: [
+            { text: '源规则帮助', link: '/dev/rule' },
+            { text: 'JS 变量和函数', link: '/dev/js' },
+            { text: 'XPath 路径表达式', link: '/dev/xpath' },
+            { text: '正则表达式', link: '/dev/regex' },
+          ],
+        },
+        {
+          text: '配置规范',
+          items: [
+            { text: '请求头配置', link: '/dev/request-headers' },
+            { text: '认证与登录', link: '/dev/authentication' },
+            { text: '发现 URL 配置', link: '/dev/discovery-url' },
+            { text: '首页模块配置', link: '/spec/homepage-modules' },
+            { text: '关联书籍配置', link: '/spec/related-books' },
+          ],
+        },
+        {
+          text: '扩展功能',
+          items: [
+            { text: '源调试', link: '/dev/debug' },
+            { text: '字典规则', link: '/dev/dict-rule' },
+            { text: '在线朗读规则', link: '/dev/tts-rule' },
+            { text: 'TXT 目录正则', link: '/dev/txt-toc' },
+            { text: 'MIME 类型参考', link: '/spec/mime-types' },
+          ],
+        },
+      ],
+    },
+    socialLinks: [
+      { icon: 'github', link: 'https://github.com/HapeLee/legado-with-MD3' },
+    ],
+    footer: {
+      message: '基于 Apache-2.0 许可发布',
+      copyright: 'Copyright © 2026 Legado',
+    },
+    search: {
+      provider: 'local',
+    },
+    outline: {
+      level: [2, 3],
+      label: '页面导航',
+    },
+    docFooter: {
+      prev: '上一页',
+      next: '下一页',
+    },
+    lastUpdated: {
+      text: '最后更新于',
+    },
+    editLink: {
+      pattern: 'https://github.com/HapeLee/legado-with-MD3/edit/main/docs/:path',
+      text: '在 GitHub 上编辑此页面',
+    },
+  },
+  lastUpdated: true,
+  markdown: {
+    lineNumbers: true,
+  },
+})

+ 129 - 0
docs/dev/authentication.md

@@ -0,0 +1,129 @@
+# 认证与登录 (Authentication)
+
+[[toc]]
+
+Legado 支持通过 CookieJar、登录 UI 表单和登录 URL 脚本三种方式实现网站认证。
+
+## 1. CookieJar
+
+启用后自动保存每次响应头中的 `Set-Cookie` 值,适用于需要 session 的网站(如验证码图片)。
+
+在源编辑器中勾选「启用 CookieJar」即可,无需额外配置。
+
+## 2. 登录 UI (Login UI)
+
+通过 JSON 数组定义登录表单,替代内置 WebView 登录方式。
+
+### 字段定义
+
+| 字段       | 类型       | 必须 | 说明                     |
+|:---------|:---------|:---|:-----------------------|
+| `name`   | `String` | 是  | 字段名称,显示为输入框标签          |
+| `type`   | `String` | 是  | 字段类型                   |
+| `action` | `String` | 否  | 仅 `button` 类型:点击时执行的动作 |
+| `style`  | `Object` | 否  | Flexbox 布局配置           |
+
+### type 取值
+
+| 值          | 说明                                     |
+|:-----------|:---------------------------------------|
+| `text`     | 文本输入框                                  |
+| `password` | 密码输入框                                  |
+| `button`   | 可点击按钮,`action` 为 URL 时打开浏览器,为函数名时调用 JS |
+
+### JSON 示例
+
+```json
+[
+    { "name": "telephone", "type": "text" },
+    { "name": "password", "type": "password" },
+    {
+        "name": "注册",
+        "type": "button",
+        "action": "http://www.example.com/register"
+    },
+    {
+        "name": "获取验证码",
+        "type": "button",
+        "action": "getVerificationCode()",
+        "style": {
+            "layout_flexGrow": 0,
+            "layout_flexShrink": 1,
+            "layout_alignSelf": "auto",
+            "layout_flexBasisPercent": -1,
+            "layout_wrapBefore": false
+        }
+    }
+]
+```
+
+### style 布局属性
+
+| 属性                        | 类型        | 说明             |
+|:--------------------------|:----------|:---------------|
+| `layout_flexGrow`         | `Int`     | 弹性增长比例         |
+| `layout_flexShrink`       | `Int`     | 弹性收缩比例         |
+| `layout_alignSelf`        | `String`  | 交叉轴对齐方式        |
+| `layout_flexBasisPercent` | `Int`     | 基础尺寸百分比,-1 为自动 |
+| `layout_wrapBefore`       | `Boolean` | 是否强制换行         |
+
+::: tip 版本变更
+从版本 20221113 起,按钮支持调用「登录 URL」规则中的函数,必须实现 `login` 函数。
+:::
+
+## 3. 登录 URL (Login URL)
+
+可填写登录链接或实现登录逻辑的 JavaScript。配合登录 UI 使用时,需要实现 `login` 函数。
+
+### JS 示例
+
+```js
+function login() {
+    java.log("模拟登录请求");
+    java.log(source.getLoginInfoMap());
+}
+function getVerificationCode() {
+    java.log("登录UI按钮:获取到手机号码" + result.get("telephone"))
+}
+```
+
+### 获取登录信息
+
+在登录按钮函数和 `login` 函数中,可通过以下方式获取用户输入:
+
+```js
+// 登录按钮函数中
+result.get("telephone")
+
+// login 函数中
+source.getLoginInfo()
+source.getLoginInfoMap().get("telephone")
+```
+
+### source 登录相关方法
+
+| 方法                             | 返回值       | 说明        |
+|:-------------------------------|:----------|:----------|
+| `login()`                      | —         | 执行登录      |
+| `getHeaderMap(hasLoginHeader)` | `Map`     | 获取请求头     |
+| `getLoginHeader()`             | `String?` | 获取登录头字符串  |
+| `getLoginHeaderMap()`          | `Map?`    | 获取登录头 Map |
+| `putLoginHeader(header)`       | —         | 保存登录头     |
+| `removeLoginHeader()`          | —         | 清除登录头     |
+| `setVariable(variable)`        | —         | 设置源变量     |
+| `getVariable()`                | `String?` | 获取源变量     |
+
+### AnalyzeUrl 函数
+
+以下函数仅在「登录检查 JS」规则中有效:
+
+| 方法                                                 | 返回值           | 说明                  |
+|:---------------------------------------------------|:--------------|:--------------------|
+| `initUrl()`                                        | —             | 重新解析 URL            |
+| `getHeaderMap().putAll(source.getHeaderMap(true))` | —             | 重新设置登录头             |
+| `getStrResponse(jsStr, sourceRegex)`               | `StrResponse` | 返回文本类型的访问结果         |
+| `getResponse()`                                    | `Response`    | 返回 Response 类型的访问结果 |
+
+## 4. 登录检查 JS (Login Check)
+
+在源的「登录检查 JS」字段中填写 JavaScript,用于判断当前登录状态。返回 `true` 表示已登录,返回其他值会触发重新登录流程。

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно