diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 140ac226b..a2ce29d65 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -11,7 +11,10 @@ "Bash(./gradlew :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/\")" + "Bash(cp -r \"D:/AndroidPrj/legado-with-MD3/.codex/skills/legado-compose-review/references\" \"D:/AndroidPrj/legado-with-MD3/.claude/skills/legado-compose-review/\")", + "WebSearch", + "Bash(./gradlew assembleAppDebug)", + "Bash(git checkout *)" ] } } diff --git a/.claude/skills/legado-compose-migration/SKILL.md b/.claude/skills/legado-compose-migration/SKILL.md new file mode 100644 index 000000000..8d32e067f --- /dev/null +++ b/.claude/skills/legado-compose-migration/SKILL.md @@ -0,0 +1,73 @@ +--- +name: legado-compose-migration +description: Guide Legado Android UI migration from XML/View/RecyclerView/DialogFragment screens to Jetpack Compose and Material 3, and guide new Compose-first screens using standard Android architecture. Use when creating, migrating, rewriting, reviewing, or planning a Legado screen, Activity, Fragment, dialog, adapter, navigation destination, or settings page, especially when MainActivity navigation, MVI/UDF, StateFlow/SharedFlow, Koin ViewModels, domain/usecase boundaries, edge-to-edge insets, predictive back, or existing Compose component conventions matter. +--- + +# Legado Compose Migration + +## Overview + +Migrate one UI surface at a time, or create one new Compose destination at a time. Preserve behavior first for migrations; for newly created screens, prefer standard modern Android architecture over mixed legacy patterns. Use the project's existing `MainActivity` navigation, `BaseComposeActivity` compatibility hosts, `*Screen`, `*Contract`, `*ViewModel`, `StateFlow`, `SharedFlow`, Koin, theme, and widget patterns. + +Before editing, inspect the target View implementation if one exists, `MainActivity` navigation when adding a destination, and at least two nearby migrated Compose screens. For concrete project patterns, including current Compose state/performance rules, read `references/project-patterns.md`. + +## Workflow + +1. State assumptions and success criteria. + - Name the exact screen or destination being created/migrated. + - For migrations, define behavior that must remain unchanged: inputs, result codes, navigation, menu actions, dialogs/sheets, list selection, refresh, persistence, and event bus behavior. + - For new screens, define the route owner, UI state, events/effects, domain/usecase boundary, and verification target. + - If the target mixes UI and business logic heavily, keep the migration surgical and defer deeper domain cleanup unless needed. + +2. Map the old surface. + - For migrations, read the Activity/Fragment, XML layouts, adapters, menu XML, dialogs, result launchers, and ViewModel. + - For new screens, read `MainActivity`, nearby route screens, the relevant ViewModel/usecase/repository patterns, and shared UI components. + - List UI state, user intents, one-shot effects, and external side effects. + - Identify reusable Compose components under `ui/widget/components` before creating new components. + +3. Choose the minimal migration shape. + - For new Compose-first screens, add a `MainActivity` navigation destination instead of creating a standalone Activity. + - Use `BaseComposeActivity` for full-screen Activity migrations only when a legacy Activity must remain as an entry point. + - Keep existing Activity Result APIs, `Intent` extras, permission flows, and Android framework calls in the host/compatibility Activity. + - If an unreworked View screen still starts the migrated screen with `Intent`, keep the old Activity only as a compatibility host that parses legacy extras and delegates to the Compose screen or `MainActivity` route boundary. + - Put renderable state in `UiState`, user actions in `Intent`, and one-shot navigation/framework work in `Effect`. + - For new screens, use standard Android/Compose architecture: UDF/MVI-style state hoisting, lifecycle-aware Flow collection, ViewModel-owned state, repository/usecase boundaries, and UI free of business logic. + - Ensure the new screen handles edge-to-edge correctly: use `Scaffold` (which respects `WindowInsets` automatically) or apply `Modifier.windowInsetsPadding(WindowInsets.safeDrawing)` on the outermost container. Do not carry over `fitsSystemWindows` / manual padding patterns from XML. + - Use mixed legacy patterns only as an integration boundary for unreworked View screens or existing framework contracts. + - Use existing repositories/usecases when they already fit; introduce new domain/usecase classes when a new screen needs clean business boundaries or when a migration would otherwise duplicate or entangle business logic. + +4. Implement by layers. + - Create or update `FeatureContract.kt` first for `UiState`, `Intent`, `Effect`, dialog/sheet models, and menu action enums. + - Update `FeatureViewModel.kt` to expose `uiState: StateFlow` and `effects: SharedFlow`, with a single `onIntent(...)` entry point unless the existing feature has a simpler established pattern. + - Create `FeatureScreen.kt` as a stateless route-level composable: `state`, callbacks, and `onIntent`. + - Wire new Compose destinations through `MainActivity` route handling; update a retained Activity only when legacy `Intent` compatibility is required. + - Collect state with `collectAsStateWithLifecycle()` and collect effects in `LaunchedEffect(Unit)` from the route or compatibility host. + - Register new ViewModels in `di/appModule.kt` with `viewModelOf(::FeatureViewModel)` unless parameters require `viewModel { ... }`. + +5. Remove only obsolete migration artifacts. + - Delete XML layouts, adapters, menu resources, binding fields, and imports only when the migrated screen no longer references them. + - Do not refactor unrelated View screens or shared utilities. + +6. Verify. + - Prefer the smallest Gradle check that compiles the touched app code, usually `.\gradlew.bat :app:compileAppDebugKotlin`. + - If resources, manifests, or XML deletion are involved, run `.\gradlew.bat :app:assembleAppDebug` when feasible. + - For behavior-heavy changes, add or update focused tests only where the project already has a practical test seam. + +## Boundaries + +- Keep Compose functions side-effect-light. Use `LaunchedEffect` for collecting effects and use callbacks for user actions. +- Do not pass `Activity`, `View`, binding objects, or mutable domain entities deep into composables unless an existing local pattern requires it. +- Prefer project theme/components: `LegadoTheme`, `AppScaffold`, `ListScaffold`, `AppAlertDialog`, `AppModalBottomSheet`, `RoundDropdownMenu`, top bar helpers, setting items, cover components, and list utilities. +- Prefer `StateFlow`/`SharedFlow` over `LiveData` for newly migrated Compose surfaces. +- Annotate `UiState` data classes and UI-facing model wrappers with `@Stable` when they hold collections or entity data passed to composables. Kotlin 2.x strong skipping is on by default, but explicit `@Stable` provides the strongest compiler guarantee and helps document contract boundaries. +- When using `FeatureIntent` for MVI user actions, distinguish it from Android `Intent` extras and launch APIs in names, comments, and explanations where both appear. +- For new Compose-first screens, do not copy View-era shortcuts such as UI logic in Activity/Fragment, direct binding-like mutable UI state, adapter-owned state, or Activity-context business operations. +- Keep one-off Android actions out of `UiState`: navigation, file opening, clipboard, dialogs implemented as Android DialogFragments, result launchers, permission requests, and callbacks that require host context should be `Effect`s handled by `MainActivity` route handling or a compatibility Activity. +- Prefer `MainActivity` navigation for new Compose destinations. Treat standalone Activities for migrated screens as legacy entry points only when existing View code still depends on `Intent` navigation. +- For edge-to-edge: this project targets SDK 37, so edge-to-edge is enforced on Android 15+. Use Material 3 `Scaffold` insets or `WindowInsets.safeDrawing` / `safeContent` padding; ensure migrated screens draw behind system bars via `Modifier.windowInsetsPadding()` or top-level scaffold padding rather than manual hardcoded offsets. Migrated screens that relied on `fitsSystemWindows` in XML must be updated. +- For predictive back: New Compose destinations should work with the predictive back gesture (enabled by default in Navigation 3). When a screen requires back confirmation (unsaved changes, selection mode), use `BackHandler` to intercept and route through `FeatureIntent.BackPressed`. +- Keep existing Chinese string resources and localization behavior; add strings to resources when user-facing text is new. + +## Reference + +Read `references/project-patterns.md` when implementing or reviewing a migration. It contains project-specific examples, file placement rules, state/effect conventions, and verification commands. diff --git a/.claude/skills/legado-compose-migration/references/project-patterns.md b/.claude/skills/legado-compose-migration/references/project-patterns.md new file mode 100644 index 000000000..d09162952 --- /dev/null +++ b/.claude/skills/legado-compose-migration/references/project-patterns.md @@ -0,0 +1,194 @@ +# Legado Compose Migration Project Patterns + +## Existing Shape + +- App module: `app`. +- Main source root: `app/src/main/java/io/legado/app`. +- UI package: `ui/...`, usually grouped by feature. +- Data package: `data/dao`, `data/entities`, `data/repository`. +- Domain package: `domain/gateway`, `domain/model`, `domain/repository`, `domain/usecase`. +- DI: `di/appModule.kt`, using Koin `singleOf`, `viewModelOf`, and parameterized `viewModel { ... }`. +- Main navigation: `ui/main/MainActivity.kt`, using Navigation3 `NavKey`, `rememberNavBackStack`, `entryProvider`, and `NavDisplay`, plus legacy route extras such as `EXTRA_START_ROUTE`. +- Base Compose host: `base/BaseComposeActivity.kt`, which wraps `Content()` in `AppTheme`, configures system bars, locale/font, background image, and LiveBus recreation events. +- Compose dependencies are already enabled in `app/build.gradle.kts`; do not add new UI libraries unless the target screen truly requires one. +- `kotlinx.collections.immutable` is available for Compose-facing screen state. Prefer `ImmutableList`, `ImmutableSet`, and `ImmutableMap` at `UiState` boundaries when collection state is passed to composables and changes often. + +## Files to Inspect Before Migrating + +Inspect these as local examples, not as APIs to copy blindly: + +- `ui/book/info/BookInfoActivity.kt` +- `ui/book/info/BookInfoContract.kt` +- `ui/book/info/BookInfoViewModel.kt` +- `ui/book/info/BookInfoScreen.kt` +- `ui/book/search/SearchContract.kt` +- `ui/book/search/SearchViewModel.kt` +- `ui/book/search/SearchScreen.kt` +- `ui/main/MainActivity.kt` +- `ui/main/MainScreen.kt` +- `ui/widget/components/AppScaffold.kt` +- `ui/widget/components/list/ListScaffold.kt` +- `ui/theme/LegadoTheme.kt` +- `ui/theme/AppTheme.kt` + +## Recommended Feature Layout + +For a migrated feature, prefer colocated files: + +```text +ui/// + FeatureActivity.kt # only for legacy Intent compatibility or full-screen migration host + FeatureContract.kt + FeatureViewModel.kt + FeatureScreen.kt + FeatureSheets.kt # only if sheets are substantial + FeatureDialogs.kt # only if dialogs are substantial +``` + +Use the smallest set of files. Do not split files just to match this shape. + +## MVI and UDF Rules + +Use this shape for behavior-heavy screens: + +```kotlin +@Stable +data class FeatureUiState( + val isLoading: Boolean = false, + val items: ImmutableList = persistentListOf(), + val dialog: FeatureDialog? = null, + val sheet: FeatureSheet = FeatureSheet.None, +) + +sealed interface FeatureIntent { + data object BackPressed : FeatureIntent + data object Refresh : FeatureIntent + data class ItemClick(val id: Long) : FeatureIntent +} + +sealed interface FeatureEffect { + data object Finish : FeatureEffect + data class OpenDetail(val id: Long) : FeatureEffect +} +``` + +ViewModel rules: + +- Keep `_uiState = MutableStateFlow(FeatureUiState())` private and expose `uiState = _uiState.asStateFlow()`. +- Keep `_effects = MutableSharedFlow(extraBufferCapacity = 8)` private and expose `effects = _effects.asSharedFlow()`. +- Prefer `fun onIntent(intent: FeatureIntent)` as the UI entry point. +- Treat `FeatureIntent` as an MVI/user-action type. When Android `Intent` launch/extras are also involved, name variables and explanations clearly enough that the two concepts are not confused. +- Use `_uiState.update { it.copy(...) }` for state changes. +- Emit one-shot work through effects, not booleans in state. +- Keep cached current entities private in the ViewModel when existing project behavior needs mutation or incremental sync, but publish immutable render state. +- Annotate `UiState` data classes with `@Stable` to give the Compose compiler the strongest stability guarantee. This is especially important when `UiState` fields include collections or entity types that come from non-Compose modules. +- For collection-heavy `UiState`, convert DAO/repository `List`/`Set`/`Map` values to `kotlinx.collections.immutable` at the ViewModel/UI-state boundary with `toImmutableList()`, `toImmutableSet()`, or `toImmutableMap()`. Do not force repository, DAO, or domain APIs to use persistent collections unless the domain contract truly benefits. +- Keep internal flow pipelines free to use normal Kotlin collections for sorting, grouping, and persistence work; the immutable collection rule is primarily for values exposed to Compose. +- Use existing `BaseViewModel.execute { ... }.onSuccess { ... }.onError { ... }` when the surrounding ViewModel already uses that pattern. + +Activity rules: + +- Prefer `MainActivity` as the owner of Compose-first navigation. Add a Navigation3 route/key and `entryProvider` entry there for new destinations. +- Extend `BaseComposeActivity` for migrated Activity screens only when a standalone Activity remains necessary. +- Keep a legacy Activity for a migrated screen only when unreworked View code still calls it through `Intent`; in that case, make the Activity a thin compatibility host that translates extras/results and delegates to Compose state/effects. +- Keep Activity Result launchers, file openers, clipboard, permission APIs, Android DialogFragments, and framework navigation in the Activity. +- In `Content()`, collect state with `collectAsStateWithLifecycle().value`. +- Collect effects in `LaunchedEffect(Unit) { viewModel.effects.collectLatest { ... } }`. +- Pass only `state` and callbacks into the screen. +- Edge-to-edge: Since target SDK ≥ 35, edge-to-edge is enforced. In `BaseComposeActivity`, `enableEdgeToEdge()` is called before `setContent`. Ensure screen-level composables apply `Modifier.windowInsetsPadding(WindowInsets.safeDrawing)` on the outermost non-scaffold container, or rely on Material 3 `Scaffold` (which handles `WindowInsets` automatically). Do not use manual `statusBarsPadding()` / `navigationBarsPadding()` on top of scaffold-level insets — double-padding is a common pitfall. + +Navigation rules: + +- For new Compose-first destinations, add the route to `MainActivity` instead of adding a new Activity. +- Follow `MainActivity`'s current route style: serializable `NavKey` routes for in-process navigation and launcher `Intent` helper methods only when external or legacy callers need a stable entry point. +- Keep legacy extra names stable when replacing an old Activity entry point. +- When a compatibility Activity is retained, let it translate old `Intent` inputs/results; do not make it the source of truth for navigation or business behavior. +- Predictive back: Navigation 3 enables predictive back by default on Android 14+. New composable destinations work with it automatically. When a screen needs back confirmation (unsaved changes, selection mode, add-to-shelf prompt), use `BackHandler(enabled = ...) { onIntent(FeatureIntent.BackPressed) }` to intercept and let the ViewModel decide — don't bypass ViewModel back logic. + +Screen rules: + +- Make `FeatureScreen(state, onIntent, ...)` stateless for business state. +- Use `remember` and `rememberSaveable` only for local UI affordances such as menu expansion, scroll state, transient animation state, and text field drafts when committing through an intent. +- Use `rememberSaveable` for user-driven transient targets that should survive recreation, such as a delete-confirmation item ID or URL. Store only the stable ID in saved state and resolve the current entity from `UiState`. +- Use `BackHandler` to route back actions through `FeatureIntent.BackPressed` when ViewModel decisions matter. +- Keep expensive derived values in `remember(key)` or ViewModel state when they depend on repository data. +- Prefer `LazyColumn`, `LazyRow`, stable keys, and existing fast-scroller/list components for adapter migrations. For heterogeneous lazy lists or grids, provide `contentType` in addition to `key`. +- In long-lived `LaunchedEffect` collectors, wrap changing callbacks, `Context`-dependent operations, or lambdas from parents with `rememberUpdatedState` when the effect should not restart. +- Hoist state only to the lowest common owner that reads and writes it. Move branch-only ViewModel lookup and Flow collection into the branch or a small child composable instead of collecting at a high-level route. +- Avoid UI-state feedback loops such as `LaunchedEffect(uiState.items) { viewModel.pruneSelection(...) }`. Prefer deriving consistency inside the ViewModel with `combine(...)`, or reduce the state as part of the flow that produces the data. + +## Current Compose Performance Notes + +- Kotlin 2.x + Compose compiler plugin (`org.jetbrains.kotlin.plugin.compose`) enables **strong skipping by default**. This means composable functions with unstable parameters CAN still be skipped if their arguments are equal by `equals()`. However, ordinary Kotlin `List`, `Set`, and `Map` are still **unstable types** — passing them as parameters prevents the compiler from inferring stability, so changing a list reference still triggers recomposition of callers even with strong skipping. +- Use `@Stable` on `UiState` data classes and UI model wrappers to give the Compose compiler the strongest stability guarantee. `@Stable` tells the compiler: "if `equals()` says two instances are the same, the UI hasn't changed." This is critical for data classes holding collections. +- Use `kotlinx.collections.immutable` (`ImmutableList`, `ImmutableSet`, `ImmutableMap`) for render state that crosses into Compose. These are recognized as stable by the Compose compiler. Do not mechanically replace every temporary collection, Room query result, or internal mutable accumulator. +- If data entities come from modules where the Compose compiler plugin is not applied, consider a `@Stable` UI model wrapper when the entity is passed deeply through composables and causes measurable recomposition cost. +- Treat `SnapshotStateList` / `SnapshotStateMap` as UI-owned mutable state, not as a default ViewModel `UiState` transport type. + +## Clean Architecture Boundaries + +This project is mid-migration, so use Clean Architecture pragmatically. For newly created screens, default to standard modern Android architecture and avoid mixed legacy patterns unless they are needed to interoperate with View screens that have not been rewritten. + +- For new screens, keep UI, presentation, domain, and data boundaries explicit: Compose renders state, ViewModel owns state and intent handling, usecases hold reusable business actions, repositories mediate data access. +- For new screens, do not place business rules in Activity/Fragment or composables. +- For new screens, do not read DAOs directly from UI/presentation unless the project already has no reasonable repository/usecase boundary and adding one would be disproportionate. +- For migrations, use existing DAOs/repositories directly if the old ViewModel already does and the migration is UI-only. +- Prefer existing `domain/usecase` classes for reusable business actions such as cache, delete, group update, startup maintenance, reading progress, and WebDAV flows. +- Add a usecase when a new screen has meaningful business rules, when logic would otherwise be duplicated across features, or when a UI migration needs to extract meaningful business rules from an Activity/adapter. +- Keep UI-only formatting and Compose layout decisions out of domain. +- Keep entity-to-display mapping near the UI or ViewModel unless it is reused business language. + +## Compose Components and Theme + +Prefer project components before raw Material widgets: + +- Scaffold/top bars: `AppScaffold`, `GlassTopAppBar...`, `DynamicTopAppBar`, `TopBarActionButton`, `TopBarNavigationButton`. +- Lists: `ListScaffold`, `TopFloatingStickyItem`, `VerticalFastScroller`, `SelectionBottomBar`. +- Dialog/sheet: `AppAlertDialog`, `AppModalBottomSheet`, `OptionSheet`, text list input components. +- Settings: `ClickableSettingItem`, `SwitchSettingItem`, `SliderSettingItem`, `ListSettingItem`, `SettingCard`. +- Buttons/chips: `AppIconButton`, `SmallIconButton`, `SmallTextButton`, `ToggleChip`, `AlertButton`. +- Covers/images: `CoilBookCover`, `BookshelfCover`, `buildCoverImageRequest`; use Coil for Compose image loading. +- Theme: `LegadoTheme.colorScheme`, `LegadoTheme.typography`, `ThemeResolver`, `ProvideThemeOverride` where existing feature behavior needs theme override. + +Avoid introducing a second visual system. If a raw Material 3 component is used, style it from `LegadoTheme`. + +## Migration Checklist + +Before editing: + +- Identify old XML layouts, ViewBinding fields, adapters, decorations, menu XML, dialogs, and result contracts. +- Capture behavior: empty/loading/error states, refresh, search, sort, selection, long click, swipe, menu actions, back behavior, result codes, events, and persisted preferences. +- Decide whether the task is UI-only or needs domain extraction. +- For new screens, explicitly decide the presentation/domain/data boundary before coding; do not default to legacy mixed architecture. + +During editing: + +- Create/update `Contract`, then `ViewModel`, then `Screen`, then `MainActivity` route or compatibility host. +- For new Compose destinations, wire navigation through `MainActivity` unless a legacy `Intent` caller must remain supported. +- When retaining an Activity for compatibility, keep it small: parse legacy extras, collect effects, bridge result codes, and avoid placing feature business logic there. +- Verify edge-to-edge: ensure the screen draws edge-to-edge without overlapping system bars. Use `Scaffold` or `Modifier.windowInsetsPadding(WindowInsets.safeDrawing)`. Remove any `fitsSystemWindows`-based margin hacks from the old layout. +- Reuse existing string resources and add new resources for new user-facing text. +- Register the ViewModel in `di/appModule.kt`. +- Remove only resources made obsolete by this migration. +- Keep existing public intent extra names and result codes unless explicitly changing an API. + +Verification: + +- Run `.\gradlew.bat :app:compileAppDebugKotlin` for Kotlin-only changes. +- Run `.\gradlew.bat :app:assembleAppDebug` when resources, manifests, XML deletion, or generated bindings are affected. +- If a screen uses Room queries or migration-sensitive repositories, run the relevant existing Android/unit tests if practical. +- For manual verification, open the migrated screen and compare core flows against the old behavior list. + +## Common Pitfalls + +- Do not put navigation or Activity launchers inside composables. +- Do not store one-time navigation as nullable fields in `UiState` unless the existing local pattern already does; prefer `SharedFlow` effects. +- Do not keep adapter selection state split between old adapter and new Compose state. +- Do not delete XML/menu resources until all references and generated binding imports are gone. +- Do not add speculative domain layers for a single migrated screen. +- Do not use mixed architecture for new screens unless compatibility with unreworked View UI requires it. +- Do not make a migrated screen's retained Activity the primary navigation model when `MainActivity` can own the Compose destination. +- Do not bypass `BaseComposeActivity` unless the screen must remain inside a Fragment or legacy host. +- Do not replace existing event bus or service interactions as part of a UI migration unless required for correctness. +- Do not forget edge-to-edge: XML `fitsSystemWindows` does not carry over to Compose. Rely on `Scaffold` or `Modifier.windowInsetsPadding()` instead. +- Do not forget `@Stable` on `UiState` data classes — missing annotations cause unnecessary recomposition, especially when state contains collections. diff --git a/.claude/skills/legado-compose-review/SKILL.md b/.claude/skills/legado-compose-review/SKILL.md index 3b016d859..0bce834f5 100644 --- a/.claude/skills/legado-compose-review/SKILL.md +++ b/.claude/skills/legado-compose-review/SKILL.md @@ -1,6 +1,6 @@ --- 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. +description: Review existing Legado Jetpack Compose code for architecture, behavior, maintainability, and project convention issues. Use when auditing, reviewing, inspecting, evaluating, or finding 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, edge-to-edge insets, @Stable stability annotations, predictive back, and View-era mixed-pattern drift. --- # Legado Compose Review @@ -42,8 +42,8 @@ Read `references/review-checklist.md` for the project-specific checklist and sev ## 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. +- **P0/P1**: behavior breakage, lost navigation/result behavior, unsafe lifecycle collection, stale state, data corruption, crashes, compatibility entry points that no longer work, or content obscured by system bars (edge-to-edge breakage). +- **P2**: architecture drift that will cause duplicated logic, hard-to-test behavior, recomposition bugs, incorrect ownership of state/effects, or missing `@Stable` annotations that cause measurable recomposition. - **P3**: convention drift, maintainability issues, weak naming, missing previews/tests, or small cleanup that should not block behavior. ## Boundaries @@ -53,6 +53,8 @@ Read `references/review-checklist.md` for the project-specific checklist and sev - 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. +- For edge-to-edge: expect new and migrated Compose screens to handle system bar insets correctly — `Scaffold` or `Modifier.windowInsetsPadding()`, not hardcoded padding or XML `fitsSystemWindows` hacks. Target SDK 37 enforces edge-to-edge on Android 15+. +- For stability: expect `@Stable` on `UiState` data classes and UI model wrappers. Flag unannotated state classes that hold collections or entity types from non-Compose modules, as they cause unnecessary recomposition. ## Reference diff --git a/.codex/skills/legado-compose-migration/SKILL.md b/.codex/skills/legado-compose-migration/SKILL.md index 44cd62edb..6e438cd45 100644 --- a/.codex/skills/legado-compose-migration/SKILL.md +++ b/.codex/skills/legado-compose-migration/SKILL.md @@ -9,7 +9,7 @@ description: Guide Legado Android UI migration from XML/View/RecyclerView/Dialog Migrate one UI surface at a time, or create one new Compose destination at a time. Preserve behavior first for migrations; for newly created screens, prefer standard modern Android architecture over mixed legacy patterns. Use the project's existing `MainActivity` navigation, `BaseComposeActivity` compatibility hosts, `*Screen`, `*Contract`, `*ViewModel`, `StateFlow`, `SharedFlow`, Koin, theme, and widget patterns. -Before editing, inspect the target View implementation if one exists, `MainActivity` navigation when adding a destination, and at least two nearby migrated Compose screens. For concrete project patterns, read `references/project-patterns.md`. +Before editing, inspect the target View implementation if one exists, `MainActivity` navigation when adding a destination, and at least two nearby migrated Compose screens. For concrete project patterns, including current Compose state/performance rules, read `references/project-patterns.md`. ## Workflow diff --git a/.codex/skills/legado-compose-migration/references/project-patterns.md b/.codex/skills/legado-compose-migration/references/project-patterns.md index afaba4f58..c9993d3d3 100644 --- a/.codex/skills/legado-compose-migration/references/project-patterns.md +++ b/.codex/skills/legado-compose-migration/references/project-patterns.md @@ -11,6 +11,7 @@ - Main navigation: `ui/main/MainActivity.kt`, using Navigation3 `NavKey`, `rememberNavBackStack`, `entryProvider`, and `NavDisplay`, plus legacy route extras such as `EXTRA_START_ROUTE`. - Base Compose host: `base/BaseComposeActivity.kt`, which wraps `Content()` in `AppTheme`, configures system bars, locale/font, background image, and LiveBus recreation events. - Compose dependencies are already enabled in `app/build.gradle.kts`; do not add new UI libraries unless the target screen truly requires one. +- `kotlinx.collections.immutable` is available for Compose-facing screen state. Prefer `ImmutableList`, `ImmutableSet`, and `ImmutableMap` at `UiState` boundaries when collection state is passed to composables and changes often. ## Files to Inspect Before Migrating @@ -79,6 +80,8 @@ ViewModel rules: - Use `_uiState.update { it.copy(...) }` for state changes. - Emit one-shot work through effects, not booleans in state. - Keep cached current entities private in the ViewModel when existing project behavior needs mutation or incremental sync, but publish immutable render state. +- For collection-heavy `UiState`, convert DAO/repository `List`/`Set`/`Map` values to `kotlinx.collections.immutable` at the ViewModel/UI-state boundary with `toImmutableList()`, `toImmutableSet()`, or `toImmutableMap()`. Do not force repository, DAO, or domain APIs to use persistent collections unless the domain contract truly benefits. +- Keep internal flow pipelines free to use normal Kotlin collections for sorting, grouping, and persistence work; the immutable collection rule is primarily for values exposed to Compose. - Use existing `BaseViewModel.execute { ... }.onSuccess { ... }.onError { ... }` when the surrounding ViewModel already uses that pattern. Activity rules: @@ -102,9 +105,20 @@ Screen rules: - Make `FeatureScreen(state, onIntent, ...)` stateless for business state. - Use `remember` and `rememberSaveable` only for local UI affordances such as menu expansion, scroll state, transient animation state, and text field drafts when committing through an intent. +- Use `rememberSaveable` for user-driven transient targets that should survive recreation, such as a delete-confirmation item ID or URL. Store only the stable ID in saved state and resolve the current entity from `UiState`. - Use `BackHandler` to route back actions through `FeatureIntent.BackPressed` when ViewModel decisions matter. - Keep expensive derived values in `remember(key)` or ViewModel state when they depend on repository data. -- Prefer `LazyColumn`, `LazyRow`, stable keys, and existing fast-scroller/list components for adapter migrations. +- Prefer `LazyColumn`, `LazyRow`, stable keys, and existing fast-scroller/list components for adapter migrations. For heterogeneous lazy lists or grids, provide `contentType` in addition to `key`. +- In long-lived `LaunchedEffect` collectors, wrap changing callbacks, `Context`-dependent operations, or lambdas from parents with `rememberUpdatedState` when the effect should not restart. +- Hoist state only to the lowest common owner that reads and writes it. Move branch-only ViewModel lookup and Flow collection into the branch or a small child composable instead of collecting at a high-level route. +- Avoid UI-state feedback loops such as `LaunchedEffect(uiState.items) { viewModel.pruneSelection(...) }`. Prefer deriving consistency inside the ViewModel with `combine(...)`, or reduce the state as part of the flow that produces the data. + +## Current Compose Performance Notes + +- Kotlin 2.x enables strong skipping by default, but ordinary Kotlin `List`, `Set`, and `Map` are still unstable to Compose. This means high-frequency screen state with normal collections can still widen recomposition work. +- Use immutable collections for render state that crosses into Compose. Do not mechanically replace every temporary collection, Room query result, or internal mutable accumulator. +- If data entities come from modules where the Compose compiler does not run, consider a stable UI model wrapper when the entity is passed deeply through composables and causes measurable recomposition cost. +- Treat `SnapshotStateList` / `SnapshotStateMap` as UI-owned mutable state, not as a default ViewModel `UiState` transport type. ## Clean Architecture Boundaries diff --git a/.codex/skills/legado-compose-review/SKILL.md b/.codex/skills/legado-compose-review/SKILL.md index 3b016d859..8d7908f6e 100644 --- a/.codex/skills/legado-compose-review/SKILL.md +++ b/.codex/skills/legado-compose-review/SKILL.md @@ -9,7 +9,7 @@ description: Review existing Legado Jetpack Compose code for architecture, behav 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. +Read `references/review-checklist.md` for the project-specific checklist, current Compose state/performance checks, and severity guidance. ## Workflow diff --git a/.codex/skills/legado-compose-review/references/review-checklist.md b/.codex/skills/legado-compose-review/references/review-checklist.md index 135344d0e..8a540852d 100644 --- a/.codex/skills/legado-compose-review/references/review-checklist.md +++ b/.codex/skills/legado-compose-review/references/review-checklist.md @@ -39,11 +39,26 @@ 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. +- User-driven transient state that should survive recreation, such as a pending delete-confirmation target, is held with plain `remember` instead of `rememberSaveable` or ViewModel state. - `LaunchedEffect` keys are unstable or cause repeated data loading, duplicate navigation, duplicate toasts, or repeated service calls. +- A long-lived `LaunchedEffect` collector captures parent callbacks or context-dependent values that can change without using `rememberUpdatedState`, unless the effect is intentionally keyed to restart. - 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. +- Heterogeneous lazy lists or grids provide `key` but omit `contentType`, reducing composition reuse quality when headers, rows, ads, loading items, or expanded content mix in the same lazy layout. - 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)`. +- UI effects write list-derived consistency back into the ViewModel, such as pruning selection from `LaunchedEffect(uiState.items)`. Prefer deriving this in the ViewModel with `combine(...)` or reducing it when the data flow updates. +- State is hoisted higher than the lowest common owner, such as a top-level route collecting a child ViewModel flow only needed inside a conditional branch or one popup menu. + +## Compose Stability and Collection Checks + +Flag issues when: + +- Collection-heavy `UiState` exposed to Compose uses ordinary Kotlin `List`, `Set`, or `Map` in hot paths. Kotlin 2.x strong skipping is helpful but does not make standard Kotlin collections stable to Compose. +- DAO or repository lists are passed directly through `UiState` to deep composables without conversion to `ImmutableList` / `ImmutableSet` / `ImmutableMap` or a stable UI model wrapper. +- Review recommendations imply replacing every internal collection. Keep the finding scoped to Compose-facing render state; temporary accumulators, sorting inputs, Room DAO signatures, and domain APIs can remain normal collections unless they are the actual recomposition boundary. +- Mutable collections such as `ArrayList`, `MutableList`, or mutable maps are stored in Compose state or ViewModel `UiState`. +- Entity classes from non-Compose modules are passed deeply through composables and cause visible recomposition churn; prefer stable UI render models if measurement or code shape shows this matters. ## Navigation and Compatibility Checks diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d425c4ddb..2323cb69a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -172,6 +172,7 @@ dependencies { testImplementation(libs.junit) androidTestImplementation(libs.bundles.androidTest) implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.serialization.json) implementation(libs.bundles.coroutines) implementation(libs.core.ktx) diff --git a/app/src/main/java/io/legado/app/data/dao/BookDao.kt b/app/src/main/java/io/legado/app/data/dao/BookDao.kt index 1cb47bbf8..f557ff3bc 100644 --- a/app/src/main/java/io/legado/app/data/dao/BookDao.kt +++ b/app/src/main/java/io/legado/app/data/dao/BookDao.kt @@ -76,6 +76,7 @@ interface BookDao { bookUrl, name, author, + origin, originName, coverUrl, customCoverUrl, @@ -109,6 +110,7 @@ interface BookDao { bookUrl, name, author, + origin, originName, coverUrl, customCoverUrl, @@ -139,6 +141,7 @@ interface BookDao { bookUrl, name, author, + origin, originName, coverUrl, customCoverUrl, @@ -169,6 +172,7 @@ interface BookDao { bookUrl, name, author, + origin, originName, coverUrl, customCoverUrl, @@ -204,6 +208,7 @@ interface BookDao { bookUrl, name, author, + origin, originName, coverUrl, customCoverUrl, @@ -240,6 +245,7 @@ interface BookDao { bookUrl, name, author, + origin, originName, coverUrl, customCoverUrl, @@ -271,6 +277,7 @@ interface BookDao { bookUrl, name, author, + origin, originName, coverUrl, customCoverUrl, @@ -303,6 +310,7 @@ interface BookDao { bookUrl, name, author, + origin, originName, coverUrl, customCoverUrl, @@ -333,6 +341,7 @@ interface BookDao { bookUrl, name, author, + origin, originName, coverUrl, customCoverUrl, @@ -364,6 +373,7 @@ interface BookDao { bookUrl, name, author, + origin, originName, coverUrl, customCoverUrl, @@ -394,6 +404,7 @@ interface BookDao { bookUrl, name, author, + origin, originName, coverUrl, customCoverUrl, @@ -424,6 +435,7 @@ interface BookDao { bookUrl, name, author, + origin, originName, coverUrl, customCoverUrl, @@ -454,6 +466,7 @@ interface BookDao { bookUrl, name, author, + origin, originName, coverUrl, customCoverUrl, @@ -484,6 +497,7 @@ interface BookDao { bookUrl, name, author, + origin, originName, coverUrl, customCoverUrl, diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt index 6be5b43a5..1ed4f006c 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt @@ -42,7 +42,6 @@ sealed interface BookInfoSheet { } sealed interface BookInfoDialog { - data object AddToShelfOnBack : BookInfoDialog data class DeleteBook(val isLocal: Boolean) : BookInfoDialog data class EditRemark(val remark: String?) : BookInfoDialog data class PhotoPreview(val path: String) : BookInfoDialog @@ -60,7 +59,6 @@ data class BookInfoWebFile( } sealed interface BookInfoIntent { - data object BackPressed : BookInfoIntent data object DismissSheet : BookInfoIntent data object DismissDialog : BookInfoIntent data object DismissAppLogSheet : BookInfoIntent @@ -77,7 +75,6 @@ sealed interface BookInfoIntent { data object ChangeSourceClick : BookInfoIntent data object ReadRecordClick : BookInfoIntent data object RemarkClick : BookInfoIntent - data object ConfirmBackAddToShelf : BookInfoIntent data class ConfirmDelete(val deleteOriginal: Boolean) : BookInfoIntent data class UpdateRemark(val remark: String) : BookInfoIntent data class SelectGroup(val groupId: Long) : BookInfoIntent diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt index bf232cc01..8cd29b95c 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt @@ -5,6 +5,9 @@ import android.content.Intent import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -33,6 +36,7 @@ import io.legado.app.utils.showDialogFragment import io.legado.app.utils.startActivity import kotlinx.coroutines.flow.collectLatest +@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun BookInfoRouteScreen( bookUrl: String, @@ -40,6 +44,9 @@ fun BookInfoRouteScreen( onBack: () -> Unit, onFinish: (resultCode: Int?, afterTransition: Boolean) -> Unit, onOpenSearch: (String) -> Unit, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, + sharedCoverKey: String? = null, onRegisterVariableSetter: (((String, String?) -> Unit)?) -> Unit = {} ) { val context = LocalContext.current @@ -156,6 +163,9 @@ fun BookInfoRouteScreen( state = viewModel.uiState.collectAsStateWithLifecycle().value, onIntent = viewModel::onIntent, onBack = onBack, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + sharedCoverKey = sharedCoverKey, ) } diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt index e8a7ded31..a1a9a3456 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt @@ -1,6 +1,8 @@ package io.legado.app.ui.book.info -import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable @@ -109,6 +111,9 @@ fun BookInfoScreen( state: BookInfoUiState, onIntent: (BookInfoIntent) -> Unit, onBack: () -> Unit, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, + sharedCoverKey: String? = null, ) { val bookColorTheme = rememberBookInfoColorTheme(state.book) @@ -117,16 +122,22 @@ fun BookInfoScreen( state = state, onIntent = onIntent, onBack = onBack, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + sharedCoverKey = sharedCoverKey, ) } } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) @Composable private fun BookInfoScreenContent( state: BookInfoUiState, onIntent: (BookInfoIntent) -> Unit, onBack: () -> Unit, + sharedTransitionScope: SharedTransitionScope?, + animatedVisibilityScope: AnimatedVisibilityScope?, + sharedCoverKey: String?, ) { val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine) val scrollBehavior = if (isMiuix) { @@ -138,8 +149,6 @@ private fun BookInfoScreenContent( val pullState = rememberPullToRefreshState() var showMenu by rememberSaveable { mutableStateOf(false) } - BackHandler { onIntent(BookInfoIntent.BackPressed) } - AppScaffold( modifier = Modifier .fillMaxSize() @@ -150,7 +159,7 @@ private fun BookInfoScreenContent( showMenu = showMenu, onShowMenuChange = { showMenu = it }, onMenuAction = { onIntent(BookInfoIntent.MenuAction(it)) }, - onBackPressed = { onIntent(BookInfoIntent.BackPressed) }, + onBackPressed = onBack, scrollBehavior = scrollBehavior, ) }, @@ -211,6 +220,9 @@ private fun BookInfoScreenContent( onAuthorClick = { onIntent(BookInfoIntent.AuthorClick(it)) }, onBookNameClick = { onIntent(BookInfoIntent.BookNameClick(it)) }, onOriginClick = { onIntent(BookInfoIntent.OriginClick) }, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + sharedCoverKey = sharedCoverKey, ) } item { @@ -313,7 +325,7 @@ private fun BookInfoScreenContent( ) } - BookInfoDialogs(state = state, onIntent = onIntent, onBack = onBack) + BookInfoDialogs(state = state, onIntent = onIntent) } @Composable @@ -391,14 +403,27 @@ private fun BookInfoTransparentTopAppBar( @Composable private fun rememberBookInfoColorTheme(book: Book?): ThemeOverrideState? { val imageLoader = koinInject() - val coverPath = book?.getDisplayCover() - val sourceOrigin = book?.origin + var shouldExtractColor by remember(book?.bookUrl) { mutableStateOf(false) } + + LaunchedEffect(book?.bookUrl) { + shouldExtractColor = false + if (book != null) { + delay(520) + shouldExtractColor = true + } + } + + val coverPath = if (shouldExtractColor) book?.getDisplayCover() else null + val sourceOrigin = if (shouldExtractColor) book?.origin else null val loadOnlyWifi = CoverConfig.loadCoverOnlyWifi + val requestKey = remember(coverPath, sourceOrigin, loadOnlyWifi) { + listOf(coverPath, sourceOrigin, loadOnlyWifi) + } val seedColor = rememberImageSeedColor( imageLoader = imageLoader, data = coverPath, - requestKey = listOf(coverPath, sourceOrigin, loadOnlyWifi), + requestKey = requestKey, ) { setParameter("sourceOrigin", sourceOrigin) setParameter("loadOnlyWifi", loadOnlyWifi) @@ -449,6 +474,16 @@ private fun BookInfoBackdrop(book: Book) { val loadOnlyWifi = CoverConfig.loadCoverOnlyWifi val context = LocalContext.current val imageLoader = koinInject() + var showBackdropImage by remember(cover) { mutableStateOf(false) } + + LaunchedEffect(cover) { + showBackdropImage = false + if (!cover.isNullOrBlank()) { + delay(520) + showBackdropImage = true + } + } + val backdropRequest = remember(cover, sourceOrigin, loadOnlyWifi, context) { buildCoverImageRequest( context = context, @@ -464,14 +499,15 @@ private fun BookInfoBackdrop(book: Book) { 0.42f ) Box(modifier = Modifier.fillMaxSize()) { - if (!cover.isNullOrBlank()) { + if (!cover.isNullOrBlank() && showBackdropImage) { AsyncImage( model = backdropRequest, imageLoader = imageLoader, contentDescription = null, modifier = Modifier - .fillMaxSize() - .blur(32.dp), + .fillMaxWidth() + .height(360.dp) + .blur(24.dp), contentScale = ContentScale.Crop, ) } @@ -600,6 +636,9 @@ private fun BookInfoHeader( onAuthorClick: (Boolean) -> Unit, onBookNameClick: (Boolean) -> Unit, onOriginClick: () -> Unit, + sharedTransitionScope: SharedTransitionScope?, + animatedVisibilityScope: AnimatedVisibilityScope?, + sharedCoverKey: String?, ) { Column( modifier = Modifier @@ -631,12 +670,25 @@ private fun BookInfoHeader( .width(112.dp) .combinedClickable(onClick = onCoverClick, onLongClick = onCoverLongClick) ) { + val coverModifier = with(sharedTransitionScope) { + if (this != null && animatedVisibilityScope != null && sharedCoverKey != null) { + Modifier + .width(112.dp) + .sharedElement( + sharedContentState = rememberSharedContentState(sharedCoverKey), + animatedVisibilityScope = animatedVisibilityScope, + ) + } else { + Modifier.width(112.dp) + } + } CoilBookCover( name = book.name, author = book.author, path = book.getDisplayCover(), sourceOrigin = book.origin, - modifier = Modifier.width(112.dp) + modifier = coverModifier, + showLoadingPlaceholder = sharedCoverKey == null, ) } Column( @@ -895,25 +947,11 @@ private fun BookInfoSummary( private fun BookInfoDialogs( state: BookInfoUiState, onIntent: (BookInfoIntent) -> Unit, - onBack: () -> Unit, ) { val dialog = state.dialog var deleteOriginal by remember(dialog, state.deleteOriginal) { mutableStateOf(state.deleteOriginal) } var remarkText by remember(dialog) { mutableStateOf((dialog as? BookInfoDialog.EditRemark)?.remark.orEmpty()) } - if (dialog is BookInfoDialog.AddToShelfOnBack) { - AppAlertDialog( - show = true, - onDismissRequest = { onIntent(BookInfoIntent.DismissDialog) }, - title = stringResource(R.string.add_to_bookshelf), - text = stringResource(R.string.check_add_bookshelf, state.book?.name.orEmpty()), - confirmText = stringResource(android.R.string.ok), - onConfirm = { onIntent(BookInfoIntent.ConfirmBackAddToShelf) }, - dismissText = stringResource(android.R.string.cancel), - onDismiss = onBack, - ) - } - if (dialog is BookInfoDialog.DeleteBook) { AppAlertDialog( show = true, diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt index ae7904928..04efbf699 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt @@ -118,17 +118,22 @@ class BookInfoViewModel( clearReadRecordObserve() _uiState.value = BookInfoUiState() execute { - appDb.bookDao.getBook(bookUrl)?.let { + val book = appDb.bookDao.getBook(bookUrl)?.let { inBookshelf = !it.isNotShelf - return@execute it - } - appDb.searchBookDao.getSearchBook(bookUrl)?.toBook()?.let { + it + } ?: appDb.searchBookDao.getSearchBook(bookUrl)?.toBook()?.let { inBookshelf = false - return@execute it + it + } ?: throw NoStackTraceException("未找到书籍") + + val source = if (book.isLocal) { + null + } else { + appDb.bookSourceDao.getBookSource(book.origin) } - throw NoStackTraceException("未找到书籍") + book to source }.onSuccess { - upBook(it) + upBook(it.first, it.second) }.onError { context.toastOnUi(it.localizedMessage ?: "未找到书籍") emitEffect(BookInfoEffect.Finish(afterTransition = true)) @@ -137,7 +142,6 @@ class BookInfoViewModel( fun onIntent(intent: BookInfoIntent) { when (intent) { - BookInfoIntent.BackPressed -> onBackPressed() BookInfoIntent.DismissSheet -> dismissSheet() BookInfoIntent.DismissDialog -> dismissDialog() is BookInfoIntent.MenuAction -> handleMenuAction(intent.action) @@ -159,13 +163,6 @@ class BookInfoViewModel( BookInfoIntent.ChangeSourceClick -> setSheet(BookInfoSheet.SourcePicker) BookInfoIntent.ReadRecordClick -> setSheet(BookInfoSheet.ReadRecord) BookInfoIntent.RemarkClick -> showDialog(BookInfoDialog.EditRemark(currentBook?.remark)) - BookInfoIntent.ConfirmBackAddToShelf -> { - dismissDialog() - addToBookshelf { - emitEffect(BookInfoEffect.Finish(afterTransition = true)) - } - } - is BookInfoIntent.ConfirmDelete -> { dismissDialog() deleteBook(intent.deleteOriginal) @@ -246,7 +243,17 @@ class BookInfoViewModel( fun onInfoEdited() { currentBook?.bookUrl?.let { bookUrl -> - appDb.bookDao.getBook(bookUrl)?.let { upBook(it) } + execute { + val book = appDb.bookDao.getBook(bookUrl) ?: return@execute null + val source = if (book.isLocal) { + null + } else { + appDb.bookSourceDao.getBookSource(book.origin) + } + book to source + }.onSuccess { + it?.let { (book, source) -> upBook(book, source) } + } } } @@ -651,18 +658,17 @@ class BookInfoViewModel( } } - private fun upBook(book: Book) { + private fun upBook(book: Book, source: BookSource?) { currentBook = book currentChapterList = emptyList() currentWebFiles = emptyList() currentKindLabels = emptyList() currentGroupNames = null currentHasCustomGroup = false + bookSource = source syncUiState(isTocLoading = true) refreshMeta(book) upCoverByRule(book) - bookSource = if (book.isLocal) null else appDb.bookSourceDao.getBookSource(book.origin) - syncUiState(isTocLoading = true) if (book.tocUrl.isEmpty() && !book.isLocal) { loadBookInfo(book, runPreUpdateJs = inBookshelf) } else { @@ -801,14 +807,6 @@ class BookInfoViewModel( } } - private fun onBackPressed() { - if (!inBookshelf && AppConfig.showAddToShelfAlert && currentBook != null) { - showDialog(BookInfoDialog.AddToShelfOnBack) - } else { - emitEffect(BookInfoEffect.Finish(afterTransition = true)) - } - } - private fun onReadClick() { val book = currentBook ?: return if (book.isWebFile) { diff --git a/app/src/main/java/io/legado/app/ui/main/BookCoverSharedElement.kt b/app/src/main/java/io/legado/app/ui/main/BookCoverSharedElement.kt new file mode 100644 index 000000000..bf3a36879 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/BookCoverSharedElement.kt @@ -0,0 +1,3 @@ +package io.legado.app.ui.main + +fun bookCoverSharedElementKey(bookUrl: String): String = "book-cover:$bookUrl" diff --git a/app/src/main/java/io/legado/app/ui/main/MainActivity.kt b/app/src/main/java/io/legado/app/ui/main/MainActivity.kt index 19f2303ea..c68117d79 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainActivity.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainActivity.kt @@ -6,6 +6,8 @@ import android.content.res.Configuration import android.os.Bundle import android.text.format.DateUtils import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearOutSlowInEasing import androidx.compose.animation.core.tween @@ -24,6 +26,7 @@ import androidx.lifecycle.lifecycleScope import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.LocalNavAnimatedContentScope import androidx.navigation3.ui.NavDisplay import io.legado.app.BuildConfig import io.legado.app.R @@ -340,6 +343,7 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback { routeEvents.tryEmit(resolveStartRoute(intent)) } + @OptIn(ExperimentalSharedTransitionApi::class) @Composable override fun Content() { val orientation = resources.configuration.orientation @@ -362,9 +366,10 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback { } } - NavDisplay( - backStack = backStack, - transitionSpec = { + SharedTransitionLayout { + NavDisplay( + backStack = backStack, + transitionSpec = { (slideIntoContainer( towards = AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween( @@ -441,7 +446,7 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback { finish() } }, - entryProvider = entryProvider { + entryProvider = entryProvider { entry { MainScreen( useRail = useRail, @@ -508,7 +513,9 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback { openUrl = openUrl ) ) - } + }, + sharedTransitionScope = this@SharedTransitionLayout, + animatedVisibilityScope = LocalNavAnimatedContentScope.current, ) } @@ -671,6 +678,9 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback { onOpenSearch = { keyword -> navigateToRoute(backStack, MainRouteSearch(key = keyword)) }, + sharedTransitionScope = this@SharedTransitionLayout, + animatedVisibilityScope = LocalNavAnimatedContentScope.current, + sharedCoverKey = bookCoverSharedElementKey(route.bookUrl), onRegisterVariableSetter = { setter -> bookInfoVariableSetter = setter } @@ -695,8 +705,9 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback { } ) } - } - ) + } + ) + } } private fun navigateToRoute(backStack: MutableList, route: NavKey) { diff --git a/app/src/main/java/io/legado/app/ui/main/MainDestination.kt b/app/src/main/java/io/legado/app/ui/main/MainDestination.kt index 27fa25a46..5cdfdac8a 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainDestination.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainDestination.kt @@ -2,6 +2,7 @@ package io.legado.app.ui.main import androidx.annotation.StringRes import io.legado.app.R +import kotlinx.collections.immutable.persistentListOf sealed class MainDestination( val route: String, @@ -28,6 +29,6 @@ sealed class MainDestination( ) companion object { - val mainDestinations = listOf(Bookshelf, Explore, Rss, My) + val mainDestinations = persistentListOf(Bookshelf, Explore, Rss, My) } } diff --git a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt b/app/src/main/java/io/legado/app/ui/main/MainScreen.kt index 0a104d6e3..9ff86f367 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainScreen.kt @@ -1,6 +1,13 @@ package io.legado.app.ui.main +import android.content.Intent +import android.net.Uri import android.os.Build +import androidx.activity.ComponentActivity +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable @@ -74,13 +81,18 @@ import io.legado.app.ui.widget.components.icon.AppIcons import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem import io.legado.app.ui.widget.components.text.AppText +import io.legado.app.ui.widget.dialog.TextDialog +import io.legado.app.utils.sendToClip +import io.legado.app.utils.showDialogFragment import io.legado.app.utils.startActivityForBook +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.koin.androidx.compose.koinViewModel @OptIn( ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class, - ExperimentalFoundationApi::class + ExperimentalFoundationApi::class, ExperimentalSharedTransitionApi::class ) @Composable fun MainScreen( @@ -95,14 +107,46 @@ fun MainScreen( onNavigateToBookInfo: (name: String, author: String, bookUrl: String) -> Unit, onNavigateToExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit, onNavigateToRssSort: (sourceUrl: String, sortUrl: String?, key: String?) -> Unit, - onNavigateToRssRead: (title: String?, origin: String, link: String?, openUrl: String?) -> Unit + onNavigateToRssRead: (title: String?, origin: String, link: String?, openUrl: String?) -> Unit, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, ) { val context = LocalContext.current val coroutineScope = rememberCoroutineScope() val mainUiState by viewModel.uiState.collectAsStateWithLifecycle() - val bookshelfViewModel: BookshelfViewModel = koinViewModel() - val bookshelfGroupState by bookshelfViewModel.groupSelectorState.collectAsStateWithLifecycle() + LaunchedEffect(viewModel, context) { + viewModel.effects.collect { effect -> + when (effect) { + is MainEffect.OpenUrl -> { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(effect.url)) + ) + } + + is MainEffect.CopyUrl -> context.sendToClip(effect.url) + is MainEffect.ShowMarkdown -> { + val activity = context as? AppCompatActivity ?: return@collect + val title = effect.title.ifBlank { context.getString(R.string.help) } + val mdText = withContext(Dispatchers.IO) { + context.assets + .open("web/help/md/${effect.path}.md") + .bufferedReader() + .use { it.readText() } + } + activity.showDialogFragment(TextDialog(title, mdText, TextDialog.Mode.MD)) + } + + is MainEffect.StartActivity -> { + context.startActivity(Intent(context, effect.destination).apply { + effect.configTag?.let { putExtra("configTag", it) } + }) + } + + MainEffect.ExitApp -> (context as? ComponentActivity)?.finish() + } + } + } val hazeState = remember { HazeState() } val floatingBarSurfaceColor = MaterialTheme.colorScheme.surface @@ -213,34 +257,15 @@ fun MainScreen( ) if (destination == MainDestination.Bookshelf && showGroupMenu) { - RoundDropdownMenu( + BookshelfRailGroupMenu( expanded = showGroupMenu, - onDismissRequest = { showGroupMenu = false } - ) { dismiss -> - bookshelfGroupState.groups.forEachIndexed { groupIndex, group -> - RoundDropdownMenuItem( - text = group.groupName, - onClick = { - coroutineScope.launch { - if (pagerState.currentPage != index) { - pagerState.scrollToPage(index) - } - bookshelfViewModel.changeGroup(group.groupId) - dismiss() - } - }, - trailingIcon = { - if (bookshelfGroupState.selectedGroupIndex == groupIndex) { - Icon( - Icons.Default.Check, - null, - modifier = Modifier.size(18.dp) - ) - } - } - ) + onDismissRequest = { showGroupMenu = false }, + onBeforeSelectGroup = { + if (pagerState.currentPage != index) { + pagerState.scrollToPage(index) + } } - } + ) } } }, @@ -372,7 +397,9 @@ fun MainScreen( onNavigateToSearch = { query -> onNavigateToSearch(query) }, onNavigateToRemoteImport = onNavigateToRemoteImport, onNavigateToLocalImport = onNavigateToLocalImport, - onNavigateToCache = onNavigateToCache + onNavigateToCache = onNavigateToCache, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, ) MainDestination.Explore -> ExploreScreen( @@ -392,7 +419,7 @@ fun MainScreen( if (event == PrefClickEvent.OpenBookCacheManage) { onNavigateToBookCacheManage() } else { - viewModel.onPrefClickEvent(context, event) + viewModel.onPrefClickEvent(event) } } ) @@ -403,6 +430,44 @@ fun MainScreen( } } +@Composable +private fun BookshelfRailGroupMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + onBeforeSelectGroup: suspend () -> Unit, + viewModel: BookshelfViewModel = koinViewModel() +) { + val groupState by viewModel.groupSelectorState.collectAsStateWithLifecycle() + val coroutineScope = rememberCoroutineScope() + + RoundDropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest + ) { dismiss -> + groupState.groups.forEachIndexed { groupIndex, group -> + RoundDropdownMenuItem( + text = group.groupName, + onClick = { + coroutineScope.launch { + onBeforeSelectGroup() + viewModel.changeGroup(group.groupId) + dismiss() + } + }, + trailingIcon = { + if (groupState.selectedGroupIndex == groupIndex) { + Icon( + Icons.Default.Check, + null, + modifier = Modifier.size(18.dp) + ) + } + } + ) + } + } +} + @Composable private fun NavigationIcon( destination: MainDestination, diff --git a/app/src/main/java/io/legado/app/ui/main/MainViewModel.kt b/app/src/main/java/io/legado/app/ui/main/MainViewModel.kt index 5dfd1ff73..239d00d45 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainViewModel.kt @@ -1,10 +1,7 @@ package io.legado.app.ui.main import android.app.Application -import android.content.Context -import android.content.Intent import android.content.SharedPreferences -import androidx.appcompat.app.AppCompatActivity import io.legado.app.base.BaseViewModel import io.legado.app.constant.PreferKey import io.legado.app.constant.EventBus @@ -12,16 +9,17 @@ import io.legado.app.domain.usecase.AppStartupMaintenanceUseCase import io.legado.app.domain.usecase.WebDavBackupUseCase import io.legado.app.ui.config.mainConfig.MainConfig import io.legado.app.ui.main.my.PrefClickEvent -import io.legado.app.ui.widget.dialog.TextDialog import io.legado.app.utils.defaultSharedPreferences import io.legado.app.utils.eventBus.FlowEventBus import io.legado.app.utils.getPrefBoolean import io.legado.app.utils.getPrefString -import io.legado.app.utils.sendToClip -import io.legado.app.utils.showDialogFragment +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList class MainViewModel( application: Application, @@ -49,6 +47,8 @@ class MainViewModel( private val _uiState = MutableStateFlow(readMainUiState()) val uiState = _uiState.asStateFlow() + private val _effects = MutableSharedFlow(extraBufferCapacity = 8) + val effects = _effects.asSharedFlow() init { prefs.registerOnSharedPreferenceChangeListener(preferenceListener) @@ -90,35 +90,27 @@ class MainViewModel( MainConfig.navExtended = expanded } - fun onPrefClickEvent(context: Context, event: PrefClickEvent) { + fun onPrefClickEvent(event: PrefClickEvent) { when (event) { - is PrefClickEvent.OpenUrl -> context.startActivity( - Intent( - Intent.ACTION_VIEW, - android.net.Uri.parse(event.url) + is PrefClickEvent.OpenUrl -> _effects.tryEmit(MainEffect.OpenUrl(event.url)) + is PrefClickEvent.CopyUrl -> _effects.tryEmit(MainEffect.CopyUrl(event.url)) + is PrefClickEvent.ShowMd -> _effects.tryEmit( + MainEffect.ShowMarkdown( + title = event.title, + path = event.path ) ) - is PrefClickEvent.CopyUrl -> context.sendToClip(event.url) - is PrefClickEvent.ShowMd -> { - 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 -> { - context.startActivity(Intent(context, event.destination).apply { - event.configTag?.let { putExtra("configTag", it) } - }) + _effects.tryEmit( + MainEffect.StartActivity( + destination = event.destination, + configTag = event.configTag + ) + ) } - PrefClickEvent.ExitApp -> { - if (context is androidx.activity.ComponentActivity) { - context.finish() - } - } + PrefClickEvent.ExitApp -> _effects.tryEmit(MainEffect.ExitApp) else -> Unit } @@ -126,8 +118,20 @@ class MainViewModel( } +sealed interface MainEffect { + data class OpenUrl(val url: String) : MainEffect + data class CopyUrl(val url: String) : MainEffect + data class ShowMarkdown(val title: String, val path: String) : MainEffect + data class StartActivity( + val destination: Class<*>, + val configTag: String? = null + ) : MainEffect + + data object ExitApp : MainEffect +} + data class MainUiState( - val destinations: List = MainDestination.mainDestinations, + val destinations: ImmutableList = MainDestination.mainDestinations, val defaultHomePage: String = "bookshelf", val showBottomView: Boolean = true, val useFloatingBottomBar: Boolean = false, @@ -147,7 +151,7 @@ private fun MainViewModel.readMainUiState(): MainUiState { MainDestination.Rss -> showRss else -> true } - } + }.toImmutableList() return MainUiState( destinations = destinations, defaultHomePage = context.getPrefString(PreferKey.defaultHomePage, "bookshelf") diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt index d9e6920fb..1ae723793 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt @@ -1,5 +1,8 @@ package io.legado.app.ui.main.bookshelf +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable @@ -389,6 +392,7 @@ fun BookGroupItemList( ) } +@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun BookItem( book: BookShelfItem, @@ -404,6 +408,9 @@ fun BookItem( coverShadow: Boolean = false, isSearchMode: Boolean = false, searchKey: String = "", + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, + sharedCoverKey: String? = null, onClick: () -> Unit, onLongClick: (() -> Unit)? ) { @@ -447,15 +454,28 @@ fun BookItem( } } else null, cover = { modifier -> + val coverModifier = with(sharedTransitionScope) { + if (this != null && animatedVisibilityScope != null && sharedCoverKey != null) { + Modifier.fillMaxWidth().sharedElement( + sharedContentState = rememberSharedContentState(sharedCoverKey), + animatedVisibilityScope = animatedVisibilityScope, + ) + } else { + Modifier.fillMaxWidth() + } + } BookshelfCover( name = book.name, author = book.author, path = book.getDisplayCover(), isUpdating = isUpdating, modifier = modifier, + coverModifier = coverModifier, + sourceOrigin = book.origin, badgeText = if (layoutMode != 0) unreadText else null, showBadgeDot = BookshelfConfig.showUnread && BookshelfConfig.showUnreadNew && book.isNew, - leftBottomText = matchedSourceLabel ?: bookTypeLabel + leftBottomText = matchedSourceLabel ?: bookTypeLabel, + showLoadingPlaceholder = sharedCoverKey == null, ) }, title = book.name, diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookShelfItem.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookShelfItem.kt index 0290c38fb..11542ffe3 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookShelfItem.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookShelfItem.kt @@ -10,6 +10,7 @@ data class BookShelfItem( val bookUrl: String, val name: String, val author: String, + val origin: String, val originName: String, val coverUrl: String?, val customCoverUrl: String?, @@ -44,6 +45,7 @@ data class BookShelfItem( fun BookShelfItem.toLightBook() = Book( bookUrl = bookUrl, + origin = origin, originName = originName, name = name, author = author, diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt index 751fee276..9f55e8791 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt @@ -7,6 +7,9 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.clickable import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement @@ -77,6 +80,7 @@ import io.legado.app.data.entities.BookGroup import io.legado.app.ui.about.AppLogSheet import io.legado.app.ui.book.info.GroupSelectSheet import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig +import io.legado.app.ui.main.bookCoverSharedElementKey import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.ThemeResolver import io.legado.app.ui.theme.adaptiveContentPadding @@ -110,7 +114,7 @@ import sh.calvin.reorderable.rememberReorderableLazyGridState @OptIn( ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, - ExperimentalMaterial3ExpressiveApi::class + ExperimentalMaterial3ExpressiveApi::class, ExperimentalSharedTransitionApi::class ) @Composable fun BookshelfScreen( @@ -120,7 +124,9 @@ fun BookshelfScreen( onNavigateToSearch: (String) -> Unit, onNavigateToRemoteImport: () -> Unit, onNavigateToLocalImport: () -> Unit, - onNavigateToCache: (Long) -> Unit + onNavigateToCache: (Long) -> Unit, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() @@ -260,10 +266,6 @@ fun BookshelfScreen( clearSelection() } - LaunchedEffect(uiState.items) { - viewModel.pruneSelectionToVisible(uiState.items) - } - BackHandler(enabled = isEditMode) { if (selectedBookUrls.isNotEmpty()) { clearSelection() @@ -722,7 +724,9 @@ fun BookshelfScreen( onSyncDragState = { _, _ -> }, onGlobalSearch = { onNavigateToSearch(uiState.searchKey.trim()) }, onBookClick = onBookClick, - onBookLongClick = onBookLongClick + onBookLongClick = onBookLongClick, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, ) } else { HorizontalPager( @@ -741,7 +745,8 @@ fun BookshelfScreen( } val canReorderBooks = isEditMode && !uiState.isSearch && - group.getRealBookSort() == 3 && + (group.bookSort.takeIf { it >= 0 } + ?: uiState.bookshelfSort) == 3 && isSelectedGroup BookshelfPage( paddingValues = paddingValues, @@ -782,7 +787,9 @@ fun BookshelfScreen( }, onGlobalSearch = { onNavigateToSearch(uiState.searchKey.trim()) }, onBookClick = onBookClick, - onBookLongClick = onBookLongClick + onBookLongClick = onBookLongClick, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, ) } } @@ -1030,7 +1037,9 @@ fun BookshelfPage( onSyncDragState: (books: List, canReorderBooks: Boolean) -> Unit, onGlobalSearch: () -> Unit, onBookClick: (BookShelfItem) -> Unit, - onBookLongClick: (BookShelfItem) -> Unit + onBookLongClick: (BookShelfItem) -> Unit, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, ) { if (books.isEmpty()) { if (uiState.isSearch) { @@ -1135,6 +1144,9 @@ fun BookshelfPage( coverShadow = BookshelfConfig.bookshelfCoverShadow, isSearchMode = uiState.isSearch, searchKey = uiState.searchKey, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + sharedCoverKey = bookCoverSharedElementKey(book.bookUrl), onClick = { if (isEditMode) { onToggleBookSelection(book) diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfUiState.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfUiState.kt index c80291403..088011e13 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfUiState.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfUiState.kt @@ -2,9 +2,15 @@ package io.legado.app.ui.main.bookshelf import io.legado.app.data.entities.BookGroup import io.legado.app.ui.widget.components.list.ListUiState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.persistentSetOf data class BookshelfGroupSelectorState( - val groups: List = emptyList(), + val groups: ImmutableList = persistentListOf(), val selectedGroupIndex: Int = 0, val selectedGroupId: Long = BookGroup.IdAll ) @@ -22,31 +28,33 @@ sealed interface BookshelfOverlay { } data class BookshelfUiState( - override val items: List = emptyList(), - override val selectedIds: Set = emptySet(), + override val items: ImmutableList = persistentListOf(), + override val selectedIds: ImmutableSet = persistentSetOf(), override val searchKey: String = "", override val isSearch: Boolean = false, override val isLoading: Boolean = false, - val groups: List = emptyList(), - val allGroups: List = emptyList(), - val groupPreviews: Map> = emptyMap(), - val groupBookCounts: Map = emptyMap(), + val groups: ImmutableList = persistentListOf(), + val allGroups: ImmutableList = persistentListOf(), + val groupPreviews: ImmutableMap> = persistentMapOf(), + val groupBookCounts: ImmutableMap = persistentMapOf(), val currentGroupBookCount: Int = 0, val allBooksCount: Int = 0, val selectedGroupIndex: Int = 0, val selectedGroupId: Long = BookGroup.IdAll, val loadingText: String? = null, val upBooksCount: Int = 0, - val updatingBooks: Set = emptySet(), + val updatingBooks: ImmutableSet = persistentSetOf(), val activeOverlay: BookshelfOverlay? = null, val isEditMode: Boolean = false, - val selectedBookUrls: Set = emptySet(), + val selectedBookUrls: ImmutableSet = persistentSetOf(), val isInFolderRoot: Boolean = false, val isRefreshing: Boolean = false, val bookGroupStyle: Int = 0, + val bookshelfSort: Int = 0, + val bookshelfSortOrder: Int = 1, val title: String = "", val subtitle: String? = null, val currentGroupName: String? = null, - val draggingBooks: List? = null, - val pendingSavedBooks: List? = null + val draggingBooks: ImmutableList? = null, + val pendingSavedBooks: ImmutableList? = null ) : ListUiState diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt index ff17dc852..963b9ac8d 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt @@ -1,6 +1,5 @@ package io.legado.app.ui.main.bookshelf -import kotlinx.coroutines.flow.flow import android.app.Application import android.net.Uri import androidx.compose.runtime.snapshotFlow @@ -54,11 +53,17 @@ import io.legado.app.utils.postEvent import io.legado.app.utils.printOnDebug import io.legado.app.utils.readText import io.legado.app.utils.toastOnUi +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableMap +import kotlinx.collections.immutable.toImmutableSet +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive @@ -71,9 +76,8 @@ import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.isActive @@ -101,7 +105,6 @@ class BookshelfViewModel( private val groupIdFlow = MutableStateFlow(BookshelfConfig.saveTabPosition) private val searchKeyFlow = MutableStateFlow("") private val searchModeFlow = MutableStateFlow(false) - private val refreshTrigger = MutableStateFlow(0) private val loadingTextFlow = MutableStateFlow(null) private val activeOverlayFlow = MutableStateFlow(null) private val isEditModeFlow = MutableStateFlow(false) @@ -112,7 +115,23 @@ class BookshelfViewModel( private val draggingBooksFlow = MutableStateFlow?>(null) private val pendingSavedBooksFlow = MutableStateFlow?>(null) + private data class BookshelfSortConfig( + val sort: Int, + val sortOrder: Int + ) + + private fun readSortConfig() = BookshelfSortConfig( + sort = BookshelfConfig.bookshelfSort, + sortOrder = BookshelfConfig.bookshelfSortOrder + ) + + private val sortConfigFlow: StateFlow = snapshotFlow { + readSortConfig() + }.distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), readSortConfig()) + // 更新相关 + private val updateQueueLock = Any() private val waitUpTocBooks = LinkedList() private val onUpTocBooks = ConcurrentHashMap.newKeySet() private val updatingBooksFlow = MutableStateFlow>(emptySet()) @@ -144,8 +163,8 @@ class BookshelfViewModel( .flowOn(Dispatchers.Default) private data class GroupPreviewState( - val previews: Map>, - val counts: Map, + val previews: ImmutableMap>, + val counts: ImmutableMap, val allBookCount: Int ) @@ -154,7 +173,7 @@ class BookshelfViewModel( groupIdFlow ) { groups, selectedGroupId -> BookshelfGroupSelectorState( - groups = groups, + groups = groups.toImmutableList(), selectedGroupIndex = groups.indexOfFirst { it.groupId == selectedGroupId } .coerceAtLeast(0), selectedGroupId = selectedGroupId @@ -163,22 +182,45 @@ class BookshelfViewModel( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), BookshelfGroupSelectorState()) @OptIn(ExperimentalCoroutinesApi::class) - val booksFlow = combine(groupIdFlow, refreshTrigger) { groupId, _ -> groupId } + val booksFlow = groupIdFlow .flatMapLatest { groupId -> combine( appDb.bookDao.flowBookShelfByGroup(groupId), - groupsFlow - ) { list, groups -> + groupsFlow, + sortConfigFlow + ) { list, groups, sortConfig -> sortBooks( list, - groups.find { it.groupId == groupId } + groups.find { it.groupId == groupId }, + sortConfig ) } }.distinctUntilChanged().flowOn(Dispatchers.Default) + private val visibleBooksFlow = combine( + booksFlow, + searchKeyFlow, + searchModeFlow + ) { books, searchKey, isSearchMode -> + filterBooks(books, searchKey, isSearchMode) + }.distinctUntilChanged() + + private val selectedVisibleBookUrlsFlow = combine( + selectedBookUrlsFlow, + visibleBooksFlow + ) { selectedBookUrls, visibleBooks -> + val visibleBookUrls = visibleBooks.mapTo(hashSetOf()) { it.bookUrl } + selectedBookUrls.intersect(visibleBookUrls) + }.distinctUntilChanged() + private val groupPreviewsFlow = - combine(groupsFlow, allBooksFlow, refreshTrigger, bookGroupStyleFlow) { groups, allBooks, _, bookGroupStyle -> - buildGroupPreviewState(groups, allBooks, bookGroupStyle) + combine( + groupsFlow, + allBooksFlow, + bookGroupStyleFlow, + sortConfigFlow + ) { groups, allBooks, bookGroupStyle, sortConfig -> + buildGroupPreviewState(groups, allBooks, bookGroupStyle, sortConfig) }.distinctUntilChanged().flowOn(Dispatchers.Default) private val coreInternalStateFlow = combine( @@ -188,23 +230,36 @@ class BookshelfViewModel( loadingTextFlow, updatingBooksFlow ) { groupId, searchKey, isSearchMode, loadingText, updatingBooks -> - InternalState(groupId, searchKey, isSearchMode, loadingText, updatingBooks, 0) + InternalState( + groupId = groupId, + searchKey = searchKey, + isSearchMode = isSearchMode, + loadingText = loadingText, + updatingBooks = updatingBooks, + upBooksCount = 0, + sortConfig = readSortConfig() + ) } private val internalStateFlow = combine( coreInternalStateFlow, - upBooksCountFlow - ) { core, upBooksCount -> - core.copy(upBooksCount = upBooksCount) + upBooksCountFlow, + sortConfigFlow + ) { core, upBooksCount, sortConfig -> + core.copy( + upBooksCount = upBooksCount, + sortConfig = sortConfig + ) } - data class InternalState( + private data class InternalState( val groupId: Long, val searchKey: String, val isSearchMode: Boolean, val loadingText: String?, val updatingBooks: Set, - val upBooksCount: Int + val upBooksCount: Int, + val sortConfig: BookshelfSortConfig ) data class BookshelfInteractionState( @@ -221,7 +276,7 @@ class BookshelfViewModel( private val editStateFlow = combine( activeOverlayFlow, isEditModeFlow, - selectedBookUrlsFlow, + selectedVisibleBookUrlsFlow, isInFolderRootFlow ) { activeOverlay, isEditMode, selectedBookUrls, isInFolderRoot -> EditState(activeOverlay, isEditMode, selectedBookUrls, isInFolderRoot) @@ -280,11 +335,7 @@ class BookshelfViewModel( val allGroups = data.allGroups val previews = data.previews val internal = data.internal - val filteredBooks = if (!internal.isSearchMode || internal.searchKey.isBlank()) { - books - } else { - books.filter { it.matchesSearchKey(internal.searchKey) } - } + val filteredBooks = filterBooks(books, internal.searchKey, internal.isSearchMode) val selectedGroupIndex = groups.indexOfFirst { it.groupId == internal.groupId } .coerceAtLeast(0) val currentGroupName = allGroups.firstOrNull { it.groupId == internal.groupId }?.groupName @@ -300,10 +351,10 @@ class BookshelfViewModel( ) BookshelfUiState( - items = filteredBooks, - selectedIds = selectedIds, - groups = groups, - allGroups = allGroups, + items = filteredBooks.toImmutableList(), + selectedIds = selectedIds.toImmutableSet(), + groups = groups.toImmutableList(), + allGroups = allGroups.toImmutableList(), groupPreviews = previews.previews, groupBookCounts = previews.counts, currentGroupBookCount = books.size, @@ -315,13 +366,15 @@ class BookshelfViewModel( isLoading = internal.loadingText != null, loadingText = internal.loadingText, upBooksCount = internal.upBooksCount, - updatingBooks = internal.updatingBooks, + updatingBooks = internal.updatingBooks.toImmutableSet(), activeOverlay = interaction.activeOverlay, isEditMode = interaction.isEditMode, - selectedBookUrls = interaction.selectedBookUrls, + selectedBookUrls = interaction.selectedBookUrls.toImmutableSet(), isInFolderRoot = interaction.isInFolderRoot, isRefreshing = interaction.isRefreshing, bookGroupStyle = interaction.bookGroupStyle, + bookshelfSort = internal.sortConfig.sort, + bookshelfSortOrder = internal.sortConfig.sortOrder, title = title, subtitle = if (interaction.isEditMode) { context.getString(R.string.bookshelf_total_count, previews.allBookCount) @@ -329,8 +382,8 @@ class BookshelfViewModel( null }, currentGroupName = currentGroupName, - draggingBooks = interaction.draggingBooks, - pendingSavedBooks = interaction.pendingSavedBooks + draggingBooks = interaction.draggingBooks?.toImmutableList(), + pendingSavedBooks = interaction.pendingSavedBooks?.toImmutableList() ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), BookshelfUiState()) @@ -340,29 +393,19 @@ class BookshelfViewModel( upAllBookToc() } } - viewModelScope.launch { - FlowEventBus.with(EventBus.BOOKSHELF_REFRESH).collect { - refresh() - } - } - - // 监听排序配置变化,触发刷新 - viewModelScope.launch { - snapshotFlow { BookshelfConfig.bookshelfSort }.collect { refresh() } - } - viewModelScope.launch { - snapshotFlow { BookshelfConfig.bookshelfSortOrder }.collect { refresh() } - } viewModelScope.launch { snapshotFlow { BookshelfConfig.bookGroupStyle } .distinctUntilChanged() .collect { style -> updateBookGroupStyle(style) - refresh() } } viewModelScope.launch { - snapshotFlow { BookshelfConfig.showWaitUpCount }.collect { postUpBooksCount() } + snapshotFlow { BookshelfConfig.showWaitUpCount } + .distinctUntilChanged() + .collect { + postUpBooksCount() + } } if (BookshelfConfig.autoRefreshBook) { @@ -370,9 +413,29 @@ class BookshelfViewModel( } } - private fun sortBooks(list: List, group: BookGroup?): List { - val bookSort = group?.getRealBookSort() ?: BookshelfConfig.bookshelfSort - val isDescending = BookshelfConfig.bookshelfSortOrder == 1 + private fun filterBooks( + books: List, + searchKey: String, + isSearchMode: Boolean + ): List { + return if (!isSearchMode || searchKey.isBlank()) { + books + } else { + books.filter { it.matchesSearchKey(searchKey) } + } + } + + private fun sortBooks( + list: List, + group: BookGroup?, + sortConfig: BookshelfSortConfig + ): List { + val bookSort = if (group != null && group.bookSort >= 0) { + group.bookSort + } else { + sortConfig.sort + } + val isDescending = sortConfig.sortOrder == 1 return when (bookSort) { 1 -> if (isDescending) list.sortedByDescending { it.latestChapterTime } @@ -435,10 +498,11 @@ class BookshelfViewModel( private fun buildGroupPreviewState( groups: List, allBooks: List, - bookGroupStyle: Int + bookGroupStyle: Int, + sortConfig: BookshelfSortConfig ): GroupPreviewState { if (bookGroupStyle !in 2..3) { - return GroupPreviewState(emptyMap(), emptyMap(), allBooks.size) + return GroupPreviewState(persistentMapOf(), persistentMapOf(), allBooks.size) } val buckets = HashMap>(groups.size) @@ -482,14 +546,14 @@ class BookshelfViewModel( } } - val previews = HashMap>(groups.size) + val previews = HashMap>(groups.size) val counts = HashMap(groups.size) groups.forEach { group -> val groupBooks = buckets[group.groupId].orEmpty() counts[group.groupId] = groupBooks.size - previews[group.groupId] = buildGroupPreview(sortBooks(groupBooks, group)) + previews[group.groupId] = buildGroupPreview(sortBooks(groupBooks, group, sortConfig)) } - return GroupPreviewState(previews, counts, allBooks.size) + return GroupPreviewState(previews.toImmutableMap(), counts.toImmutableMap(), allBooks.size) } private fun BookShelfItem.isRootGroupBook(sumUserGroupIds: Long): Boolean { @@ -509,13 +573,13 @@ class BookshelfViewModel( (sumUserGroupIds and group) == 0L } - private fun buildGroupPreview(sortedBooks: List): List { + private fun buildGroupPreview(sortedBooks: List): ImmutableList { val booksWithCover = sortedBooks.filter { it.getDisplayCover() != null } return if (booksWithCover.size >= 4) { booksWithCover.take(4) } else { (booksWithCover + sortedBooks.filter { it.getDisplayCover() == null }).take(4) - } + }.toImmutableList() } fun changeGroup(groupId: Long) { @@ -539,10 +603,6 @@ class BookshelfViewModel( clearSelection() } - fun refresh() { - refreshTrigger.value++ - } - fun showOverlay(overlay: BookshelfOverlay) { activeOverlayFlow.value = overlay } @@ -590,11 +650,6 @@ class BookshelfViewModel( } } - fun pruneSelectionToVisible(books: List) { - val visibleBookUrls = books.mapTo(hashSetOf()) { it.bookUrl } - selectedBookUrlsFlow.value = selectedBookUrlsFlow.value.intersect(visibleBookUrls) - } - fun setInFolderRoot(isInFolderRoot: Boolean) { if (isInFolderRootFlow.value != isInFolderRoot) { isInFolderRootFlow.value = isInFolderRoot @@ -738,55 +793,95 @@ class BookshelfViewModel( } } - @Synchronized private fun addToWaitUp(books: List) { - books.forEach { book -> - if (!waitUpTocBooks.contains(book.bookUrl) && !onUpTocBooks.contains(book.bookUrl)) { - waitUpTocBooks.add(book.bookUrl) + synchronized(updateQueueLock) { + books.forEach { book -> + if (!waitUpTocBooks.contains(book.bookUrl) && + !onUpTocBooks.contains(book.bookUrl) + ) { + waitUpTocBooks.add(book.bookUrl) + } + } + if (upTocJob == null && waitUpTocBooks.isNotEmpty()) { + startUpTocJobLocked() } } postUpBooksCount() - if (upTocJob == null) { - startUpTocJob() - } } - private fun startUpTocJob() { - postUpBooksCount() + private fun startUpTocJobLocked() { upTocJob = viewModelScope.launch(updateDispatcher) { + var completedWithoutFlowError = true flow { while (true) { - emit(waitUpTocBooks.poll() ?: break) + emit(pollWaitUpBookUrl() ?: break) } }.onEachParallel(updateConcurrency) { - onUpTocBooks.add(it) - updatingBooksFlow.value = onUpTocBooks.toSet() - postEvent(EventBus.UP_BOOKSHELF, it) - updateToc(it) - }.onEach { - onUpTocBooks.remove(it) - updatingBooksFlow.value = onUpTocBooks.toSet() - postEvent(EventBus.UP_BOOKSHELF, it) - postUpBooksCount() - }.onCompletion { - upTocJob = null - if (waitUpTocBooks.isNotEmpty()) { - startUpTocJob() - } else { - completeRefreshIfIdle() - } - if (it == null && cacheBookJob == null && !CacheBookService.isRun) { - cacheBook() + markBookUpdateStarted(it) + try { + postEvent(EventBus.UP_BOOKSHELF, it) + updateToc(it) + } finally { + markBookUpdateFinished(it) } }.catch { + completedWithoutFlowError = false AppLog.put("更新目录出错\n${it.localizedMessage}", it) }.collect() + + finishUpTocJob(completedWithoutFlowError) + } + postUpBooksCount() + } + + private fun pollWaitUpBookUrl(): String? = synchronized(updateQueueLock) { + waitUpTocBooks.poll() + } + + private fun markBookUpdateStarted(bookUrl: String) { + synchronized(updateQueueLock) { + onUpTocBooks.add(bookUrl) + } + updatingBooksFlow.value = onUpTocBooksSnapshot() + } + + private fun markBookUpdateFinished(bookUrl: String) { + synchronized(updateQueueLock) { + onUpTocBooks.remove(bookUrl) + } + updatingBooksFlow.value = onUpTocBooksSnapshot() + postEvent(EventBus.UP_BOOKSHELF, bookUrl) + postUpBooksCount() + } + + private fun onUpTocBooksSnapshot(): Set = synchronized(updateQueueLock) { + onUpTocBooks.toSet() + } + + private fun finishUpTocJob(completedWithoutFlowError: Boolean) { + val restarted = synchronized(updateQueueLock) { + upTocJob = null + if (waitUpTocBooks.isNotEmpty()) { + startUpTocJobLocked() + true + } else { + false + } + } + + if (!restarted) { + completeRefreshIfIdle() + } + if (!restarted && completedWithoutFlowError && cacheBookJob == null && !CacheBookService.isRun) { + cacheBook() } } - @Synchronized private fun completeRefreshIfIdle() { - if (upTocJob == null && waitUpTocBooks.isEmpty() && onUpTocBooks.isEmpty()) { + val isIdle = synchronized(updateQueueLock) { + upTocJob == null && waitUpTocBooks.isEmpty() && onUpTocBooks.isEmpty() + } + if (isIdle) { isRefreshingFlow.value = false } } @@ -841,8 +936,13 @@ class BookshelfViewModel( } private fun postUpBooksCount() { - val count = - if (BookshelfConfig.showWaitUpCount) waitUpTocBooks.size + onUpTocBooks.size else 0 + val count = if (BookshelfConfig.showWaitUpCount) { + synchronized(updateQueueLock) { + waitUpTocBooks.size + onUpTocBooks.size + } + } else { + 0 + } upBooksCountFlow.value = count } @@ -868,7 +968,7 @@ class BookshelfViewModel( cacheBookJob = viewModelScope.launch(updateDispatcher) { launch { while (isActive && CacheBook.isRun) { - CacheBook.setWorkingState(waitUpTocBooks.isEmpty() && onUpTocBooks.isEmpty()) + CacheBook.setWorkingState(isUpdateQueueIdle()) delay(1000) } } @@ -876,6 +976,10 @@ class BookshelfViewModel( } } + private fun isUpdateQueueIdle(): Boolean = synchronized(updateQueueLock) { + waitUpTocBooks.isEmpty() && onUpTocBooks.isEmpty() + } + fun addBookByUrl(bookUrls: String) { var successCount = 0 loadingTextFlow.value = "添加中..." diff --git a/app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt b/app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt index 831dd507b..76334bb25 100644 --- a/app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt @@ -41,6 +41,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate @@ -89,7 +90,10 @@ fun ExploreScreen( val context = LocalContext.current val activity = context as? AppCompatActivity val uiState by viewModel.uiState.collectAsStateWithLifecycle() - var sourceToDelete by remember { mutableStateOf(null) } + var sourceToDeleteUrl by rememberSaveable { mutableStateOf(null) } + val sourceToDelete = remember(sourceToDeleteUrl, uiState.items) { + uiState.items.firstOrNull { it.bookSourceUrl == sourceToDeleteUrl } + } val listState = rememberLazyListState() val scope = rememberCoroutineScope() val exploreKindUseCase: ExploreKindUiUseCase = koinInject() @@ -195,7 +199,13 @@ fun ExploreScreen( ) { items( items = uiState.listItems, - key = { it.key } + key = { it.key }, + contentType = { + when (it) { + is ExploreListItem.Header -> "source-header" + is ExploreListItem.KindRow -> "kind-row" + } + } ) { listItem -> when (listItem) { is ExploreListItem.Header -> { @@ -225,7 +235,7 @@ fun ExploreScreen( } }, onRefresh = { viewModel.refreshExploreKinds(item) }, - onDelete = { sourceToDelete = item }, + onDelete = { sourceToDeleteUrl = item.bookSourceUrl }, isMiuix = composeEngine ) } @@ -297,15 +307,15 @@ fun ExploreScreen( AppAlertDialog( data = sourceToDelete, - onDismissRequest = { sourceToDelete = null }, + onDismissRequest = { sourceToDeleteUrl = null }, title = stringResource(R.string.sure_del), confirmText = stringResource(android.R.string.ok), onConfirm = { source -> viewModel.deleteSource(source) - sourceToDelete = null + sourceToDeleteUrl = null }, dismissText = stringResource(android.R.string.cancel), - onDismiss = { sourceToDelete = null }, + onDismiss = { sourceToDeleteUrl = null }, ) } diff --git a/app/src/main/java/io/legado/app/ui/main/explore/ExploreViewModel.kt b/app/src/main/java/io/legado/app/ui/main/explore/ExploreViewModel.kt index 3c51c2f84..8ae0b21fb 100644 --- a/app/src/main/java/io/legado/app/ui/main/explore/ExploreViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/main/explore/ExploreViewModel.kt @@ -12,7 +12,17 @@ import io.legado.app.help.source.getExploreInfoMap import io.legado.app.ui.widget.components.explore.ExploreKindUiUseCase import io.legado.app.ui.widget.components.explore.calculateExploreKindRows import io.legado.app.ui.widget.components.list.ListUiState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -20,6 +30,10 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -39,7 +53,8 @@ class ExploreViewModel( private val _effects = MutableSharedFlow(extraBufferCapacity = 8) val effects = _effects.asSharedFlow() - private var exploreJob: Job? = null + private val searchKeyFlow = MutableStateFlow("") + private val groupFlow = MutableStateFlow("") private var kindsJob: Job? = null init { @@ -52,19 +67,19 @@ class ExploreViewModel( exploreRepository.getExploreGroups() .flowOn(IO) .collectLatest { groups -> - _uiState.update { it.copy(groups = groups) } + _uiState.update { it.copy(groups = groups.toImmutableList()) } } } } fun search(key: String) { + searchKeyFlow.value = key _uiState.update { it.copy(searchKey = key, expandedId = null) } - observeExplore() } fun setGroup(group: String) { + groupFlow.value = group _uiState.update { it.copy(selectedGroup = group, expandedId = null) } - observeExplore() } fun toggleSearchVisible(visible: Boolean) { @@ -74,17 +89,23 @@ class ExploreViewModel( } } + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) private fun observeExplore() { - exploreJob?.cancel() - exploreJob = viewModelScope.launch { - val state = _uiState.value - val query = state.searchKey - val selectedGroup = state.selectedGroup - - exploreRepository.getExploreSources(query, selectedGroup) + viewModelScope.launch { + combine( + searchKeyFlow + .debounce(250) + .distinctUntilChanged(), + groupFlow + ) { query, selectedGroup -> + query to selectedGroup + } + .flatMapLatest { (query, selectedGroup) -> + exploreRepository.getExploreSources(query, selectedGroup) + } .flowOn(IO) .collectLatest { items -> - _uiState.update { it.copy(items = items) } + _uiState.update { it.copy(items = items.toImmutableList()) } } } } @@ -95,9 +116,9 @@ class ExploreViewModel( _uiState.update { it.copy( expandedId = newExpandedId, - exploreKinds = emptyList(), - kindDisplayNames = emptyMap(), - kindValues = emptyMap(), + exploreKinds = persistentListOf(), + kindDisplayNames = persistentMapOf(), + kindValues = persistentMapOf(), loadingKinds = newExpandedId != null ) } @@ -125,9 +146,9 @@ class ExploreViewModel( _uiState.update { if (it.expandedId == source.bookSourceUrl) { it.copy( - exploreKinds = kinds, - kindDisplayNames = displayNames, - kindValues = values, + exploreKinds = kinds.toImmutableList(), + kindDisplayNames = displayNames.toImmutableMap(), + kindValues = values.toImmutableMap(), loadingKinds = false ) } else it @@ -160,7 +181,7 @@ class ExploreViewModel( fun updateKindValue(sourceUrl: String, kind: ExploreKind, value: String) { _uiState.update { state -> - state.copy(kindValues = state.kindValues + (kind.title to value)) + state.copy(kindValues = (state.kindValues + (kind.title to value)).toImmutableMap()) } viewModelScope.launch(IO) { getExploreInfoMap(sourceUrl).apply { @@ -181,23 +202,23 @@ class ExploreViewModel( } data class ExploreUiState( - override val items: List = emptyList(), - override val selectedIds: Set = emptySet(), + override val items: ImmutableList = persistentListOf(), + override val selectedIds: ImmutableSet = persistentSetOf(), override val searchKey: String = "", override val isSearch: Boolean = false, override val isLoading: Boolean = false, - val groups: List = emptyList(), + val groups: ImmutableList = persistentListOf(), val selectedGroup: String = "", val expandedId: String? = null, - val exploreKinds: List = emptyList(), - val kindDisplayNames: Map = emptyMap(), - val kindValues: Map = emptyMap(), + val exploreKinds: ImmutableList = persistentListOf(), + val kindDisplayNames: ImmutableMap = persistentMapOf(), + val kindValues: ImmutableMap = persistentMapOf(), val loadingKinds: Boolean = false, - val listItems: List = emptyList() + val listItems: ImmutableList = persistentListOf() ) : ListUiState - private fun buildExploreListItems(state: ExploreUiState): List { - if (state.items.isEmpty()) return emptyList() + private fun buildExploreListItems(state: ExploreUiState): ImmutableList { + if (state.items.isEmpty()) return persistentListOf() val expandedId = state.expandedId val kindRows = if (expandedId != null) { calculateExploreKindRows(state.exploreKinds, 6) @@ -213,13 +234,13 @@ class ExploreViewModel( ExploreListItem.KindRow( sourceUrl = source.bookSourceUrl, rowIndex = index, - rowItems = row + rowItems = row.toImmutableList() ) ) } } } - } + }.toImmutableList() } private fun buildKindValues( @@ -269,7 +290,7 @@ sealed interface ExploreListItem { data class KindRow( val sourceUrl: String, val rowIndex: Int, - val rowItems: List> + val rowItems: ImmutableList> ) : ExploreListItem { override val key: String = "${sourceUrl}_$rowIndex" } diff --git a/app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt b/app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt index d128400cb..1a8352873 100644 --- a/app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt @@ -36,7 +36,9 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -79,21 +81,27 @@ fun RssScreen( ) { val context = LocalContext.current val uiState by viewModel.uiState.collectAsStateWithLifecycle() - var sourceToDelete by remember { mutableStateOf(null) } + var sourceToDeleteUrl by rememberSaveable { mutableStateOf(null) } + val sourceToDelete = remember(sourceToDeleteUrl, uiState.items) { + uiState.items.firstOrNull { it.sourceUrl == sourceToDeleteUrl } + } + val currentContext by rememberUpdatedState(context) + val currentOnOpenSort by rememberUpdatedState(onOpenSort) + val currentOnOpenRead by rememberUpdatedState(onOpenRead) LaunchedEffect(viewModel) { viewModel.effects.collectLatest { effect -> when (effect) { is RssEffect.OpenSort -> { - onOpenSort(effect.sourceUrl, effect.sortUrl, effect.key) + currentOnOpenSort(effect.sourceUrl, effect.sortUrl, effect.key) } is RssEffect.OpenRead -> { - onOpenRead(effect.title, effect.origin, effect.link, effect.openUrl) + currentOnOpenRead(effect.title, effect.origin, effect.link, effect.openUrl) } is RssEffect.OpenExternalUrl -> { - context.openUrl(effect.url) + currentContext.openUrl(effect.url) } } } @@ -176,7 +184,7 @@ fun RssScreen( onClick = { viewModel.openSource(source) }, onTop = { viewModel.topSource(source) }, onEdit = { edit(source) }, - onDelete = { sourceToDelete = source }, + onDelete = { sourceToDeleteUrl = source.sourceUrl }, onDisable = { viewModel.disable(source) }, onLogin = { login(source) } ) @@ -186,15 +194,15 @@ fun RssScreen( AppAlertDialog( data = sourceToDelete, - onDismissRequest = { sourceToDelete = null }, + onDismissRequest = { sourceToDeleteUrl = null }, title = stringResource(R.string.draw), confirmText = stringResource(R.string.yes), onConfirm = { source -> viewModel.del(source) - sourceToDelete = null + sourceToDeleteUrl = null }, dismissText = stringResource(R.string.no), - onDismiss = { sourceToDelete = null } + onDismiss = { sourceToDeleteUrl = null } ) } diff --git a/app/src/main/java/io/legado/app/ui/main/rss/RssUiState.kt b/app/src/main/java/io/legado/app/ui/main/rss/RssUiState.kt index 153d0c3e0..3955e7808 100644 --- a/app/src/main/java/io/legado/app/ui/main/rss/RssUiState.kt +++ b/app/src/main/java/io/legado/app/ui/main/rss/RssUiState.kt @@ -2,13 +2,17 @@ package io.legado.app.ui.main.rss import io.legado.app.data.entities.RssSource import io.legado.app.ui.widget.components.list.ListUiState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf data class RssUiState( - override val items: List = emptyList(), - override val selectedIds: Set = emptySet(), + override val items: ImmutableList = persistentListOf(), + override val selectedIds: ImmutableSet = persistentSetOf(), override val searchKey: String = "", override val isSearch: Boolean = false, override val isLoading: Boolean = false, - val groups: List = emptyList(), + val groups: ImmutableList = persistentListOf(), val group: String = "" ) : ListUiState diff --git a/app/src/main/java/io/legado/app/ui/main/rss/RssViewModel.kt b/app/src/main/java/io/legado/app/ui/main/rss/RssViewModel.kt index febdb1c60..6739667a3 100644 --- a/app/src/main/java/io/legado/app/ui/main/rss/RssViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/main/rss/RssViewModel.kt @@ -7,6 +7,7 @@ import io.legado.app.base.BaseViewModel import io.legado.app.data.entities.RssSource import io.legado.app.data.repository.RssRepository import io.legado.app.utils.toastOnUi +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow @@ -43,7 +44,7 @@ class RssViewModel( rssRepository.getEnabledGroups() .flowOn(IO) .collect { groups -> - _uiState.update { state -> state.copy(groups = groups) } + _uiState.update { state -> state.copy(groups = groups.toImmutableList()) } } } } @@ -61,7 +62,7 @@ class RssViewModel( } .flowOn(IO) .onEach { sources -> - _uiState.update { state -> state.copy(items = sources) } + _uiState.update { state -> state.copy(items = sources.toImmutableList()) } } .launchIn(viewModelScope) } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/cover/BookshelfCover.kt b/app/src/main/java/io/legado/app/ui/widget/components/cover/BookshelfCover.kt index 011ca4bd2..0dff44ab2 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/cover/BookshelfCover.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/cover/BookshelfCover.kt @@ -20,21 +20,24 @@ fun BookshelfCover( author: String?, path: String?, modifier: Modifier = Modifier, + coverModifier: Modifier = Modifier.fillMaxWidth(), isUpdating: Boolean = false, badgeText: String? = null, showBadgeDot: Boolean = false, leftBottomText: String? = null, sourceOrigin: String? = null, - onLoadFinish: (() -> Unit)? = null + onLoadFinish: (() -> Unit)? = null, + showLoadingPlaceholder: Boolean = true, ) { Box(modifier = modifier) { CoilBookCover( name = name, author = author, path = path, - modifier = Modifier.fillMaxWidth(), + modifier = coverModifier, sourceOrigin = sourceOrigin, - onLoadFinish = onLoadFinish + onLoadFinish = onLoadFinish, + showLoadingPlaceholder = showLoadingPlaceholder, ) if (!badgeText.isNullOrEmpty()) { diff --git a/app/src/main/java/io/legado/app/ui/widget/components/cover/CoilBookCover.kt b/app/src/main/java/io/legado/app/ui/widget/components/cover/CoilBookCover.kt index 78e0fd39a..dd8b1899a 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/cover/CoilBookCover.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/cover/CoilBookCover.kt @@ -41,6 +41,7 @@ import coil.compose.AsyncImage import io.legado.app.ui.config.coverConfig.CoverConfig import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.widget.components.card.NormalCard +import kotlinx.coroutines.delay import org.koin.compose.koinInject import io.legado.app.model.BookCover as BookCoverModel @@ -52,7 +53,8 @@ fun CoilBookCover( modifier: Modifier = Modifier.width(64.dp), sourceOrigin: String? = null, onLoadFinish: (() -> Unit)? = null, - ignoreUseDefaultCover: Boolean = false + ignoreUseDefaultCover: Boolean = false, + showLoadingPlaceholder: Boolean = true, ) { val context = LocalContext.current val isNight = isSystemInDarkTheme() @@ -75,9 +77,23 @@ fun CoilBookCover( var isOnlineCoverLoaded by remember(path) { mutableStateOf(false) } var isFinalPathLoaded by remember(path) { mutableStateOf(false) } + var isFinalPathLoadFinished by remember(path) { mutableStateOf(false) } + var isPlaceholderAllowed by remember(path, showLoadingPlaceholder) { + mutableStateOf(showLoadingPlaceholder) + } LaunchedEffect(finalPath) { isOnlineCoverLoaded = false isFinalPathLoaded = false + isFinalPathLoadFinished = finalPath == null + } + LaunchedEffect(finalPath, showLoadingPlaceholder) { + if (showLoadingPlaceholder) { + isPlaceholderAllowed = true + } else { + isPlaceholderAllowed = false + delay(600) + isPlaceholderAllowed = true + } } NormalCard( @@ -133,10 +149,12 @@ fun CoilBookCover( onSuccess = { isOnlineCoverLoaded = true isFinalPathLoaded = true + isFinalPathLoadFinished = true onLoadFinish?.invoke() }, onError = { isOnlineCoverLoaded = false + isFinalPathLoadFinished = true onLoadFinish?.invoke() } ) @@ -146,7 +164,9 @@ fun CoilBookCover( } } - if (!hasCustomDefault && !isOnlineCoverLoaded) { + val showPlaceholder = isPlaceholderAllowed && (showLoadingPlaceholder || isFinalPathLoadFinished) + + if (!hasCustomDefault && !isOnlineCoverLoaded && showPlaceholder) { Icon( Icons.Default.Book, contentDescription = null, @@ -157,7 +177,7 @@ fun CoilBookCover( ) } - if (!isOnlineCoverLoaded) { + if (!isOnlineCoverLoaded && showPlaceholder) { CoverTextOverlay( name = name, author = author, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3d734816b..847671385 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,7 @@ kotlin = "2.3.10" kotlinxSerialization = "1.10.0" kotlinxCoroutinesAndroid = "1.10.2" kotlinxSerializationJson = "1.10.0" +kotlinxCollectionsImmutable = "0.4.0" ksp = "2.3.6" agp = "9.1.1" appcompat = "1.7.1" @@ -158,6 +159,7 @@ androidx-junit = { module = "androidx.test.ext:junit", version = "1.3.0" } hutool-crypto = { module = "cn.hutool:hutool-crypto", version.ref = "hutool" } kotlinx-coroutines-android-v181 = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" } +kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } kotlinx-serialization-json-v163 = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } libarchive = { module = "me.zhanghai.android.libarchive:library", version.ref = "libarchive" } lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "lifecycle" }