diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000..ac8672983 --- /dev/null +++ b/.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 diff --git a/CLAUDE.md b/CLAUDE.md index 742f67c17..4dbf25fa6 100644 --- a/CLAUDE.md +++ b/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 = 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(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, // 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 { route -> + val viewModel = koinViewModel() + 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(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 diff --git a/app/src/main/assets/disclaimer.md b/app/src/main/assets/disclaimer.md index f6870b2c3..a442e5768 100644 --- a/app/src/main/assets/disclaimer.md +++ b/app/src/main/assets/disclaimer.md @@ -1,14 +1,68 @@ -* 阅读是一款解析指定规则并获取内容的工具,为广大网络文学爱好者提供一种方便、快捷舒适的试读体验。 -* 当您搜索一本书的时,阅读会您所使用的规则将该书的书名以关键词的形式提交到各个第三方网络文学网站。 -各第三方网站返回的内容与阅读无关,阅读对其概不负责,亦不承担任何法律责任。 -任何通过使用阅读而链接到的第三方网页均系他人制作或提供,您可能从第三方网页上获得其他服务,阅读对其合法性概不负责,亦不承担任何法律责任。 -第三方搜索引擎结果根据您提交的书名自动搜索获得并提供试读,不代表阅读赞成或被搜索链接到的第三方网页上的内容或立场。 -您应该对使用搜索引擎的结果自行承担风险。 -* 阅读不做任何形式的保证:不保证第三方搜索引擎的搜索结果满足您的要求,不保证搜索服务不中断,不保证搜索结果的安全性、正确性、及时性、合法性。 -因网络状况、通讯线路、第三方网站等任何原因而导致您不能正常使用阅读,阅读不承担任何法律责任。 -阅读尊重并保护所有使用阅读用户的个人隐私权,您注册的用户名、电子邮件地址等个人资料,非经您亲自许可或根据相关法律、法规的强制性规定,阅读不会主动地泄露给第三方。 -* 阅读致力于最大程度地减少网络文学阅读者在自行搜寻过程中的无意义的时间浪费,通过专业搜索展示不同网站中网络文学的最新章节。 -阅读在为广大小说爱好者提供方便、快捷舒适的试读体验的同时,也使优秀网络文学得以迅速、更广泛的传播,从而达到了在一定程度促进网络文学充分繁荣发展之目的。 -阅读鼓励广大小说爱好者通过阅读发现优秀网络小说及其提供商,并建议阅读正版图书。 -任何单位或个人认为通过阅读搜索链接到的第三方网页内容可能涉嫌侵犯其信息网络传播权,应该及时向阅读提出书面权力通知,并提供身份证明、权属证明及详细侵权情况证明。 -阅读在收到上述法律文件后,将会依法尽快断开相关链接内容。 \ No newline at end of file +--- + +# 用户协议与免责声明 + +## 【特别提示】 + +在下载、安装或使用本软件前,请您务必仔细阅读并充分理解本协议及免责声明的全部内容。您一旦下载、安装或使用本软件,即视为已阅读、理解并同意接受本声明的全部内容。 + +--- + +# 一、软件性质说明 + +1. 本软件是一款用户可配置的本地化网页内容浏览工具,提供网页访问、内容解析、文本提取、阅读排版与数据管理等技术功能。 + +2. 本软件默认状态下不预置、不内置、不提供任何第三方网站内容、数据资源或解析规则。 + +3. 本软件开发者不提供任何内容运营、内容存储、内容发布或内容传播服务。 + +4. 用户可基于自身需求,自行配置或导入第三方规则,以实现对公开网页内容的个性化浏览与处理。 + +--- + +# 二、用户行为与规则说明 + +1. 用户可自行创建、编辑、导入或使用第三方分享的解析规则(以下简称“规则”)。 + +2. 相关规则仅用于定义网页内容的获取、提取与显示方式,其来源、合法性、准确性与适用性均由用户自行判断并承担责任。 + +3. 用户使用规则访问第三方网站时,相关网络请求由用户设备直接向目标网站发起并接收数据。本软件仅提供本地解析与展示能力,不对第三方网站内容进行修改、编辑或再分发。 + +4. 用户应遵守所在地法律法规、网络安全要求以及相关网站的服务协议与版权规范,不得利用本软件从事侵犯知识产权、非法传播、未授权数据获取、破坏网络服务或其他违法违规行为。 + +--- + +# 三、第三方内容与社区说明 + +1. 任何由第三方建立或维护的规则分享平台、论坛、交流群组、网站或其他社区,均属于独立运营的第三方平台,与本软件开发者无隶属关系。 + +2. 开发者不参与第三方规则、内容或社区的制作、发布、运营、维护与传播行为,亦不对相关内容承担主动审查义务。 + +3. 用户因使用第三方规则或访问第三方网站而产生的风险,包括但不限于版权纠纷、数据安全风险、网络访问风险或其他法律风险,应由相关行为主体依法承担相应责任。 + +--- + +# 四、隐私与数据说明 + +1. 本软件主要功能运行于用户本地设备,不设立用于提供网页内容服务的自有内容服务器。 + +2. 本软件不会主动收集、上传或存储用户的阅读内容、规则列表、浏览记录或其他个人隐私数据。 + +3. 为提升软件稳定性与兼容性,本软件可能集成第三方统计或崩溃分析服务(如 Firebase Crashlytics + 等),用于收集匿名化的崩溃日志、性能信息及基础设备信息。 + +4. 部分网络、存储或同步权限,仅用于实现用户主动启用的本地备份、WebDAV 同步或跨设备数据同步等功能。 + +--- + +# 五、知识产权保护 + +1. 开发者尊重并保护知识产权权利人的合法权益,并反对任何侵犯著作权、商标权或其他合法权益的行为。 + +2. 用户应确保其使用本软件获取、处理或访问相关内容的行为符合适用法律法规及权利要求。 + +3. 若权利人认为某些第三方规则存在涉嫌侵权情形,可依法向相关内容实际托管方主张权利。 + +4. 权利人亦可向开发者提交包含身份证明、权属证明、具体规则信息及相关说明的有效通知。开发者将在合理技术能力范围内,对涉嫌违规规则采取必要处理措施。 + +--- \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt b/app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt index 669b335fa..1fd62d84e 100644 --- a/app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt +++ b/app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt @@ -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 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") } diff --git a/app/src/main/java/io/legado/app/data/repository/ExploreRepository.kt b/app/src/main/java/io/legado/app/data/repository/ExploreRepository.kt index 338b5592a..863d00ff5 100644 --- a/app/src/main/java/io/legado/app/data/repository/ExploreRepository.kt +++ b/app/src/main/java/io/legado/app/data/repository/ExploreRepository.kt @@ -80,9 +80,10 @@ class ExploreRepositoryImpl( override suspend fun exploreBooks( bookSource: BookSource, url: String, - page: Int + page: Int, + key: String? ): List { - return WebBook.exploreBookSuspend(bookSource, url, page) + return WebBook.exploreBookSuspend(bookSource, url, page, key = key, isSearch = key != null) } override suspend fun getSourceExploreKinds(sourceUrl: String): List = withContext(IO) { diff --git a/app/src/main/java/io/legado/app/domain/gateway/ExploreBooksGateway.kt b/app/src/main/java/io/legado/app/domain/gateway/ExploreBooksGateway.kt index f549c0a59..d1229e15e 100644 --- a/app/src/main/java/io/legado/app/domain/gateway/ExploreBooksGateway.kt +++ b/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 + suspend fun exploreBooks( + bookSource: BookSource, + url: String, + page: Int, + key: String? = null + ): List } diff --git a/app/src/main/java/io/legado/app/domain/model/MatchMode.kt b/app/src/main/java/io/legado/app/domain/model/MatchMode.kt new file mode 100644 index 000000000..852a43419 --- /dev/null +++ b/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 } + } +} diff --git a/app/src/main/java/io/legado/app/domain/usecase/ExploreBooksUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/ExploreBooksUseCase.kt index d43034502..b9f7b1a2f 100644 --- a/app/src/main/java/io/legado/app/domain/usecase/ExploreBooksUseCase.kt +++ b/app/src/main/java/io/legado/app/domain/usecase/ExploreBooksUseCase.kt @@ -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) } diff --git a/app/src/main/java/io/legado/app/domain/usecase/SearchBooksUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/SearchBooksUseCase.kt index 60a10286b..3a381945c 100644 --- a/app/src/main/java/io/legado/app/domain/usecase/SearchBooksUseCase.kt +++ b/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? = 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 } } diff --git a/app/src/main/java/io/legado/app/model/webBook/WebBook.kt b/app/src/main/java/io/legado/app/model/webBook/WebBook.kt index 9240f3b41..6b9346658 100644 --- a/app/src/main/java/io/legado/app/model/webBook/WebBook.kt +++ b/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 { 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 { - return exploreBookAwait(bookSource, url, page, ruleData) + return exploreBookAwait(bookSource, url, page, ruleData, key, isSearch) } suspend fun exploreBookWithResolvedUrl( diff --git a/app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageScreen.kt b/app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageScreen.kt index 0b7fbfe19..815c0a5ea 100644 --- a/app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageScreen.kt +++ b/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 ) } diff --git a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowContract.kt b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowContract.kt new file mode 100644 index 000000000..6791a83b8 --- /dev/null +++ b/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 = persistentListOf(), + val kinds: ImmutableList = 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 +} diff --git a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt index 4ebfb255f..89cadf4ac 100644 --- a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt @@ -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, ) { + 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, + ) + + is ExploreShowEffect.ShowMessage -> {} + } + } + } + var previewBook by remember { mutableStateOf(null) } var previewSharedCoverKey by remember { mutableStateOf(null) } - LaunchedEffect(sourceUrl, exploreUrl, viewModel) { - viewModel.initData(sourceUrl, exploreUrl) - } - - val rawBooks by viewModel.uiBooks.collectAsState() 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 ) } - diff --git a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt index cc0da0334..44b0d9b61 100644 --- a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt @@ -6,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 { - data object Loading : UiState() - data class Success(val data: T) : UiState() - data class Error(val message: String) : UiState() - data object Empty : UiState() -} - -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>(emptyList()) + private val _bookshelf = MutableStateFlow>(emptySet()) private val _isLoading = MutableStateFlow(false) - private val _isRefreshing = MutableStateFlow(false) + private val _isEnd = MutableStateFlow(false) private val _errorMsg = MutableStateFlow(null) + private val _kinds = MutableStateFlow>(emptyList()) + private val _selectedKindTitle = MutableStateFlow(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>(emptySet()) - private val _kinds = MutableStateFlow>(emptyList()) - val kinds = _kinds.asStateFlow() - private val _selectedKindTitle = MutableStateFlow(null) - val selectedKindTitle = _selectedKindTitle.asStateFlow() - private val _layoutState = MutableStateFlow(AppConfig.exploreLayoutState) // 0=列表, 1=网格 - val layoutState: StateFlow = _layoutState.asStateFlow() - private val _gridCount = MutableStateFlow(appCtx.exploreLayoutGrid) - val gridCount = _gridCount.asStateFlow() - val isEnd: StateFlow = _isEndStateFlow.asStateFlow() - val isRefreshing = _isRefreshing.asStateFlow() + private val _uiState = MutableStateFlow( + ExploreShowUiState( + layoutState = AppConfig.exploreLayoutState, + gridCount = appCtx.exploreLayoutGrid, + ) + ) + val uiState = _uiState.asStateFlow() - fun saveGridCount(value: Int) { - appCtx.exploreLayoutGrid = value - _gridCount.value = value - } - - 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 - ) - ) - } - }.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() + private val _effects = MutableSharedFlow(extraBufferCapacity = 16) + val effects = _effects.asSharedFlow() init { - viewModelScope.launch { - repository.getBookshelfItems().collect { list -> - val keys = list.map { BookShelfKey(it.name, it.author, it.bookUrl) }.toSet() - _bookshelf.value = keys + observeBookshelf() + combineUiState() + } + + 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, + ) + ) + + is ExploreShowIntent.AddToShelf -> viewModelScope.launch { + addToBookshelfUseCase.execute(intent.book) } } } - fun initData(incomingSourceUrl: String?, incomingExploreUrl: String?) { - // 允许 incomingExploreUrl 为空,此时加载书源默认发现页 + private fun observeBookshelf() { + viewModelScope.launch { + repository.getBookshelfItems().collect { list -> + _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 + val bookshelf = values[1] as Set + val isLoading = values[2] as Boolean + val isEnd = values[3] as Boolean + val errorMsg = values[4] as String? + val kinds = values[5] as List + 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() + } + } + } + } + + 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) + } } - fun loadMore(isRefresh: Boolean = false) { + private fun saveGridCount(count: Int) { + appCtx.exploreLayoutGrid = count + _uiState.update { it.copy(gridCount = count) } + } + + 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) } } diff --git a/app/src/main/java/io/legado/app/ui/book/group/GroupEditSheet.kt b/app/src/main/java/io/legado/app/ui/book/group/GroupEditSheet.kt index 6c929afed..b4ac2613c 100644 --- a/app/src/main/java/io/legado/app/ui/book/group/GroupEditSheet.kt +++ b/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 ) } diff --git a/app/src/main/java/io/legado/app/ui/book/import/local/ImportBookScreen.kt b/app/src/main/java/io/legado/app/ui/book/import/local/ImportBookScreen.kt index 80488b2d9..fb19f67db 100644 --- a/app/src/main/java/io/legado/app/ui/book/import/local/ImportBookScreen.kt +++ b/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 ) } } diff --git a/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookScreen.kt b/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookScreen.kt index d84aeff04..c068a5f92 100644 --- a/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookScreen.kt +++ b/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, diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt index 660845acb..f3159140d 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt +++ b/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", ) } diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt index 138e4401f..abb7898b2 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt +++ b/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 ) diff --git a/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt b/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt index a1f1fb06f..e7ba1a3eb 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt +++ b/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 ) } diff --git a/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt b/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt index e4b7e3dba..0b28de08e 100644 --- a/app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt +++ b/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, ) }, diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewScreen.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewScreen.kt index 3e81955ee..d56385ae4 100644 --- a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewScreen.kt +++ b/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 ) } } diff --git a/app/src/main/java/io/legado/app/ui/book/search/ScopeSelectSheet.kt b/app/src/main/java/io/legado/app/ui/book/search/ScopeSelectSheet.kt index 7c925ca4e..8da45444b 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/ScopeSelectSheet.kt +++ b/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 ) } } diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt index 01d004271..fb6652d29 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt @@ -54,7 +54,7 @@ class SearchActivity : BaseComposeActivity() { }, onOpenSourceManage = { startActivity() - } + }, ) } diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt index 429bef7aa..434a4fee0 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt @@ -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 = emptyList(), - val history: List = emptyList(), - val bookshelfHints: List = emptyList(), - val enabledGroups: List = emptyList(), - val enabledSources: List = emptyList(), + val results: ImmutableList = persistentListOf(), + val history: ImmutableList = persistentListOf(), + val bookshelfHints: ImmutableList = persistentListOf(), + val enabledGroups: ImmutableList = persistentListOf(), + val enabledSources: ImmutableList = persistentListOf(), val scopeDisplay: String = "", - val scopeDisplayNames: List = emptyList(), + val scopeDisplayNames: ImmutableList = persistentListOf(), val selectedScopeSourceUrls: Set = 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 = 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 = 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 diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt index 5e099fc68..f3c6bb72d 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt @@ -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(null) } var previewSharedCoverKey by remember { mutableStateOf(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( - 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 - ) - ) - }, - onLongClick = { book, coverKey -> - previewBook = book - previewSharedCoverKey = coverKey - }, - sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, - sharedCoverKey = sharedCoverKey - ) - } + 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 + ) + } + } - item { - SearchResultFooter( - isSearching = state.isSearching, - hasMore = state.hasMore, - hasResult = true, - committedQuery = state.committedQuery, - onLoadMore = { viewModel.onIntent(SearchIntent.LoadMore) }, - ) + AnimatedContent( + targetState = isSourceGroupedMode, + label = "SearchLayoutTransition", + modifier = Modifier.fillMaxSize(), + ) { 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) }, + ) + } + } + } 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, +) + +@Composable +private fun ExpandedSourceSheet( + show: Boolean, + sourceName: String, + books: List, + 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, + ) + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchSourceSection.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchSourceSection.kt new file mode 100644 index 000000000..1e13f28dc --- /dev/null +++ b/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, + 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, + ) + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt b/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt index 2889a34e7..5d5fb4dd7 100644 --- a/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt @@ -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) } diff --git a/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt b/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt index ee0cc3af4..f3672607b 100644 --- a/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt +++ b/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 = "清除搜索历史" ) } diff --git a/app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt b/app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt index 77183c93c..3d299907f 100644 --- a/app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt +++ b/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( diff --git a/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt b/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt index 797a8aa54..8d25b810c 100644 --- a/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt +++ b/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 ) } ) diff --git a/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverRuleConfigSheet.kt b/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverRuleConfigSheet.kt index e85cee18e..22f7de726 100644 --- a/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverRuleConfigSheet.kt +++ b/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) diff --git a/app/src/main/java/io/legado/app/ui/config/otherConfig/DirectLinkUploadBottomSheet.kt b/app/src/main/java/io/legado/app/ui/config/otherConfig/DirectLinkUploadBottomSheet.kt index 8f8729c47..9f26076a8 100644 --- a/app/src/main/java/io/legado/app/ui/config/otherConfig/DirectLinkUploadBottomSheet.kt +++ b/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, diff --git a/app/src/main/java/io/legado/app/ui/config/themeConfig/BackgroundImageManageSheet.kt b/app/src/main/java/io/legado/app/ui/config/themeConfig/BackgroundImageManageSheet.kt index a3d6d2d79..3a724a3bb 100644 --- a/app/src/main/java/io/legado/app/ui/config/themeConfig/BackgroundImageManageSheet.kt +++ b/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 ) } } diff --git a/app/src/main/java/io/legado/app/ui/config/themeConfig/LabelColorManageSheet.kt b/app/src/main/java/io/legado/app/ui/config/themeConfig/LabelColorManageSheet.kt index 15ee1121a..80ce5334c 100644 --- a/app/src/main/java/io/legado/app/ui/config/themeConfig/LabelColorManageSheet.kt +++ b/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 ) } } diff --git a/app/src/main/java/io/legado/app/ui/config/themeConfig/NavIconManageSheet.kt b/app/src/main/java/io/legado/app/ui/config/themeConfig/NavIconManageSheet.kt index 2b47ecbe3..434e1da6b 100644 --- a/app/src/main/java/io/legado/app/ui/config/themeConfig/NavIconManageSheet.kt +++ b/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 { diff --git a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt index d04728771..4ed50ccd7 100644 --- a/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt +++ b/app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt @@ -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) } ) diff --git a/app/src/main/java/io/legado/app/ui/config/themeManage/EditThemeSheet.kt b/app/src/main/java/io/legado/app/ui/config/themeManage/EditThemeSheet.kt index 5496cba9c..6e9cbd6f1 100644 --- a/app/src/main/java/io/legado/app/ui/config/themeManage/EditThemeSheet.kt +++ b/app/src/main/java/io/legado/app/ui/config/themeManage/EditThemeSheet.kt @@ -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" ) } diff --git a/app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageScreen.kt b/app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageScreen.kt index 016928dc9..b5e0c602e 100644 --- a/app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageScreen.kt +++ b/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 ) } } diff --git a/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleEditSheet.kt b/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleEditSheet.kt deleted file mode 100644 index 4f9fa9118..000000000 --- a/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleEditSheet.kt +++ /dev/null @@ -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 - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt b/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt index 89aefe0fb..cee923bc0 100644 --- a/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt +++ b/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(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 ) } ) diff --git a/app/src/main/java/io/legado/app/ui/main/MainActivity.kt b/app/src/main/java/io/legado/app/ui/main/MainActivity.kt index c8972f406..34165cc65 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainActivity.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainActivity.kt @@ -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) } diff --git a/app/src/main/java/io/legado/app/ui/main/MainIntent.kt b/app/src/main/java/io/legado/app/ui/main/MainIntent.kt index 0f70a3c8b..f14cabc33 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainIntent.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainIntent.kt @@ -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) diff --git a/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt b/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt index 1156b1805..15b818ddf 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt @@ -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 { route -> + val exploreViewModel = koinViewModel() + + 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( diff --git a/app/src/main/java/io/legado/app/ui/main/MainNavigator.kt b/app/src/main/java/io/legado/app/ui/main/MainNavigator.kt index fc9a82973..fe2197638 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainNavigator.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainNavigator.kt @@ -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 diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt index c486447ac..bd4c54c54 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt @@ -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) ) } diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt index 17b877585..43e20f052 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt @@ -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>> = 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>> = + 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>()) { 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>> = - 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> = 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? ) - 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, - 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>>( - 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 diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/GroupManageSheet.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/GroupManageSheet.kt index a25e76897..2888b82e3 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/GroupManageSheet.kt +++ b/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( diff --git a/app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt b/app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt index 63848142d..c782d9eeb 100644 --- a/app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt @@ -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(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) diff --git a/app/src/main/java/io/legado/app/ui/main/explore/ExploreViewModel.kt b/app/src/main/java/io/legado/app/ui/main/explore/ExploreViewModel.kt index 61b027fcb..de1ce9d59 100644 --- a/app/src/main/java/io/legado/app/ui/main/explore/ExploreViewModel.kt +++ b/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 = _uiState - .map { state -> state.copy(listItems = buildExploreListItems(state)) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ExploreUiState()) private val _effects = MutableSharedFlow(extraBufferCapacity = 8) val effects = _effects.asSharedFlow() @@ -200,11 +198,10 @@ class ExploreViewModel( val exploreKinds: ImmutableList = persistentListOf(), val kindDisplayNames: ImmutableMap = persistentMapOf(), val kindValues: ImmutableMap = persistentMapOf(), - val loadingKinds: Boolean = false, - val listItems: ImmutableList = persistentListOf() + val loadingKinds: Boolean = false ) : ListUiState - private fun buildExploreListItems(state: ExploreUiState): ImmutableList { + fun buildExploreListItems(state: ExploreUiState): ImmutableList { if (state.items.isEmpty()) return persistentListOf() val expandedId = state.expandedId val kindRows = if (expandedId != null) { diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleManageSheet.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleManageSheet.kt index 84f72c697..6a0a33205 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleManageSheet.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageModuleManageSheet.kt @@ -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 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, diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt index 2e7a41d1d..675673854 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt @@ -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 ) } } diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt index f916f8b15..b916ef690 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt @@ -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) diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/manage/SetDetailPage.kt b/app/src/main/java/io/legado/app/ui/main/homepage/manage/SetDetailPage.kt index 3e6f0f86f..b5fb74381 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/manage/SetDetailPage.kt +++ b/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 ) } ) diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/manage/SetListPage.kt b/app/src/main/java/io/legado/app/ui/main/homepage/manage/SetListPage.kt index ee703a5fb..6a9b73e1a 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/manage/SetListPage.kt +++ b/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 ) } ) diff --git a/app/src/main/java/io/legado/app/ui/main/homepage/manage/SourceBrowseDetailPage.kt b/app/src/main/java/io/legado/app/ui/main/homepage/manage/SourceBrowseDetailPage.kt index 195c2fbe9..5139c24ac 100644 --- a/app/src/main/java/io/legado/app/ui/main/homepage/manage/SourceBrowseDetailPage.kt +++ b/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 ) } } diff --git a/app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt b/app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt index c57a0c336..31d7e7790 100644 --- a/app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt +++ b/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) ) } } diff --git a/app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditScreen.kt b/app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditScreen.kt index 840b4f124..9b5316134 100644 --- a/app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditScreen.kt +++ b/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 ) } } diff --git a/app/src/main/java/io/legado/app/ui/rss/article/RssSortScreen.kt b/app/src/main/java/io/legado/app/ui/rss/article/RssSortScreen.kt index bce6868c3..c228f15b7 100644 --- a/app/src/main/java/io/legado/app/ui/rss/article/RssSortScreen.kt +++ b/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) ) } diff --git a/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesScreen.kt b/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesScreen.kt index a60b1ad29..c7640cc56 100644 --- a/app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesScreen.kt +++ b/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" ) }, diff --git a/app/src/main/java/io/legado/app/ui/rss/read/RssReadRouteScreen.kt b/app/src/main/java/io/legado/app/ui/rss/read/RssReadRouteScreen.kt index bc922e627..3aca4ad5d 100644 --- a/app/src/main/java/io/legado/app/ui/rss/read/RssReadRouteScreen.kt +++ b/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) ) } diff --git a/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceScreen.kt b/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceScreen.kt index 993dc281b..c0c35b5ea 100644 --- a/app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceScreen.kt +++ b/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 ) } ) diff --git a/app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubScreen.kt b/app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubScreen.kt index 1167b7cfb..36f9a1b8c 100644 --- a/app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubScreen.kt +++ b/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( diff --git a/app/src/main/java/io/legado/app/ui/theme/AdaptivePadding.kt b/app/src/main/java/io/legado/app/ui/theme/AdaptivePadding.kt index df59a4bdf..825783977 100644 --- a/app/src/main/java/io/legado/app/ui/theme/AdaptivePadding.kt +++ b/app/src/main/java/io/legado/app/ui/theme/AdaptivePadding.kt @@ -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, diff --git a/app/src/main/java/io/legado/app/ui/widget/components/AppSlider.kt b/app/src/main/java/io/legado/app/ui/widget/components/AppSlider.kt new file mode 100644 index 000000000..3b2acc148 --- /dev/null +++ b/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 = 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 + ) + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/EmptyMessage.kt b/app/src/main/java/io/legado/app/ui/widget/components/EmptyMessage.kt index c835f6747..8b81f5962 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/EmptyMessage.kt +++ b/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 ) } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/GroupManageBottomSheet.kt b/app/src/main/java/io/legado/app/ui/widget/components/GroupManageBottomSheet.kt index 5c1f1dfcf..3251bcbc9 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/GroupManageBottomSheet.kt +++ b/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) ) } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/JsonRawEditor.kt b/app/src/main/java/io/legado/app/ui/widget/components/JsonRawEditor.kt index 3f039e9ba..ce6da2a77 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/JsonRawEditor.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/JsonRawEditor.kt @@ -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 = "压缩" ) } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/ValueStepper.kt b/app/src/main/java/io/legado/app/ui/widget/components/ValueStepper.kt index c538322fa..ad02da8e8 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/ValueStepper.kt +++ b/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 ) } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/AnimatedTextButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/AnimatedTextButton.kt deleted file mode 100644 index 973e5973a..000000000 --- a/app/src/main/java/io/legado/app/ui/widget/components/button/AnimatedTextButton.kt +++ /dev/null @@ -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) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/MediumIconButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/MediumIconButton.kt deleted file mode 100644 index b17a540bc..000000000 --- a/app/src/main/java/io/legado/app/ui/widget/components/button/MediumIconButton.kt +++ /dev/null @@ -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 - ) - } - ) - } -} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/MediumTextButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/MediumTextButton.kt deleted file mode 100644 index 88bede26b..000000000 --- a/app/src/main/java/io/legado/app/ui/widget/components/button/MediumTextButton.kt +++ /dev/null @@ -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) - ) - } - } -} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/SmallIconButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/SmallIconButton.kt deleted file mode 100644 index 4bb659a67..000000000 --- a/app/src/main/java/io/legado/app/ui/widget/components/button/SmallIconButton.kt +++ /dev/null @@ -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 - ) - } - ) - } - } -} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/SmallTextButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/SmallTextButton.kt deleted file mode 100644 index 3fd6a42e3..000000000 --- a/app/src/main/java/io/legado/app/ui/widget/components/button/SmallTextButton.kt +++ /dev/null @@ -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 - ) - } - } - } -} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/AnimatedActionButtonCore.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/series/AnimatedActionButtonCore.kt similarity index 97% rename from app/src/main/java/io/legado/app/ui/widget/components/button/AnimatedActionButtonCore.kt rename to app/src/main/java/io/legado/app/ui/widget/components/button/series/AnimatedActionButtonCore.kt index 48b733ba5..dac4b704b 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/button/AnimatedActionButtonCore.kt +++ b/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 diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/AnimatedIcon.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/series/AnimatedIcon.kt similarity index 94% rename from app/src/main/java/io/legado/app/ui/widget/components/button/AnimatedIcon.kt rename to app/src/main/java/io/legado/app/ui/widget/components/button/series/AnimatedIcon.kt index ffbfaaeb7..4e0296ba4 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/button/AnimatedIcon.kt +++ b/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 diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumAnimatedButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumAnimatedButton.kt new file mode 100644 index 000000000..4ba6e5f3f --- /dev/null +++ b/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, + ) + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumOutlinedButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumOutlinedButton.kt new file mode 100644 index 000000000..827ba55e5 --- /dev/null +++ b/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) + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumPlainButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumPlainButton.kt new file mode 100644 index 000000000..946878215 --- /dev/null +++ b/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) + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumToggleButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumToggleButton.kt new file mode 100644 index 000000000..be39c468f --- /dev/null +++ b/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, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumTonalButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumTonalButton.kt new file mode 100644 index 000000000..24d7bac4a --- /dev/null +++ b/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) + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallAnimatedButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallAnimatedButton.kt new file mode 100644 index 000000000..69172217c --- /dev/null +++ b/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), + ) + } + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallOutlinedButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallOutlinedButton.kt new file mode 100644 index 000000000..24939120d --- /dev/null +++ b/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) + } + } + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallPlainButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallPlainButton.kt new file mode 100644 index 000000000..839ff94a3 --- /dev/null +++ b/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) + } + } + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallToggleButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallToggleButton.kt new file mode 100644 index 000000000..77ce135fe --- /dev/null +++ b/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), + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallTonalButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallTonalButton.kt new file mode 100644 index 000000000..87709b123 --- /dev/null +++ b/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) + } + } + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/card/SelectionItemCard.kt b/app/src/main/java/io/legado/app/ui/widget/components/card/SelectionItemCard.kt index 0560ef3c8..6d6de6098 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/card/SelectionItemCard.kt +++ b/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( diff --git a/app/src/main/java/io/legado/app/ui/widget/components/heatmap/HeatmapCalendarComponents.kt b/app/src/main/java/io/legado/app/ui/widget/components/heatmap/HeatmapCalendarComponents.kt index 0bef92c93..55d25ce8a 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/heatmap/HeatmapCalendarComponents.kt +++ b/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, ) } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt b/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt index e94779fe9..d0d9e8ba2 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/importComponents/ImportComponents.kt +++ b/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 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 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 = "详情" ) } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/log/AppLogSheet.kt b/app/src/main/java/io/legado/app/ui/widget/components/log/AppLogSheet.kt index c32920750..4e48cb95c 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/log/AppLogSheet.kt +++ b/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 ) } ) { diff --git a/app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/OptionSheet.kt b/app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/OptionSheet.kt index 8e5634d30..337bd2849 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/OptionSheet.kt +++ b/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( diff --git a/app/src/main/java/io/legado/app/ui/widget/components/rules/RuleEditSheet.kt b/app/src/main/java/io/legado/app/ui/widget/components/rules/RuleEditSheet.kt index 46f278a63..7ac8dd570 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/rules/RuleEditSheet.kt +++ b/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 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 }) { diff --git a/app/src/main/java/io/legado/app/ui/widget/components/topbar/GlassMediumFlexibleTopAppBar.kt b/app/src/main/java/io/legado/app/ui/widget/components/topbar/GlassMediumFlexibleTopAppBar.kt index f063a8660..3610d664a 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/topbar/GlassMediumFlexibleTopAppBar.kt +++ b/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 ) diff --git a/app/src/main/java/io/legado/app/ui/widget/components/topbar/TopBarButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/topbar/TopBarButton.kt index e42304535..cc0abdae8 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/topbar/TopBarButton.kt +++ b/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, diff --git a/app/src/main/java/io/legado/app/web/socket/BookSearchWebSocket.kt b/app/src/main/java/io/legado/app/web/socket/BookSearchWebSocket.kt index c7228ec92..d70755ef0 100644 --- a/app/src/main/java/io/legado/app/web/socket/BookSearchWebSocket.kt +++ b/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() 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 diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index f81f8ae8b..5cab84642 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -24,7 +24,7 @@ 去设置 无法跳转至设置界面 - 手动输入 + 输入URL 偏好对比度 来源:系统对比度 来源:手动输入 @@ -899,7 +899,7 @@ 正文标题 显示/隐藏 页眉与页脚 - 规则订阅 + 规则 添加大佬们提供的规则导入地址\n添加后点击可导入规则 拉取云端进度 覆盖云端进度 @@ -1648,6 +1648,8 @@ · 进度 %1$d/%2$d %1$s分组搜索结果为空,是否关闭精准搜索? %1$s分组搜索结果为空,是否切换到全部分组? + 列表布局 + 来源分组 下载与缓存 HTTP缓存 封面缓存 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8cf6b04df..daf51a89c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1684,6 +1684,8 @@ · Progress %1$d/%2$d %1$s group search returned no results. Disable precise search? %1$s group search returned no results. Switch to all groups? + List layout + Source grouped Download & Cache HTTP Cache Cover Cache diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 000000000..2c1fa99cb --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.vitepress/dist/ +.vitepress/cache/ diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts new file mode 100644 index 000000000..83cdfb3e8 --- /dev/null +++ b/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, + }, +}) diff --git a/docs/dev/authentication.md b/docs/dev/authentication.md new file mode 100644 index 000000000..c4405dd68 --- /dev/null +++ b/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` 表示已登录,返回其他值会触发重新登录流程。 diff --git a/docs/dev/debug.md b/docs/dev/debug.md new file mode 100644 index 000000000..8e77c7f72 --- /dev/null +++ b/docs/dev/debug.md @@ -0,0 +1,62 @@ +# 源调试 + +编写源后,可以通过 Legado 内置的调试功能逐项验证各规则是否正确。调试入口位于源编辑页面底部的「调试」按钮。 + +[[toc]] + +## 调试搜索 + +输入搜索关键字,验证搜索规则能否正确返回书籍列表。 + +**输入示例:** + +``` +系统 +``` + +## 调试发现 + +输入发现页 URL,支持 `{{page}}` 分页变量。格式为 `标题::URL`。 + +**输入示例:** + +``` +排行榜::https://www.example.com/rank?page={{page}} +``` + +## 调试详情页 + +直接输入书籍详情页的 URL,验证详情页规则能否正确提取书名、作者、简介等信息。 + +**输入示例:** + +``` +https://www.example.com/book/12345 +``` + +## 调试目录页 + +输入目录页 URL,前缀 `++` 表示使用正则匹配方式。 + +**输入示例:** + +``` +++https://www.example.com/read/12345 +``` + +## 调试正文页 + +输入正文页 URL,前缀 `--` 表示使用正则匹配方式。 + +**输入示例:** + +``` +--https://www.example.com/chapter/12345/67890 +``` + +::: tip 调试技巧 + +- 每个调试项独立运行,可以逐个排查问题 +- 调试结果会直接显示提取到的内容,方便对比预期 +- 如果规则使用了 js,调试时可以看到 `java.log()` 输出的日志 + ::: diff --git a/docs/dev/dict-rule.md b/docs/dev/dict-rule.md new file mode 100644 index 000000000..e9a983b9a --- /dev/null +++ b/docs/dev/dict-rule.md @@ -0,0 +1,22 @@ +# 字典规则 + +字典规则用于正文阅读时的「选中文字 → 字典/翻译」功能。用户选中文字后,Legado 会调用字典规则查询结果并显示在弹窗中。 + +[[toc]] + +## 字段说明 + +| 字段 | 说明 | +|------------|-----------------------| +| `urlRule` | 请求 URL 规则,语法同源 URL 规则 | +| `showRule` | 从响应结果中提取显示内容的规则 | + +## 配置示例 + +`urlRule` 中可以使用 `{{key}}` 引用用户选中的文字: + +``` +https://dict.example.com/s?wd={{key}}&ptype=zici +``` + +`showRule` 使用标准的源规则语法提取页面中的释义内容。 diff --git a/docs/dev/discovery-url.md b/docs/dev/discovery-url.md new file mode 100644 index 000000000..578840c45 --- /dev/null +++ b/docs/dev/discovery-url.md @@ -0,0 +1,77 @@ +# 发现 URL (Discovery URL) 配置规范 + +[[toc]] + +源的「发现 URL」字段支持通过 JSON 数组定义多个发现页入口,每个入口包含标题、URL +和可选的布局样式。这使得一个源可以提供多个分类或榜单的快捷入口。 + +## 1. 字段位置 + +在源编辑器的 **发现** 选项卡中: + +``` +发现 → 发现 URL +``` + +## 2. 数据结构 + +发现 URL 是一个包含多个入口对象的 JSON 数组。 + +### 入口字段 + +| 字段 | 类型 | 必须 | 说明 | +|:--------|:---------|:---|:--------------------------| +| `title` | `String` | 是 | 入口标题,显示在发现页列表中 | +| `url` | `String` | 是 | 目标 URL,支持 `{{page}}` 分页变量 | +| `style` | `Object` | 否 | Flexbox 布局配置 | + +### style 布局属性 + +| 属性 | 类型 | 说明 | +|:--------------------------|:----------|:---------------| +| `layout_flexGrow` | `Int` | 弹性增长比例 | +| `layout_flexShrink` | `Int` | 弹性收缩比例 | +| `layout_alignSelf` | `String` | 交叉轴对齐方式 | +| `layout_flexBasisPercent` | `Int` | 基础尺寸百分比,-1 为自动 | +| `layout_wrapBefore` | `Boolean` | 是否强制换行 | + +## 3. 完整 JSON 示例 + +```json +[ + { + "title": "热门榜", + "url": "https://example.com/rank/hot?page={{page}}" + }, + { + "title": "新书榜", + "url": "https://example.com/rank/new?page={{page}}" + }, + { + "title": "完本榜", + "url": "https://example.com/rank/finish?page={{page}}" + } +] +``` + +## 4. 两种格式 + +发现 URL 支持两种填写格式: + +| 格式 | 说明 | 适用场景 | +|:--------|:-----------------------------------------|:----------| +| 字符串 | 直接填写 URL,如 `https://example.com/explore` | 单个发现页 | +| JSON 数组 | 定义多个入口 | 多个分类/榜单入口 | + +## 5. 分页变量 + +URL 中可使用 `{{page}}` 变量实现分页,Legado 会自动递增页码: + +``` +https://example.com/rank?page={{page}} +``` + +## 6. 与首页模块的关系 + +发现 URL 定义的入口可被 [首页模块](../spec/homepage-modules) 的 `kindTitle` 字段引用。当首页模块的 +`kindTitle` 与某个发现入口的 `title` 完全匹配时,模块会自动使用该入口的 URL 和规则。 diff --git a/docs/dev/examples.md b/docs/dev/examples.md new file mode 100644 index 000000000..9d8064211 --- /dev/null +++ b/docs/dev/examples.md @@ -0,0 +1,201 @@ +# 源示例 (Source Examples) + +[[toc]] + +本文档提供三种典型源的完整 JSON 示例,分别演示 CSS+正则、XPath+正则、JSONPath 的用法。 + +## 1. CSS + 正则 + +使用 JSOUP CSS 选择器和正则表达式的源示例。 + +```json +{ + "bookSourceComment": "", + "bookSourceGroup": "CSS; 正则", + "bookSourceName": "示例源", + "bookSourceType": 0, + "bookSourceUrl": "https://www.example.com", + "bookUrlPattern": "", + "customOrder": 0, + "enabled": true, + "enabledExplore": false, + "exploreUrl": "", + "lastUpdateTime": 0, + "loginUrl": "", + "ruleBookInfo": { + "author": "##:author\"[^\"]+\"([^\"]*)##$1###", + "coverUrl": "##og:image\"[^\"]+\"([^\"]*)##$1###", + "intro": "##:description\"[^\"]+\"([\\w\\W]*?)\"/##$1###", + "kind": "##:category\"[^\"]+\"([^\"]*)##$1###", + "lastChapter": "##_chapter_name\"[^\"]+\"([^\"]*)##$1###", + "name": "##:book_name\"[^\"]+\"([^\"]*)##$1###", + "tocUrl": "" + }, + "ruleContent": { + "content": "@css:.chapter-content p@textNodes##广告文字|本站声明.*|", + "nextContentUrl": "" + }, + "ruleExplore": {}, + "ruleSearch": { + "author": "@css:p:eq(2)>a@text", + "bookList": "@css:li.clearfix", + "bookUrl": "@css:.name>a@href", + "coverUrl": "@css:img@src", + "intro": "@css:.note.clearfix p@text", + "kind": "@css:.note_text,p:eq(4)@text", + "lastChapter": "@css:p:eq(3)@text", + "name": "@css:.name@text" + }, + "ruleToc": { + "chapterList": "-:
  • ([^<]*)", + "chapterName": "$2", + "chapterUrl": "$1", + "nextTocUrl": "" + }, + "searchUrl": "/search?q={{key}}&page={{page}}", + "weight": 0 +} +``` + +**要点:** + +- 详情页规则使用 OnlyOne 正则(`##...###`)从 meta 标签提取信息 +- 正文规则使用 CSS 选择器取文本节点,后接净化正则移除广告 +- 目录规则使用 AllInOne 正则(`:` 开头),`-` 前缀使列表倒序 + +## 2. XPath + 正则 + +使用 XPath 和正则表达式的源示例。 + +```json +{ + "bookSourceComment": "", + "bookSourceGroup": "XPath; 正则", + "bookSourceName": "示例移动源", + "bookSourceType": 0, + "bookSourceUrl": "https://m.example.com", + "bookUrlPattern": "", + "customOrder": 0, + "enabled": true, + "enabledExplore": false, + "exploreUrl": "", + "lastUpdateTime": 0, + "loginUrl": "", + "ruleBookInfo": { + "author": "//*[@property=\"og:novel:author\"]/@content", + "coverUrl": "//*[@property=\"og:image\"]/@content", + "intro": "//*[@property=\"og:description\"]/@content", + "kind": "//*[@property=\"og:novel:category\"]/@content", + "lastChapter": "//*[@id=\"latest-chapter\"]//li[1]/a/text()", + "name": "//*[@property=\"og:novel:book_name\"]/@content", + "tocUrl": "//a[text()=\"阅读\"]/@href" + }, + "ruleContent": { + "content": "//*[@id=\"content\"]", + "nextContentUrl": "" + }, + "ruleExplore": {}, + "ruleSearch": { + "author": "//dd[2]/text()", + "bookList": "//*[@id=\"search-result\"]/dl", + "bookUrl": "//dt/a/@href", + "coverUrl": "//img/@src", + "kind": "//dd[2]/span/text()", + "lastChapter": "", + "name": "//h3/a/text()" + }, + "ruleToc": { + "chapterList": ":href=\"(/read[^\"]*html)\">([^<]*)", + "chapterName": "$2", + "chapterUrl": "$1", + "nextTocUrl": "//*[@id=\"chapter-list\"]/*[position()>1]/@value" + }, + "searchUrl": "/search,{\n \"method\": \"POST\",\n \"body\": \"q={{key}}\"\n}", + "weight": 0 +} +``` + +**要点:** + +- 详情页使用 XPath 从 `og:novel` meta 标签提取信息 +- 目录使用 AllInOne 正则提取链接和标题 +- 目录下一页使用 XPath 的 `position()` 谓语条件 +- 搜索使用 POST 方法 + +## 3. JSONPath + +使用 JSON API 的源示例。 + +```json +{ + "bookSourceComment": "", + "bookSourceGroup": "JSON", + "bookSourceName": "示例 API 源", + "bookSourceType": 0, + "bookSourceUrl": "http://api.example.com", + "customOrder": 0, + "enabled": true, + "enabledExplore": false, + "header": "{\n \"User-Agent\": \"Mozilla/5.0\"\n}", + "lastUpdateTime": 0, + "ruleBookInfo": {}, + "ruleContent": { + "content": "$.chapter.body" + }, + "ruleExplore": {}, + "ruleSearch": { + "author": "$.author", + "bookList": "$..books[*]", + "bookUrl": "/book/detail?id={$._id}", + "coverUrl": "$.cover", + "intro": "$.shortIntro", + "kind": "$.minorCate", + "lastChapter": "$.lastChapter", + "name": "$.title" + }, + "ruleToc": { + "chapterList": "$.chapterInfo.chapters.[*]", + "chapterName": "$.title", + "chapterUrl": "$.link" + }, + "searchUrl": "/book/search?query={{key}}&start={{(page-1)*20}}&limit=20", + "weight": 0 +} +``` + +**要点:** + +- 详情页为空(`ruleBookInfo: {}`),直接从搜索结果获取信息 +- 目录 URL 使用 JSONPath 表达式 `{$._id}` 拼接 +- 书列表使用递归查找 `$..books[*]` +- 搜索 URL 使用 `{{(page-1)*20}}` 计算偏移量 + +## 4. 调试技巧 + +### JS 错误处理 + +使用 try-catch 包裹 JS 逻辑,方便调试: + +```javascript +(function(result){ + try{ + // 处理 result + return result; + } + catch(e){ + return "" + e; // 返回错误信息 + } +})(result); +``` + +### 调试入口 + +| 调试项 | 输入示例 | +|:----|:--------------------------------------------------| +| 搜索 | `系统` | +| 发现 | `排行榜::https://www.example.com/rank?page={{page}}` | +| 详情页 | `https://www.example.com/book/12345` | +| 目录页 | `++https://www.example.com/read/12345` | +| 正文页 | `--https://www.example.com/chapter/12345/67890` | + +详细调试方法参见 [源调试](./debug)。 diff --git a/docs/dev/index.md b/docs/dev/index.md new file mode 100644 index 000000000..db9c7d244 --- /dev/null +++ b/docs/dev/index.md @@ -0,0 +1,61 @@ +# 开发文档 + +欢迎来到 Legado 开发文档。本文档为源开发者和贡献者提供参考。 + +::: tip 快速上手 +如果你是第一次接触 Legado 源开发,建议按以下顺序阅读: + +1. [规则语法详解](./syntax) — 了解所有支持的规则语法 +2. [URL 参数详解](./url-options) — 掌握请求参数配置 +3. [源字段速查](./source-fields) — 查看各部分可用字段 +4. [源示例](./examples) — 参考完整源 JSON + ::: + +## 入门 + +从零开始学习源开发。 + +| 文档 | 说明 | +|:--------------------------|:-------------------------------------------------------------| +| [规则语法详解](./syntax) | JSOUP Default/CSS、JSONPath、XPath、正则 AllInOne/OnlyOne/净化、连接符号 | +| [URL 参数详解](./url-options) | GET/POST 请求、WebView 模式、模板变量、完整 UrlOption 字段 | +| [源字段速查](./source-fields) | 搜索、发现、详情页、目录、正文各部分的所有字段说明 | +| [源示例](./examples) | CSS+正则、XPath+正则、JSONPath 三种完整源 JSON | + +## 参考 + +核心 API 和语法参考。 + +| 文档 | 说明 | +|:-----------------------|:--------------------------------------| +| [源规则帮助](./rule) | 规则标志、jsLib、并发率、源类型、字体解析、图片解密 | +| [JS 变量和函数](./js) | Rhino 引擎内置变量、加解密、网络请求、Java 互操作等完整 API | +| [XPath 路径表达式](./xpath) | 13 种轴、谓语条件、通配符、内置函数 | +| [正则表达式](./regex) | 元字符、字符集、断言、常用正则示例 | + +## 配置规范 + +源 JSON 字段的详细规范。 + +| 文档 | 说明 | +|:-----------------------------------|:----------------------------------------| +| [请求头配置](./request-headers) | 代理设置、自定义 Headers、URL 动态参数、重定向拦截 | +| [认证与登录](./authentication) | CookieJar、登录 UI 表单、登录 URL 脚本、登录检查 | +| [发现 URL 配置](./discovery-url) | 发现页入口 JSON 格式、分页变量、与首页模块的关系 | +| [首页模块配置](../spec/homepage-modules) | `homepageModules` 字段规范:列表、轮播、排行榜等模块类型 | +| [关联书籍配置](../spec/related-books) | `ruleBookInfo.relatedBooks` 字段规范:相关书籍推荐 | + +## 扩展功能 + +| 文档 | 说明 | +|:--------------------------------|:-----------------------| +| [源调试](./debug) | 搜索、发现、详情页、目录页、正文页的调试方法 | +| [字典规则](./dict-rule) | 正文选中菜单的字典/翻译规则配置 | +| [在线朗读规则](./tts-rule) | 自定义在线 TTS 接口(支持语速控制) | +| [TXT 目录正则](./txt-toc) | 自定义 TXT 书籍的章节识别规则 | +| [MIME 类型参考](../spec/mime-types) | 支持的文件扩展名和 MIME 类型对照表 | + +## 外部资源 + +- [Legado GitHub](https://github.com/HapeLee/legado-with-MD3) — 项目源码和 Issue 跟踪 +- [legado-with-MD3 Wiki](https://github.com/HapeLee/legado-with-MD3/wiki) — 社区维护的文档 diff --git a/docs/dev/js.md b/docs/dev/js.md new file mode 100644 index 000000000..23b00bbc0 --- /dev/null +++ b/docs/dev/js.md @@ -0,0 +1,349 @@ +# JS 变量和函数 + +[[toc]] + +Legado 使用 [Rhino v1.8.0](https://github.com/mozilla/rhino) 作为 JavaScript 引擎,支持在源规则中调用 +Java 类和方法。本文档列出所有可用的内置变量、对象属性和扩展函数。 + +## 1. Rhino 引擎概览 + +| 构造函数 | 函数 | 调用类 | 说明 | +|:---------------|:------------------------------|:------------------------------------------------------------------------------------------------------------------------------------------|:--------------------------| +| `JavaImporter` | `importClass` `importPackage` | [ImporterTopLevel](https://github.com/mozilla/rhino/blob/master/rhino/src/main/java/org/mozilla/javascript/ImporterTopLevel.java) | 导入 Java 类到 JavaScript | +| — | `getClass` | [NativeJavaTopPackage](https://github.com/mozilla/rhino/blob/master/rhino/src/main/java/org/mozilla/javascript/NativeJavaTopPackage.java) | 默认导入 JavaScript 中的 Java 类 | +| `JavaAdapter` | — | [JavaAdapter](https://github.com/mozilla/rhino/blob/master/rhino/src/main/java/org/mozilla/javascript/JavaAdapter.java) | 继承 Java 类 | + +- [Rhino 运行时](https://github.com/mozilla/rhino/blob/master/rhino/src/main/java/org/mozilla/javascript/ScriptRuntime.java) + 懒加载导入的 Java 类和方法 +- [ECMAScript 兼容性表格](https://mozilla.github.io/rhino/compat/engines.html) + +::: warning 注意事项 + +- `java` 变量已被 Legado 修改,调用 `java.*` 下的包请使用 `Packages.java.*` +- 在源规则中使用 `@js`、``、{{}} 可调用 Legado 内置的类和方法 +- 为安全起见,部分 Java + 类调用被屏蔽,见 [RhinoClassShutter](https://github.com/HapeLee/legado-with-MD3/blob/master/modules/rhino/src/main/java/com/script/rhino/RhinoClassShutter.kt) +- 不同源规则中支持调用的 Java 类和方法可能不同 +- `const` 声明的变量不支持块级作用域,循环中使用会出现值不变的问题,请改用 `var` + ::: + +## 2. 内置变量 (Built-in Variables) + +以下变量在源规则的 JS 执行环境中自动可用。 + +| 变量 | 类型 | 作用域 | 说明 | +|:-----------------|:---------------|:----|:-------------------------------------------------------------------------------------------------------------------------------| +| `java` | `Object` | 全局 | 扩展工具对象,提供网络请求、加解密、文件操作等方法 | +| `baseUrl` | `String` | 全局 | 当前请求的 URL | +| `result` | `Any` | 全局 | 上一步规则的执行结果 | +| `book` | `Book` | 全局 | [书籍对象](https://github.com/HapeLee/legado-with-MD3/blob/master/app/src/main/java/io/legado/app/data/entities/Book.kt) | +| `rssArticle` | `RssArticle` | 全局 | [RSS 文章对象](https://github.com/HapeLee/legado-with-MD3/blob/master/app/src/main/java/io/legado/app/data/entities/RssArticle.kt) | +| `chapter` | `BookChapter` | 全局 | [章节对象](https://github.com/HapeLee/legado-with-MD3/blob/master/app/src/main/java/io/legado/app/data/entities/BookChapter.kt) | +| `source` | `BaseSource` | 全局 | [源对象](https://github.com/HapeLee/legado-with-MD3/blob/master/app/src/main/java/io/legado/app/data/entities/BaseSource.kt) | +| `cookie` | `CookieStore` | 全局 | [Cookie 操作对象](https://github.com/HapeLee/legado-with-MD3/blob/master/app/src/main/java/io/legado/app/help/http/CookieStore.kt) | +| `cache` | `CacheManager` | 全局 | [缓存操作对象](https://github.com/HapeLee/legado-with-MD3/blob/master/app/src/main/java/io/legado/app/help/CacheManager.kt) | +| `title` | `String` | 全局 | 当前章节标题 | +| `src` | `String` | 全局 | 请求返回的源码 | +| `nextChapterUrl` | `String` | 全局 | 下一章节 URL | + +## 3. java 对象方法 + +`java` 对象是 Legado 暴露给 JS 环境的核心工具对象,聚合了多个扩展类的方法。 + +### 3.1 RSS 扩展 ([RssJsExtensions](https://github.com/HapeLee/legado-with-MD3/blob/master/app/src/main/java/io/legado/app/ui/rss/read/RssJsExtensions.kt)) + +::: warning 作用域限制 +只能在订阅源 `shouldOverrideUrlLoading` 规则中使用。URL 跳转拦截规则不能执行耗时操作。 +::: + +```js +java.searchBook(bookName: String) // 调用 Legado 搜索 +java.addBook(bookUrl: String) // 添加书架 +``` + +### 3.2 URL 解析 ([AnalyzeUrl](https://github.com/HapeLee/legado-with-MD3/blob/master/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeUrl.kt)) + +通过 `java.` 调用,仅在 `登录检查 JS` 规则中有效。 + +| 方法 | 返回值 | 说明 | +|:---------------------------------------------------|:--------------|:--------------------| +| `initUrl()` | — | 重新解析 URL | +| `getHeaderMap().putAll(source.getHeaderMap(true))` | — | 重新设置登录头 | +| `getStrResponse(jsStr, sourceRegex)` | `StrResponse` | 返回文本类型的访问结果 | +| `getResponse()` | `Response` | 返回 Response 类型的访问结果 | + +### 3.3 规则解析 ([AnalyzeRule](https://github.com/HapeLee/legado-with-MD3/blob/master/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeRule.kt)) + +```js +// 获取文本/文本列表 +java.getString(ruleStr: String?, mContent: Any? = null, isUrl: Boolean = false) +java.getStringList(ruleStr: String?, mContent: Any? = null, isUrl: Boolean = false) + +// 设置解析内容 +java.setContent(content: Any?, baseUrl: String? = null) + +// 获取 Element/Element 列表 +java.getElement(ruleStr: String) +java.getElements(ruleStr: String) + +// 重新搜索书籍/重新获取目录 url(只能在刷新目录之前使用) +java.reGetBook() +java.refreshTocUrl() + +// 变量存取 +java.get(key) +java.put(key, value) +``` + +### 3.4 扩展工具 ([JsExtensions](https://github.com/HapeLee/legado-with-MD3/blob/master/app/src/main/java/io/legado/app/help/JsExtensions.kt)) + +#### 链接解析 + +| 方法 | 返回值 | 说明 | +|:---------------------------|:---------|:----------------------| +| `java.toURL(url)` | `JsURL` | 将字符串解析为 URL 对象 | +| `java.toURL(url, baseUrl)` | `JsURL` | 基于 baseUrl 解析相对 URL | +| `java.getWebViewUA()` | `String` | 获取 WebView User-Agent | + +#### 网络请求 + +| 方法 | 返回值 | 说明 | +|:--------------------------------------------------------------|:----------------------|:------------------------------------------------------------------| +| `java.ajax(urlStr)` | `String` | GET 请求,返回响应体 | +| `java.ajaxAll(urlList)` | `Array` | 批量请求 | +| `java.connect(urlStr)` | `StrResponse` | 返回 `body()` `code()` `message()` `headers()` `raw()` `toString()` | +| `java.post(url, body, headerMap)` | `Connection.Response` | POST 请求 | +| `java.get(url, headerMap)` | `Connection.Response` | GET 请求 | +| `java.head(url, headerMap)` | `Connection.Response` | HEAD 请求 | +| `java.webView(html, url, js)` | `String?` | 使用 WebView 访问网络 | +| `java.webViewGetOverrideUrl(html, url, js, overrideUrlRegex)` | `String?` | 使用 WebView 获取跳转 URL | +| `java.webViewGetSource(html, url, js, sourceRegex)` | `String?` | 使用 WebView 获取资源 URL | +| `java.startBrowser(url, title)` | — | 使用内置浏览器打开链接 | +| `java.startBrowserAwait(url, title, refetchAfterSuccess)` | `StrResponse` | 使用内置浏览器打开链接,等待结果 | + +#### 调试与提示 + +| 方法 | 说明 | +|:-------------------------------------|:---------| +| `java.log(msg)` | 输出日志 | +| `java.logType(var)` | 输出变量类型 | +| `java.getVerificationCode(imageUrl)` | 弹出验证码输入框 | +| `java.longToast(msg)` | 长时间提示 | +| `java.toast(msg)` | 短时间提示 | + +#### 脚本导入 + +| 方法 | 说明 | +|:----------------------------------|:-------------------------------------------| +| `java.importScript(url)` | 从网络加载脚本 | +| `java.importScript(relativePath)` | 从相对路径加载(支持 `android/data/{package}/cache`) | +| `java.importScript(absolutePath)` | 从绝对路径加载 | + +#### 文件缓存 + +```js +java.cacheFile(url) // 缓存网络文件 +java.cacheFile(url, saveTime) // 缓存并指定保存时间 +eval(String(java.cacheFile(url))) // 缓存并执行 +cache.delete(java.md5Encode16(url)) // 使缓存失效 +``` + +#### 编码与转换 + +| 分类 | 方法 | 说明 | +|:----------|:-------------------------------------------|:---------------------| +| URI | `java.encodeURI(str, enc?)` | URI 编码,默认 UTF-8 | +| Base64 | `java.base64Decode(str, charset?)` | Base64 解码为字符串 | +| Base64 | `java.base64DecodeToByteArray(str, flags)` | Base64 解码为 ByteArray | +| Base64 | `java.base64Encode(str, flags)` | Base64 编码 | +| ByteArray | `java.strToBytes(str, charset?)` | 字符串转 ByteArray | +| ByteArray | `java.bytesToStr(bytes, charset?)` | ByteArray 转字符串 | +| Hex | `java.hexDecodeToByteArray(hex)` | Hex 解码为 ByteArray | +| Hex | `java.hexDecodeToString(hex)` | Hex 解码为字符串 | +| Hex | `java.hexEncodeToString(utf8)` | 字符串转 Hex | + +#### 标识与格式化 + +| 方法 | 返回值 | 说明 | +|:---------------------------------------|:----------|:--------------| +| `java.randomUUID()` | `String` | 生成 UUID | +| `java.androidId()` | `String` | 获取 Android ID | +| `java.t2s(text)` | `String` | 繁体转简体 | +| `java.s2t(text)` | `String` | 简体转繁体 | +| `java.timeFormatUTC(time, format, sh)` | `String?` | UTC 时间格式化 | +| `java.timeFormat(time)` | `String` | 时间格式化 | +| `java.htmlFormat(str)` | `String` | HTML 格式化 | + +#### 文件操作 + +::: tip 路径限制 +所有文件读写删除操作均使用相对路径,只能操作阅读缓存目录 `android/data/{package}/cache/` 内的文件。 +::: + +| 方法 | 返回值 | 说明 | +|:----------------------------|:---------|:------------| +| `downloadFile(url)` | `String` | 文件下载,返回文件路径 | +| `unArchiveFile(zipPath)` | `String` | 文件解压,返回解压路径 | +| `unzipFile(zipPath)` | `String` | ZIP 解压 | +| `unrarFile(zipPath)` | `String` | RAR 解压 | +| `un7zFile(zipPath)` | `String` | 7Z 解压 | +| `getTxtInFolder(unzipPath)` | `String` | 读取文件夹内所有文件 | +| `readTxtFile(path)` | `String` | 读取文本文件 | +| `deleteFile(path)` | — | 删除文件 | + +#### 外部链接跳转 + +| 方法 | 说明 | +|:------------------------------|:-------------------------| +| `java.openUrl(url)` | 跳转外部链接(HTTP 或 scheme) | +| `java.openUrl(url, mimeType)` | 指定 MIME 类型跳转,如 `video/*` | + +### 3.5 加解密 ([JsEncodeUtils](https://github.com/HapeLee/legado-with-MD3/blob/master/app/src/main/java/io/legado/app/help/JsEncodeUtils.kt)) + +提供在 JavaScript 环境中快捷调用 crypto +算法的函数,由 [hutool-crypto](https://www.hutool.cn/docs/#/crypto/概述) 实现(当前版本 5.8.22)。 + +::: warning 输入类型 +如果输入参数不是 Utf8String,可先调用 `java.hexDecodeToByteArray` 或 `java.base64DecodeToByteArray` +转成 ByteArray。 +::: + +#### 对称加密 + +```js +// 创建 Cipher,key/iv 支持 ByteArray | Utf8String +java.createSymmetricCrypto(transformation, key, iv) + +// data 支持 ByteArray | Base64String | HexString | InputStream +cipher.decrypt(data) // 解密为 ByteArray +cipher.decryptStr(data) // 解密为字符串 +cipher.encrypt(data) // 加密为 ByteArray +cipher.encryptBase64(data) // 加密为 Base64 +cipher.encryptHex(data) // 加密为 Hex +``` + +#### 非对称加密 + +```js +java.createAsymmetricCrypto(transformation) + .setPublicKey(key) + .setPrivateKey(key) + +cipher.decrypt(data, usePublicKey: Boolean? = true) +cipher.decryptStr(data, usePublicKey: Boolean? = true) +cipher.encrypt(data, usePublicKey: Boolean? = true) +cipher.encryptBase64(data, usePublicKey: Boolean? = true) +cipher.encryptHex(data, usePublicKey: Boolean? = true) +``` + +#### 签名 + +```js +java.createSign(algorithm) + .setPublicKey(key) + .setPrivateKey(key) + +sign.sign(data) +sign.signHex(data) +``` + +#### 摘要与 HMAC + +| 方法 | 返回值 | 说明 | +|:----------------------------------------|:----------|:-------------| +| `java.digestHex(data, algorithm)` | `String?` | 摘要(Hex) | +| `java.digestBase64Str(data, algorithm)` | `String?` | 摘要(Base64) | +| `java.md5Encode(str)` | `String` | MD5(32 位) | +| `java.md5Encode16(str)` | `String` | MD5(16 位) | +| `java.HMacHex(data, algorithm, key)` | `String` | HMAC(Hex) | +| `java.HMacBase64(data, algorithm, key)` | `String` | HMAC(Base64) | + +## 4. book 对象 + +在 JS 中或 {{}} 中使用 `book.属性` 的方式获取。 + +| 属性 | 类型 | 说明 | +|:---------------------|:----------|:--------------------| +| `bookUrl` | `String` | 详情页 URL(本地源为完整文件路径) | +| `tocUrl` | `String` | 目录页 URL | +| `origin` | `String` | 源 URL | +| `originName` | `String` | 源名称或本地书籍文件名 | +| `name` | `String` | 书籍名称 | +| `author` | `String` | 作者名称 | +| `kind` | `String` | 分类信息 | +| `customTag` | `String` | 分类信息(用户修改) | +| `coverUrl` | `String` | 封面 URL | +| `customCoverUrl` | `String` | 封面 URL(用户修改) | +| `intro` | `String` | 简介内容 | +| `customIntro` | `String` | 简介内容(用户修改) | +| `charset` | `String` | 自定义字符集名称(仅本地书籍) | +| `type` | `Int` | 0: text, 1: audio | +| `group` | `Int` | 自定义分组索引号 | +| `latestChapterTitle` | `String` | 最新章节标题 | +| `latestChapterTime` | `Long` | 最新章节更新时间 | +| `lastCheckTime` | `Long` | 最近一次更新书籍信息的时间 | +| `lastCheckCount` | `Int` | 最近一次发现新章节数量 | +| `totalChapterNum` | `Int` | 书籍目录总数 | +| `durChapterTitle` | `String` | 当前章节名称 | +| `durChapterIndex` | `Int` | 当前章节索引 | +| `durChapterPos` | `Long` | 当前阅读进度 | +| `durChapterTime` | `Long` | 最近一次阅读时间 | +| `canUpdate` | `Boolean` | 刷新书架时是否更新 | +| `order` | `Int` | 手动排序 | +| `originOrder` | `Int` | 源排序 | +| `variable` | `String?` | 自定义书籍变量 | + +## 5. chapter 对象 + +| 属性 | 类型 | 说明 | +|:--------------|:----------|:-----------| +| `url` | `String` | 章节地址 | +| `title` | `String` | 章节标题 | +| `baseUrl` | `String` | 用于拼接相对 URL | +| `bookUrl` | `String` | 书籍地址 | +| `index` | `Int` | 章节序号 | +| `resourceUrl` | `String` | 音频真实 URL | +| `tag` | `String` | 标签 | +| `start` | `Long` | 章节起始位置 | +| `end` | `Long` | 章节终止位置 | +| `variable` | `String?` | 变量 | + +## 6. source 对象 + +| 方法 | 返回值 | 说明 | +|:--------------------------------------|:----------|:----------| +| `source.getKey()` | `String` | 获取源 URL | +| `source.setVariable(variable)` | — | 设置源变量 | +| `source.getVariable()` | `String?` | 获取源变量 | +| `source.getLoginHeader()` | `String?` | 获取登录头 | +| `source.getLoginHeaderMap().get(key)` | `String?` | 获取登录头某一键值 | +| `source.putLoginHeader(header)` | — | 保存登录头 | +| `source.removeLoginHeader()` | — | 清除登录头 | +| `source.getLoginInfo()` | `String?` | 获取登录信息 | +| `source.getLoginInfoMap().get(key)` | `String?` | 获取登录信息键值 | +| `source.removeLoginInfo()` | — | 清除登录信息 | + +## 7. cookie 对象 + +| 方法 | 返回值 | 说明 | +|:------------------------------------|:----------|:------------| +| `cookie.getCookie(url)` | `String` | 获取全部 cookie | +| `cookie.getKey(url, key)` | `String?` | 获取某一键值 | +| `cookie.setCookie(url, cookie)` | — | 设置 cookie | +| `cookie.replaceCookie(url, cookie)` | — | 替换 cookie | +| `cookie.removeCookie(url)` | — | 删除 cookie | + +## 8. cache 对象 + +saveTime 单位:秒,可省略。保存至数据库和缓存文件(50MB),内容较大时请使用 `getFile` / `putFile`。 + +| 方法 | 返回值 | 说明 | +|:---------------------------------------|:----------|:--------| +| `cache.put(key, value, saveTime?)` | — | 保存到数据库 | +| `cache.get(key)` | `String?` | 从数据库读取 | +| `cache.delete(key)` | — | 删除数据库缓存 | +| `cache.putFile(key, value, saveTime?)` | — | 缓存文件内容 | +| `cache.getFile(key)` | `String?` | 读取文件缓存 | +| `cache.putMemory(key, value)` | — | 保存到内存 | +| `cache.getFromMemory(key)` | `Any?` | 从内存读取 | +| `cache.deleteMemory(key)` | — | 删除内存缓存 | diff --git a/docs/dev/regex.md b/docs/dev/regex.md new file mode 100644 index 000000000..8a956c490 --- /dev/null +++ b/docs/dev/regex.md @@ -0,0 +1,123 @@ +# 正则表达式 + +正则表达式(Regular Expression)用于在文本中匹配、查找和替换特定模式。Legado 源规则中,以 `:` +开头的规则即为正则表达式,可用于书籍列表和目录列表的提取。 + +[[toc]] + +## 基本匹配 + +正则表达式是我们用于在文本中检索字母和数字的模式。例如正则表达式 `cat`,表示:字母 `c` 后面跟着一个字母 +`a`,再后面跟着一个字母 `t`。 + +正则表达式通常区分大小写,因此 `Cat` 与字符串 "cat" 不匹配。 + +## 元字符 + +元字符是正则表达式的基本组成元素。 + +| 元字符 | 说明 | +|:-------:|-----------------------| +| `.` | 匹配除换行符以外的任意字符 | +| `[ ]` | 字符类,匹配方括号中包含的任意字符 | +| `[^ ]` | 否定字符类,匹配方括号中不包含的任意字符 | +| `*` | 匹配前面的子表达式零次或多次 | +| `+` | 匹配前面的子表达式一次或多次 | +| `?` | 匹配前面的子表达式零次或一次 | +| `{n,m}` | 匹配前面字符至少 n 次,但不超过 m 次 | +| `(xyz)` | 字符组,按照确切的顺序匹配字符 xyz | +| `\|` | 分支结构,匹配符号之前的字符或后面的字符 | +| `\` | 转义符 | +| `^` | 匹配行的开始 | +| `$` | 匹配行的结束 | + +### 英文句号 + +`.` 可以匹配任意单个字符,但不会匹配换行符。例如 `.ar` 匹配 "car"、"par"、"gar"。 + +### 字符集 + +字符集(字符类)使用方括号指定。例如 `[Tt]he` 匹配 "The" 或 "the"。 + +**否定字符集**:`^` 在方括号内出现时会取消字符集。例如 `[^c]ar` 匹配除了 "car" 以外的 "par"、"gar"。 + +### 重复 + +- **星号 `*`**:匹配上一个匹配规则的零次或多次。例如 `[a-z]*` 匹配一行中任意数量的小写字母。 +- **加号 `+`**:匹配上一个字符的一次或多次。例如 `c.+t` 匹配 "cat sat on the mat"。 +- **问号 `?`**:表示前一个字符是可选的。例如 `[T]?he` 同时匹配 "The" 和 "he"。 + +### 花括号(量词) + +用于指定字符或一组字符可以重复的次数。例如 `[0-9]{2,3}` 匹配至少 2 位但不超过 3 位数字。 + +### 字符组 + +写在圆括号内的子模式 `(...)`。例如 `(ab)*` 匹配零个或多个 "ab"。`(c|g|p)ar` 匹配 "car"、"gar" 或 "par"。 + +### 分支结构 + +`|` 用来定义分支结构。字符集只在字符级别上有作用,而分支结构在表达式级别上依然可以使用。 + +### 转义特殊字符 + +使用 `\` 来转义下一个字符。例如 `(f|c|m)at\.?` 匹配 "fat"、"cat"、"mat" 以及可选的 "." 字符。 + +### 定位符 + +- `^` 检查匹配字符是否是起始字符 +- `$` 检查匹配字符是否是最后一个字符 + +## 简写字符集 + +| 简写 | 说明 | +|:----:|-----------------------------| +| `.` | 匹配除换行符以外的任意字符 | +| `\w` | 匹配所有字母和数字的字符:`[a-zA-Z0-9_]` | +| `\W` | 匹配非字母和数字的字符:`[^\w]` | +| `\d` | 匹配数字:`[0-9]` | +| `\D` | 匹配非数字:`[^\d]` | +| `\s` | 匹配空格符:`[\t\n\f\r\p{Z}]` | +| `\S` | 匹配非空格符:`[^\s]` | + +## 断言 + +| 符号 | 说明 | +|:-----:|--------| +| `?=` | 正向先行断言 | +| `?!` | 负向先行断言 | +| `?<=` | 正向后行断言 | +| `?]+?>` | +| 求更求转发致谢 | `[\((【].*?[求更谢乐发推].*?[】)\)]` | +| 查找最新章节 | `您可以.*?查找最新章节` | +| PS/ps | `(?i)ps\b.*` | diff --git a/docs/dev/request-headers.md b/docs/dev/request-headers.md new file mode 100644 index 000000000..8c3c809d0 --- /dev/null +++ b/docs/dev/request-headers.md @@ -0,0 +1,83 @@ +# 请求头配置 (Request Headers) + +[[toc]] + +源的请求头字段用于控制 HTTP 请求行为,包括代理设置、自定义 Headers 和动态参数注入。 + +## 1. 基本格式 + +请求头以 JSON 对象形式填写,key **区分大小写**。 + +| 正确 | 错误 | +|:-------------|:-------------| +| `User-Agent` | `user-agent` | +| `Referer` | `referer` | + +## 2. 代理配置 (Proxy) + +支持 HTTP、SOCKS4、SOCKS5 三种代理协议。 + +| 协议 | 格式 | 示例 | +|:----------|:-----------------------------|:--------------------------------------------------| +| SOCKS5 | `socks5://host:port` | `{"proxy": "socks5://127.0.0.1:1080"}` | +| SOCKS4 | `socks4://host:port` | `{"proxy": "socks4://127.0.0.1:1080"}` | +| HTTP | `http://host:port` | `{"proxy": "http://127.0.0.1:1080"}` | +| HTTP(带认证) | `http://host:port@user@pass` | `{"proxy": "http://127.0.0.1:1080@admin@secret"}` | + +## 3. URL 附加 JS 参数 + +在 URL 后附加 JSON 对象,可在解析 URL 时执行 JavaScript 动态处理请求。 + +**语法:** + +``` +URL,{"js":"JavaScript 代码"} +``` + +**示例:** + +``` +https://www.example.com,{"js":"java.headerMap.put('xxx', 'yyy')"} +https://www.example.com,{"js":"java.url=java.url+'yyyy'"} +``` + +## 4. 图片链接自定义 Headers + +在正文图片链接中附加自定义请求头,适用于需要 Referer 或 Cookie 才能加载图片的场景。 + +```js +let options = { + "headers": { + "User-Agent": "xxxx", + "Referrer": baseUrl, + "Cookie": "aaa=vbbb;" + } +}; +'' +``` + +## 5. 重定向拦截 (Redirect Interception) + +通过 `java.get` / `java.post` 方法拦截重定向,获取最终跳转后的 URL。适用于搜索结果会重定向的网站。 + +```js +// 方法签名 +java.get(urlStr: String, headers: Map) +java.post(urlStr: String, body: String, headers: Map) +``` + +**示例**:搜索重定向场景下获取真实 URL: + +```js +(() => { + if (page == 1) { + let url = 'https://www.example.com/search,' + JSON.stringify({ + "method": "POST", + "body": "show=title&tempid=1&keyboard=" + key + }); + return source.put('surl', String(java.connect(url).raw().request().url())); + } else { + return source.get('surl') + '&page=' + (page - 1) + } +})() +``` diff --git a/docs/dev/rule.md b/docs/dev/rule.md new file mode 100644 index 000000000..f7d281229 --- /dev/null +++ b/docs/dev/rule.md @@ -0,0 +1,131 @@ +# 源规则 (Source Rule) 帮助 + +[[toc]] + +本文档介绍 Legado 源规则的核心配置项和语法。源通过声明式的 JSON 配置和可选的 JavaScript 逻辑,从网站抓取书籍内容。 + +::: tip 相关文档 + +- [JS 变量和函数](./js) — 内置变量和扩展 API +- [XPath 路径表达式](./xpath) — XPath 语法 +- [正则表达式](./regex) — 正则语法 +- [请求头配置](./request-headers) — 代理、Headers、重定向拦截 +- [认证与登录](./authentication) — CookieJar、登录 UI、登录 URL +- [发现 URL 配置](./discovery-url) — 发现页入口 JSON 格式 + ::: + +辅助键盘 ❓ 中可插入 URL 参数模板,打开帮助,js 教程,正则教程,选择文件。 + +## 2. 规则标志 (Rule Flags) + +在 {{......}} 内使用规则时,必须包含明确的规则标志。没有规则标志的内容将作为 +JavaScript 执行。 + +| 标志 | 语法 | 可省略条件 | 适用范围 | +|:----------|:---------|:--------------|:-----------| +| `@@` | 默认规则 | 直接写时可省略 | 所有字段 | +| `@XPath:` | XPath 规则 | 以 `//` 开头时可省略 | 所有字段 | +| `@Json:` | JSON 规则 | 以 `$.` 开头时可省略 | 所有字段 | +| `:` | 正则规则 | 不可省略 | 仅书籍列表和目录列表 | + +## 3. JS 库注入 (jsLib) + +注入 JavaScript 到 Rhino 引擎中,支持两种格式,可实现函数共用。 + +| 格式 | 说明 | 示例 | +|:------------------|:-----------------------|:-------------------------------------------| +| `JavaScript Code` | 直接填写 JavaScript 片段 | `function myUtil(s) { return s.trim() }` | +| JSON Map | URL 映射表,自动复用已下载的 js 文件 | `{"example":"https://example.com/lib.js"}` | + +::: warning 线程安全 +此处定义的函数可能被多个线程同时调用,函数内的全局变量内容将被共享。对其进行修改可能导致竞争问题。 + +- 函数内**不可**声明全局变量 +- 函数外的全局变量**不可**再赋值,否则会抛出 `无法修改密封对象的属性` 异常 + ::: + +## 4. 并发率 (Request Rate Limit) + +控制对目标网站的请求频率,支持两种格式: + +| 格式 | 说明 | 示例 | +|:------|:-----------|:----------------------------| +| `N` | 访问间隔(毫秒) | `1000` — 每次请求间隔 1 秒 | +| `N/M` | 时间窗口内最大请求数 | `20/60000` — 60 秒内最多 20 次请求 | + +## 5. 源类型:文件 (File Source) + +适用于提供文件下载的网站(如知轩藏书)。在源详情的「下载 URL」规则中获取文件链接。 + +**工作原理:** + +1. 通过截取下载链接或文件响应头获取文件信息 +2. 获取失败时自动拼接 `书名`、`作者` 和下载链接 `UrlOption` 的 `type` 字段 +3. 压缩文件解压缓存在下次启动后自动清理,不占用额外空间 + +## 6. 字体解析 (Font Parsing) + +在正文替换规则中使用,根据源字体的字形数据到目标字体中查找对应编码。 + +```js +(function(){ + var b64 = String(src).match(/ttf;base64,([^\)]+)/); + if (b64) { + var f1 = java.queryTTF(b64[1]); + var f2 = java.queryTTF("https://example.com/font/SourceHanSansCN.ttf"); + return java.replaceFont(result, f1, f2, true); + } + return result; +})() +``` + +## 7. 购买操作 (Purchase Action) + +可直接填写链接或 JavaScript。 + +| 返回值 | 行为 | +|:--------------|:------------| +| 网络链接 | 自动打开浏览器 | +| `true`(JS 返回) | 自动刷新目录和当前章节 | + +## 8. 图片解密 (Image Decryption) + +适用于图片需要二次解密的情况。直接填写 JavaScript,返回解密后的 `ByteArray`。 + +**可用变量:** + +| 变量 | 说明 | +|:---------|:-------------------------------------------------------------------------------------------------------------------------------------| +| `java` | 仅支持 [JsExtensions](https://github.com/HapeLee/legado-with-MD3/blob/master/app/src/main/java/io/legado/app/help/JsExtensions.kt) 中的方法 | +| `result` | 待解密图片的 `ByteArray` | +| `src` | 图片链接 | + +**示例 — AES 解密:** + +```js +java.createSymmetricCrypto("AES/CBC/PKCS5Padding", key, iv).decrypt(result) +``` + +**示例 — XOR 解密:** + +```js +function decodeImage(data, key) { + var input = new Packages.java.io.ByteArrayInputStream(data) + var out = new Packages.java.io.ByteArrayOutputStream() + var byte + while ((byte = input.read()) != -1) { + out.write(byte ^ key) + } + return out.toByteArray() +} + +decodeImage(result, key) +``` + +### 封面解密 + +与图片解密类似,其中 `result` 为待解密封面的 `InputStream`。 + +```js +java.createSymmetricCrypto("AES/CBC/PKCS5Padding", key, iv).decrypt(result) +``` diff --git a/docs/dev/source-fields.md b/docs/dev/source-fields.md new file mode 100644 index 000000000..4cb283526 --- /dev/null +++ b/docs/dev/source-fields.md @@ -0,0 +1,193 @@ +# 源字段速查 (Source Fields) + +[[toc]] + +本文档列出源 JSON 中各部分(搜索、发现、详情页、目录、正文)的所有可用字段。 + +## 1. 基本字段 + +| 字段 | 必须 | 说明 | +|:--------------------|:---|:------------------------------------------| +| `bookSourceUrl` | 是 | 源 URL,唯一标识,不可重复。与其他源相同会覆盖 | +| `bookSourceName` | 是 | 源名称,可重复 | +| `bookSourceGroup` | 否 | 源分组,用于整理 | +| `bookSourceType` | 否 | 源类型:`0`(文本,默认)、`1`(音频) | +| `bookUrlPattern` | 否 | 书籍 URL 正则,添加网址时用于识别源 | +| `header` | 否 | 请求头 JSON 字符串,见 [请求头配置](./request-headers) | +| `loginUrl` | 否 | 登录 URL,见 [认证与登录](./authentication) | +| `exploreUrl` | 否 | 发现地址,见 [发现 URL 配置](./discovery-url) | +| `searchUrl` | 否 | 搜索地址 | +| `weight` | 否 | 源权重,搜索排序时使用 | +| `enabled` | 否 | 是否启用 | +| `enabledExplore` | 否 | 是否启用发现 | +| `customOrder` | 否 | 自定义排序 | +| `lastUpdateTime` | 否 | 最后更新时间 | +| `bookSourceComment` | 否 | 源备注 | + +## 2. 搜索字段 (`ruleSearch`) + +| 字段 | 说明 | +|:--------------|:-----------------------------------| +| `url` | 搜索地址。`{{key}}` 为关键字,`{{page}}` 为页码 | +| `bookList` | 书籍列表规则 | +| `name` | 书名规则 | +| `author` | 作者规则 | +| `kind` | 分类规则 | +| `wordCount` | 字数规则 | +| `lastChapter` | 最新章节规则 | +| `intro` | 简介规则 | +| `coverUrl` | 封面规则 | +| `bookUrl` | 详情页 URL 规则 | + +## 3. 发现字段 (`ruleExplore`) + +| 字段 | 说明 | +|:--------------|:---------------------------------| +| `url` | 发现地址。`{{page}}` 为页码,支持 JSON 数组格式 | +| `bookList` | 书籍列表规则 | +| `name` | 书名规则 | +| `author` | 作者规则 | +| `kind` | 分类规则 | +| `wordCount` | 字数规则 | +| `lastChapter` | 最新章节规则 | +| `intro` | 简介规则 | +| `coverUrl` | 封面规则 | +| `bookUrl` | 详情页 URL 规则 | + +## 4. 详情页字段 (`ruleBookInfo`) + +| 字段 | 说明 | +|:---------------|:-----------------------------------------| +| `bookInfoInit` | 预处理规则(仅支持 AllInOne 正则或 JS) | +| `name` | 书名规则 | +| `author` | 作者规则 | +| `kind` | 分类规则 | +| `wordCount` | 字数规则 | +| `lastChapter` | 最新章节规则 | +| `intro` | 简介规则 | +| `coverUrl` | 封面规则 | +| `tocUrl` | 目录 URL 规则(仅支持单个 URL) | +| `canReName` | 允许修改书名作者 | +| `relatedBooks` | 关联书籍配置,见 [关联书籍配置](../spec/related-books) | + +### 预处理规则 (`bookInfoInit`) + +只能使用 AllInOne 正则(以 `:` 开头)或 JS。JS 返回值需为 JSON 对象: + +```javascript +(function(){ + return { + a: "书名", + b: "作者", + c: "分类", + d: "字数", + e: "最新章节", + f: "简介", + g: "封面URL", + h: "目录URL" + }; +})() +``` + +此时各规则字段填对应的 key:`name` → `a`,`author` → `b`,以此类推。 + +### `canReName` 逻辑 + +| 条件 | 行为 | +|:-----------------|:--------| +| 规则不为空 且 详情页书名不为空 | 使用详情页书名 | +| 否则 | 使用搜索页书名 | +| 规则不为空 且 详情页作者不为空 | 使用详情页作者 | +| 否则 | 使用搜索页作者 | + +## 5. 目录字段 (`ruleToc`) + +| 字段 | 说明 | +|:--------------|:---------------------------------------------------| +| `chapterList` | 目录列表规则。首字符 `-` 可使列表反序 | +| `chapterName` | 章节名称规则 | +| `chapterUrl` | 章节 URL 规则 | +| `isVip` | VIP 标识。结果为 `null`、`false`、`0`、`""` 时为非 VIP | +| `updateTime` | 章节信息(可用 `java.timeFormat(timestamp)` 转换时间戳) | +| `nextTocUrl` | 目录下一页规则。支持单个 URL、URL 数组,JS 返回 `[]`/`null`/`""` 时停止 | + +## 6. 正文字段 (`ruleContent`) + +| 字段 | 说明 | +|:-----------------|:---------------------------------------| +| `content` | 正文规则 | +| `nextContentUrl` | 正文下一页 URL 规则。支持单个 URL、URL 数组 | +| `sourceRegex` | 资源正则,用于嗅探媒体资源 | +| `webJs` | WebView JS,用于模拟点击等操作。必须有返回值(不为空表示执行成功) | + +### WebView JS (`webJs`) + +用于模拟鼠标点击等操作,返回值不为空表示执行成功(否则会无限循环),返回值用于资源正则或正文。 + +**示例:** + +```javascript +getDecode();$('#content').html(); +``` + +### 资源正则 (`sourceRegex`) + +用于嗅探 WebView 加载的媒体资源。配合章节 URL 的 `{"webView": true}` 使用。 + +一般写 `.*\.(mp3|mp4).*` 即可匹配常见媒体格式。 + +## 7. 完整 JSON 结构 + +```json +{ + "bookSourceUrl": "https://www.example.com", + "bookSourceName": "示例源", + "bookSourceGroup": "分组", + "bookSourceType": 0, + "bookUrlPattern": "", + "header": "", + "loginUrl": "", + "searchUrl": "/search?key={{key}}&page={{page}}", + "exploreUrl": "", + "enabled": true, + "enabledExplore": false, + "weight": 0, + "ruleSearch": { + "bookList": "", + "name": "", + "author": "", + "kind": "", + "wordCount": "", + "lastChapter": "", + "intro": "", + "coverUrl": "", + "bookUrl": "" + }, + "ruleExplore": { + "bookList": "", + "name": "", + "author": "", + "bookUrl": "" + }, + "ruleBookInfo": { + "name": "", + "author": "", + "kind": "", + "intro": "", + "coverUrl": "", + "tocUrl": "" + }, + "ruleToc": { + "chapterList": "", + "chapterName": "", + "chapterUrl": "", + "nextTocUrl": "" + }, + "ruleContent": { + "content": "", + "nextContentUrl": "", + "sourceRegex": "", + "webJs": "" + } +} +``` diff --git a/docs/dev/syntax.md b/docs/dev/syntax.md new file mode 100644 index 000000000..31547c208 --- /dev/null +++ b/docs/dev/syntax.md @@ -0,0 +1,263 @@ +# 规则语法详解 (Rule Syntax) + +[[toc]] + +Legado 支持多种规则语法,可在源的不同字段中混合使用。本文档详细介绍每种语法的写法和使用场景。 + +## 1. JSOUP 默认规则 (Default) + +以 `@` 为分隔符的 CSS 选择器语法,是 Legado 最常用的规则类型。 + +### 基本结构 + +每段规则可分为 3 部分,用 `.` 连接: + +``` +类型.名称.位置 +``` + +| 部分 | 说明 | 示例 | +|:---|:-----------------|:-------------------------------------| +| 类型 | 选择器类型 | `class`、`id`、`tag`、`text`、`children` | +| 名称 | 匹配值 | `odd`、`content`、`a` | +| 位置 | 索引(从 0 开始,负数为倒数) | `0`、`-1` | + +**示例:** + +``` +class.odd.0@tag.a.0@text // 取 class="odd" 的第一个元素下第一个 标签的文本 +tag.div.-1@text // 取最后一个
    的文本 +``` + +### 最后一段为获取内容 + +| 值 | 说明 | +|:------------|:------------| +| `text` | 文本内容 | +| `textNodes` | 文本节点列表 | +| `ownText` | 自身文本(不含子元素) | +| `href` | 链接地址 | +| `src` | 图片/资源地址 | +| `html` | HTML 内容 | +| `all` | 全部内容 | + +### 排除符号 `!` + +用 `!` 排除不需要的元素,多个排除用 `:` 分隔: + +``` +tag.div!0:2@text // 排除第 1 个和第 3 个 div +``` + +### 列表倒置 `-` + +在获取列表的最前面加 `-` 可使列表倒置,适用于网站目录列表本身是倒序的情况: + +``` +-tag.dd@tag.a@text // 倒序获取所有 dd 下的 a 标签文本 +``` + +### 正则替换 `##` + +在规则末尾添加 `##正则表达式##替换内容`: + +``` +tag.p@text##去除的文字##替换为的文字 +tag.p@text##要去除的文字 // 替换内容为空时可省略第二个 ## +``` + +### 数组写法 `[index]` + +支持类似数组的索引选择: + +| 写法 | 说明 | +|:-----------|:--------------| +| `[0]` | 第一个元素 | +| `[-1]` | 最后一个元素 | +| `[0:3]` | 前 3 个元素 | +| `[2:5]` | 第 3 到第 5 个元素 | +| `[0:10:2]` | 每隔一个取一个 | +| `[!0:2]` | 排除第 1 个和第 3 个 | +| `[-1:0]` | 反转列表 | + +### 连接符号 + +| 符号 | 说明 | 示例 | +|:-------|:---------------|:------------------------------| +| `\|\|` | 优先取第一个有值的结果 | `tag.a@text\|\|tag.span@text` | +| `&&` | 合并所有取到的值 | `tag.a@text&&tag.span@text` | +| `%%` | 交替取值(依次从各列表中取) | `tag.a@href%%tag.a@text` | + +::: tip 使用限制 +连接符号只能在同种规则间使用,不包括 js 和正则。 +::: + +## 2. JSOUP CSS 选择器 (CSS Selector) + +以 `@css:` 开头的标准 CSS 选择器语法。 + +``` +@css:选择器@获取内容 +``` + +**示例:** + +``` +@css:.book-list li@text // 获取 class="book-list" 下所有 li 的文本 +@css:#content p@html // 获取 id="content" 下所有 p 的 HTML +@css:[property=og:image]@content // 获取 meta 标签的 content 属性 +``` + +- 在线测试:[Try jsoup online](https://try.jsoup.org/) + +## 3. JSONPath 路径表达式 + +以 `@json:` 或 `$.` 开头的 JSON 数据提取语法。 + +``` +@json:表达式 +$.表达式 +``` + +**示例:** + +``` +$.data.books[*] // 获取所有书籍 +$.data.books[0].title // 获取第一本书的标题 +$..books[*] // 递归查找所有 books 数组 +$.info.Datas // 获取 info 下的 Datas +``` + +- 在线测试:[Jayway JsonPath Evaluator](http://jsonpath.herokuapp.com/) + +## 4. XPath 路径表达式 + +以 `@XPath:` 或 `//` 开头的 XML/HTML 路径表达式。 + +``` +@XPath:表达式 +//表达式 +``` + +**示例:** + +``` +//div[@class="book-list"]/ul/li // 获取指定 class 下的列表项 +//*[@property="og:novel:author"]/@content // 获取 meta 标签属性 +//a[text()="阅读"]/@href // 获取特定文本链接的 href +``` + +详细语法参见 [XPath 路径表达式](./xpath)。 + +## 5. JavaScript 脚本 + +在 `` 或 `@js:` 中使用 JavaScript。 + +| 形式 | 使用位置 | 说明 | +|:------------|:----------------|:-----------------------| +| `@js:` | 只能放在其他规则的**最后** | 将前面规则的结果作为 `result` 变量 | +| `` | 任意位置 | 可作为其他规则的分隔符 | + +**示例:** + +``` +@css:.book@text@js:result.replace("旧", "新") // 先取文本再用 JS 处理 +tag.li//a // JS 作为分隔符 +@js:java.base64Encode(key) // 纯 JS 表达式 +``` + +### 在搜索/发现列表中使用 + +以 `+` 开头可使用 AllInOne 规则,在搜索列表、发现列表和目录中使用。 + +## 6. 正则表达式 + +Legado 中有三种正则用法: + +### 全部匹配 (AllInOne) + +- 只能在搜索列表、发现列表、详情页预加载和目录列表中使用 +- 必须以 `:` 开头 + +``` +:<正则表达式> +``` + +**示例(目录页):** + +``` +-:
  • ([^<]*) // 提取链接和标题,- 前缀表示倒序 +``` + +### 单一匹配 (OnlyOne) + +形式:`##正则表达式##替换内容###` + +- 只能在搜索列表、发现列表、详情页预加载、目录列表**之外**使用 +- 只获取**第一个**匹配结果并替换 + +**示例(详情页):** + +``` +##:book_name"[^"]+"([^"]+)"##$1### // 提取书名 +``` + +### 净化(循环替换) + +形式:`##正则表达式##替换内容` + +- 跟在其他规则后面,对结果进行循环匹配替换 +- 独立使用相当于 `all##正则表达式##替换内容` + +**示例(正文页):** + +``` +@css:.content@html#### // 移除所有 script 标签 +``` + +## 7. `{{}}` 模板变量 + +### 在搜索 URL 和发现 URL 中 + +`{{}}` 内只能使用 JavaScript: + +``` +/search?key={{key}}&page={{page}} +/search?key={{java.base64Encode(key)}}&page={{(page-1)*20}} +``` + +### 在其他规则中 + +`{{}}` 内可使用任意规则,默认为 JS。使用其他规则需要标志头: + +| 规则类型 | 标志头 | +|:---------|:-----------------| +| JS(默认) | 无需标志 | +| Default | `@@` | +| XPath | `@xpath:` 或 `//` | +| JSONPath | `@json:` 或 `$.` | +| CSS | `@css:` | + +### `{}` 旧语法 + +保留的阅读 2.0 语法,只能使用 JSONPath,建议避免使用。 + +## 8. 变量存取 + +### `@put` 与 `@get` + +只能在 JS 以外的规则中使用: + +``` +@put:{bid:"//*[@bid-data]/@bid-data"} // 存储变量 +@get:bid // 读取变量 +``` + +### `java.put` 与 `java.get` + +只能在 JS 中使用(JS 中无法使用 `@get`): + +```js +java.put('key', 'value') // 存储 +java.get('key') // 读取 +``` diff --git a/docs/dev/tts-rule.md b/docs/dev/tts-rule.md new file mode 100644 index 000000000..7518736ff --- /dev/null +++ b/docs/dev/tts-rule.md @@ -0,0 +1,31 @@ +# 在线朗读规则 + +在线朗读规则用于接入第三方 TTS(文本转语音)服务。规则格式为 URL 规则,语法同源 URL 规则。 + +[[toc]] + +## JS 参数 + +在规则中可以使用以下 JS 变量: + +| 参数 | 说明 | +|--------------|--------------| +| `speakText` | 待朗读的文本内容 | +| `speakSpeed` | 朗读速度,范围 5-50 | + +## 示例 + +在线 TTS 接口示例: + +``` +http://tts.example.com/text2audio,{ + "method": "POST", + "body": "text={{java.encodeURI(speakText)}}&speed={{speakSpeed}}&lang=zh" +} +``` + +::: tip 说明 + +- 请求返回的音频会自动播放 +- `speakSpeed` 值越大语速越快,需根据具体 TTS 服务的参数范围进行映射 + ::: diff --git a/docs/dev/txt-toc.md b/docs/dev/txt-toc.md new file mode 100644 index 000000000..a41a39a53 --- /dev/null +++ b/docs/dev/txt-toc.md @@ -0,0 +1,30 @@ +# TXT 目录正则 + +对于 TXT 格式的本地书籍,Legado 使用正则表达式自动识别章节标题并生成目录。当默认规则无法满足需求时,可以自定义目录正则。 + +[[toc]] + +## 菜单区 + +| 按钮 | 说明 | +|------------|-------------------------| +| **新增目录规则** | 创建自定义目录正则,满足个性化需求 | +| **导入默认规则** | 恢复或更新 Legado 内置的默认目录规则 | +| **网络导入** | 从网络导入他人分享的目录规则 | +| **拆分超长章节** | 启用后,单章超过约 3 万字时会自动拆分为多章 | + +::: tip 注意 +导入默认规则不会覆盖用户自定义的规则,但会重置用户对内置规则的修改。 +::: + +## 操作区 + +界面上的按钮分为三组: + +- **按钮 ①**(单选):选中表示当前书籍使用该目录规则。如果 Legado 自动识别的目录不理想,可以手动选择其他规则。该按钮 + **仅对当前书籍生效**。 +- **按钮组 ②**: + - 左边开关:启用后该规则会在自动识别目录时尝试匹配,**对所有 TXT 书籍生效** + - 中间按钮:编辑当前规则 + - 右边按钮:删除当前规则(内置规则删除后可通过「导入默认规则」恢复) +- **按钮 ③**(确认):在当前界面进行操作后,需要点击确认按钮使选择生效 diff --git a/docs/dev/url-options.md b/docs/dev/url-options.md new file mode 100644 index 000000000..44ba5bebd --- /dev/null +++ b/docs/dev/url-options.md @@ -0,0 +1,193 @@ +# URL 参数详解 (URL Options) + +[[toc]] + +Legado 源中的 URL 支持通过 JSON 对象附加请求参数,控制请求方法、编码、Headers、WebView 等行为。 + +## 1. 基本语法 + +URL 和参数之间用逗号 `,` 连接: + +``` +URL,{JSON 参数} +``` + +**简单 GET 请求:** + +``` +https://www.example.com/api/list +``` + +**带参数的请求:** + +``` +https://www.example.com/api/list,{ + "charset": "gbk", + "headers": {"User-Agent": "Mozilla/5.0 ..."} +} +``` + +## 2. UrlOption 完整字段 + +| 字段 | 类型 | 必须 | 说明 | +|:----------|:----------|:---|:----------------------------------| +| `method` | `String` | 否 | 请求方法,`GET`(默认)或 `POST` | +| `charset` | `String` | 否 | 响应编码,默认 `utf-8` | +| `headers` | `Object` | 否 | 自定义请求头 | +| `body` | `String` | 否 | POST 请求体 | +| `webView` | `Boolean` | 否 | 是否使用 WebView 加载 | +| `js` | `String` | 否 | 解析 URL 时执行的 JS | +| `type` | `String` | 否 | 文件类型(用于文件类源) | +| `retry` | `Int` | 否 | 重试次数,默认 0 | +| `proxy` | `String` | 否 | 代理地址,见 [请求头配置](./request-headers) | + +## 3. GET 请求 + +### 简单形式 + +``` +https://www.example.com/api/list +``` + +### 带 Headers + +``` +https://www.example.com/api/list,{ + "headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept-Language": "zh-CN,zh;q=0.9" + } +} +``` + +### 指定编码 + +``` +https://www.example.com/list,{ + "charset": "gbk" +} +``` + +### 使用 WebView + +``` +https://www.example.com/book/123,{ + "webView": true +} +``` + +### JS 动态构建 + +```javascript +var ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"; +var headers = {"User-Agent": ua}; +var option = { + "charset": "gbk", + "headers": headers, + "webView": true +}; +"https://www.example.com," + JSON.stringify(option) +``` + +## 4. POST 请求 + +### 简单形式 + +``` +https://www.example.com/search,{ + "method": "POST", + "body": "keyword=系统&page=1" +} +``` + +### 带 Headers 和编码 + +``` +https://www.example.com/search,{ + "charset": "gbk", + "method": "POST", + "body": "searchkey={{key}}&page={{page}}", + "headers": { + "User-Agent": "Mozilla/5.0 ..." + } +} +``` + +### JS 动态构建 + +```javascript +var body = "key=" + key + "&page=" + page; +var option = { + "method": "POST", + "body": String(body), + "headers": {"User-Agent": "Mozilla/5.0 ..."} +}; +"https://www.example.com/search," + JSON.stringify(option) +``` + +::: warning body 类型 +`body` 必须保证是 JavaScript 的 String 类型。变量是计算得到的尽量都用 `String()` 强转。 +::: + +## 5. WebView 模式 + +设置 `"webView": true` 后,Legado 会使用内置 WebView 加载页面,适用于需要 JavaScript 渲染的网站。 + +### WebView 加载 + +``` +https://www.example.com/book/123,{ + "webView": true +} +``` + +### WebView + 正文嗅探 + +章节链接加 `{"webView": true}`,配合正文的 `sourceRegex` 嗅探媒体资源: + +```json +{ + "ruleToc": { + "chapterUrl": "href##$##{\"webView\":true}" + }, + "ruleContent": { + "content": "result", + "sourceRegex": ".*\\.(mp3|mp4).*" + } +} +``` + +**嗅探步骤:** + +1. 章节链接后面加 `,{"webView":true}` +2. 在有嗅探功能的浏览器中输入章节链接(不带 webView 参数) +3. 媒体开始播放后使用浏览器的嗅探功能查看资源链接 +4. 在资源正则里填写资源链接的正则,如 `.*\.(mp3|mp4).*` +5. 正文填写 `result` + +## 6. 模板变量 + +URL 中可使用以下模板变量: + +| 变量 | 说明 | 适用位置 | +|:--------------------------------|:-----------|:--------------| +| `{{key}}` | 搜索关键字 | 搜索 URL | +| `{{page}}` | 页码(从 1 开始) | 搜索 URL、发现 URL | +| `{{page - 1 == 0 ? "" : page}}` | 第一页无页码 | 搜索 URL、发现 URL | +| `<,{{page}}>` | 第一页无页码(简写) | 搜索 URL、发现 URL | + +### 页码计算示例 + +``` +/search?key={{key}}&start={{(page-1)*20}}&limit=20 +/search?key={{key}}&page={{page - 1 == 0 ? "" : page}} +``` + +## 7. 相对 URL + +URL 支持相对路径,会自动基于源 URL 拼接: + +``` +/search?key={{key}} // 相对于 bookSourceUrl +/api/list // 相对于 bookSourceUrl +``` diff --git a/docs/dev/xpath.md b/docs/dev/xpath.md new file mode 100644 index 000000000..0c0be2bbb --- /dev/null +++ b/docs/dev/xpath.md @@ -0,0 +1,165 @@ +# XPath 路径表达式 + +[[toc]] + +XPath 是一种用于在 XML/HTML 文档中定位节点的语言。Legado 源支持使用 XPath 规则提取网页内容,以 `//` +开头的规则会自动识别为 XPath 表达式。 + +_注:本文所有代码均通过 Chrome(版本 123.0.6312.86) 验证_ + +## 1. 轴 (Axes) + +XPath 规范定义了 13 种轴,用于定位元素树上相对于当前元素的节点。 + +| 轴 | 说明 | 缩写 | +|:---------------------|:---------------------------|:-----| +| `attribute` | 元素的属性 | `@` | +| `self` | 元素本身 | `.` | +| `parent` | 当前元素的父元素 | `..` | +| `child` | 当前元素的子元素 | — | +| `ancestor` | 当前元素的所有直属祖先 | — | +| `ancestor-or-self` | 当前元素及其所有直属祖先 | — | +| `descendant` | 当前元素的所有递归子元素 | — | +| `descendant-or-self` | 当前元素及其所有递归子元素 | — | +| `following` | 当前元素之后出现的所有元素(无视层级,不含直属后代) | — | +| `following-sibling` | 当前元素之后出现的所有同级元素 | — | +| `preceding` | 当前元素之前出现的所有元素(无视层级,不含直属祖先) | — | +| `preceding-sibling` | 当前元素之前出现的所有同级元素 | — | +| `namespace` | 不支持 | — | + +**语法**:`轴名::表达式` + +```js +> $x('//body/ancestor-or-self::*') +< [body, html] +``` + +## 2. 路径格式 + +XPath 通过"路径表达式"(Path Expression)选取元素,形式上与文件系统路径类似。 + +| 概念 | 说明 | +|:-----|:----------------------------------| +| `/` | 路径内部分隔符 | +| 绝对路径 | 以 `/` 起首,后跟根元素,如 `/step/step/...` | +| 相对路径 | 除绝对路径外的其他写法,如 `step/step` | +| `.` | 当前元素 | +| `..` | 当前元素的父元素 | + +### 选取语法 + +| 符号 | 说明 | +|:-----------|:------------| +| `/` | 选取根元素 | +| `//` | 选取任意位置的某个元素 | +| `nodename` | 选取指定名称的元素 | +| `@` | 选取某个属性 | + +## 3. 示例 + +以下示例基于这段 HTML: + +```html + + + + + 标题 + + + +
    + Harry Potter +

    29.39

    +

    usd

    +
    +
    + Cpp高级编程 +

    39.95

    +

    rmb

    +
    +
    +
    +
    +
    +
    +
    +
    + + +``` + +| 表达式 | 结果 | 说明 | +|:-----------------------|:---------------|:--------------| +| `$x('/')` | `[document]` | 选取根元素 | +| `$x('/html')` | `[html]` | 绝对路径选取 | +| `$x('html/head/meta')` | `[meta, meta]` | 相对路径选取 | +| `$x('//p')` | `[p, p, p, p]` | 选取所有 p 元素 | +| `$x('html/body//a')` | `[a, a, a]` | 选取 body 下所有 a | +| `$x('//@lang')` | `[lang, lang]` | 选取属性 | +| `$x('//meta/..')` | `[head]` | 选取父元素 | + +## 4. 谓语条件 (Predicate) + +谓语条件是对路径表达式的附加筛选条件,写在方括号 `[]` 中。 + +| 表达式 | 结果 | 说明 | +|:-------------------------------|:-------------------------------|:--------| +| `html/head/meta[1]` | `` | 选取第一个 | +| `html/head/meta[last()]` | `` | 选取最后一个 | +| `html/head/meta[last()-1]` | `` | 选取倒数第二个 | +| `html/head/meta[position()>1]` | `` | 位置大于 1 | +| `//title[@lang]` | 两个 title | 具有特定属性 | +| `//title[@lang="eng"]` | English title | 属性值匹配 | +| `/html/body/div[dl]` | `
    ` | 包含特定子元素 | +| `/html/body/div[p>35.00]` | Cpp 高级编程 div | 子元素值条件 | + +## 5. 通配符 + +| 符号 | 说明 | +|:-----|:--------| +| `*` | 匹配任何元素 | +| `@*` | 匹配任何属性名 | + +```js +$x('//*') // 选取所有元素 +$x('/*/*') // 选取所有第二层元素 +$x('//title[@*]') // 选取所有带属性的 title +``` + +## 6. 多路径选择 + +用 `|` 合并多个表达式的选取结果: + +```js +$x('//title | //a') // 选取所有 title 和 a 元素 +``` + +## 7. 函数 + +XPath 函数的参数可以是静态字符串或表达式,函数可嵌套调用。XPath 索引从 **1** 开始。 + +| 函数 | 说明 | 示例 | +|:---------------------|:----------|:------------------------------------------------------| +| `boolean()` | 转换为布尔值 | `boolean(//title)` → `true` | +| `number()` | 转换为数字 | `number(//p[1])` → `29.39` | +| `round()` | 四舍五入 | `round(//p[1])` → `29` | +| `ceiling()` | 向上取整 | `ceiling(//p[1])` → `30` | +| `floor()` | 向下取整 | `floor(//p[1])` → `29` | +| `concat()` | 字符串拼接 | `concat("cost:", //p[1], //p[2])` → `'cost:29.39usd'` | +| `contains()` | 判断是否包含 | `contains(//p[1], "29.39")` → `true` | +| `count()` | 统计元素个数 | `count(//p)` → `4` | +| `id()` | 根据 id 选取 | `id("list")` → `[dl#list]` | +| `last()` | 同级元素集合数量 | `//p[last()]` | +| `name()` | 返回元素名 | `name(//*[@id])` → `'dl'` | +| `normalize-space()` | 去除前后空白 | `normalize-space(" test ")` → `'test'` | +| `not()` | 返回布尔反值 | `//title[not(@lang)]` | +| `position()` | 返回元素位置 | `//meta[position()=2]` | +| `starts-with()` | 检查字符串开头 | `//title[starts-with(., "Cpp")]` | +| `string()` | 转换为字符串 | `string(//p)` → `'29.39'` | +| `string-length()` | 返回字符串长度 | `string-length(string(//p))` → `5` | +| `substring()` | 截取字符串 | `substring(string(//p), 1, 3)` → `'29.'` | +| `substring-after()` | 某字符之后的字符串 | `substring-after(string(//p), ".")` → `'39'` | +| `substring-before()` | 某字符之前的字符串 | `substring-before(string(//p), ".")` → `'29'` | +| `sum()` | 对数字求和 | `sum(//p[1])` → `69.34` | +| `translate()` | 依次替换字符 | `translate("aabbcc", "ac", "V8")` → `'VVbb88'` | diff --git a/docs/guide/book-source.md b/docs/guide/book-source.md new file mode 100644 index 000000000..923804f1b --- /dev/null +++ b/docs/guide/book-source.md @@ -0,0 +1,46 @@ +# 导入源管理界面帮助 + +[[toc]] + +## 源标志 + +- 🟢 绿点:源有发现,且启用了发现 +- 🔴 红点:源有发现,但是未启用 +- 无标志:此源没有发现 + +## 功能菜单 + +右上角有分组菜单,可以按分组筛选源。 + +右上角更多菜单里包含: + +- 新建源 +- 本地导入 +- 网络导入 +- 二维码导入 +- 分享选中源 + +## 批量操作 + +选择源的更多操作在右下角的菜单里面,操作都是针对选择的源: + +- 启用所选 +- 禁用所选 +- 添加分组 +- 移除分组 +- 启用发现 +- 禁用发现 +- 置顶所选 +- 置底所选 +- 导出所选 +- 校验所选 + +## 校验源 + +校验源可批量校验源,由于网络等原因结果仅限参考。 + +- "校验成功"是指所选的校验项目全部通过 +- 可正常识别搜索为空、发现为空、搜索(发现)目录为空、搜索(发现)正文为空、校验超时、js + 执行错误导致的失效,其余的原因视为网站失效 +- 校验搜索优先使用源填写的校验关键词,不存在时使用用户输入的关键词 +- 校验结束后会自动筛选"失效"源 diff --git a/docs/guide/index.md b/docs/guide/index.md new file mode 100644 index 000000000..32036d734 --- /dev/null +++ b/docs/guide/index.md @@ -0,0 +1,212 @@ +# 帮助文档 + +[[toc]] + +## 新人必读 + +### 1. 为什么第一次安装后没有内容? + +本应用是一款内容聚合阅读工具,不直接提供内容。需导入本地书籍文件或配置内容源后方可使用。 + +### 2. 正文出现缺字漏字、内容缺失或排版错乱怎么办? + +可能是替换净化规则导致。请先关闭替换净化并刷新,若恢复正常则说明净化规则存在误配。关闭后仍有问题,请点击源链接查看原文是否一致,若不一致再进行反馈。 + +## 导入源相关 + +### 1. 如何导入本地导入源文件? + +1. 打开本应用 +2. 点击"**我的**"——"**导入源管理**" +3. 点击右上角选择"**本地导入**" +4. 选择导入源文件所在的路径 +5. 点击导入源文件导入 + +::: tip 注意 +导入源格式后缀有 `.txt` 和 `.json`,其中 `.json` 文件在某些情况下可能无法导入,需要修改后缀为 `.txt` +才可导入。 +::: + +### 2. 如何新建导入源? + +1. 复制导入源代码 +2. 点击"**我的**"——"**导入源管理**" +3. 右上角"**⁝**"——"**+ 新建导入源**" +4. 进入后点击右上角"**⁝**"——"**粘贴源**" +5. 粘贴完成后点击上方保存按钮 + +::: tip 注意 +如果导入源有错误或者复制不全会显示格式错误,请重新复制。 +::: + +### 3. 校验导入源显示失效就说明导入源不能用了吗? + +校验仅作为参考,显示失效不代表导入源不可用。 + +## 本地/WebDav 远程书籍相关 + +### 1. 目前支持哪些格式的本地书籍? + +目前支持 TXT 和 EPUB 格式。 + +### 2. 如何导入本地/WebDav 远程书籍? + +- **本地**:在书架页面点击右上角"**⁝**",选择"**添加本地**",授予相关权限后即可导入本地书籍。也可在文件管理器中使用本应用打开相关书籍。 +- **远程**:在主页面点击右上角"**⁝**",选择 **WebDav 书籍**,正确配置好后即可看到上传的远程书籍,点击 * + *加入书架** 按钮导入即可。 + +### 3. 如何上传本地书籍到 WebDav 远程? + +长按本地书籍,进入书籍详情页,点击右上角"**⁝**",选择 **上传 WebDav**,等待几秒后即可上传到远程。 + +或进入书籍缓存页面,点击右上角"**⁝**",选择 **导出到 WebDav**,在书籍导出时便可同时上传到远程。 + +### 4. 导入 TXT 文件提示 "LoadTocError" 或 "List of empty" 是怎么回事? + +- 请先去应用详情中确认是否授予了本应用"读写手机存储"的权限。 +- 自动识别目录失败,可能是相关目录规则未开启,请点击右上角的换源按钮手动更换目录规则。 + +如果尝试所有规则均无法识别,请在 GitHub 上提交 Issue 并附上相关 TXT 文件。 + +### 5. 如何下载书籍到本地? + +把在线书籍加入到书架后,在书架页面点击右上角,选择"**离线缓存**"即可。 + +### 6. 如何自定义导出的 TXT 或 EPUB 文件名称? + +- 点击"**离线缓存**"——"**导出文件名**" +- 使用方法: + - 导出文件名支持 js 语法 + - 可用变量:`name`(书名)和 `author`(作者) + - 示例:`name + " - " + author` + +::: tip 注意 +`name`、`author` 等变量与字符串的拼接都需要在 JSON 上下文环境中进行,即必须使用 `{}` 将变量与字符串包裹起来。 +::: + +### 7. 打开本地 TXT 文件显示乱码怎么办? + +部分编码可能识别错误,建议先用文本编辑器转换为 UTF-8 格式。 + +### 8. 正文被识别为标题怎么办? + +点击右上角更换目录规则即可。 + +## 书籍界面相关 + +### 1. 如何刷新书架? + +在书架界面下拉即可刷新。 + +### 2. 书架界面书籍右上角的数字代表什么? + +红色代表书籍有更新,灰色代表无更新,数字代表未读章节。 + +### 3. 如何查看书籍详情? + +长按书籍即可查看。 + +### 4. 如何对书架上的书进行删除、切换书架的操作? + +书籍详情页操作即可。 + +### 5. 如何禁止或允许某本书更新? + +书籍详情页,点击右上角——"**允许更新**"。 + +### 6. 如何更换书籍封面、名字、作者或简介? + +书籍详情页,点击右上角修改按钮。 + +### 7. 怎么使用自定义字体? + +阅读界面——"**字体**"——点击右上角选择字体文件路径。 + +### 8. 目前支持哪些格式的字体文件? + +目前支持 TTF 和 OTF 格式。 + +### 9. 书籍经常"正在加载中"怎么办? + +在线书籍通常是由于源质量不好或不兼容引起的,可以换其它源多试试;本地书籍通常是目录规则问题,手动切换规则可以解决。 + +### 10. 书籍内容只有标题,正文内容是路径怎么办? + +通常是缓存路径引起的,更换缓存路径即可。 + +### 11. 阅读时遇到"目录为空"、"加载失败"等情况怎么办? + +在线书籍一般是源问题,切换或更新源即可。本地书籍请尝试手动更换目录规则。 + +### 12. 每章最后一页文字和横线背景对不齐怎么办? + +请在"**设置**"——"**文字底部对齐**"选项中关闭底部对齐,再调整排版。 + +### 13. 图片章节只能看到第一页怎么办? + +请先查看原网页是否正常,若正常,请在阅读界面点击右上角"**⁝**",选择"**翻页动画(本书)**" +,将翻页动画更改为"**滚动**"。 + +### 14. 图片被缩放到一页中怎么办? + +- **临时方案**:长按图片可以进行双指缩放。图片章节请先将翻页动画更改为"**滚动**"。 +- **图片样式**:在阅读界面点击右上角"**⁝**",选择"**图片样式**"——`full`。 + +## 替换净化相关 + +### 1. 替换净化是什么? + +替换净化可以去除书籍内容里的广告、错别字、屏蔽词等。 + +### 2. 如何填写净化替换规则? + +1. **第一行**:替换规则名称 +2. **第二行**:分组 +3. **第三行**:替换规则,填写需要被替换的内容 +4. **第四行**:替换为,填写想替换成的内容(留空表示删除) +5. **第五行**:替换范围,选填书名或源名(留空则对所有书籍和源生效) + +::: tip 注意 +如常规去除方法去除不掉,则需要勾选"使用正则表达式",同时第三行里的替换规则也需要按照正则表达式来填写。 +::: + +## 备份相关 + +### 1. 云备份在哪? + +"**我的**"——"**备份与恢复**"——"**WebDav 设置**"。 + +### 2. 如何操作进行云备份? + +1. 侧栏设置,WebDav 设置 +2. 正确填写 WebDAV 服务器地址、账号和密码 +3. 无需操作,APP 默认每天自动云备份一次 + +### 3. 关于云备份的相关说明 + +在正确设置好云备份的情况下,APP +默认每天自动云备份一次,当日多次手动云备份会对当日的旧云备份文件进行覆盖,并不会覆盖之前及之后不同日期的备份文件,每天所自动云备份的文件会按照日期进行命名。 + +### 4. 本地备份和云备份都能备份哪些内容? + +书架、阅读进度、搜索记录、导入源、替换规则和 APP 设置等都会备份,基本涵盖所有内容。 + +### 5. 出现未知问题怎么办? + +清除软件数据试试看,不行再进行反馈。 + +## 其他 + +### 1. 如何听书? + +可以使用手机自带的朗读引擎,也可使用第三方如 Google 或小米等朗读引擎。 + +具体操作:安装 → 系统设置 → 其他高级设置 → 辅助功能 → TTS 输出 → 选择安装的朗读引擎。 + +### 2. 如何设置屏幕方向、屏幕显示时长等? + +阅读界面——"**设置**"(可上划,下面还有其他设置)。 + +### 3. 搜索时感觉手机卡顿怎么办? + +"**我的**"——"**其他设置**"——调低"**更新和搜索线程数**"。 diff --git a/docs/guide/reading.md b/docs/guide/reading.md new file mode 100644 index 000000000..75791be71 --- /dev/null +++ b/docs/guide/reading.md @@ -0,0 +1,59 @@ +# 阅读界面帮助文档 + +[[toc]] + +## 阅读界面主菜单 + +### 顶部操作 + +- **章节名称**:点击可编辑源配置 +- **章节 url**:点击可打开浏览器浏览 +- **菜单**:不同类型的书籍显示的菜单不同。详情请查看菜单文字,长按菜单图标可显示文字 + +### 中间左侧 - 亮度调节 + +亮度调节的顶端有跟随系统亮度的开关,打开后亮度跟随系统,关闭后才可以调节亮度条 + +### 底部操作 + +- 4 个圆形按钮依次为:全文搜索 · 自动翻页 · 替换净化 · 切换夜间模式 +- 上一章 / 下一章中间的进度条为页数进度,要快速跳转章节点击目录按钮进入目录快速跳转 +- **目录** → 目录和书签界面 +- **朗读** → 单击开始朗读,长按进入朗读设置界面 +- **界面** → 所有排版设置都在里面 +- **设置** → 其它一些设置,找不到的设置去这里看看,可滚动 + +## 全文搜索 + +搜索本地缓存或者本地文件,不能搜索在线内容。 + +书籍字数、净化规则数量、简繁转化、文章分段都会影响到搜索速度,请酌情启用。 + +## 朗读设置界面 + +- **后台** → 进入后台朗读,可以做一些其它事 +- **设置** → 朗读引擎设置,可以切换本地 TTS 和在线朗读,在线朗读可自定义 + +## 排版设置界面 + +- 白天模式和夜间模式背景不同布局相同 +- **共用布局**:启用共用布局时所有背景使用同一布局,关闭共用布局则每个背景单独布局 +- 长按背景可进入文字颜色和背景设置界面 + +## 其它设置界面 + +- 屏幕方向 +- 屏幕超时 +- 隐藏状态栏 +- 扩展到刘海 +- 隐藏导航栏 +- 文字两端对齐 +- 文字底部对齐 +- 音量键翻页 +- 点击翻页 +- 朗读时音量键翻页 +- 自动换源(源被删除时自动切换到其它源) +- 长按选择文本 +- 显示亮度调节控件 +- 点击区域设置 +- 自定义翻页按键 diff --git a/docs/guide/replace-rule.md b/docs/guide/replace-rule.md new file mode 100644 index 000000000..73fba7866 --- /dev/null +++ b/docs/guide/replace-rule.md @@ -0,0 +1,9 @@ +# 替换管理界面帮助 + +替换规则是用来替换正文内容的一种规则。 + +## 功能说明 + +- 菜单可以新建和导入规则 +- 可以拖动排序 +- 可以选择操作 diff --git a/docs/guide/rss-source.md b/docs/guide/rss-source.md new file mode 100644 index 000000000..8dd08b76a --- /dev/null +++ b/docs/guide/rss-source.md @@ -0,0 +1,33 @@ +# 订阅源管理界面帮助 + +[[toc]] + +订阅源可以通过规则订阅一些网络内容。 + +## 订阅源标志 + +- 🟢 绿点:源有发现,且启用了发现 +- 🔴 红点:源有发现,但是未启用 +- 无标志:此源没有发现 + +## 功能菜单 + +右上角有分组菜单,可以按分组筛选源。 + +右上角更多菜单里包含: + +- 新建订阅源 +- 本地导入 +- 网络导入 +- 二维码导入 +- 分享选中源 + +## 批量操作 + +选择源的更多操作在右下角的菜单里面,操作都是针对选择的源: + +- 启用所选 +- 禁用所选 +- 置顶所选 +- 置底所选 +- 导出所选 diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 000000000..dcd034400 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,39 @@ +--- +layout: home + +hero: + name: Legado-with-MD3 + text: 阅读 + tagline: 一款免费、开源的 Android 内容聚合阅读器,支持自定义内容源、RSS 订阅和本地书籍管理 + actions: + - theme: brand + text: 快速开始 + link: /guide/ + - theme: alt + text: 开发文档 + link: /dev/ + - theme: alt + text: GitHub + link: https://github.com/HapeLee/legado-with-MD3 + +features: + - icon: 📖 + title: 多格式支持 + details: 支持 TXT、EPUB 等主流电子书格式,满足不同阅读需求 + - icon: 🔧 + title: 自定义内容源 + details: 支持自定义内容抓取规则,可从任意网站聚合文字内容 + - icon: 🎙️ + title: 多种朗读引擎 + details: 支持本地 TTS 和在线朗读,提供沉浸式听书体验 + - icon: ☁️ + title: WebDAV 同步 + details: 支持 WebDAV 云备份和书籍同步,多设备无缝切换 + - icon: 📡 + title: RSS 订阅 + details: 内置 RSS 订阅源管理,轻松订阅和阅读网络内容 + - icon: 🛠️ + title: 灵活的规则引擎 + details: 支持 JS、XPath、正则表达式等多种规则语法,灵活解析网页内容 +--- + diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 000000000..c6d323f3a --- /dev/null +++ b/docs/package.json @@ -0,0 +1,13 @@ +{ + "name": "legado-docs", + "private": true, + "type": "module", + "scripts": { + "dev": "vitepress dev", + "build": "vitepress build", + "preview": "vitepress preview" + }, + "devDependencies": { + "vitepress": "^1.6.3" + } +} diff --git a/docs/spec/homepage-modules.md b/docs/spec/homepage-modules.md new file mode 100644 index 000000000..decb53936 --- /dev/null +++ b/docs/spec/homepage-modules.md @@ -0,0 +1,107 @@ +# 首页模块 (Homepage Modules) 配置规范 + +[[toc]] + +源的 `homepageModules` 字段允许开发者声明该源在首页展示的内容模块。这些模块通过 JSON +数组进行定义,支持高度自定义的布局和数据来源。 + +## 1. 数据结构 (Data Structure) + +`homepageModules` 是一个包含多个模块定义对象的 JSON 数组。 + +### 模块通用字段 + +| 字段 | 类型 | 必须 | 说明 | +|:-----------------|:---------|:---|:-------------------------------------------| +| **key** | `String` | 是 | 模块唯一标识。建议使用 `[a-z0-9_]` 字符。用于保存用户的排序/显隐设置。 | +| **type** | `Enum` | 是 | 模块类型。定义了渲染方式和交互逻辑。 | +| **title** | `String` | 是 | 模块默认标题。用户可在本地自定义覆盖。 | +| **kindTitle** | `String` | 否 | 用于匹配源「发现」规则中的分类标题。匹配成功后自动继承其 URL 和规则。 | +| **url** | `String` | 否 | 显式指定数据接口 URL。优先级高于 `kindTitle`。支持变量替换。 | +| **args** | `String` | 否 | 附加参数。在 `buttonGroup` 类型中为 JSON 数组字符串。 | +| **layoutConfig** | `Object` | 否 | 布局配置对象,用于调整列数、行数、图标等。 | + +## 2. 模块类型 (Module Types) + +### 列表与轮播类 + +| 类型 | 描述 | 特点 | +|:----------|:------|:----------------------| +| `banner` | 横滑轮播图 | 适合展示高权重的精品推荐,使用大图封面 | +| `ranking` | 排行榜列表 | 垂直列表展示,带排名序号 | +| `card` | 推荐卡片 | 横向滑动的卡片流,同时显示封面、标题及简介 | + +### 网格类 + +| 类型 | 描述 | 特点 | +|:---------------|:------|:-----------------| +| `grid` | 标准网格 | 最常用的展示形式,支持自定义行列 | +| `gridRanking` | 网格排行榜 | 多行多列的排行展示,横向翻页 | +| `infiniteGrid` | 无限网格 | 垂直滚动的网格流,无限加载 | +| `waterfall` | 错位瀑布流 | 垂直错位排列的书架流,无限加载 | + +### 功能类 + +| 类型 | 描述 | 特点 | +|:--------------|:------|:-------------------------------------------| +| `buttonGroup` | 快捷按钮组 | 渲染为一组圆形/图标按钮,支持自动填充宽度与自动分列。通常用于放置常用分类或功能入口 | + +## 3. 布局配置 (LayoutConfig) + +通过 `layoutConfig` 对象,可以精细化控制模块的表现。 + +| 属性 | 类型 | 适用类型 | 默认值 | 说明 | +|:----------|:---------|:------------------------------------|:----|:----------------------------------------| +| `columns` | `Int` | `grid`, `waterfall`, `infiniteGrid` | 3 | 每行显示的列数 | +| `icon` | `String` | `buttonGroup` | - | 按钮组的默认统一图标 URL | +| `icons` | `Object` | `buttonGroup` | - | 图标映射表。例:`{"排行": "http://path/to/icon"}` | + +## 4. 数据绑定逻辑 (Data Binding) + +1. **自动匹配**:如果提供了 `kindTitle`,系统会遍历源 `exploreKinds()` 返回的列表。如果某个分类的 + `title` 与之完全一致,该模块将自动使用该分类的 `url`。 +2. **静态指定**:如果提供了 `url`,系统将直接请求该 URL。 +3. **降级逻辑**:若 `kindTitle` 未匹配且无 `url`,模块将回退至源的主 `exploreUrl`。 + +## 5. 完整 JSON 示例 + +```json +[ + { + "key": "top_banner", + "type": "banner", + "title": "精品强推", + "kindTitle": "首页推荐" + }, + { + "key": "quick_nav", + "type": "buttonGroup", + "title": "分类导航", + "args": "[\"武侠\", \"仙侠\", \"都市\", \"历史\"]", + "layoutConfig": { + "icon": "https://example.com/icons/default.png", + "icons": { + "武侠": "https://example.com/icons/wuxia.png" + } + } + }, + { + "key": "hot_rank", + "type": "ranking", + "title": "热门榜单", + "kindTitle": "排行榜", + "layoutConfig": { + "rows": 5 + } + }, + { + "key": "explore_waterfall", + "type": "waterfall", + "title": "发现更多", + "kindTitle": "全部", + "layoutConfig": { + "columns": 2 + } + } +] +``` diff --git a/docs/spec/index.md b/docs/spec/index.md new file mode 100644 index 000000000..8e0311e56 --- /dev/null +++ b/docs/spec/index.md @@ -0,0 +1,5 @@ +# 功能规范 + +- [首页模块配置](./homepage-modules) — `homepageModules` JSON 字段规范 +- [关联书籍配置](./related-books) — `ruleBookInfo.relatedBooks` 字段规范 +- [MIME 类型参考](./mime-types) — 支持的文件扩展名和 MIME 类型 diff --git a/docs/spec/mime-types.md b/docs/spec/mime-types.md new file mode 100644 index 000000000..1373e3bb6 --- /dev/null +++ b/docs/spec/mime-types.md @@ -0,0 +1,82 @@ +# MIME 类型参考 + +以下是阅读支持的 MIME 类型(ContentType)参考表。 + +| 扩展名 | 描述 | MIME 类型 | +|:-------|:------------------------------|:--------------------------------------------------------------------------| +| acc | AAC 音频 | audio/aac | +| abw | AbiWord 文件 | application/x-abiword | +| arc | 存档文件 | application/x-freearc | +| avi | 音频视频交错格式 | video/x-msvideo | +| azw | 亚马逊 Kindle 电子书格式 | application/vnd.amazon.ebook | +| bin | 任何类型的二进制数据 | application/octet-stream | +| bmp | Windows OS / 2 位图图形 | image/bmp | +| bz | BZip 存档 | application/x-bzip | +| bz2 | BZip2 存档 | application/x-bzip2 | +| csh | C-Shell 脚本 | application/x-csh | +| css | 级联样式表(CSS) | text/css | +| csv | 逗号分隔值(CSV) | text/csv | +| doc | 微软 Word 文件 | application/msword | +| docx | Microsoft Word(OpenXML) | application/vnd.openxmlformats-officedocument.wordprocessingml.document | +| eot | MS Embedded OpenType 字体 | application/vnd.ms-fontobject | +| epub | 电子出版物(EPUB) | application/epub+zip | +| gz | GZip 压缩档案 | application/gzip | +| gif | 图形交换格式(GIF) | image/gif | +| htm | 超文本标记语言(HTML) | text/html | +| html | 超文本标记语言(HTML) | text/html | +| ico | 图标格式 | image/vnd.microsoft.icon | +| ics | iCalendar 格式 | text/calendar | +| jar | Java 存档 | application/java-archive | +| jpeg | JPEG 图像 | image/jpeg | +| jpg | JPEG 图像 | image/jpeg | +| js | JavaScript | text/javascript | +| json | JSON 格式 | application/json | +| jsonld | JSON-LD 格式 | application/ld+json | +| mid | 乐器数字接口(MIDI) | audio/midi | +| midi | 乐器数字接口(MIDI) | audio/midi | +| mjs | JavaScript 模块 | text/javascript | +| mp3 | MP3 音频 | audio/mpeg | +| mpeg | MPEG 视频 | video/mpeg | +| mpkg | 苹果安装程序包 | application/vnd.apple.installer+xml | +| odp | OpenDocument 演示文稿文档 | application/vnd.oasis.opendocument.presentation | +| ods | OpenDocument 电子表格文档 | application/vnd.oasis.opendocument.spreadsheet | +| odt | OpenDocument 文字文件 | application/vnd.oasis.opendocument.text | +| oga | OGG 音讯 | audio/ogg | +| ogv | OGG 视频 | video/ogg | +| ogx | OGG | application/ogg | +| opus | OPUS 音频 | audio/opus | +| otf | otf 字体 | font/otf | +| png | 便携式网络图形 | image/png | +| pdf | Adobe 可移植文档格式(PDF) | application/pdf | +| php | php | application/x-httpd-php | +| ppt | Microsoft PowerPoint | application/vnd.ms-powerpoint | +| pptx | Microsoft PowerPoint(OpenXML) | application/vnd.openxmlformats-officedocument.presentationml.presentation | +| rar | RAR 档案 | application/vnd.rar | +| rtf | 富文本格式 | application/rtf | +| sh | Bourne Shell 脚本 | application/x-sh | +| svg | 可缩放矢量图形(SVG) | image/svg+xml | +| swf | 小型 Web 格式(SWF) | application/x-shockwave-flash | +| tar | 磁带存档(TAR) | application/x-tar | +| tif | 标记图像文件格式(TIFF) | image/tiff | +| tiff | 标记图像文件格式(TIFF) | image/tiff | +| ts | MPEG 传输流 | video/mp2t | +| ttf | ttf 字体 | font/ttf | +| txt | 文本 | text/plain | +| vsd | 微软 Visio | application/vnd.visio | +| wav | 波形音频格式 | audio/wav | +| weba | WEBM 音频 | audio/webm | +| webm | WEBM 视频 | video/webm | +| webp | WEBP 图像 | image/webp | +| woff | Web 开放字体格式(WOFF) | font/woff | +| woff2 | Web 开放字体格式(WOFF) | font/woff2 | +| xhtml | XHTML | application/xhtml+xml | +| xls | 微软 Excel | application/vnd.ms-excel | +| xlsx | 微软 Excel(OpenXML) | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet | +| xml | XML | application/xml | +| xul | XUL | application/vnd.mozilla.xul+xml | +| zip | ZIP | application/zip | +| 3gp | 3GPP 音视频容器 | video/3gpp | +| 3gp | 3GPP 纯音频 | audio/3gpp2 | +| 3g2 | 3GPP2 音视频容器 | video/3gpp2 | +| 3g2 | 3GPP2 纯音频 | audio/3gpp2 | +| 7z | 7-zip 存档 | application/x-7z-compressed | diff --git a/docs/spec/related-books.md b/docs/spec/related-books.md new file mode 100644 index 000000000..3ee8bbb43 --- /dev/null +++ b/docs/spec/related-books.md @@ -0,0 +1,178 @@ +# 关联书籍 (Related Books) 配置规范 + +[[toc]] + +源的 `ruleBookInfo.relatedBooks` +字段允许开发者声明一组关联书籍模块,用于在书籍详情页底部展示「关联书籍」横滑轮播。支持配置多个模块,每个模块有独立的标题和数据来源,例如「同作者作品」、「读这本书的人还在读」等。 + +## 1. 字段位置 + +在源编辑器的 **详情页** 选项卡中,新增了 `relatedBooks` 字段。 + +``` +详情页 → relatedBooks +``` + +对应 JSON 路径:`ruleBookInfo.relatedBooks` + +## 2. 数据结构 + +`relatedBooks` 是一个包含多个模块定义对象的 JSON 数组。 + +### 模块字段 + +| 字段 | 类型 | 必须 | 说明 | +|:----------|:---------|:---|:------------------------------------------| +| **key** | `String` | 否 | 模块唯一标识。建议使用 `[a-z0-9_]` 字符。若未提供则使用 title。 | +| **title** | `String` | 是 | 模块标题,显示在轮播上方。如「同作者作品」。 | +| **url** | `String` | 是 | 数据接口 URL。支持模板变量替换。 | + +## 3. 工作原理 + +1. 当用户打开某本书的详情页时,如果该源配置了 `relatedBooks`,系统会解析 JSON 数组。 +2. 对每个模块,系统将 URL 中的模板变量替换为当前书籍的实际值,然后发起请求。 +3. 返回的数据使用源的 **发现规则**(`ruleExplore`)进行解析,获取书籍列表。 +4. 解析后的书籍以横滑轮播的形式展示在详情页的操作按钮和书籍简介之间。 +5. 当前查看的书籍会自动从结果中过滤掉,避免重复显示。 +6. 如果某个模块请求失败或返回空列表,该模块会被静默跳过,不影响其他模块和详情页功能。 + +::: warning 注意 +关联书籍的解析复用的是「发现」规则(`ruleExplore`),而非「详情页」规则(`ruleBookInfo`)。请确保源的发现规则已正确配置。 +::: + +## 4. URL 语法 + +URL 支持与 `exploreUrl` 相同的 JS 语法,包括 `@js:` 前缀、`` +标签和 {{...}} 内嵌表达式。 + +### JS 上下文中的可用对象 + +| 对象 | 说明 | 示例属性 | +|:---------|:----------|:--------------------------------------------------------------------------------------| +| `book` | 当前书籍对象 | `book.name`, `book.author`, `book.kind`, `book.bookUrl`, `book.tocUrl`, `book.origin` | +| `source` | 当前源对象 | `source.bookSourceUrl`, `source.getVariable()` 等 | +| `cookie` | Cookie 存储 | `cookie.getKey(domain, key)` | +| `page` | 页码(固定为 1) | `page` | +| `java` | JS 扩展工具 | `java.ajax()`, `java.log()` 等 | + +### 简单模板语法 + +对于简单的 URL,可以直接使用 {{book.属性名}} 语法,值会自动进行 URL 编码: + +``` +https://example.com/search?keyword={{book.author}}&name={{book.name}} +``` + +### `@js:` 表达式 + +对于需要逻辑处理的 URL,使用 `@js:` 前缀: + +``` +@js:"https://example.com/api/related?author=" + java.net.URLEncoder.encode(book.author, "UTF-8") +``` + +## 5. 完整 JSON 示例 + +```json +[ + { + "key": "same_author", + "title": "同作者作品", + "url": "https://example.com/search?keyword={{book.author}}&type=author&page=1" + }, + { + "key": "readers_also_read", + "title": "读这本书的人还在读", + "url": "https://example.com/api/related?book={{book.bookUrl}}&limit=20" + }, + { + "key": "same_genre", + "title": "同类推荐", + "url": "https://example.com/category/{{book.kind}}?page=1" + } +] +``` + +以上配置会在详情页底部显示三行轮播: + +``` +┌─────────────────────────────────────────────┐ +│ [操作按钮区域] │ +├─────────────────────────────────────────────┤ +│ 同作者作品 │ +│ [封面1] [封面2] [封面3] [封面4] → │ +│ │ +│ 读这本书的人还在读 │ +│ [封面1] [封面2] [封面3] [封面4] → │ +│ │ +│ 同类推荐 │ +│ [封面1] [封面2] [封面3] [封面4] → │ +├─────────────────────────────────────────────┤ +│ [书籍简介区域] │ +└─────────────────────────────────────────────┘ +``` + +## 6. URL 示例 + +### 按作者查找相关书籍 + +``` +https://example.com/search?keyword={{book.author}}&type=author +``` + +### 按书籍名称查找同系列 + +``` +https://example.com/search?keyword={{book.name}}&type=related +``` + +### 按分类查找同类书籍 + +``` +https://example.com/category/{{book.kind}}?page=1 +``` + +### 使用 JS 表达式 + +**简单拼接:** + +``` +@js:"https://example.com/api/related?author=" + java.net.URLEncoder.encode(book.author, "UTF-8") + "&book_id=" + book.bookUrl.split("/").pop() +``` + +**带条件逻辑:** + +``` +@js: +var base = "https://example.com/api/related"; +if (book.kind && book.kind.contains("玄幻")) { + base + "?genre=fantasy&author=" + java.net.URLEncoder.encode(book.author, "UTF-8") +} else { + base + "?author=" + java.net.URLEncoder.encode(book.author, "UTF-8") +} +``` + +## 7. 显示逻辑 + +| 条件 | 行为 | +|:----------------------|:-----------------| +| `relatedBooks` 为空或未配置 | 不显示关联书籍模块 | +| JSON 格式错误 | 静默跳过,不影响详情页其他内容 | +| 某个模块的 URL 请求失败 | 跳过该模块,其他模块正常显示 | +| 某个模块返回空列表 | 跳过该模块,其他模块正常显示 | +| 所有模块均无结果 | 不显示关联书籍区域 | +| 结果中包含当前书籍 | 自动过滤掉当前书籍 | +| 本地书籍(无源) | 不显示关联书籍模块 | +| 切换源时 | 清空关联书籍,重新加载新源的数据 | + +## 8. 最佳实践 + +1. **合理设置模块数量**:建议 1-3 个模块,过多的轮播行会影响页面体验。 +2. **使用有意义的标题**:标题应清晰描述推荐来源,如「同作者作品」比「推荐」更具引导性。 +3. **优先使用作者或分类**:按作者查找是最常见的关联方式。 +4. **避免过于宽泛的查询**:如果 URL 返回的结果与当前书籍关联性不强,用户体验会下降。 +5. **确保发现规则兼容**:URL 返回的数据必须能被 `ruleExplore` 正确解析。 +6. **简单场景用模板,复杂场景用 JS**:简单的 {{book.author}} + 替换直接用模板语法;需要条件判断等复杂逻辑时用 `@js:` 表达式。 +7. **测试边界情况**:测试作者名包含特殊字符时 URL 是否正常工作。 +8. **控制返回数量**:建议服务端限制返回数量(如 10-20 本)。 diff --git a/docs/tutorial/index.md b/docs/tutorial/index.md new file mode 100644 index 000000000..bbb86166f --- /dev/null +++ b/docs/tutorial/index.md @@ -0,0 +1,4 @@ +# 教程 + +- [WebDAV 备份教程](./webdav-backup) — WebDav 云备份配置与自动备份说明 +- [WebDAV 书籍同步](./webdav-book) — 多设备书籍上传/下载同步 diff --git a/docs/tutorial/webdav-backup.md b/docs/tutorial/webdav-backup.md new file mode 100644 index 000000000..4daf9d316 --- /dev/null +++ b/docs/tutorial/webdav-backup.md @@ -0,0 +1,21 @@ +# WebDav 备份教程 + +[[toc]] + +本应用支持云备份,采用 WebDav 协议。所有支持 WebDav 的云盘均可使用。 + +## 配置步骤 + +1. 注册并登录您选择的 WebDav 云盘服务 +2. 进入账户设置,找到安全选项或第三方应用管理 +3. 添加一个新的第三方应用,获取服务器地址、用户名和密码 +4. 打开本应用,进入 **我的 → 备份与恢复 → WebDav 设置** +5. 填入获取的服务器地址、账号和密码 +6. 创建子文件夹选项保持默认即可 +7. 设置完成后手动执行一下备份,验证是否成功 +8. 恢复时选择想要恢复的备份文件 + +## 自动备份说明 + +- 设置好备份之后每次退出 App 会自动进行备份 +- WebDav 同一天的备份会覆盖,不同日期的备份不会覆盖 diff --git a/docs/tutorial/webdav-book.md b/docs/tutorial/webdav-book.md new file mode 100644 index 000000000..f38a84167 --- /dev/null +++ b/docs/tutorial/webdav-book.md @@ -0,0 +1,73 @@ +# WebDav 书籍简明使用教程 + +[[toc]] + +> 本帮助页会在第一次进入时弹出,后续则不再出现,如想查看,请点击右上角 " **⁝** " > 帮助 查看此页。 + +本应用主要用作内容聚合阅读器,同时提供本地书籍管理功能(支持 EPUB、TXT 格式)。 + +本地书籍管理的一个常见问题是如何在多设备间同步阅读进度和书籍文件。更换设备后,原来设备上的本地书籍需要再次手动导入,操作较为繁琐。 + +本应用本身不提供云存储服务,但支持 WebDav 备份协议,可以利用 WebDav 来同步书籍。 + +## 前提条件 + +1. **配置好书籍存储位置**(WebDav 书籍下载存储到的位置):依次点击 **我的 / 其他设置 / 书籍存储位置** + ,选择书籍保存位置即可。 + +2. **配置好 WebDav 备份**(WebDav 书籍的保存位置):**我的 / 备份与恢复 / WebDav 设置**。这里需要配置 + WebDav 备份的服务器地址、账号、密码。 + +## 上传书籍到 WebDav + +配置好 WebDav 后,从主界面进入 WebDav 书籍页没有任何书籍显示,这是很正常的,因为我们 WebDav +的服务器上还没有任何书籍。 + +目前将书籍上传到 WebDav 的方式有三种: + +### 方式一:App 上传已导入的本地书籍 + +长按已导入的本地书籍进入书籍详情 → 右上角 " **⁝** " 找到 **上传 WebDav** ,点击,等待几秒后即可上传成功。 + +### 方式二:App 上传已缓存的网络书籍 + +主界面右上角点击更多设置 → 点击缓存/导出,在此页面右上角 " **⁝** " 找到 **导出到 WebDav** +并勾选。那么在书籍导出的时候便会自动上传一份到 WebDav 服务器中。 + +### 方式三:使用客户端批量上传 + +对于大部分用户来说,App 上传足够了,但有些用户书籍数量可能比较大,更好的方式是使用您所使用的 WebDav +服务的客户端批量上传。 + +以您所使用的 WebDav 服务为例,下载对应平台的客户端安装运行,找到文件夹目录 `legado/books` +,这里就是存放书籍的位置,您可以批量将书籍上传到该文件夹下。 + +::: warning 注意 +不管是使用上述的任何一种方式上传的书籍,为了确保上传无误,请您最好在上传书籍后进入 WebDav +书籍页检查是否能看到已经上传的书籍。 +::: + +## 下载 WebDav 书籍到本地 + +在 **WebDav 书籍页** 浏览已经上传的书籍,找到自己要下载的书籍,点击 **加入书架** +按钮,软件则会自动下载该书籍到本地并加入到书架中。 + +## 注意事项 + +部分 WebDav 服务有免费流量限额,对于同步 App 设置及少量书籍通常足够。频繁上传/下载大容量书籍时请注意用量,避免超出限额影响同步。 + +## 常见问题 + +### 进入 WebDav 书籍页提示 "获取 WebDav 书籍出错 webDav 没有配置" + +这是因为没有配置 WebDav 同步服务,按照上文前提条件中提到的配置 Webdav 同步的方法配置好就行了。 + +### A 设备上传的本地书籍能否在 B 设备上看到? + +如果 A 设备和 B 设备配置了相同的 WebDav 服务,那么 B 在 **WebDav 书籍页** 就能看到 A +上传的书籍。但是无法直接在书架上看到该书籍,目前必须自己在 **WebDav 书籍页** 找到该书籍手动点击 **加入书架 +** 导入才行。 + +### 本地书籍的阅读进度/书签等是否同步? + +可以同步。