[优化] 性能优化
This commit is contained in:
@@ -9,7 +9,9 @@
|
||||
"Bash(./gradlew :app:cleanCompileAppDebugKotlin)",
|
||||
"Bash(./gradlew :app:compileAppDebugKotlin --rerun-tasks)",
|
||||
"Bash(./gradlew :app:compileAppDebugKotlin)",
|
||||
"Bash(./gradlew :app:cleanCompileAppDebugKotlin :app:compileAppDebugKotlin)"
|
||||
"Bash(./gradlew :app:cleanCompileAppDebugKotlin :app:compileAppDebugKotlin)",
|
||||
"Bash(cp -r \"D:/AndroidPrj/legado-with-MD3/.codex/skills/legado-compose-review/SKILL.md\" \"D:/AndroidPrj/legado-with-MD3/.claude/skills/legado-compose-review/SKILL.md\")",
|
||||
"Bash(cp -r \"D:/AndroidPrj/legado-with-MD3/.codex/skills/legado-compose-review/references\" \"D:/AndroidPrj/legado-with-MD3/.claude/skills/legado-compose-review/\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
name: legado-compose-review
|
||||
description: Review existing Legado Jetpack Compose code for architecture, behavior, maintainability, and project convention issues. Use when Codex is asked to audit, review, inspect, evaluate, or find problems in Legado Compose screens, routes, ViewModels, contracts, dialogs, sheets, navigation, or early Compose implementations, especially for MVI/UDF, StateFlow/SharedFlow, Clean Architecture, MainActivity navigation, legacy Activity compatibility, and View-era mixed-pattern drift.
|
||||
---
|
||||
|
||||
# Legado Compose Review
|
||||
|
||||
## Overview
|
||||
|
||||
Review existing Compose code before rewriting it. Focus on concrete defects, architectural drift, behavior risks, and missing verification, especially in early Compose screens that may predate the current MVI/UDF, Clean Architecture, and `MainActivity` navigation expectations.
|
||||
|
||||
Read `references/review-checklist.md` for the project-specific checklist and severity guidance.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Define the review scope.
|
||||
- Identify the exact screen, route, ViewModel, contract, or package under review.
|
||||
- State whether the user wants review only or review plus fixes. If unclear, review first and do not edit code.
|
||||
- Treat unrelated legacy View code as context, not as part of the review, unless it affects the Compose surface.
|
||||
|
||||
2. Build context from code.
|
||||
- Read the `*Screen`, `*ViewModel`, `*Contract`, host Activity/route, DI registration, and any repositories/usecases used by the feature.
|
||||
- Read the old View implementation only if it is still a compatibility caller or behavior reference.
|
||||
- Compare against nearby current examples such as `BookInfo*`, `Search*`, `MainActivity`, and shared components under `ui/widget/components`.
|
||||
|
||||
3. Review by risk, not style preference.
|
||||
- Prioritize behavior regressions, state duplication, lifecycle bugs, navigation bugs, business logic in UI, direct data access from UI/presentation, recomposition hazards, and missing compatibility handling.
|
||||
- Flag style-only issues only when they conflict with established project conventions or make future migration harder.
|
||||
- Do not require broad refactors for a small screen unless the current code creates real behavior or maintenance risk.
|
||||
|
||||
4. Output findings first.
|
||||
- Use code-review style: list findings before summaries.
|
||||
- Include file and tight line references.
|
||||
- Explain the concrete impact and the smallest credible fix.
|
||||
- If using Codex review directives, emit one `::code-comment{...}` per finding.
|
||||
- If no issues are found, say that clearly and mention remaining test/manual verification gaps.
|
||||
|
||||
5. Suggest fixes only after findings.
|
||||
- Group fixes into small, reviewable steps.
|
||||
- For architecture drift, separate compatibility-preserving fixes from larger cleanup.
|
||||
- Recommend `legado-compose-migration` only when the finding implies a migration or rewrite workflow.
|
||||
|
||||
## Review Priorities
|
||||
|
||||
- **P0/P1**: behavior breakage, lost navigation/result behavior, unsafe lifecycle collection, stale state, data corruption, crashes, or compatibility entry points that no longer work.
|
||||
- **P2**: architecture drift that will cause duplicated logic, hard-to-test behavior, recomposition bugs, or incorrect ownership of state/effects.
|
||||
- **P3**: convention drift, maintainability issues, weak naming, missing previews/tests, or small cleanup that should not block behavior.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Do not rewrite code during a review unless the user asks for fixes.
|
||||
- Do not demand pure architecture where a compatibility boundary is necessary for unreworked View screens.
|
||||
- For new Compose-first code, expect standard Android architecture: `MainActivity` route ownership, ViewModel-owned state, UDF/MVI user actions, `StateFlow`/`SharedFlow`, repositories/usecases, and UI without business logic.
|
||||
- For migrated code, allow a thin retained Activity only for Android `Intent` compatibility with unreworked View callers.
|
||||
- Distinguish MVI `FeatureIntent` user actions from Android `Intent` launch/extras when both appear.
|
||||
|
||||
## Reference
|
||||
|
||||
Read `references/review-checklist.md` for detailed checks. When a review turns into implementation, also read `../legado-compose-migration/references/project-patterns.md` if available.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Legado Compose Review Checklist
|
||||
|
||||
## Files to Read
|
||||
|
||||
For a feature review, inspect the smallest complete slice:
|
||||
|
||||
- `FeatureScreen.kt` and any `FeatureSheets.kt` / `FeatureDialogs.kt`.
|
||||
- `FeatureViewModel.kt`.
|
||||
- `FeatureContract.kt` if present.
|
||||
- Host route in `MainActivity.kt` or retained `FeatureActivity.kt`.
|
||||
- Koin registration in `di/appModule.kt`.
|
||||
- Repositories/usecases used by the feature.
|
||||
- Old View caller or XML/adapters only when still used for compatibility or behavior comparison.
|
||||
|
||||
Use current examples as references:
|
||||
|
||||
- `ui/main/MainActivity.kt` for Navigation3 route ownership.
|
||||
- `ui/book/info/BookInfoContract.kt`, `BookInfoViewModel.kt`, `BookInfoScreen.kt` for behavior-heavy MVI/UDF.
|
||||
- `ui/book/search/SearchContract.kt`, `SearchViewModel.kt`, `SearchScreen.kt` for route-level search state.
|
||||
- `ui/widget/components/...` and `ui/theme/...` for shared UI conventions.
|
||||
|
||||
## Architecture Checks
|
||||
|
||||
Flag issues when:
|
||||
|
||||
- A new Compose-first screen is implemented as a standalone Activity instead of a `MainActivity` destination without a compatibility reason.
|
||||
- A retained Activity contains feature business logic instead of acting as a thin Android `Intent` compatibility host.
|
||||
- Business rules, repository calls, DAO calls, persistence writes, or service orchestration live in composables.
|
||||
- ViewModel exposes mutable state directly, exposes `MutableStateFlow`, or lets UI mutate domain objects.
|
||||
- UI state is split across Activity fields, composable `remember` state, adapter state, and ViewModel state in a way that can diverge.
|
||||
- One-shot events such as navigation, result codes, file opening, permission requests, or clipboard writes are represented as persistent `UiState` fields that can replay incorrectly.
|
||||
- MVI `FeatureIntent` user actions are confused with Android `Intent` launch/extras.
|
||||
- Domain/usecase boundaries are bypassed in new screens when a meaningful business action exists.
|
||||
- A migration introduces new domain abstractions for a single trivial UI action without reducing real complexity.
|
||||
|
||||
## UDF and State Checks
|
||||
|
||||
Flag issues when:
|
||||
|
||||
- `Screen` functions own business state instead of receiving `state` and callbacks.
|
||||
- `remember` / `rememberSaveable` stores source-of-truth data that should survive process or route recreation through ViewModel state.
|
||||
- `LaunchedEffect` keys are unstable or cause repeated data loading, duplicate navigation, duplicate toasts, or repeated service calls.
|
||||
- Flows are collected without lifecycle awareness in UI routes where `collectAsStateWithLifecycle()` should be used.
|
||||
- List items lack stable keys where mutation, selection, or animation can make state attach to the wrong row.
|
||||
- Selection, search query, sorting, filtering, loading, and error states are not represented in a single coherent `UiState`.
|
||||
- Derived values are recomputed expensively on every recomposition instead of living in ViewModel state or `remember(key)`.
|
||||
|
||||
## Navigation and Compatibility Checks
|
||||
|
||||
Flag issues when:
|
||||
|
||||
- New Compose destinations skip `MainActivity` Navigation3 route registration.
|
||||
- Legacy Android `Intent` extras or result codes change without an explicit compatibility plan.
|
||||
- A migrated screen is reachable both through `MainActivity` and a retained Activity with inconsistent state initialization.
|
||||
- Navigation is performed directly inside nested composables instead of through callbacks/effects.
|
||||
- Activity Result launchers, file pickers, permission requests, or Android DialogFragments are hidden inside reusable UI composables.
|
||||
- Back behavior bypasses ViewModel decisions when unsaved changes, selection mode, add-to-shelf prompts, or confirmation dialogs exist.
|
||||
|
||||
## UI and Project Convention Checks
|
||||
|
||||
Flag issues when:
|
||||
|
||||
- Raw Material components ignore `LegadoTheme` or existing shared components where the project already has a wrapper.
|
||||
- A screen recreates shared UI already present under `ui/widget/components`.
|
||||
- User-facing text is hardcoded instead of using string resources, except for temporary debug-only text.
|
||||
- Compose UI keeps ViewBinding, adapter, or XML assumptions after migration.
|
||||
- Dialogs and bottom sheets use inconsistent project components when `AppAlertDialog`, `AppModalBottomSheet`, or existing option sheets fit.
|
||||
- Image loading bypasses existing Coil cover/image helpers where cover behavior, cache, SVG, or GIF handling matters.
|
||||
|
||||
## Clean Architecture Checks
|
||||
|
||||
For new screens, expect:
|
||||
|
||||
- Compose renders state and emits user actions.
|
||||
- ViewModel owns state, intent handling, and effect emission.
|
||||
- Usecases own reusable business actions.
|
||||
- Repositories mediate data access.
|
||||
- DAOs remain behind repositories unless adding a boundary would be disproportionate and local project patterns already allow direct use.
|
||||
|
||||
For migrated screens, allow pragmatic intermediate code only when:
|
||||
|
||||
- It preserves behavior.
|
||||
- It is isolated behind a clear compatibility boundary.
|
||||
- It does not spread View-era assumptions into new Compose-first code.
|
||||
|
||||
## Output Template
|
||||
|
||||
Use this shape unless the user asks for another format:
|
||||
|
||||
```text
|
||||
Findings
|
||||
- [P1] Title
|
||||
file:line
|
||||
Impact: ...
|
||||
Fix: ...
|
||||
|
||||
Open Questions
|
||||
- ...
|
||||
|
||||
Notes
|
||||
- No issues found in ... / Tests not run ...
|
||||
```
|
||||
|
||||
When using Codex inline review comments, emit one directive per finding with tight line ranges:
|
||||
|
||||
```text
|
||||
::code-comment{title="[P2] Keep route state in ViewModel" body="..." file="/absolute/path/FeatureScreen.kt" start=42 end=45 priority=2 confidence=0.8}
|
||||
```
|
||||
|
||||
## Verification Suggestions
|
||||
|
||||
Recommend verification based on the reviewed change:
|
||||
|
||||
- Kotlin-only review/fix: `.\gradlew.bat :app:compileAppDebugKotlin`.
|
||||
- Resource/XML/manifest impact: `.\gradlew.bat :app:assembleAppDebug`.
|
||||
- Navigation or compatibility changes: manually open both `MainActivity` route and any retained legacy Android `Intent` entry point.
|
||||
- Behavior-heavy ViewModel changes: add or run focused tests only where the project has a practical test seam.
|
||||
@@ -1,63 +0,0 @@
|
||||
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
|
||||
|
||||
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
||||
|
||||
## 1. Think Before Coding
|
||||
|
||||
**Don't assume. Don't hide confusion. Surface tradeoffs.**
|
||||
|
||||
Before implementing:
|
||||
- State your assumptions explicitly. If uncertain, ask.
|
||||
- If multiple interpretations exist, present them - don't pick silently.
|
||||
- If a simpler approach exists, say so. Push back when warranted.
|
||||
- If something is unclear, stop. Name what's confusing. Ask.
|
||||
|
||||
## 2. Simplicity First
|
||||
|
||||
**Minimum code that solves the problem. Nothing speculative.**
|
||||
|
||||
- No features beyond what was asked.
|
||||
- No abstractions for single-use code.
|
||||
- No "flexibility" or "configurability" that wasn't requested.
|
||||
- No error handling for impossible scenarios.
|
||||
- If you write 200 lines and it could be 50, rewrite it.
|
||||
|
||||
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||
|
||||
## 3. Surgical Changes
|
||||
|
||||
**Touch only what you must. Clean up only your own mess.**
|
||||
|
||||
When editing existing code:
|
||||
- Don't "improve" adjacent code, comments, or formatting.
|
||||
- Don't refactor things that aren't broken.
|
||||
- Match existing style, even if you'd do it differently.
|
||||
- If you notice unrelated dead code, mention it - don't delete it.
|
||||
|
||||
When your changes create orphans:
|
||||
- Remove imports/variables/functions that YOUR changes made unused.
|
||||
- Don't remove pre-existing dead code unless asked.
|
||||
|
||||
The test: Every changed line should trace directly to the user's request.
|
||||
|
||||
## 4. Goal-Driven Execution
|
||||
|
||||
**Define success criteria. Loop until verified.**
|
||||
|
||||
Transform tasks into verifiable goals:
|
||||
- "Add validation" -> "Write tests for invalid inputs, then make them pass"
|
||||
- "Fix the bug" -> "Write a test that reproduces it, then make it pass"
|
||||
- "Refactor X" -> "Ensure tests pass before and after"
|
||||
|
||||
For multi-step tasks, state a brief plan:
|
||||
```
|
||||
1. [Step] -> verify: [check]
|
||||
2. [Step] -> verify: [check]
|
||||
3. [Step] -> verify: [check]
|
||||
```
|
||||
|
||||
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
||||
|
||||
---
|
||||
|
||||
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
|
||||
@@ -2,6 +2,41 @@
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Coding Guidelines
|
||||
|
||||
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
||||
|
||||
### Think Before Coding
|
||||
|
||||
- State assumptions explicitly. If uncertain, ask.
|
||||
- If multiple interpretations exist, present them — don't pick silently.
|
||||
- If a simpler approach exists, say so. Push back when warranted.
|
||||
- If something is unclear, stop. Name what's confusing. Ask.
|
||||
|
||||
### Simplicity First
|
||||
|
||||
- No features beyond what was asked.
|
||||
- No abstractions for single-use code.
|
||||
- No "flexibility" or "configurability" that wasn't requested.
|
||||
- No error handling for impossible scenarios.
|
||||
- If you write 200 lines and it could be 50, rewrite it.
|
||||
|
||||
### Surgical Changes
|
||||
|
||||
- Don't "improve" adjacent code, comments, or formatting.
|
||||
- Don't refactor things that aren't broken.
|
||||
- Match existing style, even if you'd do it differently.
|
||||
- If you notice unrelated dead code, mention it — don't delete it.
|
||||
- Remove imports/variables/functions that YOUR changes made unused.
|
||||
- Don't remove pre-existing dead code unless asked.
|
||||
|
||||
### Goal-Driven Execution
|
||||
|
||||
Transform tasks into verifiable goals:
|
||||
- "Add validation" → "Write tests for invalid inputs, then make them pass"
|
||||
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
|
||||
- "Refactor X" → "Ensure tests pass before and after"
|
||||
|
||||
## Build / Test / Run
|
||||
|
||||
```bash
|
||||
@@ -100,6 +135,8 @@ 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/`.
|
||||
|
||||
## Rhino JavaScript Engine
|
||||
|
||||
Book sources, RSS sources, and HTTP TTS use JavaScript rules. `initRhino()` in `App.kt` registers `NativeBaseSource` wrappers for `BookSource`, `RssSource`, `HttpTTS` (writable JS objects) and `ReadOnlyJavaObject` wrappers for rule entities. Rule parsing logic lives in `help/source/` and `model/analyzeRule/`.
|
||||
|
||||
@@ -2,11 +2,12 @@ package io.legado.app.data.repository
|
||||
|
||||
import io.legado.app.data.AppDatabase
|
||||
import io.legado.app.data.entities.BookSource
|
||||
import io.legado.app.data.entities.BookSourcePart
|
||||
import io.legado.app.data.entities.SearchBook
|
||||
import io.legado.app.data.entities.rule.ExploreKind
|
||||
import io.legado.app.help.source.SourceHelp
|
||||
import io.legado.app.help.source.exploreKinds
|
||||
import io.legado.app.model.webBook.WebBook
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -14,10 +15,14 @@ import kotlinx.coroutines.withContext
|
||||
|
||||
interface ExploreRepository {
|
||||
fun getBookshelfItems(): Flow<List<SearchBook>>
|
||||
fun getExploreGroups(): Flow<List<String>>
|
||||
fun getExploreSources(query: String, selectedGroup: String): Flow<List<BookSourcePart>>
|
||||
suspend fun getBookSource(url: String): BookSource?
|
||||
suspend fun exploreBook(source: BookSource, url: String, page: Int): Result<List<SearchBook>>
|
||||
suspend fun saveSearchBooks(books: List<SearchBook>)
|
||||
suspend fun getSourceExploreKinds(sourceUrl: String): List<ExploreKind>
|
||||
suspend fun topSource(bookSource: BookSourcePart)
|
||||
suspend fun deleteSource(sourceUrl: String)
|
||||
}
|
||||
|
||||
class ExploreRepositoryImpl(
|
||||
@@ -41,6 +46,33 @@ class ExploreRepositoryImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override fun getExploreGroups(): Flow<List<String>> {
|
||||
return appDb.bookSourceDao.flowExploreGroups()
|
||||
}
|
||||
|
||||
override fun getExploreSources(
|
||||
query: String,
|
||||
selectedGroup: String
|
||||
): Flow<List<BookSourcePart>> {
|
||||
return when {
|
||||
query.isNotBlank() -> {
|
||||
if (query.startsWith("group:")) {
|
||||
appDb.bookSourceDao.flowGroupExplore(query.substringAfter("group:"))
|
||||
} else {
|
||||
appDb.bookSourceDao.flowExplore(query)
|
||||
}
|
||||
}
|
||||
|
||||
selectedGroup.isNotBlank() -> {
|
||||
appDb.bookSourceDao.flowGroupExplore(selectedGroup)
|
||||
}
|
||||
|
||||
else -> {
|
||||
appDb.bookSourceDao.flowExplore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getBookSource(url: String): BookSource? {
|
||||
return appDb.bookSourceDao.getBookSource(url)
|
||||
}
|
||||
@@ -65,4 +97,13 @@ class ExploreRepositoryImpl(
|
||||
override suspend fun saveSearchBooks(books: List<SearchBook>) {
|
||||
appDb.searchBookDao.insert(*books.toTypedArray())
|
||||
}
|
||||
|
||||
override suspend fun topSource(bookSource: BookSourcePart) {
|
||||
val minOrder = appDb.bookSourceDao.minOrder
|
||||
appDb.bookSourceDao.upOrder(bookSource.copy(customOrder = minOrder - 1))
|
||||
}
|
||||
|
||||
override suspend fun deleteSource(sourceUrl: String) {
|
||||
SourceHelp.deleteBookSource(sourceUrl)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package io.legado.app.data.repository
|
||||
|
||||
import io.legado.app.data.appDb
|
||||
import io.legado.app.data.dao.RssSourceDao
|
||||
import io.legado.app.data.entities.RssSource
|
||||
import io.legado.app.help.source.SourceHelp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
class RssRepository {
|
||||
private val dao = appDb.rssSourceDao
|
||||
class RssRepository(
|
||||
private val dao: RssSourceDao
|
||||
) {
|
||||
|
||||
fun getEnabledSources(): Flow<List<RssSource>> = dao.flowEnabled()
|
||||
|
||||
@@ -15,12 +16,42 @@ class RssRepository {
|
||||
fun getEnabledSourcesByGroup(group: String): Flow<List<RssSource>> =
|
||||
dao.flowEnabledByGroup(group)
|
||||
|
||||
fun getEnabledSources(searchKey: String, group: String): Flow<List<RssSource>> {
|
||||
return when {
|
||||
searchKey.isNotEmpty() -> dao.flowEnabled(searchKey)
|
||||
group.isNotEmpty() -> dao.flowEnabledByGroup(group)
|
||||
else -> dao.flowEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
fun getEnabledGroups(): Flow<List<String>> = dao.flowEnabledGroups()
|
||||
|
||||
suspend fun updateSources(vararg sources: RssSource) {
|
||||
dao.update(*sources)
|
||||
}
|
||||
|
||||
suspend fun topSources(vararg sources: RssSource) {
|
||||
val minOrder = dao.minOrder - 1
|
||||
val sortedSources = sources.sortedBy { it.customOrder }
|
||||
val updates = Array(sortedSources.size) { index ->
|
||||
sortedSources[index].copy(customOrder = minOrder - index)
|
||||
}
|
||||
dao.update(*updates)
|
||||
}
|
||||
|
||||
suspend fun bottomSources(vararg sources: RssSource) {
|
||||
val maxOrder = dao.maxOrder + 1
|
||||
val sortedSources = sources.sortedBy { it.customOrder }
|
||||
val updates = Array(sortedSources.size) { index ->
|
||||
sortedSources[index].copy(customOrder = maxOrder + index)
|
||||
}
|
||||
dao.update(*updates)
|
||||
}
|
||||
|
||||
suspend fun disableSource(source: RssSource) {
|
||||
dao.update(source.copy(enabled = false))
|
||||
}
|
||||
|
||||
suspend fun deleteSources(sources: List<RssSource>) {
|
||||
SourceHelp.deleteRssSources(sources)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import io.legado.app.data.repository.ExploreRepositoryImpl
|
||||
import io.legado.app.data.repository.LocalBookRepository
|
||||
import io.legado.app.data.repository.ReadRecordRepository
|
||||
import io.legado.app.data.repository.RemoteBookRepository
|
||||
import io.legado.app.data.repository.RssRepository
|
||||
import io.legado.app.data.repository.SearchRepository
|
||||
import io.legado.app.data.repository.SearchRepositoryImpl
|
||||
import io.legado.app.data.repository.SearchContentRepository
|
||||
@@ -142,6 +143,7 @@ val appModule = module {
|
||||
single<ReadingProgressGateway> { WebDavReadingProgressRepository() }
|
||||
single<BookDomainRepository> { BookDomainRepositoryImpl(get(), get()) }
|
||||
single<ExploreRepository> { ExploreRepositoryImpl(get()) }
|
||||
singleOf(::RssRepository)
|
||||
single {
|
||||
SearchRepositoryImpl(get())
|
||||
}
|
||||
|
||||
@@ -56,7 +56,6 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.legado.app.R
|
||||
import io.legado.app.ui.config.mainConfig.MainConfig
|
||||
import io.legado.app.ui.main.bookshelf.BookshelfScreen
|
||||
import io.legado.app.ui.main.bookshelf.BookshelfViewModel
|
||||
import io.legado.app.ui.main.explore.ExploreScreen
|
||||
@@ -100,9 +99,10 @@ fun MainScreen(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val mainUiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
val bookshelfViewModel: BookshelfViewModel = koinViewModel()
|
||||
val bookshelfUiState by bookshelfViewModel.uiState.collectAsStateWithLifecycle()
|
||||
val bookshelfGroupState by bookshelfViewModel.groupSelectorState.collectAsStateWithLifecycle()
|
||||
|
||||
val hazeState = remember { HazeState() }
|
||||
val floatingBarSurfaceColor = MaterialTheme.colorScheme.surface
|
||||
@@ -110,45 +110,37 @@ fun MainScreen(
|
||||
drawRect(floatingBarSurfaceColor)
|
||||
drawContent()
|
||||
}
|
||||
val destinations = remember(MainConfig.showDiscovery, MainConfig.showRSS) {
|
||||
MainDestination.mainDestinations.filter {
|
||||
when (it) {
|
||||
MainDestination.Explore -> MainConfig.showDiscovery
|
||||
MainDestination.Rss -> MainConfig.showRSS
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
}
|
||||
val destinations = mainUiState.destinations
|
||||
|
||||
val initialPage = remember(destinations) {
|
||||
val index = destinations.indexOfFirst { it.route == MainConfig.defaultHomePage }
|
||||
val initialPage = remember(destinations, mainUiState.defaultHomePage) {
|
||||
val index = destinations.indexOfFirst { it.route == mainUiState.defaultHomePage }
|
||||
if (index != -1) index else 0
|
||||
}
|
||||
val pagerState = rememberPagerState(initialPage = initialPage) { destinations.size }
|
||||
val labelVisibilityMode = MainConfig.labelVisibilityMode
|
||||
LaunchedEffect(destinations) {
|
||||
if (destinations.isNotEmpty() && pagerState.currentPage !in destinations.indices) {
|
||||
pagerState.scrollToPage(destinations.lastIndex)
|
||||
}
|
||||
}
|
||||
val labelVisibilityMode = mainUiState.labelVisibilityMode
|
||||
val isUnlabeled = labelVisibilityMode == "unlabeled"
|
||||
val useFloatingBottomBar =
|
||||
!useRail && MainConfig.showBottomView && MainConfig.useFloatingBottomBar
|
||||
!useRail && mainUiState.showBottomView && mainUiState.useFloatingBottomBar
|
||||
val useLiquidGlass = useFloatingBottomBar &&
|
||||
MainConfig.useFloatingBottomBarLiquidGlass &&
|
||||
mainUiState.useFloatingBottomBarLiquidGlass &&
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
|
||||
val alwaysShowLabel = labelVisibilityMode == "labeled"
|
||||
val showLabel = !isUnlabeled
|
||||
|
||||
val navState = rememberWideNavigationRailState(
|
||||
initialValue = if (MainConfig.navExtended)
|
||||
initialValue = if (mainUiState.navExtended)
|
||||
WideNavigationRailValue.Expanded
|
||||
else
|
||||
WideNavigationRailValue.Collapsed
|
||||
)
|
||||
|
||||
LaunchedEffect(navState.currentValue) {
|
||||
MainConfig.navExtended =
|
||||
navState.currentValue == WideNavigationRailValue.Expanded
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
if (useRail && MainConfig.showBottomView) {
|
||||
if (useRail && mainUiState.showBottomView) {
|
||||
WideNavigationRail(
|
||||
state = navState,
|
||||
header = {
|
||||
@@ -159,8 +151,10 @@ fun MainScreen(
|
||||
modifier = Modifier.padding(start = 24.dp),
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
if (expanded) navState.collapse()
|
||||
else navState.expand()
|
||||
val targetExpanded = !expanded
|
||||
if (targetExpanded) navState.expand()
|
||||
else navState.collapse()
|
||||
viewModel.setNavExtended(targetExpanded)
|
||||
}
|
||||
}
|
||||
) {
|
||||
@@ -185,7 +179,6 @@ fun MainScreen(
|
||||
}
|
||||
}
|
||||
) {
|
||||
val labelVisibilityMode = MainConfig.labelVisibilityMode
|
||||
destinations.forEachIndexed { index, destination ->
|
||||
val selected = pagerState.targetPage == index
|
||||
var showGroupMenu by remember { mutableStateOf(false) }
|
||||
@@ -224,7 +217,7 @@ fun MainScreen(
|
||||
expanded = showGroupMenu,
|
||||
onDismissRequest = { showGroupMenu = false }
|
||||
) { dismiss ->
|
||||
bookshelfUiState.groups.forEachIndexed { groupIndex, group ->
|
||||
bookshelfGroupState.groups.forEachIndexed { groupIndex, group ->
|
||||
RoundDropdownMenuItem(
|
||||
text = group.groupName,
|
||||
onClick = {
|
||||
@@ -237,7 +230,7 @@ fun MainScreen(
|
||||
}
|
||||
},
|
||||
trailingIcon = {
|
||||
if (bookshelfUiState.selectedGroupIndex == groupIndex) {
|
||||
if (bookshelfGroupState.selectedGroupIndex == groupIndex) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
null,
|
||||
@@ -262,7 +255,7 @@ fun MainScreen(
|
||||
AppScaffold(
|
||||
modifier = Modifier.weight(1f),
|
||||
bottomBar = {
|
||||
if (!useRail && MainConfig.showBottomView) {
|
||||
if (!useRail && mainUiState.showBottomView) {
|
||||
if (useFloatingBottomBar) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
FloatingBottomBar(
|
||||
@@ -365,7 +358,7 @@ fun MainScreen(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
userScrollEnabled = true,
|
||||
beyondViewportPageCount = 3
|
||||
beyondViewportPageCount = 1
|
||||
) { page ->
|
||||
val destination = destinations.getOrNull(page) ?: return@HorizontalPager
|
||||
when (destination) {
|
||||
@@ -394,7 +387,6 @@ fun MainScreen(
|
||||
}
|
||||
)
|
||||
MainDestination.My -> MyScreen(
|
||||
viewModel = koinViewModel(),
|
||||
onOpenSettings = onOpenSettings,
|
||||
onNavigate = { event ->
|
||||
if (event == PrefClickEvent.OpenBookCacheManage) {
|
||||
|
||||
@@ -3,13 +3,25 @@ package io.legado.app.ui.main
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import io.legado.app.base.BaseViewModel
|
||||
import io.legado.app.constant.PreferKey
|
||||
import io.legado.app.constant.EventBus
|
||||
import io.legado.app.domain.usecase.AppStartupMaintenanceUseCase
|
||||
import io.legado.app.domain.usecase.WebDavBackupUseCase
|
||||
import io.legado.app.ui.config.mainConfig.MainConfig
|
||||
import io.legado.app.ui.main.my.PrefClickEvent
|
||||
import io.legado.app.ui.widget.dialog.TextDialog
|
||||
import io.legado.app.utils.defaultSharedPreferences
|
||||
import io.legado.app.utils.eventBus.FlowEventBus
|
||||
import io.legado.app.utils.getPrefBoolean
|
||||
import io.legado.app.utils.getPrefString
|
||||
import io.legado.app.utils.sendToClip
|
||||
import io.legado.app.utils.showDialogFragment
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
class MainViewModel(
|
||||
application: Application,
|
||||
@@ -17,10 +29,37 @@ class MainViewModel(
|
||||
private val webDavBackupUseCase: WebDavBackupUseCase
|
||||
) : BaseViewModel(application) {
|
||||
|
||||
private val prefs = context.defaultSharedPreferences
|
||||
private val mainPreferenceKeys = setOf(
|
||||
PreferKey.showDiscovery,
|
||||
PreferKey.showRss,
|
||||
PreferKey.showBottomView,
|
||||
PreferKey.useFloatingBottomBar,
|
||||
PreferKey.useFloatingBottomBarLiquidGlass,
|
||||
PreferKey.defaultHomePage,
|
||||
PreferKey.labelVisibilityMode,
|
||||
NAV_EXTENDED_KEY
|
||||
)
|
||||
private val preferenceListener =
|
||||
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
if (key in mainPreferenceKeys) {
|
||||
_uiState.value = readMainUiState()
|
||||
}
|
||||
}
|
||||
|
||||
private val _uiState = MutableStateFlow(readMainUiState())
|
||||
val uiState = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
prefs.registerOnSharedPreferenceChangeListener(preferenceListener)
|
||||
deleteNotShelfBook()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
prefs.unregisterOnSharedPreferenceChangeListener(preferenceListener)
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
fun upAllBookToc() {
|
||||
FlowEventBus.post(EventBus.UP_ALL_BOOK_TOC, Unit)
|
||||
}
|
||||
@@ -45,6 +84,12 @@ class MainViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun setNavExtended(expanded: Boolean) {
|
||||
if (_uiState.value.navExtended == expanded) return
|
||||
_uiState.update { it.copy(navExtended = expanded) }
|
||||
MainConfig.navExtended = expanded
|
||||
}
|
||||
|
||||
fun onPrefClickEvent(context: Context, event: PrefClickEvent) {
|
||||
when (event) {
|
||||
is PrefClickEvent.OpenUrl -> context.startActivity(
|
||||
@@ -56,7 +101,11 @@ class MainViewModel(
|
||||
|
||||
is PrefClickEvent.CopyUrl -> context.sendToClip(event.url)
|
||||
is PrefClickEvent.ShowMd -> {
|
||||
// Handle showing MD dialog
|
||||
if (context is AppCompatActivity) {
|
||||
val title = event.title.ifBlank { context.getString(io.legado.app.R.string.help) }
|
||||
val mdText = String(context.assets.open("web/help/md/${event.path}.md").readBytes())
|
||||
context.showDialogFragment(TextDialog(title, mdText, TextDialog.Mode.MD))
|
||||
}
|
||||
}
|
||||
|
||||
is PrefClickEvent.StartActivity -> {
|
||||
@@ -76,3 +125,40 @@ class MainViewModel(
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
data class MainUiState(
|
||||
val destinations: List<MainDestination> = MainDestination.mainDestinations,
|
||||
val defaultHomePage: String = "bookshelf",
|
||||
val showBottomView: Boolean = true,
|
||||
val useFloatingBottomBar: Boolean = false,
|
||||
val useFloatingBottomBarLiquidGlass: Boolean = false,
|
||||
val labelVisibilityMode: String = "auto",
|
||||
val navExtended: Boolean = false
|
||||
)
|
||||
|
||||
private const val NAV_EXTENDED_KEY = "navExtended"
|
||||
|
||||
private fun MainViewModel.readMainUiState(): MainUiState {
|
||||
val showDiscovery = context.getPrefBoolean(PreferKey.showDiscovery, true)
|
||||
val showRss = context.getPrefBoolean(PreferKey.showRss, true)
|
||||
val destinations = MainDestination.mainDestinations.filter {
|
||||
when (it) {
|
||||
MainDestination.Explore -> showDiscovery
|
||||
MainDestination.Rss -> showRss
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
return MainUiState(
|
||||
destinations = destinations,
|
||||
defaultHomePage = context.getPrefString(PreferKey.defaultHomePage, "bookshelf")
|
||||
?: "bookshelf",
|
||||
showBottomView = context.getPrefBoolean(PreferKey.showBottomView, true),
|
||||
useFloatingBottomBar = context.getPrefBoolean(PreferKey.useFloatingBottomBar, false),
|
||||
useFloatingBottomBarLiquidGlass = context.getPrefBoolean(
|
||||
PreferKey.useFloatingBottomBarLiquidGlass,
|
||||
false
|
||||
),
|
||||
labelVisibilityMode = context.getPrefString(PreferKey.labelVisibilityMode, "auto") ?: "auto",
|
||||
navExtended = context.getPrefBoolean(NAV_EXTENDED_KEY, false)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -56,10 +56,9 @@ import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -67,7 +66,6 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -85,7 +83,9 @@ import io.legado.app.ui.theme.adaptiveContentPadding
|
||||
import io.legado.app.ui.theme.adaptiveContentPaddingBookshelf
|
||||
import io.legado.app.ui.theme.adaptiveHorizontalPadding
|
||||
import io.legado.app.ui.theme.adaptiveHorizontalPaddingTab
|
||||
import io.legado.app.ui.widget.components.ActionItem
|
||||
import io.legado.app.ui.widget.components.EmptyMessage
|
||||
import io.legado.app.ui.widget.components.SelectionActions
|
||||
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.alert.AppAlertDialog
|
||||
@@ -102,10 +102,6 @@ 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.tabRow.AppTabRow
|
||||
import io.legado.app.ui.widget.components.text.AppText
|
||||
import io.legado.app.utils.move
|
||||
import io.legado.app.utils.readText
|
||||
import io.legado.app.utils.toastOnUi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
@@ -126,21 +122,15 @@ fun BookshelfScreen(
|
||||
onNavigateToLocalImport: () -> Unit,
|
||||
onNavigateToCache: (Long) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var showAddUrlDialog by remember { mutableStateOf(false) }
|
||||
var showImportSheet by remember { mutableStateOf(false) }
|
||||
var showExportSheet by remember { mutableStateOf(false) }
|
||||
var showConfigSheet by remember { mutableStateOf(false) }
|
||||
var showGroupManageSheet by remember { mutableStateOf(false) }
|
||||
var showLogSheet by remember { mutableStateOf(false) }
|
||||
var showGroupMenu by remember { mutableStateOf(false) }
|
||||
var showGroupSelectSheet by remember { mutableStateOf(false) }
|
||||
var showBatchDownloadConfirmDialog by remember { mutableStateOf(false) }
|
||||
var isEditMode by remember { mutableStateOf(false) }
|
||||
var selectedBookUrls by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
val activeOverlay = uiState.activeOverlay
|
||||
val showGroupMenu = activeOverlay == BookshelfOverlay.GroupMenu
|
||||
val isEditMode = uiState.isEditMode
|
||||
val selectedBookUrls = uiState.selectedBookUrls
|
||||
val isInFolderRoot = uiState.isInFolderRoot
|
||||
val bookGroupStyle = uiState.bookGroupStyle
|
||||
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
@@ -173,14 +163,8 @@ fun BookshelfScreen(
|
||||
contract = ActivityResultContracts.OpenDocument(),
|
||||
onResult = { uri ->
|
||||
uri?.let {
|
||||
runCatching {
|
||||
val text = it.readText(context)
|
||||
val groupId =
|
||||
uiState.groups.getOrNull(uiState.selectedGroupIndex)?.groupId ?: -1L
|
||||
viewModel.importBookshelf(text, groupId)
|
||||
}.onFailure {
|
||||
context.toastOnUi(it.localizedMessage ?: "ERROR")
|
||||
}
|
||||
val groupId = uiState.groups.getOrNull(uiState.selectedGroupIndex)?.groupId ?: -1L
|
||||
viewModel.importBookshelf(it, groupId)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -192,10 +176,35 @@ fun BookshelfScreen(
|
||||
}
|
||||
)
|
||||
|
||||
if (uiState.groups.isEmpty()) {
|
||||
ListScaffold(
|
||||
title = uiState.title.ifEmpty { stringResource(R.string.bookshelf) },
|
||||
subtitle = uiState.subtitle,
|
||||
state = uiState,
|
||||
showSearchAction = true,
|
||||
onSearchToggle = { viewModel.setSearchMode(it) },
|
||||
onSearchQueryChange = { viewModel.setSearchKey(it) },
|
||||
snackbarHostState = snackbarHostState
|
||||
) { paddingValues ->
|
||||
EmptyMessage(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(
|
||||
top = paddingValues.calculateTopPadding(),
|
||||
bottom = paddingValues.calculateBottomPadding()
|
||||
),
|
||||
messageResId = R.string.bookshelf_empty
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = uiState.selectedGroupIndex,
|
||||
pageCount = { uiState.groups.size }
|
||||
)
|
||||
val latestGroups by rememberUpdatedState(uiState.groups)
|
||||
val latestSelectedGroupId by rememberUpdatedState(uiState.selectedGroupId)
|
||||
|
||||
LaunchedEffect(uiState.groups, uiState.isSearch) {
|
||||
if (!uiState.isSearch && uiState.groups.isNotEmpty()) {
|
||||
@@ -209,14 +218,13 @@ fun BookshelfScreen(
|
||||
}
|
||||
|
||||
LaunchedEffect(pagerState) {
|
||||
snapshotFlow { pagerState.currentPage }
|
||||
snapshotFlow { pagerState.settledPage }
|
||||
.distinctUntilChanged()
|
||||
.collect { page ->
|
||||
if (uiState.groups.isNotEmpty() && page in uiState.groups.indices) {
|
||||
val targetGroupId = uiState.groups[page].groupId
|
||||
val currentGroupId =
|
||||
uiState.groups.getOrNull(uiState.selectedGroupIndex)?.groupId
|
||||
if (currentGroupId != targetGroupId) {
|
||||
val groups = latestGroups
|
||||
if (groups.isNotEmpty() && page in groups.indices) {
|
||||
val targetGroupId = groups[page].groupId
|
||||
if (latestSelectedGroupId != targetGroupId) {
|
||||
viewModel.changeGroup(targetGroupId)
|
||||
}
|
||||
}
|
||||
@@ -234,36 +242,18 @@ fun BookshelfScreen(
|
||||
val isUsingStandaloneSearchGroup = uiState.isSearch &&
|
||||
uiState.groups.none { it.groupId == currentGroupId }
|
||||
val currentGroupBookCount = uiState.currentGroupBookCount
|
||||
val allGroupsBookCount = uiState.allBooksCount
|
||||
|
||||
val bookGroupStyle = BookshelfConfig.bookGroupStyle
|
||||
// 控制是否处于“文件夹列表”根视图,还是“文件夹内部”书籍视图
|
||||
var isInFolderRoot by remember(bookGroupStyle) { mutableStateOf(bookGroupStyle == 2) }
|
||||
|
||||
val clearSelection = {
|
||||
selectedBookUrls = emptySet()
|
||||
viewModel.clearSelection()
|
||||
}
|
||||
val exitEditMode = {
|
||||
isEditMode = false
|
||||
clearSelection()
|
||||
viewModel.exitEditMode()
|
||||
}
|
||||
val toggleEditMode = {
|
||||
if (isEditMode) {
|
||||
exitEditMode()
|
||||
} else {
|
||||
if (bookGroupStyle == 2 && isInFolderRoot) {
|
||||
isInFolderRoot = false
|
||||
}
|
||||
isEditMode = true
|
||||
clearSelection()
|
||||
}
|
||||
viewModel.toggleEditMode()
|
||||
}
|
||||
val toggleBookSelection: (String) -> Unit = { bookUrl ->
|
||||
selectedBookUrls = if (selectedBookUrls.contains(bookUrl)) {
|
||||
selectedBookUrls - bookUrl
|
||||
} else {
|
||||
selectedBookUrls + bookUrl
|
||||
}
|
||||
viewModel.toggleBookSelection(bookUrl)
|
||||
}
|
||||
|
||||
LaunchedEffect(pagerState.currentPage, isInFolderRoot) {
|
||||
@@ -271,8 +261,7 @@ fun BookshelfScreen(
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.items) {
|
||||
val visibleBookUrls = uiState.items.mapTo(hashSetOf()) { it.bookUrl }
|
||||
selectedBookUrls = selectedBookUrls.intersect(visibleBookUrls)
|
||||
viewModel.pruneSelectionToVisible(uiState.items)
|
||||
}
|
||||
|
||||
BackHandler(enabled = isEditMode) {
|
||||
@@ -283,36 +272,11 @@ fun BookshelfScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val currentGroupName = uiState.allGroups.firstOrNull { it.groupId == currentGroupId }?.groupName
|
||||
?: uiState.groups.getOrNull(pagerState.currentPage)?.groupName
|
||||
|
||||
val baseTitle = when {
|
||||
uiState.isSearch && bookGroupStyle == 0 -> stringResource(R.string.bookshelf)
|
||||
uiState.isSearch -> currentGroupName ?: stringResource(R.string.bookshelf)
|
||||
bookGroupStyle == 1 -> currentGroupName ?: stringResource(R.string.bookshelf)
|
||||
bookGroupStyle == 2 && uiState.groups.isNotEmpty() -> {
|
||||
if (isInFolderRoot) stringResource(R.string.bookshelf)
|
||||
else currentGroupName ?: stringResource(R.string.bookshelf)
|
||||
}
|
||||
|
||||
else -> stringResource(R.string.bookshelf)
|
||||
}
|
||||
val title = if (isEditMode) {
|
||||
stringResource(R.string.bookshelf)
|
||||
} else if (uiState.upBooksCount > 0) {
|
||||
"$baseTitle (${uiState.upBooksCount})"
|
||||
} else {
|
||||
baseTitle
|
||||
}
|
||||
val subtitle = if (isEditMode) {
|
||||
"共${allGroupsBookCount}本"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val currentGroupName = uiState.currentGroupName
|
||||
|
||||
if (bookGroupStyle == 2 && !isInFolderRoot && !isEditMode) {
|
||||
BackHandler {
|
||||
isInFolderRoot = true
|
||||
viewModel.setInFolderRoot(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,8 +301,8 @@ fun BookshelfScreen(
|
||||
}
|
||||
|
||||
ListScaffold(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
title = uiState.title.ifEmpty { stringResource(R.string.bookshelf) },
|
||||
subtitle = uiState.subtitle,
|
||||
state = uiState,
|
||||
showSearchAction = true,
|
||||
onSearchToggle = { active ->
|
||||
@@ -369,19 +333,14 @@ fun BookshelfScreen(
|
||||
topBarActions = {
|
||||
AnimatedVisibility(visible = isEditMode) {
|
||||
TopBarActionButton(
|
||||
onClick = {
|
||||
selectedBookUrls = uiState.items.mapTo(hashSetOf()) { it.bookUrl }
|
||||
},
|
||||
onClick = { viewModel.selectAllVisible() },
|
||||
imageVector = Icons.Default.SelectAll,
|
||||
contentDescription = stringResource(R.string.select_all)
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(visible = isEditMode) {
|
||||
TopBarActionButton(
|
||||
onClick = {
|
||||
val visibleBookUrls = uiState.items.mapTo(hashSetOf()) { it.bookUrl }
|
||||
selectedBookUrls = visibleBookUrls - selectedBookUrls
|
||||
},
|
||||
onClick = { viewModel.invertVisibleSelection() },
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = stringResource(R.string.revert_selection)
|
||||
)
|
||||
@@ -390,7 +349,7 @@ fun BookshelfScreen(
|
||||
TopBarActionButton(
|
||||
onClick = {
|
||||
if (selectedBookUrls.isNotEmpty()) {
|
||||
showBatchDownloadConfirmDialog = true
|
||||
viewModel.showOverlay(BookshelfOverlay.BatchDownloadConfirmDialog)
|
||||
}
|
||||
},
|
||||
imageVector = Icons.Default.Download,
|
||||
@@ -401,7 +360,7 @@ fun BookshelfScreen(
|
||||
TopBarActionButton(
|
||||
onClick = {
|
||||
if (selectedBookUrls.isNotEmpty()) {
|
||||
showGroupSelectSheet = true
|
||||
viewModel.showOverlay(BookshelfOverlay.GroupSelectSheet)
|
||||
}
|
||||
},
|
||||
imageVector = Icons.Default.Bookmarks,
|
||||
@@ -428,17 +387,26 @@ fun BookshelfScreen(
|
||||
)
|
||||
RoundDropdownMenuItem(
|
||||
text = stringResource(R.string.layout_setting),
|
||||
onClick = { showConfigSheet = true; dismiss() },
|
||||
onClick = {
|
||||
viewModel.showOverlay(BookshelfOverlay.ConfigSheet)
|
||||
dismiss()
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Default.GridView, null) }
|
||||
)
|
||||
RoundDropdownMenuItem(
|
||||
text = stringResource(R.string.group_manage),
|
||||
onClick = { showGroupManageSheet = true; dismiss() },
|
||||
onClick = {
|
||||
viewModel.showOverlay(BookshelfOverlay.GroupManageSheet)
|
||||
dismiss()
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Default.Edit, null) }
|
||||
)
|
||||
RoundDropdownMenuItem(
|
||||
text = stringResource(R.string.add_url),
|
||||
onClick = { showAddUrlDialog = true; dismiss() },
|
||||
onClick = {
|
||||
viewModel.showOverlay(BookshelfOverlay.AddUrlDialog)
|
||||
dismiss()
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Default.Link, null) }
|
||||
)
|
||||
RoundDropdownMenuItem(
|
||||
@@ -462,26 +430,58 @@ fun BookshelfScreen(
|
||||
RoundDropdownMenuItem(
|
||||
text = stringResource(R.string.export_bookshelf),
|
||||
onClick = {
|
||||
showExportSheet = true
|
||||
viewModel.showOverlay(BookshelfOverlay.ExportSheet)
|
||||
dismiss()
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Default.UploadFile, null) }
|
||||
)
|
||||
RoundDropdownMenuItem(
|
||||
text = stringResource(R.string.import_bookshelf),
|
||||
onClick = { showImportSheet = true; dismiss() },
|
||||
onClick = {
|
||||
viewModel.showOverlay(BookshelfOverlay.ImportSheet)
|
||||
dismiss()
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Default.CloudDownload, null) }
|
||||
)
|
||||
RoundDropdownMenuItem(
|
||||
text = stringResource(R.string.log),
|
||||
onClick = {
|
||||
showLogSheet = true
|
||||
viewModel.showOverlay(BookshelfOverlay.LogSheet)
|
||||
dismiss()
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Default.History, null) }
|
||||
)
|
||||
}
|
||||
} else null,
|
||||
selectionActions = if (isEditMode) {
|
||||
SelectionActions(
|
||||
primaryAction = ActionItem(
|
||||
text = stringResource(R.string.action_download),
|
||||
icon = { Icon(Icons.Default.Download, contentDescription = null) },
|
||||
onClick = {
|
||||
if (selectedBookUrls.isNotEmpty()) {
|
||||
viewModel.showOverlay(BookshelfOverlay.BatchDownloadConfirmDialog)
|
||||
}
|
||||
}
|
||||
),
|
||||
secondaryActions = listOf(
|
||||
ActionItem(
|
||||
text = stringResource(R.string.move_to_group),
|
||||
icon = { Icon(Icons.Default.Bookmarks, contentDescription = null) },
|
||||
onClick = {
|
||||
if (selectedBookUrls.isNotEmpty()) {
|
||||
viewModel.showOverlay(BookshelfOverlay.GroupSelectSheet)
|
||||
}
|
||||
}
|
||||
)
|
||||
),
|
||||
onClearSelection = { viewModel.clearSelection() },
|
||||
onSelectAll = { viewModel.selectAllVisible() },
|
||||
onSelectInvert = { viewModel.invertVisibleSelection() }
|
||||
)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
snackbarHostState = snackbarHostState,
|
||||
bottomContent = if (bookGroupStyle == 0) {
|
||||
{
|
||||
@@ -511,13 +511,19 @@ fun BookshelfScreen(
|
||||
Box(modifier = Modifier) {
|
||||
SmallOutlinedIconToggleButton(
|
||||
checked = showGroupMenu,
|
||||
onCheckedChange = { showGroupMenu = it },
|
||||
onCheckedChange = {
|
||||
if (it) {
|
||||
viewModel.showOverlay(BookshelfOverlay.GroupMenu)
|
||||
} else {
|
||||
viewModel.dismissOverlay()
|
||||
}
|
||||
},
|
||||
imageVector = Icons.AutoMirrored.Filled.FormatListBulleted,
|
||||
contentDescription = stringResource(R.string.group_manage)
|
||||
)
|
||||
RoundDropdownMenu(
|
||||
expanded = showGroupMenu,
|
||||
onDismissRequest = { showGroupMenu = false }
|
||||
onDismissRequest = { viewModel.dismissOverlay() }
|
||||
) { dismiss ->
|
||||
uiState.groups.forEachIndexed { index, group ->
|
||||
RoundDropdownMenuItem(
|
||||
@@ -606,7 +612,6 @@ fun BookshelfScreen(
|
||||
}
|
||||
} else null
|
||||
) { paddingValues ->
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
val pullToRefreshState = rememberPullToRefreshState()
|
||||
val currentGroup = if (uiState.isSearch) {
|
||||
uiState.allGroups.firstOrNull { it.groupId == currentGroupId }
|
||||
@@ -620,15 +625,8 @@ fun BookshelfScreen(
|
||||
.fillMaxSize()
|
||||
.pullToRefresh(
|
||||
state = pullToRefreshState,
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = {
|
||||
scope.launch {
|
||||
isRefreshing = true
|
||||
viewModel.upToc(uiState.items)
|
||||
delay(1000)
|
||||
isRefreshing = false
|
||||
}
|
||||
},
|
||||
isRefreshing = uiState.isRefreshing,
|
||||
onRefresh = { viewModel.refreshBooks(uiState.items) },
|
||||
enabled = pullToRefreshEnabled
|
||||
)
|
||||
) {
|
||||
@@ -675,9 +673,11 @@ fun BookshelfScreen(
|
||||
titleMaxLines = BookshelfConfig.bookshelfTitleMaxLines,
|
||||
onClick = {
|
||||
scope.launch { pagerState.scrollToPage(index) }
|
||||
isInFolderRoot = false
|
||||
viewModel.setInFolderRoot(false)
|
||||
},
|
||||
onLongClick = { showGroupManageSheet = true }
|
||||
onLongClick = {
|
||||
viewModel.showOverlay(BookshelfOverlay.GroupManageSheet)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
BookGroupItemGrid(
|
||||
@@ -692,9 +692,11 @@ fun BookshelfScreen(
|
||||
coverShadow = BookshelfConfig.bookshelfCoverShadow,
|
||||
onClick = {
|
||||
scope.launch { pagerState.scrollToPage(index) }
|
||||
isInFolderRoot = false
|
||||
viewModel.setInFolderRoot(false)
|
||||
},
|
||||
onLongClick = { showGroupManageSheet = true }
|
||||
onLongClick = {
|
||||
viewModel.showOverlay(BookshelfOverlay.GroupManageSheet)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -712,7 +714,12 @@ fun BookshelfScreen(
|
||||
selectedBookUrls = selectedBookUrls,
|
||||
canReorderBooks = false,
|
||||
onToggleBookSelection = { toggleBookSelection(it.bookUrl) },
|
||||
onSaveBookOrder = {},
|
||||
draggingBooks = null,
|
||||
pendingSavedBooks = null,
|
||||
onDragStarted = {},
|
||||
onMoveBook = { _, _, _ -> },
|
||||
onDragFinished = {},
|
||||
onSyncDragState = { _, _ -> },
|
||||
onGlobalSearch = { onNavigateToSearch(uiState.searchKey.trim()) },
|
||||
onBookClick = onBookClick,
|
||||
onBookLongClick = onBookLongClick
|
||||
@@ -726,10 +733,16 @@ fun BookshelfScreen(
|
||||
) { pageIndex ->
|
||||
val group = uiState.groups.getOrNull(pageIndex)
|
||||
if (group != null) {
|
||||
val booksFlow = remember(group.groupId) {
|
||||
viewModel.getBooksFlow(group.groupId)
|
||||
val isSelectedGroup = group.groupId == uiState.selectedGroupId
|
||||
val books = if (isSelectedGroup) {
|
||||
uiState.items
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
val books by booksFlow.collectAsStateWithLifecycle(emptyList())
|
||||
val canReorderBooks = isEditMode &&
|
||||
!uiState.isSearch &&
|
||||
group.getRealBookSort() == 3 &&
|
||||
isSelectedGroup
|
||||
BookshelfPage(
|
||||
paddingValues = paddingValues,
|
||||
books = books,
|
||||
@@ -739,12 +752,33 @@ fun BookshelfScreen(
|
||||
bookshelfLayoutList = bookshelfLayoutList,
|
||||
isEditMode = isEditMode,
|
||||
selectedBookUrls = selectedBookUrls,
|
||||
canReorderBooks = isEditMode &&
|
||||
!uiState.isSearch &&
|
||||
group.getRealBookSort() == 3,
|
||||
canReorderBooks = canReorderBooks,
|
||||
onToggleBookSelection = { toggleBookSelection(it.bookUrl) },
|
||||
onSaveBookOrder = { reorderedBooks ->
|
||||
viewModel.saveBookOrder(reorderedBooks)
|
||||
draggingBooks = if (isSelectedGroup) {
|
||||
uiState.draggingBooks
|
||||
} else {
|
||||
null
|
||||
},
|
||||
pendingSavedBooks = if (isSelectedGroup) {
|
||||
uiState.pendingSavedBooks
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onDragStarted = {
|
||||
if (isSelectedGroup) viewModel.startDraggingBooks(it)
|
||||
},
|
||||
onMoveBook = { from, to, currentBooks ->
|
||||
if (isSelectedGroup) {
|
||||
viewModel.moveDraggingBook(from, to, currentBooks)
|
||||
}
|
||||
},
|
||||
onDragFinished = {
|
||||
if (isSelectedGroup) viewModel.finishDraggingBooks()
|
||||
},
|
||||
onSyncDragState = { currentBooks, canReorder ->
|
||||
if (isSelectedGroup) {
|
||||
viewModel.syncDragState(currentBooks, canReorder)
|
||||
}
|
||||
},
|
||||
onGlobalSearch = { onNavigateToSearch(uiState.searchKey.trim()) },
|
||||
onBookClick = onBookClick,
|
||||
@@ -782,11 +816,19 @@ fun BookshelfScreen(
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
AppText(
|
||||
text = "已选${summary.selectedCount}本",
|
||||
text = stringResource(
|
||||
R.string.bookshelf_selected_count,
|
||||
summary.selectedCount
|
||||
),
|
||||
style = LegadoTheme.typography.labelSmallEmphasized
|
||||
)
|
||||
AppText(
|
||||
text = " · 共${summary.currentGroupTotalCount}本",
|
||||
text = " · ${
|
||||
stringResource(
|
||||
R.string.bookshelf_total_count,
|
||||
summary.currentGroupTotalCount
|
||||
)
|
||||
}",
|
||||
style = LegadoTheme.typography.labelSmallEmphasized
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
@@ -798,7 +840,9 @@ fun BookshelfScreen(
|
||||
cornerRadius = 16.dp,
|
||||
verticalPadding = 8.dp,
|
||||
horizontalPadding = 12.dp,
|
||||
onClick = { showGroupMenu = true }
|
||||
onClick = {
|
||||
viewModel.showOverlay(BookshelfOverlay.GroupMenu)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -808,7 +852,7 @@ fun BookshelfScreen(
|
||||
if (summary.showGroupName) {
|
||||
RoundDropdownMenu(
|
||||
expanded = showGroupMenu,
|
||||
onDismissRequest = { showGroupMenu = false }
|
||||
onDismissRequest = { viewModel.dismissOverlay() }
|
||||
) { dismiss ->
|
||||
uiState.groups.forEach { group ->
|
||||
RoundDropdownMenuItem(
|
||||
@@ -827,7 +871,7 @@ fun BookshelfScreen(
|
||||
viewModel.changeGroup(group.groupId)
|
||||
}
|
||||
if (bookGroupStyle == 2) {
|
||||
isInFolderRoot = false
|
||||
viewModel.setInFolderRoot(false)
|
||||
}
|
||||
dismiss()
|
||||
},
|
||||
@@ -849,7 +893,7 @@ fun BookshelfScreen(
|
||||
|
||||
PullToRefreshDefaults.LoadingIndicator(
|
||||
state = pullToRefreshState,
|
||||
isRefreshing = isRefreshing,
|
||||
isRefreshing = uiState.isRefreshing,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = paddingValues.calculateTopPadding())
|
||||
@@ -858,82 +902,81 @@ fun BookshelfScreen(
|
||||
}
|
||||
|
||||
BookshelfConfigSheet(
|
||||
show = showConfigSheet,
|
||||
onDismissRequest = { showConfigSheet = false }
|
||||
show = activeOverlay == BookshelfOverlay.ConfigSheet,
|
||||
onDismissRequest = { viewModel.dismissOverlay() }
|
||||
)
|
||||
|
||||
GroupManageSheet(
|
||||
show = showGroupManageSheet,
|
||||
onDismissRequest = { showGroupManageSheet = false }
|
||||
show = activeOverlay == BookshelfOverlay.GroupManageSheet,
|
||||
onDismissRequest = { viewModel.dismissOverlay() }
|
||||
)
|
||||
|
||||
GroupSelectSheet(
|
||||
show = showGroupSelectSheet,
|
||||
show = activeOverlay == BookshelfOverlay.GroupSelectSheet,
|
||||
currentGroupId = 0L,
|
||||
onDismissRequest = { showGroupSelectSheet = false },
|
||||
onDismissRequest = { viewModel.dismissOverlay() },
|
||||
onConfirm = { groupId ->
|
||||
viewModel.moveBooksToGroup(selectedBookUrls, groupId)
|
||||
showGroupSelectSheet = false
|
||||
viewModel.dismissOverlay()
|
||||
clearSelection()
|
||||
}
|
||||
)
|
||||
|
||||
SourceInputDialog(
|
||||
show = showAddUrlDialog,
|
||||
show = activeOverlay == BookshelfOverlay.AddUrlDialog,
|
||||
title = stringResource(R.string.add_book_url),
|
||||
onDismissRequest = { showAddUrlDialog = false },
|
||||
onDismissRequest = { viewModel.dismissOverlay() },
|
||||
onConfirm = { url ->
|
||||
viewModel.addBookByUrl(url)
|
||||
showAddUrlDialog = false
|
||||
viewModel.dismissOverlay()
|
||||
}
|
||||
)
|
||||
|
||||
FilePickerSheet(
|
||||
show = showImportSheet,
|
||||
onDismissRequest = { showImportSheet = false },
|
||||
show = activeOverlay == BookshelfOverlay.ImportSheet,
|
||||
onDismissRequest = { viewModel.dismissOverlay() },
|
||||
title = stringResource(R.string.import_bookshelf),
|
||||
onSelectSysFile = { types ->
|
||||
importLauncher.launch(types)
|
||||
showImportSheet = false
|
||||
viewModel.dismissOverlay()
|
||||
},
|
||||
onManualInput = {
|
||||
showAddUrlDialog = true
|
||||
showImportSheet = false
|
||||
viewModel.showOverlay(BookshelfOverlay.AddUrlDialog)
|
||||
},
|
||||
allowExtensions = arrayOf("json", "txt")
|
||||
)
|
||||
|
||||
FilePickerSheet(
|
||||
show = showExportSheet,
|
||||
onDismissRequest = { showExportSheet = false },
|
||||
show = activeOverlay == BookshelfOverlay.ExportSheet,
|
||||
onDismissRequest = { viewModel.dismissOverlay() },
|
||||
title = stringResource(R.string.export_bookshelf),
|
||||
onSelectSysDir = {
|
||||
showExportSheet = false
|
||||
viewModel.dismissOverlay()
|
||||
exportLauncher.launch("bookshelf.json")
|
||||
},
|
||||
onUpload = {
|
||||
showExportSheet = false
|
||||
viewModel.dismissOverlay()
|
||||
viewModel.uploadBookshelf(uiState.items)
|
||||
}
|
||||
)
|
||||
|
||||
AppLogSheet(
|
||||
show = showLogSheet,
|
||||
onDismissRequest = { showLogSheet = false }
|
||||
show = activeOverlay == BookshelfOverlay.LogSheet,
|
||||
onDismissRequest = { viewModel.dismissOverlay() }
|
||||
)
|
||||
|
||||
AppAlertDialog(
|
||||
show = showBatchDownloadConfirmDialog,
|
||||
onDismissRequest = { showBatchDownloadConfirmDialog = false },
|
||||
show = activeOverlay == BookshelfOverlay.BatchDownloadConfirmDialog,
|
||||
onDismissRequest = { viewModel.dismissOverlay() },
|
||||
title = stringResource(R.string.draw),
|
||||
text = stringResource(R.string.sure_cache_book),
|
||||
confirmText = stringResource(android.R.string.ok),
|
||||
onConfirm = {
|
||||
showBatchDownloadConfirmDialog = false
|
||||
viewModel.dismissOverlay()
|
||||
viewModel.downloadBooks(selectedBookUrls)
|
||||
},
|
||||
dismissText = stringResource(android.R.string.cancel),
|
||||
onDismiss = { showBatchDownloadConfirmDialog = false }
|
||||
onDismiss = { viewModel.dismissOverlay() }
|
||||
)
|
||||
|
||||
if (uiState.isLoading) {
|
||||
@@ -979,7 +1022,12 @@ fun BookshelfPage(
|
||||
selectedBookUrls: Set<String>,
|
||||
canReorderBooks: Boolean,
|
||||
onToggleBookSelection: (BookShelfItem) -> Unit,
|
||||
onSaveBookOrder: (books: List<BookShelfItem>) -> Unit,
|
||||
draggingBooks: List<BookShelfItem>?,
|
||||
pendingSavedBooks: List<BookShelfItem>?,
|
||||
onDragStarted: (List<BookShelfItem>) -> Unit,
|
||||
onMoveBook: (fromIndex: Int, toIndex: Int, currentBooks: List<BookShelfItem>) -> Unit,
|
||||
onDragFinished: () -> Unit,
|
||||
onSyncDragState: (books: List<BookShelfItem>, canReorderBooks: Boolean) -> Unit,
|
||||
onGlobalSearch: () -> Unit,
|
||||
onBookClick: (BookShelfItem) -> Unit,
|
||||
onBookLongClick: (BookShelfItem) -> Unit
|
||||
@@ -993,8 +1041,8 @@ fun BookshelfPage(
|
||||
top = paddingValues.calculateTopPadding(),
|
||||
bottom = paddingValues.calculateBottomPadding()
|
||||
),
|
||||
message = "没有书籍,尝试全局搜索",
|
||||
buttonText = "全局搜索",
|
||||
message = stringResource(R.string.bookshelf_empty_global_search),
|
||||
buttonText = stringResource(R.string.global_search),
|
||||
onButtonClick = onGlobalSearch
|
||||
)
|
||||
} else {
|
||||
@@ -1018,36 +1066,20 @@ fun BookshelfPage(
|
||||
val gridContentHorizontalPadding = totalHorizontalPadding / 2
|
||||
val gridInnerHorizontalPadding = totalHorizontalPadding / 2
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
var draggingBooks by remember { mutableStateOf<List<BookShelfItem>?>(null) }
|
||||
var pendingSavedBooks by remember { mutableStateOf<List<BookShelfItem>?>(null) }
|
||||
val displayBooks = draggingBooks ?: pendingSavedBooks ?: books
|
||||
LaunchedEffect(books, pendingSavedBooks, canReorderBooks) {
|
||||
if (!canReorderBooks) {
|
||||
draggingBooks = null
|
||||
pendingSavedBooks = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val pending = pendingSavedBooks ?: return@LaunchedEffect
|
||||
if (books.map { it.bookUrl } == pending.map { it.bookUrl }) {
|
||||
pendingSavedBooks = null
|
||||
}
|
||||
onSyncDragState(books, canReorderBooks)
|
||||
}
|
||||
val gridState = rememberLazyGridState()
|
||||
val reorderableState = rememberReorderableLazyGridState(gridState) { from, to ->
|
||||
if (canReorderBooks) {
|
||||
draggingBooks = displayBooks.toMutableList().apply {
|
||||
move(from.index, to.index)
|
||||
}
|
||||
onMoveBook(from.index, to.index, displayBooks)
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(reorderableState.isAnyItemDragging) {
|
||||
if (!reorderableState.isAnyItemDragging) {
|
||||
draggingBooks?.let { reorderedBooks ->
|
||||
pendingSavedBooks = reorderedBooks
|
||||
onSaveBookOrder(reorderedBooks)
|
||||
draggingBooks = null
|
||||
}
|
||||
onDragFinished()
|
||||
}
|
||||
}
|
||||
FastScrollLazyVerticalGrid(
|
||||
@@ -1077,7 +1109,7 @@ fun BookshelfPage(
|
||||
if (canReorderBooks) {
|
||||
Modifier.longPressDraggableHandle(
|
||||
onDragStarted = {
|
||||
draggingBooks = displayBooks
|
||||
onDragStarted(displayBooks)
|
||||
hapticFeedback.performHapticFeedback(
|
||||
HapticFeedbackType.GestureThresholdActivate
|
||||
)
|
||||
|
||||
@@ -3,6 +3,24 @@ package io.legado.app.ui.main.bookshelf
|
||||
import io.legado.app.data.entities.BookGroup
|
||||
import io.legado.app.ui.widget.components.list.ListUiState
|
||||
|
||||
data class BookshelfGroupSelectorState(
|
||||
val groups: List<BookGroup> = emptyList(),
|
||||
val selectedGroupIndex: Int = 0,
|
||||
val selectedGroupId: Long = BookGroup.IdAll
|
||||
)
|
||||
|
||||
sealed interface BookshelfOverlay {
|
||||
data object AddUrlDialog : BookshelfOverlay
|
||||
data object ImportSheet : BookshelfOverlay
|
||||
data object ExportSheet : BookshelfOverlay
|
||||
data object ConfigSheet : BookshelfOverlay
|
||||
data object GroupManageSheet : BookshelfOverlay
|
||||
data object LogSheet : BookshelfOverlay
|
||||
data object GroupMenu : BookshelfOverlay
|
||||
data object GroupSelectSheet : BookshelfOverlay
|
||||
data object BatchDownloadConfirmDialog : BookshelfOverlay
|
||||
}
|
||||
|
||||
data class BookshelfUiState(
|
||||
override val items: List<BookShelfItem> = emptyList(),
|
||||
override val selectedIds: Set<Any> = emptySet(),
|
||||
@@ -19,5 +37,16 @@ data class BookshelfUiState(
|
||||
val selectedGroupId: Long = BookGroup.IdAll,
|
||||
val loadingText: String? = null,
|
||||
val upBooksCount: Int = 0,
|
||||
val updatingBooks: Set<String> = emptySet()
|
||||
val updatingBooks: Set<String> = emptySet(),
|
||||
val activeOverlay: BookshelfOverlay? = null,
|
||||
val isEditMode: Boolean = false,
|
||||
val selectedBookUrls: Set<String> = emptySet(),
|
||||
val isInFolderRoot: Boolean = false,
|
||||
val isRefreshing: Boolean = false,
|
||||
val bookGroupStyle: Int = 0,
|
||||
val title: String = "",
|
||||
val subtitle: String? = null,
|
||||
val currentGroupName: String? = null,
|
||||
val draggingBooks: List<BookShelfItem>? = null,
|
||||
val pendingSavedBooks: List<BookShelfItem>? = null
|
||||
) : ListUiState<BookShelfItem>
|
||||
|
||||
@@ -48,19 +48,20 @@ import io.legado.app.utils.eventBus.FlowEventBus
|
||||
import io.legado.app.utils.fromJsonArray
|
||||
import io.legado.app.utils.isAbsUrl
|
||||
import io.legado.app.utils.isJsonArray
|
||||
import io.legado.app.utils.move
|
||||
import io.legado.app.utils.onEachParallel
|
||||
import io.legado.app.utils.postEvent
|
||||
import io.legado.app.utils.printOnDebug
|
||||
import io.legado.app.utils.readText
|
||||
import io.legado.app.utils.toastOnUi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -71,7 +72,6 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
@@ -86,7 +86,6 @@ import java.io.FileOutputStream
|
||||
import java.io.OutputStreamWriter
|
||||
import java.util.LinkedList
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
@@ -97,18 +96,23 @@ class BookshelfViewModel(
|
||||
private val batchCacheDownloadUseCase: BatchCacheDownloadUseCase,
|
||||
private val updateBooksGroupUseCase: UpdateBooksGroupUseCase
|
||||
) : BaseViewModel(application) {
|
||||
var addBookJob: Coroutine<*>? = null
|
||||
private var addBookJob: Coroutine<*>? = null
|
||||
|
||||
private val groupIdFlow = MutableStateFlow(BookshelfConfig.saveTabPosition)
|
||||
private val searchKeyFlow = MutableStateFlow("")
|
||||
private val searchModeFlow = MutableStateFlow(false)
|
||||
private val refreshTrigger = MutableStateFlow(0)
|
||||
private val loadingTextFlow = MutableStateFlow<String?>(null)
|
||||
private val activeOverlayFlow = MutableStateFlow<BookshelfOverlay?>(null)
|
||||
private val isEditModeFlow = MutableStateFlow(false)
|
||||
private val selectedBookUrlsFlow = MutableStateFlow<Set<String>>(emptySet())
|
||||
private val isInFolderRootFlow = MutableStateFlow(BookshelfConfig.bookGroupStyle == 2)
|
||||
private val isRefreshingFlow = MutableStateFlow(false)
|
||||
private val bookGroupStyleFlow = MutableStateFlow(BookshelfConfig.bookGroupStyle)
|
||||
private val draggingBooksFlow = MutableStateFlow<List<BookShelfItem>?>(null)
|
||||
private val pendingSavedBooksFlow = MutableStateFlow<List<BookShelfItem>?>(null)
|
||||
|
||||
// 更新相关
|
||||
private var threadCount = AppConfig.threadCount
|
||||
private var poolSize = threadCount
|
||||
private var upTocPool = Executors.newFixedThreadPool(poolSize).asCoroutineDispatcher()
|
||||
private val waitUpTocBooks = LinkedList<String>()
|
||||
private val onUpTocBooks = ConcurrentHashMap.newKeySet<String>()
|
||||
private val updatingBooksFlow = MutableStateFlow<Set<String>>(emptySet())
|
||||
@@ -119,6 +123,13 @@ class BookshelfViewModel(
|
||||
|
||||
val scrollTrigger = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
|
||||
private val updateConcurrency: Int
|
||||
get() = AppConfig.threadCount.coerceIn(1, AppConst.MAX_THREAD)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val updateDispatcher: CoroutineDispatcher
|
||||
get() = Dispatchers.IO.limitedParallelism(updateConcurrency)
|
||||
|
||||
protected val _eventChannel = Channel<BaseRuleEvent>()
|
||||
val events = _eventChannel.receiveAsFlow()
|
||||
|
||||
@@ -138,79 +149,36 @@ class BookshelfViewModel(
|
||||
val allBookCount: Int
|
||||
)
|
||||
|
||||
val groupSelectorState: StateFlow<BookshelfGroupSelectorState> = combine(
|
||||
groupsFlow,
|
||||
groupIdFlow
|
||||
) { groups, selectedGroupId ->
|
||||
BookshelfGroupSelectorState(
|
||||
groups = groups,
|
||||
selectedGroupIndex = groups.indexOfFirst { it.groupId == selectedGroupId }
|
||||
.coerceAtLeast(0),
|
||||
selectedGroupId = selectedGroupId
|
||||
)
|
||||
}.distinctUntilChanged()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), BookshelfGroupSelectorState())
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val booksFlow = combine(groupIdFlow, refreshTrigger) { groupId, _ -> groupId }
|
||||
.flatMapLatest { groupId ->
|
||||
appDb.bookDao.flowBookShelfByGroup(groupId).map { list ->
|
||||
combine(
|
||||
appDb.bookDao.flowBookShelfByGroup(groupId),
|
||||
groupsFlow
|
||||
) { list, groups ->
|
||||
sortBooks(
|
||||
list,
|
||||
groupsFlow.value.find { it.groupId == groupId })
|
||||
groups.find { it.groupId == groupId }
|
||||
)
|
||||
}
|
||||
}.distinctUntilChanged().flowOn(Dispatchers.Default)
|
||||
|
||||
private val groupPreviewsFlow =
|
||||
combine(groupsFlow, allBooksFlow, refreshTrigger) { groups, allBooks, _ ->
|
||||
if (BookshelfConfig.bookGroupStyle in 2..3) {
|
||||
val previews = HashMap<Long, List<BookShelfItem>>(groups.size)
|
||||
val counts = HashMap<Long, Int>(groups.size)
|
||||
groups.forEach { group ->
|
||||
val groupBooks = when (group.groupId) {
|
||||
BookGroup.IdRoot -> {
|
||||
val sumUserGroupIds =
|
||||
groups.filter { it.groupId > 0 }.sumOf { it.groupId }
|
||||
allBooks.filter { book ->
|
||||
(book.type and BookType.text) > 0 &&
|
||||
(book.type and BookType.local) == 0 &&
|
||||
(sumUserGroupIds and book.group) == 0L
|
||||
}
|
||||
}
|
||||
|
||||
BookGroup.IdAll -> allBooks
|
||||
BookGroup.IdLocal -> allBooks.filter { (it.type and BookType.local) > 0 }
|
||||
BookGroup.IdAudio -> allBooks.filter { (it.type and BookType.audio) > 0 }
|
||||
BookGroup.IdNetNone -> {
|
||||
val sumUserGroupIds =
|
||||
groups.filter { it.groupId > 0 }.sumOf { it.groupId }
|
||||
allBooks.filter { book ->
|
||||
(book.type and BookType.audio) == 0 &&
|
||||
(book.type and BookType.local) == 0 &&
|
||||
(sumUserGroupIds and book.group) == 0L
|
||||
}
|
||||
}
|
||||
|
||||
BookGroup.IdLocalNone -> {
|
||||
val sumUserGroupIds =
|
||||
groups.filter { it.groupId > 0 }.sumOf { it.groupId }
|
||||
allBooks.filter { book ->
|
||||
(book.type and BookType.local) > 0 &&
|
||||
(sumUserGroupIds and book.group) == 0L
|
||||
}
|
||||
}
|
||||
|
||||
BookGroup.IdManga -> allBooks.filter { (it.type and BookType.image) > 0 }
|
||||
BookGroup.IdText -> allBooks.filter { (it.type and BookType.text) > 0 }
|
||||
BookGroup.IdError -> allBooks.filter { (it.type and BookType.updateError) > 0 }
|
||||
BookGroup.IdUnread -> allBooks.filter { it.durChapterIndex == 0 && it.durChapterPos == 0 }
|
||||
BookGroup.IdReading -> allBooks.filter { it.totalChapterNum > 0 && it.durChapterIndex > 0 && it.durChapterIndex < it.totalChapterNum - 1 }
|
||||
BookGroup.IdReadFinished -> allBooks.filter { it.totalChapterNum > 0 && it.durChapterIndex >= it.totalChapterNum - 1 }
|
||||
else -> allBooks.filter { (it.group and group.groupId) != 0L }
|
||||
}
|
||||
counts[group.groupId] = groupBooks.size
|
||||
val sortedBooks = sortBooks(groupBooks, group)
|
||||
val booksWithCover = sortedBooks.filter { it.getDisplayCover() != null }
|
||||
val result = if (booksWithCover.size >= 4) {
|
||||
booksWithCover.take(4)
|
||||
} else {
|
||||
(booksWithCover + sortedBooks.filter { it.getDisplayCover() == null }).take(
|
||||
4
|
||||
)
|
||||
}
|
||||
previews[group.groupId] = result
|
||||
}
|
||||
GroupPreviewState(previews, counts, allBooks.size)
|
||||
} else {
|
||||
GroupPreviewState(emptyMap(), emptyMap(), allBooks.size)
|
||||
}
|
||||
combine(groupsFlow, allBooksFlow, refreshTrigger, bookGroupStyleFlow) { groups, allBooks, _, bookGroupStyle ->
|
||||
buildGroupPreviewState(groups, allBooks, bookGroupStyle)
|
||||
}.distinctUntilChanged().flowOn(Dispatchers.Default)
|
||||
|
||||
private val coreInternalStateFlow = combine(
|
||||
@@ -239,36 +207,130 @@ class BookshelfViewModel(
|
||||
val upBooksCount: Int
|
||||
)
|
||||
|
||||
val uiState: StateFlow<BookshelfUiState> = combine(
|
||||
data class BookshelfInteractionState(
|
||||
val activeOverlay: BookshelfOverlay?,
|
||||
val isEditMode: Boolean,
|
||||
val selectedBookUrls: Set<String>,
|
||||
val isInFolderRoot: Boolean,
|
||||
val isRefreshing: Boolean,
|
||||
val bookGroupStyle: Int,
|
||||
val draggingBooks: List<BookShelfItem>?,
|
||||
val pendingSavedBooks: List<BookShelfItem>?
|
||||
)
|
||||
|
||||
private val editStateFlow = combine(
|
||||
activeOverlayFlow,
|
||||
isEditModeFlow,
|
||||
selectedBookUrlsFlow,
|
||||
isInFolderRootFlow
|
||||
) { activeOverlay, isEditMode, selectedBookUrls, isInFolderRoot ->
|
||||
EditState(activeOverlay, isEditMode, selectedBookUrls, isInFolderRoot)
|
||||
}
|
||||
|
||||
private data class EditState(
|
||||
val activeOverlay: BookshelfOverlay?,
|
||||
val isEditMode: Boolean,
|
||||
val selectedBookUrls: Set<String>,
|
||||
val isInFolderRoot: Boolean
|
||||
)
|
||||
|
||||
private val interactionStateFlow = combine(
|
||||
editStateFlow,
|
||||
isRefreshingFlow,
|
||||
bookGroupStyleFlow,
|
||||
draggingBooksFlow,
|
||||
pendingSavedBooksFlow
|
||||
) { editState, isRefreshing, bookGroupStyle, draggingBooks, pendingSavedBooks ->
|
||||
BookshelfInteractionState(
|
||||
activeOverlay = editState.activeOverlay,
|
||||
isEditMode = editState.isEditMode,
|
||||
selectedBookUrls = editState.selectedBookUrls,
|
||||
isInFolderRoot = editState.isInFolderRoot,
|
||||
isRefreshing = isRefreshing,
|
||||
bookGroupStyle = bookGroupStyle,
|
||||
draggingBooks = draggingBooks,
|
||||
pendingSavedBooks = pendingSavedBooks
|
||||
)
|
||||
}
|
||||
|
||||
private val dataStateFlow = combine(
|
||||
booksFlow,
|
||||
groupsFlow,
|
||||
allGroupsFlow,
|
||||
groupPreviewsFlow,
|
||||
internalStateFlow
|
||||
) { books, groups, allGroups, previews, internal ->
|
||||
BookshelfDataState(books, groups, allGroups, previews, internal)
|
||||
}
|
||||
|
||||
private data class BookshelfDataState(
|
||||
val books: List<BookShelfItem>,
|
||||
val groups: List<BookGroup>,
|
||||
val allGroups: List<BookGroup>,
|
||||
val previews: GroupPreviewState,
|
||||
val internal: InternalState
|
||||
)
|
||||
|
||||
val uiState: StateFlow<BookshelfUiState> = combine(
|
||||
dataStateFlow,
|
||||
interactionStateFlow
|
||||
) { data, interaction ->
|
||||
val books = data.books
|
||||
val groups = data.groups
|
||||
val allGroups = data.allGroups
|
||||
val previews = data.previews
|
||||
val internal = data.internal
|
||||
val filteredBooks = if (!internal.isSearchMode || internal.searchKey.isBlank()) {
|
||||
books
|
||||
} else {
|
||||
books.filter { it.matchesSearchKey(internal.searchKey) }
|
||||
}
|
||||
val selectedGroupIndex = groups.indexOfFirst { it.groupId == internal.groupId }
|
||||
.coerceAtLeast(0)
|
||||
val currentGroupName = allGroups.firstOrNull { it.groupId == internal.groupId }?.groupName
|
||||
?: groups.getOrNull(selectedGroupIndex)?.groupName
|
||||
val selectedIds = interaction.selectedBookUrls.mapTo(linkedSetOf<Any>()) { it }
|
||||
val title = buildTitle(
|
||||
bookGroupStyle = interaction.bookGroupStyle,
|
||||
isInFolderRoot = interaction.isInFolderRoot,
|
||||
isEditMode = interaction.isEditMode,
|
||||
isSearchMode = internal.isSearchMode,
|
||||
currentGroupName = currentGroupName,
|
||||
upBooksCount = internal.upBooksCount
|
||||
)
|
||||
|
||||
BookshelfUiState(
|
||||
items = filteredBooks,
|
||||
selectedIds = selectedIds,
|
||||
groups = groups,
|
||||
allGroups = allGroups,
|
||||
groupPreviews = previews.previews,
|
||||
groupBookCounts = previews.counts,
|
||||
currentGroupBookCount = books.size,
|
||||
allBooksCount = previews.allBookCount,
|
||||
selectedGroupIndex = groups.indexOfFirst { it.groupId == internal.groupId }
|
||||
.coerceAtLeast(0),
|
||||
selectedGroupIndex = selectedGroupIndex,
|
||||
selectedGroupId = internal.groupId,
|
||||
searchKey = internal.searchKey,
|
||||
isSearch = internal.isSearchMode,
|
||||
isLoading = internal.loadingText != null,
|
||||
loadingText = internal.loadingText,
|
||||
upBooksCount = internal.upBooksCount,
|
||||
updatingBooks = internal.updatingBooks
|
||||
updatingBooks = internal.updatingBooks,
|
||||
activeOverlay = interaction.activeOverlay,
|
||||
isEditMode = interaction.isEditMode,
|
||||
selectedBookUrls = interaction.selectedBookUrls,
|
||||
isInFolderRoot = interaction.isInFolderRoot,
|
||||
isRefreshing = interaction.isRefreshing,
|
||||
bookGroupStyle = interaction.bookGroupStyle,
|
||||
title = title,
|
||||
subtitle = if (interaction.isEditMode) {
|
||||
context.getString(R.string.bookshelf_total_count, previews.allBookCount)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
currentGroupName = currentGroupName,
|
||||
draggingBooks = interaction.draggingBooks,
|
||||
pendingSavedBooks = interaction.pendingSavedBooks
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), BookshelfUiState())
|
||||
|
||||
@@ -292,7 +354,12 @@ class BookshelfViewModel(
|
||||
snapshotFlow { BookshelfConfig.bookshelfSortOrder }.collect { refresh() }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
snapshotFlow { BookshelfConfig.bookGroupStyle }.collect { refresh() }
|
||||
snapshotFlow { BookshelfConfig.bookGroupStyle }
|
||||
.distinctUntilChanged()
|
||||
.collect { style ->
|
||||
updateBookGroupStyle(style)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
snapshotFlow { BookshelfConfig.showWaitUpCount }.collect { postUpBooksCount() }
|
||||
@@ -303,11 +370,6 @@ class BookshelfViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
upTocPool.close()
|
||||
}
|
||||
|
||||
private fun sortBooks(list: List<BookShelfItem>, group: BookGroup?): List<BookShelfItem> {
|
||||
val bookSort = group?.getRealBookSort() ?: BookshelfConfig.bookshelfSort
|
||||
val isDescending = BookshelfConfig.bookshelfSortOrder == 1
|
||||
@@ -342,29 +404,126 @@ class BookshelfViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun getBooksFlow(groupId: Long): Flow<List<BookShelfItem>> {
|
||||
return combine(
|
||||
appDb.bookDao.flowBookShelfByGroup(groupId),
|
||||
searchKeyFlow,
|
||||
searchModeFlow,
|
||||
groupsFlow,
|
||||
refreshTrigger
|
||||
) { books, searchKey, isSearchMode, groups, _ ->
|
||||
val group = groups.find { it.groupId == groupId }
|
||||
val filtered = if (!isSearchMode || searchKey.isBlank()) {
|
||||
books
|
||||
private fun buildTitle(
|
||||
bookGroupStyle: Int,
|
||||
isInFolderRoot: Boolean,
|
||||
isEditMode: Boolean,
|
||||
isSearchMode: Boolean,
|
||||
currentGroupName: String?,
|
||||
upBooksCount: Int
|
||||
): String {
|
||||
val bookshelfTitle = context.getString(R.string.bookshelf)
|
||||
val baseTitle = when {
|
||||
isSearchMode && bookGroupStyle == 0 -> bookshelfTitle
|
||||
isSearchMode -> currentGroupName ?: bookshelfTitle
|
||||
bookGroupStyle == 1 -> currentGroupName ?: bookshelfTitle
|
||||
bookGroupStyle == 2 -> if (isInFolderRoot) {
|
||||
bookshelfTitle
|
||||
} else {
|
||||
books.filter { it.matchesSearchKey(searchKey) }
|
||||
currentGroupName ?: bookshelfTitle
|
||||
}
|
||||
sortBooks(filtered, group)
|
||||
}.distinctUntilChanged().flowOn(Dispatchers.Default)
|
||||
|
||||
else -> bookshelfTitle
|
||||
}
|
||||
return when {
|
||||
isEditMode -> bookshelfTitle
|
||||
upBooksCount > 0 -> "$baseTitle ($upBooksCount)"
|
||||
else -> baseTitle
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildGroupPreviewState(
|
||||
groups: List<BookGroup>,
|
||||
allBooks: List<BookShelfItem>,
|
||||
bookGroupStyle: Int
|
||||
): GroupPreviewState {
|
||||
if (bookGroupStyle !in 2..3) {
|
||||
return GroupPreviewState(emptyMap(), emptyMap(), allBooks.size)
|
||||
}
|
||||
|
||||
val buckets = HashMap<Long, MutableList<BookShelfItem>>(groups.size)
|
||||
groups.forEach { group ->
|
||||
buckets[group.groupId] = ArrayList()
|
||||
}
|
||||
|
||||
val userGroups = groups.filter { it.groupId > 0 }
|
||||
val sumUserGroupIds = userGroups.sumOf { it.groupId }
|
||||
|
||||
fun add(groupId: Long, book: BookShelfItem) {
|
||||
buckets[groupId]?.add(book)
|
||||
}
|
||||
|
||||
allBooks.forEach { book ->
|
||||
add(BookGroup.IdAll, book)
|
||||
if (book.isRootGroupBook(sumUserGroupIds)) add(BookGroup.IdRoot, book)
|
||||
if (book.isLocal) add(BookGroup.IdLocal, book)
|
||||
if (book.isAudio) add(BookGroup.IdAudio, book)
|
||||
if (book.isNetNoneGroupBook(sumUserGroupIds)) add(BookGroup.IdNetNone, book)
|
||||
if (book.isLocalNoneGroupBook(sumUserGroupIds)) add(BookGroup.IdLocalNone, book)
|
||||
if (book.isImage) add(BookGroup.IdManga, book)
|
||||
if ((book.type and BookType.text) > 0) add(BookGroup.IdText, book)
|
||||
if ((book.type and BookType.updateError) > 0) add(BookGroup.IdError, book)
|
||||
if (book.durChapterIndex == 0 && book.durChapterPos == 0) {
|
||||
add(BookGroup.IdUnread, book)
|
||||
}
|
||||
if (book.totalChapterNum > 0 &&
|
||||
book.durChapterIndex > 0 &&
|
||||
book.durChapterIndex < book.totalChapterNum - 1
|
||||
) {
|
||||
add(BookGroup.IdReading, book)
|
||||
}
|
||||
if (book.totalChapterNum > 0 && book.durChapterIndex >= book.totalChapterNum - 1) {
|
||||
add(BookGroup.IdReadFinished, book)
|
||||
}
|
||||
userGroups.forEach { group ->
|
||||
if ((book.group and group.groupId) != 0L) {
|
||||
add(group.groupId, book)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val previews = HashMap<Long, List<BookShelfItem>>(groups.size)
|
||||
val counts = HashMap<Long, Int>(groups.size)
|
||||
groups.forEach { group ->
|
||||
val groupBooks = buckets[group.groupId].orEmpty()
|
||||
counts[group.groupId] = groupBooks.size
|
||||
previews[group.groupId] = buildGroupPreview(sortBooks(groupBooks, group))
|
||||
}
|
||||
return GroupPreviewState(previews, counts, allBooks.size)
|
||||
}
|
||||
|
||||
private fun BookShelfItem.isRootGroupBook(sumUserGroupIds: Long): Boolean {
|
||||
return (type and BookType.text) > 0 &&
|
||||
(type and BookType.local) == 0 &&
|
||||
(sumUserGroupIds and group) == 0L
|
||||
}
|
||||
|
||||
private fun BookShelfItem.isNetNoneGroupBook(sumUserGroupIds: Long): Boolean {
|
||||
return (type and BookType.audio) == 0 &&
|
||||
(type and BookType.local) == 0 &&
|
||||
(sumUserGroupIds and group) == 0L
|
||||
}
|
||||
|
||||
private fun BookShelfItem.isLocalNoneGroupBook(sumUserGroupIds: Long): Boolean {
|
||||
return (type and BookType.local) > 0 &&
|
||||
(sumUserGroupIds and group) == 0L
|
||||
}
|
||||
|
||||
private fun buildGroupPreview(sortedBooks: List<BookShelfItem>): List<BookShelfItem> {
|
||||
val booksWithCover = sortedBooks.filter { it.getDisplayCover() != null }
|
||||
return if (booksWithCover.size >= 4) {
|
||||
booksWithCover.take(4)
|
||||
} else {
|
||||
(booksWithCover + sortedBooks.filter { it.getDisplayCover() == null }).take(4)
|
||||
}
|
||||
}
|
||||
|
||||
fun changeGroup(groupId: Long) {
|
||||
if (groupIdFlow.value != groupId) {
|
||||
groupIdFlow.value = groupId
|
||||
BookshelfConfig.saveTabPosition = groupId
|
||||
clearSelection()
|
||||
clearDragState()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,12 +536,86 @@ class BookshelfViewModel(
|
||||
if (!active) {
|
||||
searchKeyFlow.value = ""
|
||||
}
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
refreshTrigger.value++
|
||||
}
|
||||
|
||||
fun showOverlay(overlay: BookshelfOverlay) {
|
||||
activeOverlayFlow.value = overlay
|
||||
}
|
||||
|
||||
fun dismissOverlay() {
|
||||
activeOverlayFlow.value = null
|
||||
}
|
||||
|
||||
fun toggleEditMode() {
|
||||
if (isEditModeFlow.value) {
|
||||
exitEditMode()
|
||||
return
|
||||
}
|
||||
if (bookGroupStyleFlow.value == 2 && isInFolderRootFlow.value) {
|
||||
isInFolderRootFlow.value = false
|
||||
}
|
||||
isEditModeFlow.value = true
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
fun exitEditMode() {
|
||||
isEditModeFlow.value = false
|
||||
clearSelection()
|
||||
clearDragState()
|
||||
}
|
||||
|
||||
fun clearSelection() {
|
||||
selectedBookUrlsFlow.value = emptySet()
|
||||
}
|
||||
|
||||
fun selectAllVisible() {
|
||||
selectedBookUrlsFlow.value = uiState.value.items.mapTo(hashSetOf()) { it.bookUrl }
|
||||
}
|
||||
|
||||
fun invertVisibleSelection() {
|
||||
val visibleBookUrls = uiState.value.items.mapTo(hashSetOf()) { it.bookUrl }
|
||||
selectedBookUrlsFlow.value = visibleBookUrls - selectedBookUrlsFlow.value
|
||||
}
|
||||
|
||||
fun toggleBookSelection(bookUrl: String) {
|
||||
selectedBookUrlsFlow.value = if (selectedBookUrlsFlow.value.contains(bookUrl)) {
|
||||
selectedBookUrlsFlow.value - bookUrl
|
||||
} else {
|
||||
selectedBookUrlsFlow.value + bookUrl
|
||||
}
|
||||
}
|
||||
|
||||
fun pruneSelectionToVisible(books: List<BookShelfItem>) {
|
||||
val visibleBookUrls = books.mapTo(hashSetOf()) { it.bookUrl }
|
||||
selectedBookUrlsFlow.value = selectedBookUrlsFlow.value.intersect(visibleBookUrls)
|
||||
}
|
||||
|
||||
fun setInFolderRoot(isInFolderRoot: Boolean) {
|
||||
if (isInFolderRootFlow.value != isInFolderRoot) {
|
||||
isInFolderRootFlow.value = isInFolderRoot
|
||||
clearSelection()
|
||||
clearDragState()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateBookGroupStyle(bookGroupStyle: Int) {
|
||||
val previousStyle = bookGroupStyleFlow.value
|
||||
if (previousStyle == bookGroupStyle) return
|
||||
bookGroupStyleFlow.value = bookGroupStyle
|
||||
if (bookGroupStyle == 2 && previousStyle != 2) {
|
||||
isInFolderRootFlow.value = true
|
||||
} else if (bookGroupStyle != 2) {
|
||||
isInFolderRootFlow.value = false
|
||||
}
|
||||
clearSelection()
|
||||
clearDragState()
|
||||
}
|
||||
|
||||
fun moveBooksToGroup(bookUrls: Set<String>, groupId: Long) {
|
||||
if (bookUrls.isEmpty()) return
|
||||
execute {
|
||||
@@ -429,6 +662,48 @@ class BookshelfViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshBooks(books: List<BookShelfItem>) {
|
||||
if (isRefreshingFlow.value) return
|
||||
isRefreshingFlow.value = true
|
||||
enqueueTocUpdate(books, resetRefreshWhenIdle = true)
|
||||
}
|
||||
|
||||
fun startDraggingBooks(books: List<BookShelfItem>) {
|
||||
draggingBooksFlow.value = books
|
||||
}
|
||||
|
||||
fun moveDraggingBook(fromIndex: Int, toIndex: Int, fallbackBooks: List<BookShelfItem>) {
|
||||
if (fromIndex == toIndex) return
|
||||
val sourceBooks = draggingBooksFlow.value ?: fallbackBooks
|
||||
if (fromIndex !in sourceBooks.indices || toIndex !in sourceBooks.indices) return
|
||||
draggingBooksFlow.value = sourceBooks.toMutableList().apply {
|
||||
move(fromIndex, toIndex)
|
||||
}
|
||||
}
|
||||
|
||||
fun finishDraggingBooks() {
|
||||
val reorderedBooks = draggingBooksFlow.value ?: return
|
||||
pendingSavedBooksFlow.value = reorderedBooks
|
||||
draggingBooksFlow.value = null
|
||||
saveBookOrder(reorderedBooks)
|
||||
}
|
||||
|
||||
fun syncDragState(books: List<BookShelfItem>, canReorderBooks: Boolean) {
|
||||
if (!canReorderBooks) {
|
||||
clearDragState()
|
||||
return
|
||||
}
|
||||
val pending = pendingSavedBooksFlow.value ?: return
|
||||
if (books.map { it.bookUrl } == pending.map { it.bookUrl }) {
|
||||
pendingSavedBooksFlow.value = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearDragState() {
|
||||
draggingBooksFlow.value = null
|
||||
pendingSavedBooksFlow.value = null
|
||||
}
|
||||
|
||||
fun gotoTop() {
|
||||
scrollTrigger.tryEmit(Unit)
|
||||
}
|
||||
@@ -441,10 +716,25 @@ class BookshelfViewModel(
|
||||
}
|
||||
|
||||
fun upToc(books: List<BookShelfItem>) {
|
||||
execute(context = upTocPool) {
|
||||
enqueueTocUpdate(books, resetRefreshWhenIdle = false)
|
||||
}
|
||||
|
||||
private fun enqueueTocUpdate(
|
||||
books: List<BookShelfItem>,
|
||||
resetRefreshWhenIdle: Boolean
|
||||
) {
|
||||
execute(context = updateDispatcher) {
|
||||
val bookUrls = books.filter { !it.isLocal && it.canUpdate }.map { it.bookUrl }
|
||||
val fullBooks = bookUrls.mapNotNull { appDb.bookDao.getBook(it) }
|
||||
addToWaitUp(fullBooks)
|
||||
}.onError {
|
||||
if (resetRefreshWhenIdle) {
|
||||
isRefreshingFlow.value = false
|
||||
}
|
||||
}.onFinally {
|
||||
if (resetRefreshWhenIdle) {
|
||||
completeRefreshIfIdle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,14 +752,13 @@ class BookshelfViewModel(
|
||||
}
|
||||
|
||||
private fun startUpTocJob() {
|
||||
upPool()
|
||||
postUpBooksCount()
|
||||
upTocJob = viewModelScope.launch(upTocPool) {
|
||||
upTocJob = viewModelScope.launch(updateDispatcher) {
|
||||
flow {
|
||||
while (true) {
|
||||
emit(waitUpTocBooks.poll() ?: break)
|
||||
}
|
||||
}.onEachParallel(threadCount) {
|
||||
}.onEachParallel(updateConcurrency) {
|
||||
onUpTocBooks.add(it)
|
||||
updatingBooksFlow.value = onUpTocBooks.toSet()
|
||||
postEvent(EventBus.UP_BOOKSHELF, it)
|
||||
@@ -483,6 +772,8 @@ class BookshelfViewModel(
|
||||
upTocJob = null
|
||||
if (waitUpTocBooks.isNotEmpty()) {
|
||||
startUpTocJob()
|
||||
} else {
|
||||
completeRefreshIfIdle()
|
||||
}
|
||||
if (it == null && cacheBookJob == null && !CacheBookService.isRun) {
|
||||
cacheBook()
|
||||
@@ -493,13 +784,11 @@ class BookshelfViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun upPool() {
|
||||
threadCount = AppConfig.threadCount
|
||||
val newPoolSize = threadCount
|
||||
if (poolSize == newPoolSize) return
|
||||
poolSize = newPoolSize
|
||||
upTocPool.close()
|
||||
upTocPool = Executors.newFixedThreadPool(poolSize).asCoroutineDispatcher()
|
||||
@Synchronized
|
||||
private fun completeRefreshIfIdle() {
|
||||
if (upTocJob == null && waitUpTocBooks.isEmpty() && onUpTocBooks.isEmpty()) {
|
||||
isRefreshingFlow.value = false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateToc(bookUrl: String) {
|
||||
@@ -576,14 +865,14 @@ class BookshelfViewModel(
|
||||
eventListenerSource.clear()
|
||||
if (AppConfig.preDownloadNum == 0) return
|
||||
cacheBookJob?.cancel()
|
||||
cacheBookJob = viewModelScope.launch(upTocPool) {
|
||||
cacheBookJob = viewModelScope.launch(updateDispatcher) {
|
||||
launch {
|
||||
while (isActive && CacheBook.isRun) {
|
||||
CacheBook.setWorkingState(waitUpTocBooks.isEmpty() && onUpTocBooks.isEmpty())
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
CacheBook.startProcessJob(upTocPool)
|
||||
CacheBook.startProcessJob(updateDispatcher)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -768,6 +1057,16 @@ class BookshelfViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun importBookshelf(uri: Uri, groupId: Long) {
|
||||
execute {
|
||||
uri.readText(context)
|
||||
}.onSuccess {
|
||||
importBookshelf(it, groupId)
|
||||
}.onError {
|
||||
context.toastOnUi(it.localizedMessage ?: "ERROR")
|
||||
}
|
||||
}
|
||||
|
||||
private fun importBookshelfByJson(json: String, groupId: Long) {
|
||||
loadingTextFlow.value = "导入中..."
|
||||
execute {
|
||||
|
||||
@@ -15,7 +15,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Login
|
||||
@@ -35,7 +35,6 @@ import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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
|
||||
@@ -49,6 +48,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import io.legado.app.R
|
||||
import io.legado.app.data.entities.BookSourcePart
|
||||
import io.legado.app.ui.widget.components.explore.ExploreKindUiUseCase
|
||||
@@ -64,7 +64,6 @@ import io.legado.app.ui.widget.components.alert.AppAlertDialog
|
||||
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.divider.PillHeaderDivider
|
||||
import io.legado.app.ui.widget.components.explore.calculateExploreKindRows
|
||||
import io.legado.app.ui.widget.components.explore.ExploreKindMultiTypeItem
|
||||
import io.legado.app.ui.widget.components.EmptyMessage
|
||||
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
|
||||
@@ -75,6 +74,7 @@ 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.utils.startActivity
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import org.koin.compose.koinInject
|
||||
@@ -88,47 +88,59 @@ fun ExploreScreen(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as? AppCompatActivity
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
var sourceToDelete by remember { mutableStateOf<BookSourcePart?>(null) }
|
||||
val listState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val exploreKindUseCase: ExploreKindUiUseCase = koinInject()
|
||||
|
||||
// 自动滚动置顶
|
||||
LaunchedEffect(uiState.expandedId) {
|
||||
uiState.expandedId?.let { id ->
|
||||
var realIndex = 0
|
||||
for (item in uiState.items) {
|
||||
if (item.bookSourceUrl == id) break
|
||||
realIndex++
|
||||
}
|
||||
if (realIndex >= 0) {
|
||||
listState.animateScrollToItem(realIndex)
|
||||
LaunchedEffect(viewModel, activity, exploreKindUseCase) {
|
||||
viewModel.effects.collect { effect ->
|
||||
when (effect) {
|
||||
is ExploreEffect.ExecuteKindAction -> {
|
||||
exploreKindUseCase.executeAction(
|
||||
action = effect.kind.action,
|
||||
title = effect.kind.title,
|
||||
sourceUrl = effect.sourceUrl,
|
||||
activity = activity,
|
||||
onRefreshKinds = { viewModel.refreshExploreKinds(effect.sourceUrl) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val stickyHeaderSource by remember {
|
||||
derivedStateOf {
|
||||
val expandedId = uiState.expandedId ?: return@derivedStateOf null
|
||||
val expandedSource =
|
||||
uiState.items.find { it.bookSourceUrl == expandedId } ?: return@derivedStateOf null
|
||||
|
||||
var headerIndex = 0
|
||||
var contentRowCount = 0
|
||||
for (item in uiState.items) {
|
||||
if (item.bookSourceUrl == expandedId) {
|
||||
contentRowCount = calculateExploreKindRows(uiState.exploreKinds, 6).size
|
||||
break
|
||||
val expandedHeader = remember(uiState.expandedId, uiState.listItems) {
|
||||
val expandedId = uiState.expandedId ?: return@remember null
|
||||
val headerIndex = uiState.listItems.indexOfFirst {
|
||||
it is ExploreListItem.Header && it.source.bookSourceUrl == expandedId
|
||||
}
|
||||
val headerItem = uiState.listItems.getOrNull(headerIndex) as? ExploreListItem.Header
|
||||
if (headerItem != null) {
|
||||
ExpandedExploreHeader(
|
||||
source = headerItem.source,
|
||||
headerIndex = headerIndex,
|
||||
contentRowCount = uiState.listItems.count {
|
||||
it is ExploreListItem.KindRow && it.sourceUrl == expandedId
|
||||
}
|
||||
headerIndex++
|
||||
}
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val lastContentIndex = headerIndex + contentRowCount
|
||||
LaunchedEffect(expandedHeader?.headerIndex) {
|
||||
expandedHeader?.let { listState.animateScrollToItem(it.headerIndex) }
|
||||
}
|
||||
|
||||
val stickyHeaderSource by remember(expandedHeader) {
|
||||
derivedStateOf {
|
||||
val header = expandedHeader ?: return@derivedStateOf null
|
||||
val lastContentIndex = header.headerIndex + header.contentRowCount
|
||||
val firstVisible = listState.firstVisibleItemIndex
|
||||
|
||||
if (firstVisible in (headerIndex + 1)..lastContentIndex) {
|
||||
expandedSource
|
||||
if (firstVisible in (header.headerIndex + 1)..lastContentIndex) {
|
||||
header.source
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@@ -181,15 +193,14 @@ fun ExploreScreen(
|
||||
bottom = 120.dp
|
||||
)
|
||||
) {
|
||||
uiState.items.forEach { item ->
|
||||
val isExpanded = uiState.expandedId == item.bookSourceUrl
|
||||
|
||||
item(key = item.bookSourceUrl) {
|
||||
if (isExpanded) {
|
||||
LaunchedEffect(item.bookSourceUrl) {
|
||||
exploreKindUseCase.warmUp(item.bookSourceUrl)
|
||||
}
|
||||
}
|
||||
items(
|
||||
items = uiState.listItems,
|
||||
key = { it.key }
|
||||
) { listItem ->
|
||||
when (listItem) {
|
||||
is ExploreListItem.Header -> {
|
||||
val item = listItem.source
|
||||
val isExpanded = uiState.expandedId == item.bookSourceUrl
|
||||
ExploreSourceHeader(
|
||||
modifier = Modifier.animateItem(),
|
||||
item = item,
|
||||
@@ -217,14 +228,9 @@ fun ExploreScreen(
|
||||
onDelete = { sourceToDelete = item },
|
||||
isMiuix = composeEngine
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isExpanded) {
|
||||
val rows = calculateExploreKindRows(uiState.exploreKinds, 6)
|
||||
itemsIndexed(
|
||||
items = rows,
|
||||
key = { index, _ -> "${item.bookSourceUrl}_$index" }
|
||||
) { _, rowItems ->
|
||||
is ExploreListItem.KindRow -> {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -232,22 +238,27 @@ fun ExploreScreen(
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
rowItems.forEach { (kind, span) ->
|
||||
listItem.rowItems.forEach { (kind, span) ->
|
||||
ExploreKindMultiTypeItem(
|
||||
kind = kind,
|
||||
sourceUrl = item.bookSourceUrl,
|
||||
activity = activity,
|
||||
sourceUrl = listItem.sourceUrl,
|
||||
onOpenUrl = { url ->
|
||||
onOpenExploreShow(kind.title, item.bookSourceUrl, url)
|
||||
onOpenExploreShow(kind.title, listItem.sourceUrl, url)
|
||||
},
|
||||
onRefreshKinds = { viewModel.refreshExploreKinds(item) },
|
||||
modifier = Modifier.weight(span.toFloat()),
|
||||
isMiuix = composeEngine,
|
||||
useCase = exploreKindUseCase
|
||||
displayNameOverride = uiState.kindDisplayNames[kind.title],
|
||||
valueOverride = uiState.kindValues[kind.title],
|
||||
onValueChange = { value ->
|
||||
viewModel.updateKindValue(listItem.sourceUrl, kind, value)
|
||||
},
|
||||
onRunAction = {
|
||||
viewModel.requestKindAction(listItem.sourceUrl, kind)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val totalSpan = rowItems.sumOf { it.second }
|
||||
val totalSpan = listItem.rowItems.sumOf { it.second }
|
||||
if (totalSpan < 6) {
|
||||
Spacer(
|
||||
modifier = Modifier.weight((6 - totalSpan).toFloat())
|
||||
@@ -298,6 +309,12 @@ fun ExploreScreen(
|
||||
)
|
||||
}
|
||||
|
||||
private data class ExpandedExploreHeader(
|
||||
val source: BookSourcePart,
|
||||
val headerIndex: Int,
|
||||
val contentRowCount: Int
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ExploreSourceHeader(
|
||||
|
||||
@@ -3,26 +3,41 @@ package io.legado.app.ui.main.explore
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.legado.app.base.BaseViewModel
|
||||
import io.legado.app.data.appDb
|
||||
import io.legado.app.data.entities.BookSourcePart
|
||||
import io.legado.app.data.entities.rule.ExploreKind
|
||||
import io.legado.app.help.source.SourceHelp
|
||||
import io.legado.app.data.repository.ExploreRepository
|
||||
import io.legado.app.help.source.clearExploreKindsCache
|
||||
import io.legado.app.help.source.exploreKinds
|
||||
import io.legado.app.help.source.getExploreInfoMap
|
||||
import io.legado.app.ui.widget.components.explore.ExploreKindUiUseCase
|
||||
import io.legado.app.ui.widget.components.explore.calculateExploreKindRows
|
||||
import io.legado.app.ui.widget.components.list.ListUiState
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
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
|
||||
|
||||
class ExploreViewModel(application: Application) : BaseViewModel(application) {
|
||||
class ExploreViewModel(
|
||||
application: Application,
|
||||
private val exploreRepository: ExploreRepository,
|
||||
private val exploreKindUseCase: ExploreKindUiUseCase
|
||||
) : BaseViewModel(application) {
|
||||
|
||||
private val _uiState = MutableStateFlow(ExploreUiState())
|
||||
val uiState = _uiState.asStateFlow()
|
||||
val uiState: StateFlow<ExploreUiState> = _uiState
|
||||
.map { state -> state.copy(listItems = buildExploreListItems(state)) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ExploreUiState())
|
||||
private val _effects = MutableSharedFlow<ExploreEffect>(extraBufferCapacity = 8)
|
||||
val effects = _effects.asSharedFlow()
|
||||
|
||||
private var exploreJob: Job? = null
|
||||
private var kindsJob: Job? = null
|
||||
@@ -34,9 +49,11 @@ class ExploreViewModel(application: Application) : BaseViewModel(application) {
|
||||
|
||||
private fun observeGroups() {
|
||||
viewModelScope.launch {
|
||||
appDb.bookSourceDao.flowExploreGroups().collectLatest { groups ->
|
||||
_uiState.update { it.copy(groups = groups) }
|
||||
}
|
||||
exploreRepository.getExploreGroups()
|
||||
.flowOn(IO)
|
||||
.collectLatest { groups ->
|
||||
_uiState.update { it.copy(groups = groups) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,28 +81,11 @@ class ExploreViewModel(application: Application) : BaseViewModel(application) {
|
||||
val query = state.searchKey
|
||||
val selectedGroup = state.selectedGroup
|
||||
|
||||
val flow = when {
|
||||
query.isNotBlank() -> {
|
||||
if (query.startsWith("group:")) {
|
||||
val key = query.substringAfter("group:")
|
||||
appDb.bookSourceDao.flowGroupExplore(key)
|
||||
} else {
|
||||
appDb.bookSourceDao.flowExplore(query)
|
||||
}
|
||||
exploreRepository.getExploreSources(query, selectedGroup)
|
||||
.flowOn(IO)
|
||||
.collectLatest { items ->
|
||||
_uiState.update { it.copy(items = items) }
|
||||
}
|
||||
|
||||
selectedGroup.isNotBlank() -> {
|
||||
appDb.bookSourceDao.flowGroupExplore(selectedGroup)
|
||||
}
|
||||
|
||||
else -> {
|
||||
appDb.bookSourceDao.flowExplore()
|
||||
}
|
||||
}
|
||||
|
||||
flow.flowOn(IO).collectLatest { items ->
|
||||
_uiState.update { it.copy(items = items) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,8 @@ class ExploreViewModel(application: Application) : BaseViewModel(application) {
|
||||
it.copy(
|
||||
expandedId = newExpandedId,
|
||||
exploreKinds = emptyList(),
|
||||
kindDisplayNames = emptyMap(),
|
||||
kindValues = emptyMap(),
|
||||
loadingKinds = newExpandedId != null
|
||||
)
|
||||
}
|
||||
@@ -110,9 +112,24 @@ class ExploreViewModel(application: Application) : BaseViewModel(application) {
|
||||
kindsJob = viewModelScope.launch(IO) {
|
||||
try {
|
||||
val kinds = source.exploreKinds()
|
||||
exploreKindUseCase.warmUp(source.bookSourceUrl)
|
||||
val infoMap = getExploreInfoMap(source.bookSourceUrl)
|
||||
val displayNames = kinds.associate { kind ->
|
||||
kind.title to exploreKindUseCase.resolveDisplayName(
|
||||
kind = kind,
|
||||
sourceUrl = source.bookSourceUrl,
|
||||
infoMap = infoMap
|
||||
)
|
||||
}
|
||||
val values = buildKindValues(kinds, source.bookSourceUrl)
|
||||
_uiState.update {
|
||||
if (it.expandedId == source.bookSourceUrl) {
|
||||
it.copy(exploreKinds = kinds, loadingKinds = false)
|
||||
it.copy(
|
||||
exploreKinds = kinds,
|
||||
kindDisplayNames = displayNames,
|
||||
kindValues = values,
|
||||
loadingKinds = false
|
||||
)
|
||||
} else it
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -132,15 +149,34 @@ class ExploreViewModel(application: Application) : BaseViewModel(application) {
|
||||
|
||||
fun topSource(bookSource: BookSourcePart) {
|
||||
execute {
|
||||
val minXh = appDb.bookSourceDao.minOrder
|
||||
bookSource.customOrder = minXh - 1
|
||||
appDb.bookSourceDao.upOrder(bookSource)
|
||||
exploreRepository.topSource(bookSource)
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshExploreKinds(sourceUrl: String) {
|
||||
val source = _uiState.value.items.firstOrNull { it.bookSourceUrl == sourceUrl } ?: return
|
||||
refreshExploreKinds(source)
|
||||
}
|
||||
|
||||
fun updateKindValue(sourceUrl: String, kind: ExploreKind, value: String) {
|
||||
_uiState.update { state ->
|
||||
state.copy(kindValues = state.kindValues + (kind.title to value))
|
||||
}
|
||||
viewModelScope.launch(IO) {
|
||||
getExploreInfoMap(sourceUrl).apply {
|
||||
this[kind.title] = value
|
||||
saveNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun requestKindAction(sourceUrl: String, kind: ExploreKind) {
|
||||
_effects.tryEmit(ExploreEffect.ExecuteKindAction(sourceUrl, kind))
|
||||
}
|
||||
|
||||
fun deleteSource(source: BookSourcePart) {
|
||||
execute {
|
||||
SourceHelp.deleteBookSource(source.bookSourceUrl)
|
||||
exploreRepository.deleteSource(source.bookSourceUrl)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +190,94 @@ class ExploreViewModel(application: Application) : BaseViewModel(application) {
|
||||
val selectedGroup: String = "",
|
||||
val expandedId: String? = null,
|
||||
val exploreKinds: List<ExploreKind> = emptyList(),
|
||||
val loadingKinds: Boolean = false
|
||||
val kindDisplayNames: Map<String, String> = emptyMap(),
|
||||
val kindValues: Map<String, String> = emptyMap(),
|
||||
val loadingKinds: Boolean = false,
|
||||
val listItems: List<ExploreListItem> = emptyList()
|
||||
) : ListUiState<BookSourcePart>
|
||||
|
||||
private fun buildExploreListItems(state: ExploreUiState): List<ExploreListItem> {
|
||||
if (state.items.isEmpty()) return emptyList()
|
||||
val expandedId = state.expandedId
|
||||
val kindRows = if (expandedId != null) {
|
||||
calculateExploreKindRows(state.exploreKinds, 6)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
return buildList {
|
||||
state.items.forEach { source ->
|
||||
add(ExploreListItem.Header(source))
|
||||
if (source.bookSourceUrl == expandedId) {
|
||||
kindRows.forEachIndexed { index, row ->
|
||||
add(
|
||||
ExploreListItem.KindRow(
|
||||
sourceUrl = source.bookSourceUrl,
|
||||
rowIndex = index,
|
||||
rowItems = row
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildKindValues(
|
||||
kinds: List<ExploreKind>,
|
||||
sourceUrl: String
|
||||
): Map<String, String> {
|
||||
val infoMap = getExploreInfoMap(sourceUrl)
|
||||
var shouldSave = false
|
||||
val values = HashMap<String, String>()
|
||||
kinds.forEach { kind ->
|
||||
when (kind.type) {
|
||||
ExploreKind.Type.text -> {
|
||||
values[kind.title] = infoMap[kind.title].orEmpty()
|
||||
}
|
||||
|
||||
ExploreKind.Type.toggle,
|
||||
ExploreKind.Type.select -> {
|
||||
val chars = kind.chars
|
||||
?.filterNotNull()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: listOf("chars", "is null")
|
||||
val value = infoMap[kind.title]
|
||||
?.takeUnless { it.isEmpty() }
|
||||
?: (kind.default ?: chars.first()).also {
|
||||
infoMap[kind.title] = it
|
||||
shouldSave = true
|
||||
}
|
||||
values[kind.title] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
if (shouldSave) {
|
||||
infoMap.saveNow()
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
sealed interface ExploreListItem {
|
||||
val key: String
|
||||
|
||||
data class Header(val source: BookSourcePart) : ExploreListItem {
|
||||
override val key: String = source.bookSourceUrl
|
||||
}
|
||||
|
||||
data class KindRow(
|
||||
val sourceUrl: String,
|
||||
val rowIndex: Int,
|
||||
val rowItems: List<Pair<ExploreKind, Int>>
|
||||
) : ExploreListItem {
|
||||
override val key: String = "${sourceUrl}_$rowIndex"
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface ExploreEffect {
|
||||
data class ExecuteKindAction(
|
||||
val sourceUrl: String,
|
||||
val kind: ExploreKind
|
||||
) : ExploreEffect
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@ package io.legado.app.ui.main.my
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
@@ -14,7 +17,6 @@ import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ExitToApp
|
||||
import androidx.compose.material.icons.automirrored.filled.HelpOutline
|
||||
@@ -36,12 +38,12 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import io.legado.app.R
|
||||
import io.legado.app.ui.about.AboutActivity
|
||||
import io.legado.app.ui.book.bookmark.AllBookmarkActivity
|
||||
@@ -59,17 +61,18 @@ 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 org.koin.androidx.compose.koinViewModel
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun MyScreen(
|
||||
viewModel: MyViewModel,
|
||||
viewModel: MyViewModel = koinViewModel(),
|
||||
onOpenSettings: () -> Unit,
|
||||
onNavigate: (PrefClickEvent) -> Unit
|
||||
) {
|
||||
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
|
||||
|
||||
AppScaffold(
|
||||
@@ -81,7 +84,14 @@ fun MyScreen(
|
||||
title = stringResource(R.string.my),
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = { onNavigate(PrefClickEvent.ShowMd("appHelp", "xxx")) }
|
||||
onClick = {
|
||||
onNavigate(
|
||||
PrefClickEvent.ShowMd(
|
||||
title = "",
|
||||
path = "appHelp"
|
||||
)
|
||||
)
|
||||
}
|
||||
) {Icon(
|
||||
Icons.AutoMirrored.Filled.HelpOutline, null)
|
||||
}
|
||||
@@ -90,119 +100,123 @@ fun MyScreen(
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier,
|
||||
contentPadding = adaptiveContentPadding(
|
||||
top = padding.calculateTopPadding(),
|
||||
bottom = 120.dp
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(
|
||||
adaptiveContentPadding(
|
||||
top = padding.calculateTopPadding(),
|
||||
bottom = 120.dp
|
||||
)
|
||||
)
|
||||
) {
|
||||
item {
|
||||
SplicedColumnGroup(
|
||||
title = ""
|
||||
) {
|
||||
WebServiceSettingBlock(
|
||||
uiState = uiState,
|
||||
viewModel = viewModel,
|
||||
onNavigate = onNavigate
|
||||
)
|
||||
}
|
||||
SplicedColumnGroup(
|
||||
title = ""
|
||||
) {
|
||||
WebServiceSettingBlock(
|
||||
uiState = uiState,
|
||||
onToggleWebService = {
|
||||
viewModel.onEvent(PrefClickEvent.ToggleWebService)
|
||||
},
|
||||
onNavigate = onNavigate
|
||||
)
|
||||
}
|
||||
|
||||
SplicedColumnGroup(
|
||||
title = stringResource(R.string.rule_segment),
|
||||
) {
|
||||
ClickableSettingItem(
|
||||
title = stringResource(R.string.book_source_manage),
|
||||
description = stringResource(R.string.book_source_manage_desc),
|
||||
imageVector = Icons.Default.Source,
|
||||
onClick = {
|
||||
onNavigate(
|
||||
PrefClickEvent.StartActivity(BookSourceActivity::class.java)
|
||||
)
|
||||
}
|
||||
)
|
||||
ClickableSettingItem(
|
||||
title = stringResource(R.string.replace_purify),
|
||||
imageVector = Icons.Default.FindReplace,
|
||||
onClick = {
|
||||
onNavigate(
|
||||
PrefClickEvent.StartActivity(ReplaceRuleActivity::class.java)
|
||||
)
|
||||
}
|
||||
)
|
||||
ClickableSettingItem(
|
||||
title = stringResource(R.string.txt_toc_rule),
|
||||
imageVector = Icons.AutoMirrored.Filled.Rule,
|
||||
onClick = {
|
||||
onNavigate(
|
||||
PrefClickEvent.StartActivity(TxtTocRuleActivity::class.java)
|
||||
)
|
||||
}
|
||||
)
|
||||
ClickableSettingItem(
|
||||
title = stringResource(R.string.dict_rule),
|
||||
imageVector = Icons.AutoMirrored.Filled.LibraryBooks,
|
||||
onClick = {
|
||||
onNavigate(
|
||||
PrefClickEvent.StartActivity(DictRuleActivity::class.java)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
SplicedColumnGroup(
|
||||
title = stringResource(R.string.rule_segment),
|
||||
) {
|
||||
ClickableSettingItem(
|
||||
title = stringResource(R.string.book_source_manage),
|
||||
description = stringResource(R.string.book_source_manage_desc),
|
||||
imageVector = Icons.Default.Source,
|
||||
onClick = {
|
||||
onNavigate(
|
||||
PrefClickEvent.StartActivity(BookSourceActivity::class.java)
|
||||
)
|
||||
}
|
||||
)
|
||||
ClickableSettingItem(
|
||||
title = stringResource(R.string.replace_purify),
|
||||
imageVector = Icons.Default.FindReplace,
|
||||
onClick = {
|
||||
onNavigate(
|
||||
PrefClickEvent.StartActivity(ReplaceRuleActivity::class.java)
|
||||
)
|
||||
}
|
||||
)
|
||||
ClickableSettingItem(
|
||||
title = stringResource(R.string.txt_toc_rule),
|
||||
imageVector = Icons.AutoMirrored.Filled.Rule,
|
||||
onClick = {
|
||||
onNavigate(
|
||||
PrefClickEvent.StartActivity(TxtTocRuleActivity::class.java)
|
||||
)
|
||||
}
|
||||
)
|
||||
ClickableSettingItem(
|
||||
title = stringResource(R.string.dict_rule),
|
||||
imageVector = Icons.AutoMirrored.Filled.LibraryBooks,
|
||||
onClick = {
|
||||
onNavigate(
|
||||
PrefClickEvent.StartActivity(DictRuleActivity::class.java)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
SplicedColumnGroup(
|
||||
title = stringResource(R.string.other)
|
||||
) {
|
||||
ClickableSettingItem(
|
||||
title = stringResource(R.string.setting),
|
||||
imageVector = Icons.Default.Settings,
|
||||
onClick = {
|
||||
onOpenSettings()
|
||||
}
|
||||
)
|
||||
ClickableSettingItem(
|
||||
title = stringResource(R.string.bookmark),
|
||||
imageVector = Icons.Default.Bookmark,
|
||||
onClick = {
|
||||
onNavigate(PrefClickEvent.StartActivity(AllBookmarkActivity::class.java))
|
||||
}
|
||||
)
|
||||
ClickableSettingItem(
|
||||
title = stringResource(R.string.read_record),
|
||||
imageVector = Icons.Default.History,
|
||||
onClick = {
|
||||
onNavigate(PrefClickEvent.StartActivity(ReadRecordActivity::class.java))
|
||||
}
|
||||
)
|
||||
ClickableSettingItem(
|
||||
title = "缓存管理",
|
||||
imageVector = Icons.Default.Download,
|
||||
onClick = {
|
||||
onNavigate(PrefClickEvent.OpenBookCacheManage)
|
||||
}
|
||||
)
|
||||
ClickableSettingItem(
|
||||
title = stringResource(R.string.file_manage),
|
||||
imageVector = Icons.Default.Folder,
|
||||
onClick = {
|
||||
onNavigate(PrefClickEvent.StartActivity(FileManageActivity::class.java))
|
||||
}
|
||||
)
|
||||
ClickableSettingItem(
|
||||
title = stringResource(R.string.about),
|
||||
imageVector = Icons.Default.Info,
|
||||
onClick = {
|
||||
onNavigate(PrefClickEvent.StartActivity(AboutActivity::class.java))
|
||||
}
|
||||
)
|
||||
ClickableSettingItem(
|
||||
title = stringResource(R.string.exit),
|
||||
imageVector = Icons.AutoMirrored.Filled.ExitToApp,
|
||||
onClick = {
|
||||
onNavigate(PrefClickEvent.ExitApp)
|
||||
}
|
||||
)
|
||||
}
|
||||
SplicedColumnGroup(
|
||||
title = stringResource(R.string.other)
|
||||
) {
|
||||
ClickableSettingItem(
|
||||
title = stringResource(R.string.setting),
|
||||
imageVector = Icons.Default.Settings,
|
||||
onClick = {
|
||||
onOpenSettings()
|
||||
}
|
||||
)
|
||||
ClickableSettingItem(
|
||||
title = stringResource(R.string.bookmark),
|
||||
imageVector = Icons.Default.Bookmark,
|
||||
onClick = {
|
||||
onNavigate(PrefClickEvent.StartActivity(AllBookmarkActivity::class.java))
|
||||
}
|
||||
)
|
||||
ClickableSettingItem(
|
||||
title = stringResource(R.string.read_record),
|
||||
imageVector = Icons.Default.History,
|
||||
onClick = {
|
||||
onNavigate(PrefClickEvent.StartActivity(ReadRecordActivity::class.java))
|
||||
}
|
||||
)
|
||||
ClickableSettingItem(
|
||||
title = stringResource(R.string.cache_management),
|
||||
imageVector = Icons.Default.Download,
|
||||
onClick = {
|
||||
onNavigate(PrefClickEvent.OpenBookCacheManage)
|
||||
}
|
||||
)
|
||||
ClickableSettingItem(
|
||||
title = stringResource(R.string.file_manage),
|
||||
imageVector = Icons.Default.Folder,
|
||||
onClick = {
|
||||
onNavigate(PrefClickEvent.StartActivity(FileManageActivity::class.java))
|
||||
}
|
||||
)
|
||||
ClickableSettingItem(
|
||||
title = stringResource(R.string.about),
|
||||
imageVector = Icons.Default.Info,
|
||||
onClick = {
|
||||
onNavigate(PrefClickEvent.StartActivity(AboutActivity::class.java))
|
||||
}
|
||||
)
|
||||
ClickableSettingItem(
|
||||
title = stringResource(R.string.exit),
|
||||
imageVector = Icons.AutoMirrored.Filled.ExitToApp,
|
||||
onClick = {
|
||||
onNavigate(PrefClickEvent.ExitApp)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,7 +226,7 @@ fun MyScreen(
|
||||
@Composable
|
||||
fun WebServiceSettingBlock(
|
||||
uiState: MyUiState,
|
||||
viewModel: MyViewModel,
|
||||
onToggleWebService: () -> Unit,
|
||||
onNavigate: (PrefClickEvent) -> Unit
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -225,9 +239,7 @@ fun WebServiceSettingBlock(
|
||||
},
|
||||
imageVector = Icons.Default.Web,
|
||||
checked = uiState.isWebServiceRun,
|
||||
onCheckedChange = {
|
||||
viewModel.onEvent(PrefClickEvent.ToggleWebService)
|
||||
}
|
||||
onCheckedChange = { onToggleWebService() }
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
@@ -242,7 +254,7 @@ fun WebServiceSettingBlock(
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
SmallTextButton(
|
||||
text = "复制地址",
|
||||
text = stringResource(R.string.copy_url),
|
||||
imageVector = Icons.Default.ContentCopy,
|
||||
onClick = {
|
||||
onNavigate(PrefClickEvent.CopyUrl(uiState.webServiceAddress))
|
||||
@@ -252,7 +264,7 @@ fun WebServiceSettingBlock(
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
SmallTextButton(
|
||||
text = "浏览器打开",
|
||||
text = stringResource(R.string.open_in_browser),
|
||||
imageVector = Icons.Default.OpenInBrowser,
|
||||
onClick = {
|
||||
onNavigate(PrefClickEvent.OpenUrl(uiState.webServiceAddress))
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package io.legado.app.ui.main.my
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.legado.app.base.BaseViewModel
|
||||
import io.legado.app.constant.EventBus
|
||||
import io.legado.app.service.WebService
|
||||
import io.legado.app.utils.eventBus.FlowEventBus
|
||||
@@ -29,7 +29,7 @@ sealed class PrefClickEvent {
|
||||
|
||||
class MyViewModel(
|
||||
application: Application
|
||||
) : AndroidViewModel(application) {
|
||||
) : BaseViewModel(application) {
|
||||
|
||||
private val _uiState = MutableStateFlow(
|
||||
MyUiState(
|
||||
@@ -59,9 +59,9 @@ class MyViewModel(
|
||||
val currentIsRun = _uiState.value.isWebServiceRun
|
||||
|
||||
if (!currentIsRun) {
|
||||
WebService.start(getApplication())
|
||||
WebService.start(context)
|
||||
} else {
|
||||
WebService.stop(getApplication())
|
||||
WebService.stop(context)
|
||||
_uiState.update { it.copy(isWebServiceRun = false, webServiceAddress = "") }
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -45,6 +45,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import io.legado.app.R
|
||||
import io.legado.app.data.entities.RssSource
|
||||
import io.legado.app.ui.login.SourceLoginActivity
|
||||
@@ -66,6 +67,7 @@ import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
|
||||
import io.legado.app.ui.widget.components.text.AppText
|
||||
import io.legado.app.utils.openUrl
|
||||
import io.legado.app.utils.startActivity
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -76,25 +78,24 @@ fun RssScreen(
|
||||
onOpenRead: (title: String?, origin: String, link: String?, openUrl: String?) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
var sourceToDelete by remember { mutableStateOf<RssSource?>(null) }
|
||||
|
||||
val openRss: (RssSource) -> Unit = { rssSource ->
|
||||
if (rssSource.singleUrl) {
|
||||
viewModel.getSingleUrl(rssSource) { url ->
|
||||
if (url.startsWith("http", true)) {
|
||||
onOpenRead(
|
||||
rssSource.sourceName,
|
||||
url,
|
||||
null,
|
||||
null
|
||||
)
|
||||
} else {
|
||||
context.openUrl(url)
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.effects.collectLatest { effect ->
|
||||
when (effect) {
|
||||
is RssEffect.OpenSort -> {
|
||||
onOpenSort(effect.sourceUrl, effect.sortUrl, effect.key)
|
||||
}
|
||||
|
||||
is RssEffect.OpenRead -> {
|
||||
onOpenRead(effect.title, effect.origin, effect.link, effect.openUrl)
|
||||
}
|
||||
|
||||
is RssEffect.OpenExternalUrl -> {
|
||||
context.openUrl(effect.url)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
onOpenSort(rssSource.sourceUrl, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +115,7 @@ fun RssScreen(
|
||||
ListScaffold(
|
||||
title = stringResource(R.string.rss),
|
||||
state = uiState,
|
||||
subtitle = uiState.group.ifEmpty { "全部" },
|
||||
subtitle = uiState.group.ifEmpty { stringResource(R.string.all) },
|
||||
onBackClick = null,
|
||||
onSearchToggle = { viewModel.toggleSearchVisible(it) },
|
||||
onSearchQueryChange = { viewModel.search(it) },
|
||||
@@ -172,7 +173,7 @@ fun RssScreen(
|
||||
RssSourceGridItem(
|
||||
modifier = Modifier.animateItem(),
|
||||
source = source,
|
||||
onClick = { openRss(source) },
|
||||
onClick = { viewModel.openSource(source) },
|
||||
onTop = { viewModel.topSource(source) },
|
||||
onEdit = { edit(source) },
|
||||
onDelete = { sourceToDelete = source },
|
||||
|
||||
@@ -4,28 +4,34 @@ import android.app.Application
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.script.rhino.runScriptWithContext
|
||||
import io.legado.app.base.BaseViewModel
|
||||
import io.legado.app.data.appDb
|
||||
import io.legado.app.data.entities.RssSource
|
||||
import io.legado.app.help.source.SourceHelp
|
||||
import io.legado.app.data.repository.RssRepository
|
||||
import io.legado.app.utils.toastOnUi
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class RssViewModel(application: Application) : BaseViewModel(application) {
|
||||
class RssViewModel(
|
||||
application: Application,
|
||||
private val rssRepository: RssRepository
|
||||
) : BaseViewModel(application) {
|
||||
|
||||
private val _uiState = MutableStateFlow(RssUiState())
|
||||
val uiState = _uiState.asStateFlow()
|
||||
private val searchKeyFlow = MutableStateFlow("")
|
||||
private val groupFlow = MutableStateFlow("")
|
||||
private val _effects = MutableSharedFlow<RssEffect>(extraBufferCapacity = 8)
|
||||
val effects = _effects.asSharedFlow()
|
||||
|
||||
init {
|
||||
initGroupData()
|
||||
@@ -34,7 +40,7 @@ class RssViewModel(application: Application) : BaseViewModel(application) {
|
||||
|
||||
private fun initGroupData() {
|
||||
viewModelScope.launch {
|
||||
appDb.rssSourceDao.flowEnabledGroups()
|
||||
rssRepository.getEnabledGroups()
|
||||
.flowOn(IO)
|
||||
.collect { groups ->
|
||||
_uiState.update { state -> state.copy(groups = groups) }
|
||||
@@ -45,17 +51,13 @@ class RssViewModel(application: Application) : BaseViewModel(application) {
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private fun initRssData() {
|
||||
combine(
|
||||
_uiState.map { it.searchKey }.distinctUntilChanged(),
|
||||
_uiState.map { it.group }.distinctUntilChanged()
|
||||
searchKeyFlow,
|
||||
groupFlow
|
||||
) { searchKey, group ->
|
||||
searchKey to group
|
||||
}
|
||||
.flatMapLatest { (searchKey, group) ->
|
||||
when {
|
||||
searchKey.isNotEmpty() -> appDb.rssSourceDao.flowEnabled(searchKey)
|
||||
group.isNotEmpty() -> appDb.rssSourceDao.flowEnabledByGroup(group)
|
||||
else -> appDb.rssSourceDao.flowEnabled()
|
||||
}
|
||||
rssRepository.getEnabledSources(searchKey, group)
|
||||
}
|
||||
.flowOn(IO)
|
||||
.onEach { sources ->
|
||||
@@ -65,14 +67,20 @@ class RssViewModel(application: Application) : BaseViewModel(application) {
|
||||
}
|
||||
|
||||
fun search(key: String) {
|
||||
searchKeyFlow.value = key
|
||||
_uiState.update { it.copy(searchKey = key, isSearch = key.isNotEmpty()) }
|
||||
}
|
||||
|
||||
fun setGroup(group: String) {
|
||||
groupFlow.value = group
|
||||
searchKeyFlow.value = ""
|
||||
_uiState.update { it.copy(group = group, searchKey = "", isSearch = false) }
|
||||
}
|
||||
|
||||
fun toggleSearchVisible(visible: Boolean) {
|
||||
if (!visible) {
|
||||
searchKeyFlow.value = ""
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(isSearch = visible, searchKey = if (visible) it.searchKey else "")
|
||||
}
|
||||
@@ -80,70 +88,96 @@ class RssViewModel(application: Application) : BaseViewModel(application) {
|
||||
|
||||
fun topSource(vararg sources: RssSource) {
|
||||
execute {
|
||||
sources.sortBy { it.customOrder }
|
||||
val minOrder = appDb.rssSourceDao.minOrder - 1
|
||||
val array = Array(sources.size) {
|
||||
sources[it].copy(customOrder = minOrder - it)
|
||||
}
|
||||
appDb.rssSourceDao.update(*array)
|
||||
rssRepository.topSources(*sources)
|
||||
}
|
||||
}
|
||||
|
||||
fun bottomSource(vararg sources: RssSource) {
|
||||
execute {
|
||||
sources.sortBy { it.customOrder }
|
||||
val maxOrder = appDb.rssSourceDao.maxOrder + 1
|
||||
val array = Array(sources.size) {
|
||||
sources[it].copy(customOrder = maxOrder + it)
|
||||
}
|
||||
appDb.rssSourceDao.update(*array)
|
||||
rssRepository.bottomSources(*sources)
|
||||
}
|
||||
}
|
||||
|
||||
fun del(vararg rssSource: RssSource) {
|
||||
execute {
|
||||
SourceHelp.deleteRssSources(rssSource.toList())
|
||||
rssRepository.deleteSources(rssSource.toList())
|
||||
}
|
||||
}
|
||||
|
||||
fun disable(rssSource: RssSource) {
|
||||
execute {
|
||||
rssSource.enabled = false
|
||||
appDb.rssSourceDao.update(rssSource)
|
||||
rssRepository.disableSource(rssSource)
|
||||
}
|
||||
}
|
||||
|
||||
fun getSingleUrl(rssSource: RssSource, onSuccess: (url: String) -> Unit) {
|
||||
fun openSource(rssSource: RssSource) {
|
||||
if (!rssSource.singleUrl) {
|
||||
_effects.tryEmit(RssEffect.OpenSort(rssSource.sourceUrl, null, null))
|
||||
return
|
||||
}
|
||||
|
||||
execute {
|
||||
var sortUrl = rssSource.sortUrl
|
||||
if (!sortUrl.isNullOrBlank()) {
|
||||
if (sortUrl.startsWith("<js>", false)
|
||||
|| sortUrl.startsWith("@js:", false)
|
||||
) {
|
||||
val jsStr = if (sortUrl.startsWith("@")) {
|
||||
sortUrl.substring(4)
|
||||
} else {
|
||||
sortUrl.substring(4, sortUrl.lastIndexOf("<"))
|
||||
}
|
||||
val result = runScriptWithContext {
|
||||
rssSource.evalJS(jsStr)?.toString()
|
||||
}
|
||||
if (!result.isNullOrBlank()) {
|
||||
sortUrl = result
|
||||
}
|
||||
}
|
||||
if (sortUrl.contains("::")) {
|
||||
return@execute sortUrl.split("::")[1]
|
||||
} else {
|
||||
return@execute sortUrl
|
||||
}
|
||||
}
|
||||
rssSource.sourceUrl
|
||||
resolveSingleUrl(rssSource)
|
||||
}.timeout(10000)
|
||||
.onSuccess {
|
||||
onSuccess.invoke(it)
|
||||
.onSuccess { url ->
|
||||
if (url.startsWith("http", true)) {
|
||||
_effects.tryEmit(
|
||||
RssEffect.OpenRead(
|
||||
title = rssSource.sourceName,
|
||||
origin = url,
|
||||
link = null,
|
||||
openUrl = null
|
||||
)
|
||||
)
|
||||
} else {
|
||||
_effects.tryEmit(RssEffect.OpenExternalUrl(url))
|
||||
}
|
||||
}.onError {
|
||||
context.toastOnUi(it.localizedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveSingleUrl(rssSource: RssSource): String {
|
||||
var sortUrl = rssSource.sortUrl
|
||||
if (!sortUrl.isNullOrBlank()) {
|
||||
if (sortUrl.startsWith("<js>", false)
|
||||
|| sortUrl.startsWith("@js:", false)
|
||||
) {
|
||||
val jsStr = if (sortUrl.startsWith("@")) {
|
||||
sortUrl.substring(4)
|
||||
} else {
|
||||
sortUrl.substring(4, sortUrl.lastIndexOf("<"))
|
||||
}
|
||||
val result = runScriptWithContext {
|
||||
rssSource.evalJS(jsStr)?.toString()
|
||||
}
|
||||
if (!result.isNullOrBlank()) {
|
||||
sortUrl = result
|
||||
}
|
||||
}
|
||||
return if (sortUrl.contains("::")) {
|
||||
sortUrl.split("::")[1]
|
||||
} else {
|
||||
sortUrl
|
||||
}
|
||||
}
|
||||
return rssSource.sourceUrl
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface RssEffect {
|
||||
data class OpenSort(
|
||||
val sourceUrl: String,
|
||||
val sortUrl: String?,
|
||||
val key: String?
|
||||
) : RssEffect
|
||||
|
||||
data class OpenRead(
|
||||
val title: String?,
|
||||
val origin: String,
|
||||
val link: String?,
|
||||
val openUrl: String?
|
||||
) : RssEffect
|
||||
|
||||
data class OpenExternalUrl(val url: String) : RssEffect
|
||||
}
|
||||
|
||||
+63
-30
@@ -57,32 +57,57 @@ import top.yukonga.miuix.kmp.icon.basic.ArrowUpDown
|
||||
fun ExploreKindMultiTypeItem(
|
||||
kind: ExploreKind,
|
||||
sourceUrl: String?,
|
||||
activity: AppCompatActivity?,
|
||||
activity: AppCompatActivity? = null,
|
||||
onOpenUrl: (String) -> Unit,
|
||||
onRefreshKinds: () -> Unit,
|
||||
onRefreshKinds: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
backgroundColor: Color = LegadoTheme.colorScheme.surfaceContainer,
|
||||
isMiuix: Boolean,
|
||||
useCase: ExploreKindUiUseCase
|
||||
displayNameOverride: String? = null,
|
||||
valueOverride: String? = null,
|
||||
onValueChange: ((String) -> Unit)? = null,
|
||||
onRunAction: (() -> Unit)? = null,
|
||||
useCase: ExploreKindUiUseCase? = null
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val infoMap = remember(sourceUrl) { sourceUrl?.takeIf { it.isNotBlank() }?.let(::getExploreInfoMap) }
|
||||
val infoMap = remember(sourceUrl, useCase) {
|
||||
if (useCase == null) null else sourceUrl?.takeIf { it.isNotBlank() }?.let(::getExploreInfoMap)
|
||||
}
|
||||
var displayName by remember(sourceUrl, kind.title, kind.viewName) { mutableStateOf(kind.title) }
|
||||
|
||||
LaunchedEffect(sourceUrl, kind.title, kind.viewName) {
|
||||
displayName = useCase.resolveDisplayName(kind, sourceUrl, infoMap)
|
||||
LaunchedEffect(displayNameOverride, sourceUrl, kind.title, kind.viewName, useCase) {
|
||||
displayName = displayNameOverride
|
||||
?: useCase?.resolveDisplayName(kind, sourceUrl, infoMap)
|
||||
?: kind.title
|
||||
}
|
||||
|
||||
fun runAction(action: String?) {
|
||||
scope.launch(IO) {
|
||||
useCase.executeAction(
|
||||
action = action,
|
||||
title = kind.title,
|
||||
sourceUrl = sourceUrl,
|
||||
infoMap = infoMap,
|
||||
activity = activity,
|
||||
onRefreshKinds = onRefreshKinds
|
||||
)
|
||||
if (action.isNullOrBlank()) return
|
||||
if (onRunAction != null) {
|
||||
onRunAction()
|
||||
} else {
|
||||
val useCase = useCase ?: return
|
||||
scope.launch(IO) {
|
||||
useCase.executeAction(
|
||||
action = action,
|
||||
title = kind.title,
|
||||
sourceUrl = sourceUrl,
|
||||
infoMap = infoMap,
|
||||
activity = activity,
|
||||
onRefreshKinds = onRefreshKinds
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateValue(value: String) {
|
||||
if (onValueChange != null) {
|
||||
onValueChange(value)
|
||||
} else {
|
||||
infoMap?.let {
|
||||
it[kind.title] = value
|
||||
it.saveNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,17 +156,19 @@ fun ExploreKindMultiTypeItem(
|
||||
|
||||
ExploreKind.Type.text -> {
|
||||
var value by remember(sourceUrl, kind.title) {
|
||||
mutableStateOf(infoMap?.get(kind.title).orEmpty())
|
||||
mutableStateOf(valueOverride ?: infoMap?.get(kind.title).orEmpty())
|
||||
}
|
||||
LaunchedEffect(valueOverride) {
|
||||
if (valueOverride != null) {
|
||||
value = valueOverride
|
||||
}
|
||||
}
|
||||
var actionJob by remember(sourceUrl, kind.title) { mutableStateOf<Job?>(null) }
|
||||
ExploreKindCompactTextField(
|
||||
value = value,
|
||||
onValueChange = { newValue ->
|
||||
value = newValue
|
||||
infoMap?.let {
|
||||
it[kind.title] = newValue
|
||||
it.saveNow()
|
||||
}
|
||||
updateValue(newValue)
|
||||
if (!kind.action.isNullOrBlank()) {
|
||||
actionJob?.cancel()
|
||||
actionJob = scope.launch {
|
||||
@@ -164,7 +191,8 @@ fun ExploreKindMultiTypeItem(
|
||||
val left = kind.style().layout_justifySelf != "right"
|
||||
var char by remember(sourceUrl, kind.title, kind.default, kind.chars) {
|
||||
mutableStateOf(
|
||||
infoMap?.get(kind.title)
|
||||
valueOverride
|
||||
?: infoMap?.get(kind.title)
|
||||
?.takeUnless { it.isEmpty() }
|
||||
?: (kind.default ?: chars.first()).also {
|
||||
infoMap?.let { map ->
|
||||
@@ -174,6 +202,11 @@ fun ExploreKindMultiTypeItem(
|
||||
}
|
||||
)
|
||||
}
|
||||
LaunchedEffect(valueOverride) {
|
||||
if (valueOverride != null) {
|
||||
char = valueOverride
|
||||
}
|
||||
}
|
||||
val text = if (left) "$char$displayName" else "$displayName$char"
|
||||
ExploreKindItem(
|
||||
kind = kind,
|
||||
@@ -182,10 +215,7 @@ fun ExploreKindMultiTypeItem(
|
||||
val currentIndex = chars.indexOf(char)
|
||||
val nextIndex = if (currentIndex < 0) 0 else (currentIndex + 1) % chars.size
|
||||
char = chars.getOrElse(nextIndex) { "" }
|
||||
infoMap?.let { map ->
|
||||
map[kind.title] = char
|
||||
map.saveNow()
|
||||
}
|
||||
updateValue(char)
|
||||
runAction(kind.action)
|
||||
},
|
||||
modifier = modifier,
|
||||
@@ -209,7 +239,8 @@ fun ExploreKindMultiTypeItem(
|
||||
}
|
||||
var selected by remember(sourceUrl, kind.title, kind.default, kind.chars) {
|
||||
mutableStateOf(
|
||||
infoMap?.get(kind.title)
|
||||
valueOverride
|
||||
?: infoMap?.get(kind.title)
|
||||
?.takeUnless { it.isEmpty() }
|
||||
?: (kind.default ?: chars.first()).also {
|
||||
infoMap?.let { map ->
|
||||
@@ -219,6 +250,11 @@ fun ExploreKindMultiTypeItem(
|
||||
}
|
||||
)
|
||||
}
|
||||
LaunchedEffect(valueOverride) {
|
||||
if (valueOverride != null) {
|
||||
selected = valueOverride
|
||||
}
|
||||
}
|
||||
var showSelector by remember(sourceUrl, kind.title) { mutableStateOf(false) }
|
||||
Box(modifier = modifier) {
|
||||
ExploreKindItem(
|
||||
@@ -249,10 +285,7 @@ fun ExploreKindMultiTypeItem(
|
||||
showSelector = false
|
||||
if (selected != option) {
|
||||
selected = option
|
||||
infoMap?.let { map ->
|
||||
map[kind.title] = option
|
||||
map.saveNow()
|
||||
}
|
||||
updateValue(option)
|
||||
runAction(kind.action)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import io.legado.app.data.dao.BookSourceDao
|
||||
import io.legado.app.data.entities.BaseSource
|
||||
import io.legado.app.data.entities.BookSource
|
||||
import io.legado.app.data.entities.rule.ExploreKind
|
||||
import io.legado.app.help.source.getExploreInfoMap
|
||||
import io.legado.app.ui.login.SourceLoginJsExtensions
|
||||
import io.legado.app.utils.InfoMap
|
||||
import kotlinx.coroutines.Dispatchers.IO
|
||||
@@ -43,6 +44,18 @@ class ExploreKindUiUseCase(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun executeAction(
|
||||
action: String?,
|
||||
title: String,
|
||||
sourceUrl: String?,
|
||||
activity: AppCompatActivity?,
|
||||
onRefreshKinds: () -> Unit
|
||||
) {
|
||||
val effectiveSourceUrl = sourceUrl ?: return
|
||||
val infoMap = getExploreInfoMap(effectiveSourceUrl)
|
||||
executeAction(action, title, effectiveSourceUrl, infoMap, activity, onRefreshKinds)
|
||||
}
|
||||
|
||||
suspend fun executeAction(
|
||||
action: String?,
|
||||
title: String,
|
||||
|
||||
@@ -1466,4 +1466,9 @@
|
||||
<string name="subtitle_margin">次行距离</string>
|
||||
<string name="heading_spacing">标题段距</string>
|
||||
<string name="follow_read_background">跟随阅读背景</string>
|
||||
<string name="bookshelf_total_count">共%1$d本</string>
|
||||
<string name="bookshelf_selected_count">已选%1$d本</string>
|
||||
<string name="bookshelf_empty_global_search">没有书籍,尝试全局搜索</string>
|
||||
<string name="global_search">全局搜索</string>
|
||||
<string name="cache_management">缓存管理</string>
|
||||
</resources>
|
||||
|
||||
@@ -1261,4 +1261,9 @@
|
||||
<string name="input_value_range">輸入數值(%1$d-%2$d)</string>
|
||||
<string name="miuix_monet">Miuix Monet</string>
|
||||
<string name="miuix_monet_summary">為 Miuix 使用 Monet 演算法生成配色。目前主題為動態主題且系統為 Android 12 或以上時,會優先使用系統取色;否則使用下方種子色。</string>
|
||||
<string name="bookshelf_total_count">共%1$d本</string>
|
||||
<string name="bookshelf_selected_count">已選%1$d本</string>
|
||||
<string name="bookshelf_empty_global_search">沒有書籍,嘗試全局搜索</string>
|
||||
<string name="global_search">全局搜索</string>
|
||||
<string name="cache_management">緩存管理</string>
|
||||
</resources>
|
||||
|
||||
@@ -1264,4 +1264,9 @@
|
||||
<string name="subtitle_scale">次行縮放</string>
|
||||
<string name="subtitle_margin">次行距離</string>
|
||||
<string name="heading_spacing">標題段距</string>
|
||||
<string name="bookshelf_total_count">共%1$d本</string>
|
||||
<string name="bookshelf_selected_count">已選%1$d本</string>
|
||||
<string name="bookshelf_empty_global_search">沒有書籍,嘗試全域搜尋</string>
|
||||
<string name="global_search">全域搜尋</string>
|
||||
<string name="cache_management">快取管理</string>
|
||||
</resources>
|
||||
|
||||
@@ -1473,4 +1473,9 @@
|
||||
<string name="subtitle_margin">Subtitle Margin</string>
|
||||
<string name="heading_spacing">Heading Spacing</string>
|
||||
<string name="follow_read_background">Follow Background</string>
|
||||
<string name="bookshelf_total_count">%1$d books</string>
|
||||
<string name="bookshelf_selected_count">%1$d selected</string>
|
||||
<string name="bookshelf_empty_global_search">No books here. Try global search.</string>
|
||||
<string name="global_search">Global search</string>
|
||||
<string name="cache_management">Cache management</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user