[优化] 性能优化

This commit is contained in:
HapeLee
2026-05-01 00:55:43 +08:00
parent e2d90d153c
commit 03d0528dd2
25 changed files with 1625 additions and 708 deletions
+3 -1
View File
@@ -9,7 +9,9 @@
"Bash(./gradlew :app:cleanCompileAppDebugKotlin)", "Bash(./gradlew :app:cleanCompileAppDebugKotlin)",
"Bash(./gradlew :app:compileAppDebugKotlin --rerun-tasks)", "Bash(./gradlew :app:compileAppDebugKotlin --rerun-tasks)",
"Bash(./gradlew :app:compileAppDebugKotlin)", "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.
-63
View File
@@ -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.
+37
View File
@@ -2,6 +2,41 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 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 ## Build / Test / Run
```bash ```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. 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 ## 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/`. 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.AppDatabase
import io.legado.app.data.entities.BookSource 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.SearchBook
import io.legado.app.data.entities.rule.ExploreKind 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.help.source.exploreKinds
import io.legado.app.model.webBook.WebBook import io.legado.app.model.webBook.WebBook
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@@ -14,10 +15,14 @@ import kotlinx.coroutines.withContext
interface ExploreRepository { interface ExploreRepository {
fun getBookshelfItems(): Flow<List<SearchBook>> 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 getBookSource(url: String): BookSource?
suspend fun exploreBook(source: BookSource, url: String, page: Int): Result<List<SearchBook>> suspend fun exploreBook(source: BookSource, url: String, page: Int): Result<List<SearchBook>>
suspend fun saveSearchBooks(books: List<SearchBook>) suspend fun saveSearchBooks(books: List<SearchBook>)
suspend fun getSourceExploreKinds(sourceUrl: String): List<ExploreKind> suspend fun getSourceExploreKinds(sourceUrl: String): List<ExploreKind>
suspend fun topSource(bookSource: BookSourcePart)
suspend fun deleteSource(sourceUrl: String)
} }
class ExploreRepositoryImpl( 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? { override suspend fun getBookSource(url: String): BookSource? {
return appDb.bookSourceDao.getBookSource(url) return appDb.bookSourceDao.getBookSource(url)
} }
@@ -65,4 +97,13 @@ class ExploreRepositoryImpl(
override suspend fun saveSearchBooks(books: List<SearchBook>) { override suspend fun saveSearchBooks(books: List<SearchBook>) {
appDb.searchBookDao.insert(*books.toTypedArray()) 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 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.data.entities.RssSource
import io.legado.app.help.source.SourceHelp import io.legado.app.help.source.SourceHelp
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
class RssRepository { class RssRepository(
private val dao = appDb.rssSourceDao private val dao: RssSourceDao
) {
fun getEnabledSources(): Flow<List<RssSource>> = dao.flowEnabled() fun getEnabledSources(): Flow<List<RssSource>> = dao.flowEnabled()
@@ -15,12 +16,42 @@ class RssRepository {
fun getEnabledSourcesByGroup(group: String): Flow<List<RssSource>> = fun getEnabledSourcesByGroup(group: String): Flow<List<RssSource>> =
dao.flowEnabledByGroup(group) 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() fun getEnabledGroups(): Flow<List<String>> = dao.flowEnabledGroups()
suspend fun updateSources(vararg sources: RssSource) { suspend fun updateSources(vararg sources: RssSource) {
dao.update(*sources) 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>) { suspend fun deleteSources(sources: List<RssSource>) {
SourceHelp.deleteRssSources(sources) 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.LocalBookRepository
import io.legado.app.data.repository.ReadRecordRepository import io.legado.app.data.repository.ReadRecordRepository
import io.legado.app.data.repository.RemoteBookRepository 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.SearchRepository
import io.legado.app.data.repository.SearchRepositoryImpl import io.legado.app.data.repository.SearchRepositoryImpl
import io.legado.app.data.repository.SearchContentRepository import io.legado.app.data.repository.SearchContentRepository
@@ -142,6 +143,7 @@ val appModule = module {
single<ReadingProgressGateway> { WebDavReadingProgressRepository() } single<ReadingProgressGateway> { WebDavReadingProgressRepository() }
single<BookDomainRepository> { BookDomainRepositoryImpl(get(), get()) } single<BookDomainRepository> { BookDomainRepositoryImpl(get(), get()) }
single<ExploreRepository> { ExploreRepositoryImpl(get()) } single<ExploreRepository> { ExploreRepositoryImpl(get()) }
singleOf(::RssRepository)
single { single {
SearchRepositoryImpl(get()) SearchRepositoryImpl(get())
} }
@@ -56,7 +56,6 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import io.legado.app.R 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.BookshelfScreen
import io.legado.app.ui.main.bookshelf.BookshelfViewModel import io.legado.app.ui.main.bookshelf.BookshelfViewModel
import io.legado.app.ui.main.explore.ExploreScreen import io.legado.app.ui.main.explore.ExploreScreen
@@ -100,9 +99,10 @@ fun MainScreen(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val mainUiState by viewModel.uiState.collectAsStateWithLifecycle()
val bookshelfViewModel: BookshelfViewModel = koinViewModel() val bookshelfViewModel: BookshelfViewModel = koinViewModel()
val bookshelfUiState by bookshelfViewModel.uiState.collectAsStateWithLifecycle() val bookshelfGroupState by bookshelfViewModel.groupSelectorState.collectAsStateWithLifecycle()
val hazeState = remember { HazeState() } val hazeState = remember { HazeState() }
val floatingBarSurfaceColor = MaterialTheme.colorScheme.surface val floatingBarSurfaceColor = MaterialTheme.colorScheme.surface
@@ -110,45 +110,37 @@ fun MainScreen(
drawRect(floatingBarSurfaceColor) drawRect(floatingBarSurfaceColor)
drawContent() drawContent()
} }
val destinations = remember(MainConfig.showDiscovery, MainConfig.showRSS) { val destinations = mainUiState.destinations
MainDestination.mainDestinations.filter {
when (it) {
MainDestination.Explore -> MainConfig.showDiscovery
MainDestination.Rss -> MainConfig.showRSS
else -> true
}
}
}
val initialPage = remember(destinations) { val initialPage = remember(destinations, mainUiState.defaultHomePage) {
val index = destinations.indexOfFirst { it.route == MainConfig.defaultHomePage } val index = destinations.indexOfFirst { it.route == mainUiState.defaultHomePage }
if (index != -1) index else 0 if (index != -1) index else 0
} }
val pagerState = rememberPagerState(initialPage = initialPage) { destinations.size } 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 isUnlabeled = labelVisibilityMode == "unlabeled"
val useFloatingBottomBar = val useFloatingBottomBar =
!useRail && MainConfig.showBottomView && MainConfig.useFloatingBottomBar !useRail && mainUiState.showBottomView && mainUiState.useFloatingBottomBar
val useLiquidGlass = useFloatingBottomBar && val useLiquidGlass = useFloatingBottomBar &&
MainConfig.useFloatingBottomBarLiquidGlass && mainUiState.useFloatingBottomBarLiquidGlass &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
val alwaysShowLabel = labelVisibilityMode == "labeled" val alwaysShowLabel = labelVisibilityMode == "labeled"
val showLabel = !isUnlabeled val showLabel = !isUnlabeled
val navState = rememberWideNavigationRailState( val navState = rememberWideNavigationRailState(
initialValue = if (MainConfig.navExtended) initialValue = if (mainUiState.navExtended)
WideNavigationRailValue.Expanded WideNavigationRailValue.Expanded
else else
WideNavigationRailValue.Collapsed WideNavigationRailValue.Collapsed
) )
LaunchedEffect(navState.currentValue) {
MainConfig.navExtended =
navState.currentValue == WideNavigationRailValue.Expanded
}
Row(modifier = Modifier.fillMaxSize()) { Row(modifier = Modifier.fillMaxSize()) {
if (useRail && MainConfig.showBottomView) { if (useRail && mainUiState.showBottomView) {
WideNavigationRail( WideNavigationRail(
state = navState, state = navState,
header = { header = {
@@ -159,8 +151,10 @@ fun MainScreen(
modifier = Modifier.padding(start = 24.dp), modifier = Modifier.padding(start = 24.dp),
onClick = { onClick = {
coroutineScope.launch { coroutineScope.launch {
if (expanded) navState.collapse() val targetExpanded = !expanded
else navState.expand() if (targetExpanded) navState.expand()
else navState.collapse()
viewModel.setNavExtended(targetExpanded)
} }
} }
) { ) {
@@ -185,7 +179,6 @@ fun MainScreen(
} }
} }
) { ) {
val labelVisibilityMode = MainConfig.labelVisibilityMode
destinations.forEachIndexed { index, destination -> destinations.forEachIndexed { index, destination ->
val selected = pagerState.targetPage == index val selected = pagerState.targetPage == index
var showGroupMenu by remember { mutableStateOf(false) } var showGroupMenu by remember { mutableStateOf(false) }
@@ -224,7 +217,7 @@ fun MainScreen(
expanded = showGroupMenu, expanded = showGroupMenu,
onDismissRequest = { showGroupMenu = false } onDismissRequest = { showGroupMenu = false }
) { dismiss -> ) { dismiss ->
bookshelfUiState.groups.forEachIndexed { groupIndex, group -> bookshelfGroupState.groups.forEachIndexed { groupIndex, group ->
RoundDropdownMenuItem( RoundDropdownMenuItem(
text = group.groupName, text = group.groupName,
onClick = { onClick = {
@@ -237,7 +230,7 @@ fun MainScreen(
} }
}, },
trailingIcon = { trailingIcon = {
if (bookshelfUiState.selectedGroupIndex == groupIndex) { if (bookshelfGroupState.selectedGroupIndex == groupIndex) {
Icon( Icon(
Icons.Default.Check, Icons.Default.Check,
null, null,
@@ -262,7 +255,7 @@ fun MainScreen(
AppScaffold( AppScaffold(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
bottomBar = { bottomBar = {
if (!useRail && MainConfig.showBottomView) { if (!useRail && mainUiState.showBottomView) {
if (useFloatingBottomBar) { if (useFloatingBottomBar) {
Box(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth()) {
FloatingBottomBar( FloatingBottomBar(
@@ -365,7 +358,7 @@ fun MainScreen(
state = pagerState, state = pagerState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
userScrollEnabled = true, userScrollEnabled = true,
beyondViewportPageCount = 3 beyondViewportPageCount = 1
) { page -> ) { page ->
val destination = destinations.getOrNull(page) ?: return@HorizontalPager val destination = destinations.getOrNull(page) ?: return@HorizontalPager
when (destination) { when (destination) {
@@ -394,7 +387,6 @@ fun MainScreen(
} }
) )
MainDestination.My -> MyScreen( MainDestination.My -> MyScreen(
viewModel = koinViewModel(),
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
onNavigate = { event -> onNavigate = { event ->
if (event == PrefClickEvent.OpenBookCacheManage) { if (event == PrefClickEvent.OpenBookCacheManage) {
@@ -3,13 +3,25 @@ package io.legado.app.ui.main
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences
import androidx.appcompat.app.AppCompatActivity
import io.legado.app.base.BaseViewModel import io.legado.app.base.BaseViewModel
import io.legado.app.constant.PreferKey
import io.legado.app.constant.EventBus import io.legado.app.constant.EventBus
import io.legado.app.domain.usecase.AppStartupMaintenanceUseCase import io.legado.app.domain.usecase.AppStartupMaintenanceUseCase
import io.legado.app.domain.usecase.WebDavBackupUseCase 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.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.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.sendToClip
import io.legado.app.utils.showDialogFragment
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
class MainViewModel( class MainViewModel(
application: Application, application: Application,
@@ -17,10 +29,37 @@ class MainViewModel(
private val webDavBackupUseCase: WebDavBackupUseCase private val webDavBackupUseCase: WebDavBackupUseCase
) : BaseViewModel(application) { ) : 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 { init {
prefs.registerOnSharedPreferenceChangeListener(preferenceListener)
deleteNotShelfBook() deleteNotShelfBook()
} }
override fun onCleared() {
prefs.unregisterOnSharedPreferenceChangeListener(preferenceListener)
super.onCleared()
}
fun upAllBookToc() { fun upAllBookToc() {
FlowEventBus.post(EventBus.UP_ALL_BOOK_TOC, Unit) 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) { fun onPrefClickEvent(context: Context, event: PrefClickEvent) {
when (event) { when (event) {
is PrefClickEvent.OpenUrl -> context.startActivity( is PrefClickEvent.OpenUrl -> context.startActivity(
@@ -56,7 +101,11 @@ class MainViewModel(
is PrefClickEvent.CopyUrl -> context.sendToClip(event.url) is PrefClickEvent.CopyUrl -> context.sendToClip(event.url)
is PrefClickEvent.ShowMd -> { 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 -> { 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.ClipEntry
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp 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.adaptiveContentPaddingBookshelf
import io.legado.app.ui.theme.adaptiveHorizontalPadding import io.legado.app.ui.theme.adaptiveHorizontalPadding
import io.legado.app.ui.theme.adaptiveHorizontalPaddingTab 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.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.button.SmallOutlinedIconToggleButton
import io.legado.app.ui.widget.components.topbar.TopBarActionButton import io.legado.app.ui.widget.components.topbar.TopBarActionButton
import io.legado.app.ui.widget.components.alert.AppAlertDialog 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.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.tabRow.AppTabRow import io.legado.app.ui.widget.components.tabRow.AppTabRow
import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.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.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@@ -126,21 +122,15 @@ fun BookshelfScreen(
onNavigateToLocalImport: () -> Unit, onNavigateToLocalImport: () -> Unit,
onNavigateToCache: (Long) -> Unit onNavigateToCache: (Long) -> Unit
) { ) {
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var showAddUrlDialog by remember { mutableStateOf(false) } val activeOverlay = uiState.activeOverlay
var showImportSheet by remember { mutableStateOf(false) } val showGroupMenu = activeOverlay == BookshelfOverlay.GroupMenu
var showExportSheet by remember { mutableStateOf(false) } val isEditMode = uiState.isEditMode
var showConfigSheet by remember { mutableStateOf(false) } val selectedBookUrls = uiState.selectedBookUrls
var showGroupManageSheet by remember { mutableStateOf(false) } val isInFolderRoot = uiState.isInFolderRoot
var showLogSheet by remember { mutableStateOf(false) } val bookGroupStyle = uiState.bookGroupStyle
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 clipboardManager = LocalClipboard.current val clipboardManager = LocalClipboard.current
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@@ -173,14 +163,8 @@ fun BookshelfScreen(
contract = ActivityResultContracts.OpenDocument(), contract = ActivityResultContracts.OpenDocument(),
onResult = { uri -> onResult = { uri ->
uri?.let { uri?.let {
runCatching { val groupId = uiState.groups.getOrNull(uiState.selectedGroupIndex)?.groupId ?: -1L
val text = it.readText(context) viewModel.importBookshelf(it, groupId)
val groupId =
uiState.groups.getOrNull(uiState.selectedGroupIndex)?.groupId ?: -1L
viewModel.importBookshelf(text, groupId)
}.onFailure {
context.toastOnUi(it.localizedMessage ?: "ERROR")
}
} }
} }
) )
@@ -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( val pagerState = rememberPagerState(
initialPage = uiState.selectedGroupIndex, initialPage = uiState.selectedGroupIndex,
pageCount = { uiState.groups.size } pageCount = { uiState.groups.size }
) )
val latestGroups by rememberUpdatedState(uiState.groups)
val latestSelectedGroupId by rememberUpdatedState(uiState.selectedGroupId)
LaunchedEffect(uiState.groups, uiState.isSearch) { LaunchedEffect(uiState.groups, uiState.isSearch) {
if (!uiState.isSearch && uiState.groups.isNotEmpty()) { if (!uiState.isSearch && uiState.groups.isNotEmpty()) {
@@ -209,14 +218,13 @@ fun BookshelfScreen(
} }
LaunchedEffect(pagerState) { LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage } snapshotFlow { pagerState.settledPage }
.distinctUntilChanged() .distinctUntilChanged()
.collect { page -> .collect { page ->
if (uiState.groups.isNotEmpty() && page in uiState.groups.indices) { val groups = latestGroups
val targetGroupId = uiState.groups[page].groupId if (groups.isNotEmpty() && page in groups.indices) {
val currentGroupId = val targetGroupId = groups[page].groupId
uiState.groups.getOrNull(uiState.selectedGroupIndex)?.groupId if (latestSelectedGroupId != targetGroupId) {
if (currentGroupId != targetGroupId) {
viewModel.changeGroup(targetGroupId) viewModel.changeGroup(targetGroupId)
} }
} }
@@ -234,36 +242,18 @@ fun BookshelfScreen(
val isUsingStandaloneSearchGroup = uiState.isSearch && val isUsingStandaloneSearchGroup = uiState.isSearch &&
uiState.groups.none { it.groupId == currentGroupId } uiState.groups.none { it.groupId == currentGroupId }
val currentGroupBookCount = uiState.currentGroupBookCount val currentGroupBookCount = uiState.currentGroupBookCount
val allGroupsBookCount = uiState.allBooksCount
val bookGroupStyle = BookshelfConfig.bookGroupStyle
// 控制是否处于“文件夹列表”根视图,还是“文件夹内部”书籍视图
var isInFolderRoot by remember(bookGroupStyle) { mutableStateOf(bookGroupStyle == 2) }
val clearSelection = { val clearSelection = {
selectedBookUrls = emptySet() viewModel.clearSelection()
} }
val exitEditMode = { val exitEditMode = {
isEditMode = false viewModel.exitEditMode()
clearSelection()
} }
val toggleEditMode = { val toggleEditMode = {
if (isEditMode) { viewModel.toggleEditMode()
exitEditMode()
} else {
if (bookGroupStyle == 2 && isInFolderRoot) {
isInFolderRoot = false
}
isEditMode = true
clearSelection()
}
} }
val toggleBookSelection: (String) -> Unit = { bookUrl -> val toggleBookSelection: (String) -> Unit = { bookUrl ->
selectedBookUrls = if (selectedBookUrls.contains(bookUrl)) { viewModel.toggleBookSelection(bookUrl)
selectedBookUrls - bookUrl
} else {
selectedBookUrls + bookUrl
}
} }
LaunchedEffect(pagerState.currentPage, isInFolderRoot) { LaunchedEffect(pagerState.currentPage, isInFolderRoot) {
@@ -271,8 +261,7 @@ fun BookshelfScreen(
} }
LaunchedEffect(uiState.items) { LaunchedEffect(uiState.items) {
val visibleBookUrls = uiState.items.mapTo(hashSetOf()) { it.bookUrl } viewModel.pruneSelectionToVisible(uiState.items)
selectedBookUrls = selectedBookUrls.intersect(visibleBookUrls)
} }
BackHandler(enabled = isEditMode) { BackHandler(enabled = isEditMode) {
@@ -283,36 +272,11 @@ fun BookshelfScreen(
} }
} }
val currentGroupName = uiState.allGroups.firstOrNull { it.groupId == currentGroupId }?.groupName val currentGroupName = uiState.currentGroupName
?: 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
}
if (bookGroupStyle == 2 && !isInFolderRoot && !isEditMode) { if (bookGroupStyle == 2 && !isInFolderRoot && !isEditMode) {
BackHandler { BackHandler {
isInFolderRoot = true viewModel.setInFolderRoot(true)
} }
} }
@@ -337,8 +301,8 @@ fun BookshelfScreen(
} }
ListScaffold( ListScaffold(
title = title, title = uiState.title.ifEmpty { stringResource(R.string.bookshelf) },
subtitle = subtitle, subtitle = uiState.subtitle,
state = uiState, state = uiState,
showSearchAction = true, showSearchAction = true,
onSearchToggle = { active -> onSearchToggle = { active ->
@@ -369,19 +333,14 @@ fun BookshelfScreen(
topBarActions = { topBarActions = {
AnimatedVisibility(visible = isEditMode) { AnimatedVisibility(visible = isEditMode) {
TopBarActionButton( TopBarActionButton(
onClick = { onClick = { viewModel.selectAllVisible() },
selectedBookUrls = uiState.items.mapTo(hashSetOf()) { it.bookUrl }
},
imageVector = Icons.Default.SelectAll, imageVector = Icons.Default.SelectAll,
contentDescription = stringResource(R.string.select_all) contentDescription = stringResource(R.string.select_all)
) )
} }
AnimatedVisibility(visible = isEditMode) { AnimatedVisibility(visible = isEditMode) {
TopBarActionButton( TopBarActionButton(
onClick = { onClick = { viewModel.invertVisibleSelection() },
val visibleBookUrls = uiState.items.mapTo(hashSetOf()) { it.bookUrl }
selectedBookUrls = visibleBookUrls - selectedBookUrls
},
imageVector = Icons.Default.Refresh, imageVector = Icons.Default.Refresh,
contentDescription = stringResource(R.string.revert_selection) contentDescription = stringResource(R.string.revert_selection)
) )
@@ -390,7 +349,7 @@ fun BookshelfScreen(
TopBarActionButton( TopBarActionButton(
onClick = { onClick = {
if (selectedBookUrls.isNotEmpty()) { if (selectedBookUrls.isNotEmpty()) {
showBatchDownloadConfirmDialog = true viewModel.showOverlay(BookshelfOverlay.BatchDownloadConfirmDialog)
} }
}, },
imageVector = Icons.Default.Download, imageVector = Icons.Default.Download,
@@ -401,7 +360,7 @@ fun BookshelfScreen(
TopBarActionButton( TopBarActionButton(
onClick = { onClick = {
if (selectedBookUrls.isNotEmpty()) { if (selectedBookUrls.isNotEmpty()) {
showGroupSelectSheet = true viewModel.showOverlay(BookshelfOverlay.GroupSelectSheet)
} }
}, },
imageVector = Icons.Default.Bookmarks, imageVector = Icons.Default.Bookmarks,
@@ -428,17 +387,26 @@ fun BookshelfScreen(
) )
RoundDropdownMenuItem( RoundDropdownMenuItem(
text = stringResource(R.string.layout_setting), text = stringResource(R.string.layout_setting),
onClick = { showConfigSheet = true; dismiss() }, onClick = {
viewModel.showOverlay(BookshelfOverlay.ConfigSheet)
dismiss()
},
leadingIcon = { Icon(Icons.Default.GridView, null) } leadingIcon = { Icon(Icons.Default.GridView, null) }
) )
RoundDropdownMenuItem( RoundDropdownMenuItem(
text = stringResource(R.string.group_manage), text = stringResource(R.string.group_manage),
onClick = { showGroupManageSheet = true; dismiss() }, onClick = {
viewModel.showOverlay(BookshelfOverlay.GroupManageSheet)
dismiss()
},
leadingIcon = { Icon(Icons.Default.Edit, null) } leadingIcon = { Icon(Icons.Default.Edit, null) }
) )
RoundDropdownMenuItem( RoundDropdownMenuItem(
text = stringResource(R.string.add_url), text = stringResource(R.string.add_url),
onClick = { showAddUrlDialog = true; dismiss() }, onClick = {
viewModel.showOverlay(BookshelfOverlay.AddUrlDialog)
dismiss()
},
leadingIcon = { Icon(Icons.Default.Link, null) } leadingIcon = { Icon(Icons.Default.Link, null) }
) )
RoundDropdownMenuItem( RoundDropdownMenuItem(
@@ -462,26 +430,58 @@ fun BookshelfScreen(
RoundDropdownMenuItem( RoundDropdownMenuItem(
text = stringResource(R.string.export_bookshelf), text = stringResource(R.string.export_bookshelf),
onClick = { onClick = {
showExportSheet = true viewModel.showOverlay(BookshelfOverlay.ExportSheet)
dismiss() dismiss()
}, },
leadingIcon = { Icon(Icons.Default.UploadFile, null) } leadingIcon = { Icon(Icons.Default.UploadFile, null) }
) )
RoundDropdownMenuItem( RoundDropdownMenuItem(
text = stringResource(R.string.import_bookshelf), text = stringResource(R.string.import_bookshelf),
onClick = { showImportSheet = true; dismiss() }, onClick = {
viewModel.showOverlay(BookshelfOverlay.ImportSheet)
dismiss()
},
leadingIcon = { Icon(Icons.Default.CloudDownload, null) } leadingIcon = { Icon(Icons.Default.CloudDownload, null) }
) )
RoundDropdownMenuItem( RoundDropdownMenuItem(
text = stringResource(R.string.log), text = stringResource(R.string.log),
onClick = { onClick = {
showLogSheet = true viewModel.showOverlay(BookshelfOverlay.LogSheet)
dismiss() dismiss()
}, },
leadingIcon = { Icon(Icons.Default.History, null) } leadingIcon = { Icon(Icons.Default.History, null) }
) )
} }
} else 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, snackbarHostState = snackbarHostState,
bottomContent = if (bookGroupStyle == 0) { bottomContent = if (bookGroupStyle == 0) {
{ {
@@ -511,13 +511,19 @@ fun BookshelfScreen(
Box(modifier = Modifier) { Box(modifier = Modifier) {
SmallOutlinedIconToggleButton( SmallOutlinedIconToggleButton(
checked = showGroupMenu, checked = showGroupMenu,
onCheckedChange = { showGroupMenu = it }, onCheckedChange = {
if (it) {
viewModel.showOverlay(BookshelfOverlay.GroupMenu)
} else {
viewModel.dismissOverlay()
}
},
imageVector = Icons.AutoMirrored.Filled.FormatListBulleted, imageVector = Icons.AutoMirrored.Filled.FormatListBulleted,
contentDescription = stringResource(R.string.group_manage) contentDescription = stringResource(R.string.group_manage)
) )
RoundDropdownMenu( RoundDropdownMenu(
expanded = showGroupMenu, expanded = showGroupMenu,
onDismissRequest = { showGroupMenu = false } onDismissRequest = { viewModel.dismissOverlay() }
) { dismiss -> ) { dismiss ->
uiState.groups.forEachIndexed { index, group -> uiState.groups.forEachIndexed { index, group ->
RoundDropdownMenuItem( RoundDropdownMenuItem(
@@ -606,7 +612,6 @@ fun BookshelfScreen(
} }
} else null } else null
) { paddingValues -> ) { paddingValues ->
var isRefreshing by remember { mutableStateOf(false) }
val pullToRefreshState = rememberPullToRefreshState() val pullToRefreshState = rememberPullToRefreshState()
val currentGroup = if (uiState.isSearch) { val currentGroup = if (uiState.isSearch) {
uiState.allGroups.firstOrNull { it.groupId == currentGroupId } uiState.allGroups.firstOrNull { it.groupId == currentGroupId }
@@ -620,15 +625,8 @@ fun BookshelfScreen(
.fillMaxSize() .fillMaxSize()
.pullToRefresh( .pullToRefresh(
state = pullToRefreshState, state = pullToRefreshState,
isRefreshing = isRefreshing, isRefreshing = uiState.isRefreshing,
onRefresh = { onRefresh = { viewModel.refreshBooks(uiState.items) },
scope.launch {
isRefreshing = true
viewModel.upToc(uiState.items)
delay(1000)
isRefreshing = false
}
},
enabled = pullToRefreshEnabled enabled = pullToRefreshEnabled
) )
) { ) {
@@ -675,9 +673,11 @@ fun BookshelfScreen(
titleMaxLines = BookshelfConfig.bookshelfTitleMaxLines, titleMaxLines = BookshelfConfig.bookshelfTitleMaxLines,
onClick = { onClick = {
scope.launch { pagerState.scrollToPage(index) } scope.launch { pagerState.scrollToPage(index) }
isInFolderRoot = false viewModel.setInFolderRoot(false)
}, },
onLongClick = { showGroupManageSheet = true } onLongClick = {
viewModel.showOverlay(BookshelfOverlay.GroupManageSheet)
}
) )
} else { } else {
BookGroupItemGrid( BookGroupItemGrid(
@@ -692,9 +692,11 @@ fun BookshelfScreen(
coverShadow = BookshelfConfig.bookshelfCoverShadow, coverShadow = BookshelfConfig.bookshelfCoverShadow,
onClick = { onClick = {
scope.launch { pagerState.scrollToPage(index) } 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, selectedBookUrls = selectedBookUrls,
canReorderBooks = false, canReorderBooks = false,
onToggleBookSelection = { toggleBookSelection(it.bookUrl) }, onToggleBookSelection = { toggleBookSelection(it.bookUrl) },
onSaveBookOrder = {}, draggingBooks = null,
pendingSavedBooks = null,
onDragStarted = {},
onMoveBook = { _, _, _ -> },
onDragFinished = {},
onSyncDragState = { _, _ -> },
onGlobalSearch = { onNavigateToSearch(uiState.searchKey.trim()) }, onGlobalSearch = { onNavigateToSearch(uiState.searchKey.trim()) },
onBookClick = onBookClick, onBookClick = onBookClick,
onBookLongClick = onBookLongClick onBookLongClick = onBookLongClick
@@ -726,10 +733,16 @@ fun BookshelfScreen(
) { pageIndex -> ) { pageIndex ->
val group = uiState.groups.getOrNull(pageIndex) val group = uiState.groups.getOrNull(pageIndex)
if (group != null) { if (group != null) {
val booksFlow = remember(group.groupId) { val isSelectedGroup = group.groupId == uiState.selectedGroupId
viewModel.getBooksFlow(group.groupId) val books = if (isSelectedGroup) {
uiState.items
} else {
emptyList()
} }
val books by booksFlow.collectAsStateWithLifecycle(emptyList()) val canReorderBooks = isEditMode &&
!uiState.isSearch &&
group.getRealBookSort() == 3 &&
isSelectedGroup
BookshelfPage( BookshelfPage(
paddingValues = paddingValues, paddingValues = paddingValues,
books = books, books = books,
@@ -739,12 +752,33 @@ fun BookshelfScreen(
bookshelfLayoutList = bookshelfLayoutList, bookshelfLayoutList = bookshelfLayoutList,
isEditMode = isEditMode, isEditMode = isEditMode,
selectedBookUrls = selectedBookUrls, selectedBookUrls = selectedBookUrls,
canReorderBooks = isEditMode && canReorderBooks = canReorderBooks,
!uiState.isSearch &&
group.getRealBookSort() == 3,
onToggleBookSelection = { toggleBookSelection(it.bookUrl) }, onToggleBookSelection = { toggleBookSelection(it.bookUrl) },
onSaveBookOrder = { reorderedBooks -> draggingBooks = if (isSelectedGroup) {
viewModel.saveBookOrder(reorderedBooks) 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()) }, onGlobalSearch = { onNavigateToSearch(uiState.searchKey.trim()) },
onBookClick = onBookClick, onBookClick = onBookClick,
@@ -782,11 +816,19 @@ fun BookshelfScreen(
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
AppText( AppText(
text = "已选${summary.selectedCount}", text = stringResource(
R.string.bookshelf_selected_count,
summary.selectedCount
),
style = LegadoTheme.typography.labelSmallEmphasized style = LegadoTheme.typography.labelSmallEmphasized
) )
AppText( AppText(
text = " · ${summary.currentGroupTotalCount}", text = " · ${
stringResource(
R.string.bookshelf_total_count,
summary.currentGroupTotalCount
)
}",
style = LegadoTheme.typography.labelSmallEmphasized style = LegadoTheme.typography.labelSmallEmphasized
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
@@ -798,7 +840,9 @@ fun BookshelfScreen(
cornerRadius = 16.dp, cornerRadius = 16.dp,
verticalPadding = 8.dp, verticalPadding = 8.dp,
horizontalPadding = 12.dp, horizontalPadding = 12.dp,
onClick = { showGroupMenu = true } onClick = {
viewModel.showOverlay(BookshelfOverlay.GroupMenu)
}
) )
} }
} }
@@ -808,7 +852,7 @@ fun BookshelfScreen(
if (summary.showGroupName) { if (summary.showGroupName) {
RoundDropdownMenu( RoundDropdownMenu(
expanded = showGroupMenu, expanded = showGroupMenu,
onDismissRequest = { showGroupMenu = false } onDismissRequest = { viewModel.dismissOverlay() }
) { dismiss -> ) { dismiss ->
uiState.groups.forEach { group -> uiState.groups.forEach { group ->
RoundDropdownMenuItem( RoundDropdownMenuItem(
@@ -827,7 +871,7 @@ fun BookshelfScreen(
viewModel.changeGroup(group.groupId) viewModel.changeGroup(group.groupId)
} }
if (bookGroupStyle == 2) { if (bookGroupStyle == 2) {
isInFolderRoot = false viewModel.setInFolderRoot(false)
} }
dismiss() dismiss()
}, },
@@ -849,7 +893,7 @@ fun BookshelfScreen(
PullToRefreshDefaults.LoadingIndicator( PullToRefreshDefaults.LoadingIndicator(
state = pullToRefreshState, state = pullToRefreshState,
isRefreshing = isRefreshing, isRefreshing = uiState.isRefreshing,
modifier = Modifier modifier = Modifier
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.padding(top = paddingValues.calculateTopPadding()) .padding(top = paddingValues.calculateTopPadding())
@@ -858,82 +902,81 @@ fun BookshelfScreen(
} }
BookshelfConfigSheet( BookshelfConfigSheet(
show = showConfigSheet, show = activeOverlay == BookshelfOverlay.ConfigSheet,
onDismissRequest = { showConfigSheet = false } onDismissRequest = { viewModel.dismissOverlay() }
) )
GroupManageSheet( GroupManageSheet(
show = showGroupManageSheet, show = activeOverlay == BookshelfOverlay.GroupManageSheet,
onDismissRequest = { showGroupManageSheet = false } onDismissRequest = { viewModel.dismissOverlay() }
) )
GroupSelectSheet( GroupSelectSheet(
show = showGroupSelectSheet, show = activeOverlay == BookshelfOverlay.GroupSelectSheet,
currentGroupId = 0L, currentGroupId = 0L,
onDismissRequest = { showGroupSelectSheet = false }, onDismissRequest = { viewModel.dismissOverlay() },
onConfirm = { groupId -> onConfirm = { groupId ->
viewModel.moveBooksToGroup(selectedBookUrls, groupId) viewModel.moveBooksToGroup(selectedBookUrls, groupId)
showGroupSelectSheet = false viewModel.dismissOverlay()
clearSelection() clearSelection()
} }
) )
SourceInputDialog( SourceInputDialog(
show = showAddUrlDialog, show = activeOverlay == BookshelfOverlay.AddUrlDialog,
title = stringResource(R.string.add_book_url), title = stringResource(R.string.add_book_url),
onDismissRequest = { showAddUrlDialog = false }, onDismissRequest = { viewModel.dismissOverlay() },
onConfirm = { url -> onConfirm = { url ->
viewModel.addBookByUrl(url) viewModel.addBookByUrl(url)
showAddUrlDialog = false viewModel.dismissOverlay()
} }
) )
FilePickerSheet( FilePickerSheet(
show = showImportSheet, show = activeOverlay == BookshelfOverlay.ImportSheet,
onDismissRequest = { showImportSheet = false }, onDismissRequest = { viewModel.dismissOverlay() },
title = stringResource(R.string.import_bookshelf), title = stringResource(R.string.import_bookshelf),
onSelectSysFile = { types -> onSelectSysFile = { types ->
importLauncher.launch(types) importLauncher.launch(types)
showImportSheet = false viewModel.dismissOverlay()
}, },
onManualInput = { onManualInput = {
showAddUrlDialog = true viewModel.showOverlay(BookshelfOverlay.AddUrlDialog)
showImportSheet = false
}, },
allowExtensions = arrayOf("json", "txt") allowExtensions = arrayOf("json", "txt")
) )
FilePickerSheet( FilePickerSheet(
show = showExportSheet, show = activeOverlay == BookshelfOverlay.ExportSheet,
onDismissRequest = { showExportSheet = false }, onDismissRequest = { viewModel.dismissOverlay() },
title = stringResource(R.string.export_bookshelf), title = stringResource(R.string.export_bookshelf),
onSelectSysDir = { onSelectSysDir = {
showExportSheet = false viewModel.dismissOverlay()
exportLauncher.launch("bookshelf.json") exportLauncher.launch("bookshelf.json")
}, },
onUpload = { onUpload = {
showExportSheet = false viewModel.dismissOverlay()
viewModel.uploadBookshelf(uiState.items) viewModel.uploadBookshelf(uiState.items)
} }
) )
AppLogSheet( AppLogSheet(
show = showLogSheet, show = activeOverlay == BookshelfOverlay.LogSheet,
onDismissRequest = { showLogSheet = false } onDismissRequest = { viewModel.dismissOverlay() }
) )
AppAlertDialog( AppAlertDialog(
show = showBatchDownloadConfirmDialog, show = activeOverlay == BookshelfOverlay.BatchDownloadConfirmDialog,
onDismissRequest = { showBatchDownloadConfirmDialog = false }, onDismissRequest = { viewModel.dismissOverlay() },
title = stringResource(R.string.draw), title = stringResource(R.string.draw),
text = stringResource(R.string.sure_cache_book), text = stringResource(R.string.sure_cache_book),
confirmText = stringResource(android.R.string.ok), confirmText = stringResource(android.R.string.ok),
onConfirm = { onConfirm = {
showBatchDownloadConfirmDialog = false viewModel.dismissOverlay()
viewModel.downloadBooks(selectedBookUrls) viewModel.downloadBooks(selectedBookUrls)
}, },
dismissText = stringResource(android.R.string.cancel), dismissText = stringResource(android.R.string.cancel),
onDismiss = { showBatchDownloadConfirmDialog = false } onDismiss = { viewModel.dismissOverlay() }
) )
if (uiState.isLoading) { if (uiState.isLoading) {
@@ -979,7 +1022,12 @@ fun BookshelfPage(
selectedBookUrls: Set<String>, selectedBookUrls: Set<String>,
canReorderBooks: Boolean, canReorderBooks: Boolean,
onToggleBookSelection: (BookShelfItem) -> Unit, 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, onGlobalSearch: () -> Unit,
onBookClick: (BookShelfItem) -> Unit, onBookClick: (BookShelfItem) -> Unit,
onBookLongClick: (BookShelfItem) -> Unit onBookLongClick: (BookShelfItem) -> Unit
@@ -993,8 +1041,8 @@ fun BookshelfPage(
top = paddingValues.calculateTopPadding(), top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding() bottom = paddingValues.calculateBottomPadding()
), ),
message = "没有书籍,尝试全局搜索", message = stringResource(R.string.bookshelf_empty_global_search),
buttonText = "全局搜索", buttonText = stringResource(R.string.global_search),
onButtonClick = onGlobalSearch onButtonClick = onGlobalSearch
) )
} else { } else {
@@ -1018,36 +1066,20 @@ fun BookshelfPage(
val gridContentHorizontalPadding = totalHorizontalPadding / 2 val gridContentHorizontalPadding = totalHorizontalPadding / 2
val gridInnerHorizontalPadding = totalHorizontalPadding / 2 val gridInnerHorizontalPadding = totalHorizontalPadding / 2
val hapticFeedback = LocalHapticFeedback.current 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 val displayBooks = draggingBooks ?: pendingSavedBooks ?: books
LaunchedEffect(books, pendingSavedBooks, canReorderBooks) { LaunchedEffect(books, pendingSavedBooks, canReorderBooks) {
if (!canReorderBooks) { onSyncDragState(books, canReorderBooks)
draggingBooks = null
pendingSavedBooks = null
return@LaunchedEffect
}
val pending = pendingSavedBooks ?: return@LaunchedEffect
if (books.map { it.bookUrl } == pending.map { it.bookUrl }) {
pendingSavedBooks = null
}
} }
val gridState = rememberLazyGridState() val gridState = rememberLazyGridState()
val reorderableState = rememberReorderableLazyGridState(gridState) { from, to -> val reorderableState = rememberReorderableLazyGridState(gridState) { from, to ->
if (canReorderBooks) { if (canReorderBooks) {
draggingBooks = displayBooks.toMutableList().apply { onMoveBook(from.index, to.index, displayBooks)
move(from.index, to.index)
}
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
} }
} }
LaunchedEffect(reorderableState.isAnyItemDragging) { LaunchedEffect(reorderableState.isAnyItemDragging) {
if (!reorderableState.isAnyItemDragging) { if (!reorderableState.isAnyItemDragging) {
draggingBooks?.let { reorderedBooks -> onDragFinished()
pendingSavedBooks = reorderedBooks
onSaveBookOrder(reorderedBooks)
draggingBooks = null
}
} }
} }
FastScrollLazyVerticalGrid( FastScrollLazyVerticalGrid(
@@ -1077,7 +1109,7 @@ fun BookshelfPage(
if (canReorderBooks) { if (canReorderBooks) {
Modifier.longPressDraggableHandle( Modifier.longPressDraggableHandle(
onDragStarted = { onDragStarted = {
draggingBooks = displayBooks onDragStarted(displayBooks)
hapticFeedback.performHapticFeedback( hapticFeedback.performHapticFeedback(
HapticFeedbackType.GestureThresholdActivate HapticFeedbackType.GestureThresholdActivate
) )
@@ -3,6 +3,24 @@ package io.legado.app.ui.main.bookshelf
import io.legado.app.data.entities.BookGroup import io.legado.app.data.entities.BookGroup
import io.legado.app.ui.widget.components.list.ListUiState 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( data class BookshelfUiState(
override val items: List<BookShelfItem> = emptyList(), override val items: List<BookShelfItem> = emptyList(),
override val selectedIds: Set<Any> = emptySet(), override val selectedIds: Set<Any> = emptySet(),
@@ -19,5 +37,16 @@ data class BookshelfUiState(
val selectedGroupId: Long = BookGroup.IdAll, val selectedGroupId: Long = BookGroup.IdAll,
val loadingText: String? = null, val loadingText: String? = null,
val upBooksCount: Int = 0, 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> ) : ListUiState<BookShelfItem>
@@ -48,19 +48,20 @@ import io.legado.app.utils.eventBus.FlowEventBus
import io.legado.app.utils.fromJsonArray import io.legado.app.utils.fromJsonArray
import io.legado.app.utils.isAbsUrl import io.legado.app.utils.isAbsUrl
import io.legado.app.utils.isJsonArray import io.legado.app.utils.isJsonArray
import io.legado.app.utils.move
import io.legado.app.utils.onEachParallel import io.legado.app.utils.onEachParallel
import io.legado.app.utils.postEvent import io.legado.app.utils.postEvent
import io.legado.app.utils.printOnDebug import io.legado.app.utils.printOnDebug
import io.legado.app.utils.readText
import io.legado.app.utils.toastOnUi import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -71,7 +72,6 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
@@ -86,7 +86,6 @@ import java.io.FileOutputStream
import java.io.OutputStreamWriter import java.io.OutputStreamWriter
import java.util.LinkedList import java.util.LinkedList
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
@@ -97,18 +96,23 @@ class BookshelfViewModel(
private val batchCacheDownloadUseCase: BatchCacheDownloadUseCase, private val batchCacheDownloadUseCase: BatchCacheDownloadUseCase,
private val updateBooksGroupUseCase: UpdateBooksGroupUseCase private val updateBooksGroupUseCase: UpdateBooksGroupUseCase
) : BaseViewModel(application) { ) : BaseViewModel(application) {
var addBookJob: Coroutine<*>? = null private var addBookJob: Coroutine<*>? = null
private val groupIdFlow = MutableStateFlow(BookshelfConfig.saveTabPosition) private val groupIdFlow = MutableStateFlow(BookshelfConfig.saveTabPosition)
private val searchKeyFlow = MutableStateFlow("") private val searchKeyFlow = MutableStateFlow("")
private val searchModeFlow = MutableStateFlow(false) private val searchModeFlow = MutableStateFlow(false)
private val refreshTrigger = MutableStateFlow(0) private val refreshTrigger = MutableStateFlow(0)
private val loadingTextFlow = MutableStateFlow<String?>(null) 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 waitUpTocBooks = LinkedList<String>()
private val onUpTocBooks = ConcurrentHashMap.newKeySet<String>() private val onUpTocBooks = ConcurrentHashMap.newKeySet<String>()
private val updatingBooksFlow = MutableStateFlow<Set<String>>(emptySet()) private val updatingBooksFlow = MutableStateFlow<Set<String>>(emptySet())
@@ -119,6 +123,13 @@ class BookshelfViewModel(
val scrollTrigger = MutableSharedFlow<Unit>(extraBufferCapacity = 1) 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>() protected val _eventChannel = Channel<BaseRuleEvent>()
val events = _eventChannel.receiveAsFlow() val events = _eventChannel.receiveAsFlow()
@@ -138,79 +149,36 @@ class BookshelfViewModel(
val allBookCount: Int 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) @OptIn(ExperimentalCoroutinesApi::class)
val booksFlow = combine(groupIdFlow, refreshTrigger) { groupId, _ -> groupId } val booksFlow = combine(groupIdFlow, refreshTrigger) { groupId, _ -> groupId }
.flatMapLatest { groupId -> .flatMapLatest { groupId ->
appDb.bookDao.flowBookShelfByGroup(groupId).map { list -> combine(
appDb.bookDao.flowBookShelfByGroup(groupId),
groupsFlow
) { list, groups ->
sortBooks( sortBooks(
list, list,
groupsFlow.value.find { it.groupId == groupId }) groups.find { it.groupId == groupId }
)
} }
}.distinctUntilChanged().flowOn(Dispatchers.Default) }.distinctUntilChanged().flowOn(Dispatchers.Default)
private val groupPreviewsFlow = private val groupPreviewsFlow =
combine(groupsFlow, allBooksFlow, refreshTrigger) { groups, allBooks, _ -> combine(groupsFlow, allBooksFlow, refreshTrigger, bookGroupStyleFlow) { groups, allBooks, _, bookGroupStyle ->
if (BookshelfConfig.bookGroupStyle in 2..3) { buildGroupPreviewState(groups, allBooks, bookGroupStyle)
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)
}
}.distinctUntilChanged().flowOn(Dispatchers.Default) }.distinctUntilChanged().flowOn(Dispatchers.Default)
private val coreInternalStateFlow = combine( private val coreInternalStateFlow = combine(
@@ -239,36 +207,130 @@ class BookshelfViewModel(
val upBooksCount: Int 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, booksFlow,
groupsFlow, groupsFlow,
allGroupsFlow, allGroupsFlow,
groupPreviewsFlow, groupPreviewsFlow,
internalStateFlow internalStateFlow
) { books, groups, allGroups, previews, internal -> ) { 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()) { val filteredBooks = if (!internal.isSearchMode || internal.searchKey.isBlank()) {
books books
} else { } else {
books.filter { it.matchesSearchKey(internal.searchKey) } 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( BookshelfUiState(
items = filteredBooks, items = filteredBooks,
selectedIds = selectedIds,
groups = groups, groups = groups,
allGroups = allGroups, allGroups = allGroups,
groupPreviews = previews.previews, groupPreviews = previews.previews,
groupBookCounts = previews.counts, groupBookCounts = previews.counts,
currentGroupBookCount = books.size, currentGroupBookCount = books.size,
allBooksCount = previews.allBookCount, allBooksCount = previews.allBookCount,
selectedGroupIndex = groups.indexOfFirst { it.groupId == internal.groupId } selectedGroupIndex = selectedGroupIndex,
.coerceAtLeast(0),
selectedGroupId = internal.groupId, selectedGroupId = internal.groupId,
searchKey = internal.searchKey, searchKey = internal.searchKey,
isSearch = internal.isSearchMode, isSearch = internal.isSearchMode,
isLoading = internal.loadingText != null, isLoading = internal.loadingText != null,
loadingText = internal.loadingText, loadingText = internal.loadingText,
upBooksCount = internal.upBooksCount, 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()) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), BookshelfUiState())
@@ -292,7 +354,12 @@ class BookshelfViewModel(
snapshotFlow { BookshelfConfig.bookshelfSortOrder }.collect { refresh() } snapshotFlow { BookshelfConfig.bookshelfSortOrder }.collect { refresh() }
} }
viewModelScope.launch { viewModelScope.launch {
snapshotFlow { BookshelfConfig.bookGroupStyle }.collect { refresh() } snapshotFlow { BookshelfConfig.bookGroupStyle }
.distinctUntilChanged()
.collect { style ->
updateBookGroupStyle(style)
refresh()
}
} }
viewModelScope.launch { viewModelScope.launch {
snapshotFlow { BookshelfConfig.showWaitUpCount }.collect { postUpBooksCount() } 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> { private fun sortBooks(list: List<BookShelfItem>, group: BookGroup?): List<BookShelfItem> {
val bookSort = group?.getRealBookSort() ?: BookshelfConfig.bookshelfSort val bookSort = group?.getRealBookSort() ?: BookshelfConfig.bookshelfSort
val isDescending = BookshelfConfig.bookshelfSortOrder == 1 val isDescending = BookshelfConfig.bookshelfSortOrder == 1
@@ -342,29 +404,126 @@ class BookshelfViewModel(
} }
} }
@OptIn(ExperimentalCoroutinesApi::class) private fun buildTitle(
fun getBooksFlow(groupId: Long): Flow<List<BookShelfItem>> { bookGroupStyle: Int,
return combine( isInFolderRoot: Boolean,
appDb.bookDao.flowBookShelfByGroup(groupId), isEditMode: Boolean,
searchKeyFlow, isSearchMode: Boolean,
searchModeFlow, currentGroupName: String?,
groupsFlow, upBooksCount: Int
refreshTrigger ): String {
) { books, searchKey, isSearchMode, groups, _ -> val bookshelfTitle = context.getString(R.string.bookshelf)
val group = groups.find { it.groupId == groupId } val baseTitle = when {
val filtered = if (!isSearchMode || searchKey.isBlank()) { isSearchMode && bookGroupStyle == 0 -> bookshelfTitle
books isSearchMode -> currentGroupName ?: bookshelfTitle
bookGroupStyle == 1 -> currentGroupName ?: bookshelfTitle
bookGroupStyle == 2 -> if (isInFolderRoot) {
bookshelfTitle
} else { } 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) { fun changeGroup(groupId: Long) {
if (groupIdFlow.value != groupId) { if (groupIdFlow.value != groupId) {
groupIdFlow.value = groupId groupIdFlow.value = groupId
BookshelfConfig.saveTabPosition = groupId BookshelfConfig.saveTabPosition = groupId
clearSelection()
clearDragState()
} }
} }
@@ -377,12 +536,86 @@ class BookshelfViewModel(
if (!active) { if (!active) {
searchKeyFlow.value = "" searchKeyFlow.value = ""
} }
clearSelection()
} }
fun refresh() { fun refresh() {
refreshTrigger.value++ 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) { fun moveBooksToGroup(bookUrls: Set<String>, groupId: Long) {
if (bookUrls.isEmpty()) return if (bookUrls.isEmpty()) return
execute { 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() { fun gotoTop() {
scrollTrigger.tryEmit(Unit) scrollTrigger.tryEmit(Unit)
} }
@@ -441,10 +716,25 @@ class BookshelfViewModel(
} }
fun upToc(books: List<BookShelfItem>) { 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 bookUrls = books.filter { !it.isLocal && it.canUpdate }.map { it.bookUrl }
val fullBooks = bookUrls.mapNotNull { appDb.bookDao.getBook(it) } val fullBooks = bookUrls.mapNotNull { appDb.bookDao.getBook(it) }
addToWaitUp(fullBooks) addToWaitUp(fullBooks)
}.onError {
if (resetRefreshWhenIdle) {
isRefreshingFlow.value = false
}
}.onFinally {
if (resetRefreshWhenIdle) {
completeRefreshIfIdle()
}
} }
} }
@@ -462,14 +752,13 @@ class BookshelfViewModel(
} }
private fun startUpTocJob() { private fun startUpTocJob() {
upPool()
postUpBooksCount() postUpBooksCount()
upTocJob = viewModelScope.launch(upTocPool) { upTocJob = viewModelScope.launch(updateDispatcher) {
flow { flow {
while (true) { while (true) {
emit(waitUpTocBooks.poll() ?: break) emit(waitUpTocBooks.poll() ?: break)
} }
}.onEachParallel(threadCount) { }.onEachParallel(updateConcurrency) {
onUpTocBooks.add(it) onUpTocBooks.add(it)
updatingBooksFlow.value = onUpTocBooks.toSet() updatingBooksFlow.value = onUpTocBooks.toSet()
postEvent(EventBus.UP_BOOKSHELF, it) postEvent(EventBus.UP_BOOKSHELF, it)
@@ -483,6 +772,8 @@ class BookshelfViewModel(
upTocJob = null upTocJob = null
if (waitUpTocBooks.isNotEmpty()) { if (waitUpTocBooks.isNotEmpty()) {
startUpTocJob() startUpTocJob()
} else {
completeRefreshIfIdle()
} }
if (it == null && cacheBookJob == null && !CacheBookService.isRun) { if (it == null && cacheBookJob == null && !CacheBookService.isRun) {
cacheBook() cacheBook()
@@ -493,13 +784,11 @@ class BookshelfViewModel(
} }
} }
private fun upPool() { @Synchronized
threadCount = AppConfig.threadCount private fun completeRefreshIfIdle() {
val newPoolSize = threadCount if (upTocJob == null && waitUpTocBooks.isEmpty() && onUpTocBooks.isEmpty()) {
if (poolSize == newPoolSize) return isRefreshingFlow.value = false
poolSize = newPoolSize }
upTocPool.close()
upTocPool = Executors.newFixedThreadPool(poolSize).asCoroutineDispatcher()
} }
private suspend fun updateToc(bookUrl: String) { private suspend fun updateToc(bookUrl: String) {
@@ -576,14 +865,14 @@ class BookshelfViewModel(
eventListenerSource.clear() eventListenerSource.clear()
if (AppConfig.preDownloadNum == 0) return if (AppConfig.preDownloadNum == 0) return
cacheBookJob?.cancel() cacheBookJob?.cancel()
cacheBookJob = viewModelScope.launch(upTocPool) { cacheBookJob = viewModelScope.launch(updateDispatcher) {
launch { launch {
while (isActive && CacheBook.isRun) { while (isActive && CacheBook.isRun) {
CacheBook.setWorkingState(waitUpTocBooks.isEmpty() && onUpTocBooks.isEmpty()) CacheBook.setWorkingState(waitUpTocBooks.isEmpty() && onUpTocBooks.isEmpty())
delay(1000) 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) { private fun importBookshelfByJson(json: String, groupId: Long) {
loadingTextFlow.value = "导入中..." loadingTextFlow.value = "导入中..."
execute { execute {
@@ -15,7 +15,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Login 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.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R import io.legado.app.R
import io.legado.app.data.entities.BookSourcePart import io.legado.app.data.entities.BookSourcePart
import io.legado.app.ui.widget.components.explore.ExploreKindUiUseCase 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.GlassCard
import io.legado.app.ui.widget.components.card.TextCard 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.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.explore.ExploreKindMultiTypeItem
import io.legado.app.ui.widget.components.EmptyMessage import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn 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.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.utils.startActivity import io.legado.app.utils.startActivity
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject import org.koin.compose.koinInject
@@ -88,47 +88,59 @@ fun ExploreScreen(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val activity = context as? AppCompatActivity val activity = context as? AppCompatActivity
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var sourceToDelete by remember { mutableStateOf<BookSourcePart?>(null) } var sourceToDelete by remember { mutableStateOf<BookSourcePart?>(null) }
val listState = rememberLazyListState() val listState = rememberLazyListState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val exploreKindUseCase: ExploreKindUiUseCase = koinInject() val exploreKindUseCase: ExploreKindUiUseCase = koinInject()
// 自动滚动置顶 LaunchedEffect(viewModel, activity, exploreKindUseCase) {
LaunchedEffect(uiState.expandedId) { viewModel.effects.collect { effect ->
uiState.expandedId?.let { id -> when (effect) {
var realIndex = 0 is ExploreEffect.ExecuteKindAction -> {
for (item in uiState.items) { exploreKindUseCase.executeAction(
if (item.bookSourceUrl == id) break action = effect.kind.action,
realIndex++ title = effect.kind.title,
} sourceUrl = effect.sourceUrl,
if (realIndex >= 0) { activity = activity,
listState.animateScrollToItem(realIndex) onRefreshKinds = { viewModel.refreshExploreKinds(effect.sourceUrl) }
)
}
} }
} }
} }
val stickyHeaderSource by remember { val expandedHeader = remember(uiState.expandedId, uiState.listItems) {
derivedStateOf { val expandedId = uiState.expandedId ?: return@remember null
val expandedId = uiState.expandedId ?: return@derivedStateOf null val headerIndex = uiState.listItems.indexOfFirst {
val expandedSource = it is ExploreListItem.Header && it.source.bookSourceUrl == expandedId
uiState.items.find { it.bookSourceUrl == expandedId } ?: return@derivedStateOf null }
val headerItem = uiState.listItems.getOrNull(headerIndex) as? ExploreListItem.Header
var headerIndex = 0 if (headerItem != null) {
var contentRowCount = 0 ExpandedExploreHeader(
for (item in uiState.items) { source = headerItem.source,
if (item.bookSourceUrl == expandedId) { headerIndex = headerIndex,
contentRowCount = calculateExploreKindRows(uiState.exploreKinds, 6).size contentRowCount = uiState.listItems.count {
break 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 val firstVisible = listState.firstVisibleItemIndex
if (firstVisible in (headerIndex + 1)..lastContentIndex) { if (firstVisible in (header.headerIndex + 1)..lastContentIndex) {
expandedSource header.source
} else { } else {
null null
} }
@@ -181,15 +193,14 @@ fun ExploreScreen(
bottom = 120.dp bottom = 120.dp
) )
) { ) {
uiState.items.forEach { item -> items(
val isExpanded = uiState.expandedId == item.bookSourceUrl items = uiState.listItems,
key = { it.key }
item(key = item.bookSourceUrl) { ) { listItem ->
if (isExpanded) { when (listItem) {
LaunchedEffect(item.bookSourceUrl) { is ExploreListItem.Header -> {
exploreKindUseCase.warmUp(item.bookSourceUrl) val item = listItem.source
} val isExpanded = uiState.expandedId == item.bookSourceUrl
}
ExploreSourceHeader( ExploreSourceHeader(
modifier = Modifier.animateItem(), modifier = Modifier.animateItem(),
item = item, item = item,
@@ -217,14 +228,9 @@ fun ExploreScreen(
onDelete = { sourceToDelete = item }, onDelete = { sourceToDelete = item },
isMiuix = composeEngine isMiuix = composeEngine
) )
} }
if (isExpanded) { is ExploreListItem.KindRow -> {
val rows = calculateExploreKindRows(uiState.exploreKinds, 6)
itemsIndexed(
items = rows,
key = { index, _ -> "${item.bookSourceUrl}_$index" }
) { _, rowItems ->
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -232,22 +238,27 @@ fun ExploreScreen(
.padding(vertical = 4.dp), .padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
rowItems.forEach { (kind, span) -> listItem.rowItems.forEach { (kind, span) ->
ExploreKindMultiTypeItem( ExploreKindMultiTypeItem(
kind = kind, kind = kind,
sourceUrl = item.bookSourceUrl, sourceUrl = listItem.sourceUrl,
activity = activity,
onOpenUrl = { url -> onOpenUrl = { url ->
onOpenExploreShow(kind.title, item.bookSourceUrl, url) onOpenExploreShow(kind.title, listItem.sourceUrl, url)
}, },
onRefreshKinds = { viewModel.refreshExploreKinds(item) },
modifier = Modifier.weight(span.toFloat()), modifier = Modifier.weight(span.toFloat()),
isMiuix = composeEngine, 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) { if (totalSpan < 6) {
Spacer( Spacer(
modifier = Modifier.weight((6 - totalSpan).toFloat()) 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) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun ExploreSourceHeader( fun ExploreSourceHeader(
@@ -3,26 +3,41 @@ package io.legado.app.ui.main.explore
import android.app.Application import android.app.Application
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.legado.app.base.BaseViewModel 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.BookSourcePart
import io.legado.app.data.entities.rule.ExploreKind 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.clearExploreKindsCache
import io.legado.app.help.source.exploreKinds 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 io.legado.app.ui.widget.components.list.ListUiState
import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow 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.collectLatest
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch 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()) 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 exploreJob: Job? = null
private var kindsJob: Job? = null private var kindsJob: Job? = null
@@ -34,9 +49,11 @@ class ExploreViewModel(application: Application) : BaseViewModel(application) {
private fun observeGroups() { private fun observeGroups() {
viewModelScope.launch { viewModelScope.launch {
appDb.bookSourceDao.flowExploreGroups().collectLatest { groups -> exploreRepository.getExploreGroups()
_uiState.update { it.copy(groups = groups) } .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 query = state.searchKey
val selectedGroup = state.selectedGroup val selectedGroup = state.selectedGroup
val flow = when { exploreRepository.getExploreSources(query, selectedGroup)
query.isNotBlank() -> { .flowOn(IO)
if (query.startsWith("group:")) { .collectLatest { items ->
val key = query.substringAfter("group:") _uiState.update { it.copy(items = items) }
appDb.bookSourceDao.flowGroupExplore(key)
} else {
appDb.bookSourceDao.flowExplore(query)
}
} }
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( it.copy(
expandedId = newExpandedId, expandedId = newExpandedId,
exploreKinds = emptyList(), exploreKinds = emptyList(),
kindDisplayNames = emptyMap(),
kindValues = emptyMap(),
loadingKinds = newExpandedId != null loadingKinds = newExpandedId != null
) )
} }
@@ -110,9 +112,24 @@ class ExploreViewModel(application: Application) : BaseViewModel(application) {
kindsJob = viewModelScope.launch(IO) { kindsJob = viewModelScope.launch(IO) {
try { try {
val kinds = source.exploreKinds() 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 { _uiState.update {
if (it.expandedId == source.bookSourceUrl) { if (it.expandedId == source.bookSourceUrl) {
it.copy(exploreKinds = kinds, loadingKinds = false) it.copy(
exploreKinds = kinds,
kindDisplayNames = displayNames,
kindValues = values,
loadingKinds = false
)
} else it } else it
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -132,15 +149,34 @@ class ExploreViewModel(application: Application) : BaseViewModel(application) {
fun topSource(bookSource: BookSourcePart) { fun topSource(bookSource: BookSourcePart) {
execute { execute {
val minXh = appDb.bookSourceDao.minOrder exploreRepository.topSource(bookSource)
bookSource.customOrder = minXh - 1
appDb.bookSourceDao.upOrder(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) { fun deleteSource(source: BookSourcePart) {
execute { execute {
SourceHelp.deleteBookSource(source.bookSourceUrl) exploreRepository.deleteSource(source.bookSourceUrl)
} }
} }
@@ -154,7 +190,94 @@ class ExploreViewModel(application: Application) : BaseViewModel(application) {
val selectedGroup: String = "", val selectedGroup: String = "",
val expandedId: String? = null, val expandedId: String? = null,
val exploreKinds: List<ExploreKind> = emptyList(), 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> ) : 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.AnimatedVisibility
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets 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.padding
import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ExitToApp import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.automirrored.filled.HelpOutline 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.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R import io.legado.app.R
import io.legado.app.ui.about.AboutActivity import io.legado.app.ui.about.AboutActivity
import io.legado.app.ui.book.bookmark.AllBookmarkActivity 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.settingItem.SwitchSettingItem
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun MyScreen( fun MyScreen(
viewModel: MyViewModel, viewModel: MyViewModel = koinViewModel(),
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onNavigate: (PrefClickEvent) -> Unit onNavigate: (PrefClickEvent) -> Unit
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior() val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
AppScaffold( AppScaffold(
@@ -81,7 +84,14 @@ fun MyScreen(
title = stringResource(R.string.my), title = stringResource(R.string.my),
actions = { actions = {
IconButton( IconButton(
onClick = { onNavigate(PrefClickEvent.ShowMd("appHelp", "xxx")) } onClick = {
onNavigate(
PrefClickEvent.ShowMd(
title = "",
path = "appHelp"
)
)
}
) {Icon( ) {Icon(
Icons.AutoMirrored.Filled.HelpOutline, null) Icons.AutoMirrored.Filled.HelpOutline, null)
} }
@@ -90,119 +100,123 @@ fun MyScreen(
) )
} }
) { padding -> ) { padding ->
LazyColumn( Column(
modifier = Modifier, modifier = Modifier
contentPadding = adaptiveContentPadding( .fillMaxSize()
top = padding.calculateTopPadding(), .verticalScroll(rememberScrollState())
bottom = 120.dp .padding(
) adaptiveContentPadding(
top = padding.calculateTopPadding(),
bottom = 120.dp
)
)
) { ) {
item { SplicedColumnGroup(
SplicedColumnGroup( title = ""
title = "" ) {
) { WebServiceSettingBlock(
WebServiceSettingBlock( uiState = uiState,
uiState = uiState, onToggleWebService = {
viewModel = viewModel, viewModel.onEvent(PrefClickEvent.ToggleWebService)
onNavigate = onNavigate },
) onNavigate = onNavigate
} )
}
SplicedColumnGroup( SplicedColumnGroup(
title = stringResource(R.string.rule_segment), title = stringResource(R.string.rule_segment),
) { ) {
ClickableSettingItem( ClickableSettingItem(
title = stringResource(R.string.book_source_manage), title = stringResource(R.string.book_source_manage),
description = stringResource(R.string.book_source_manage_desc), description = stringResource(R.string.book_source_manage_desc),
imageVector = Icons.Default.Source, imageVector = Icons.Default.Source,
onClick = { onClick = {
onNavigate( onNavigate(
PrefClickEvent.StartActivity(BookSourceActivity::class.java) PrefClickEvent.StartActivity(BookSourceActivity::class.java)
) )
} }
) )
ClickableSettingItem( ClickableSettingItem(
title = stringResource(R.string.replace_purify), title = stringResource(R.string.replace_purify),
imageVector = Icons.Default.FindReplace, imageVector = Icons.Default.FindReplace,
onClick = { onClick = {
onNavigate( onNavigate(
PrefClickEvent.StartActivity(ReplaceRuleActivity::class.java) PrefClickEvent.StartActivity(ReplaceRuleActivity::class.java)
) )
} }
) )
ClickableSettingItem( ClickableSettingItem(
title = stringResource(R.string.txt_toc_rule), title = stringResource(R.string.txt_toc_rule),
imageVector = Icons.AutoMirrored.Filled.Rule, imageVector = Icons.AutoMirrored.Filled.Rule,
onClick = { onClick = {
onNavigate( onNavigate(
PrefClickEvent.StartActivity(TxtTocRuleActivity::class.java) PrefClickEvent.StartActivity(TxtTocRuleActivity::class.java)
) )
} }
) )
ClickableSettingItem( ClickableSettingItem(
title = stringResource(R.string.dict_rule), title = stringResource(R.string.dict_rule),
imageVector = Icons.AutoMirrored.Filled.LibraryBooks, imageVector = Icons.AutoMirrored.Filled.LibraryBooks,
onClick = { onClick = {
onNavigate( onNavigate(
PrefClickEvent.StartActivity(DictRuleActivity::class.java) PrefClickEvent.StartActivity(DictRuleActivity::class.java)
) )
} }
) )
} }
SplicedColumnGroup( SplicedColumnGroup(
title = stringResource(R.string.other) title = stringResource(R.string.other)
) { ) {
ClickableSettingItem( ClickableSettingItem(
title = stringResource(R.string.setting), title = stringResource(R.string.setting),
imageVector = Icons.Default.Settings, imageVector = Icons.Default.Settings,
onClick = { onClick = {
onOpenSettings() onOpenSettings()
} }
) )
ClickableSettingItem( ClickableSettingItem(
title = stringResource(R.string.bookmark), title = stringResource(R.string.bookmark),
imageVector = Icons.Default.Bookmark, imageVector = Icons.Default.Bookmark,
onClick = { onClick = {
onNavigate(PrefClickEvent.StartActivity(AllBookmarkActivity::class.java)) onNavigate(PrefClickEvent.StartActivity(AllBookmarkActivity::class.java))
} }
) )
ClickableSettingItem( ClickableSettingItem(
title = stringResource(R.string.read_record), title = stringResource(R.string.read_record),
imageVector = Icons.Default.History, imageVector = Icons.Default.History,
onClick = { onClick = {
onNavigate(PrefClickEvent.StartActivity(ReadRecordActivity::class.java)) onNavigate(PrefClickEvent.StartActivity(ReadRecordActivity::class.java))
} }
) )
ClickableSettingItem( ClickableSettingItem(
title = "缓存管理", title = stringResource(R.string.cache_management),
imageVector = Icons.Default.Download, imageVector = Icons.Default.Download,
onClick = { onClick = {
onNavigate(PrefClickEvent.OpenBookCacheManage) onNavigate(PrefClickEvent.OpenBookCacheManage)
} }
) )
ClickableSettingItem( ClickableSettingItem(
title = stringResource(R.string.file_manage), title = stringResource(R.string.file_manage),
imageVector = Icons.Default.Folder, imageVector = Icons.Default.Folder,
onClick = { onClick = {
onNavigate(PrefClickEvent.StartActivity(FileManageActivity::class.java)) onNavigate(PrefClickEvent.StartActivity(FileManageActivity::class.java))
} }
) )
ClickableSettingItem( ClickableSettingItem(
title = stringResource(R.string.about), title = stringResource(R.string.about),
imageVector = Icons.Default.Info, imageVector = Icons.Default.Info,
onClick = { onClick = {
onNavigate(PrefClickEvent.StartActivity(AboutActivity::class.java)) onNavigate(PrefClickEvent.StartActivity(AboutActivity::class.java))
} }
) )
ClickableSettingItem( ClickableSettingItem(
title = stringResource(R.string.exit), title = stringResource(R.string.exit),
imageVector = Icons.AutoMirrored.Filled.ExitToApp, imageVector = Icons.AutoMirrored.Filled.ExitToApp,
onClick = { onClick = {
onNavigate(PrefClickEvent.ExitApp) onNavigate(PrefClickEvent.ExitApp)
} }
) )
}
} }
} }
} }
@@ -212,7 +226,7 @@ fun MyScreen(
@Composable @Composable
fun WebServiceSettingBlock( fun WebServiceSettingBlock(
uiState: MyUiState, uiState: MyUiState,
viewModel: MyViewModel, onToggleWebService: () -> Unit,
onNavigate: (PrefClickEvent) -> Unit onNavigate: (PrefClickEvent) -> Unit
) { ) {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
@@ -225,9 +239,7 @@ fun WebServiceSettingBlock(
}, },
imageVector = Icons.Default.Web, imageVector = Icons.Default.Web,
checked = uiState.isWebServiceRun, checked = uiState.isWebServiceRun,
onCheckedChange = { onCheckedChange = { onToggleWebService() }
viewModel.onEvent(PrefClickEvent.ToggleWebService)
}
) )
AnimatedVisibility( AnimatedVisibility(
@@ -242,7 +254,7 @@ fun WebServiceSettingBlock(
horizontalArrangement = Arrangement.End horizontalArrangement = Arrangement.End
) { ) {
SmallTextButton( SmallTextButton(
text = "复制地址", text = stringResource(R.string.copy_url),
imageVector = Icons.Default.ContentCopy, imageVector = Icons.Default.ContentCopy,
onClick = { onClick = {
onNavigate(PrefClickEvent.CopyUrl(uiState.webServiceAddress)) onNavigate(PrefClickEvent.CopyUrl(uiState.webServiceAddress))
@@ -252,7 +264,7 @@ fun WebServiceSettingBlock(
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
SmallTextButton( SmallTextButton(
text = "浏览器打开", text = stringResource(R.string.open_in_browser),
imageVector = Icons.Default.OpenInBrowser, imageVector = Icons.Default.OpenInBrowser,
onClick = { onClick = {
onNavigate(PrefClickEvent.OpenUrl(uiState.webServiceAddress)) onNavigate(PrefClickEvent.OpenUrl(uiState.webServiceAddress))
@@ -1,8 +1,8 @@
package io.legado.app.ui.main.my package io.legado.app.ui.main.my
import android.app.Application import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.legado.app.base.BaseViewModel
import io.legado.app.constant.EventBus import io.legado.app.constant.EventBus
import io.legado.app.service.WebService import io.legado.app.service.WebService
import io.legado.app.utils.eventBus.FlowEventBus import io.legado.app.utils.eventBus.FlowEventBus
@@ -29,7 +29,7 @@ sealed class PrefClickEvent {
class MyViewModel( class MyViewModel(
application: Application application: Application
) : AndroidViewModel(application) { ) : BaseViewModel(application) {
private val _uiState = MutableStateFlow( private val _uiState = MutableStateFlow(
MyUiState( MyUiState(
@@ -59,9 +59,9 @@ class MyViewModel(
val currentIsRun = _uiState.value.isWebServiceRun val currentIsRun = _uiState.value.isWebServiceRun
if (!currentIsRun) { if (!currentIsRun) {
WebService.start(getApplication()) WebService.start(context)
} else { } else {
WebService.stop(getApplication()) WebService.stop(context)
_uiState.update { it.copy(isWebServiceRun = false, webServiceAddress = "") } _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.MaterialTheme
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R import io.legado.app.R
import io.legado.app.data.entities.RssSource import io.legado.app.data.entities.RssSource
import io.legado.app.ui.login.SourceLoginActivity 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.ui.widget.components.text.AppText
import io.legado.app.utils.openUrl import io.legado.app.utils.openUrl
import io.legado.app.utils.startActivity import io.legado.app.utils.startActivity
import kotlinx.coroutines.flow.collectLatest
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -76,25 +78,24 @@ fun RssScreen(
onOpenRead: (title: String?, origin: String, link: String?, openUrl: String?) -> Unit onOpenRead: (title: String?, origin: String, link: String?, openUrl: String?) -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var sourceToDelete by remember { mutableStateOf<RssSource?>(null) } var sourceToDelete by remember { mutableStateOf<RssSource?>(null) }
val openRss: (RssSource) -> Unit = { rssSource -> LaunchedEffect(viewModel) {
if (rssSource.singleUrl) { viewModel.effects.collectLatest { effect ->
viewModel.getSingleUrl(rssSource) { url -> when (effect) {
if (url.startsWith("http", true)) { is RssEffect.OpenSort -> {
onOpenRead( onOpenSort(effect.sourceUrl, effect.sortUrl, effect.key)
rssSource.sourceName, }
url,
null, is RssEffect.OpenRead -> {
null onOpenRead(effect.title, effect.origin, effect.link, effect.openUrl)
) }
} else {
context.openUrl(url) is RssEffect.OpenExternalUrl -> {
context.openUrl(effect.url)
} }
} }
} else {
onOpenSort(rssSource.sourceUrl, null, null)
} }
} }
@@ -114,7 +115,7 @@ fun RssScreen(
ListScaffold( ListScaffold(
title = stringResource(R.string.rss), title = stringResource(R.string.rss),
state = uiState, state = uiState,
subtitle = uiState.group.ifEmpty { "全部" }, subtitle = uiState.group.ifEmpty { stringResource(R.string.all) },
onBackClick = null, onBackClick = null,
onSearchToggle = { viewModel.toggleSearchVisible(it) }, onSearchToggle = { viewModel.toggleSearchVisible(it) },
onSearchQueryChange = { viewModel.search(it) }, onSearchQueryChange = { viewModel.search(it) },
@@ -172,7 +173,7 @@ fun RssScreen(
RssSourceGridItem( RssSourceGridItem(
modifier = Modifier.animateItem(), modifier = Modifier.animateItem(),
source = source, source = source,
onClick = { openRss(source) }, onClick = { viewModel.openSource(source) },
onTop = { viewModel.topSource(source) }, onTop = { viewModel.topSource(source) },
onEdit = { edit(source) }, onEdit = { edit(source) },
onDelete = { sourceToDelete = source }, onDelete = { sourceToDelete = source },
@@ -4,28 +4,34 @@ import android.app.Application
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.script.rhino.runScriptWithContext import com.script.rhino.runScriptWithContext
import io.legado.app.base.BaseViewModel import io.legado.app.base.BaseViewModel
import io.legado.app.data.appDb
import io.legado.app.data.entities.RssSource 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 io.legado.app.utils.toastOnUi
import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch 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()) private val _uiState = MutableStateFlow(RssUiState())
val uiState = _uiState.asStateFlow() val uiState = _uiState.asStateFlow()
private val searchKeyFlow = MutableStateFlow("")
private val groupFlow = MutableStateFlow("")
private val _effects = MutableSharedFlow<RssEffect>(extraBufferCapacity = 8)
val effects = _effects.asSharedFlow()
init { init {
initGroupData() initGroupData()
@@ -34,7 +40,7 @@ class RssViewModel(application: Application) : BaseViewModel(application) {
private fun initGroupData() { private fun initGroupData() {
viewModelScope.launch { viewModelScope.launch {
appDb.rssSourceDao.flowEnabledGroups() rssRepository.getEnabledGroups()
.flowOn(IO) .flowOn(IO)
.collect { groups -> .collect { groups ->
_uiState.update { state -> state.copy(groups = groups) } _uiState.update { state -> state.copy(groups = groups) }
@@ -45,17 +51,13 @@ class RssViewModel(application: Application) : BaseViewModel(application) {
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
private fun initRssData() { private fun initRssData() {
combine( combine(
_uiState.map { it.searchKey }.distinctUntilChanged(), searchKeyFlow,
_uiState.map { it.group }.distinctUntilChanged() groupFlow
) { searchKey, group -> ) { searchKey, group ->
searchKey to group searchKey to group
} }
.flatMapLatest { (searchKey, group) -> .flatMapLatest { (searchKey, group) ->
when { rssRepository.getEnabledSources(searchKey, group)
searchKey.isNotEmpty() -> appDb.rssSourceDao.flowEnabled(searchKey)
group.isNotEmpty() -> appDb.rssSourceDao.flowEnabledByGroup(group)
else -> appDb.rssSourceDao.flowEnabled()
}
} }
.flowOn(IO) .flowOn(IO)
.onEach { sources -> .onEach { sources ->
@@ -65,14 +67,20 @@ class RssViewModel(application: Application) : BaseViewModel(application) {
} }
fun search(key: String) { fun search(key: String) {
searchKeyFlow.value = key
_uiState.update { it.copy(searchKey = key, isSearch = key.isNotEmpty()) } _uiState.update { it.copy(searchKey = key, isSearch = key.isNotEmpty()) }
} }
fun setGroup(group: String) { fun setGroup(group: String) {
groupFlow.value = group
searchKeyFlow.value = ""
_uiState.update { it.copy(group = group, searchKey = "", isSearch = false) } _uiState.update { it.copy(group = group, searchKey = "", isSearch = false) }
} }
fun toggleSearchVisible(visible: Boolean) { fun toggleSearchVisible(visible: Boolean) {
if (!visible) {
searchKeyFlow.value = ""
}
_uiState.update { _uiState.update {
it.copy(isSearch = visible, searchKey = if (visible) it.searchKey else "") 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) { fun topSource(vararg sources: RssSource) {
execute { execute {
sources.sortBy { it.customOrder } rssRepository.topSources(*sources)
val minOrder = appDb.rssSourceDao.minOrder - 1
val array = Array(sources.size) {
sources[it].copy(customOrder = minOrder - it)
}
appDb.rssSourceDao.update(*array)
} }
} }
fun bottomSource(vararg sources: RssSource) { fun bottomSource(vararg sources: RssSource) {
execute { execute {
sources.sortBy { it.customOrder } rssRepository.bottomSources(*sources)
val maxOrder = appDb.rssSourceDao.maxOrder + 1
val array = Array(sources.size) {
sources[it].copy(customOrder = maxOrder + it)
}
appDb.rssSourceDao.update(*array)
} }
} }
fun del(vararg rssSource: RssSource) { fun del(vararg rssSource: RssSource) {
execute { execute {
SourceHelp.deleteRssSources(rssSource.toList()) rssRepository.deleteSources(rssSource.toList())
} }
} }
fun disable(rssSource: RssSource) { fun disable(rssSource: RssSource) {
execute { execute {
rssSource.enabled = false rssRepository.disableSource(rssSource)
appDb.rssSourceDao.update(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 { execute {
var sortUrl = rssSource.sortUrl resolveSingleUrl(rssSource)
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
}.timeout(10000) }.timeout(10000)
.onSuccess { .onSuccess { url ->
onSuccess.invoke(it) 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 { }.onError {
context.toastOnUi(it.localizedMessage) 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
} }
@@ -57,32 +57,57 @@ import top.yukonga.miuix.kmp.icon.basic.ArrowUpDown
fun ExploreKindMultiTypeItem( fun ExploreKindMultiTypeItem(
kind: ExploreKind, kind: ExploreKind,
sourceUrl: String?, sourceUrl: String?,
activity: AppCompatActivity?, activity: AppCompatActivity? = null,
onOpenUrl: (String) -> Unit, onOpenUrl: (String) -> Unit,
onRefreshKinds: () -> Unit, onRefreshKinds: () -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
backgroundColor: Color = LegadoTheme.colorScheme.surfaceContainer, backgroundColor: Color = LegadoTheme.colorScheme.surfaceContainer,
isMiuix: Boolean, isMiuix: Boolean,
useCase: ExploreKindUiUseCase displayNameOverride: String? = null,
valueOverride: String? = null,
onValueChange: ((String) -> Unit)? = null,
onRunAction: (() -> Unit)? = null,
useCase: ExploreKindUiUseCase? = null
) { ) {
val scope = rememberCoroutineScope() 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) } var displayName by remember(sourceUrl, kind.title, kind.viewName) { mutableStateOf(kind.title) }
LaunchedEffect(sourceUrl, kind.title, kind.viewName) { LaunchedEffect(displayNameOverride, sourceUrl, kind.title, kind.viewName, useCase) {
displayName = useCase.resolveDisplayName(kind, sourceUrl, infoMap) displayName = displayNameOverride
?: useCase?.resolveDisplayName(kind, sourceUrl, infoMap)
?: kind.title
} }
fun runAction(action: String?) { fun runAction(action: String?) {
scope.launch(IO) { if (action.isNullOrBlank()) return
useCase.executeAction( if (onRunAction != null) {
action = action, onRunAction()
title = kind.title, } else {
sourceUrl = sourceUrl, val useCase = useCase ?: return
infoMap = infoMap, scope.launch(IO) {
activity = activity, useCase.executeAction(
onRefreshKinds = onRefreshKinds 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 -> { ExploreKind.Type.text -> {
var value by remember(sourceUrl, kind.title) { 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) } var actionJob by remember(sourceUrl, kind.title) { mutableStateOf<Job?>(null) }
ExploreKindCompactTextField( ExploreKindCompactTextField(
value = value, value = value,
onValueChange = { newValue -> onValueChange = { newValue ->
value = newValue value = newValue
infoMap?.let { updateValue(newValue)
it[kind.title] = newValue
it.saveNow()
}
if (!kind.action.isNullOrBlank()) { if (!kind.action.isNullOrBlank()) {
actionJob?.cancel() actionJob?.cancel()
actionJob = scope.launch { actionJob = scope.launch {
@@ -164,7 +191,8 @@ fun ExploreKindMultiTypeItem(
val left = kind.style().layout_justifySelf != "right" val left = kind.style().layout_justifySelf != "right"
var char by remember(sourceUrl, kind.title, kind.default, kind.chars) { var char by remember(sourceUrl, kind.title, kind.default, kind.chars) {
mutableStateOf( mutableStateOf(
infoMap?.get(kind.title) valueOverride
?: infoMap?.get(kind.title)
?.takeUnless { it.isEmpty() } ?.takeUnless { it.isEmpty() }
?: (kind.default ?: chars.first()).also { ?: (kind.default ?: chars.first()).also {
infoMap?.let { map -> 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" val text = if (left) "$char$displayName" else "$displayName$char"
ExploreKindItem( ExploreKindItem(
kind = kind, kind = kind,
@@ -182,10 +215,7 @@ fun ExploreKindMultiTypeItem(
val currentIndex = chars.indexOf(char) val currentIndex = chars.indexOf(char)
val nextIndex = if (currentIndex < 0) 0 else (currentIndex + 1) % chars.size val nextIndex = if (currentIndex < 0) 0 else (currentIndex + 1) % chars.size
char = chars.getOrElse(nextIndex) { "" } char = chars.getOrElse(nextIndex) { "" }
infoMap?.let { map -> updateValue(char)
map[kind.title] = char
map.saveNow()
}
runAction(kind.action) runAction(kind.action)
}, },
modifier = modifier, modifier = modifier,
@@ -209,7 +239,8 @@ fun ExploreKindMultiTypeItem(
} }
var selected by remember(sourceUrl, kind.title, kind.default, kind.chars) { var selected by remember(sourceUrl, kind.title, kind.default, kind.chars) {
mutableStateOf( mutableStateOf(
infoMap?.get(kind.title) valueOverride
?: infoMap?.get(kind.title)
?.takeUnless { it.isEmpty() } ?.takeUnless { it.isEmpty() }
?: (kind.default ?: chars.first()).also { ?: (kind.default ?: chars.first()).also {
infoMap?.let { map -> 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) } var showSelector by remember(sourceUrl, kind.title) { mutableStateOf(false) }
Box(modifier = modifier) { Box(modifier = modifier) {
ExploreKindItem( ExploreKindItem(
@@ -249,10 +285,7 @@ fun ExploreKindMultiTypeItem(
showSelector = false showSelector = false
if (selected != option) { if (selected != option) {
selected = option selected = option
infoMap?.let { map -> updateValue(option)
map[kind.title] = option
map.saveNow()
}
runAction(kind.action) 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.BaseSource
import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.rule.ExploreKind 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.ui.login.SourceLoginJsExtensions
import io.legado.app.utils.InfoMap import io.legado.app.utils.InfoMap
import kotlinx.coroutines.Dispatchers.IO 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( suspend fun executeAction(
action: String?, action: String?,
title: String, title: String,
@@ -1466,4 +1466,9 @@
<string name="subtitle_margin">次行距离</string> <string name="subtitle_margin">次行距离</string>
<string name="heading_spacing">标题段距</string> <string name="heading_spacing">标题段距</string>
<string name="follow_read_background">跟随阅读背景</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> </resources>
@@ -1261,4 +1261,9 @@
<string name="input_value_range">輸入數值(%1$d-%2$d</string> <string name="input_value_range">輸入數值(%1$d-%2$d</string>
<string name="miuix_monet">Miuix Monet</string> <string name="miuix_monet">Miuix Monet</string>
<string name="miuix_monet_summary">為 Miuix 使用 Monet 演算法生成配色。目前主題為動態主題且系統為 Android 12 或以上時,會優先使用系統取色;否則使用下方種子色。</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> </resources>
@@ -1264,4 +1264,9 @@
<string name="subtitle_scale">次行縮放</string> <string name="subtitle_scale">次行縮放</string>
<string name="subtitle_margin">次行距離</string> <string name="subtitle_margin">次行距離</string>
<string name="heading_spacing">標題段距</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> </resources>
+5
View File
@@ -1473,4 +1473,9 @@
<string name="subtitle_margin">Subtitle Margin</string> <string name="subtitle_margin">Subtitle Margin</string>
<string name="heading_spacing">Heading Spacing</string> <string name="heading_spacing">Heading Spacing</string>
<string name="follow_read_background">Follow Background</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> </resources>