diff --git a/.gitignore b/.gitignore index b3972f34..be3f6f5b 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,21 @@ build/ /output /.kotlin /cmake/build* + +# Tooling / AI assistants +.github/ +.gemini/ +.claude/ +agent-os/ +.beans/ +.beans.yml +CLAUDE.md + +# Design / working files +New UI/ + +# Pre-existing vendored third-party repos +third_party/secret-service/ + +# Prebuilt native libraries +**/androidMain/jniLibs/**/*.so diff --git a/ADAPTIVE_READER_BACKGROUND_PLAN.md b/ADAPTIVE_READER_BACKGROUND_PLAN.md new file mode 100644 index 00000000..8b95cb20 --- /dev/null +++ b/ADAPTIVE_READER_BACKGROUND_PLAN.md @@ -0,0 +1,114 @@ +# Adaptive Reader Background (Edge-Sampled Gradients) Implementation Plan + +This plan outlines the implementation of an "Adaptive Background" feature for the comic reader in Komelia. This feature improves visual immersion by replacing solid letterbox/pillarbox bars with a two-color gradient sampled from the current page's edges. + +## 1. Feature Overview +When a page does not perfectly fill the screen (due to "Fit to Screen" settings), the empty space (letterbox or pillarbox) will be filled with a gradient. +- **Top/Bottom gaps (Letterbox):** Vertical gradient from Top Edge Color to Bottom Edge Color. +- **Left/Right gaps (Pillarbox):** Horizontal gradient from Left Edge Color to Right Edge Color. +- **Panel Mode:** Uses the edge colors of the *full page* even when zoomed into a panel. +- **Configurability:** Independent toggles for Paged Mode and Panel Mode in Reader Settings. + +## 2. Technical Strategy + +### A. Color Sampling (Domain/Infra) +We need an efficient way to extract the average color of image edges using the `KomeliaImage` (libvips) abstraction. + +1. **Utility Function:** Create `getEdgeColors(image: KomeliaImage): Pair` (or similar) in `komelia-infra/image-decoder`. +2. **Implementation:** + - To get Top/Bottom colors: + - Extract a small horizontal strip from the top (e.g., full width, 10px height). + - Shrink the strip to 1x1. + - Repeat for the bottom. + - To get Left/Right colors: + - Extract a vertical strip from the left (e.g., 10px width, full height). + - Shrink to 1x1. + - Repeat for the right. +3. **Efficiency:** Libvips is optimized for these operations; it will avoid full decompression where possible and perform the resize/averaging in a streaming fashion. + +### B. State Management +1. **Settings:** + - Add `pagedReaderAdaptiveBackground` and `panelReaderAdaptiveBackground` to `ImageReaderSettingsRepository`. + - Update `PagedReaderState` and `PanelsReaderState` to collect these settings. +2. **Page State:** + - Add `edgeColors: Pair?` to the `Page` data class in `PagedReaderState`. + - When a page is loaded, trigger the background sampling task asynchronously. + - `PanelsReaderState` will track the edge colors of the *current page* it is showing panels for. + +### C. UI Implementation (Compose) +1. **AdaptiveBackground Composable:** + - Location: `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/` + - Parameters: `topColor: Color`, `bottomColor: Color`, `orientation: Orientation`. + - Use `animateColorAsState` for smooth transitions between pages. + - Use `Brush.linearGradient` to draw the background. +2. **Integration:** + - Wrap `ReaderImageContent` in `PagedReaderContent` and `PanelsReaderContent` with the new `AdaptiveBackground` component. + - Pass the colors based on whether the feature is enabled in the current mode's settings. + +### D. Settings UI +1. **Reader Settings:** + - Add two new toggles in `SettingsContent.kt` (used by `BottomSheetSettingsOverlay` and `SettingsSideMenu`). + - Labels: "Adaptive Background (Paged)" and "Adaptive Background (Panels)". + - Position them near "Double tap to zoom" for consistency. + +## 3. Implementation Steps + +1. **Infra:** Implement the color sampling logic in `komelia-infra/image-decoder`. +2. **Domain:** Update `ImageReaderSettingsRepository` interface and its implementation (e.g., `AndroidImageReaderSettingsRepository`). +3. **State:** + - Update `PagedReaderState` to perform color sampling when images are loaded. + - Update `PanelsReaderState` to share this logic for its current page. +4. **UI:** + - Create the `AdaptiveBackground` composable. + - Update the settings screens to include the toggles. + - Connect the state to the UI to render the gradients. + +## 4. Edge Cases & Considerations +- **Transparent Images:** Sampled colors should consider the background (likely white) if the image has transparency. +- **Very Thin Margins:** If the "Fit to Screen" fills the entire screen, the background won't be visible (current behavior preserved). +- **Performance:** Ensure sampling happens on a background thread and doesn't block the UI or delay page rendering. +- **Color Consistency:** Sampled colors can be slightly desaturated or darkened if they are too bright and distracting. + +## Phase 2: Per-Pixel Edge Gradients (Blooming Effect) +This phase improves the background by preserving the color variation along the image edges and fading them into the theme's background. + +### 1. Technical Strategy +- **Sampling:** Instead of a single 1x1 average, sample a 1D line of colors. + - Top/Bottom: Extract 10px strip, resize to `width x 1`. + - Left/Right: Extract 10px strip, resize to `1 x height`. +- **Rendering:** + - Draw the 1D sampled line stretched across the gap (creating color bars). + - Apply a gradient overlay from `Transparent` (image side) to `ThemeBackground` (screen edge side). + +## Phase 2.5: Image-Relative Bloom Gradients +This fix ensures the background "bloom" starts exactly at the image edges rather than the center of the screen. + +### 1. Technical Strategy +- **Image Bounds:** Retrieve the actual display dimensions and position of the image within the container. +- **Top Bloom:** Start at the `image.top` with `Color.Transparent` (showing full colors) and fade to `MaterialTheme.colorScheme.background` at the screen `top (0)`. +- **Bottom Bloom:** Start at the `image.bottom` with `Color.Transparent` and fade to `MaterialTheme.colorScheme.background` at the screen `bottom (height)`. +- **Logic:** The "colorful" part of the background should "leak" from the image outward to the screen edges. + +### 2. Implementation Steps +1. **UI:** Update `AdaptiveBackground` to accept the `imageSize` or `imageBounds`. +2. **Rendering:** Recalculate the `drawRect` and `Brush` coordinates to align with these bounds. + +## Phase 3: Four-Side Sampling & Corner Blending (Panel Mode Optimization) +This phase addresses scenarios where gaps exist on all four sides of the image, which is common in Panel Mode. + +### 1. Technical Strategy +- **Sampling:** Always sample all four edges (Top, Bottom, Left, Right). +- **Rendering:** + - Create four independent gradient zones. + - **Corner Miter:** Use a 45-degree clipping or alpha-blending in the corners so that adjacent edge colors (e.g., Top and Left) meet seamlessly. +- **Panel Padding:** Ensure the background fills the additional "safety margin" or padding added around panels, providing a consistent immersive feel even when the panel is much smaller than the screen. + +### 2. Implementation Steps +1. **Infra:** Update sampling to return all four edge lines. +2. **UI:** Update `AdaptiveBackground` to render four zones with corner blending logic. +3. **Panel Mode:** Verify integration with panel zooming and padding logic. + +### 2. Implementation Steps +1. **Infra:** Add `getEdgeColorLines()` to `KomeliaImage` / `ReaderImageUtils`. +2. **UI:** Create a version of `AdaptiveBackground` that handles `ImageBitmap` buffers and applies the "bloom" fade. +3. **Switching:** Maintain both Phase 1 and Phase 2 logic for easy comparison/toggling during development. diff --git a/CARD_LAYOUT_PLAN.md b/CARD_LAYOUT_PLAN.md new file mode 100644 index 00000000..8e963d20 --- /dev/null +++ b/CARD_LAYOUT_PLAN.md @@ -0,0 +1,67 @@ +# Proposal: Adaptive Library Card Layout + +## Objective +Introduce a new layout option for library items (Books, Series, Collections, Read Lists) that places text metadata below the thumbnail in a structured Material 3 Filled Card, improving readability and providing a more traditional "bookshelf" aesthetic. + +## 1. User Interface Changes + +### Appearance Settings +- **New Toggle**: `Card Layout` +- **Options**: + - `Overlay` (Current default): Text appears on top of the thumbnail with a gradient overlay. + - `Below`: Text appears in a dedicated area below the thumbnail. +- **Description**: "Show title and metadata below the thumbnail instead of on top." +- **Preview**: The card size slider preview in the settings will adapt to show the selected layout. + +### Card Design (`Below` Layout) +- **Dimensions**: + - **Width**: Strictly aligned to the `cardWidth` setting (same as `Overlay` layout). + - **Height**: Calculated dynamically based on the thumbnail's aspect ratio plus the fixed height of the text area. +- **Container**: + - **Type**: Material 3 Filled Card. + - **Colors**: `surfaceContainerHighest` for the container, following Material 3 guidelines for both Light and Dark themes. + - **Corners**: The card container will have rounded corners on all four sides (M3 standard, typically 12dp). +- **Thumbnail**: + - Fills the top, left, and right edges of the card. + - Maintains the existing 0.703 aspect ratio. + - **Corners**: Rounded corners **only on the top** to match the card's top profile; bottom of the thumbnail remains square where it meets the text area. +- **Text Area**: + - Located directly beneath the thumbnail. + - **Title**: Maximum of 2 lines, ellipsis on overflow. + - **Padding**: 8dp to 10dp padding around text elements to ensure M3 spacing standards. + +## 2. Technical Implementation + +### Persistence (`komelia-domain` & `komelia-infra`) +- Add `cardLayoutBelow` to `AppSettings` data class in `komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/AppSettings.kt`. +- **Database Migrations**: + - Add `V19__card_layout_below.sql` migration for SQLite (Android/Desktop) in `komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/`. + - Update `AppSettingsTable.kt` in `komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/` to include the new column. + - Update `ExposedSettingsRepository.kt` in `komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/` to map the new column to the `AppSettings` object. +- Update `CommonSettingsRepository` and `SettingsRepositoryWrapper` to handle this new preference. + +### UI State (`komelia-ui`) +- Define `LocalCardLayoutBelow` in `CompositionLocals.kt`. +- Update `MainView.kt` to collect the setting and provide it to the composition. +- Enhance `ItemCard` in `ItemCard.kt` to act as the layout engine: + - If `Overlay`: Use existing `Box` structure. + - If `Below`: Use `Column` with thumbnail followed by a content area. + +### Component Updates +- **SeriesItemCard.kt**: + - Extract `SeriesImageOverlay` logic. + - Pass title and unread count to `ItemCard`'s content slot when in `Below` mode. +- **BookItemCard.kt**: + - Ensure the read progress bar and "unread" indicators are correctly positioned. + - Handle book titles and series titles (if enabled) in the text area. +- **Other Cards**: Apply similar changes to `CollectionItemCard.kt` and `ReadListItemCard.kt`. + +## 3. Implementation Phases +1. **Phase 1: Domain & Infrastructure**: Update settings storage and repository layers. +2. **Phase 2: Settings UI**: Add the toggle to the Appearance settings screen and ViewModel. +3. **Phase 3: Base Component Refactoring**: Update `ItemCard` to support dual-layout switching. +4. **Phase 4: Content Implementation**: Update Series, Book, Collection, and Read List cards to fill the "Below" layout content slot. +5. **Phase 5: Visual Polish**: Finalize padding, M3 colors, and layout constraints (max 2 lines). + +## 4. Default Behavior +- The default will remain as the `Overlay` layout to preserve the current user experience until explicitly changed by the user. diff --git a/DISABLE_DOUBLE_TAP_PLAN.md b/DISABLE_DOUBLE_TAP_PLAN.md new file mode 100644 index 00000000..432fd459 --- /dev/null +++ b/DISABLE_DOUBLE_TAP_PLAN.md @@ -0,0 +1,70 @@ +# Implementation Plan: Disable Double-Tap to Zoom + +## Goal +Allow users to disable the double-tap gesture to zoom. This eliminates the system's wait-time for a second tap, making the single-tap (for page/panel turns) feel instantaneous. + +--- + +## 1. Database Migration +**File**: `komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V17__reader_tap_settings.sql` +```sql +ALTER TABLE ImageReaderSettings ADD COLUMN tap_to_zoom BOOLEAN NOT NULL DEFAULT 1; +``` + +**File**: `komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/migrations/AppMigrations.kt` +- Add `"V17__reader_tap_settings.sql"` to the `migrations` list. + +--- + +## 2. Persistence Layer Updates + +**File**: `komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/ImageReaderSettingsTable.kt` +```kotlin + val tapToZoom = bool("tap_to_zoom").default(true) +``` + +**File**: `komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/ImageReaderSettings.kt` +```kotlin + val tapToZoom: Boolean = true, +``` + +**File**: `komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/ImageReaderSettingsRepository.kt` +```kotlin + fun getTapToZoom(): Flow + suspend fun putTapToZoom(enabled: Boolean) +``` + +**File**: `komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/repository/ReaderSettingsRepositoryWrapper.kt` +- Implement `getTapToZoom` and `putTapToZoom`. + +**File**: `komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedImageReaderSettingsRepository.kt` +- Map `tapToZoom` in `get()` and `save()`. + +--- + +## 3. State Management +**File**: `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/ReaderState.kt` (since it applies to multiple modes) +- Load `tapToZoom` from repository. +- Provide a `onTapToZoomChange(Boolean)` handler. + +--- + +## 4. UI Layer (Settings) +Add a "Tap to zoom" toggle to the reading mode settings for both **Paged** and **Panels** modes. + +**Files**: +- `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/BottomSheetSettingsOverlay.kt` (Mobile) +- `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/SettingsSideMenu.kt` (Desktop) + +--- + +## 5. Gesture Integration +**File**: `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ReaderContent.kt` +- Modify `ReaderControlsOverlay` to accept a `tapToZoom: Boolean` parameter. +- Update `detectTapGestures`: +```kotlin +detectTapGestures( + onTap = { ... }, + onDoubleTap = if (tapToZoom) { offset -> ... } else null +) +``` diff --git a/GESTURE_SYSTEM_FIX.md b/GESTURE_SYSTEM_FIX.md new file mode 100644 index 00000000..48a7caf1 --- /dev/null +++ b/GESTURE_SYSTEM_FIX.md @@ -0,0 +1,74 @@ +# Gesture System: Simultaneous Pan/Zoom & Velocity Fix + +This document records the implementation of the verified solution for the comic reader's gesture handling system. + +## Problems Addressed + +1. **Pan-Zoom Lock**: The gesture detector prevented panning whenever a zoom change was detected, making the UI feel stiff and unresponsive during multi-touch gestures. +2. **Velocity Jumps (The "Leap" Bug)**: When lifting one finger during a two-finger gesture, the "centroid" (the center point between fingers) would suddenly jump from the center of two fingers to the position of the remaining finger. This one-frame jump was interpreted as extreme velocity, causing the image to fly violently off-screen. +3. **Continuous Mode Regression**: Previous attempts to fix these issues often broke the native kinetic scrolling of the `LazyColumn` in continuous mode by either consuming events prematurely or introducing asynchronous timing mismatches. + +## The Solution: "Surgical Frame Filtering" + +The implementation uses a non-invasive approach that filters input data before it reaches the movement logic, ensuring the output behavior remains compatible with native scrolling. + +### 1. Pointer Count Stability Tracking +We added tracking for the number of active fingers (`lastIterationPointerCount`) inside the `detectTransformGestures` loop in `ScalableContainer.kt`. + +- **Mechanism**: We only apply zoom and pan changes if the pointer count is **stable** (exactly the same as the previous frame). +- **Result**: This automatically ignores the single "jump frame" that occurs at the exact millisecond a finger is added or removed. + +### 2. Synchronized Velocity Reset +We added a `resetVelocity()` method to `ScreenScaleState.kt` to clear the `VelocityTracker`'s history. + +- **Mechanism**: This is called whenever the finger count changes. +- **Result**: It ensures that the velocity for the "new" gesture (e.g., transitioning from two-finger zoom to one-finger pan) is calculated from a clean slate, preventing the jump from being factored into the momentum. + +### 3. Native Scroll Preservation +Crucially, the implementation continues to **not consume** the pointer events. + +- **Result**: Because the events are not consumed, the `LazyColumn` in continuous mode can still see the vertical movement and handle it using its own internal, highly-optimized physics. This preserves the smooth, kinetic feel of the vertical scroll while allowing simultaneous pan/zoom. + +## Implementation Details + +### Files Modified: +- **`komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/ScreenScaleState.kt`** + - Added `resetVelocity()` helper. +- **`komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ScalableContainer.kt`** + - Implemented `lastIterationPointerCount` tracking. + - Removed the `if/else` block that prevented simultaneous pan/zoom. + - Integrated `resetVelocity()` calls on pointer count changes. + +--- + +## Smooth Mode-Aware Double Tap to Zoom (Implemented) + +### Problem: The "Fit Height" Jump +Previously, double-tapping to zoom out would always return the image to a zoom level of 1.0 (Fit Height). If a user was reading in "Fit Width" or a padded webtoon mode, this behavior was jarring as it forced them out of their preferred layout. + +### The Solution: "Layout Base Zoom" +We implemented a system where the reader remembers the "base" zoom level intended by the current reading mode and uses it as the target for zooming out. + +#### 1. Base Zoom Tracking +In `ScreenScaleState.kt`, we added a `baseZoom` property. +- **Mechanism**: The layout engines (Paged, Continuous, Panels) now flag their initial zoom calculations as "Base Zoom" using `setZoom(..., updateBase = true)`. +- **Result**: `ScreenScaleState` always knows what the "correct" zoom level is for the current mode (e.g., 1.2x for Fit Width). + +#### 2. Animated Mode Toggle +We implemented a `toggleZoom(focus)` function that provides a smooth, kinetic transition. +- **Animation**: Uses a `SpringSpec` (`StiffnessLow`) for a natural, decelerating feel. +- **Logic**: + - If current zoom > base: Zoom out to `baseZoom`. + - If current zoom <= base: Zoom in to `max(base * 2.5, 2.5)`. +- **Focus Preservation**: The tapped point (`focus`) remains stationary under the finger as the image expands or contracts around it. + +#### 3. Reader Mode Integration +The solution is integrated across all reader modes to ensure consistent behavior: +- **Paged Mode**: Returns to "Fit Width", "Fit Height", or "Original" as defined by the user settings. +- **Continuous Mode**: Returns to the padded column width (Webtoon style). +- **Panels Mode**: Returns to the specific fit level of the current panel. + +### Files Modified: +- **`ScreenScaleState.kt`**: Implemented `baseZoom` tracking and the smooth `toggleZoom` animation. +- **`ReaderContent.kt`**: Integrated `onDoubleTap` into the `ReaderControlsOverlay` gesture detector. +- **`PagedReaderState.kt`, `ContinuousReaderState.kt`, `PanelsReaderState.kt`**: Updated layout logic to set the `baseZoom`. diff --git a/IMMERSIVE_TRANSITION_ANALYSIS.md b/IMMERSIVE_TRANSITION_ANALYSIS.md new file mode 100644 index 00000000..7cb8b609 --- /dev/null +++ b/IMMERSIVE_TRANSITION_ANALYSIS.md @@ -0,0 +1,46 @@ +# Analysis: Immersive Detail Screen Smoothness and Flickering + +## 1. Problem Description +Users experience visual instability when navigating to a book or oneshot page in immersive mode from a series page. The issues include: +* **Visual "Jumps"**: The content card moves inconsistently during the opening animation. +* **Layering Issues**: The cover image appears above the content card briefly before the card "pops" to the front. +* **Image Flickering**: The image seems to load once, then "flashes" or reloads shortly after the screen opens. + +These issues do not occur when opening the immersive series screen from the Library or Home screens. + +--- + +## 2. 1-to-1 Comparison: Why Series Works and Books Don't + +| Feature | Series Transition (Working) | Book Transition (Buggy) | +| :--- | :--- | :--- | +| **Layout Level** | Root of the screen. | Nested inside a **HorizontalPager**. | +| **Constraints** | Uses stable screen height. | Recalculates height every frame due to morphing. | +| **Data Flow** | Data is static for the screen. | **Asynchronous**: Pager re-layouts after opening. | +| **Crossfade** | Managed by `SeriesThumbnail`. | **Hardcoded to `true`** in the scaffold. | + +--- + +## 3. Implementation Plan: Replicating Series Smoothness + +To make the book transition behave identically to the working series transition, the following changes are required: + +### I. Stabilize Layout Constraints (Fixes the "Jump") +The current scaffold applies `sharedBounds` to a `BoxWithConstraints`. As the shared transition morphs the thumbnail into the full screen, the `maxHeight` changes every frame, causing the card's position (calculated as `0.65 * maxHeight`) to jump. +* **Fix**: Move the `sharedBounds` modifier from the `BoxWithConstraints` to the inner `Box`. This ensures the layout logic always sees the full stable screen height, while the contents still morph visually. + +### II. Synchronize Pager State (Fixes the "Flicker") +The series screen is smooth because it is static. The book screen flickers because the `HorizontalPager` starts with 1 item and then "jumps" or reloads once the full list of books arrives from the server. +* **Fix**: Update `SeriesScreen` to pass the currently loaded list of books to the `BookScreen` during navigation. This allows the pager to initialize with the correct page count and index from frame one, making it as stable as the series screen. + +### III. Explicit Layering (Fixes the Layering Issue) +When using shared transitions, elements are rendered in a top-level overlay. Standard composition order can be ignored in this mode. +* **Fix**: Apply explicit `.zIndex(0f)` to the background image and `.zIndex(1f)` to the content card in `ImmersiveDetailScaffold`. This forces the card to remain on top of the image throughout the entire animation. + +### IV. Disable Redundant Animations (Fixes the Image Flash) +During a shared transition, the image is already being visually morphed from the source thumbnail. If the destination image also performs its own `crossfade`, it creates a visual "double load" flash. +* **Fix**: Update the scaffold to disable the `ThumbnailImage` crossfade whenever a shared transition is active, matching the logic already used in the working series thumbnail. + +### V. Transition Key Alignment (Fixes Oneshots) +Navigating to a oneshot from a book list currently fails to trigger a shared transition because of an ID mismatch (Source uses `bookId`, Destination uses `seriesId`). +* **Fix**: Update `ImmersiveOneshotContent` to use the `bookId` for its transition key when navigated to from a book list context. diff --git a/MATERIAL_3_ALIGNMENT_PLAN.md b/MATERIAL_3_ALIGNMENT_PLAN.md new file mode 100644 index 00000000..0afdb3d5 --- /dev/null +++ b/MATERIAL_3_ALIGNMENT_PLAN.md @@ -0,0 +1,145 @@ +# Material 3 Alignment Plan: Navigation, FABs, and Menus - COMPLETED Feb 2026 + +This document outlines the plan to align the core navigation, Floating Action Buttons (FABs), and Dropdown Menus across the Komelia application with the Material 3 (M3) specification. + +## 1. Bottom Navigation Bar Alignment (New UI Mode Only) - DONE + +### Target Files & Functions +1. **`komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt`** + * **Modify**: `MobileLayout(navigator, vm)` to use a single `Scaffold` for both UI modes. + * **Delete**: `PillBottomNavigationBar(...)` and `PillNavItem(...)` composables. + * **Create**: `AppNavigationBar(navigator, toggleLibrariesDrawer)` using M3 `NavigationBar`. +2. **`komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/MobileSettingsScreen.kt`** + * **Modify**: `Content()` to remove the bottom `Spacer` and `windowInsetsBottomHeight`. +3. **`komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/SeriesLists.kt`** + * **Modify**: `SeriesLazyCardGrid(...)` to review and likely remove/reduce the `65.dp` bottom content padding in `LazyVerticalGrid` to rely on `Scaffold` padding. +4. **`komelia-ui/src/commonMain/kotlin/snd/komelia/ui/home/HomeContent.kt`** + * **Modify**: `DisplayContent(...)` to remove the manual `50.dp` bottom padding in `LazyColumn` and `LazyVerticalGrid`. + +### Implementation Details + +#### 1.1 Unify `MobileLayout` in `MainScreen.kt` +Refactor `MobileLayout` to use a single `Scaffold` regardless of the UI mode. This ensures consistent anchoring and proper window inset handling via `PaddingValues`. + +* **Scaffold structure**: + ```kotlin + val isImmersiveScreen = navigator.lastItem is SeriesScreen || + navigator.lastItem is BookScreen || + navigator.lastItem is OneshotScreen + + Scaffold( + bottomBar = { + if (!isImmersiveScreen) { + if (useNewLibraryUI) { + AppNavigationBar( + navigator = navigator, + toggleLibrariesDrawer = { coroutineScope.launch { vm.toggleNavBar() } } + ) + } else { + StandardBottomNavigationBar( + navigator = navigator, + toggleLibrariesDrawer = { coroutineScope.launch { vm.toggleNavBar() } }, + modifier = Modifier + ) + } + } + } + ) { paddingValues -> + ModalNavigationDrawer( + drawerState = vm.navBarState, + drawerContent = { LibrariesNavBar(vm, navigator) }, + content = { + Box(Modifier.padding(paddingValues).consumeWindowInsets(paddingValues).statusBarsPadding()) { + // Apply AnimatedContent/CurrentScreen logic here + } + } + ) + } + ``` +* **Removal**: Delete `PillBottomNavigationBar` and `PillNavItem`. + +#### 1.2 Implement `AppNavigationBar` (M3 Expressive) +* **Component**: Use `androidx.compose.material3.NavigationBar`. +* **Styling**: + * Set `alwaysShowLabel = true`. + * Use `LocalNavBarColor.current` for `containerColor`. +* **Items & Icons (Rounded variants)**: + 1. **Libraries**: `Icons.Rounded.LocalLibrary`. Toggles side drawer via `vm.toggleNavBar()`. + 2. **Home**: `Icons.Rounded.Home`. `navigator.replaceAll(HomeScreen())`. Selected if `lastItem is HomeScreen`. + 3. **Search**: `Icons.Rounded.Search`. `navigator.push(SearchScreen(null))`. Selected if `lastItem is SearchScreen`. + 4. **Settings**: `Icons.Rounded.Settings`. **CRITICAL**: Use `navigator.push(MobileSettingsScreen())` (NOT `navigator.parent!!.push`). This keeps the bottom bar visible. Selected if `lastItem is SettingsScreen`. + +#### 1.3 `MobileSettingsScreen.kt` Cleanup +* **Function**: `Content()` +* **Change**: Delete `Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars))` and any hardcoded bottom padding in the main `Column`. The `Scaffold`'s `paddingValues` in `MainScreen` will manage this. + +#### 1.4 Padding Cleanup in Lists +* **`SeriesLists.kt`**: In `SeriesLazyCardGrid`, reduce `bottom = navBarBottom + 65.dp` in `contentPadding` to rely on the parent `Scaffold` padding. +* **`HomeContent.kt`**: In `DisplayContent`, remove `contentPadding = PaddingValues(bottom = 50.dp)` from `LazyColumn` and `LazyVerticalGrid`. + +### Changes included: +* **Exclusion**: Immersive screens (`SeriesScreen`, `BookScreen`, `OneshotScreen`) hide the bar. +* **Anchoring**: The bar is anchored to the bottom using `Scaffold`. +* **UI Isolation**: `StandardBottomNavigationBar` remains unchanged for users with "New UI" disabled. + +--- + +## 2. Floating Action Buttons (FABs) - DONE + +### Target +* **File**: `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailFab.kt` +* **Current State**: Custom split-pill design for "Read Now" and "Incognito". +* **M3 Target**: Separate into standard M3 FAB components (Expressive update). +* **Changes**: + * "Read Now": **`ExtendedFloatingActionButton`** with `Icons.AutoMirrored.Rounded.MenuBook`. + * **Color**: Use `LocalNavBarColor.current ?: MaterialTheme.colorScheme.primaryContainer`. + * **Shape**: Default M3 Squircle (`large`). + * "Incognito": **`FloatingActionButton`** with `Icons.Rounded.VisibilityOff`. + * **Color**: Default M3 FAB color (`PrimaryContainer`). + * **Shape**: Default M3 Squircle (`large`). + * "Download": **`FloatingActionButton`** with `Icons.Rounded.Download`. + * **Color**: Default M3 FAB color (`PrimaryContainer`). + * **Shape**: Default M3 Squircle (`large`). + * Ensure proper spacing and alignment at the bottom of the immersive screen. + +--- + +## 3. Dropdown Menus (Action Menus) - DONE + +### Targets +* `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/SeriesActionsMenu.kt` +* `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/BookActionsMenu.kt` +* `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/OneshotActionsMenu.kt` +* `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/LibraryActionsMenu.kt` +* `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/CollectionActionsMenu.kt` +* `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/ReadListActionsMenu.kt` + +### M3 Target +* Standardize visual hierarchy with **leading icons** and M3 typography. + +### Changes +* **Leading Icons**: Added icons to all items (e.g., Edit, Delete, Mark as Read, Analyze). +* **Typography**: Use `MaterialTheme.typography.labelLarge`. +* **Colors**: Use `MaterialTheme.colorScheme.error` for destructive actions via `MenuItemColors`. +* **Cleanup**: Removed manual hover background overrides in favor of standard M3 states. + +--- + +## 4. Card & Toolbar Triggers - DONE + +### Targets +* `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/SeriesItemCard.kt` +* `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/BookItemCard.kt` +* `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/SeriesImageCard.kt` +* `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/BookImageCard.kt` + +### Changes +* Ensure all 3-dot triggers use `IconButton` with `Icons.Rounded.MoreVert`. +* Verify 48dp touch targets for all menu triggers. + +--- + +## 5. Verification Plan - DONE +1. **Navigation**: Ensure the M3 NavigationBar appears only when "New UI" is ON and is hidden on immersive screens. +2. **FABs**: Verify the new FAB layout doesn't overlap content and respects accent colors. +3. **Menus**: Confirm icons are aligned and destructive actions are correctly colored. diff --git a/PAGED_SWIPE_PLAN.md b/PAGED_SWIPE_PLAN.md new file mode 100644 index 00000000..bd83ebd3 --- /dev/null +++ b/PAGED_SWIPE_PLAN.md @@ -0,0 +1,100 @@ +# Implementation Plan: Paged Mode Sticky Swipe Navigation + +## Goal +Implement a high-quality "Sticky Swipe" navigation for the Paged Reader. +- **Requirement 1 (The Barrier)**: If the image is zoomed in, swiping should pan the image. When hitting the edge, the movement must stop (no immediate page turn). A second, separate swipe starting from the edge is required to turn the page. +- **Requirement 2 (The Control)**: Page turns must be manual and controllable. The user should see the next page sliding in under their finger. +- **Requirement 3 (Kinetic Completion)**: Releasing a swipe should smoothly and kinetically complete the transition to the next page or snap back to the current one. +- **Requirement 4 (Safety)**: These changes must not affect Continuous (Webtoon) mode or Panels mode. + +--- + +## 1. TransformGestureDetector.kt +**Change**: Convert the gesture detector to be fully synchronous and lifecycle-aware. +- Make the `onGesture` callback a `suspend` function. +- Add an `onEnd` `suspend` callback. +- **Reason**: This allows the gesture loop to wait for the UI (Pager/LazyColumn) to finish its `scrollBy` before processing the next millisecond of touch data. This is the foundation of "frame-perfect" kinetic movement. + +```kotlin +suspend fun PointerInputScope.detectTransformGestures( + panZoomLock: Boolean = false, + onGesture: suspend (changes: List, centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit, + onEnd: suspend () -> Unit = {} +) { + // Wrap existing loop in awaitEachGesture + // Call onGesture with 'suspend' + // Invoke onEnd() when the touch loop finishes (all fingers up) +} +``` + +--- + +## 2. ScreenScaleState.kt +**Change**: Add the "Sticky" logic and synchronize the scrolling handoff. + +- **New Properties**: + - `edgeHandoffEnabled: Boolean` (Default: `false`). Only set to `true` by Paged Mode. + - `gestureStartedAtHorizontalEdge: Boolean`: Internal flag to track if a swipe is allowed to turn the page. + - `cumulativePagerScroll: Float`: Tracks total pager movement during one gesture to prevent skipping multiple pages. + - `isGestureInProgress: MutableStateFlow`: Used for UI snapping. + - `isFlinging: MutableStateFlow`: Used for UI snapping. + +- **Methods**: + - `onGestureStart()`: Sets `gestureStartedAtHorizontalEdge = isAtHorizontalEdge()`. + - `isAtHorizontalEdge()`: Returns true if image is zoomed out OR currently clamped to a left/right boundary. + - `addPan(...)`: Convert to `suspend`. Only call `applyScroll` if `!edgeHandoffEnabled || gestureStartedAtHorizontalEdge`. + - `applyScroll(...)`: Convert to `suspend`. Remove `scrollScope.launch`. Implement the "Single-Page Constraint" by clamping `cumulativePagerScroll` to `+/- ScreenWidth`. + +--- + +## 3. ScalableContainer.kt +**Change**: Integrate the new gesture lifecycle. + +```kotlin +.pointerInput(areaSize) { + detectTransformGestures( + onGesture = { changes, centroid, pan, zoom, _ -> + // On first iteration of a new touch: + if (!scaleState.isGestureInProgress.value) { + scaleState.onGestureStart() + scaleState.isGestureInProgress.value = true + } + + // ... existing pointer count / velocity reset logic ... + + if (pointerCountStable) { + scaleState.addPan(changes, pan) // Now a suspend call + } + }, + onEnd = { + scaleState.isGestureInProgress.value = false + } + ) +} +``` + +--- + +## 4. PagedReaderState.kt +**Change**: Configure the state and provide data for the Pager. + +- **Initialization**: Set `screenScaleState.edgeHandoffEnabled = true` and `screenScaleState.enableOverscrollArea(true)`. +- **New Helper**: `getImage(PageMetadata): ReaderImageResult`. + - **Reason**: The Pager needs to load the "Next" spread while the user is still swiping. This method provides direct access to the image cache/loader. + +--- + +## 5. PagedReaderContent.kt +**Change**: Replace static layout with a controlled `HorizontalPager`. + +- **Pager Setup**: + - `val pagerState = rememberPagerState(...)` + - `userScrollEnabled = false`: Crucial. The Pager must not handle its own touches; it only moves when `ScreenScaleState` calls `scrollBy`. +- **Sync Logic**: + - `LaunchedEffect(pagerState)`: Call `scaleState.setScrollState(pagerState)`. + - `LaunchedEffect(pagerState.currentPage)`: Update `pagedReaderState.onPageChange`. +- **Snapping Effect**: + - Add a `LaunchedEffect(isGestureInProgress, isFlinging)`. + - If both are false and the pager is "between" pages, call `pagerState.animateScrollToPage(target)`. +- **Rendering**: + - The pager items will render either a `TransitionPage` (Start/End) or a `DoublePageLayout`/`SinglePageLayout` using the new `getImage` helper. diff --git a/PANEL_NAVIGATION_SYSTEM.md b/PANEL_NAVIGATION_SYSTEM.md new file mode 100644 index 00000000..30750083 --- /dev/null +++ b/PANEL_NAVIGATION_SYSTEM.md @@ -0,0 +1,52 @@ +# Panel-by-Panel Navigation System: High-Level Overview + +This document describes how the panel detection and navigation system works in the Comic Reader. + +## 1. Detection Engine (The "Brain") +The system uses a machine learning model (`RF-Detr`) to identify panels within a page spread. + +- **Model Format**: ONNX (Open Neural Network Exchange). +- **Runtime**: `OnnxRuntimeRfDetr` (implemented via ONNX Runtime). +- **Core Interface**: `KomeliaPanelDetector` handles the model lifecycle, including initialization and inference. +- **Input**: A `KomeliaImage` object (the raw decoded page). +- **Output**: A list of `DetectResult` objects, each containing: + - `classId`: The type of detection (usually represents a panel). + - `confidence`: How certain the model is about the detection. + - `boundingBox`: An `ImageRect` (Left, Top, Right, Bottom) in the coordinate space of the original image. + +## 2. Pre-Processing & Sorting (`PanelsReaderState.kt`) +Once the model returns raw coordinates, the reader performs several logical steps to make them "readable": + +- **Sorting**: The raw list of panels is sorted based on the current **Reading Direction** (Left-to-Right or Right-to-Left). This ensures that "Next Panel" follows the logical flow of the comic. +- **Coverage Analysis**: The system calculates the total area covered by detected panels versus the total image area. + - If panels cover > 80% of the image, the system flags it. + - If detection coverage is low, it might use a "Find Trim" fallback to ignore blank margins. +- **Caching**: Panel coordinates are cached alongside the image data in a `Cache` (using `cache4k`) to avoid re-running inference on every page turn. + +## 3. UI Navigation Logic +The `PanelsReaderState` manages the state of which panel is currently "active" using a `PageIndex` (page number + panel index). + +### The "Next" Command (`nextPanel()`) +When the user requests the next panel: +1. **Check Current Page**: If there's another panel in the sorted list for the current page, it scrolls to it. +2. **Boundary Logic**: + - If it was the last panel, it first zooms out to show the full page (`scrollToFit`). + - If the user clicks again while zoomed out, it moves to the next physical page. +3. **Calculation**: It uses `getPanelOffsetAndZoom()` to convert the panel's bounding box into a specific `Offset` and `Zoom` level for the `ScreenScaleState`. + +### Screen Centering Math +The most critical part of the system is the coordinate transformation: +- **Scale Calculation**: It determines the maximum scale that allows the panel to fit entirely within the screen dimensions without being clipped. +- **Offset Transformation**: It calculates the precise X and Y translation needed to place the center of the panel bounding box exactly in the center of the viewport. + +## 4. Execution +The transition is handled by `ScreenScaleState.scrollTo(offset)` and `setZoom()`. Because `ScreenScaleState` uses animated state holders (via `AnimationState`), the move from one panel to the next is a smooth, kinetic slide and zoom effect rather than a jump. + +--- + +## Technical Summary of the Flow: +1. **Load Page** → **Run AI Inference** → **Get Bounding Boxes**. +2. **Sort Boxes** based on LTR/RTL settings. +3. **Calculate Viewport** (Zoom + Offset) to isolate the box. +4. **Animate `ScreenScaleState`** to the calculated viewport. +5. **Update Index** to track progress within the page. diff --git a/PANEL_SETTINGS_DETAILED_PLAN.md b/PANEL_SETTINGS_DETAILED_PLAN.md new file mode 100644 index 00000000..1aa66249 --- /dev/null +++ b/PANEL_SETTINGS_DETAILED_PLAN.md @@ -0,0 +1,140 @@ +# Detailed Implementation Plan: Panel Reader "Show Full Page" Settings + +## 1. Domain Model +**File**: `komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/model/PanelsFullPageDisplayMode.kt` +```kotlin +package snd.komelia.settings.model + +enum class PanelsFullPageDisplayMode { + NONE, + BEFORE, + AFTER, + BOTH +} +``` + +## 2. Database Migration +**File**: `komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V16__panel_reader_settings.sql` +```sql +ALTER TABLE ImageReaderSettings ADD COLUMN panels_full_page_display_mode TEXT NOT NULL DEFAULT 'NONE'; +``` + +**File**: `komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/migrations/AppMigrations.kt` +```kotlin + private val migrations = listOf( + // ... existing + "V15__new_library_ui.sql", + "V16__panel_reader_settings.sql" + ) +``` + +## 3. Persistence Layer Updates + +**File**: `komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/ImageReaderSettingsTable.kt` +```kotlin + val panelsFullPageDisplayMode = text("panels_full_page_display_mode").default("NONE") +``` + +**File**: `komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/ImageReaderSettings.kt` +```kotlin + val panelsFullPageDisplayMode: PanelsFullPageDisplayMode = PanelsFullPageDisplayMode.NONE, +``` + +**File**: `komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/ImageReaderSettingsRepository.kt` +```kotlin + fun getPanelsFullPageDisplayMode(): Flow + suspend fun putPanelsFullPageDisplayMode(mode: PanelsFullPageDisplayMode) +``` + +**File**: `komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedImageReaderSettingsRepository.kt` +- **In `get()` mapping**: +```kotlin + panelsFullPageDisplayMode = PanelsFullPageDisplayMode.valueOf(it[ImageReaderSettingsTable.panelsFullPageDisplayMode]), +``` +- **In `save()` mapping**: +```kotlin + it[panelsFullPageDisplayMode] = settings.panelsFullPageDisplayMode.name +``` + +## 4. State Management +**File**: `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt` +- **Properties**: +```kotlin + val fullPageDisplayMode = MutableStateFlow(PanelsFullPageDisplayMode.NONE) +``` +- **In `initialize()`**: +```kotlin + fullPageDisplayMode.value = settingsRepository.getPanelsFullPageDisplayMode().first() +``` +- **Logic in `doPageLoad()`**: +```kotlin + val mode = fullPageDisplayMode.value + val showFirst = mode == PanelsFullPageDisplayMode.BEFORE || mode == PanelsFullPageDisplayMode.BOTH + val showLast = mode == PanelsFullPageDisplayMode.AFTER || mode == PanelsFullPageDisplayMode.BOTH + + if (showFirst && !alreadyHasFullPage) finalPanels.add(fullPageRect) + finalPanels.addAll(sortedPanels) + if (showLast && !alreadyHasFullPage) finalPanels.add(fullPageRect) +``` +- **Change Handler**: +```kotlin + fun onFullPageDisplayModeChange(mode: PanelsFullPageDisplayMode) { + this.fullPageDisplayMode.value = mode + stateScope.launch { settingsRepository.putPanelsFullPageDisplayMode(mode) } + launchPageLoad(currentPageIndex.value.page) + } +``` + +## 5. UI Components + +### Desktop UI +**File**: `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/SettingsSideMenu.kt` +- Update `PanelsReaderSettingsContent` signature and body: +```kotlin +@Composable +private fun PanelsReaderSettingsContent( + state: PanelsReaderState +) { + val strings = LocalStrings.current.pagedReader + val readingDirection = state.readingDirection.collectAsState().value + val displayMode = state.fullPageDisplayMode.collectAsState().value + + Column { + DropdownChoiceMenu(...) // Reading Direction + + DropdownChoiceMenu( + selectedOption = LabeledEntry(displayMode, displayMode.name), // TODO: Add strings + options = PanelsFullPageDisplayMode.entries.map { LabeledEntry(it, it.name) }, + onOptionChange = { state.onFullPageDisplayModeChange(it.value) }, + label = { Text("Show full page") }, + inputFieldModifier = Modifier.fillMaxWidth(), + inputFieldColor = MaterialTheme.colorScheme.surfaceVariant + ) + } +} +``` + +### Mobile UI +**File**: `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/BottomSheetSettingsOverlay.kt` +- Update `PanelsModeSettings`: +```kotlin +@Composable +private fun PanelsModeSettings(state: PanelsReaderState) { + val displayMode = state.fullPageDisplayMode.collectAsState().value + Column { + Text("Reading direction") + // ... existing chips + + Text("Show full page") + FlowRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + PanelsFullPageDisplayMode.entries.forEach { mode -> + InputChip( + selected = displayMode == mode, + onClick = { state.onFullPageDisplayModeChange(mode) }, + label = { Text(mode.name) } + ) + } + } + } +} +``` diff --git a/PANEL_VIEWER_IMPLEMENTATION_PLAN.md b/PANEL_VIEWER_IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..9c8084bc --- /dev/null +++ b/PANEL_VIEWER_IMPLEMENTATION_PLAN.md @@ -0,0 +1,57 @@ +# Implementation Plan: Panel-by-Panel "Full Page" Sequence + +## Goal +Implement a consistent "Full Page -> Panels -> Full Page" sequence for the Panel Reader. +- When entering a new page, show the full page first (Optional). +- Navigate through all detected panels. +- After the last panel, show the full page again (Optional). +- Next click moves to the next physical page. + +--- + +## 1. List-Based Injection (`PanelsReaderState.kt`) +**Change**: Inject a "Full Page" rectangle at the beginning and/or end of the detected panel list based on settings. + +- **New State Properties** (Phase 2 Settings placeholder): + - `showFullPageFirst: Boolean` (Default: `true`) + - `showFullPageLast: Boolean` (Default: `true`) +- **Location**: `doPageLoad()` method. +- **Logic**: + ```kotlin + val fullPageRect = ImageRect(0, 0, imageSize.width, imageSize.height) + val panels = mutableListOf() + + if (showFullPageFirst) panels.add(fullPageRect) + panels.addAll(sortedPanels) + if (showFullPageLast) panels.add(fullPageRect) + ``` +- **Optimization**: If a detected panel is already >95% of the page size, skip injection for that specific slot to avoid "double-viewing" splash pages. + +## 2. Simplify Navigation Logic +**Change**: Remove the "Smart" zoom-out logic from `nextPanel()` and `previousPanel()`. + +- **`nextPanel()`**: + - If `panelIndex + 1 < panels.size`, move to `panelIndex + 1`. + - Else, call `nextPage()`. +- **`previousPanel()`**: + - If `panelIndex - 1 >= 0`, move to `panelIndex - 1`. + - Else, call `previousPage()`. + +## 3. Directional Page Changes +**Change**: Ensure that moving backwards starts the user at the *end* of the previous page. + +- **`nextPage()`**: Calls `onPageChange(currentPageIndex + 1)` (starts at index 0). +- **`previousPage()`**: Calls `onPageChange(currentPageIndex - 1, startAtLast = true)`. +- **`launchPageLoad`**: Update signature to `launchPageLoad(pageIndex: Int, startAtLast: Boolean = false)`. + +--- + +## 4. Technical Steps +1. **Refactor `PageIndex`**: Remove the `isLastPanelZoomOutActive` flag. +2. **Update `launchPageLoad`**: Accept a `startAtLast: Boolean` flag. +3. **Modify `doPageLoad`**: + - Perform detection. + - Sort panels. + - Apply Injection logic (First/Last). + - Set `currentPageIndex` to `0` or `panels.lastIndex` based on `startAtLast`. +4. **Update `previousPage()`**: Call `onPageChange(index - 1, startAtLast = true)`. diff --git a/PANEL_VIEWER_RESEARCH.md b/PANEL_VIEWER_RESEARCH.md new file mode 100644 index 00000000..7f74b6fd --- /dev/null +++ b/PANEL_VIEWER_RESEARCH.md @@ -0,0 +1,38 @@ +# Comic Viewer: Panel-by-Panel Navigation Options + +This document outlines two strategies for implementing a "Full Page -> Panels -> Full Page" navigation flow in the comic book reader. + +## Option 1: State-Based Logic (Explicit Flags) +This approach involves adding explicit state flags to track whether the user is currently viewing the "intro" or "outro" full-page view for any given page. + +### Key Changes +- **`PageIndex` Data Class**: Add `isInitialFullPageActive` and `isLastPanelZoomOutActive` (already exists but needs more consistent use). +- **`nextPanel()` / `previousPanel()`**: Add conditional logic to check these flags. + - `nextPanel()`: If `isInitialFullPageActive`, move to panel index 0. If at the last panel, move to full-page zoom and set `isLastPanelZoomOutActive`. +- **`doPageLoad()`**: Initialize the state based on whether the user is moving forward (start at full page) or backward (start at "outro" full page). + +### Pros & Cons +- **Pros**: Very precise control; easy to add granular settings (e.g., "Only show full page at start"). +- **Cons**: More complex logic branches in the navigation methods; requires passing "navigation direction" through several method calls. + +--- + +## Option 2: List-Based Injection (Surgical approach) +This approach involves injecting a "full page" coordinate rectangle into the beginning and end of the panel list returned by the AI. + +### Key Changes +- **`doPageLoad()`**: After the AI detects panels and they are sorted, manually insert a rectangle covering `(0, 0, imageWidth, imageHeight)` at index `0` and at the end of the list. +- **`nextPanel()` / `previousPanel()`**: Simplify these methods to just move through the list. Remove the existing "smart" logic that skips the zoom-out based on page coverage. +- **Backward Navigation**: Update `previousPage()` and `onPageChange()` to accept a starting index, so when navigating back from Page 5, Page 4 starts at its last panel (the injected full-page view). + +### Pros & Cons +- **Pros**: Simplest implementation; leverages the existing "move to next panel" infrastructure with zero changes to the UI layer. +- **Cons**: Potential for "double views" on splash pages where the AI already detected a full-page panel (requires a simple `if` check before injection). + +--- + +## Technical Locations +- **File**: `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt` +- **Detection Logic**: `launchDownload` +- **Sorting & List Prep**: `doPageLoad` +- **Navigation Logic**: `nextPanel` and `previousPanel` diff --git a/READER_MOTION_PLAN.md b/READER_MOTION_PLAN.md new file mode 100644 index 00000000..958dbd0d --- /dev/null +++ b/READER_MOTION_PLAN.md @@ -0,0 +1,55 @@ +# Reader Motion System Plan: Spring Physics Migration + +## 1. Objective +To replace the current fixed-duration (1000ms) page and panel transitions with a unified, physics-based system. This will ensure consistent timing across different screen sizes (phones vs. tablets) and provide a more responsive, premium feel aligned with Material 3 Motion principles. + +## 2. Reasoning +* **Scale Independence**: Fixed-duration `tween` animations cover distance at different physical speeds depending on screen size. On a large tablet, a 1-second slide feels much slower than on a phone. Spring physics use tension and stiffness to calculate velocity based on distance, maintaining a consistent "tempo." +* **Responsiveness**: Springs naturally handle "velocity handoff." If a user triggers a new navigation while an old one is finishing, the spring animation inherits the current momentum instead of cutting or restarting abruptly. +* **OS Setting Resilience**: Fixed `tween` durations are directly multiplied by the Android "Animation Duration Scale" developer setting. If this is set high, reader navigation becomes painfully slow. Springs are more resilient to these multipliers. +* **M3 Compliance**: Material 3 recommends spring-based motion for expressive, natural interactions like page flipping and camera movement. + +## 3. Architecture: Centralized Motion Spec +To prevent disjointed animation speeds, we will create a centralized motion configuration. + +**New File**: `komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ReaderAnimation.kt` +```kotlin +package snd.komelia.ui.reader.image.common + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring + +object ReaderAnimation { + /** + * Unified spring spec for all manual navigation (taps, arrow keys). + * Damping: NoBouncy (1.0f) for a clean, professional finish. + * Stiffness: MediumLow (approx 400ms feel) for a snappy, premium response. + */ + val NavSpringSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow + ) +} +``` + +## 4. Implementation Details + +### A. ScreenScaleState.kt (Camera Movement) +**Function**: `animateTo(offset: Offset, zoom: Float)` +* **Change**: Replace `tween(durationMillis = 1000)` with `ReaderAnimation.NavSpringSpec`. +* **Effect**: Panel-to-panel transitions and "Fit to Screen" zooms will use physical momentum. + +### B. PagedReaderContent.kt (Paged Mode) +**Function**: `pagerState.animateScrollToPage(...)` inside navigation event collection. +* **Change**: Replace `tween(durationMillis = 1000)` with `ReaderAnimation.NavSpringSpec`. +* **Effect**: Tapping left/right will slide the page into place using the same force as the camera. + +### C. PanelsReaderContent.kt (Panel Mode) +**Function**: `pagerState.animateScrollToPage(...)` inside navigation event collection. +* **Change**: Replace `tween(durationMillis = 1000)` with `ReaderAnimation.NavSpringSpec`. +* **Effect**: Moving to the next physical page while in panel mode will be perfectly synchronized with the camera's zoom/pan spring. + +## 5. Verification +1. **Phone vs. Tablet**: Perform side-by-side tests to ensure the "tempo" of the page turns feels identical despite the physical distance difference. +2. **Continuous Tapping**: Tap quickly through several panels/pages to verify the animation doesn't "stutter" or reset awkwardly. +3. **RTL Direction**: Verify springs correctly pull the page in the right direction for RTL reading. diff --git a/README.md b/README.md index 4eb0034f..666a6fdf 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,31 @@ # Komelia - Komga media client +## Fork Improvements +This is a fork of [Komelia](https://github.com/Gaysuist/Komelia) with several enhancements and new features: + +### Library UI Changes +* **Immersive Detail Screens:** New immersive layout for Book, Series, and Oneshot screens where the cover artwork integrates with the status bar using adaptive color gradients. +* **Shared-Element Transitions:** Cover images now animate and expand directly from the library list into the detail view when opened. +* **Material 3 Components:** Implementation of a floating pill-shaped navigation bar, squarish Material 3 chips, and updated FABs and menus. +* **Transparent Styling:** Selection dropdowns and filters now use a transparent, shadowless background. +* **"Below" Card Layout:** New card style that displays metadata in a single-line-per-segment format below the thumbnail. + +### Reader Changes +* **Adaptive Backgrounds:** New background system that samples colors from all four image edges to create blooming or gradient effects that fill the screen. +* **Kinetic Swipe:** Implementation of a kinetic "sticky" swipe system for paged reading with full RTL (Right-to-Left) support. +* **Panel Navigation:** Unified smooth pan-and-zoom controls and full-page context sequences for panel-to-panel navigation. +* **Improved Panel Detection:** Upgraded AI model (rf-detr-med) for more accurate automatic panel identification. +* **Spring Physics:** Density-aware spring physics for consistent gesture response across different screen types. +* **"Tap to Zoom" Toggle:** Ability to enable or disable single-tap zooming independently for paged and panel modes. + +### Settings +* **Accent Presets:** Selection of predefined accent color presets and an adaptive color system. +* **Card Layout:** Option to toggle between the standard and the new "Below" info card layout. +* **Background Configuration:** Detailed settings for adaptive background bloom, gradient styles, and corner blending. +* **Gesture Controls:** Toggles for tap-to-zoom and mode-specific navigation behaviors. + +--- + ### Downloads: - Latest prebuilt release is available at https://github.com/Snd-R/Komelia/releases diff --git a/REORG_TASKS.md b/REORG_TASKS.md new file mode 100644 index 00000000..ff6fe6ce --- /dev/null +++ b/REORG_TASKS.md @@ -0,0 +1,23 @@ +# Task Reorganization Strategy + +To align task management with the **Agent OS** specification structure, the project's tasks (managed by the `beans` CLI) have been reorganized from a flat `.beans/` directory into spec-specific subfolders. + +## Reorganization Steps + +1. **New Task Location**: For every specification folder under `agent-os/specs/`, a dedicated `tasks/` subfolder was created to hold the related "beans" (task files). + * Example: `agent-os/specs/2026-02-23-1450-immersive-detail-screens/tasks/` + +2. **Configuration Update**: The root `.beans.yml` configuration was updated to point to the new parent directory: + ```yaml + beans: + path: agent-os/specs + ``` + +3. **Recursive Discovery**: The `beans` CLI is recursive by default. By setting the path to `agent-os/specs`, it automatically scans all subdirectories (like `spec-name/tasks/`) to find and list all tasks across all features. + +4. **Cleanup**: The original `.beans/` directory was removed once all tasks were successfully moved and verified. + +## Benefits +- **Contextual Alignment**: Tasks are now physically located alongside the specifications, plans, and standards they fulfill. +- **Multi-Spec Support**: The `beans list` command still provides a unified view of all project tasks, even though they are distributed across different spec folders. +- **Cleaner Root**: Removes the `.beans/` folder from the root of the project. diff --git a/komelia-domain/core/src/androidMain/kotlin/snd/komelia/image/AndroidPanelDetector.android.kt b/komelia-domain/core/src/androidMain/kotlin/snd/komelia/image/AndroidPanelDetector.android.kt index a3129afc..1017d7cf 100644 --- a/komelia-domain/core/src/androidMain/kotlin/snd/komelia/image/AndroidPanelDetector.android.kt +++ b/komelia-domain/core/src/androidMain/kotlin/snd/komelia/image/AndroidPanelDetector.android.kt @@ -19,7 +19,7 @@ class AndroidPanelDetector( private val logger = KotlinLogging.logger { } override fun getModelPath(): String? { - val path = dataDir.resolve("rf-detr-nano.onnx") + val path = dataDir.resolve("rf-detr-med.onnx") logger.info { "panel detector path string $path" } val exists = path.exists() return if (exists) path.toString() else null diff --git a/komelia-domain/core/src/androidMain/kotlin/snd/komelia/image/ImageBitmap.android.kt b/komelia-domain/core/src/androidMain/kotlin/snd/komelia/image/ImageBitmap.android.kt index f3826d2d..40ddb6e2 100644 --- a/komelia-domain/core/src/androidMain/kotlin/snd/komelia/image/ImageBitmap.android.kt +++ b/komelia-domain/core/src/androidMain/kotlin/snd/komelia/image/ImageBitmap.android.kt @@ -1,9 +1,17 @@ package snd.komelia.image +import android.graphics.Bitmap import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import snd.komelia.image.AndroidBitmap.toBitmap +import java.nio.ByteBuffer actual suspend fun KomeliaImage.toImageBitmap(): ImageBitmap { return this.toBitmap().asImageBitmap() +} + +actual fun ByteArray.toImageBitmap(width: Int, height: Int): ImageBitmap { + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + bitmap.copyPixelsFromBuffer(ByteBuffer.wrap(this)) + return bitmap.asImageBitmap() } \ No newline at end of file diff --git a/komelia-domain/core/src/androidMain/kotlin/snd/komelia/updates/AndroidOnnxModelDownloader.kt b/komelia-domain/core/src/androidMain/kotlin/snd/komelia/updates/AndroidOnnxModelDownloader.kt index 1d17f88d..61a6cc1b 100644 --- a/komelia-domain/core/src/androidMain/kotlin/snd/komelia/updates/AndroidOnnxModelDownloader.kt +++ b/komelia-domain/core/src/androidMain/kotlin/snd/komelia/updates/AndroidOnnxModelDownloader.kt @@ -24,7 +24,7 @@ import kotlin.io.path.inputStream import kotlin.io.path.outputStream private const val panelDetectionModelLink = - "https://github.com/Snd-R/komelia-onnxruntime/releases/download/model/rf-detr-nano.onnx.zip" + "https://github.com/Snd-R/komelia-onnxruntime/releases/download/model/rf-detr-med.onnx.zip" class AndroidOnnxModelDownloader( private val updateClient: UpdateClient, @@ -41,7 +41,7 @@ class AndroidOnnxModelDownloader( return flow { emit(UpdateProgress(0, 0, panelDetectionModelLink)) - val archiveFile = createTempFile("rf-detr-nano.onnx.zip") + val archiveFile = createTempFile("rf-detr-med.onnx.zip") archiveFile.toFile().deleteOnExit() appNotifications.runCatchingToNotifications { diff --git a/komelia-domain/core/src/commonMain/kotlin/snd/komelia/image/ImageBitmap.kt b/komelia-domain/core/src/commonMain/kotlin/snd/komelia/image/ImageBitmap.kt index e3399c5a..ac8e58c1 100644 --- a/komelia-domain/core/src/commonMain/kotlin/snd/komelia/image/ImageBitmap.kt +++ b/komelia-domain/core/src/commonMain/kotlin/snd/komelia/image/ImageBitmap.kt @@ -3,3 +3,5 @@ package snd.komelia.image import androidx.compose.ui.graphics.ImageBitmap expect suspend fun KomeliaImage.toImageBitmap(): ImageBitmap + +expect fun ByteArray.toImageBitmap(width: Int, height: Int): ImageBitmap diff --git a/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/CommonSettingsRepository.kt b/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/CommonSettingsRepository.kt index 3ab61f91..700a669e 100644 --- a/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/CommonSettingsRepository.kt +++ b/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/CommonSettingsRepository.kt @@ -39,4 +39,16 @@ interface CommonSettingsRepository { fun getAppTheme(): Flow suspend fun putAppTheme(theme: AppTheme) + + fun getNavBarColor(): Flow + suspend fun putNavBarColor(color: Long?) + + fun getAccentColor(): Flow + suspend fun putAccentColor(color: Long?) + + fun getUseNewLibraryUI(): Flow + suspend fun putUseNewLibraryUI(enabled: Boolean) + + fun getCardLayoutBelow(): Flow + suspend fun putCardLayoutBelow(enabled: Boolean) } \ No newline at end of file diff --git a/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/ImageReaderSettingsRepository.kt b/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/ImageReaderSettingsRepository.kt index 4ce07460..9c9ee1d3 100644 --- a/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/ImageReaderSettingsRepository.kt +++ b/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/ImageReaderSettingsRepository.kt @@ -9,6 +9,7 @@ import snd.komelia.settings.model.ContinuousReadingDirection import snd.komelia.settings.model.LayoutScaleType import snd.komelia.settings.model.PageDisplayLayout import snd.komelia.settings.model.PagedReadingDirection +import snd.komelia.settings.model.PanelsFullPageDisplayMode import snd.komelia.settings.model.ReaderFlashColor import snd.komelia.settings.model.ReaderType @@ -78,4 +79,19 @@ interface ImageReaderSettingsRepository { fun getUpscalerOnnxModel(): Flow suspend fun putUpscalerOnnxModel(name: PlatformFile?) + + fun getPanelsFullPageDisplayMode(): Flow + suspend fun putPanelsFullPageDisplayMode(mode: PanelsFullPageDisplayMode) + + fun getPagedReaderTapToZoom(): Flow + suspend fun putPagedReaderTapToZoom(enabled: Boolean) + + fun getPanelReaderTapToZoom(): Flow + suspend fun putPanelReaderTapToZoom(enabled: Boolean) + + fun getPagedReaderAdaptiveBackground(): Flow + suspend fun putPagedReaderAdaptiveBackground(enabled: Boolean) + + fun getPanelReaderAdaptiveBackground(): Flow + suspend fun putPanelReaderAdaptiveBackground(enabled: Boolean) } \ No newline at end of file diff --git a/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/model/PanelsFullPageDisplayMode.kt b/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/model/PanelsFullPageDisplayMode.kt new file mode 100644 index 00000000..c71d7c6e --- /dev/null +++ b/komelia-domain/core/src/commonMain/kotlin/snd/komelia/settings/model/PanelsFullPageDisplayMode.kt @@ -0,0 +1,8 @@ +package snd.komelia.settings.model + +enum class PanelsFullPageDisplayMode { + NONE, + BEFORE, + AFTER, + BOTH +} diff --git a/komelia-domain/core/src/jvmMain/kotlin/snd/komelia/image/ImageBitmap.jvm.kt b/komelia-domain/core/src/jvmMain/kotlin/snd/komelia/image/ImageBitmap.jvm.kt index 5cea1d2d..4dab5fe1 100644 --- a/komelia-domain/core/src/jvmMain/kotlin/snd/komelia/image/ImageBitmap.jvm.kt +++ b/komelia-domain/core/src/jvmMain/kotlin/snd/komelia/image/ImageBitmap.jvm.kt @@ -2,7 +2,17 @@ package snd.komelia.image import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asComposeImageBitmap +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ImageInfo import snd.komelia.image.SkiaBitmap.toSkiaBitmap actual suspend fun KomeliaImage.toImageBitmap(): ImageBitmap = - this.toSkiaBitmap().asComposeImageBitmap() \ No newline at end of file + this.toSkiaBitmap().asComposeImageBitmap() + +actual fun ByteArray.toImageBitmap(width: Int, height: Int): ImageBitmap { + val bitmap = Bitmap() + bitmap.allocPixels(ImageInfo.makeS32(width, height, ColorAlphaType.PREMUL)) + bitmap.installPixels(this) + return bitmap.asComposeImageBitmap() +} \ No newline at end of file diff --git a/komelia-domain/core/src/wasmJsMain/kotlin/snd/komelia/image/ImageBitmap.wasmJs.kt b/komelia-domain/core/src/wasmJsMain/kotlin/snd/komelia/image/ImageBitmap.wasmJs.kt index 9d327bc7..589b97f9 100644 --- a/komelia-domain/core/src/wasmJsMain/kotlin/snd/komelia/image/ImageBitmap.wasmJs.kt +++ b/komelia-domain/core/src/wasmJsMain/kotlin/snd/komelia/image/ImageBitmap.wasmJs.kt @@ -12,6 +12,14 @@ import org.jetbrains.skia.ImageInfo actual suspend fun KomeliaImage.toImageBitmap(): ImageBitmap = this.toBitmap().asComposeImageBitmap() +actual fun ByteArray.toImageBitmap(width: Int, height: Int): ImageBitmap { + val bitmap = Bitmap() + bitmap.allocPixels(ImageInfo.makeS32(width, height, ColorAlphaType.PREMUL)) + bitmap.installPixels(this) + bitmap.setImmutable() + return bitmap.asComposeImageBitmap() +} + suspend fun KomeliaImage.toBitmap(): Bitmap { val colorInfo = when (type) { ImageFormat.GRAYSCALE_8 -> { diff --git a/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/AppSettings.kt b/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/AppSettings.kt index b4ede040..1136ec8e 100644 --- a/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/AppSettings.kt +++ b/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/AppSettings.kt @@ -21,4 +21,9 @@ data class AppSettings( val updateLastCheckedTimestamp: Instant? = null, val updateLastCheckedReleaseVersion: AppVersion? = null, val updateDismissedVersion: AppVersion? = null, + + val navBarColor: Long? = null, + val accentColor: Long? = null, + val useNewLibraryUI: Boolean = true, + val cardLayoutBelow: Boolean = false, ) diff --git a/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/ImageReaderSettings.kt b/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/ImageReaderSettings.kt index 8b279773..872edfac 100644 --- a/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/ImageReaderSettings.kt +++ b/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/ImageReaderSettings.kt @@ -9,6 +9,7 @@ import snd.komelia.settings.model.ContinuousReadingDirection import snd.komelia.settings.model.LayoutScaleType import snd.komelia.settings.model.PageDisplayLayout import snd.komelia.settings.model.PagedReadingDirection +import snd.komelia.settings.model.PanelsFullPageDisplayMode import snd.komelia.settings.model.ReaderFlashColor import snd.komelia.settings.model.ReaderType import snd.komelia.settings.model.ReaderType.PAGED @@ -39,4 +40,10 @@ data class ImageReaderSettings( val ortUpscalerUserModelPath: PlatformFile? = null, val ortUpscalerDeviceId: Int = 0, val ortUpscalerTileSize: Int = 512, + + val panelsFullPageDisplayMode: PanelsFullPageDisplayMode = PanelsFullPageDisplayMode.NONE, + val pagedReaderTapToZoom: Boolean = true, + val panelReaderTapToZoom: Boolean = true, + val pagedReaderAdaptiveBackground: Boolean = false, + val panelReaderAdaptiveBackground: Boolean = false, ) \ No newline at end of file diff --git a/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/repository/ReaderSettingsRepositoryWrapper.kt b/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/repository/ReaderSettingsRepositoryWrapper.kt index 59e4c553..4462efd8 100644 --- a/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/repository/ReaderSettingsRepositoryWrapper.kt +++ b/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/repository/ReaderSettingsRepositoryWrapper.kt @@ -12,6 +12,7 @@ import snd.komelia.settings.model.ContinuousReadingDirection import snd.komelia.settings.model.LayoutScaleType import snd.komelia.settings.model.PageDisplayLayout import snd.komelia.settings.model.PagedReadingDirection +import snd.komelia.settings.model.PanelsFullPageDisplayMode import snd.komelia.settings.model.ReaderFlashColor import snd.komelia.settings.model.ReaderType @@ -194,4 +195,44 @@ class ReaderSettingsRepositoryWrapper( override suspend fun putUpscalerOnnxModel(name: PlatformFile?) { wrapper.transform { it.copy(ortUpscalerUserModelPath = name) } } + + override fun getPanelsFullPageDisplayMode(): Flow { + return wrapper.mapState { it.panelsFullPageDisplayMode } + } + + override suspend fun putPanelsFullPageDisplayMode(mode: PanelsFullPageDisplayMode) { + wrapper.transform { it.copy(panelsFullPageDisplayMode = mode) } + } + + override fun getPagedReaderTapToZoom(): Flow { + return wrapper.mapState { it.pagedReaderTapToZoom } + } + + override suspend fun putPagedReaderTapToZoom(enabled: Boolean) { + wrapper.transform { it.copy(pagedReaderTapToZoom = enabled) } + } + + override fun getPanelReaderTapToZoom(): Flow { + return wrapper.mapState { it.panelReaderTapToZoom } + } + + override suspend fun putPanelReaderTapToZoom(enabled: Boolean) { + wrapper.transform { it.copy(panelReaderTapToZoom = enabled) } + } + + override fun getPagedReaderAdaptiveBackground(): Flow { + return wrapper.mapState { it.pagedReaderAdaptiveBackground } + } + + override suspend fun putPagedReaderAdaptiveBackground(enabled: Boolean) { + wrapper.transform { it.copy(pagedReaderAdaptiveBackground = enabled) } + } + + override fun getPanelReaderAdaptiveBackground(): Flow { + return wrapper.mapState { it.panelReaderAdaptiveBackground } + } + + override suspend fun putPanelReaderAdaptiveBackground(enabled: Boolean) { + wrapper.transform { it.copy(panelReaderAdaptiveBackground = enabled) } + } } \ No newline at end of file diff --git a/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/repository/SettingsRepositoryWrapper.kt b/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/repository/SettingsRepositoryWrapper.kt index 8a6bb0ac..cfc07c07 100644 --- a/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/repository/SettingsRepositoryWrapper.kt +++ b/komelia-infra/database/shared/src/commonMain/kotlin/snd/komelia/db/repository/SettingsRepositoryWrapper.kt @@ -103,4 +103,36 @@ class SettingsRepositoryWrapper( wrapper.transform { it.copy(appTheme = theme) } } + override fun getNavBarColor(): Flow { + return wrapper.state.map { it.navBarColor }.distinctUntilChanged() + } + + override suspend fun putNavBarColor(color: Long?) { + wrapper.transform { it.copy(navBarColor = color) } + } + + override fun getAccentColor(): Flow { + return wrapper.state.map { it.accentColor }.distinctUntilChanged() + } + + override suspend fun putAccentColor(color: Long?) { + wrapper.transform { it.copy(accentColor = color) } + } + + override fun getUseNewLibraryUI(): Flow { + return wrapper.state.map { it.useNewLibraryUI }.distinctUntilChanged() + } + + override suspend fun putUseNewLibraryUI(enabled: Boolean) { + wrapper.transform { it.copy(useNewLibraryUI = enabled) } + } + + override fun getCardLayoutBelow(): Flow { + return wrapper.state.map { it.cardLayoutBelow }.distinctUntilChanged() + } + + override suspend fun putCardLayoutBelow(enabled: Boolean) { + wrapper.transform { it.copy(cardLayoutBelow = enabled) } + } + } \ No newline at end of file diff --git a/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V13__ui_colors.sql b/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V13__ui_colors.sql new file mode 100644 index 00000000..b8c20857 --- /dev/null +++ b/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V13__ui_colors.sql @@ -0,0 +1,2 @@ +ALTER TABLE AppSettings ADD COLUMN nav_bar_color TEXT; +ALTER TABLE AppSettings ADD COLUMN accent_color TEXT; diff --git a/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V14__immersive_layout.sql b/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V14__immersive_layout.sql new file mode 100644 index 00000000..0ffe12c6 --- /dev/null +++ b/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V14__immersive_layout.sql @@ -0,0 +1 @@ +ALTER TABLE AppSettings ADD COLUMN use_immersive_detail_layout INTEGER NOT NULL DEFAULT 0; diff --git a/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V15__new_library_ui.sql b/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V15__new_library_ui.sql new file mode 100644 index 00000000..2166302f --- /dev/null +++ b/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V15__new_library_ui.sql @@ -0,0 +1 @@ +ALTER TABLE AppSettings RENAME COLUMN use_immersive_detail_layout TO use_new_library_ui; diff --git a/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V16__panel_reader_settings.sql b/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V16__panel_reader_settings.sql new file mode 100644 index 00000000..f9d7fa74 --- /dev/null +++ b/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V16__panel_reader_settings.sql @@ -0,0 +1 @@ +ALTER TABLE ImageReaderSettings ADD COLUMN panels_full_page_display_mode TEXT NOT NULL DEFAULT 'NONE'; diff --git a/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V17__reader_tap_settings.sql b/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V17__reader_tap_settings.sql new file mode 100644 index 00000000..259ad8f0 --- /dev/null +++ b/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V17__reader_tap_settings.sql @@ -0,0 +1,2 @@ +ALTER TABLE ImageReaderSettings ADD COLUMN paged_reader_tap_to_zoom BOOLEAN NOT NULL DEFAULT 1; +ALTER TABLE ImageReaderSettings ADD COLUMN panel_reader_tap_to_zoom BOOLEAN NOT NULL DEFAULT 1; diff --git a/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V18__reader_adaptive_background.sql b/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V18__reader_adaptive_background.sql new file mode 100644 index 00000000..42c68200 --- /dev/null +++ b/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V18__reader_adaptive_background.sql @@ -0,0 +1,2 @@ +ALTER TABLE ImageReaderSettings ADD COLUMN paged_reader_adaptive_background BOOLEAN DEFAULT 0; +ALTER TABLE ImageReaderSettings ADD COLUMN panel_reader_adaptive_background BOOLEAN DEFAULT 0; diff --git a/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V19__card_layout_below.sql b/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V19__card_layout_below.sql new file mode 100644 index 00000000..10717e77 --- /dev/null +++ b/komelia-infra/database/sqlite/src/commonMain/composeResources/files/migrations/app/V19__card_layout_below.sql @@ -0,0 +1 @@ +ALTER TABLE AppSettings ADD COLUMN card_layout_below INTEGER NOT NULL DEFAULT 0; diff --git a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/migrations/AppMigrations.kt b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/migrations/AppMigrations.kt index 625ba6a9..556d44ea 100644 --- a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/migrations/AppMigrations.kt +++ b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/migrations/AppMigrations.kt @@ -19,6 +19,13 @@ class AppMigrations : MigrationResourcesProvider() { "V10__komf_settings.sql", "V11__home_filters.sql", "V12__offline_mode.sql", + "V13__ui_colors.sql", + "V14__immersive_layout.sql", + "V15__new_library_ui.sql", + "V16__panel_reader_settings.sql", + "V17__reader_tap_settings.sql", + "V18__reader_adaptive_background.sql", + "V19__card_layout_below.sql", ) override suspend fun getMigration(name: String): ByteArray? { diff --git a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedImageReaderSettingsRepository.kt b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedImageReaderSettingsRepository.kt index 1049c787..83887af1 100644 --- a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedImageReaderSettingsRepository.kt +++ b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedImageReaderSettingsRepository.kt @@ -17,6 +17,7 @@ import snd.komelia.settings.model.ContinuousReadingDirection import snd.komelia.settings.model.LayoutScaleType import snd.komelia.settings.model.PageDisplayLayout import snd.komelia.settings.model.PagedReadingDirection +import snd.komelia.settings.model.PanelsFullPageDisplayMode import snd.komelia.settings.model.ReaderFlashColor import snd.komelia.settings.model.ReaderType @@ -53,6 +54,12 @@ class ExposedImageReaderSettingsRepository(database: Database) : ExposedReposito ?.let { PlatformFile(it) }, ortUpscalerDeviceId = it[ImageReaderSettingsTable.ortDeviceId], ortUpscalerTileSize = it[ImageReaderSettingsTable.ortUpscalerTileSize], + panelsFullPageDisplayMode = it[ImageReaderSettingsTable.panelsFullPageDisplayMode] + .let { mode -> PanelsFullPageDisplayMode.valueOf(mode) }, + pagedReaderTapToZoom = it[ImageReaderSettingsTable.pagedReaderTapToZoom], + panelReaderTapToZoom = it[ImageReaderSettingsTable.panelReaderTapToZoom], + pagedReaderAdaptiveBackground = it[ImageReaderSettingsTable.pagedReaderAdaptiveBackground], + panelReaderAdaptiveBackground = it[ImageReaderSettingsTable.panelReaderAdaptiveBackground], ) } } @@ -84,6 +91,11 @@ class ExposedImageReaderSettingsRepository(database: Database) : ExposedReposito it[ortUpscalerUserModelPath] = settings.ortUpscalerUserModelPath?.path it[ortDeviceId] = settings.ortUpscalerDeviceId it[ortUpscalerTileSize] = settings.ortUpscalerTileSize + it[panelsFullPageDisplayMode] = settings.panelsFullPageDisplayMode.name + it[pagedReaderTapToZoom] = settings.pagedReaderTapToZoom + it[panelReaderTapToZoom] = settings.panelReaderTapToZoom + it[pagedReaderAdaptiveBackground] = settings.pagedReaderAdaptiveBackground + it[panelReaderAdaptiveBackground] = settings.panelReaderAdaptiveBackground } } } diff --git a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedSettingsRepository.kt b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedSettingsRepository.kt index ef934075..00103a98 100644 --- a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedSettingsRepository.kt +++ b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/settings/ExposedSettingsRepository.kt @@ -39,6 +39,10 @@ class ExposedSettingsRepository(database: Database) : ExposedRepository(database it[updateLastCheckedTimestamp] = settings.updateLastCheckedTimestamp?.toString() it[updateLastCheckedReleaseVersion] = settings.updateLastCheckedReleaseVersion?.toString() it[updateDismissedVersion] = settings.updateDismissedVersion?.toString() + it[navBarColor] = settings.navBarColor?.toString(16) + it[accentColor] = settings.accentColor?.toString(16) + it[useNewLibraryUI] = settings.useNewLibraryUI + it[cardLayoutBelow] = settings.cardLayoutBelow } } } @@ -60,6 +64,10 @@ class ExposedSettingsRepository(database: Database) : ExposedRepository(database ?.let { AppVersion.fromString(it) }, updateDismissedVersion = get(AppSettingsTable.updateDismissedVersion) ?.let { AppVersion.fromString(it) }, + navBarColor = get(AppSettingsTable.navBarColor)?.toLong(16), + accentColor = get(AppSettingsTable.accentColor)?.toLong(16), + useNewLibraryUI = get(AppSettingsTable.useNewLibraryUI), + cardLayoutBelow = get(AppSettingsTable.cardLayoutBelow), ) } diff --git a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/AppSettingsTable.kt b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/AppSettingsTable.kt index eadd9f12..94e9a187 100644 --- a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/AppSettingsTable.kt +++ b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/AppSettingsTable.kt @@ -22,5 +22,10 @@ object AppSettingsTable : Table("AppSettings") { val updateLastCheckedReleaseVersion = text("update_last_checked_release_version").nullable() val updateDismissedVersion = text("update_dismissed_version").nullable() + val navBarColor = text("nav_bar_color").nullable() + val accentColor = text("accent_color").nullable() + val useNewLibraryUI = bool("use_new_library_ui").default(true) + val cardLayoutBelow = bool("card_layout_below").default(false) + override val primaryKey = PrimaryKey(version) } \ No newline at end of file diff --git a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/ImageReaderSettingsTable.kt b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/ImageReaderSettingsTable.kt index 45bf986d..2b846cd7 100644 --- a/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/ImageReaderSettingsTable.kt +++ b/komelia-infra/database/sqlite/src/commonMain/kotlin/snd/komelia/db/tables/ImageReaderSettingsTable.kt @@ -34,5 +34,11 @@ object ImageReaderSettingsTable : Table("ImageReaderSettings") { val ortUpscalerTileSize = integer("onnx_runtime_tile_size") val ortUpscalerUserModelPath = text("onnx_runtime_model_path").nullable() + val panelsFullPageDisplayMode = text("panels_full_page_display_mode").default("NONE") + val pagedReaderTapToZoom = bool("paged_reader_tap_to_zoom").default(true) + val panelReaderTapToZoom = bool("panel_reader_tap_to_zoom").default(true) + val pagedReaderAdaptiveBackground = bool("paged_reader_adaptive_background").default(false) + val panelReaderAdaptiveBackground = bool("panel_reader_adaptive_background").default(false) + override val primaryKey = PrimaryKey(bookId) } \ No newline at end of file diff --git a/komelia-infra/image-decoder/shared/src/commonMain/kotlin/snd/komelia/image/KomeliaImage.kt b/komelia-infra/image-decoder/shared/src/commonMain/kotlin/snd/komelia/image/KomeliaImage.kt index cb0fdd97..03432f17 100644 --- a/komelia-infra/image-decoder/shared/src/commonMain/kotlin/snd/komelia/image/KomeliaImage.kt +++ b/komelia-infra/image-decoder/shared/src/commonMain/kotlin/snd/komelia/image/KomeliaImage.kt @@ -27,6 +27,7 @@ interface KomeliaImage : AutoCloseable { suspend fun mapLookupTable(table: ByteArray): KomeliaImage suspend fun getBytes(): ByteArray + suspend fun averageColor(): Int? } data class ImageDimensions( @@ -62,3 +63,15 @@ enum class ReduceKernel { MKS2021, DEFAULT, } + +data class EdgeSampling( + val top: EdgeSample? = null, + val bottom: EdgeSample? = null, + val left: EdgeSample? = null, + val right: EdgeSample? = null, +) + +data class EdgeSample( + val averageColor: Int, + val colorLine: ByteArray, +) diff --git a/komelia-infra/image-decoder/shared/src/commonMain/kotlin/snd/komelia/image/ReaderImageUtils.kt b/komelia-infra/image-decoder/shared/src/commonMain/kotlin/snd/komelia/image/ReaderImageUtils.kt new file mode 100644 index 00000000..7a3b29bc --- /dev/null +++ b/komelia-infra/image-decoder/shared/src/commonMain/kotlin/snd/komelia/image/ReaderImageUtils.kt @@ -0,0 +1,88 @@ +package snd.komelia.image + +suspend fun KomeliaImage.getEdgeColors(vertical: Boolean): Pair? { + return if (vertical) { + val top = extractArea(ImageRect(0, 0, width, 10.coerceAtMost(height))) + val topColor = top.averageColor() + top.close() + + val bottom = extractArea(ImageRect(0, (height - 10).coerceAtLeast(0), width, height)) + val bottomColor = bottom.averageColor() + bottom.close() + + if (topColor != null && bottomColor != null) topColor to bottomColor + else null + } else { + val left = extractArea(ImageRect(0, 0, 10.coerceAtMost(width), height)) + val leftColor = left.averageColor() + left.close() + + val right = extractArea(ImageRect((width - 10).coerceAtLeast(0), 0, width, height)) + val rightColor = right.averageColor() + right.close() + + if (leftColor != null && rightColor != null) leftColor to rightColor + else null + } +} + +suspend fun KomeliaImage.getEdgeColorLines(vertical: Boolean): Pair? { + return if (vertical) { + val top = extractArea(ImageRect(0, 0, width, 10.coerceAtMost(height))) + val topResized = top.resize(width, 1) + val topBytes = topResized.getBytes() + top.close() + topResized.close() + + val bottom = extractArea(ImageRect(0, (height - 10).coerceAtLeast(0), width, height)) + val bottomResized = bottom.resize(width, 1) + val bottomBytes = bottomResized.getBytes() + bottom.close() + bottomResized.close() + + if (topBytes.isNotEmpty() && bottomBytes.isNotEmpty()) topBytes to bottomBytes + else null + } else { + val left = extractArea(ImageRect(0, 0, 10.coerceAtMost(width), height)) + val leftResized = left.resize(1, height) + val leftBytes = leftResized.getBytes() + left.close() + leftResized.close() + + val right = extractArea(ImageRect((width - 10).coerceAtLeast(0), 0, width, height)) + val rightResized = right.resize(1, height) + val rightBytes = rightResized.getBytes() + right.close() + rightResized.close() + + if (leftBytes.isNotEmpty() && rightBytes.isNotEmpty()) leftBytes to rightBytes + else null + } +} + +suspend fun KomeliaImage.getEdgeSampling(): EdgeSampling { + val top = sampleEdge(ImageRect(0, 0, width, 10.coerceAtMost(height)), true) + val bottom = sampleEdge(ImageRect(0, (height - 10).coerceAtLeast(0), width, height), true) + val left = sampleEdge(ImageRect(0, 0, 10.coerceAtMost(width), height), false) + val right = sampleEdge(ImageRect((width - 10).coerceAtLeast(0), 0, width, height), false) + + return EdgeSampling( + top = top, + bottom = bottom, + left = left, + right = right + ) +} + +private suspend fun KomeliaImage.sampleEdge(rect: ImageRect, horizontal: Boolean): EdgeSample? { + if (rect.width <= 0 || rect.height <= 0) return null + val edge = extractArea(rect) + val color = edge.averageColor() + val resized = if (horizontal) edge.resize(rect.width, 1) else edge.resize(1, rect.height) + val bytes = resized.getBytes() + edge.close() + resized.close() + + return if (color != null && bytes.isNotEmpty()) EdgeSample(color, bytes) + else null +} diff --git a/komelia-infra/image-decoder/vips/src/commonMain/kotlin/snd/komelia/image/VipsImageDecoder.kt b/komelia-infra/image-decoder/vips/src/commonMain/kotlin/snd/komelia/image/VipsImageDecoder.kt index 88b9506d..1577dbfb 100644 --- a/komelia-infra/image-decoder/vips/src/commonMain/kotlin/snd/komelia/image/VipsImageDecoder.kt +++ b/komelia-infra/image-decoder/vips/src/commonMain/kotlin/snd/komelia/image/VipsImageDecoder.kt @@ -123,6 +123,39 @@ class VipsBackedImage(val vipsImage: VipsImage) : KomeliaImage { return vipsImage.getBytes() } + override suspend fun averageColor(): Int? { + return withContext(Dispatchers.Default) { + val resized = vipsImage.resize(1, 1, null, false) + val bytes = resized.getBytes() + resized.close() + if (bytes.isEmpty()) return@withContext null + + when (bands) { + 4 -> { + val r = bytes[0].toInt() and 0xFF + val g = bytes[1].toInt() and 0xFF + val b = bytes[2].toInt() and 0xFF + val a = bytes[3].toInt() and 0xFF + (a shl 24) or (r shl 16) or (g shl 8) or b + } + + 3 -> { + val r = bytes[0].toInt() and 0xFF + val g = bytes[1].toInt() and 0xFF + val b = bytes[2].toInt() and 0xFF + (0xFF shl 24) or (r shl 16) or (g shl 8) or b + } + + 1 -> { + val v = bytes[0].toInt() and 0xFF + (0xFF shl 24) or (v shl 16) or (v shl 8) or v + } + + else -> null + } + } + } + override suspend fun shrink(factor: Double): KomeliaImage { return withContext(Dispatchers.Default) { VipsBackedImage(vipsImage.shrink(factor)) diff --git a/komelia-infra/image-decoder/wasm-image-worker/src/wasmJsMain/kotlin/snd/komelia/image/wasm/client/WorkerImage.kt b/komelia-infra/image-decoder/wasm-image-worker/src/wasmJsMain/kotlin/snd/komelia/image/wasm/client/WorkerImage.kt index b1eb39f8..4f15f408 100644 --- a/komelia-infra/image-decoder/wasm-image-worker/src/wasmJsMain/kotlin/snd/komelia/image/wasm/client/WorkerImage.kt +++ b/komelia-infra/image-decoder/wasm-image-worker/src/wasmJsMain/kotlin/snd/komelia/image/wasm/client/WorkerImage.kt @@ -105,6 +105,37 @@ class WorkerImage( return result.bytes.asByteArray() } + override suspend fun averageColor(): Int? { + val resized = resize(1, 1, false, ReduceKernel.DEFAULT) + val bytes = resized.getBytes() + resized.close() + if (bytes.isEmpty()) return null + + return when (bands) { + 4 -> { + val r = bytes[0].toInt() and 0xFF + val g = bytes[1].toInt() and 0xFF + val b = bytes[2].toInt() and 0xFF + val a = bytes[3].toInt() and 0xFF + (a shl 24) or (r shl 16) or (g shl 8) or b + } + + 3 -> { + val r = bytes[0].toInt() and 0xFF + val g = bytes[1].toInt() and 0xFF + val b = bytes[2].toInt() and 0xFF + (0xFF shl 24) or (r shl 16) or (g shl 8) or b + } + + 1 -> { + val v = bytes[0].toInt() and 0xFF + (0xFF shl 24) or (v shl 16) or (v shl 8) or v + } + + else -> null + } + } + override fun close() { coroutineScope.launch { val message = closeImageRequest(worker.getNextId(), imageId) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/CompositionLocals.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/CompositionLocals.kt index cff3c624..f7907503 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/CompositionLocals.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/CompositionLocals.kt @@ -1,8 +1,14 @@ package snd.komelia.ui +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import com.dokar.sonner.ToasterState import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow @@ -17,6 +23,7 @@ import snd.komga.client.library.KomgaLibrary import snd.komga.client.sse.KomgaEvent val LocalViewModelFactory = compositionLocalOf { error("ViewModel factory is not set") } +val LocalMainScreenViewModel = compositionLocalOf { error("MainScreenViewModel is not set") } val LocalToaster = compositionLocalOf { error("Toaster is not set") } val LocalKomgaEvents = compositionLocalOf> { error("Komga events are not set") } @@ -34,3 +41,13 @@ val LocalBookDownloadEvents = staticCompositionLocalOf?> { error("Book download event flow was not initialized") } val LocalOfflineMode = staticCompositionLocalOf> { error("offline mode flow was not initialized") } val LocalKomgaState = staticCompositionLocalOf { error("komga state was not initialized") } +val LocalNavBarColor = compositionLocalOf { null } +val LocalAccentColor = compositionLocalOf { null } +val LocalUseNewLibraryUI = compositionLocalOf { true } +val LocalCardLayoutBelow = compositionLocalOf { false } +val LocalRawStatusBarHeight = staticCompositionLocalOf { 0.dp } +val LocalRawNavBarHeight = staticCompositionLocalOf { 0.dp } + +@OptIn(ExperimentalSharedTransitionApi::class) +val LocalSharedTransitionScope = compositionLocalOf { null } +val LocalAnimatedVisibilityScope = compositionLocalOf { null } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt index 78949ac2..c2259b47 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainScreen.kt @@ -1,5 +1,6 @@ package snd.komelia.ui +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -11,14 +12,22 @@ import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.LocalLibrary -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.rounded.Home +import androidx.compose.material.icons.rounded.LocalLibrary +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.DrawerValue.Closed import androidx.compose.material3.DrawerValue.Open import androidx.compose.material3.HorizontalDivider @@ -29,12 +38,25 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.contentColorFor +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEvent @@ -50,9 +72,12 @@ import cafe.adriel.voyager.navigator.CurrentScreen import cafe.adriel.voyager.navigator.Navigator import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.launch +import snd.komelia.ui.book.BookScreen import snd.komelia.ui.book.bookScreen import snd.komelia.ui.home.HomeScreen import snd.komelia.ui.library.LibraryScreen +import snd.komelia.ui.oneshot.OneshotScreen +import snd.komelia.ui.series.SeriesScreen import snd.komelia.ui.platform.PlatformType.DESKTOP import snd.komelia.ui.platform.PlatformType.MOBILE import snd.komelia.ui.platform.PlatformType.WEB_KOMF @@ -82,9 +107,11 @@ class MainScreen( ) { navigator -> val vm = rememberScreenModel { viewModelFactory.getNavigationViewModel() } - when (platform) { - MOBILE -> MobileLayout(navigator, vm) - DESKTOP, WEB_KOMF -> DesktopLayout(navigator, vm) + CompositionLocalProvider(LocalMainScreenViewModel provides vm) { + when (platform) { + MOBILE -> MobileLayout(navigator, vm) + DESKTOP, WEB_KOMF -> DesktopLayout(navigator, vm) + } } LaunchedEffect(Unit) { vm.initialize(navigator) @@ -151,48 +178,136 @@ class MainScreen( } } + @OptIn(ExperimentalSharedTransitionApi::class) @Composable private fun MobileLayout( navigator: Navigator, vm: MainScreenViewModel ) { val coroutineScope = rememberCoroutineScope() - Scaffold( - containerColor = MaterialTheme.colorScheme.surface, - bottomBar = { - BottomNavigationBar( - navigator = navigator, - toggleLibrariesDrawer = { coroutineScope.launch { vm.toggleNavBar() } }, - modifier = Modifier - ) - }, - ) { paddingValues -> - val layoutDirection = LocalLayoutDirection.current - - ModalNavigationDrawer( - drawerState = vm.navBarState, - drawerContent = { LibrariesNavBar(vm, navigator) }, - content = { - Box( - Modifier - .fillMaxSize() - .padding( - start = paddingValues.calculateStartPadding(layoutDirection), - end = paddingValues.calculateEndPadding(layoutDirection), - top = paddingValues.calculateTopPadding(), - bottom = paddingValues.calculateBottomPadding(), - ) - .consumeWindowInsets(paddingValues) - ) { - CurrentScreen() + val useNewLibraryUI = LocalUseNewLibraryUI.current + val isImmersiveScreen = navigator.lastItem is SeriesScreen || + navigator.lastItem is BookScreen || + navigator.lastItem is OneshotScreen + val rawStatusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val rawNavBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + CompositionLocalProvider( + LocalRawStatusBarHeight provides rawStatusBarHeight, + LocalRawNavBarHeight provides rawNavBarHeight, + ) { + Scaffold( + containerColor = MaterialTheme.colorScheme.surface, + bottomBar = { + if (useNewLibraryUI) { + AppNavigationBar( + navigator = navigator, + toggleLibrariesDrawer = { coroutineScope.launch { vm.toggleNavBar() } }, + containerColor = if (isImmersiveScreen) MaterialTheme.colorScheme.surfaceVariant + else LocalNavBarColor.current ?: MaterialTheme.colorScheme.surface + ) + } else { + StandardBottomNavigationBar( + navigator = navigator, + toggleLibrariesDrawer = { coroutineScope.launch { vm.toggleNavBar() } }, + modifier = Modifier + ) } } + ) { paddingValues -> + val layoutDirection = LocalLayoutDirection.current + ModalNavigationDrawer( + drawerState = vm.navBarState, + drawerContent = { LibrariesNavBar(vm, navigator) }, + content = { + Box( + Modifier + .fillMaxSize() + .padding( + start = paddingValues.calculateStartPadding(layoutDirection), + end = paddingValues.calculateEndPadding(layoutDirection), + top = paddingValues.calculateTopPadding(), + bottom = paddingValues.calculateBottomPadding(), + ) + .consumeWindowInsets(paddingValues) + .statusBarsPadding() + ) { + AnimatedContent( + targetState = navigator.lastItem, + transitionSpec = { fadeIn(tween(400)) togetherWith fadeOut(tween(250)) }, + label = "nav", + ) { screen -> + CompositionLocalProvider(LocalAnimatedVisibilityScope provides this) { + navigator.saveableState("screen", screen) { + screen.Content() + } + } + } + } + } + ) + } + } + } + + @Composable + private fun AppNavigationBar( + navigator: Navigator, + toggleLibrariesDrawer: () -> Unit, + containerColor: Color = LocalNavBarColor.current ?: MaterialTheme.colorScheme.surface, + ) { + val accentColor = LocalAccentColor.current + val itemColors = if (accentColor != null) { + NavigationBarItemDefaults.colors( + selectedIconColor = if (accentColor.luminance() > 0.5f) Color.Black else Color.White, + selectedTextColor = MaterialTheme.colorScheme.primary, + indicatorColor = accentColor + ) + } else { + NavigationBarItemDefaults.colors() + } + NavigationBar( + containerColor = containerColor, + ) { + NavigationBarItem( + alwaysShowLabel = true, + selected = false, + onClick = toggleLibrariesDrawer, + icon = { Icon(Icons.Rounded.LocalLibrary, null) }, + label = { Text("Libraries") }, + colors = itemColors + ) + NavigationBarItem( + alwaysShowLabel = true, + selected = navigator.lastItem is HomeScreen, + onClick = { if (navigator.lastItem !is HomeScreen) navigator.replaceAll(HomeScreen()) }, + icon = { Icon(Icons.Rounded.Home, null) }, + label = { Text("Home") }, + colors = itemColors + ) + NavigationBarItem( + alwaysShowLabel = true, + selected = navigator.lastItem is SearchScreen, + onClick = { if (navigator.lastItem !is SearchScreen) navigator.push(SearchScreen(null)) }, + icon = { Icon(Icons.Rounded.Search, null) }, + label = { Text("Search") }, + colors = itemColors + ) + NavigationBarItem( + alwaysShowLabel = true, + selected = navigator.lastItem is MobileSettingsScreen || navigator.lastItem is SettingsScreen, + onClick = { + if (navigator.lastItem !is MobileSettingsScreen && navigator.lastItem !is SettingsScreen) + navigator.push(MobileSettingsScreen()) + }, + icon = { Icon(Icons.Rounded.Settings, null) }, + label = { Text("Settings") }, + colors = itemColors ) } } @Composable - private fun BottomNavigationBar( + private fun StandardBottomNavigationBar( navigator: Navigator, toggleLibrariesDrawer: () -> Unit, modifier: Modifier @@ -208,7 +323,7 @@ class MainScreen( ) { CompactNavButton( text = "Libraries", - icon = Icons.Default.LocalLibrary, + icon = Icons.Rounded.LocalLibrary, onClick = { toggleLibrariesDrawer() }, isSelected = false, modifier = Modifier.weight(1f) @@ -216,31 +331,31 @@ class MainScreen( CompactNavButton( text = "Home", - icon = Icons.Default.Home, - onClick = { navigator.replaceAll(HomeScreen()) }, + icon = Icons.Rounded.Home, + onClick = { if (navigator.lastItem !is HomeScreen) navigator.replaceAll(HomeScreen()) }, isSelected = navigator.lastItem is HomeScreen, modifier = Modifier.weight(1f) ) - CompactNavButton( text = "Search", - icon = Icons.Default.Search, - onClick = { navigator.push(SearchScreen(null)) }, + icon = Icons.Rounded.Search, + onClick = { if (navigator.lastItem !is SearchScreen) navigator.push(SearchScreen(null)) }, isSelected = navigator.lastItem is SearchScreen, modifier = Modifier.weight(1f) ) CompactNavButton( text = "Settings", - icon = Icons.Default.Settings, - onClick = { navigator.parent!!.push(MobileSettingsScreen()) }, - isSelected = navigator.lastItem is SettingsScreen, + icon = Icons.Rounded.Settings, + onClick = { + if (navigator.lastItem !is MobileSettingsScreen && navigator.lastItem !is SettingsScreen) + navigator.push(MobileSettingsScreen()) + }, + isSelected = navigator.lastItem is SettingsScreen || navigator.lastItem is MobileSettingsScreen, modifier = Modifier.weight(1f) ) - } - Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) } } } @@ -253,11 +368,12 @@ class MainScreen( isSelected: Boolean, modifier: Modifier ) { + val accentColor = LocalAccentColor.current Surface( modifier = modifier, contentColor = - if (isSelected) MaterialTheme.colorScheme.secondary - else contentColorFor(MaterialTheme.colorScheme.surfaceVariant) + if (isSelected) accentColor ?: MaterialTheme.colorScheme.secondary + else contentColorFor(MaterialTheme.colorScheme.surfaceVariant) ) { Column( modifier = Modifier @@ -272,7 +388,6 @@ class MainScreen( } } - @Composable private fun NavBar( vm: MainScreenViewModel, @@ -289,12 +404,18 @@ class MainScreen( if (width != FULL) coroutineScope.launch { vm.navBarState.snapTo(Closed) } }, onLibrariesClick = { - navigator.replaceAll(LibraryScreen()) + val current = navigator.lastItem + if (current !is LibraryScreen || current.libraryId != null) { + navigator.replaceAll(LibraryScreen()) + } if (width != FULL) coroutineScope.launch { vm.navBarState.snapTo(Closed) } }, - onLibraryClick = { - navigator.replaceAll(LibraryScreen(it)) + onLibraryClick = { libraryId -> + val current = navigator.lastItem + if (current !is LibraryScreen || current.libraryId != libraryId) { + navigator.replaceAll(LibraryScreen(libraryId)) + } if (width != FULL) coroutineScope.launch { vm.navBarState.snapTo(Closed) } }, onSettingsClick = { navigator.parent!!.push(SettingsScreen()) }, @@ -313,12 +434,18 @@ class MainScreen( libraries = vm.libraries.collectAsState().value, libraryActions = vm.getLibraryActions(), onLibrariesClick = { - navigator.replaceAll(LibraryScreen()) + val current = navigator.lastItem + if (current !is LibraryScreen || current.libraryId != null) { + navigator.replaceAll(LibraryScreen()) + } coroutineScope.launch { vm.navBarState.snapTo(Closed) } }, - onLibraryClick = { - navigator.replaceAll(LibraryScreen(it)) + onLibraryClick = { libraryId -> + val current = navigator.lastItem + if (current !is LibraryScreen || current.libraryId != libraryId) { + navigator.replaceAll(LibraryScreen(libraryId)) + } coroutineScope.launch { vm.navBarState.snapTo(Closed) } }, ) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainView.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainView.kt index 1b4a46a4..c4eb3474 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainView.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/MainView.kt @@ -1,5 +1,7 @@ package snd.komelia.ui +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets @@ -14,6 +16,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -68,9 +71,29 @@ fun MainView( keyEvents: SharedFlow ) { var theme by rememberSaveable { mutableStateOf(Theme.DARK) } + var navBarColor by remember { mutableStateOf(null) } + var accentColor by remember { mutableStateOf(null) } + var useNewLibraryUI by remember { mutableStateOf(true) } + var cardLayoutBelow by remember { mutableStateOf(false) } LaunchedEffect(dependencies) { dependencies?.appRepositories?.settingsRepository?.getAppTheme()?.collect { theme = it.toTheme() } } + LaunchedEffect(dependencies) { + dependencies?.appRepositories?.settingsRepository?.getNavBarColor() + ?.collect { navBarColor = it?.let { v -> Color(v.toInt()) } } + } + LaunchedEffect(dependencies) { + dependencies?.appRepositories?.settingsRepository?.getAccentColor() + ?.collect { accentColor = it?.let { v -> Color(v.toInt()) } } + } + LaunchedEffect(dependencies) { + dependencies?.appRepositories?.settingsRepository?.getUseNewLibraryUI() + ?.collect { useNewLibraryUI = it } + } + LaunchedEffect(dependencies) { + dependencies?.appRepositories?.settingsRepository?.getCardLayoutBelow() + ?.collect { cardLayoutBelow = it } + } MaterialTheme(colorScheme = theme.colorScheme) { ConfigurePlatformTheme(theme) @@ -114,7 +137,11 @@ fun MainView( LocalReloadEvents provides viewModelFactory.screenReloadEvents, LocalBookDownloadEvents provides dependencies.offlineDependencies.bookDownloadEvents, LocalOfflineMode provides dependencies.isOffline, - LocalKomgaState provides dependencies.komgaSharedState + LocalKomgaState provides dependencies.komgaSharedState, + LocalNavBarColor provides navBarColor, + LocalAccentColor provides accentColor, + LocalUseNewLibraryUI provides useNewLibraryUI, + LocalCardLayoutBelow provides cardLayoutBelow, ) { MainContent(platformType, dependencies.komgaSharedState) @@ -130,6 +157,7 @@ fun MainView( } } +@OptIn(ExperimentalSharedTransitionApi::class) @Composable private fun MainContent( platformType: PlatformType, @@ -142,45 +170,50 @@ private fun MainContent( } } - Navigator( - screen = loginScreen, - disposeBehavior = NavigatorDisposeBehavior(disposeNestedNavigators = false), - onBackPressed = null - ) { navigator -> - var canProceed by remember { mutableStateOf(komgaSharedState.authenticationState.value == Loaded) } - // FIXME this looks like a hack. Find a multiplatform way to handle this outside of composition? - // variable to track if Android app was killed in background and later restored - var wasInitializedBefore by rememberSaveable { mutableStateOf(false) } - navigator.clearEvent() - - LaunchedEffect(Unit) { - if (canProceed) return@LaunchedEffect - - // not really necessary since Voyager navigator doesn't dispose existing MainScreen when it's replaced with LoginScreen - // when LoginScreen replaces itself back to MainScreen, it's restored to old state - // not sure if it's intended, do proper initialization here to avoid loading LoginScreen - if (wasInitializedBefore) { - komgaSharedState.tryReloadState() - } + SharedTransitionLayout { + CompositionLocalProvider(LocalSharedTransitionScope provides this) { + Navigator( + screen = loginScreen, + disposeBehavior = NavigatorDisposeBehavior(disposeNestedNavigators = false), + onBackPressed = null + ) { navigator -> + var canProceed by remember { mutableStateOf(komgaSharedState.authenticationState.value == Loaded) } + // FIXME this looks like a hack. Find a multiplatform way to handle this outside of composition? + // variable to track if Android app was killed in background and later restored + var wasInitializedBefore by rememberSaveable { mutableStateOf(false) } + navigator.clearEvent() - val currentState = komgaSharedState.authenticationState.value - when (currentState) { - AuthenticationRequired -> navigator.replaceAll(loginScreen) - Loaded -> {} - } - canProceed = true + LaunchedEffect(Unit) { + if (canProceed) return@LaunchedEffect + + // not really necessary since Voyager navigator doesn't dispose existing MainScreen when it's replaced with LoginScreen + // when LoginScreen replaces itself back to MainScreen, it's restored to old state + // not sure if it's intended, do proper initialization here to avoid loading LoginScreen + if (wasInitializedBefore) { + komgaSharedState.tryReloadState() + } + + val currentState = komgaSharedState.authenticationState.value + when (currentState) { + AuthenticationRequired -> navigator.replaceAll(loginScreen) + Loaded -> {} + } + canProceed = true - komgaSharedState.authenticationState.collect { - wasInitializedBefore = when (it) { - AuthenticationRequired -> false - Loaded -> true + komgaSharedState.authenticationState.collect { + wasInitializedBefore = when (it) { + AuthenticationRequired -> false + Loaded -> true + } + } + } + + if (canProceed) { + CurrentScreen() } } } - - if (canProceed) CurrentScreen() } - } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/Theme.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/Theme.kt index 8dab4e04..ae0d82f6 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/Theme.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/Theme.kt @@ -65,17 +65,17 @@ enum class Theme( tertiaryContainer = Color(red = 181, green = 130, blue = 49), onTertiaryContainer = Color.White, - background = Color(red = 254, green = 247, blue = 255), + background = Color.White, // Original: Color(red = 254, green = 247, blue = 255) onBackground = Color(red = 29, green = 27, blue = 32), - surface = Color(red = 254, green = 247, blue = 255), + surface = Color.White, // Original: Color(red = 254, green = 247, blue = 255) onSurface = Color(red = 29, green = 27, blue = 32), - surfaceVariant = Color(red = 231, green = 224, blue = 236), - surfaceContainerHighest = Color(red = 230, green = 224, blue = 233), + surfaceVariant = Color(red = 240, green = 240, blue = 240), // Original: Color(red = 231, green = 224, blue = 236) + surfaceContainerHighest = Color(red = 235, green = 235, blue = 235), // Original: Color(red = 230, green = 224, blue = 233) onSurfaceVariant = Color(red = 73, green = 69, blue = 79), - surfaceDim = Color(red = 222, green = 216, blue = 225), + surfaceDim = Color(red = 225, green = 225, blue = 225), // Original: Color(red = 222, green = 216, blue = 225) surfaceBright = Color(red = 180, green = 180, blue = 180), error = Color(red = 240, green = 70, blue = 60), diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/ViewModelFactory.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/ViewModelFactory.kt index b34fbc4f..fdecd16c 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/ViewModelFactory.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/ViewModelFactory.kt @@ -122,6 +122,7 @@ class ViewModelFactory( libraryApi = komgaApi.libraryApi, collectionApi = komgaApi.collectionsApi, readListsApi = komgaApi.readListApi, + bookApi = komgaApi.bookApi, seriesApi = komgaApi.seriesApi, referentialApi = komgaApi.referentialApi, diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookScreen.kt index fee5d8d7..1079c354 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookScreen.kt @@ -27,6 +27,12 @@ import snd.komga.client.book.KomgaBookId import snd.komga.client.series.KomgaSeriesId import kotlin.jvm.Transient +import snd.komelia.ui.LocalAccentColor +import snd.komelia.ui.LocalPlatform +import snd.komelia.ui.LocalUseNewLibraryUI +import snd.komelia.ui.book.immersive.ImmersiveBookContent +import snd.komelia.ui.platform.PlatformType + fun bookScreen( book: KomeliaBook, bookSiblingsContext: BookSiblingsContext? = null @@ -66,6 +72,47 @@ class BookScreen( onDispose { vm.stopKomgaEventHandler() } } + val platform = LocalPlatform.current + val useNewUI = LocalUseNewLibraryUI.current + if (platform == PlatformType.MOBILE && useNewUI) { + val book = vm.book.collectAsState().value ?: return + val siblings = vm.siblingBooks.collectAsState().value + + ImmersiveBookContent( + book = book, + siblingBooks = siblings, + accentColor = LocalAccentColor.current, + bookMenuActions = vm.bookMenuActions, + onBackClick = { onBackPress(navigator, book.seriesId) }, + onReadBook = { selectedBook, markReadProgress -> + navigator.parent?.push( + readerScreen(selectedBook, markReadProgress, bookSiblingsContext) + ) + }, + onDownload = vm::onBookDownload, + onFilterClick = { filter -> + navigator.push(LibraryScreen(book.libraryId, filter)) + }, + readLists = vm.readListsState.readLists, + onReadListClick = { navigator.push(ReadListScreen(it.id)) }, + onReadListBookPress = { listBook, readList -> + if (listBook.id != book.id) navigator.push( + bookScreen( + book = listBook, + bookSiblingsContext = BookSiblingsContext.ReadList(readList.id) + ) + ) + }, + cardWidth = vm.cardWidth.collectAsState().value, + onSeriesClick = { seriesId -> navigator.push(SeriesScreen(seriesId)) }, + onBookChange = vm::setCurrentBook, + initiallyExpanded = vm.isExpanded, + onExpandChange = { vm.isExpanded = it } + ) + BackPressHandler { onBackPress(navigator, book.seriesId) } + return + } + val book = vm.book.collectAsState().value ScreenPullToRefreshBox( diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookViewModel.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookViewModel.kt index 81addd81..bebc75a5 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookViewModel.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/BookViewModel.kt @@ -33,7 +33,9 @@ import snd.komelia.ui.common.cards.defaultCardWidth import snd.komelia.ui.common.menus.BookMenuActions import snd.komelia.ui.readlist.BookReadListsState import snd.komga.client.book.KomgaBookId +import snd.komga.client.common.KomgaPageRequest import snd.komga.client.library.KomgaLibrary +import snd.komga.client.search.allOfBooks import snd.komga.client.sse.KomgaEvent import snd.komga.client.sse.KomgaEvent.BookAdded import snd.komga.client.sse.KomgaEvent.BookChanged @@ -42,7 +44,7 @@ import snd.komga.client.sse.KomgaEvent.ReadProgressDeleted class BookViewModel( book: KomeliaBook?, - private val bookId: KomgaBookId, + bookId: KomgaBookId, private val bookApi: KomgaBookApi, private val notifications: AppNotifications, private val komgaEvents: SharedFlow, @@ -55,6 +57,8 @@ class BookViewModel( var library by mutableStateOf(null) private set val book = MutableStateFlow(book) + private val currentBookId = MutableStateFlow(bookId) + var isExpanded by mutableStateOf(false) private val reloadEventsEnabled = MutableStateFlow(true) private val reloadJobsFlow = MutableSharedFlow(1, 0, DROP_OLDEST) @@ -70,6 +74,8 @@ class BookViewModel( val cardWidth = settingsRepository.getCardWidth().map { it.dp } .stateIn(screenModelScope, Eagerly, defaultCardWidth.dp) + val siblingBooks = MutableStateFlow>(emptyList()) + val bookMenuActions = BookMenuActions(bookApi, notifications, screenModelScope, taskEmitter) suspend fun initialize() { @@ -79,6 +85,7 @@ class BookViewModel( else mutableState.value = Success(Unit) loadLibrary() readListsState.initialize() + loadSiblingBooks() startKomgaEventListener() reloadJobsFlow.onEach { @@ -95,10 +102,23 @@ class BookViewModel( } } + fun loadSiblingBooks() { + screenModelScope.launch { + val seriesId = book.value?.seriesId ?: return@launch + notifications.runCatchingToNotifications { + val page = bookApi.getBookList( + conditionBuilder = allOfBooks { seriesId { isEqualTo(seriesId) } }, + pageRequest = KomgaPageRequest(unpaged = true) + ) + siblingBooks.value = page.content + } + } + } + private suspend fun loadBook() { notifications.runCatchingToNotifications { mutableState.value = Loading - val loadedBook = bookApi.getOne(bookId) + val loadedBook = bookApi.getOne(currentBookId.value) book.value = loadedBook } .onSuccess { mutableState.value = Success(Unit) } @@ -120,6 +140,12 @@ class BookViewModel( readListsState.startKomgaEventHandler() } + fun setCurrentBook(book: KomeliaBook) { + this.book.value = book + this.currentBookId.value = book.id + loadLibrary() + } + fun onBookDownload() { screenModelScope.launch { book.value?.let { taskEmitter.downloadBook(it.id) } @@ -134,6 +160,7 @@ class BookViewModel( private fun startKomgaEventListener() { komgaEvents.onEach { event -> + val bookId = currentBookId.value when (event) { is BookChanged, is BookAdded -> if (event.bookId == bookId) reloadJobsFlow.tryEmit(Unit) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt new file mode 100644 index 00000000..3ee503b4 --- /dev/null +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/book/immersive/ImmersiveBookContent.kt @@ -0,0 +1,481 @@ +package snd.komelia.ui.book.immersive + +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.NavigateNext +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.flow.first +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.lerp +import snd.komelia.ui.LocalAnimatedVisibilityScope +import snd.komelia.ui.LocalSharedTransitionScope +import kotlinx.datetime.TimeZone +import kotlinx.datetime.format +import kotlinx.datetime.toLocalDateTime +import snd.komelia.DefaultDateTimeFormats.localDateTimeFormat +import snd.komelia.image.coil.BookDefaultThumbnailRequest +import snd.komelia.komga.api.model.KomeliaBook +import snd.komelia.ui.book.BookInfoColumn +import snd.komelia.ui.common.images.ThumbnailImage +import snd.komelia.ui.common.immersive.ImmersiveDetailFab +import snd.komelia.ui.common.immersive.ImmersiveDetailScaffold +import snd.komelia.ui.common.menus.BookActionsMenu +import snd.komelia.ui.common.menus.BookMenuActions +import snd.komelia.ui.dialogs.ConfirmationDialog +import snd.komelia.ui.dialogs.permissions.DownloadNotificationRequestDialog +import snd.komelia.ui.library.SeriesScreenFilter +import snd.komelia.ui.readlist.BookReadListsContent +import snd.komga.client.readlist.KomgaReadList +import snd.komga.client.series.KomgaSeriesId +import kotlin.math.roundToInt + +private val emphasizedAccelerateEasing = CubicBezierEasing(0.3f, 0.0f, 0.8f, 0.15f) + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun ImmersiveBookContent( + book: KomeliaBook, + siblingBooks: List, + accentColor: Color?, + bookMenuActions: BookMenuActions, + onBackClick: () -> Unit, + onReadBook: (KomeliaBook, Boolean) -> Unit, + onDownload: () -> Unit, + onFilterClick: (SeriesScreenFilter) -> Unit, + readLists: Map>, + onReadListClick: (KomgaReadList) -> Unit, + onReadListBookPress: (KomeliaBook, KomgaReadList) -> Unit, + cardWidth: Dp, + onSeriesClick: (KomgaSeriesId) -> Unit, + onBookChange: (KomeliaBook) -> Unit = {}, + initiallyExpanded: Boolean, + onExpandChange: (Boolean) -> Unit, +) { + // Detect when the shared transition is no longer "entering". + // animatedVisibilityScope is null when there is no shared transition (fallback: treat as settled). + val animatedVisibilityScope = LocalAnimatedVisibilityScope.current + val transitionIsSettled = remember(animatedVisibilityScope) { + derivedStateOf { + animatedVisibilityScope == null || + animatedVisibilityScope.transition.currentState == EnterExitState.Visible + } + } + + // pagerExpanded: controls page count (1 → N). Flipped first so the pager can be scrolled. + // initialScrollDone: controls the pageBook guard. Flipped AFTER scrollToPage so that no + // page shows the wrong cover during the brief window between expansion and scroll. + var pagerExpanded by remember { mutableStateOf(false) } + var initialScrollDone by remember { mutableStateOf(false) } + val pagerPageCount = if (pagerExpanded) maxOf(1, siblingBooks.size) else 1 + val pagerState = rememberPagerState(pageCount = { pagerPageCount }) + + LaunchedEffect(siblingBooks) { + if (!initialScrollDone && siblingBooks.isNotEmpty()) { + // Wait for the transition to settle so new pages don't fire animateEnterExit. + snapshotFlow { transitionIsSettled.value }.first { it } + val idx = siblingBooks.indexOfFirst { it.id == book.id }.coerceAtLeast(0) + // Expand pager (pageBook guard still holds — all pages show book's cover). + pagerExpanded = true + // Wait for the pager to recognise the expanded page count, then snap to correct page. + snapshotFlow { pagerState.pageCount }.first { it > idx } + pagerState.scrollToPage(idx) + // Only now unlock pageBook — pager is already on the right page, so siblingBooks[idx] + // is the same book and coverData key is unchanged → no flash. + initialScrollDone = true + } + } + + // selectedBook drives the FAB and 3-dot menu after each swipe settles. + // Guard by initialScrollDone: before the pager has landed on the right page, always return + // the originally-tapped book. Without this guard, settledPage=0 when siblings first load + // would produce selectedBook=siblingBooks[0], triggering onBookChange with the wrong book — + // which propagates back as the `book` prop and corrupts the pageBook guard above. + val selectedBook = remember(pagerState.settledPage, siblingBooks, initialScrollDone) { + if (initialScrollDone) siblingBooks.getOrNull(pagerState.settledPage) ?: book + else book + } + + LaunchedEffect(selectedBook) { + onBookChange(selectedBook) + } + + var showDownloadConfirmationDialog by remember { mutableStateOf(false) } + + val sharedTransitionScope = LocalSharedTransitionScope.current + + val fabOverlayModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { + with(sharedTransitionScope) { + with(animatedVisibilityScope) { + Modifier + .renderInSharedTransitionScopeOverlay(zIndexInOverlay = 1f) + .animateEnterExit( + enter = fadeIn(tween(300, delayMillis = 50)), + exit = slideOutVertically(tween(200, easing = emphasizedAccelerateEasing)) { it / 2 } + + fadeOut(tween(150)) + ) + } + } + } else Modifier + + val uiOverlayModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { + with(sharedTransitionScope) { + with(animatedVisibilityScope) { + Modifier + .renderInSharedTransitionScopeOverlay(zIndexInOverlay = 0.75f) + .animateEnterExit( + enter = fadeIn(tween(durationMillis = 500)), + exit = fadeOut(tween(durationMillis = 100)) + ) + } + } + } else Modifier + + Box(modifier = Modifier.fillMaxSize()) { + + // Outer HorizontalPager — slides the entire scaffold (cover + card) laterally + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + pageSpacing = 8.dp, + ) { pageIndex -> + // During the transition (initialScrollDone = false, pager has 1 page) always show the + // tapped book so the shared-element cover key stays stable throughout the animation. + val pageBook = if (!initialScrollDone) book else (siblingBooks.getOrNull(pageIndex) ?: book) + // Memoize to avoid a new Random requestCache on every recomposition, which would + // cause ThumbnailImage's remember(data,cacheKey) to rebuild the ImageRequest and flash. + val coverData = remember(pageBook.id) { BookDefaultThumbnailRequest(pageBook.id) } + + ImmersiveDetailScaffold( + coverData = coverData, + coverKey = pageBook.id.value, + cardColor = null, + immersive = true, + initiallyExpanded = initiallyExpanded, + onExpandChange = onExpandChange, + topBarContent = {}, // Fixed overlay handles this + fabContent = {}, // Fixed overlay handles this + cardContent = { expandFraction -> + val thumbnailOffset = (126.dp * expandFraction).coerceAtLeast(0.dp) + val thumbnailTopGap = 20.dp + val thumbnailHeight = 110.dp / 0.703f // ≈ 156.5 dp + + val navBarBottom = with(LocalDensity.current) { + WindowInsets.navigationBars.getBottom(this).toDp() + } + LazyVerticalGrid( + columns = GridCells.Fixed(1), + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(0.dp), + contentPadding = PaddingValues(bottom = navBarBottom + 80.dp), + ) { + // Collapsed stats line (fades out as card expands) + item(span = { GridItemSpan(maxLineSpan) }) { + val alpha = (1f - expandFraction * 2f).coerceIn(0f, 1f) + if (alpha > 0.01f) + BookStatsLine(pageBook, Modifier + .padding(start = 16.dp, end = 16.dp, top = 4.dp) + .graphicsLayer { this.alpha = alpha }) + } + + // Header: thumbnail offset + series title · #N, book title, writers (year) + item { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = (thumbnailTopGap + thumbnailHeight) * expandFraction) + .padding( + start = 16.dp, + end = 16.dp, + top = lerp(8f, thumbnailTopGap.value, expandFraction).dp, + ) + ) { + if (expandFraction > 0.01f) { + Box( + modifier = Modifier + .padding(top = (thumbnailTopGap - 8.dp) * expandFraction) + .graphicsLayer { alpha = (expandFraction * 2f - 1f).coerceIn(0f, 1f) } + ) { + ThumbnailImage( + data = coverData, + cacheKey = pageBook.id.value, + crossfade = false, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(width = 110.dp, height = thumbnailHeight) + .clip(RoundedCornerShape(8.dp)) + ) + } + } + + Column( + modifier = Modifier.padding(start = thumbnailOffset) + ) { + // Line 1: Series · #N (headlineSmall, bold) — tappable link + Row( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .clickable { onSeriesClick(pageBook.seriesId) } + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = "${pageBook.seriesTitle} · #${pageBook.metadata.number}", + style = MaterialTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.Bold, + ), + color = MaterialTheme.colorScheme.primary, + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.NavigateNext, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f), + ) + } + // Line 2: Book title (titleMedium) — only if different from series title + if (pageBook.metadata.title != pageBook.seriesTitle) { + Text( + text = pageBook.metadata.title, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 2.dp), + ) + } + // Line 3: Writers (year) — labelSmall + val writers = remember(pageBook.metadata.authors) { + pageBook.metadata.authors + .filter { it.role.lowercase() == "writer" } + .joinToString(", ") { it.name } + } + val year = pageBook.metadata.releaseDate?.year + val writersYearText = buildString { + if (writers.isNotEmpty()) append(writers) + if (year != null) { + if (writers.isNotEmpty()) append(" ") + append("($year)") + } + } + if (writersYearText.isNotEmpty()) { + Text( + text = writersYearText, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(top = 2.dp), + ) + } + } + } + } + + // Expanded stats line (fades in as card expands) + item(span = { GridItemSpan(maxLineSpan) }) { + val alpha = (expandFraction * 2f - 1f).coerceIn(0f, 1f) + if (alpha > 0.01f) + BookStatsLine(pageBook, Modifier + .padding(horizontal = 16.dp, vertical = 4.dp) + .graphicsLayer { this.alpha = alpha }) + } + + // Summary + if (pageBook.metadata.summary.isNotBlank()) { + item { + Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Text( + text = pageBook.metadata.summary, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + + // Divider + item { HorizontalDivider(Modifier.padding(vertical = 8.dp)) } + + // Book metadata (authors, tags, links, file info, ISBN) + item { + Box(Modifier.padding(horizontal = 16.dp)) { + BookInfoColumn( + publisher = null, + genres = null, + authors = pageBook.metadata.authors, + tags = pageBook.metadata.tags, + links = pageBook.metadata.links, + sizeInMiB = pageBook.size, + mediaType = pageBook.media.mediaType, + isbn = pageBook.metadata.isbn, + fileUrl = pageBook.url, + onFilterClick = onFilterClick, + ) + } + } + + // Reading lists + item(span = { GridItemSpan(maxLineSpan) }) { + BookReadListsContent( + readLists = readLists, + onReadListClick = onReadListClick, + onBookClick = onReadListBookPress, + cardWidth = cardWidth, + ) + } + } + } + ) + } + + // Fixed overlay: back button + 3-dot menu (stays still while pager slides) + Row( + modifier = Modifier + .fillMaxWidth() + .then(uiOverlayModifier) + .statusBarsPadding() + .padding(start = 12.dp, end = 4.dp, top = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(36.dp) + .background(Color.Black.copy(alpha = 0.55f), CircleShape) + .clickable(onClick = onBackClick), + contentAlignment = Alignment.Center + ) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = Color.White) + } + + var expandActions by remember { mutableStateOf(false) } + Box { + Box( + modifier = Modifier + .size(36.dp) + .background(Color.Black.copy(alpha = 0.55f), CircleShape) + .clickable { expandActions = true }, + contentAlignment = Alignment.Center + ) { + Icon(Icons.Rounded.MoreVert, contentDescription = null, tint = Color.White) + } + BookActionsMenu( + book = selectedBook, + actions = bookMenuActions, + expanded = expandActions, + showEditOption = true, + showDownloadOption = false, // download is in FAB + onDismissRequest = { expandActions = false }, + ) + } + } + + // Fixed overlay: FAB (stays still while pager slides) + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .then(fabOverlayModifier) + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(bottom = 16.dp) + ) { + ImmersiveDetailFab( + onReadClick = { onReadBook(selectedBook, true) }, + onReadIncognitoClick = { onReadBook(selectedBook, false) }, + onDownloadClick = { showDownloadConfirmationDialog = true }, + accentColor = accentColor, + showReadActions = true, + ) + } + } + + // Two-step download confirmation dialog + if (showDownloadConfirmationDialog) { + var permissionRequested by remember { mutableStateOf(false) } + DownloadNotificationRequestDialog { permissionRequested = true } + if (permissionRequested) { + ConfirmationDialog( + body = "Download \"${selectedBook.metadata.title}\"?", + onDialogConfirm = { + onDownload() + showDownloadConfirmationDialog = false + }, + onDialogDismiss = { showDownloadConfirmationDialog = false }, + ) + } + } +} + +@Composable +private fun BookStatsLine(book: KomeliaBook, modifier: Modifier = Modifier) { + val pagesCount = book.media.pagesCount + val segments = remember(book) { + buildList { + add("$pagesCount page${if (pagesCount == 1) "" else "s"}") + book.metadata.releaseDate?.let { add(it.toString()) } + book.readProgress?.let { progress -> + if (!progress.completed) { + val pagesLeft = pagesCount - progress.page + val pct = (progress.page.toFloat() / pagesCount * 100).roundToInt() + add("$pct%, $pagesLeft page${if (pagesLeft == 1) "" else "s"} left") + } + add(progress.readDate + .toLocalDateTime(TimeZone.currentSystemDefault()) + .format(localDateTimeFormat)) + } + } + } + if (segments.isEmpty()) return + Text( + text = segments.joinToString(" | "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier, + ) +} diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/BookItemCard.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/BookItemCard.kt index 353f77d5..86fd7fc3 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/BookItemCard.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/BookItemCard.kt @@ -24,8 +24,8 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.OfflinePin +import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon @@ -47,13 +47,17 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.filter import snd.komelia.komga.api.model.KomeliaBook import snd.komelia.offline.sync.model.DownloadEvent import snd.komelia.ui.LocalBookDownloadEvents +import snd.komelia.ui.LocalCardLayoutBelow import snd.komelia.ui.LocalLibraries import snd.komelia.ui.LocalWindowWidth import snd.komelia.ui.common.BookReadButton @@ -81,6 +85,8 @@ fun BookImageCard( val libraryIsDeleted = remember { libraries.value.firstOrNull { it.id == book.libraryId }?.unavailable ?: false } + val cardLayoutBelow = LocalCardLayoutBelow.current + ItemCard( modifier = modifier, onClick = onBookClick, @@ -97,7 +103,8 @@ fun BookImageCard( BookImageOverlay( book = book, libraryIsDeleted = libraryIsDeleted, - showSeriesTitle = showSeriesTitle, + showTitle = !cardLayoutBelow, + showSeriesTitle = showSeriesTitle && !cardLayoutBelow, ) { BookThumbnail( book.id, @@ -106,6 +113,55 @@ fun BookImageCard( ) } } + }, + content = { + if (cardLayoutBelow) { + Column( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalArrangement = Arrangement.Center + ) { + val isUnavailable = book.deleted || libraryIsDeleted + val showSeries = showSeriesTitle && !book.oneshot + + if (isUnavailable) { + Text( + text = "Unavailable", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + Text( + text = book.metadata.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + } else if (showSeries) { + Text( + text = book.seriesTitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = book.metadata.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + } else { + Text( + text = book.metadata.title, + maxLines = 2, + minLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } } ) } @@ -116,6 +172,7 @@ fun BookSimpleImageCard( onBookClick: (() -> Unit)? = null, modifier: Modifier = Modifier ) { + val cardLayoutBelow = LocalCardLayoutBelow.current ItemCard( modifier = modifier, onClick = onBookClick, @@ -123,7 +180,7 @@ fun BookSimpleImageCard( BookImageOverlay( book = book, libraryIsDeleted = false, - showTitle = false + showTitle = !cardLayoutBelow ) { BookThumbnail( book.id, @@ -131,6 +188,20 @@ fun BookSimpleImageCard( contentScale = ContentScale.Crop ) } + }, + content = { + if (cardLayoutBelow) { + Column(Modifier.padding(8.dp)) { + Text( + text = book.metadata.title, + maxLines = 2, + minLines = 2, + style = MaterialTheme.typography.bodyMedium, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Bold, + ) + } + } } ) } @@ -181,14 +252,14 @@ private fun BookImageOverlay( if (showTitle) { CardOutlinedText( text = book.metadata.title, - maxLines = 3 - ) - } - if (book.deleted || libraryIsDeleted) { - CardOutlinedText( - text = "Unavailable", - textColor = MaterialTheme.colorScheme.error + maxLines = DEFAULT_CARD_MAX_LINES ) + if (book.deleted || libraryIsDeleted) { + CardOutlinedText( + text = "Unavailable", + textColor = MaterialTheme.colorScheme.error + ) + } } } @@ -459,7 +530,7 @@ private fun BookDetailedListDetails( onClick = { isMenuExpanded = true }, colors = IconButtonDefaults.iconButtonColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) ) { - Icon(Icons.Default.MoreVert, null) + Icon(Icons.Rounded.MoreVert, null) } BookActionsMenu( book = book, @@ -487,7 +558,7 @@ private fun BookMenuActionsDropdown( onClick = { onActionsMenuExpand(true) }, colors = IconButtonDefaults.iconButtonColors(containerColor = MaterialTheme.colorScheme.surface) ) { - Icon(Icons.Default.MoreVert, null) + Icon(Icons.Rounded.MoreVert, null) } BookActionsMenu( @@ -500,4 +571,3 @@ private fun BookMenuActionsDropdown( ) } } - diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/CollectionItemCard.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/CollectionItemCard.kt index 51757961..879ea988 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/CollectionItemCard.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/CollectionItemCard.kt @@ -3,6 +3,7 @@ package snd.komelia.ui.common.cards import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -15,6 +16,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf @@ -25,7 +27,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import snd.komelia.ui.LocalCardLayoutBelow import snd.komelia.ui.LocalKomgaState import snd.komelia.ui.common.images.CollectionThumbnail import snd.komelia.ui.common.menus.CollectionActionsMenu @@ -38,12 +46,16 @@ fun CollectionImageCard( onCollectionDelete: () -> Unit, modifier: Modifier = Modifier ) { + val cardLayoutBelow = LocalCardLayoutBelow.current ItemCard( modifier = modifier, onClick = onCollectionClick, image = { CollectionCardHoverOverlay(collection, onCollectionDelete) { - CollectionImageOverlay(collection) { + CollectionImageOverlay( + collection = collection, + showTitle = !cardLayoutBelow, + ) { CollectionThumbnail( collectionId = collection.id, modifier = Modifier.fillMaxSize(), @@ -51,6 +63,28 @@ fun CollectionImageCard( ) } } + }, + content = { + if (cardLayoutBelow) { + Column( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalArrangement = Arrangement.Center + ) { + Text( + text = collection.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = "${collection.seriesIds.size} series", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } ) } @@ -108,6 +142,7 @@ private fun CollectionCardHoverOverlay( @Composable private fun CollectionImageOverlay( collection: KomgaCollection, + showTitle: Boolean = true, content: @Composable () -> Unit ) { @@ -116,10 +151,12 @@ private fun CollectionImageOverlay( contentAlignment = Alignment.BottomStart ) { content() - CardGradientOverlay() - Column(Modifier.padding(10.dp)) { - CardOutlinedText(collection.name) - CardOutlinedText("${collection.seriesIds.size} series") + if (showTitle) { + CardGradientOverlay() + Column(Modifier.padding(10.dp)) { + CardOutlinedText(collection.name) + CardOutlinedText("${collection.seriesIds.size} series") + } } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/ItemCard.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/ItemCard.kt index dfb2127b..eb11aa0f 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/ItemCard.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/ItemCard.kt @@ -35,31 +35,47 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.ReorderableLazyGridState +import snd.komelia.ui.LocalCardLayoutBelow import snd.komelia.ui.LocalPlatform import snd.komelia.ui.common.components.OutlinedText import snd.komelia.ui.platform.PlatformType import snd.komelia.ui.platform.cursorForHand const val defaultCardWidth = 240 +const val DEFAULT_CARD_MAX_LINES = 2 @OptIn(ExperimentalFoundationApi::class) @Composable fun ItemCard( modifier: Modifier = Modifier, - containerColor: Color = MaterialTheme.colorScheme.surfaceVariant, + containerColor: Color? = null, onClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null, image: @Composable () -> Unit, content: @Composable ColumnScope.() -> Unit = {}, ) { + val cardLayoutBelow = LocalCardLayoutBelow.current + val color = containerColor ?: if (cardLayoutBelow) Color.Transparent + else MaterialTheme.colorScheme.surfaceVariant + + val shape = if (cardLayoutBelow) RoundedCornerShape(12.dp) + else RoundedCornerShape(topStart = 5.dp, topEnd = 5.dp) + Card( - shape = RoundedCornerShape(topStart = 5.dp, topEnd = 5.dp), + shape = shape, modifier = modifier .combinedClickable(onClick = onClick ?: {}, onLongClick = onLongClick) .then(if (onClick != null || onLongClick != null) Modifier.cursorForHand() else Modifier), - colors = CardDefaults.cardColors(containerColor = containerColor), + colors = CardDefaults.cardColors(containerColor = color), ) { - Box(modifier = Modifier.aspectRatio(0.703f)) { image() } + val imageShape = if (cardLayoutBelow) RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + else RoundedCornerShape(topStart = 5.dp, topEnd = 5.dp) + + Box( + modifier = Modifier + .aspectRatio(0.703f) + .clip(imageShape) + ) { image() } content() } } @@ -104,7 +120,7 @@ fun overlayBorderModifier() = fun CardOutlinedText( text: String, textColor: Color = Color.Unspecified, - maxLines: Int = Int.MAX_VALUE, + maxLines: Int = DEFAULT_CARD_MAX_LINES, style: TextStyle = MaterialTheme.typography.bodyMedium.copy(color = Color.White), outlineDrawStyle: Stroke = Stroke(4f), ) { diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/ReadListItemCard.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/ReadListItemCard.kt index 354f7e86..902214e8 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/ReadListItemCard.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/ReadListItemCard.kt @@ -3,6 +3,7 @@ package snd.komelia.ui.common.cards import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -15,6 +16,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf @@ -25,7 +27,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import snd.komelia.ui.LocalCardLayoutBelow import snd.komelia.ui.LocalKomgaState import snd.komelia.ui.common.images.ReadListThumbnail import snd.komelia.ui.common.menus.ReadListActionsMenu @@ -38,12 +46,16 @@ fun ReadListImageCard( onCollectionDelete: () -> Unit, modifier: Modifier = Modifier ) { + val cardLayoutBelow = LocalCardLayoutBelow.current ItemCard( modifier = modifier, onClick = onCollectionClick, image = { ReadListCardHoverOverlay(readLists, onCollectionDelete) { - ReadListImageOverlay(readLists) { + ReadListImageOverlay( + readlist = readLists, + showTitle = !cardLayoutBelow, + ) { ReadListThumbnail( readListId = readLists.id, modifier = Modifier.fillMaxSize(), @@ -51,6 +63,28 @@ fun ReadListImageCard( ) } } + }, + content = { + if (cardLayoutBelow) { + Column( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalArrangement = Arrangement.Center + ) { + Text( + text = readLists.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = if (readLists.bookIds.size == 1) "1 book" else "${readLists.bookIds.size} books", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } ) } @@ -106,6 +140,7 @@ private fun ReadListCardHoverOverlay( @Composable private fun ReadListImageOverlay( readlist: KomgaReadList, + showTitle: Boolean = true, content: @Composable () -> Unit ) { @@ -114,12 +149,14 @@ private fun ReadListImageOverlay( contentAlignment = Alignment.BottomStart ) { content() - CardGradientOverlay() - Column(Modifier.padding(10.dp)) { - CardOutlinedText(readlist.name) - CardOutlinedText( - if (readlist.bookIds.size == 1) "1 book" else "${readlist.bookIds.size} books", - ) + if (showTitle) { + CardGradientOverlay() + Column(Modifier.padding(10.dp)) { + CardOutlinedText(readlist.name) + CardOutlinedText( + if (readlist.bookIds.size == 1) "1 book" else "${readlist.bookIds.size} books", + ) + } } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/SeriesItemCard.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/SeriesItemCard.kt index 59d8f6df..74fa9863 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/SeriesItemCard.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/cards/SeriesItemCard.kt @@ -18,7 +18,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material3.Card import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -34,8 +34,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import snd.komelia.ui.LocalCardLayoutBelow import snd.komelia.ui.LocalLibraries import snd.komelia.ui.common.components.NoPaddingChip import snd.komelia.ui.common.images.SeriesThumbnail @@ -57,6 +62,8 @@ fun SeriesImageCard( val libraryIsDeleted = remember { libraries.value.firstOrNull { it.id == series.libraryId }?.unavailable ?: false } + val cardLayoutBelow = LocalCardLayoutBelow.current + ItemCard( modifier = modifier, onClick = onSeriesClick, @@ -68,7 +75,11 @@ fun SeriesImageCard( isSelected = isSelected, seriesActions = seriesMenuActions, ) { - SeriesImageOverlay(series = series, libraryIsDeleted = libraryIsDeleted) { + SeriesImageOverlay( + series = series, + libraryIsDeleted = libraryIsDeleted, + showTitle = !cardLayoutBelow + ) { SeriesThumbnail( series.id, modifier = Modifier.fillMaxSize(), @@ -76,6 +87,39 @@ fun SeriesImageCard( ) } } + }, + content = { + if (cardLayoutBelow) { + Column( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalArrangement = Arrangement.Center + ) { + val isUnavailable = series.deleted || libraryIsDeleted + if (isUnavailable) { + Text( + text = series.metadata.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = "Unavailable", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } else { + Text( + text = series.metadata.title, + maxLines = 2, + minLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } } ) } @@ -86,6 +130,7 @@ fun SeriesSimpleImageCard( onSeriesClick: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { + val cardLayoutBelow = LocalCardLayoutBelow.current ItemCard( modifier = modifier, onClick = onSeriesClick, @@ -93,7 +138,7 @@ fun SeriesSimpleImageCard( SeriesImageOverlay( series = series, libraryIsDeleted = false, - showTitle = false, + showTitle = !cardLayoutBelow, ) { SeriesThumbnail( series.id, @@ -101,6 +146,20 @@ fun SeriesSimpleImageCard( contentScale = ContentScale.Crop ) } + }, + content = { + if (cardLayoutBelow) { + Column(Modifier.padding(8.dp)) { + Text( + text = series.metadata.title, + maxLines = 2, + minLines = 2, + style = MaterialTheme.typography.bodyMedium, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Bold, + ) + } + } } ) } @@ -150,7 +209,7 @@ private fun SeriesCardHoverOverlay( onClick = { isActionsMenuExpanded = true }, colors = IconButtonDefaults.iconButtonColors(containerColor = MaterialTheme.colorScheme.surface) ) { - Icon(Icons.Default.MoreVert, contentDescription = null) + Icon(Icons.Rounded.MoreVert, contentDescription = null) } SeriesActionsMenu( @@ -209,7 +268,7 @@ private fun SeriesImageOverlay( ) { if (showTitle) { - CardOutlinedText(text = series.metadata.title, maxLines = 4) + CardOutlinedText(text = series.metadata.title, maxLines = DEFAULT_CARD_MAX_LINES) if (series.deleted || libraryIsDeleted) { CardOutlinedText(text = "Unavailable", textColor = MaterialTheme.colorScheme.error) } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/AnimatedDropdownMenu.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/AnimatedDropdownMenu.kt new file mode 100644 index 00000000..a3a2832b --- /dev/null +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/AnimatedDropdownMenu.kt @@ -0,0 +1,85 @@ +package snd.komelia.ui.common.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.DropdownMenu +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first + +/** + * A [DropdownMenu] wrapper that applies M3-style fade + scale enter/exit animations. + * Scale origin defaults to top-right (1f, 0f), matching a trailing 3-dots button. + */ +@Composable +fun AnimatedDropdownMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + offset: DpOffset = DpOffset(0.dp, 0.dp), + transformOrigin: TransformOrigin = TransformOrigin(1f, 0f), + content: @Composable ColumnScope.() -> Unit, +) { + val transitionState = remember { MutableTransitionState(false) } + var popupVisible by remember { mutableStateOf(false) } + + LaunchedEffect(expanded) { + if (expanded) { + popupVisible = true + transitionState.targetState = true + } else { + transitionState.targetState = false + // Wait for exit animation to finish before removing the popup + snapshotFlow { transitionState.isIdle } + .filter { it } + .first() + popupVisible = false + } + } + + if (popupVisible) { + DropdownMenu( + expanded = true, + onDismissRequest = onDismissRequest, + offset = offset, + scrollState = rememberScrollState(), + ) { + AnimatedVisibility( + visibleState = transitionState, + enter = fadeIn(tween(200, easing = FastOutSlowInEasing)) + + scaleIn( + initialScale = 0.85f, + transformOrigin = transformOrigin, + animationSpec = tween(200, easing = FastOutSlowInEasing), + ), + exit = fadeOut(tween(120)) + + scaleOut( + targetScale = 0.85f, + transformOrigin = transformOrigin, + animationSpec = tween(120), + ), + ) { + Column(modifier = modifier) { content() } + } + } + } +} diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/DescriptionChips.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/DescriptionChips.kt index d2f5db62..3282423e 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/DescriptionChips.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/DescriptionChips.kt @@ -12,12 +12,16 @@ import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Shape +import snd.komelia.ui.LocalAccentColor +import snd.komelia.ui.LocalUseNewLibraryUI import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -88,7 +92,7 @@ fun DescriptionChips( @Composable fun NoPaddingChip( - borderColor: Color = MaterialTheme.colorScheme.surfaceVariant, + borderColor: Color = MaterialTheme.colorScheme.outline, color: Color = Color.Unspecified, onClick: () -> Unit = {}, modifier: Modifier = Modifier, @@ -96,8 +100,8 @@ fun NoPaddingChip( ) { Box( modifier = modifier - .border(Dp.Hairline, borderColor, RoundedCornerShape(10.dp)) - .clip(RoundedCornerShape(10.dp)) + .border(Dp.Hairline, borderColor, RoundedCornerShape(8.dp)) + .clip(RoundedCornerShape(8.dp)) .background(color) .clickable { onClick() } .padding(10.dp, 5.dp) @@ -117,9 +121,32 @@ fun NoPaddingChip( object AppFilterChipDefaults { @Composable - fun filterChipColors() = FilterChipDefaults.filterChipColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - selectedContainerColor = MaterialTheme.colorScheme.primary, - selectedLabelColor = MaterialTheme.colorScheme.onPrimary - ) + fun shape(): Shape { + return FilterChipDefaults.shape + } + + @Composable + fun filterChipColors(): androidx.compose.material3.SelectableChipColors { + val accent = LocalAccentColor.current ?: MaterialTheme.colorScheme.primary + val onAccent = if (0.299 * accent.red + 0.587 * accent.green + 0.114 * accent.blue > 0.5f) + Color.Black else Color.White + return FilterChipDefaults.filterChipColors( + containerColor = Color.Transparent, + labelColor = accent, + selectedContainerColor = accent, + selectedLabelColor = onAccent, + ) + } + + @Composable + fun filterChipBorder(selected: Boolean): BorderStroke? { + return if (selected) null else BorderStroke(Dp.Hairline, MaterialTheme.colorScheme.outline) + } +} + +object AppSuggestionChipDefaults { + @Composable + fun shape(): Shape { + return androidx.compose.material3.SuggestionChipDefaults.shape + } } \ No newline at end of file diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/DropdownChoiceMenu.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/DropdownChoiceMenu.kt index 6a424dc1..77df3fa2 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/DropdownChoiceMenu.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/DropdownChoiceMenu.kt @@ -76,7 +76,9 @@ fun DropdownChoiceMenu( modifier: Modifier = Modifier, label: @Composable (() -> Unit)? = null, inputFieldColor: Color = MaterialTheme.colorScheme.surfaceVariant, - contentPadding: PaddingValues = PaddingValues(10.dp) + contentPadding: PaddingValues = PaddingValues(10.dp), + selectedOptionContent: @Composable (LabeledEntry) -> Unit = { Text(it.label, maxLines = 1) }, + optionContent: @Composable (LabeledEntry) -> Unit = { Text(it.label) } ) { var isExpanded by remember { mutableStateOf(false) } ExposedDropdownMenuBox( @@ -85,7 +87,7 @@ fun DropdownChoiceMenu( onExpandedChange = { isExpanded = it }, ) { InputField( - value = selectedOption?.label ?: "", + content = selectedOption?.let { { selectedOptionContent(it) } } ?: {}, modifier = Modifier .menuAnchor(PrimaryNotEditable) .clip(RoundedCornerShape(topStart = 5.dp, topEnd = 5.dp)) @@ -106,7 +108,7 @@ fun DropdownChoiceMenu( options.forEach { DropdownMenuItem( - text = { Text(it.label) }, + text = { optionContent(it) }, onClick = { onOptionChange(it) isExpanded = false @@ -137,7 +139,7 @@ fun DropdownMultiChoiceMenu( onExpandedChange = { isExpanded = it }, ) { InputField( - value = selectedOptions.joinToString { it.label }.ifBlank { placeholder ?: "Any" }, + content = { Text(selectedOptions.joinToString { it.label }.ifBlank { placeholder ?: "Any" }, maxLines = 1) }, modifier = Modifier .menuAnchor(PrimaryNotEditable) .clip(RoundedCornerShape(topStart = 5.dp, topEnd = 5.dp)) @@ -170,7 +172,7 @@ fun DropdownMultiChoiceMenu( @Composable private fun InputField( - value: String, + content: @Composable () -> Unit, modifier: Modifier, label: @Composable (() -> Unit)? = null, trailingIcon: @Composable (() -> Unit), @@ -179,7 +181,7 @@ private fun InputField( ) { val interactionSource = remember { MutableInteractionSource() } Surface( - shadowElevation = 1.dp, + shadowElevation = 0.dp, color = color, modifier = Modifier .cursorForHand() @@ -199,7 +201,7 @@ private fun InputField( CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.labelMedium) { label?.let { it() } } - Text(value, maxLines = 1) + content() } Spacer(Modifier.weight(1f)) @@ -234,7 +236,7 @@ fun DropdownChoiceMenuWithSearch( onExpandedChange = { isExpanded = it }, ) { InputField( - value = selectedOptions.joinToString { it.label }.ifBlank { placeholder ?: "Any" }, + content = { Text(selectedOptions.joinToString { it.label }.ifBlank { placeholder ?: "Any" }, maxLines = 1) }, modifier = Modifier .menuAnchor(PrimaryNotEditable) .then(textFieldModifier), @@ -309,7 +311,7 @@ fun FilterDropdownChoice( contentPadding = PaddingValues(5.dp), label = label?.let { { Text(it) } }, inputFieldColor = MaterialTheme.colorScheme.surfaceVariant, - modifier = modifier.clip(RoundedCornerShape(5.dp)), + modifier = modifier, inputFieldModifier = Modifier.fillMaxWidth() ) } @@ -331,7 +333,7 @@ fun FilterDropdownMultiChoice( label = label?.let { { FilterLabelAndCount(label, selectedOptions.size) } }, placeholder = placeholder, inputFieldColor = MaterialTheme.colorScheme.surfaceVariant, - modifier = modifier.clip(RoundedCornerShape(5.dp)), + modifier = modifier, inputFieldModifier = Modifier.fillMaxWidth() ) } @@ -433,7 +435,7 @@ fun TagFiltersDropdownMenu( onExpandedChange = { isExpanded = it }, ) { InputField( - value = inputValue, + content = { Text(inputValue, maxLines = 1) }, modifier = Modifier .menuAnchor(PrimaryNotEditable) .then(inputFieldModifier), diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/Pagination.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/Pagination.kt index 51cb0c5c..8f0480f6 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/Pagination.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/Pagination.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.unit.dp @@ -139,7 +140,7 @@ fun PageSizeSelectionDropdown( ), onOptionChange = { onPageSizeChange(it.value) }, contentPadding = PaddingValues(5.dp), - inputFieldColor = MaterialTheme.colorScheme.surface, + inputFieldColor = Color.Transparent, inputFieldModifier = Modifier .widthIn(min = 70.dp) .clip(RoundedCornerShape(5.dp)) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/Slider.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/Slider.kt index bcda1477..51bb73de 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/Slider.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/components/Slider.kt @@ -8,18 +8,18 @@ import androidx.compose.ui.graphics.Color object AppSliderDefaults { @Composable fun colors( - thumbColor: Color = MaterialTheme.colorScheme.tertiaryContainer, - activeTrackColor: Color = MaterialTheme.colorScheme.tertiary, - activeTickColor: Color = MaterialTheme.colorScheme.tertiaryContainer, - inactiveTrackColor: Color = MaterialTheme.colorScheme.surfaceBright, - inactiveTickColor: Color = MaterialTheme.colorScheme.surfaceVariant, + accentColor: Color? = null, + thumbColor: Color = accentColor ?: MaterialTheme.colorScheme.tertiaryContainer, + activeTrackColor: Color = accentColor ?: MaterialTheme.colorScheme.tertiary, + activeTickColor: Color = (accentColor ?: MaterialTheme.colorScheme.tertiaryContainer).copy(alpha = 0.5f), + inactiveTrackColor: Color = MaterialTheme.colorScheme.surfaceBright.copy(alpha = 0.5f), + inactiveTickColor: Color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), disabledThumbColor: Color = Color.Unspecified, disabledActiveTrackColor: Color = Color.Unspecified, disabledActiveTickColor: Color = Color.Unspecified, disabledInactiveTrackColor: Color = Color.Unspecified, disabledInactiveTickColor: Color = Color.Unspecified ) = SliderDefaults.colors( - thumbColor = thumbColor, activeTrackColor = activeTrackColor, activeTickColor = activeTickColor, diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/BookThumbnail.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/BookThumbnail.kt index 4b6c2697..d6d62cfb 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/BookThumbnail.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/BookThumbnail.kt @@ -1,5 +1,10 @@ package snd.komelia.ui.common.images +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -10,10 +15,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import kotlinx.coroutines.flow.filterIsInstance import snd.komelia.image.coil.BookDefaultThumbnailRequest +import snd.komelia.ui.LocalAnimatedVisibilityScope import snd.komelia.ui.LocalKomgaEvents +import snd.komelia.ui.LocalSharedTransitionScope import snd.komga.client.book.KomgaBookId import snd.komga.client.sse.KomgaEvent.ThumbnailBookEvent +private val emphasizedEasing = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1.0f) +private val emphasizedAccelerateEasing = CubicBezierEasing(0.3f, 0.0f, 0.8f, 0.15f) + +@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun BookThumbnail( bookId: KomgaBookId, @@ -31,11 +42,27 @@ fun BookThumbnail( } } + val sharedTransitionScope = LocalSharedTransitionScope.current + val animatedVisibilityScope = LocalAnimatedVisibilityScope.current + val inSharedTransition = sharedTransitionScope != null && animatedVisibilityScope != null + val sharedModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { + with(sharedTransitionScope) { + Modifier.sharedBounds( + rememberSharedContentState(key = "cover-${bookId.value}"), + animatedVisibilityScope = animatedVisibilityScope, + enter = EnterTransition.None, + exit = ExitTransition.None, + boundsTransform = { _, _ -> tween(durationMillis = 600, easing = emphasizedEasing) }, + ) + } + } else Modifier + ThumbnailImage( data = requestData, cacheKey = bookId.value, contentScale = contentScale, - modifier = modifier + crossfade = !inSharedTransition, + modifier = modifier.then(sharedModifier), ) } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/SeriesThumbnail.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/SeriesThumbnail.kt index eae97ed0..20937e3f 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/SeriesThumbnail.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/SeriesThumbnail.kt @@ -1,5 +1,10 @@ package snd.komelia.ui.common.images +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -9,11 +14,17 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import snd.komelia.image.coil.SeriesDefaultThumbnailRequest +import snd.komelia.ui.LocalAnimatedVisibilityScope import snd.komelia.ui.LocalKomgaEvents +import snd.komelia.ui.LocalSharedTransitionScope import snd.komga.client.series.KomgaSeriesId import snd.komga.client.sse.KomgaEvent.ThumbnailBookEvent import snd.komga.client.sse.KomgaEvent.ThumbnailSeriesEvent +private val emphasizedEasing = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1.0f) +private val emphasizedAccelerateEasing = CubicBezierEasing(0.3f, 0.0f, 0.8f, 0.15f) + +@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun SeriesThumbnail( seriesId: KomgaSeriesId, @@ -35,11 +46,28 @@ fun SeriesThumbnail( } } } + + val sharedTransitionScope = LocalSharedTransitionScope.current + val animatedVisibilityScope = LocalAnimatedVisibilityScope.current + val inSharedTransition = sharedTransitionScope != null && animatedVisibilityScope != null + val sharedModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { + with(sharedTransitionScope) { + Modifier.sharedBounds( + rememberSharedContentState(key = "cover-${seriesId.value}"), + animatedVisibilityScope = animatedVisibilityScope, + enter = EnterTransition.None, + exit = ExitTransition.None, + boundsTransform = { _, _ -> tween(durationMillis = 600, easing = emphasizedEasing) }, + ) + } + } else Modifier + ThumbnailImage( data = requestData, cacheKey = seriesId.value, contentScale = contentScale, - modifier = modifier + crossfade = !inSharedTransition, + modifier = modifier.then(sharedModifier), ) } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/ThumbnailImage.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/ThumbnailImage.kt index 8e15c77c..67e667ed 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/ThumbnailImage.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/images/ThumbnailImage.kt @@ -19,25 +19,20 @@ fun ThumbnailImage( data: Any, cacheKey: String, contentScale: ContentScale = ContentScale.Fit, + crossfade: Boolean = true, + usePlaceholderKey: Boolean = true, placeholder: Painter? = NoopPainter, modifier: Modifier = Modifier, ) { val context = LocalPlatformContext.current - val request = remember(data, cacheKey) { + val request = remember(data, cacheKey, crossfade, usePlaceholderKey) { ImageRequest.Builder(context) .data(data) .memoryCacheKey(cacheKey) - .memoryCacheKeyExtra( - "scale", - when (contentScale) { - ContentScale.Fit -> "Fit" - ContentScale.Crop -> "Crop" - else -> "" - } - ) + .apply { if (usePlaceholderKey) placeholderMemoryCacheKey(cacheKey) } .diskCacheKey(cacheKey) - .precision(Precision.EXACT) - .crossfade(true) + .precision(Precision.INEXACT) + .crossfade(crossfade) .build() } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailFab.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailFab.kt new file mode 100644 index 00000000..5af1d937 --- /dev/null +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailFab.kt @@ -0,0 +1,102 @@ +package snd.komelia.ui.common.immersive + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.MenuBook +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.VisibilityOff +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.unit.dp +import snd.komelia.ui.LocalNavBarColor +import snd.komelia.ui.LocalTheme +import snd.komelia.ui.Theme + +@Composable +fun ImmersiveDetailFab( + onReadClick: () -> Unit, + onReadIncognitoClick: () -> Unit, + onDownloadClick: () -> Unit, + accentColor: Color? = null, + showReadActions: Boolean = true, +) { + val theme = LocalTheme.current + val navBarColor = LocalNavBarColor.current + + val (fabContainerColor, fabContentColor) = if (theme.type == Theme.ThemeType.LIGHT) { + Color(red = 43, green = 43, blue = 43) to Color.White + } else { + MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer + } + + val readNowContainerColor = accentColor + ?: if (theme.type == Theme.ThemeType.LIGHT) Color(red = 43, green = 43, blue = 43) + else navBarColor ?: MaterialTheme.colorScheme.primaryContainer + + val readNowContentColor = if (accentColor != null || (theme.type == Theme.ThemeType.DARK && navBarColor != null)) { + if (readNowContainerColor.luminance() > 0.5f) Color.Black else Color.White + } else { + contentColorFor(readNowContainerColor) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.weight(1f)) + + if (showReadActions) { + ExtendedFloatingActionButton( + onClick = onReadClick, + containerColor = readNowContainerColor, + contentColor = readNowContentColor, + icon = { + Icon( + Icons.AutoMirrored.Rounded.MenuBook, + contentDescription = null + ) + }, + text = { Text("Read Now") } + ) + + FloatingActionButton( + onClick = onReadIncognitoClick, + containerColor = fabContainerColor, + contentColor = fabContentColor, + ) { + Icon( + Icons.Rounded.VisibilityOff, + contentDescription = "Read Incognito" + ) + } + } + + // Download FAB + FloatingActionButton( + onClick = onDownloadClick, + containerColor = fabContainerColor, + contentColor = fabContentColor, + ) { + Icon( + Icons.Rounded.Download, + contentDescription = "Download" + ) + } + } +} diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt new file mode 100644 index 00000000..8313d9ca --- /dev/null +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/immersive/ImmersiveDetailScaffold.kt @@ -0,0 +1,380 @@ +package snd.komelia.ui.common.immersive + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.AnimationVector +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.TwoWayConverter +import androidx.compose.animation.core.VectorizedAnimationSpec +import androidx.compose.animation.core.exponentialDecay +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.snapTo +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import snd.komelia.ui.LocalAnimatedVisibilityScope +import snd.komelia.ui.LocalRawNavBarHeight +import snd.komelia.ui.LocalRawStatusBarHeight +import snd.komelia.ui.LocalSharedTransitionScope +import snd.komelia.ui.common.images.ThumbnailImage +import kotlin.math.roundToInt + +private enum class CardDragValue { COLLAPSED, EXPANDED } + +private class DirectionalSnapSpec : AnimationSpec { + override fun vectorize( + converter: TwoWayConverter + ): VectorizedAnimationSpec { + val expandSpec = tween( + durationMillis = 500, + easing = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1.0f) + ).vectorize(converter) + val collapseSpec = tween( + durationMillis = 200, + easing = CubicBezierEasing(0.3f, 0.0f, 0.8f, 0.15f) + ).vectorize(converter) + return object : VectorizedAnimationSpec { + override val isInfinite = false + private fun pick(initialValue: V, targetValue: V) = + if (converter.convertFromVector(targetValue) < converter.convertFromVector(initialValue)) expandSpec else collapseSpec + override fun getDurationNanos(initialValue: V, initialVelocity: V, targetValue: V) = + pick(initialValue, targetValue).getDurationNanos(initialValue, initialVelocity, targetValue) + override fun getValueFromNanos(playTimeNanos: Long, initialValue: V, targetValue: V, initialVelocity: V) = + pick(initialValue, targetValue).getValueFromNanos(playTimeNanos, initialValue, targetValue, initialVelocity) + override fun getVelocityFromNanos(playTimeNanos: Long, initialValue: V, targetValue: V, initialVelocity: V) = + pick(initialValue, targetValue).getVelocityFromNanos(playTimeNanos, initialValue, targetValue, initialVelocity) + } + } +} + +private val emphasizedEasing = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1.0f) +private val emphasizedAccelerateEasing = CubicBezierEasing(0.3f, 0.0f, 0.8f, 0.15f) + +@OptIn(ExperimentalFoundationApi::class, ExperimentalSharedTransitionApi::class) +@Composable +fun ImmersiveDetailScaffold( + coverData: Any, + coverKey: String, + cardColor: Color?, + modifier: Modifier = Modifier, + immersive: Boolean = false, + initiallyExpanded: Boolean = false, + onExpandChange: (Boolean) -> Unit = {}, + topBarContent: @Composable () -> Unit, + fabContent: @Composable () -> Unit, + cardContent: @Composable ColumnScope.(expandFraction: Float) -> Unit, +) { + val density = LocalDensity.current + val backgroundColor = cardColor ?: MaterialTheme.colorScheme.surfaceVariant + + // Read shared transition scopes OUTSIDE BoxWithConstraints (which uses SubcomposeLayout). + // SubcomposeLayout defers content composition to the layout phase, so any CompositionLocal + // reads inside it happen too late for SharedTransitionLayout's composition-phase matching. + val sharedTransitionScope = LocalSharedTransitionScope.current + val animatedVisibilityScope = LocalAnimatedVisibilityScope.current + + // Whether a shared transition is in progress — used to suppress crossfade during animation. + val inSharedTransition = sharedTransitionScope != null && animatedVisibilityScope != null + + // Cover image is the shared element — flies from source thumbnail to its destination position. + // Must be computed outside BoxWithConstraints for composition-phase matching. + val coverSharedModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { + with(sharedTransitionScope) { + Modifier.sharedBounds( + rememberSharedContentState(key = "cover-$coverKey"), + animatedVisibilityScope = animatedVisibilityScope, + enter = EnterTransition.None, + exit = ExitTransition.None, + boundsTransform = { _, _ -> tween(durationMillis = 600, easing = emphasizedEasing) }, + ) + } + } else Modifier + + // Scaffold fades in behind the flying cover image and sliding card. + // No slide here — the card handles its own slide via cardOverlayModifier. A slide on + // BoxWithConstraints would move the layout positions of all children, causing the card to + // double-animate and causing the cover sharedBounds destination to shift mid-transition. + val scaffoldEnterExitModifier = if (animatedVisibilityScope != null) { + with(animatedVisibilityScope) { + Modifier.animateEnterExit( + enter = fadeIn(tween(300)), + exit = fadeOut(tween(200, easing = emphasizedAccelerateEasing)) + ) + } + } else Modifier + + // Card enters in the SharedTransition overlay at z=0.5 — always above the cover image (z=0 + // from sharedBounds default). This prevents the z-flip where the source thumbnail image + // would otherwise appear on top of the card during the container-transform crossfade. + val cardOverlayModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { + with(sharedTransitionScope) { + with(animatedVisibilityScope) { + Modifier + .renderInSharedTransitionScopeOverlay(zIndexInOverlay = 0.5f) + .animateEnterExit( + enter = slideInVertically(tween(500, easing = emphasizedEasing)) { it / 4 } + + fadeIn(tween(300)), + exit = fadeOut(tween(200, easing = emphasizedAccelerateEasing)) + ) + } + } + } else Modifier + + val uiEnterExitModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { + with(sharedTransitionScope) { + with(animatedVisibilityScope) { + Modifier + .renderInSharedTransitionScopeOverlay(zIndexInOverlay = 0.75f) + .animateEnterExit( + enter = fadeIn(tween(durationMillis = 500)), + exit = fadeOut(tween(durationMillis = 100)) + ) + } + } + } else Modifier + + val fabOverlayModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { + with(sharedTransitionScope) { + with(animatedVisibilityScope) { + Modifier + .renderInSharedTransitionScopeOverlay(zIndexInOverlay = 1f) + .animateEnterExit( + enter = fadeIn(tween(300, delayMillis = 50)), + exit = slideOutVertically(tween(200, easing = emphasizedAccelerateEasing)) { it / 2 } + + fadeOut(tween(150)) + ) + } + } + } else Modifier + + BoxWithConstraints(modifier = modifier.fillMaxSize().then(scaffoldEnterExitModifier)) { + val screenHeight = maxHeight + val statusBarDp = LocalRawStatusBarHeight.current + val navBarDp = LocalRawNavBarHeight.current + val windowHeightDp = with(density) { LocalWindowInfo.current.containerSize.height.toDp() } + // Use actual window height (invariant to app nav bar showing/hiding) for stable collapsedOffset. + val stableScreenHeight = windowHeightDp - statusBarDp - navBarDp + val collapsedOffset = stableScreenHeight * 0.65f + val collapsedOffsetPx = with(density) { collapsedOffset.toPx() } + + // Use remember (not rememberSaveable) so pager pages don't restore stale saved state. + var savedExpanded by remember { mutableStateOf(initiallyExpanded) } + + val state = remember(collapsedOffsetPx) { + AnchoredDraggableState( + initialValue = if (savedExpanded) CardDragValue.EXPANDED else CardDragValue.COLLAPSED, + anchors = DraggableAnchors { + CardDragValue.COLLAPSED at collapsedOffsetPx + CardDragValue.EXPANDED at 0f + }, + positionalThreshold = { d -> d * 0.5f }, + velocityThreshold = { with(density) { 100.dp.toPx() } }, + // M3 Emphasize Decelerate (expand, 500ms) / Emphasize Accelerate (collapse, 200ms) + snapAnimationSpec = DirectionalSnapSpec(), + decayAnimationSpec = exponentialDecay(), + ) + } + + val cardOffsetPx = if (state.offset.isNaN()) collapsedOffsetPx else state.offset + val expandFraction = (1f - cardOffsetPx / collapsedOffsetPx).coerceIn(0f, 1f) + + // Snap already-composed pages (e.g. adjacent in a pager) when the parent changes the + // shared expand state. Skips the snap if the card is already in the right position. + LaunchedEffect(initiallyExpanded) { + val target = if (initiallyExpanded) CardDragValue.EXPANDED else CardDragValue.COLLAPSED + if (state.currentValue != target) { + state.snapTo(target) + } + savedExpanded = initiallyExpanded + } + + LaunchedEffect(state.currentValue) { + savedExpanded = state.currentValue == CardDragValue.EXPANDED + onExpandChange(savedExpanded) + } + + val nestedScrollConnection = remember(state) { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val delta = available.y + val currentOffset = if (state.offset.isNaN()) collapsedOffsetPx else state.offset + return if (delta < 0 && currentOffset > 0f) { + state.dispatchRawDelta(delta) + Offset(0f, delta) + } else { + Offset.Zero + } + } + + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { + val delta = available.y + return if (delta > 0 && source == NestedScrollSource.UserInput) { + val cardConsumed = state.dispatchRawDelta(delta) + Offset(0f, cardConsumed) + } else Offset.Zero + } + + override suspend fun onPreFling(available: Velocity): Velocity { + val currentOffset = if (state.offset.isNaN()) collapsedOffsetPx else state.offset + if (available.y < 0f && currentOffset > 0f) { + state.settle(-1000f) + return available + } + return Velocity.Zero + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + val currentOffset = if (state.offset.isNaN()) collapsedOffsetPx else state.offset + if (currentOffset <= 0f || currentOffset >= collapsedOffsetPx) return Velocity.Zero + + return when { + available.y > 0f -> { + // Downward fling: snap to COLLAPSED + state.settle(available.y) + available + } + available.y < 0f -> { + // Upward fling: snap to EXPANDED + state.settle(-1000f) + available + } + else -> { + // Slow stop after a collapse drag: settle by positional threshold + state.settle(0f) + Velocity.Zero + } + } + } + } + } + + val topCornerRadiusDp = lerp(28f, 0f, expandFraction).dp + val statusBarPx = with(density) { statusBarDp.toPx() } + + Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface)) { + + // Layer 1: Cover image — shared element that flies from source thumbnail. + // coverSharedModifier placed first so sharedBounds captures the element at its + // final layout position; geometry modifiers then refine size/offset within that. + // crossfade suppressed during the transition to avoid the placeholder→loaded flash. + ThumbnailImage( + data = coverData, + cacheKey = coverKey, + crossfade = !inSharedTransition, + usePlaceholderKey = false, + contentScale = ContentScale.Crop, + modifier = if (immersive) + Modifier + .then(coverSharedModifier) + .fillMaxWidth() + .offset { IntOffset(0, -statusBarPx.roundToInt()) } + .height(collapsedOffset + topCornerRadiusDp + statusBarDp) + .graphicsLayer { alpha = 1f - expandFraction } + else + Modifier + .then(coverSharedModifier) + .fillMaxWidth() + .height(collapsedOffset + topCornerRadiusDp) + .graphicsLayer { alpha = 1f - expandFraction } + ) + + // Layer 2: Card — rendered in the SharedTransition overlay at z=0.5, above the + // cover image (z=0 from sharedBounds default). This ensures the card is always + // above the cover during the transition; after the transition, both return to + // normal layout where card (drawn later) is naturally above the cover. + val cardShape = RoundedCornerShape(topStart = topCornerRadiusDp, topEnd = topCornerRadiusDp) + Column( + modifier = Modifier + .then(cardOverlayModifier) + .offset { IntOffset(0, cardOffsetPx.roundToInt()) } + .fillMaxWidth() + .height(screenHeight) + .nestedScroll(nestedScrollConnection) + .anchoredDraggable(state, Orientation.Vertical) + .shadow(elevation = 6.dp, shape = cardShape) + .clip(cardShape) + .background(backgroundColor) + ) { + Box( + modifier = Modifier.fillMaxWidth().height(28.dp), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(width = 32.dp, height = 4.dp) + .clip(RoundedCornerShape(2.dp)) + .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)) + ) + } + Column(modifier = Modifier.fillMaxWidth().weight(1f)) { + cardContent(expandFraction) + } + } + + // Layer 3: Top bar + Box(modifier = Modifier.fillMaxWidth().then(uiEnterExitModifier).statusBarsPadding()) { + topBarContent() + } + } + + // Layer 4: FAB — in overlay at z=1, above everything + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .then(fabOverlayModifier) + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(bottom = 16.dp) + ) { + fabContent() + } + } +} diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/CollectionLists.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/CollectionLists.kt index b83be54b..acd22e78 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/CollectionLists.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/CollectionLists.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyGridState @@ -18,9 +17,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch +import snd.komelia.ui.LocalUseNewLibraryUI import snd.komelia.ui.common.cards.CollectionImageCard import snd.komelia.ui.common.components.Pagination import snd.komelia.ui.platform.VerticalScrollbar +import snd.komelia.ui.platform.VerticalScrollbarWithFullSpans import snd.komga.client.collection.KomgaCollection import snd.komga.client.collection.KomgaCollectionId @@ -36,14 +37,16 @@ fun CollectionLazyCardGrid( scrollState: LazyGridState = rememberLazyGridState(), ) { val coroutineScope = rememberCoroutineScope() + val useNewLibraryUI = LocalUseNewLibraryUI.current + val cardSpacing = if (useNewLibraryUI) 7.dp else 8.dp + val horizontalPadding = 10.dp Box { LazyVerticalGrid( columns = GridCells.Adaptive(minSize), state = scrollState, - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(bottom = 30.dp), - modifier = Modifier.padding(horizontal = 10.dp) + horizontalArrangement = Arrangement.spacedBy(cardSpacing), + verticalArrangement = Arrangement.spacedBy(cardSpacing), + contentPadding = PaddingValues(start = horizontalPadding, end = horizontalPadding, bottom = 30.dp), ) { item( span = { GridItemSpan(maxLineSpan) }, @@ -61,7 +64,7 @@ fun CollectionLazyCardGrid( collection = it, onCollectionClick = { onCollectionClick(it.id) }, onCollectionDelete = { onCollectionDelete(it.id) }, - modifier = Modifier.fillMaxSize().padding(5.dp), + modifier = Modifier.fillMaxSize(), ) } @@ -82,6 +85,6 @@ fun CollectionLazyCardGrid( } - VerticalScrollbar(scrollState, Modifier.align(Alignment.TopEnd)) + VerticalScrollbarWithFullSpans(scrollState, Modifier.align(Alignment.TopEnd), 2) } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/ReadListLists.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/ReadListLists.kt index c3eb4ebe..18f177b1 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/ReadListLists.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/ReadListLists.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyGridState @@ -18,9 +17,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch +import snd.komelia.ui.LocalUseNewLibraryUI import snd.komelia.ui.common.cards.ReadListImageCard import snd.komelia.ui.common.components.Pagination -import snd.komelia.ui.platform.VerticalScrollbar +import snd.komelia.ui.platform.VerticalScrollbarWithFullSpans import snd.komga.client.readlist.KomgaReadList import snd.komga.client.readlist.KomgaReadListId @@ -36,14 +36,16 @@ fun ReadListLazyCardGrid( scrollState: LazyGridState = rememberLazyGridState(), ) { val coroutineScope = rememberCoroutineScope() + val useNewLibraryUI = LocalUseNewLibraryUI.current + val cardSpacing = if (useNewLibraryUI) 7.dp else 8.dp + val horizontalPadding = 10.dp Box { LazyVerticalGrid( columns = GridCells.Adaptive(minSize), state = scrollState, - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(bottom = 30.dp), - modifier = Modifier.padding(horizontal = 10.dp) + horizontalArrangement = Arrangement.spacedBy(cardSpacing), + verticalArrangement = Arrangement.spacedBy(cardSpacing), + contentPadding = PaddingValues(start = horizontalPadding, end = horizontalPadding, bottom = 30.dp), ) { item( span = { GridItemSpan(maxLineSpan) }, @@ -61,7 +63,7 @@ fun ReadListLazyCardGrid( readLists = it, onCollectionClick = { onReadListClick(it.id) }, onCollectionDelete = { onReadListDelete(it.id) }, - modifier = Modifier.fillMaxSize().padding(5.dp), + modifier = Modifier.fillMaxSize(), ) } item( @@ -81,6 +83,6 @@ fun ReadListLazyCardGrid( } - VerticalScrollbar(scrollState, Modifier.align(Alignment.TopEnd)) + VerticalScrollbarWithFullSpans(scrollState, Modifier.align(Alignment.TopEnd), 2) } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/SeriesLists.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/SeriesLists.kt index e08dcd2f..d626413a 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/SeriesLists.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/itemlist/SeriesLists.kt @@ -6,9 +6,11 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan @@ -27,6 +29,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch @@ -34,6 +37,7 @@ import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.ReorderableLazyGridState import sh.calvin.reorderable.rememberReorderableLazyGridState import snd.komelia.ui.LocalPlatform +import snd.komelia.ui.LocalUseNewLibraryUI import snd.komelia.ui.common.cards.DraggableImageCard import snd.komelia.ui.common.cards.SeriesImageCard import snd.komelia.ui.common.components.Pagination @@ -75,14 +79,19 @@ fun SeriesLazyCardGrid( } + val useNewLibraryUI = LocalUseNewLibraryUI.current + val cardSpacing = if (useNewLibraryUI) 7.dp else 15.dp + val horizontalPadding = if (useNewLibraryUI) 10.dp else 20.dp Box(modifier) { LazyVerticalGrid( state = gridState, columns = GridCells.Adaptive(minSize), - horizontalArrangement = Arrangement.spacedBy(15.dp), - verticalArrangement = Arrangement.spacedBy(15.dp), - contentPadding = PaddingValues(bottom = 50.dp), - modifier = Modifier.padding(horizontal = 20.dp) + horizontalArrangement = Arrangement.spacedBy(cardSpacing), + verticalArrangement = Arrangement.spacedBy(cardSpacing), + contentPadding = PaddingValues( + start = horizontalPadding, end = horizontalPadding, + bottom = 15.dp, + ), ) { item(span = { GridItemSpan(maxLineSpan) }) { beforeContent() diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/BookActionsMenu.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/BookActionsMenu.kt index ac48321c..d29fa0a1 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/BookActionsMenu.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/BookActionsMenu.kt @@ -1,12 +1,20 @@ package snd.komelia.ui.common.menus -import androidx.compose.foundation.background -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.material3.DropdownMenu +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Label +import androidx.compose.material.icons.automirrored.rounded.LabelOff +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.DeleteForever +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Search import androidx.compose.material3.DropdownMenuItem +import snd.komelia.ui.common.components.AnimatedDropdownMenu +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -110,13 +118,14 @@ fun BookActionsMenu( } val showDropdown = derivedStateOf { expanded && !showDeleteDialog && !showEditDialog } - DropdownMenu( + AnimatedDropdownMenu( expanded = showDropdown.value, onDismissRequest = onDismissRequest ) { if (isAdmin && !isOffline) { DropdownMenuItem( - text = { Text("Analyze") }, + text = { Text("Analyze", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Search, null) }, onClick = { actions.analyze(book) onDismissRequest() @@ -124,7 +133,8 @@ fun BookActionsMenu( ) DropdownMenuItem( - text = { Text("Refresh metadata") }, + text = { Text("Refresh metadata", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Refresh, null) }, onClick = { actions.refreshMetadata(book) onDismissRequest() @@ -132,7 +142,8 @@ fun BookActionsMenu( ) DropdownMenuItem( - text = { Text("Add to read list") }, + text = { Text("Add to read list", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Add, null) }, onClick = { showAddToReadListDialog = true }, ) } @@ -142,7 +153,8 @@ fun BookActionsMenu( if (!isRead) { DropdownMenuItem( - text = { Text("Mark as read") }, + text = { Text("Mark as read", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.AutoMirrored.Rounded.Label, null) }, onClick = { actions.markAsRead(book) onDismissRequest() @@ -152,7 +164,8 @@ fun BookActionsMenu( if (!isUnread) { DropdownMenuItem( - text = { Text("Mark as unread") }, + text = { Text("Mark as unread", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.AutoMirrored.Rounded.LabelOff, null) }, onClick = { actions.markAsUnread(book) onDismissRequest() @@ -162,47 +175,43 @@ fun BookActionsMenu( if (isAdmin && !isOffline && showEditOption) { DropdownMenuItem( - text = { Text("Edit") }, + text = { Text("Edit", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Edit, null) }, onClick = { showEditDialog = true }, ) } if (!isOffline && showDownloadOption) { DropdownMenuItem( - text = { Text("Download") }, + text = { Text("Download", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Download, null) }, onClick = { showDownloadDialog = true }, ) } if (book.downloaded) { - val deleteInteractionSource = remember { MutableInteractionSource() } - val deleteIsHovered = deleteInteractionSource.collectIsHoveredAsState() - val deleteColor = - if (deleteIsHovered.value) Modifier.background(MaterialTheme.colorScheme.errorContainer) - else Modifier DropdownMenuItem( - text = { Text("Delete downloaded") }, + text = { Text("Delete downloaded", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Delete, null) }, onClick = { showDeleteDownloadedDialog = true }, - modifier = Modifier - .hoverable(deleteInteractionSource) - .then(deleteColor) + colors = MenuDefaults.itemColors( + textColor = MaterialTheme.colorScheme.error, + leadingIconColor = MaterialTheme.colorScheme.error + ) ) } -// if (isAdmin && !isOffline) { -// val deleteInteractionSource = remember { MutableInteractionSource() } -// val deleteIsHovered = deleteInteractionSource.collectIsHoveredAsState() -// val deleteColor = -// if (deleteIsHovered.value) Modifier.background(MaterialTheme.colorScheme.errorContainer) -// else Modifier -// DropdownMenuItem( -// text = { Text("Delete from server") }, -// onClick = { showDeleteDialog = true }, -// modifier = Modifier -// .hoverable(deleteInteractionSource) -// .then(deleteColor) -// ) -// } + if (isAdmin && !isOffline) { + DropdownMenuItem( + text = { Text("Delete from server", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.DeleteForever, null) }, + onClick = { showDeleteDialog = true }, + colors = MenuDefaults.itemColors( + textColor = MaterialTheme.colorScheme.error, + leadingIconColor = MaterialTheme.colorScheme.error + ) + ) + } } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/CollectionActionsMenu.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/CollectionActionsMenu.kt index 922d1556..58c49f99 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/CollectionActionsMenu.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/CollectionActionsMenu.kt @@ -1,12 +1,13 @@ package snd.komelia.ui.common.menus -import androidx.compose.foundation.background -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.material3.DropdownMenu +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.DeleteForever +import androidx.compose.material.icons.rounded.Edit import androidx.compose.material3.DropdownMenuItem +import snd.komelia.ui.common.components.AnimatedDropdownMenu +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf @@ -14,7 +15,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier import snd.komelia.ui.dialogs.ConfirmationDialog import snd.komelia.ui.dialogs.collectionedit.CollectionEditDialog import snd.komga.client.collection.KomgaCollection @@ -54,26 +54,24 @@ fun CollectionActionsMenu( } val showDropdown = derivedStateOf { expanded && !showDeleteDialog && !showEditDialog } - DropdownMenu( + AnimatedDropdownMenu( expanded = showDropdown.value, onDismissRequest = onDismissRequest ) { - val deleteInteractionSource = remember { MutableInteractionSource() } - val deleteIsHovered = deleteInteractionSource.collectIsHoveredAsState() DropdownMenuItem( - text = { Text("Edit") }, + text = { Text("Edit", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Edit, null) }, onClick = { showEditDialog = true }, ) DropdownMenuItem( - text = { Text("Delete") }, + text = { Text("Delete", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.DeleteForever, null) }, onClick = { showDeleteDialog = true }, - modifier = Modifier - .hoverable(deleteInteractionSource) - .then( - if (deleteIsHovered.value) Modifier.background(MaterialTheme.colorScheme.errorContainer) - else Modifier - ) + colors = MenuDefaults.itemColors( + textColor = MaterialTheme.colorScheme.error, + leadingIconColor = MaterialTheme.colorScheme.error + ) ) } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/LibraryActionsMenu.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/LibraryActionsMenu.kt index 98dc3e2d..8e56cb0f 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/LibraryActionsMenu.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/LibraryActionsMenu.kt @@ -1,11 +1,11 @@ package snd.komelia.ui.common.menus -import androidx.compose.foundation.background -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.material3.DropdownMenu +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.DeleteForever +import androidx.compose.material.icons.rounded.DeleteSweep import androidx.compose.material3.DropdownMenuItem +import snd.komelia.ui.common.components.AnimatedDropdownMenu import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -29,6 +29,12 @@ import snd.komelia.ui.dialogs.ConfirmationDialog import snd.komelia.ui.dialogs.komf.reset.KomfResetLibraryMetadataDialog import snd.komelia.ui.dialogs.libraryedit.LibraryEditDialogs import snd.komga.client.library.KomgaLibrary +import androidx.compose.material3.Icon +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.automirrored.rounded.ManageSearch +import androidx.compose.material3.MenuDefaults @Composable fun LibraryActionsMenu( @@ -108,55 +114,52 @@ fun LibraryActionsMenu( val isAdmin = LocalKomgaState.current.authenticatedUser.collectAsState().value?.roleAdmin() ?: true val isOffline = LocalOfflineMode.current.collectAsState().value - DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) { + AnimatedDropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) { if (isAdmin && !isOffline) { DropdownMenuItem( - text = { Text("Scan library files") }, + text = { Text("Scan library files", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Search, null) }, onClick = { actions.scan(library) onDismissRequest() } ) - val deepScanInteractionSource = remember { MutableInteractionSource() } - val deepScanIsHovered = deepScanInteractionSource.collectIsHoveredAsState() - val deepScanColor = - if (deepScanIsHovered.value) Modifier.background(MaterialTheme.colorScheme.tertiaryContainer) - else Modifier - DropdownMenuItem( - text = { Text("Scan library files (deep)") }, + text = { Text("Scan library files (deep)", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.AutoMirrored.Rounded.ManageSearch, null) }, onClick = { actions.deepScan(library) onDismissRequest() - }, - modifier = Modifier - .hoverable(deepScanInteractionSource) - .then(deepScanColor) + } ) DropdownMenuItem( - text = { Text("Analyze") }, + text = { Text("Analyze", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Search, null) }, onClick = { showAnalyzeDialog = true onDismissRequest() } ) DropdownMenuItem( - text = { Text("Refresh metadata") }, + text = { Text("Refresh metadata", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Refresh, null) }, onClick = { refreshMetadataDialog = true onDismissRequest() } ) DropdownMenuItem( - text = { Text("Empty trash") }, + text = { Text("Empty trash", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.DeleteSweep, null) }, onClick = { emptyTrashDialog = true onDismissRequest() } ) DropdownMenuItem( - text = { Text("Edit") }, + text = { Text("Edit", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Edit, null) }, onClick = { showLibraryEditDialog = true onDismissRequest() @@ -171,7 +174,8 @@ fun LibraryActionsMenu( vmFactory.getKomfLibraryIdentifyViewModel(library) } DropdownMenuItem( - text = { Text("Auto-Identify (Komf)") }, + text = { Text("Auto-Identify (Komf)", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Search, null) }, onClick = { autoIdentifyVm.autoIdentify() onDismissRequest() @@ -179,39 +183,38 @@ fun LibraryActionsMenu( ) DropdownMenuItem( - text = { Text("Reset Metadata (Komf)") }, + text = { Text("Reset Metadata (Komf)", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Refresh, null) }, onClick = { showKomfResetDialog = true }, ) } - val deleteScanInteractionSource = remember { MutableInteractionSource() } - val deleteScanIsHovered = deleteScanInteractionSource.collectIsHoveredAsState() - val deleteScanColor = - if (deleteScanIsHovered.value) Modifier.background(MaterialTheme.colorScheme.errorContainer) - else Modifier - if (!isOffline && isAdmin) { DropdownMenuItem( - text = { Text("Delete") }, + text = { Text("Delete", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.DeleteForever, null) }, onClick = { deleteLibraryDialog = true onDismissRequest() }, - modifier = Modifier - .hoverable(deleteScanInteractionSource) - .then(deleteScanColor) + colors = MenuDefaults.itemColors( + textColor = MaterialTheme.colorScheme.error, + leadingIconColor = MaterialTheme.colorScheme.error + ) ) } if (isOffline) { DropdownMenuItem( - text = { Text("Delete downloaded") }, + text = { Text("Delete downloaded", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Delete, null) }, onClick = { deleteOfflineLibraryDialog = true onDismissRequest() }, - modifier = Modifier - .hoverable(deleteScanInteractionSource) - .then(deleteScanColor) + colors = MenuDefaults.itemColors( + textColor = MaterialTheme.colorScheme.error, + leadingIconColor = MaterialTheme.colorScheme.error + ) ) } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/OneshotActionsMenu.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/OneshotActionsMenu.kt index d18c8203..7dced429 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/OneshotActionsMenu.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/OneshotActionsMenu.kt @@ -1,12 +1,18 @@ package snd.komelia.ui.common.menus -import androidx.compose.foundation.background -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.material3.DropdownMenu +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.DeleteForever +import androidx.compose.material.icons.rounded.Label +import androidx.compose.material.icons.rounded.LabelOff +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Search import androidx.compose.material3.DropdownMenuItem +import snd.komelia.ui.common.components.AnimatedDropdownMenu +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -120,13 +126,14 @@ fun OneshotActionsMenu( !showAddToReadListDialog } - DropdownMenu( + AnimatedDropdownMenu( expanded = showDropdown.value, onDismissRequest = onDismissRequest ) { if (isAdmin && !isOffline) { DropdownMenuItem( - text = { Text("Analyze") }, + text = { Text("Analyze", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Search, null) }, onClick = { actions.analyze(book) onDismissRequest() @@ -134,7 +141,8 @@ fun OneshotActionsMenu( ) DropdownMenuItem( - text = { Text("Refresh metadata") }, + text = { Text("Refresh metadata", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Refresh, null) }, onClick = { actions.refreshMetadata(book) onDismissRequest() @@ -142,11 +150,13 @@ fun OneshotActionsMenu( ) DropdownMenuItem( - text = { Text("Add to read list") }, + text = { Text("Add to read list", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Add, null) }, onClick = { showAddToReadListDialog = true }, ) DropdownMenuItem( - text = { Text("Add to collection") }, + text = { Text("Add to collection", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Add, null) }, onClick = { showAddToCollectionDialog = true }, ) } @@ -156,7 +166,8 @@ fun OneshotActionsMenu( if (!isRead) { DropdownMenuItem( - text = { Text("Mark as read") }, + text = { Text("Mark as read", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Label, null) }, onClick = { actions.markAsRead(book) onDismissRequest() @@ -166,7 +177,8 @@ fun OneshotActionsMenu( if (!isUnread) { DropdownMenuItem( - text = { Text("Mark as unread") }, + text = { Text("Mark as unread", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.LabelOff, null) }, onClick = { actions.markAsUnread(book) onDismissRequest() @@ -177,40 +189,41 @@ fun OneshotActionsMenu( val komfIntegration = LocalKomfIntegration.current.collectAsState(false) if (komfIntegration.value) { DropdownMenuItem( - text = { Text("Identify (Komf)") }, + text = { Text("Identify (Komf)", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Search, null) }, onClick = { showKomfDialog = true }, ) DropdownMenuItem( - text = { Text("Reset Metadata (Komf)") }, + text = { Text("Reset Metadata (Komf)", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Refresh, null) }, onClick = { showKomfResetDialog = true }, ) } - val deleteInteractionSource = remember { MutableInteractionSource() } - val deleteIsHovered = deleteInteractionSource.collectIsHoveredAsState() - val deleteColor = - if (deleteIsHovered.value) Modifier.background(MaterialTheme.colorScheme.errorContainer) - else Modifier if (isAdmin && !isOffline) { DropdownMenuItem( - text = { Text("Delete") }, + text = { Text("Delete", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.DeleteForever, null) }, onClick = { showDeleteDialog = true }, - modifier = Modifier - .hoverable(deleteInteractionSource) - .then(deleteColor) + colors = MenuDefaults.itemColors( + textColor = MaterialTheme.colorScheme.error, + leadingIconColor = MaterialTheme.colorScheme.error + ) ) } if (isOffline) { DropdownMenuItem( - text = { Text("Delete downloaded") }, + text = { Text("Delete downloaded", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Delete, null) }, onClick = { showDeleteDownloadedDialog = true }, - modifier = Modifier - .hoverable(deleteInteractionSource) - .then(deleteColor) + colors = MenuDefaults.itemColors( + textColor = MaterialTheme.colorScheme.error, + leadingIconColor = MaterialTheme.colorScheme.error + ) ) } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/ReadListActionsMenu.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/ReadListActionsMenu.kt index 6a345f2c..068caa77 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/ReadListActionsMenu.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/ReadListActionsMenu.kt @@ -1,12 +1,13 @@ package snd.komelia.ui.common.menus -import androidx.compose.foundation.background -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.material3.DropdownMenu +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.DeleteForever +import androidx.compose.material.icons.rounded.Edit import androidx.compose.material3.DropdownMenuItem +import snd.komelia.ui.common.components.AnimatedDropdownMenu +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf @@ -14,7 +15,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier import snd.komelia.ui.dialogs.ConfirmationDialog import snd.komelia.ui.dialogs.readlistedit.ReadListEditDialog import snd.komga.client.readlist.KomgaReadList @@ -53,27 +53,24 @@ fun ReadListActionsMenu( } val showDropdown = derivedStateOf { expanded && !showDeleteDialog } - DropdownMenu( + AnimatedDropdownMenu( expanded = showDropdown.value, onDismissRequest = onDismissRequest ) { - val deleteInteractionSource = remember { MutableInteractionSource() } - val deleteIsHovered = deleteInteractionSource.collectIsHoveredAsState() - DropdownMenuItem( - text = { Text("Edit") }, + text = { Text("Edit", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Edit, null) }, onClick = { showEditDialog = true }, ) DropdownMenuItem( - text = { Text("Delete") }, + text = { Text("Delete", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.DeleteForever, null) }, onClick = { showDeleteDialog = true }, - modifier = Modifier - .hoverable(deleteInteractionSource) - .then( - if (deleteIsHovered.value) Modifier.background(MaterialTheme.colorScheme.errorContainer) - else Modifier - ) + colors = MenuDefaults.itemColors( + textColor = MaterialTheme.colorScheme.error, + leadingIconColor = MaterialTheme.colorScheme.error + ) ) } } \ No newline at end of file diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/SeriesActionsMenu.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/SeriesActionsMenu.kt index 66f60ec5..f6b4093e 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/SeriesActionsMenu.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/common/menus/SeriesActionsMenu.kt @@ -1,12 +1,20 @@ package snd.komelia.ui.common.menus -import androidx.compose.foundation.background -import androidx.compose.foundation.hoverable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsHoveredAsState -import androidx.compose.material3.DropdownMenu +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Label +import androidx.compose.material.icons.automirrored.rounded.LabelOff +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.DeleteForever +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Search import androidx.compose.material3.DropdownMenuItem +import snd.komelia.ui.common.components.AnimatedDropdownMenu +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -141,13 +149,14 @@ fun SeriesActionsMenu( !showEditDialog && !showAddToCollectionDialog } - DropdownMenu( + AnimatedDropdownMenu( expanded = showDropdown.value, onDismissRequest = onDismissRequest ) { if (isAdmin && !isOffline) { DropdownMenuItem( - text = { Text("Analyze") }, + text = { Text("Analyze", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Search, null) }, onClick = { actions.analyze(series) onDismissRequest() @@ -155,7 +164,8 @@ fun SeriesActionsMenu( ) DropdownMenuItem( - text = { Text("Refresh metadata") }, + text = { Text("Refresh metadata", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Refresh, null) }, onClick = { actions.refreshMetadata(series) onDismissRequest() @@ -163,7 +173,8 @@ fun SeriesActionsMenu( ) DropdownMenuItem( - text = { Text("Add to collection") }, + text = { Text("Add to collection", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Add, null) }, onClick = { showAddToCollectionDialog = true }, ) } @@ -172,7 +183,8 @@ fun SeriesActionsMenu( val isUnread = remember { series.booksUnreadCount == series.booksCount } if (!isRead) { DropdownMenuItem( - text = { Text("Mark as read") }, + text = { Text("Mark as read", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.AutoMirrored.Rounded.Label, null) }, onClick = { actions.markAsRead(series) onDismissRequest() @@ -182,7 +194,8 @@ fun SeriesActionsMenu( if (!isUnread) { DropdownMenuItem( - text = { Text("Mark as unread") }, + text = { Text("Mark as unread", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.AutoMirrored.Rounded.LabelOff, null) }, onClick = { actions.markAsUnread(series) onDismissRequest() @@ -192,30 +205,29 @@ fun SeriesActionsMenu( if (isAdmin && !isOffline && showEditOption) { DropdownMenuItem( - text = { Text("Edit") }, + text = { Text("Edit", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Edit, null) }, onClick = { showEditDialog = true }, ) } if (!isOffline && showDownloadOption) { DropdownMenuItem( - text = { Text("Download") }, + text = { Text("Download", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Download, null) }, onClick = { showDownloadDialog = true }, ) } if (isOffline) { - val deleteInteractionSource = remember { MutableInteractionSource() } - val deleteIsHovered = deleteInteractionSource.collectIsHoveredAsState() - val deleteColor = - if (deleteIsHovered.value) Modifier.background(MaterialTheme.colorScheme.errorContainer) - else Modifier DropdownMenuItem( - text = { Text("Delete downloaded") }, + text = { Text("Delete downloaded", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Delete, null) }, onClick = { showDeleteDownloadedDialog = true }, - modifier = Modifier - .hoverable(deleteInteractionSource) - .then(deleteColor) + colors = MenuDefaults.itemColors( + textColor = MaterialTheme.colorScheme.error, + leadingIconColor = MaterialTheme.colorScheme.error + ) ) } @@ -223,30 +235,29 @@ fun SeriesActionsMenu( val komfIntegration = LocalKomfIntegration.current.collectAsState(false) if (komfIntegration.value) { DropdownMenuItem( - text = { Text("Identify (Komf)") }, + text = { Text("Identify (Komf)", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Search, null) }, onClick = { showKomfDialog = true }, ) DropdownMenuItem( - text = { Text("Reset Metadata (Komf)") }, + text = { Text("Reset Metadata (Komf)", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.Refresh, null) }, onClick = { showKomfResetDialog = true }, ) } -// if (isAdmin && !isOffline) { -// val deleteInteractionSource = remember { MutableInteractionSource() } -// val deleteIsHovered = deleteInteractionSource.collectIsHoveredAsState() -// val deleteColor = -// if (deleteIsHovered.value) Modifier.background(MaterialTheme.colorScheme.errorContainer) -// else Modifier -// DropdownMenuItem( -// text = { Text("Delete from server") }, -// onClick = { showDeleteDialog = true }, -// modifier = Modifier -// .hoverable(deleteInteractionSource) -// .then(deleteColor) -// ) -// } + if (isAdmin && !isOffline) { + DropdownMenuItem( + text = { Text("Delete from server", style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { Icon(Icons.Rounded.DeleteForever, null) }, + onClick = { showDeleteDialog = true }, + colors = MenuDefaults.itemColors( + textColor = MaterialTheme.colorScheme.error, + leadingIconColor = MaterialTheme.colorScheme.error + ) + ) + } } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/home/HomeContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/home/HomeContent.kt index 8b310336..bf05bdae 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/home/HomeContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/home/HomeContent.kt @@ -11,6 +11,8 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan @@ -24,10 +26,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronRight -import androidx.compose.material.icons.filled.Tune +import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material3.FilterChip -import androidx.compose.material3.FilterChipDefaults -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults @@ -38,12 +38,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import snd.komelia.komga.api.model.KomeliaBook import snd.komelia.ui.LocalPlatform +import snd.komelia.ui.LocalUseNewLibraryUI import snd.komelia.ui.common.cards.BookImageCard +import snd.komelia.ui.common.components.AppFilterChipDefaults import snd.komelia.ui.common.cards.SeriesImageCard import snd.komelia.ui.common.menus.BookMenuActions import snd.komelia.ui.common.menus.SeriesMenuActions @@ -66,15 +69,20 @@ fun HomeContent( onBookReadClick: (KomeliaBook, Boolean) -> Unit, ) { val gridState = rememberLazyGridState() + val columnState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() + val useNewLibraryUI = LocalUseNewLibraryUI.current Column { Toolbar( filters = filters, currentFilterNumber = activeFilterNumber, onEditStart = onEditStart, - onFilterChange = { - onFilterChange(it) - coroutineScope.launch { gridState.animateScrollToItem(0) } + onFilterChange = { newFilter -> + onFilterChange(newFilter) + coroutineScope.launch { + if (useNewLibraryUI && newFilter == 0) columnState.animateScrollToItem(0) + else gridState.animateScrollToItem(0) + } }, ) DisplayContent( @@ -82,6 +90,7 @@ fun HomeContent( activeFilterNumber = activeFilterNumber, gridState = gridState, + columnState = columnState, cardWidth = cardWidth, onSeriesClick = onSeriesClick, seriesMenuActions = seriesMenuActions, @@ -99,11 +108,7 @@ private fun Toolbar( onFilterChange: (Int) -> Unit, onEditStart: () -> Unit ) { - val chipColors = FilterChipDefaults.filterChipColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - selectedContainerColor = MaterialTheme.colorScheme.primary, - selectedLabelColor = MaterialTheme.colorScheme.onPrimary - ) + val chipColors = AppFilterChipDefaults.filterChipColors() val nonEmptyFilters = remember(filters) { filters.filter { when (it) { @@ -118,34 +123,24 @@ private fun Toolbar( LazyRow( state = lazyRowState, - modifier = Modifier.animateContentSize(), + modifier = Modifier.animateContentSize().padding(end = 48.dp), horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically, ) { item { - Spacer(Modifier.width(20.dp)) - } - - item { - FilterChip( - onClick = onEditStart, - selected = false, - label = { - Icon(Icons.Default.Tune, null) - }, - colors = chipColors, - border = null, - ) + Spacer(Modifier.width(5.dp)) } if (filters.size > 1) { item { + val selected = currentFilterNumber == 0 FilterChip( onClick = { onFilterChange(0) }, - selected = currentFilterNumber == 0, + selected = selected, label = { Text("All") }, colors = chipColors, - border = null, + shape = AppFilterChipDefaults.shape(), + border = AppFilterChipDefaults.filterChipBorder(selected), ) } } @@ -157,12 +152,14 @@ private fun Toolbar( } } if (display) { + val selected = currentFilterNumber == data.filter.order || filters.size == 1 FilterChip( onClick = { onFilterChange(data.filter.order) }, - selected = currentFilterNumber == data.filter.order || filters.size == 1, + selected = selected, label = { Text(data.filter.label) }, colors = chipColors, - border = null, + shape = AppFilterChipDefaults.shape(), + border = AppFilterChipDefaults.filterChipBorder(selected), ) } } @@ -171,6 +168,13 @@ private fun Toolbar( } } + IconButton( + onClick = onEditStart, + modifier = Modifier.align(Alignment.CenterEnd) + ) { + Icon(Icons.Rounded.MoreVert, null) + } + if (LocalPlatform.current != PlatformType.MOBILE) { Row { if (lazyRowState.canScrollBackward) { @@ -200,6 +204,7 @@ private fun DisplayContent( filters: List, activeFilterNumber: Int, gridState: LazyGridState, + columnState: LazyListState, cardWidth: Dp, onSeriesClick: (KomgaSeries) -> Unit, seriesMenuActions: SeriesMenuActions, @@ -207,38 +212,116 @@ private fun DisplayContent( onBookClick: (KomeliaBook) -> Unit, onBookReadClick: (KomeliaBook, Boolean) -> Unit, ) { - LazyVerticalGrid( - modifier = Modifier.padding(horizontal = 20.dp), - state = gridState, - columns = GridCells.Adaptive(cardWidth), - horizontalArrangement = Arrangement.spacedBy(15.dp), - verticalArrangement = Arrangement.spacedBy(15.dp), - contentPadding = PaddingValues(bottom = 50.dp) - ) { - for (data in filters) { - if (activeFilterNumber == 0 || data.filter.order == activeFilterNumber) { - when (data) { - is BookFilterData -> BookFilterEntry( - label = data.filter.label, - books = data.books, - bookMenuActions = bookMenuActions, - onBookClick = onBookClick, - onBookReadClick = onBookReadClick, - ) - - is SeriesFilterData -> SeriesFilterEntries( - label = data.filter.label, - series = data.series, - onSeriesClick = onSeriesClick, - seriesMenuActions = seriesMenuActions, - ) + val useNewLibraryUI = LocalUseNewLibraryUI.current + if (useNewLibraryUI && activeFilterNumber == 0) { + LazyColumn( + state = columnState, + verticalArrangement = Arrangement.spacedBy(20.dp), + contentPadding = PaddingValues(bottom = 15.dp), + ) { + for (data in filters) { + val isEmpty = when (data) { + is BookFilterData -> data.books.isEmpty() + is SeriesFilterData -> data.series.isEmpty() + } + if (!isEmpty) { + item { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + SectionHeader(data.filter.label) + SectionRow( + data = data, + cardWidth = cardWidth, + onSeriesClick = onSeriesClick, + seriesMenuActions = seriesMenuActions, + bookMenuActions = bookMenuActions, + onBookClick = onBookClick, + onBookReadClick = onBookReadClick, + ) + } + } + } + } + } + } else { + LazyVerticalGrid( + modifier = Modifier.padding(horizontal = 20.dp), + state = gridState, + columns = GridCells.Adaptive(cardWidth), + horizontalArrangement = Arrangement.spacedBy(15.dp), + verticalArrangement = Arrangement.spacedBy(15.dp), + contentPadding = PaddingValues(bottom = 15.dp) + ) { + for (data in filters) { + if (activeFilterNumber == 0 || data.filter.order == activeFilterNumber) { + when (data) { + is BookFilterData -> BookFilterEntry( + label = data.filter.label, + books = data.books, + bookMenuActions = bookMenuActions, + onBookClick = onBookClick, + onBookReadClick = onBookReadClick, + ) + is SeriesFilterData -> SeriesFilterEntries( + label = data.filter.label, + series = data.series, + onSeriesClick = onSeriesClick, + seriesMenuActions = seriesMenuActions, + ) + } } } } } } +@Composable +private fun SectionHeader(label: String) { + Text( + label, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + ) +} + +@Composable +private fun SectionRow( + data: HomeFilterData, + cardWidth: Dp, + onSeriesClick: (KomgaSeries) -> Unit, + seriesMenuActions: SeriesMenuActions, + bookMenuActions: BookMenuActions, + onBookClick: (KomeliaBook) -> Unit, + onBookReadClick: (KomeliaBook, Boolean) -> Unit, +) { + LazyRow( + contentPadding = PaddingValues(horizontal = 10.dp), + horizontalArrangement = Arrangement.spacedBy(7.dp), + ) { + when (data) { + is BookFilterData -> items(data.books) { book -> + BookImageCard( + book = book, + onBookClick = { onBookClick(book) }, + onBookReadClick = { onBookReadClick(book, it) }, + bookMenuActions = bookMenuActions, + showSeriesTitle = true, + modifier = Modifier.width(cardWidth), + ) + } + + is SeriesFilterData -> items(data.series) { series -> + SeriesImageCard( + series = series, + onSeriesClick = { onSeriesClick(series) }, + seriesMenuActions = seriesMenuActions, + modifier = Modifier.width(cardWidth), + ) + } + } + } +} + private fun LazyGridScope.BookFilterEntry( label: String, books: List, @@ -249,12 +332,11 @@ private fun LazyGridScope.BookFilterEntry( if (books.isEmpty()) return item(span = { GridItemSpan(maxLineSpan) }) { - - Row(verticalAlignment = Alignment.CenterVertically) { - Text(label, style = MaterialTheme.typography.titleLarge) - Spacer(Modifier.width(10.dp)) - HorizontalDivider() - } + Text( + label, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold), + modifier = Modifier.padding(vertical = 4.dp), + ) } items(books) { book -> BookImageCard( @@ -276,13 +358,11 @@ private fun LazyGridScope.SeriesFilterEntries( ) { if (series.isEmpty()) return item(span = { GridItemSpan(maxLineSpan) }) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Text(label, style = MaterialTheme.typography.titleLarge) - Spacer(Modifier.width(10.dp)) - HorizontalDivider() - } + Text( + label, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold), + modifier = Modifier.padding(vertical = 4.dp), + ) } items(series) { diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibraryScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibraryScreen.kt index e4822452..3f3caabe 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibraryScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibraryScreen.kt @@ -1,10 +1,16 @@ package snd.komelia.ui.library +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.MoreVert @@ -19,19 +25,23 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.model.rememberScreenModel import cafe.adriel.voyager.core.screen.ScreenKey import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow +import kotlinx.coroutines.launch import snd.komelia.ui.LoadState.Error import snd.komelia.ui.LoadState.Loading import snd.komelia.ui.LoadState.Success import snd.komelia.ui.LoadState.Uninitialized import snd.komelia.ui.LocalKomgaState +import snd.komelia.ui.LocalMainScreenViewModel import snd.komelia.ui.LocalOfflineMode import snd.komelia.ui.LocalReloadEvents import snd.komelia.ui.LocalViewModelFactory @@ -50,6 +60,8 @@ import snd.komelia.ui.library.view.LibraryReadListsContent import snd.komelia.ui.platform.BackPressHandler import snd.komelia.ui.platform.ScreenPullToRefreshBox import snd.komelia.ui.readlist.ReadListScreen +import snd.komelia.ui.book.bookScreen +import snd.komelia.ui.reader.readerScreen import snd.komelia.ui.series.list.SeriesListContent import snd.komelia.ui.series.seriesScreen import snd.komga.client.common.KomgaAuthor @@ -150,6 +162,11 @@ class LibraryScreen( onPageChange = seriesTabState::onPageChange, minSize = seriesTabState.cardWidth.collectAsState().value, + + keepReadingBooks = seriesTabState.keepReadingBooks, + bookMenuActions = seriesTabState.bookMenuActions(), + onBookClick = { navigator.push(bookScreen(it)) }, + onBookReadClick = { book, mark -> navigator.push(readerScreen(book, mark)) }, ) } } @@ -245,70 +262,82 @@ fun LibraryToolBar( var showOptionsMenu by remember { mutableStateOf(false) } val isAdmin = LocalKomgaState.current.authenticatedUser.collectAsState().value?.roleAdmin() ?: true val isOffline = LocalOfflineMode.current.collectAsState().value + val mainScreenVm = LocalMainScreenViewModel.current + val coroutineScope = rememberCoroutineScope() - LazyRow( - horizontalArrangement = Arrangement.spacedBy(5.dp), + Row( + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { - item { - if (library != null && (isAdmin || isOffline)) { - Box { - IconButton( - onClick = { showOptionsMenu = true } - ) { - Icon( - Icons.Rounded.MoreVert, - contentDescription = null, - ) - } + Spacer(Modifier.width(15.dp)) + Text( + library?.let { library.name } ?: "All Libraries", + modifier = Modifier.width(68.dp).clickable { coroutineScope.launch { mainScreenVm.toggleNavBar() } }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + LazyRow( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (collectionsCount > 0 || readListsCount > 0) + item { + FilterChip( + onClick = onBrowseClick, + selected = currentTab == SERIES, + label = { Text("Series") }, + colors = chipColors, + shape = AppFilterChipDefaults.shape(), + border = AppFilterChipDefaults.filterChipBorder(selected = currentTab == SERIES), + ) + } - LibraryActionsMenu( - library = library, - actions = libraryActions, - expanded = showOptionsMenu, - onDismissRequest = { showOptionsMenu = false } + if (collectionsCount > 0) + item { + FilterChip( + onClick = onCollectionsClick, + selected = currentTab == COLLECTIONS, + label = { Text("Collections") }, + colors = chipColors, + shape = AppFilterChipDefaults.shape(), + border = AppFilterChipDefaults.filterChipBorder(selected = currentTab == COLLECTIONS), ) } - } - Text(library?.let { library.name } ?: "All Libraries") - Spacer(Modifier.width(5.dp)) + if (readListsCount > 0) + item { + FilterChip( + onClick = onReadListsClick, + selected = currentTab == READ_LISTS, + label = { Text("Read Lists") }, + colors = chipColors, + shape = AppFilterChipDefaults.shape(), + border = AppFilterChipDefaults.filterChipBorder(selected = currentTab == READ_LISTS), + ) + } } + if (library != null && (isAdmin || isOffline)) { + Box { + IconButton( + onClick = { showOptionsMenu = true } + ) { + Icon( + Icons.Rounded.MoreVert, + contentDescription = null, + ) + } - if (collectionsCount > 0 || readListsCount > 0) - item { - FilterChip( - onClick = onBrowseClick, - selected = currentTab == SERIES, - label = { Text("Series") }, - colors = chipColors, - border = null, - ) - } - - if (collectionsCount > 0) - item { - FilterChip( - onClick = onCollectionsClick, - selected = currentTab == COLLECTIONS, - label = { Text("Collections") }, - colors = chipColors, - border = null, - ) - } - - if (readListsCount > 0) - item { - FilterChip( - onClick = onReadListsClick, - selected = currentTab == READ_LISTS, - label = { Text("Read Lists") }, - colors = chipColors, - border = null, + LibraryActionsMenu( + library = library, + actions = libraryActions, + expanded = showOptionsMenu, + onDismissRequest = { showOptionsMenu = false } ) } - + } } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibrarySeriesTabState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibrarySeriesTabState.kt index c514c612..972860e8 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibrarySeriesTabState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibrarySeriesTabState.kt @@ -20,23 +20,30 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import snd.komelia.AppNotifications +import snd.komelia.komga.api.KomgaBookApi import snd.komelia.komga.api.KomgaReferentialApi import snd.komelia.komga.api.KomgaSeriesApi +import snd.komelia.komga.api.model.KomeliaBook import snd.komelia.offline.tasks.OfflineTaskEmitter import snd.komelia.settings.CommonSettingsRepository import snd.komelia.ui.LoadState +import snd.komelia.ui.common.menus.BookMenuActions import snd.komelia.ui.common.menus.SeriesMenuActions import snd.komelia.ui.series.SeriesFilter import snd.komelia.ui.series.SeriesFilterState +import snd.komga.client.book.KomgaReadStatus import snd.komga.client.common.KomgaPageRequest +import snd.komga.client.common.KomgaSort.KomgaBooksSort import snd.komga.client.common.KomgaSort.KomgaSeriesSort import snd.komga.client.common.Page import snd.komga.client.library.KomgaLibrary +import snd.komga.client.search.allOfBooks import snd.komga.client.search.allOfSeries import snd.komga.client.series.KomgaSeries import snd.komga.client.sse.KomgaEvent class LibrarySeriesTabState( + private val bookApi: KomgaBookApi, private val seriesApi: KomgaSeriesApi, referentialApi: KomgaReferentialApi, private val notifications: AppNotifications, @@ -56,6 +63,9 @@ class LibrarySeriesTabState( var currentSeriesPage by mutableStateOf(1) private set + var keepReadingBooks by mutableStateOf>(emptyList()) + private set + val isInEditMode = MutableStateFlow(false) var selectedSeries by mutableStateOf>(emptyList()) private set @@ -79,6 +89,7 @@ class LibrarySeriesTabState( pageLoadSize.value = settingsRepository.getSeriesPageLoadSize().first() loadSeriesPage(1) + loadKeepReadingBooks() settingsRepository.getSeriesPageLoadSize() .onEach { @@ -107,6 +118,7 @@ class LibrarySeriesTabState( } fun seriesMenuActions() = SeriesMenuActions(seriesApi, notifications, taskEmitter, screenModelScope) + fun bookMenuActions() = BookMenuActions(bookApi, notifications, screenModelScope, taskEmitter) fun onPageSizeChange(pageSize: Int) { pageLoadSize.value = pageSize @@ -173,6 +185,22 @@ class LibrarySeriesTabState( ) } + private suspend fun loadKeepReadingBooks() { + val lib = library.value ?: return + notifications.runCatchingToNotifications { + keepReadingBooks = bookApi.getBookList( + conditionBuilder = allOfBooks { + library { isEqualTo(lib.id) } + readStatus { isEqualTo(KomgaReadStatus.IN_PROGRESS) } + }, + pageRequest = KomgaPageRequest( + sort = KomgaBooksSort.byReadDateDesc(), + size = 20 + ) + ).content + } + } + private fun delayLoadState(): Deferred { return screenModelScope.async { delay(200) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibraryViewModel.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibraryViewModel.kt index fffc4364..b4c09a58 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibraryViewModel.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/LibraryViewModel.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import snd.komelia.AppNotifications +import snd.komelia.komga.api.KomgaBookApi import snd.komelia.komga.api.KomgaCollectionsApi import snd.komelia.komga.api.KomgaLibraryApi import snd.komelia.komga.api.KomgaReadListApi @@ -51,6 +52,7 @@ class LibraryViewModel( private val collectionApi: KomgaCollectionsApi, private val readListsApi: KomgaReadListApi, private val taskEmitter: OfflineTaskEmitter, + bookApi: KomgaBookApi, seriesApi: KomgaSeriesApi, referentialApi: KomgaReferentialApi, @@ -73,6 +75,7 @@ class LibraryViewModel( private val reloadJobsFlow = MutableSharedFlow(1, 0, DROP_OLDEST) val seriesTabState = LibrarySeriesTabState( + bookApi = bookApi, seriesApi = seriesApi, referentialApi = referentialApi, notifications = appNotifications, diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/view/LibraryCollectionsContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/view/LibraryCollectionsContent.kt index c0d2d036..0fb24370 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/view/LibraryCollectionsContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/view/LibraryCollectionsContent.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import snd.komelia.ui.common.components.AppSuggestionChipDefaults import snd.komelia.ui.common.components.LoadingMaxSizeIndicator import snd.komelia.ui.common.components.PageSizeSelectionDropdown import snd.komelia.ui.common.itemlist.CollectionLazyCardGrid @@ -47,6 +48,7 @@ fun LibraryCollectionsContent( if (collectionsTotalCount > 1) Text("$collectionsTotalCount collections") else Text("$collectionsTotalCount collection") }, + shape = AppSuggestionChipDefaults.shape(), modifier = Modifier.padding(end = 10.dp) ) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/view/LibraryReadListsContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/view/LibraryReadListsContent.kt index cb01e1db..1457669e 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/view/LibraryReadListsContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/library/view/LibraryReadListsContent.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import snd.komelia.ui.common.components.AppSuggestionChipDefaults import snd.komelia.ui.common.components.LoadingMaxSizeIndicator import snd.komelia.ui.common.components.PageSizeSelectionDropdown import snd.komelia.ui.common.itemlist.PlaceHolderLazyCardGrid @@ -48,6 +49,7 @@ fun LibraryReadListsContent( if (readListsTotalCount > 1) Text("$readListsTotalCount read lists") else Text("$readListsTotalCount read list") }, + shape = AppSuggestionChipDefaults.shape(), modifier = Modifier.padding(end = 10.dp) ) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotScreen.kt index ee2fbe45..13b53437 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotScreen.kt @@ -31,6 +31,12 @@ import snd.komga.client.series.KomgaSeries import snd.komga.client.series.KomgaSeriesId import kotlin.jvm.Transient +import snd.komelia.ui.LocalAccentColor +import snd.komelia.ui.LocalPlatform +import snd.komelia.ui.LocalUseNewLibraryUI +import snd.komelia.ui.oneshot.immersive.ImmersiveOneshotContent +import snd.komelia.ui.platform.PlatformType + class OneshotScreen( val seriesId: KomgaSeriesId, private val bookSiblingsContext: BookSiblingsContext, @@ -71,6 +77,56 @@ class OneshotScreen( onDispose { vm.stopKomgaEventHandler() } } + val platform = LocalPlatform.current + val useNewUI = LocalUseNewLibraryUI.current + val vmBook = vm.book.collectAsState().value + val vmSeries = vm.series.collectAsState().value + val vmLibrary = vm.library.collectAsState().value + if (platform == PlatformType.MOBILE && useNewUI && vmSeries != null) { + ImmersiveOneshotContent( + series = vmSeries, + book = vmBook, + library = vmLibrary, + accentColor = LocalAccentColor.current, + onLibraryClick = { navigator.push(LibraryScreen(it.id)) }, + onBookReadClick = { markReadProgress -> + val currentBook = vm.book.value ?: return@ImmersiveOneshotContent + navigator.parent?.push( + readerScreen( + book = currentBook, + markReadProgress = markReadProgress, + bookSiblingsContext = bookSiblingsContext, + ) + ) + }, + oneshotMenuActions = vm.bookMenuActions, + collections = vm.collectionsState.collections, + onCollectionClick = { collection -> navigator.push(CollectionScreen(collection.id)) }, + onSeriesClick = { navigator.push(seriesScreen(it)) }, + readLists = vm.readListsState.readLists, + onReadListClick = { navigator.push(ReadListScreen(it.id)) }, + onReadlistBookClick = { book, readList -> + navigator push bookScreen( + book = book, + bookSiblingsContext = BookSiblingsContext.ReadList(readList.id) + ) + }, + onFilterClick = { filter -> + val libraryId = vm.book.value?.libraryId ?: return@ImmersiveOneshotContent + navigator.popUntilRoot() + navigator.dispose(navigator.lastItem) + navigator.replaceAll(LibraryScreen(libraryId, filter)) + }, + onBookDownload = vm::onBookDownload, + cardWidth = vm.cardWidth.collectAsState().value, + onBackClick = { onBackPress(navigator, vmSeries.libraryId) }, + initiallyExpanded = vm.isExpanded, + onExpandChange = { vm.isExpanded = it } + ) + BackPressHandler { onBackPress(navigator, vmSeries.libraryId) } + return + } + val state = vm.state.collectAsState().value val book = vm.book.collectAsState().value val library = vm.library.collectAsState().value diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotViewModel.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotViewModel.kt index 0e801284..c6a1cf4b 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotViewModel.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/OneshotViewModel.kt @@ -1,5 +1,8 @@ package snd.komelia.ui.oneshot +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.screenModelScope @@ -67,6 +70,7 @@ class OneshotViewModel( val series = MutableStateFlow(series) val library = MutableStateFlow(null) val book = MutableStateFlow(book) + var isExpanded by mutableStateOf(false) val bookMenuActions = BookMenuActions(bookApi, notifications, screenModelScope, taskEmitter) val cardWidth = settingsRepository.getCardWidth().map { it.dp } @@ -103,6 +107,8 @@ class OneshotViewModel( }.launchIn(screenModelScope) startKomgaEventListener() + collectionsState.initialize() + readListsState.initialize() reloadFlow.onEach { reloadEventsEnabled.first { it } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt new file mode 100644 index 00000000..06367b63 --- /dev/null +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/oneshot/immersive/ImmersiveOneshotContent.kt @@ -0,0 +1,540 @@ +package snd.komelia.ui.oneshot.immersive + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import kotlinx.datetime.TimeZone +import kotlinx.datetime.format +import kotlinx.datetime.toLocalDateTime +import snd.komelia.DefaultDateTimeFormats.localDateTimeFormat +import snd.komelia.image.coil.SeriesDefaultThumbnailRequest +import snd.komelia.komga.api.model.KomeliaBook +import snd.komelia.ui.LocalAnimatedVisibilityScope +import snd.komelia.ui.LocalKomgaEvents +import snd.komelia.ui.LocalSharedTransitionScope +import snd.komelia.ui.collection.SeriesCollectionsContent +import snd.komelia.ui.common.components.AppFilterChipDefaults +import snd.komelia.ui.common.images.ThumbnailImage +import snd.komelia.ui.common.immersive.ImmersiveDetailFab +import snd.komelia.ui.common.immersive.ImmersiveDetailScaffold +import snd.komelia.ui.common.menus.BookMenuActions +import snd.komelia.ui.common.menus.OneshotActionsMenu +import snd.komelia.ui.dialogs.ConfirmationDialog +import snd.komelia.ui.dialogs.permissions.DownloadNotificationRequestDialog +import snd.komelia.ui.library.SeriesScreenFilter +import snd.komelia.ui.readlist.BookReadListsContent +import snd.komelia.ui.series.view.SeriesChipTags +import snd.komelia.ui.series.view.SeriesDescriptionRow +import snd.komelia.ui.series.view.SeriesSummary +import snd.komga.client.collection.KomgaCollection +import snd.komga.client.library.KomgaLibrary +import snd.komga.client.readlist.KomgaReadList +import snd.komga.client.series.KomgaSeries +import snd.komga.client.sse.KomgaEvent.ThumbnailBookEvent +import snd.komga.client.sse.KomgaEvent.ThumbnailSeriesEvent +import kotlin.math.roundToInt + +private val emphasizedAccelerateEasing = CubicBezierEasing(0.3f, 0.0f, 0.8f, 0.15f) + +private enum class OneshotImmersiveTab { TAGS, COLLECTIONS, READ_LISTS } + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun ImmersiveOneshotContent( + series: KomgaSeries, + book: KomeliaBook?, + library: KomgaLibrary?, + accentColor: Color?, + onLibraryClick: (KomgaLibrary) -> Unit, + onBookReadClick: (markReadProgress: Boolean) -> Unit, + oneshotMenuActions: BookMenuActions, + collections: Map>, + onCollectionClick: (KomgaCollection) -> Unit, + onSeriesClick: (KomgaSeries) -> Unit, + readLists: Map>, + onReadListClick: (KomgaReadList) -> Unit, + onReadlistBookClick: (KomeliaBook, KomgaReadList) -> Unit, + onFilterClick: (SeriesScreenFilter) -> Unit, + onBookDownload: () -> Unit, + cardWidth: Dp, + onBackClick: () -> Unit, + initiallyExpanded: Boolean, + onExpandChange: (Boolean) -> Unit, +) { + var showDownloadConfirmationDialog by remember { mutableStateOf(false) } + val komgaEvents = LocalKomgaEvents.current + var coverData by remember(series.id) { mutableStateOf(SeriesDefaultThumbnailRequest(series.id)) } + LaunchedEffect(series.id) { + komgaEvents.collect { event -> + val eventSeriesId = when (event) { + is ThumbnailSeriesEvent -> event.seriesId + is ThumbnailBookEvent -> event.seriesId + else -> null + } + if (eventSeriesId == series.id) coverData = SeriesDefaultThumbnailRequest(series.id) + } + } + + val sharedTransitionScope = LocalSharedTransitionScope.current + val animatedVisibilityScope = LocalAnimatedVisibilityScope.current + + val fabOverlayModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { + with(sharedTransitionScope) { + with(animatedVisibilityScope) { + Modifier + .renderInSharedTransitionScopeOverlay(zIndexInOverlay = 1f) + .animateEnterExit( + enter = fadeIn(tween(300, delayMillis = 50)), + exit = slideOutVertically(tween(200, easing = emphasizedAccelerateEasing)) { it / 2 } + + fadeOut(tween(150)) + ) + } + } + } else Modifier + + val uiOverlayModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { + with(sharedTransitionScope) { + with(animatedVisibilityScope) { + Modifier + .renderInSharedTransitionScopeOverlay(zIndexInOverlay = 0.75f) + .animateEnterExit( + enter = fadeIn(tween(durationMillis = 500)), + exit = fadeOut(tween(durationMillis = 100)) + ) + } + } + } else Modifier + + var currentTab by remember { mutableStateOf(OneshotImmersiveTab.TAGS) } + + Box(modifier = Modifier.fillMaxSize()) { + + ImmersiveDetailScaffold( + coverData = coverData, + coverKey = series.id.value, + cardColor = null, + immersive = true, + initiallyExpanded = initiallyExpanded, + onExpandChange = onExpandChange, + topBarContent = {}, // Fixed overlay handles this + fabContent = {}, // Fixed overlay handles this + cardContent = { expandFraction -> + if (book == null || library == null) { + Box( + modifier = Modifier.fillMaxWidth().padding(top = 48.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else { + OneshotCardContent( + series = series, + book = book, + library = library, + coverData = coverData, + expandFraction = expandFraction, + onLibraryClick = onLibraryClick, + onFilterClick = onFilterClick, + readLists = readLists, + onReadListClick = onReadListClick, + onReadlistBookClick = onReadlistBookClick, + collections = collections, + onCollectionClick = onCollectionClick, + onSeriesClick = onSeriesClick, + cardWidth = cardWidth, + currentTab = currentTab, + onTabChange = { currentTab = it } + ) + } + } + ) + + // Fixed overlay: back button + 3-dot menu + Row( + modifier = Modifier + .fillMaxWidth() + .then(uiOverlayModifier) + .statusBarsPadding() + .padding(start = 12.dp, end = 4.dp, top = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(36.dp) + .background(Color.Black.copy(alpha = 0.55f), CircleShape) + .clickable(onClick = onBackClick), + contentAlignment = Alignment.Center + ) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = Color.White) + } + + if (book != null) { + var expandActions by remember { mutableStateOf(false) } + Box { + Box( + modifier = Modifier + .size(36.dp) + .background(Color.Black.copy(alpha = 0.55f), CircleShape) + .clickable { expandActions = true }, + contentAlignment = Alignment.Center + ) { + Icon(Icons.Rounded.MoreVert, contentDescription = null, tint = Color.White) + } + OneshotActionsMenu( + series = series, + book = book, + actions = oneshotMenuActions, + expanded = expandActions, + onDismissRequest = { expandActions = false }, + ) + } + } + } + + // Fixed overlay: FAB + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .then(fabOverlayModifier) + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(bottom = 16.dp) + ) { + ImmersiveDetailFab( + onReadClick = { if (book != null) onBookReadClick(true) }, + onReadIncognitoClick = { if (book != null) onBookReadClick(false) }, + onDownloadClick = { if (book != null) showDownloadConfirmationDialog = true }, + accentColor = accentColor, + showReadActions = book != null, + ) + } + } + + if (showDownloadConfirmationDialog && book != null) { + var permissionRequested by remember { mutableStateOf(false) } + DownloadNotificationRequestDialog { permissionRequested = true } + if (permissionRequested) { + ConfirmationDialog( + body = "Download \"${book.metadata.title}\"?", + onDialogConfirm = { + onBookDownload() + showDownloadConfirmationDialog = false + }, + onDialogDismiss = { showDownloadConfirmationDialog = false }, + ) + } + } +} + +@Composable +private fun OneshotCardContent( + series: KomgaSeries, + book: KomeliaBook, + library: KomgaLibrary, + coverData: Any, + expandFraction: Float, + onLibraryClick: (KomgaLibrary) -> Unit, + onFilterClick: (SeriesScreenFilter) -> Unit, + readLists: Map>, + onReadListClick: (KomgaReadList) -> Unit, + onReadlistBookClick: (KomeliaBook, KomgaReadList) -> Unit, + collections: Map>, + onCollectionClick: (KomgaCollection) -> Unit, + onSeriesClick: (KomgaSeries) -> Unit, + cardWidth: Dp, + currentTab: OneshotImmersiveTab, + onTabChange: (OneshotImmersiveTab) -> Unit, +) { + val thumbnailOffset = (126.dp * expandFraction).coerceAtLeast(0.dp) + val thumbnailTopGap = 20.dp + val thumbnailHeight = 110.dp / 0.703f // ≈ 156.5 dp + + val navBarBottom = with(LocalDensity.current) { + WindowInsets.navigationBars.getBottom(this).toDp() + } + LazyVerticalGrid( + columns = GridCells.Fixed(1), + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(0.dp), + contentPadding = PaddingValues(bottom = navBarBottom + 80.dp), + ) { + // Collapsed stats line (fades out as card expands) + item(span = { GridItemSpan(maxLineSpan) }) { + val alpha = (1f - expandFraction * 2f).coerceIn(0f, 1f) + if (alpha > 0.01f) + BookStatsLine(book, Modifier + .padding(start = 16.dp, end = 16.dp, top = 4.dp) + .graphicsLayer { this.alpha = alpha }) + } + + // Header: book title + writers (year) + item { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = (thumbnailTopGap + thumbnailHeight) * expandFraction) + .padding( + start = 16.dp, + end = 16.dp, + top = lerp(8f, thumbnailTopGap.value, expandFraction).dp, + ) + ) { + if (expandFraction > 0.01f) { + Box( + modifier = Modifier + .padding(top = (thumbnailTopGap - 8.dp) * expandFraction) + .graphicsLayer { alpha = (expandFraction * 2f - 1f).coerceIn(0f, 1f) } + ) { + ThumbnailImage( + data = coverData, + cacheKey = series.id.value, + crossfade = false, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(width = 110.dp, height = thumbnailHeight) + .clip(RoundedCornerShape(8.dp)) + ) + } + } + + Column(modifier = Modifier.padding(start = thumbnailOffset)) { + // Book title (headlineSmall, bold) + Text( + text = book.metadata.title, + style = MaterialTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.Bold, + ), + ) + // Writers (year) — labelSmall + val writers = remember(book.metadata.authors) { + book.metadata.authors + .filter { it.role.lowercase() == "writer" } + .joinToString(", ") { it.name } + } + val year = book.metadata.releaseDate?.year + val writersYearText = buildString { + if (writers.isNotEmpty()) append(writers) + if (year != null) { + if (writers.isNotEmpty()) append(" ") + append("($year)") + } + } + if (writersYearText.isNotEmpty()) { + Text( + text = writersYearText, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(top = 2.dp), + ) + } + } + } + } + + // Expanded stats line (fades in as card expands) + item(span = { GridItemSpan(maxLineSpan) }) { + val alpha = (expandFraction * 2f - 1f).coerceIn(0f, 1f) + if (alpha > 0.01f) + BookStatsLine(book, Modifier + .padding(horizontal = 16.dp, vertical = 4.dp) + .graphicsLayer { this.alpha = alpha }) + } + + // SeriesDescriptionRow (library, status, age rating, etc.) + item(span = { GridItemSpan(maxLineSpan) }) { + SeriesDescriptionRow( + library = library, + onLibraryClick = onLibraryClick, + releaseDate = null, + status = null, + ageRating = series.metadata.ageRating, + language = series.metadata.language, + readingDirection = series.metadata.readingDirection, + deleted = series.deleted || library.unavailable, + alternateTitles = series.metadata.alternateTitles, + onFilterClick = onFilterClick, + showReleaseYear = false, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + + // Summary + item(span = { GridItemSpan(maxLineSpan) }) { + Box(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + SeriesSummary( + seriesSummary = series.metadata.summary, + bookSummary = book.metadata.summary, + bookSummaryNumber = book.metadata.number.toString(), + ) + } + } + + // Divider + item { HorizontalDivider(Modifier.padding(vertical = 8.dp)) } + + // Tab row + item { + OneshotImmersiveTabRow( + currentTab = currentTab, + onTabChange = onTabChange, + showCollectionsTab = collections.isNotEmpty(), + showReadListsTab = readLists.isNotEmpty(), + ) + } + + when (currentTab) { + OneshotImmersiveTab.TAGS -> { + item(span = { GridItemSpan(maxLineSpan) }) { + Box(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + SeriesChipTags( + series = series, + onFilterClick = onFilterClick, + ) + } + } + } + + OneshotImmersiveTab.COLLECTIONS -> { + // Collections + item(span = { GridItemSpan(maxLineSpan) }) { + SeriesCollectionsContent( + collections = collections, + onCollectionClick = onCollectionClick, + onSeriesClick = onSeriesClick, + cardWidth = cardWidth, + ) + } + } + + OneshotImmersiveTab.READ_LISTS -> { + // Reading lists + item(span = { GridItemSpan(maxLineSpan) }) { + BookReadListsContent( + readLists = readLists, + onReadListClick = onReadListClick, + onBookClick = onReadlistBookClick, + cardWidth = cardWidth, + ) + } + } + } + } +} + +@Composable +private fun OneshotImmersiveTabRow( + currentTab: OneshotImmersiveTab, + onTabChange: (OneshotImmersiveTab) -> Unit, + showCollectionsTab: Boolean, + showReadListsTab: Boolean, +) { + val chipColors = AppFilterChipDefaults.filterChipColors() + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) { + FilterChip( + onClick = { onTabChange(OneshotImmersiveTab.TAGS) }, + selected = currentTab == OneshotImmersiveTab.TAGS, + label = { Text("Tags") }, + colors = chipColors, + border = null, + ) + if (showCollectionsTab) { + FilterChip( + onClick = { onTabChange(OneshotImmersiveTab.COLLECTIONS) }, + selected = currentTab == OneshotImmersiveTab.COLLECTIONS, + label = { Text("Collections") }, + colors = chipColors, + border = null, + ) + } + if (showReadListsTab) { + FilterChip( + onClick = { onTabChange(OneshotImmersiveTab.READ_LISTS) }, + selected = currentTab == OneshotImmersiveTab.READ_LISTS, + label = { Text("Read Lists") }, + colors = chipColors, + border = null, + ) + } + } + HorizontalDivider() + } +} + +@Composable +private fun BookStatsLine(book: KomeliaBook, modifier: Modifier = Modifier) { + val pagesCount = book.media.pagesCount + val segments = remember(book) { + buildList { + add("$pagesCount page${if (pagesCount == 1) "" else "s"}") + book.metadata.releaseDate?.let { add(it.toString()) } + book.readProgress?.let { progress -> + if (!progress.completed) { + val pagesLeft = pagesCount - progress.page + val pct = (progress.page.toFloat() / pagesCount * 100).roundToInt() + add("$pct%, $pagesLeft page${if (pagesLeft == 1) "" else "s"} left") + } + add(progress.readDate + .toLocalDateTime(TimeZone.currentSystemDefault()) + .format(localDateTimeFormat)) + } + } + } + if (segments.isEmpty()) return + Text( + text = segments.joinToString(" | "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier, + ) +} diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/ScreenScaleState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/ScreenScaleState.kt index 26ac79f6..f6e6cc06 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/ScreenScaleState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/ScreenScaleState.kt @@ -1,16 +1,18 @@ package snd.komelia.ui.reader.image +import snd.komelia.ui.reader.image.common.ReaderAnimation import androidx.compose.animation.core.AnimationState import androidx.compose.animation.core.DecayAnimationSpec +import androidx.compose.animation.core.Spring import androidx.compose.animation.core.VectorConverter import androidx.compose.animation.core.animateDecay import androidx.compose.animation.core.animateTo +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.Orientation.Horizontal import androidx.compose.foundation.gestures.Orientation.Vertical import androidx.compose.foundation.gestures.ScrollableState -import androidx.compose.foundation.gestures.scrollBy import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.input.pointer.PointerInputChange @@ -52,15 +54,34 @@ class ScreenScaleState { val transformation = MutableStateFlow(Transformation(offset = Offset.Zero, scale = 1f)) + val isGestureInProgress = MutableStateFlow(false) + val isFlinging = MutableStateFlow(false) + var edgeHandoffEnabled = false + private var gestureStartedAtLeftEdge = false + private var gestureStartedAtRightEdge = false + private var cumulativePagerScroll = 0f + @Volatile var composeScope: CoroutineScope? = null @Volatile private var scrollJob: Job? = null + @Volatile + private var zoomJob: Job? = null + + @Volatile + private var baseZoom = 1f + @Volatile private var enableOverscrollArea = false + private var density = 1f + + fun setDensity(density: Float) { + this.density = density + } + fun scaleFor100PercentZoom() = max( areaSize.value.width.toFloat() / targetSize.value.width, @@ -122,23 +143,23 @@ class ScreenScaleState { return -extra - overscroll..extra + overscroll } - fun scrollTo(offset: Offset) { - val coroutineScope = composeScope - check(coroutineScope != null) + fun animateTo(offset: Offset, zoom: Float) { + val coroutineScope = composeScope ?: return scrollJob?.cancel() + zoomJob?.cancel() scrollJob = coroutineScope.launch { - logger.info { "current offset $currentOffset" } - AnimationState( - typeConverter = Offset.VectorConverter, - initialValue = currentOffset, - ).animateTo( - targetValue = offset, - animationSpec = tween(durationMillis = 1000) + val initialZoom = this@ScreenScaleState.zoom.value + val initialOffset = currentOffset + val targetZoom = zoom.coerceIn(zoomLimits.value) + + AnimationState(initialValue = 0f).animateTo( + targetValue = 1f, + animationSpec = ReaderAnimation.navSpringSpec(density) ) { - currentOffset = value + this@ScreenScaleState.zoom.value = initialZoom + (targetZoom - initialZoom) * value + currentOffset = initialOffset + (offset - initialOffset) * value applyLimits() } - logger.info { "scrolled to offset $currentOffset" } } } @@ -147,36 +168,57 @@ class ScreenScaleState { val velocity = velocityTracker.calculateVelocity().div(scale) velocityTracker.resetTracking() + isFlinging.value = true var lastValue = Offset(0f, 0f) - AnimationState( - typeConverter = Offset.VectorConverter, - initialValue = Offset.Zero, - initialVelocity = Offset(velocity.x, velocity.y), - ).animateDecay(spec) { - val delta = value - lastValue - lastValue = value - - if (scrollState.value == null) { - val canPanHorizontally = when { - delta.x < 0 -> canPanLeft() - delta.x > 0 -> canPanRight() - else -> false - } - val canPanVertically = when { - delta.y > 0 -> canPanDown() - delta.y < 0 -> canPanUp() - else -> false - } - if (!canPanHorizontally && !canPanVertically) { - this.cancelAnimation() - return@animateDecay + try { + AnimationState( + typeConverter = Offset.VectorConverter, + initialValue = Offset.Zero, + initialVelocity = Offset(velocity.x, velocity.y), + ).animateDecay(spec) { + val delta = value - lastValue + lastValue = value + + if (scrollState.value == null) { + val canPanHorizontally = when { + delta.x < 0 -> canPanLeft() + delta.x > 0 -> canPanRight() + else -> false + } + val canPanVertically = when { + delta.y > 0 -> canPanDown() + delta.y < 0 -> canPanUp() + else -> false + } + if (!canPanHorizontally && !canPanVertically) { + this.cancelAnimation() + return@animateDecay + } } - } - addPan(delta) + addPan(delta) + } + } finally { + isFlinging.value = false } } + fun onGestureStart() { + gestureStartedAtLeftEdge = isAtLeftEdge() + gestureStartedAtRightEdge = isAtRightEdge() + cumulativePagerScroll = 0f + } + + private fun isAtLeftEdge(): Boolean { + if (zoom.value <= baseZoom + 0.01f) return true + return currentOffset.x >= offsetXLimits.value.endInclusive - 0.5f + } + + private fun isAtRightEdge(): Boolean { + if (zoom.value <= baseZoom + 0.01f) return true + return currentOffset.x <= offsetXLimits.value.start + 0.5f + } + private fun canPanUp(): Boolean { return currentOffset.y > offsetYLimits.value.start } @@ -200,10 +242,27 @@ class ScreenScaleState { applyLimits() val delta = (newOffset - currentOffset) - when (scrollOrientation.value) { - Vertical -> applyScroll((delta / -zoomToScale).y) - Horizontal -> applyScroll((delta / -zoomToScale).x) - null -> {} + val pagerValue = (delta.x / -zoomToScale) + val isRtl = scrollReversed.value + + val allowNextPage = if (isRtl) { + gestureStartedAtLeftEdge && pagerValue > 0 + } else { + gestureStartedAtRightEdge && pagerValue > 0 + } + + val allowPrevPage = if (isRtl) { + gestureStartedAtRightEdge && pagerValue < 0 + } else { + gestureStartedAtLeftEdge && pagerValue < 0 + } + + if (!edgeHandoffEnabled || allowNextPage || allowPrevPage) { + when (scrollOrientation.value) { + Vertical -> applyScroll((delta / -zoomToScale).y) + Horizontal -> applyScroll(pagerValue) + null -> {} + } } } @@ -216,7 +275,20 @@ class ScreenScaleState { if (value == 0f) return val scrollState = this.scrollState.value if (scrollState != null) { - scrollScope.launch { scrollState.scrollBy(if (scrollReversed.value) -value else value) } + val delta = if (scrollReversed.value) -value else value + if (edgeHandoffEnabled) { + val screenWidth = areaSize.value.width.toFloat() + val remaining = if (delta > 0) { + (screenWidth - cumulativePagerScroll).coerceAtLeast(0f) + } else { + (-screenWidth - cumulativePagerScroll).coerceAtMost(0f) + } + val consumed = if (delta > 0) min(delta, remaining) else max(delta, remaining) + cumulativePagerScroll += consumed + scrollState.dispatchRawDelta(consumed) + } else { + scrollState.dispatchRawDelta(delta) + } } } @@ -237,8 +309,9 @@ class ScreenScaleState { this.scrollReversed.value = reversed } - fun setZoom(zoom: Float, focus: Offset = Offset.Zero) { + fun setZoom(zoom: Float, focus: Offset = Offset.Zero, updateBase: Boolean = false) { val newZoom = zoom.coerceIn(zoomLimits.value) + if (updateBase) baseZoom = newZoom val newOffset = Transformation.offsetOf( point = transformation.value.pointOf(focus), transformedPoint = focus, @@ -254,6 +327,30 @@ class ScreenScaleState { applyLimits() } + fun toggleZoom(focus: Offset) { + val coroutineScope = composeScope ?: return + zoomJob?.cancel() + val currentZoom = zoom.value + val targetZoom = if (currentZoom > baseZoom + 0.1f) { + baseZoom + } else { + max(baseZoom * 2.5f, 2.5f) + } + + zoomJob = coroutineScope.launch { + AnimationState(initialValue = currentZoom).animateTo( + targetValue = targetZoom, + animationSpec = spring(stiffness = Spring.StiffnessLow) + ) { + setZoom(value, focus) + } + } + } + + fun resetVelocity() { + velocityTracker.resetTracking() + } + fun enableOverscrollArea(enable: Boolean) { this.enableOverscrollArea = enable applyLimits() @@ -262,6 +359,7 @@ class ScreenScaleState { fun apply(other: ScreenScaleState) { scrollJob?.cancel() currentOffset = other.currentOffset + this.baseZoom = other.baseZoom if (other.targetSize.value != this.targetSize.value || other.zoom.value != this.zoom.value) { this.areaSize.value = other.areaSize.value diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/AdaptiveBackground.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/AdaptiveBackground.kt new file mode 100644 index 00000000..d17a6c06 --- /dev/null +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/AdaptiveBackground.kt @@ -0,0 +1,137 @@ +package snd.komelia.ui.reader.image.common + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.unit.IntSize +import snd.komelia.image.EdgeSampling + +@Composable +fun AdaptiveBackground( + edgeSampling: EdgeSampling?, + modifier: Modifier = Modifier, + imageBounds: Rect? = null, + content: @Composable () -> Unit +) { + val backgroundColor = MaterialTheme.colorScheme.background + val topColor = remember(edgeSampling) { edgeSampling?.top?.averageColor?.let { Color(it) } ?: Color.Transparent } + val bottomColor = remember(edgeSampling) { edgeSampling?.bottom?.averageColor?.let { Color(it) } ?: Color.Transparent } + val leftColor = remember(edgeSampling) { edgeSampling?.left?.averageColor?.let { Color(it) } ?: Color.Transparent } + val rightColor = remember(edgeSampling) { edgeSampling?.right?.averageColor?.let { Color(it) } ?: Color.Transparent } + + val animatedTop by animateColorAsState(targetValue = topColor, animationSpec = tween(durationMillis = 500)) + val animatedBottom by animateColorAsState(targetValue = bottomColor, animationSpec = tween(durationMillis = 500)) + val animatedLeft by animateColorAsState(targetValue = leftColor, animationSpec = tween(durationMillis = 500)) + val animatedRight by animateColorAsState(targetValue = rightColor, animationSpec = tween(durationMillis = 500)) + + Box( + modifier = modifier + .fillMaxSize() + .drawBehind { + if (edgeSampling != null) { + val containerWidth = size.width + val containerHeight = size.height + + val imageLeft = imageBounds?.left ?: 0f + val imageTop = imageBounds?.top ?: 0f + val imageRight = imageBounds?.right ?: containerWidth + val imageBottom = imageBounds?.bottom ?: containerHeight + + // Top Zone + if (imageTop > 0) { + val path = Path().apply { + moveTo(0f, 0f) + lineTo(containerWidth, 0f) + lineTo(imageRight, imageTop) + lineTo(imageLeft, imageTop) + close() + } + drawPath( + path = path, + brush = Brush.verticalGradient( + 0f to backgroundColor, + 1f to animatedTop, + startY = 0f, + endY = imageTop + ) + ) + } + + // Bottom Zone + if (imageBottom < containerHeight) { + val path = Path().apply { + moveTo(0f, containerHeight) + lineTo(containerWidth, containerHeight) + lineTo(imageRight, imageBottom) + lineTo(imageLeft, imageBottom) + close() + } + drawPath( + path = path, + brush = Brush.verticalGradient( + 0f to animatedBottom, + 1f to backgroundColor, + startY = imageBottom, + endY = containerHeight + ) + ) + } + + // Left Zone + if (imageLeft > 0) { + val path = Path().apply { + moveTo(0f, 0f) + lineTo(imageLeft, imageTop) + lineTo(imageLeft, imageBottom) + lineTo(0f, containerHeight) + close() + } + drawPath( + path = path, + brush = Brush.horizontalGradient( + 0f to backgroundColor, + 1f to animatedLeft, + startX = 0f, + endX = imageLeft + ) + ) + } + + // Right Zone + if (imageRight < containerWidth) { + val path = Path().apply { + moveTo(containerWidth, 0f) + lineTo(imageRight, imageTop) + lineTo(imageRight, imageBottom) + lineTo(containerWidth, containerHeight) + close() + } + drawPath( + path = path, + brush = Brush.horizontalGradient( + 0f to animatedRight, + 1f to backgroundColor, + startX = imageRight, + endX = containerWidth + ) + ) + } + } + } + ) { + content() + } +} diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ProgressSlider.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ProgressSlider.kt index 050dbf2c..8384f57d 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ProgressSlider.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ProgressSlider.kt @@ -37,6 +37,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalLayoutDirection @@ -56,6 +58,7 @@ import coil3.size.Size import coil3.size.SizeResolver import snd.komelia.image.ReaderImage import snd.komelia.image.coil.BookPageThumbnailRequest +import snd.komelia.ui.LocalAccentColor import snd.komelia.ui.common.components.AppSliderDefaults import snd.komelia.ui.reader.image.PageMetadata import kotlin.math.roundToInt @@ -98,6 +101,8 @@ fun PageSpreadProgressSlider( Modifier .fillMaxWidth() .hoverable(interactionSource) + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(horizontal = 10.dp) ) ) { if (show || isHovered.value) { @@ -107,16 +112,10 @@ fun PageSpreadProgressSlider( pageSpreads = pageSpreads, currentSpreadIndex = currentSpreadIndex, onPageNumberChange = onPageNumberChange, - layoutDirection = layoutDirection + layoutDirection = layoutDirection, + interactionSource = interactionSource, ) } - - Spacer( - Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceVariant) - .windowInsetsPadding(WindowInsets.navigationBars) - ) } } } @@ -129,6 +128,7 @@ private fun Slider( currentSpreadIndex: Int, onPageNumberChange: (Int) -> Unit, layoutDirection: LayoutDirection, + interactionSource: MutableInteractionSource, ) { var currentPos by remember(currentSpreadIndex) { mutableStateOf(currentSpreadIndex) } val currentSpread = remember(pageSpreads, currentPos) { pageSpreads.getOrElse(currentPos) { pageSpreads.last() } } @@ -143,6 +143,7 @@ private fun Slider( var showPreview by remember { mutableStateOf(false) } val sliderValue by derivedStateOf { currentPos.toFloat() } + val accentColor = LocalAccentColor.current val sliderState = rememberSliderState( value = sliderValue, @@ -159,7 +160,7 @@ private fun Slider( ) Layout(content = { - if ( showPreview) { + if (showPreview) { Row { for (pageMetadata in currentSpread) { BookPageThumbnail( @@ -170,28 +171,38 @@ private fun Slider( } } else Spacer(Modifier) + val labelBackground = accentColor?.copy(alpha = 0.8f) ?: MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f) + val onLabelColor = if (accentColor != null) { + if (accentColor.luminance() > 0.5f) Color.Black else Color.White + } else MaterialTheme.colorScheme.onSurfaceVariant + Text( label, textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.primary, + color = onLabelColor, modifier = Modifier .background( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(4.dp) + color = labelBackground, + shape = RoundedCornerShape(20.dp) ) - .border(BorderStroke(1.dp, MaterialTheme.colorScheme.surface)) - .padding(4.dp) + .padding(horizontal = 12.dp, vertical = 4.dp) .defaultMinSize(minWidth = 40.dp) ) Slider( state = sliderState, - modifier = Modifier.background(MaterialTheme.colorScheme.surfaceVariant), - colors = AppSliderDefaults.colors(), + colors = AppSliderDefaults.colors(accentColor = accentColor), track = { state -> SliderDefaults.Track( sliderState = state, - colors = AppSliderDefaults.colors(), + colors = AppSliderDefaults.colors(accentColor = accentColor), + modifier = Modifier.height(16.dp) + ) + }, + thumb = { state -> + SliderDefaults.Thumb( + interactionSource = interactionSource, + colors = AppSliderDefaults.colors(accentColor = accentColor), ) } ) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ReaderAnimation.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ReaderAnimation.kt new file mode 100644 index 00000000..eb3e7528 --- /dev/null +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ReaderAnimation.kt @@ -0,0 +1,17 @@ +package snd.komelia.ui.reader.image.common + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring + +object ReaderAnimation { + /** + * Unified spring spec for all manual navigation (taps, arrow keys). + * Damping: NoBouncy (1.0f) for a clean, professional finish. + * Normalized by density to ensure consistent physical speed across devices. + */ + fun navSpringSpec(density: Float) = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = (Spring.StiffnessLow * (2f / density)) + .coerceIn(Spring.StiffnessVeryLow..Spring.StiffnessMedium) + ) +} diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ReaderContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ReaderContent.kt index 3d1c1325..fe0f82fd 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ReaderContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ReaderContent.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType.Companion.KeyUp import androidx.compose.ui.input.key.isAltPressed @@ -195,6 +196,8 @@ fun ReaderControlsOverlay( isSettingsMenuOpen: Boolean, onSettingsMenuToggle: () -> Unit, contentAreaSize: IntSize, + scaleState: ScreenScaleState, + tapToZoom: Boolean, modifier: Modifier, content: @Composable () -> Unit, ) { @@ -211,6 +214,7 @@ fun ReaderControlsOverlay( else coroutineScope.launch { onPrevPageClick() } } + val areaCenter = remember(contentAreaSize) { Offset(contentAreaSize.width / 2f, contentAreaSize.height / 2f) } Box( modifier = modifier .fillMaxSize() @@ -219,16 +223,22 @@ fun ReaderControlsOverlay( contentAreaSize, readingDirection, onSettingsMenuToggle, - isSettingsMenuOpen + isSettingsMenuOpen, + tapToZoom ) { - detectTapGestures { offset -> - val actionWidth = contentAreaSize.width.toFloat() / 3 - when (offset.x) { - in 0f.. leftAction() - in actionWidth..actionWidth * 2 -> centerAction() - else -> rightAction() - } - } + detectTapGestures( + onTap = { offset -> + val actionWidth = contentAreaSize.width.toFloat() / 3 + when (offset.x) { + in 0f.. leftAction() + in actionWidth..actionWidth * 2 -> centerAction() + else -> rightAction() + } + }, + onDoubleTap = if (tapToZoom) { offset -> + scaleState.toggleZoom(offset - areaCenter) + } else null + ) }, contentAlignment = Alignment.Center ) { diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ScalableContainer.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ScalableContainer.kt index dbdad250..dfeb6bfa 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ScalableContainer.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/ScalableContainer.kt @@ -100,13 +100,31 @@ fun ScalableContainer( } .pointerInput(areaSize) { - detectTransformGestures { event, centroid, pan, zoom, _ -> - if (zoom != 1.0f) { - scaleState.multiplyZoom(zoom, centroid - areaCenter) - } else { - scaleState.addPan(event, pan) + var lastIterationPointerCount = 0 + detectTransformGestures( + onGesture = { changes, centroid, pan, zoom, _ -> + if (!scaleState.isGestureInProgress.value) { + scaleState.onGestureStart() + scaleState.isGestureInProgress.value = true + } + + val currentPointerCount = changes.count { it.pressed } + if (currentPointerCount != lastIterationPointerCount) { + scaleState.resetVelocity() + } + + if (currentPointerCount == lastIterationPointerCount) { + if (zoom != 1.0f) { + scaleState.multiplyZoom(zoom, centroid - areaCenter) + } + scaleState.addPan(changes, pan) + } + lastIterationPointerCount = currentPointerCount + }, + onEnd = { + scaleState.isGestureInProgress.value = false } - } + ) } .onPointerEvent(PointerEventType.Scroll) { event -> val scrollDelta = with(density) { with(scrollConfig) { calculateMouseWheelScroll(event, size) } } @@ -117,7 +135,7 @@ fun ScalableContainer( scaleState.addZoom(zoom, centroid - areaCenter) } else { val maxDelta = if (abs(scrollDelta.y) > abs(scrollDelta.x)) scrollDelta.y else scrollDelta.x - val pan = (if (scaleState.scrollReversed.value) -maxDelta else maxDelta) + val pan = maxDelta when (scrollOrientation) { Vertical -> scaleState.addPan(Offset(0f, pan)) Horizontal -> scaleState.addPan(Offset(pan, 0f)) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/TransformGestureDetector.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/TransformGestureDetector.kt index e054ff74..1846b6d2 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/TransformGestureDetector.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/common/TransformGestureDetector.kt @@ -48,7 +48,8 @@ import kotlin.math.atan2 */ suspend fun PointerInputScope.detectTransformGestures( panZoomLock: Boolean = false, - onGesture: (changes: List, centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit + onGesture: (changes: List, centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit, + onEnd: () -> Unit = {} ) { awaitEachGesture { var rotation = 0f @@ -103,6 +104,7 @@ suspend fun PointerInputScope.detectTransformGestures( } } } while (!canceled && event.changes.fastAny { it.pressed }) + onEnd() } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/continuous/ContinuousReaderContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/continuous/ContinuousReaderContent.kt index 69bd0cbb..66421f6f 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/continuous/ContinuousReaderContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/continuous/ContinuousReaderContent.kt @@ -107,6 +107,8 @@ fun BoxScope.ContinuousReaderContent( onNexPageClick = { coroutineScope.launch { continuousReaderState.scrollScreenForward() } }, onPrevPageClick = { coroutineScope.launch { continuousReaderState.scrollScreenBackward() } }, contentAreaSize = areaSize, + scaleState = screenScaleState, + tapToZoom = true, isSettingsMenuOpen = showSettingsMenu, onSettingsMenuToggle = { onShowSettingsMenuChange(!showSettingsMenu) }, modifier = Modifier.onKeyEvent { event -> diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/continuous/ContinuousReaderState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/continuous/ContinuousReaderState.kt index e6de32b6..e3b1b609 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/continuous/ContinuousReaderState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/continuous/ContinuousReaderState.kt @@ -217,7 +217,7 @@ class ContinuousReaderState( .filter { it != IntSize.Zero } .onEach { applyPadding() - screenScaleState.setZoom(0f) + screenScaleState.setZoom(0f, updateBase = true) } .launchIn(stateScope) @@ -357,17 +357,19 @@ class ContinuousReaderState( } fun scrollBy(amount: Float) { - when (readingDirection.value) { - TOP_TO_BOTTOM -> { - screenScaleState.addPan(Offset(0f, amount)) - } + stateScope.launch { + when (readingDirection.value) { + TOP_TO_BOTTOM -> { + screenScaleState.addPan(Offset(0f, amount)) + } - LEFT_TO_RIGHT -> { - screenScaleState.addPan(Offset(amount, 0f)) - } + LEFT_TO_RIGHT -> { + screenScaleState.addPan(Offset(amount, 0f)) + } - RIGHT_TO_LEFT -> { - screenScaleState.addPan(Offset(amount, 0f)) + RIGHT_TO_LEFT -> { + screenScaleState.addPan(Offset(amount, 0f)) + } } } } @@ -699,7 +701,7 @@ class ContinuousReaderState( fun onSidePaddingChange(fraction: Float) { this.sidePaddingFraction.value = fraction applyPadding() - screenScaleState.setZoom(0f) + screenScaleState.setZoom(0f, updateBase = true) stateScope.launch { settingsRepository.putContinuousReaderPadding(fraction) } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt index 77a58a70..55d72a83 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderContent.kt @@ -1,16 +1,26 @@ package snd.komelia.ui.reader.image.paged +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.Key @@ -24,6 +34,7 @@ import androidx.compose.ui.layout.Layout import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch +import snd.komelia.image.ReaderImageResult import snd.komelia.settings.model.PageDisplayLayout.DOUBLE_PAGES import snd.komelia.settings.model.PageDisplayLayout.DOUBLE_PAGES_NO_COVER import snd.komelia.settings.model.PageDisplayLayout.SINGLE_PAGE @@ -35,10 +46,18 @@ import snd.komelia.ui.reader.image.common.PagedReaderHelpDialog import snd.komelia.ui.reader.image.common.ReaderControlsOverlay import snd.komelia.ui.reader.image.common.ReaderImageContent import snd.komelia.ui.reader.image.common.ScalableContainer +import androidx.compose.ui.platform.LocalDensity +import snd.komelia.ui.reader.image.common.ReaderAnimation import snd.komelia.ui.reader.image.paged.PagedReaderState.Page import snd.komelia.ui.reader.image.paged.PagedReaderState.TransitionPage import snd.komelia.ui.reader.image.paged.PagedReaderState.TransitionPage.BookEnd import snd.komelia.ui.reader.image.paged.PagedReaderState.TransitionPage.BookStart +import snd.komelia.ui.reader.image.common.AdaptiveBackground +import kotlin.math.abs + +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.toSize @Composable fun BoxScope.PagedReaderContent( @@ -54,23 +73,81 @@ fun BoxScope.PagedReaderContent( PagedReaderHelpDialog(onDismissRequest = { onShowHelpDialogChange(false) }) } + val density = LocalDensity.current.density + LaunchedEffect(density) { screenScaleState.setDensity(density) } + val readingDirection = pagedReaderState.readingDirection.collectAsState().value val layoutDirection = when (readingDirection) { LEFT_TO_RIGHT -> LayoutDirection.Ltr RIGHT_TO_LEFT -> LayoutDirection.Rtl } - val pages = pagedReaderState.currentSpread.collectAsState().value.pages + val spreads = pagedReaderState.pageSpreads.collectAsState().value + val currentSpreadIndex = pagedReaderState.currentSpreadIndex.collectAsState().value val layout = pagedReaderState.layout.collectAsState().value val layoutOffset = pagedReaderState.layoutOffset.collectAsState().value + val tapToZoom = pagedReaderState.tapToZoom.collectAsState().value + val adaptiveBackground = pagedReaderState.adaptiveBackground.collectAsState().value val currentContainerSize = screenScaleState.areaSize.collectAsState().value + val pagerState = rememberPagerState( + initialPage = currentSpreadIndex, + pageCount = { spreads.size } + ) + + LaunchedEffect(pagerState, readingDirection) { + screenScaleState.setScrollState(pagerState) + screenScaleState.setScrollOrientation(Orientation.Horizontal, readingDirection == RIGHT_TO_LEFT) + } + val coroutineScope = rememberCoroutineScope() + LaunchedEffect(Unit) { + pagedReaderState.pageNavigationEvents.collect { event -> + if (pagerState.currentPage != event.pageIndex) { + when (event) { + is PagedReaderState.PageNavigationEvent.Animated -> { + pagerState.animateScrollToPage( + page = event.pageIndex, + animationSpec = ReaderAnimation.navSpringSpec(density) + ) + } + + is PagedReaderState.PageNavigationEvent.Immediate -> { + pagerState.scrollToPage(event.pageIndex) + } + } + } + } + } + + LaunchedEffect(pagerState.currentPage) { + if (pagerState.currentPage < spreads.size) { + pagedReaderState.onPageChange(pagerState.currentPage) + } + } + + // Snapping effect + val isGestureInProgress by screenScaleState.isGestureInProgress.collectAsState() + val isFlinging by screenScaleState.isFlinging.collectAsState() + LaunchedEffect(isGestureInProgress, isFlinging) { + if (!isGestureInProgress && !isFlinging) { + val pageOffset = pagerState.currentPageOffsetFraction + if (abs(pageOffset) > 0.001f) { + pagerState.animateScrollToPage( + page = pagerState.currentPage, + animationSpec = ReaderAnimation.navSpringSpec(density) + ) + } + } + } + ReaderControlsOverlay( readingDirection = layoutDirection, - onNexPageClick = pagedReaderState::nextPage, - onPrevPageClick = pagedReaderState::previousPage, + onNexPageClick = { coroutineScope.launch { pagedReaderState.nextPage() } }, + onPrevPageClick = { coroutineScope.launch { pagedReaderState.previousPage() } }, contentAreaSize = currentContainerSize, + scaleState = screenScaleState, + tapToZoom = tapToZoom, isSettingsMenuOpen = showSettingsMenu, onSettingsMenuToggle = { onShowSettingsMenuChange(!showSettingsMenu) }, modifier = Modifier.onKeyEvent { event -> @@ -95,9 +172,54 @@ fun BoxScope.PagedReaderContent( if (transitionPage != null) { TransitionPage(transitionPage) } else { - when (layout) { - SINGLE_PAGE -> pages.firstOrNull()?.let { SinglePageLayout(it) } - DOUBLE_PAGES, DOUBLE_PAGES_NO_COVER -> DoublePageLayout(pages, readingDirection) + if (spreads.isNotEmpty()) { + HorizontalPager( + state = pagerState, + userScrollEnabled = false, + reverseLayout = readingDirection == RIGHT_TO_LEFT, + modifier = Modifier.fillMaxSize(), + key = { if (it < spreads.size) spreads[it].first().pageNumber else it } + ) { pageIdx -> + if (pageIdx >= spreads.size) return@HorizontalPager + val spreadMetadata = spreads[pageIdx] + val spreadPages = remember(spreadMetadata) { + spreadMetadata.map { meta -> + val pageState = mutableStateOf(null) + meta to pageState + } + } + + spreadPages.forEach { (meta, pageState) -> + LaunchedEffect(meta) { + pageState.value = pagedReaderState.getPage(meta) + } + } + + val pages = spreadPages.map { (meta, pageState) -> + pageState.value ?: Page(meta, null) + } + + val edgeSampling = if (adaptiveBackground && pages.size == 1) pages.first().edgeSampling else null + val imageSize = if (pages.size == 1) pages.first().imageSize else null + val imageBounds = remember(imageSize, currentContainerSize) { + if (imageSize == null) null + else { + val left = (currentContainerSize.width - imageSize.width) / 2f + val top = (currentContainerSize.height - imageSize.height) / 2f + Rect(Offset(left, top), imageSize.toSize()) + } + } + + AdaptiveBackground( + edgeSampling = edgeSampling, + imageBounds = imageBounds, + ) { + when (layout) { + SINGLE_PAGE -> pages.firstOrNull()?.let { SinglePageLayout(it) } + DOUBLE_PAGES, DOUBLE_PAGES_NO_COVER -> DoublePageLayout(pages, readingDirection) + } + } + } } } } @@ -107,6 +229,7 @@ fun BoxScope.PagedReaderContent( @Composable private fun TransitionPage(page: TransitionPage) { + // ... rest of file same Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt index 2a74acb1..6a980fb2 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/paged/PagedReaderState.kt @@ -31,8 +31,10 @@ import kotlinx.coroutines.launch import snd.komelia.AppNotification import snd.komelia.AppNotifications import snd.komelia.image.BookImageLoader +import snd.komelia.image.EdgeSampling import snd.komelia.image.ReaderImage.PageId import snd.komelia.image.ReaderImageResult +import snd.komelia.image.getEdgeSampling import snd.komelia.komga.api.model.KomeliaBook import snd.komelia.settings.ImageReaderSettingsRepository import snd.komelia.settings.model.LayoutScaleType @@ -67,6 +69,7 @@ class PagedReaderState( ) { private val stateScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val pageLoadScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private var loadSpreadJob: kotlinx.coroutines.Job? = null private val imageCache = Cache.Builder>() .maximumCacheSize(10) @@ -91,6 +94,10 @@ class PagedReaderState( val layoutOffset = MutableStateFlow(false) val scaleType = MutableStateFlow(LayoutScaleType.SCREEN) val readingDirection = MutableStateFlow(LEFT_TO_RIGHT) + val tapToZoom = MutableStateFlow(true) + val adaptiveBackground = MutableStateFlow(false) + + val pageNavigationEvents = MutableSharedFlow(extraBufferCapacity = 1) suspend fun initialize() { layout.value = settingsRepository.getPagedReaderDisplayLayout().first() @@ -100,9 +107,13 @@ class PagedReaderState( KomgaReadingDirection.RIGHT_TO_LEFT -> RIGHT_TO_LEFT else -> settingsRepository.getPagedReaderReadingDirection().first() } + tapToZoom.value = settingsRepository.getPagedReaderTapToZoom().first() + adaptiveBackground.value = settingsRepository.getPagedReaderAdaptiveBackground().first() screenScaleState.setScrollState(null) screenScaleState.setScrollOrientation(Orientation.Vertical, false) + screenScaleState.enableOverscrollArea(false) + screenScaleState.edgeHandoffEnabled = true combine( screenScaleState.transformation, @@ -136,6 +147,9 @@ class PagedReaderState( fun stop() { stateScope.coroutineContext.cancelChildren() + pageLoadScope.coroutineContext.cancelChildren() + screenScaleState.enableOverscrollArea(false) + screenScaleState.edgeHandoffEnabled = false imageCache.invalidateAll() } @@ -226,7 +240,7 @@ class PagedReaderState( ) currentSpreadIndex.value = newSpreadIndex - loadPage(newSpreadIndex) + jumpToPage(newSpreadIndex) } fun nextPage() { @@ -297,15 +311,33 @@ class PagedReaderState( loadPage(lastPageIndex) } + fun jumpToPage(page: Int) { + pageChangeFlow.tryEmit(Unit) + val pageNumber = pageSpreads.value[page].last().pageNumber + stateScope.launch { readerState.onProgressChange(pageNumber) } + currentSpreadIndex.value = page + pageNavigationEvents.tryEmit(PageNavigationEvent.Immediate(page)) + + loadSpreadJob?.cancel() + loadSpreadJob = pageLoadScope.launch { loadSpread(page) } + } + private fun loadPage(spreadIndex: Int) { if (spreadIndex != currentSpreadIndex.value) { val pageNumber = pageSpreads.value[spreadIndex].last().pageNumber stateScope.launch { readerState.onProgressChange(pageNumber) } currentSpreadIndex.value = spreadIndex + pageNavigationEvents.tryEmit(PageNavigationEvent.Animated(spreadIndex)) } - pageLoadScope.coroutineContext.cancelChildren() - pageLoadScope.launch { loadSpread(spreadIndex) } + loadSpreadJob?.cancel() + loadSpreadJob = pageLoadScope.launch { loadSpread(spreadIndex) } + } + + sealed interface PageNavigationEvent { + val pageIndex: Int + data class Animated(override val pageIndex: Int) : PageNavigationEvent + data class Immediate(override val pageIndex: Int) : PageNavigationEvent } private suspend fun loadSpread(loadSpreadIndex: Int) { @@ -348,7 +380,18 @@ class PagedReaderState( if (cached != null && !cached.isCancelled) cached else pageLoadScope.async { val imageResult = imageLoader.loadReaderImage(meta.bookId, meta.pageNumber) - Page(meta, imageResult) + val containerSize = screenScaleState.areaSize.value + val imageSize = if (imageResult is ReaderImageResult.Success) { + imageResult.image.calculateSizeForArea(containerSize, true) + } else null + + val edgeSampling = if (adaptiveBackground.value && imageResult is ReaderImageResult.Success) { + val originalImage = imageResult.image.getOriginalImage().getOrNull() + if (originalImage != null) { + originalImage.getEdgeSampling() + } else null + } else null + Page(meta, imageResult, edgeSampling, imageSize) }.also { imageCache.put(pageId, it) } } @@ -396,6 +439,31 @@ class PagedReaderState( launchSpreadLoadJob(pagesMeta) } + suspend fun getPage(page: PageMetadata): Page { + val pageId = page.toPageId() + val cached = imageCache.get(pageId) + return if (cached != null && !cached.isCancelled) { + cached.await() + } else { + val job = pageLoadScope.async { + val imageResult = imageLoader.loadReaderImage(page.bookId, page.pageNumber) + val containerSize = screenScaleState.areaSize.value + val imageSize = if (imageResult is ReaderImageResult.Success) { + imageResult.image.calculateSizeForArea(containerSize, true) + } else null + + val edgeSampling = if (adaptiveBackground.value && imageResult is ReaderImageResult.Success) { + val originalImage = imageResult.image.getOriginalImage().getOrNull() + if (originalImage != null) { + originalImage.getEdgeSampling() + } else null + } else null + Page(page, imageResult, edgeSampling, imageSize) + }.also { imageCache.put(pageId, it) } + job.await() + } + } + private fun getMaxPageSize(pages: List, containerSize: IntSize): IntSize { return IntSize( width = containerSize.width / pages.size, @@ -528,9 +596,20 @@ class PagedReaderState( fun onReadingDirectionChange(readingDirection: PagedReadingDirection) { this.readingDirection.value = readingDirection + screenScaleState.setScrollOrientation(Orientation.Horizontal, readingDirection == RIGHT_TO_LEFT) stateScope.launch { settingsRepository.putPagedReaderReadingDirection(readingDirection) } } + fun onTapToZoomChange(enabled: Boolean) { + this.tapToZoom.value = enabled + stateScope.launch { settingsRepository.putPagedReaderTapToZoom(enabled) } + } + + fun onAdaptiveBackgroundChange(enabled: Boolean) { + this.adaptiveBackground.value = enabled + stateScope.launch { settingsRepository.putPagedReaderAdaptiveBackground(enabled) } + } + private suspend fun calculateScreenScale( pages: List, areaSize: IntSize, @@ -558,7 +637,7 @@ class PagedReaderState( } when (scaleType) { - LayoutScaleType.SCREEN -> scaleState.setZoom(0f) + LayoutScaleType.SCREEN -> scaleState.setZoom(0f, updateBase = true) LayoutScaleType.FIT_WIDTH -> { if (!stretchToFit && areaSize.width > actualSpreadSize.width) { val newZoom = zoomForOriginalSize( @@ -566,9 +645,9 @@ class PagedReaderState( fitToScreenSize, scaleState.scaleFor100PercentZoom() ) - scaleState.setZoom(newZoom.coerceAtMost(1.0f)) - } else if (fitToScreenSize.width < areaSize.width) scaleState.setZoom(1f) - else scaleState.setZoom(0f) + scaleState.setZoom(newZoom.coerceAtMost(1.0f), updateBase = true) + } else if (fitToScreenSize.width < areaSize.width) scaleState.setZoom(1f, updateBase = true) + else scaleState.setZoom(0f, updateBase = true) } LayoutScaleType.FIT_HEIGHT -> { @@ -578,10 +657,10 @@ class PagedReaderState( fitToScreenSize, scaleState.scaleFor100PercentZoom() ) - scaleState.setZoom(newZoom.coerceAtMost(1.0f)) + scaleState.setZoom(newZoom.coerceAtMost(1.0f), updateBase = true) - } else if (fitToScreenSize.height < areaSize.height) scaleState.setZoom(1f) - else scaleState.setZoom(0f) + } else if (fitToScreenSize.height < areaSize.height) scaleState.setZoom(1f, updateBase = true) + else scaleState.setZoom(0f, updateBase = true) } LayoutScaleType.ORIGINAL -> { @@ -591,9 +670,9 @@ class PagedReaderState( fitToScreenSize, scaleState.scaleFor100PercentZoom() ) - scaleState.setZoom(newZoom) + scaleState.setZoom(newZoom, updateBase = true) - } else scaleState.setZoom(0f) + } else scaleState.setZoom(0f, updateBase = true) } } @@ -660,6 +739,8 @@ class PagedReaderState( data class Page( val metadata: PageMetadata, val imageResult: ReaderImageResult?, + val edgeSampling: EdgeSampling? = null, + val imageSize: IntSize? = null, ) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt index 8a6ba5ef..ba399cdf 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderContent.kt @@ -1,5 +1,6 @@ package snd.komelia.ui.reader.image.panels +import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -7,10 +8,15 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -22,20 +28,29 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch +import snd.komelia.image.ReaderImageResult import snd.komelia.settings.model.PagedReadingDirection import snd.komelia.settings.model.PagedReadingDirection.LEFT_TO_RIGHT import snd.komelia.settings.model.PagedReadingDirection.RIGHT_TO_LEFT import snd.komelia.ui.reader.image.ScreenScaleState import snd.komelia.ui.reader.image.common.PagedReaderHelpDialog +import snd.komelia.ui.reader.image.common.ReaderAnimation import snd.komelia.ui.reader.image.common.ReaderControlsOverlay import snd.komelia.ui.reader.image.common.ReaderImageContent import snd.komelia.ui.reader.image.common.ScalableContainer +import snd.komelia.ui.reader.image.paged.PagedReaderState.PageNavigationEvent import snd.komelia.ui.reader.image.paged.PagedReaderState.TransitionPage import snd.komelia.ui.reader.image.paged.PagedReaderState.TransitionPage.BookEnd import snd.komelia.ui.reader.image.paged.PagedReaderState.TransitionPage.BookStart +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.toSize +import snd.komelia.ui.reader.image.common.AdaptiveBackground @Composable fun BoxScope.PanelsReaderContent( @@ -51,43 +66,119 @@ fun BoxScope.PanelsReaderContent( PagedReaderHelpDialog(onDismissRequest = { onShowHelpDialogChange(false) }) } + val density = LocalDensity.current.density + LaunchedEffect(density) { screenScaleState.setDensity(density) } + val readingDirection = panelsReaderState.readingDirection.collectAsState().value val layoutDirection = when (readingDirection) { LEFT_TO_RIGHT -> LayoutDirection.Ltr RIGHT_TO_LEFT -> LayoutDirection.Rtl } - val page = panelsReaderState.currentPage.collectAsState().value + val metadata = panelsReaderState.pageMetadata.collectAsState().value + val currentPageIndex = panelsReaderState.currentPageIndex.collectAsState().value val currentContainerSize = screenScaleState.areaSize.collectAsState().value + val tapToZoom = panelsReaderState.tapToZoom.collectAsState().value + val adaptiveBackground = panelsReaderState.adaptiveBackground.collectAsState().value + + val pagerState = rememberPagerState( + initialPage = currentPageIndex.page, + pageCount = { metadata.size } + ) + + LaunchedEffect(Unit) { + panelsReaderState.pageNavigationEvents.collect { event -> + if (pagerState.currentPage != event.pageIndex) { + when (event) { + is PageNavigationEvent.Animated -> { + pagerState.animateScrollToPage( + page = event.pageIndex, + animationSpec = ReaderAnimation.navSpringSpec(density) + ) + } + + is PageNavigationEvent.Immediate -> { + pagerState.scrollToPage(event.pageIndex) + } + } + } + } + } + + LaunchedEffect(pagerState.currentPage) { + if (pagerState.currentPage < metadata.size) { + panelsReaderState.onPageChange(pagerState.currentPage) + } + } val coroutineScope = rememberCoroutineScope() - ReaderControlsOverlay( - readingDirection = layoutDirection, - onNexPageClick = panelsReaderState::nextPanel, - onPrevPageClick = panelsReaderState::previousPanel, - contentAreaSize = currentContainerSize, - isSettingsMenuOpen = showSettingsMenu, - onSettingsMenuToggle = { onShowSettingsMenuChange(!showSettingsMenu) }, - modifier = Modifier.onKeyEvent { event -> - pagedReaderOnKeyEvents( - event = event, - readingDirection = readingDirection, - onReadingDirectionChange = panelsReaderState::onReadingDirectionChange, - onMoveToNextPage = { coroutineScope.launch { panelsReaderState.nextPanel() } }, - onMoveToPrevPage = { coroutineScope.launch { panelsReaderState.previousPanel() } }, - volumeKeysNavigation = volumeKeysNavigation - ) + val currentPage = panelsReaderState.currentPage.collectAsState().value + val edgeSampling = if (adaptiveBackground) currentPage?.edgeSampling else null + val transforms = screenScaleState.transformation.collectAsState().value + val targetSize = screenScaleState.targetSize.collectAsState().value + val imageBounds = remember(transforms, targetSize, currentContainerSize) { + if (targetSize == Size.Zero || currentContainerSize == IntSize.Zero) null + else { + val width = targetSize.width * transforms.scale + val height = targetSize.height * transforms.scale + val left = (currentContainerSize.width / 2f) - (width / 2f) + transforms.offset.x + val top = (currentContainerSize.height / 2f) - (height / 2f) + transforms.offset.y + Rect(left, top, left + width, top + height) } + } + + AdaptiveBackground( + edgeSampling = edgeSampling, + imageBounds = imageBounds, ) { - ScalableContainer(scaleState = screenScaleState) { - val transitionPage = panelsReaderState.transitionPage.collectAsState().value - if (transitionPage != null) { - TransitionPage(transitionPage) - } else { - page?.let { - Box(contentAlignment = Alignment.Center) { - ReaderImageContent(page.imageResult) + ReaderControlsOverlay( + readingDirection = layoutDirection, + onNexPageClick = panelsReaderState::nextPanel, + onPrevPageClick = panelsReaderState::previousPanel, + contentAreaSize = currentContainerSize, + scaleState = screenScaleState, + tapToZoom = tapToZoom, + isSettingsMenuOpen = showSettingsMenu, + onSettingsMenuToggle = { onShowSettingsMenuChange(!showSettingsMenu) }, + modifier = Modifier.onKeyEvent { event -> + pagedReaderOnKeyEvents( + event = event, + readingDirection = readingDirection, + onReadingDirectionChange = panelsReaderState::onReadingDirectionChange, + onMoveToNextPage = { coroutineScope.launch { panelsReaderState.nextPanel() } }, + onMoveToPrevPage = { coroutineScope.launch { panelsReaderState.previousPanel() } }, + volumeKeysNavigation = volumeKeysNavigation + ) + } + ) { + ScalableContainer(scaleState = screenScaleState) { + val transitionPage = panelsReaderState.transitionPage.collectAsState().value + if (transitionPage != null) { + TransitionPage(transitionPage) + } else { + if (metadata.isNotEmpty()) { + HorizontalPager( + state = pagerState, + userScrollEnabled = false, + reverseLayout = readingDirection == RIGHT_TO_LEFT, + modifier = Modifier.fillMaxSize(), + key = { if (it < metadata.size) metadata[it].pageNumber else it } + ) { pageIdx -> + if (pageIdx >= metadata.size) return@HorizontalPager + val pageMeta = metadata[pageIdx] + + val pageState = remember(pageMeta) { mutableStateOf(null) } + LaunchedEffect(pageMeta) { + pageState.value = panelsReaderState.getPage(pageMeta) + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + ReaderImageContent(pageState.value?.imageResult) + } + } } -// SinglePageLayout(page) } } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt index 8e5002bd..7effc555 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/panels/PanelsReaderState.kt @@ -33,19 +33,23 @@ import kotlinx.coroutines.launch import snd.komelia.AppNotification import snd.komelia.AppNotifications import snd.komelia.image.BookImageLoader +import snd.komelia.image.EdgeSampling import snd.komelia.image.ImageRect import snd.komelia.image.KomeliaPanelDetector import snd.komelia.image.ReaderImage.PageId import snd.komelia.image.ReaderImageResult +import snd.komelia.image.getEdgeSampling import snd.komelia.onnxruntime.OnnxRuntimeException import snd.komelia.settings.ImageReaderSettingsRepository import snd.komelia.settings.model.PagedReadingDirection import snd.komelia.settings.model.PagedReadingDirection.LEFT_TO_RIGHT import snd.komelia.settings.model.PagedReadingDirection.RIGHT_TO_LEFT +import snd.komelia.settings.model.PanelsFullPageDisplayMode import snd.komelia.ui.reader.image.BookState import snd.komelia.ui.reader.image.PageMetadata import snd.komelia.ui.reader.image.ReaderState import snd.komelia.ui.reader.image.ScreenScaleState +import snd.komelia.ui.reader.image.paged.PagedReaderState.PageNavigationEvent import snd.komelia.ui.reader.image.paged.PagedReaderState.TransitionPage import snd.komelia.ui.reader.image.paged.PagedReaderState.TransitionPage.BookEnd import snd.komelia.ui.reader.image.paged.PagedReaderState.TransitionPage.BookStart @@ -71,6 +75,7 @@ class PanelsReaderState( ) { private val stateScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val pageLoadScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private var pageLoadJob: kotlinx.coroutines.Job? = null private val imageCache = Cache.Builder>() .maximumCacheSize(10) .eventListener { @@ -90,57 +95,77 @@ class PanelsReaderState( val pageMetadata: MutableStateFlow> = MutableStateFlow(emptyList()) - val currentPageIndex = MutableStateFlow(PageIndex(0, 0, false)) + val currentPageIndex = MutableStateFlow(PageIndex(0, 0)) val currentPage: MutableStateFlow = MutableStateFlow(null) val transitionPage: MutableStateFlow = MutableStateFlow(null) val readingDirection = MutableStateFlow(LEFT_TO_RIGHT) + val fullPageDisplayMode = MutableStateFlow(PanelsFullPageDisplayMode.NONE) + val tapToZoom = MutableStateFlow(true) + val adaptiveBackground = MutableStateFlow(false) + + val pageNavigationEvents = MutableSharedFlow(extraBufferCapacity = 1) + suspend fun initialize() { readingDirection.value = when (readerState.series.value?.metadata?.readingDirection) { KomgaReadingDirection.LEFT_TO_RIGHT -> LEFT_TO_RIGHT KomgaReadingDirection.RIGHT_TO_LEFT -> RIGHT_TO_LEFT else -> settingsRepository.getPagedReaderReadingDirection().first() } + fullPageDisplayMode.value = settingsRepository.getPanelsFullPageDisplayMode().first() + tapToZoom.value = settingsRepository.getPanelReaderTapToZoom().first() + adaptiveBackground.value = settingsRepository.getPanelReaderAdaptiveBackground().first() screenScaleState.setScrollState(null) screenScaleState.setScrollOrientation(Orientation.Vertical, false) - combine( - screenScaleState.transformation, - screenScaleState.areaSize, - ) {} - .drop(1).conflate() - .onEach { - currentPage.value?.let { page -> - updateImageState(page, screenScaleState) - delay(100) + var lastAreaSize = screenScaleState.areaSize.value + screenScaleState.areaSize + .drop(1) + .onEach { areaSize -> + val page = currentPage.value ?: return@onEach + val oldSize = lastAreaSize + lastAreaSize = areaSize + if (areaSize == IntSize.Zero || areaSize == oldSize) return@onEach + + // Small delay to allow Compose layout to finish centering the content + // before we calculate the required transformation + delay(100) + + val panelIdx = currentPageIndex.value.panel + val panelData = page.panelData + val image = (page.imageResult as? ReaderImageResult.Success)?.image + if (panelData != null && image != null) { + val stretchToFit = readerState.imageStretchToFit.value + val imageDisplaySize = image.calculateSizeForArea(areaSize, stretchToFit) + if (imageDisplaySize != null) { + screenScaleState.setTargetSize(imageDisplaySize.toSize()) + val panel = panelData.panels.getOrNull(panelIdx) ?: panelData.panels.first() + scrollToPanel( + imageSize = panelData.originalImageSize, + screenSize = areaSize, + targetSize = imageDisplaySize, + panel = panel, + skipAnimation = false + ) + } } } .launchIn(stateScope) - readingDirection.drop(1).onEach { readingDirection -> - val page = currentPage.value - val panelData = page?.panelData - if (panelData != null) { - val sortedPanels = sortPanels( - panels = panelData.panels, - imageSize = panelData.originalImageSize, - readingDirection = readingDirection - ) - currentPage.value = page.copy(panelData = panelData.copy(panels = sortedPanels)) - currentPageIndex.update { it.copy(panel = 0, isLastPanelZoomOutActive = false) } - - if (sortedPanels.isNotEmpty()) { - scrollToPanel( - imageSize = page.panelData.originalImageSize, - screenSize = screenScaleState.areaSize.value, - targetSize = screenScaleState.targetSize.value.toIntSize(), - panel = sortedPanels.first() - ) + screenScaleState.transformation + .drop(1) + .conflate() + .onEach { + currentPage.value?.let { page -> + updateImageState(page, screenScaleState, currentPageIndex.value.panel) } - } + .launchIn(stateScope) + readingDirection.drop(1).onEach { + // Simple page reload to ensure correct panel sequence for new direction + launchPageLoad(currentPageIndex.value.page) }.launchIn(stateScope) readerState.booksState @@ -154,13 +179,42 @@ class PanelsReaderState( fun stop() { stateScope.coroutineContext.cancelChildren() + pageLoadScope.coroutineContext.cancelChildren() screenScaleState.enableOverscrollArea(false) imageCache.invalidateAll() } + private var density = 1f + fun setDensity(density: Float) { + this.density = density + } + + suspend fun getPage(page: PageMetadata): PanelsPage { + val pageId = page.toPageId() + val cached = imageCache.get(pageId) + return if (cached != null && !cached.isCancelled) { + cached.await() + } else { + val job = launchDownload(page) + job.await() + } + } + + suspend fun getImage(page: PageMetadata): ReaderImageResult { + val pageId = page.toPageId() + val cached = imageCache.get(pageId) + return if (cached != null && !cached.isCancelled) { + cached.await().imageResult ?: ReaderImageResult.Error(Exception("Image result is null")) + } else { + val job = launchDownload(page) + job.await().imageResult ?: ReaderImageResult.Error(Exception("Image result is null")) + } + } + private suspend fun updateImageState( page: PanelsPage, screenScaleState: ScreenScaleState, + panelIdx: Int ) { val maxPageSize = screenScaleState.areaSize.value val zoomFactor = screenScaleState.transformation.value.scale @@ -211,9 +265,9 @@ class PanelsReaderState( imageResult = null, panelData = null ) - currentPageIndex.value = PageIndex(newPageIndex, 0, false) + currentPageIndex.value = PageIndex(newPageIndex, 0) - launchPageLoad(newPageIndex) + jumpToPage(newPageIndex) } fun onReadingDirectionChange(readingDirection: PagedReadingDirection) { @@ -221,6 +275,21 @@ class PanelsReaderState( stateScope.launch { settingsRepository.putPagedReaderReadingDirection(readingDirection) } } + fun onFullPageDisplayModeChange(mode: PanelsFullPageDisplayMode) { + this.fullPageDisplayMode.value = mode + stateScope.launch { settingsRepository.putPanelsFullPageDisplayMode(mode) } + launchPageLoad(currentPageIndex.value.page) + } + + fun onTapToZoomChange(enabled: Boolean) { + this.tapToZoom.value = enabled + stateScope.launch { settingsRepository.putPanelReaderTapToZoom(enabled) } + } + + fun onAdaptiveBackgroundChange(enabled: Boolean) { + this.adaptiveBackground.value = enabled + stateScope.launch { settingsRepository.putPanelReaderAdaptiveBackground(enabled) } + } fun nextPanel() { val pageIndex = currentPageIndex.value @@ -229,42 +298,33 @@ class PanelsReaderState( nextPage() return } - val panelData = currentPage.panelData - val panels = panelData.panels + val panels = currentPage.panelData.panels val panelIndex = pageIndex.panel - if (panels.size <= panelIndex + 1) { - if (panels.isEmpty() || panelData.panelCoversMajorityOfImage || pageIndex.isLastPanelZoomOutActive) { - nextPage() - } else { - scrollToFit() - currentPageIndex.update { it.copy(isLastPanelZoomOutActive = true) } - } - return + if (panelIndex + 1 < panels.size) { + val nextPanel = panels[panelIndex + 1] + val areaSize = screenScaleState.areaSize.value + val targetSize = screenScaleState.targetSize.value.toIntSize() + val imageSize = currentPage.panelData.originalImageSize + scrollToPanel( + imageSize = imageSize, + screenSize = areaSize, + targetSize = targetSize, + panel = nextPanel + ) + currentPageIndex.update { it.copy(panel = panelIndex + 1) } + } else { + nextPage() } - val nextPanel = panels[panelIndex + 1] - val areaSize = screenScaleState.areaSize.value - val targetSize = IntSize( - screenScaleState.targetSize.value.width.roundToInt(), - screenScaleState.targetSize.value.height.roundToInt() - ) - val imageSize = currentPage.panelData.originalImageSize - scrollToPanel( - imageSize = imageSize, - screenSize = areaSize, - targetSize = targetSize, - panel = nextPanel - ) - currentPageIndex.update { it.copy(panel = panelIndex + 1) } } private fun nextPage() { - val currentPageIndex = currentPageIndex.value.page + val pageIdx = currentPageIndex.value.page val currentTransitionPage = transitionPage.value when { - currentPageIndex < pageMetadata.value.size - 1 -> { + pageIdx < pageMetadata.value.size - 1 -> { if (currentTransitionPage != null) this.transitionPage.value = null - else onPageChange(currentPageIndex + 1) + else onPageChange(pageIdx + 1) } currentTransitionPage == null -> { @@ -295,35 +355,30 @@ class PanelsReaderState( val panels = currentPage.panelData.panels val panelIndex = pageIndex.panel - if (panelIndex - 1 < 0) { + if (panelIndex - 1 >= 0) { + val prevPanel = panels[panelIndex - 1] + val areaSize = screenScaleState.areaSize.value + val targetSize = screenScaleState.targetSize.value.toIntSize() + val imageSize = currentPage.panelData.originalImageSize + scrollToPanel( + imageSize = imageSize, + screenSize = areaSize, + targetSize = targetSize, + panel = prevPanel + ) + currentPageIndex.update { it.copy(panel = panelIndex - 1) } + } else { previousPage() - return - } - val previousPage = panels[panelIndex - 1] - val areaSize = screenScaleState.areaSize.value - val targetSize = IntSize( - screenScaleState.targetSize.value.width.roundToInt(), - screenScaleState.targetSize.value.height.roundToInt() - ) - val imageSize = currentPage.panelData.originalImageSize - scrollToPanel( - imageSize = imageSize, - screenSize = areaSize, - targetSize = targetSize, - panel = previousPage - ) - currentPageIndex.update { - it.copy(panel = panelIndex - 1, isLastPanelZoomOutActive = false) } } private fun previousPage() { - val currentPgeIndex = currentPageIndex.value.page + val pageIdx = currentPageIndex.value.page val currentTransitionPage = transitionPage.value when { - currentPgeIndex != 0 -> { + pageIdx != 0 -> { if (currentTransitionPage != null) this.transitionPage.value = null - else onPageChange(currentPgeIndex - 1) + else onPageChange(pageIdx - 1, startAtLast = true) } currentTransitionPage == null -> { @@ -344,58 +399,110 @@ class PanelsReaderState( } } - fun onPageChange(page: Int) { - if (currentPageIndex.value.page == page) return + fun jumpToPage(page: Int) { pageChangeFlow.tryEmit(Unit) + val pageNumber = page + 1 + stateScope.launch { readerState.onProgressChange(pageNumber) } + currentPageIndex.update { it.copy(page = page) } + pageNavigationEvents.tryEmit(PageNavigationEvent.Immediate(page)) launchPageLoad(page) } - private fun launchPageLoad(pageIndex: Int) { + fun onPageChange(page: Int, startAtLast: Boolean = false) { + if (currentPageIndex.value.page == page) return + pageChangeFlow.tryEmit(Unit) + pageNavigationEvents.tryEmit(PageNavigationEvent.Animated(page)) + launchPageLoad(page, startAtLast, isAnimated = true) + } + + private fun launchPageLoad(pageIndex: Int, startAtLast: Boolean = false, isAnimated: Boolean = false) { if (pageIndex != currentPageIndex.value.page) { val pageNumber = pageIndex + 1 stateScope.launch { readerState.onProgressChange(pageNumber) } } - pageLoadScope.coroutineContext.cancelChildren() - pageLoadScope.launch { doPageLoad(pageIndex) } + pageLoadJob?.cancel() + pageLoadJob = pageLoadScope.launch { doPageLoad(pageIndex, startAtLast, isAnimated) } } - private suspend fun doPageLoad(pageIndex: Int) { + private suspend fun doPageLoad(pageIndex: Int, startAtLast: Boolean = false, isAnimated: Boolean = false) { val pageMeta = pageMetadata.value[pageIndex] val downloadJob = launchDownload(pageMeta) preloadImagesBetween(pageIndex) if (downloadJob.isActive) { - currentPage.value = PanelsPage( - metadata = pageMeta, - imageResult = null, - panelData = null - ) - currentPageIndex.update { PageIndex(pageIndex, 0, false) } + currentPage.update { + it?.copy( + metadata = pageMeta, + imageResult = null, + panelData = null, + edgeSampling = null, + imageSize = null + ) ?: PanelsPage( + metadata = pageMeta, + imageResult = null, + panelData = null, + edgeSampling = null, + imageSize = null + ) + } + currentPageIndex.update { PageIndex(pageIndex, 0) } transitionPage.value = null screenScaleState.enableOverscrollArea(false) - screenScaleState.setZoom(0f) + screenScaleState.setZoom(0f, updateBase = true) } val page = downloadJob.await() - val sortedPanelsPage = if (page.panelData != null) { - val sortedPanels = sortPanels( + val sortedPanels = if (page.panelData != null) { + sortPanels( page.panelData.panels, page.panelData.originalImageSize, readingDirection.value ) - page.copy(panelData = page.panelData.copy(panels = sortedPanels)) + } else emptyList() + + val finalPanels = mutableListOf() + if (page.panelData != null) { + val imageSize = page.panelData.originalImageSize + val fullPageRect = ImageRect(0, 0, imageSize.width, imageSize.height) + + // Avoid duplicate view if the AI already detected a full-page panel + val alreadyHasFullPage = sortedPanels.any { it.width >= imageSize.width * 0.95f && it.height >= imageSize.height * 0.95f } + + val mode = fullPageDisplayMode.value + val showFirst = mode == PanelsFullPageDisplayMode.BEFORE || mode == PanelsFullPageDisplayMode.BOTH + val showLast = mode == PanelsFullPageDisplayMode.AFTER || mode == PanelsFullPageDisplayMode.BOTH + + if (sortedPanels.isEmpty()) { + finalPanels.add(fullPageRect) + } else if (alreadyHasFullPage && sortedPanels.size == 1) { + // If it's a splash page (1 large panel), just show it once. + finalPanels.addAll(sortedPanels) + } else { + if (showFirst && !alreadyHasFullPage) finalPanels.add(fullPageRect) + finalPanels.addAll(sortedPanels) + if (showLast && !alreadyHasFullPage) finalPanels.add(fullPageRect) + } + } + + val pageWithInjectedPanels = if (page.panelData != null) { + page.copy(panelData = page.panelData.copy(panels = finalPanels)) } else page val containerSize = screenScaleState.areaSize.value - val scale = getScaleFor(sortedPanelsPage, containerSize) - updateImageState(sortedPanelsPage, scale) - currentPageIndex.update { PageIndex(pageIndex, 0, false) } + val initialPanelIdx = if (startAtLast) (finalPanels.size - 1).coerceAtLeast(0) else 0 + val scale = getScaleFor(pageWithInjectedPanels, containerSize, initialPanelIdx) + + updateImageState(pageWithInjectedPanels, scale, initialPanelIdx) + currentPageIndex.update { PageIndex(pageIndex, initialPanelIdx) } transitionPage.value = null - logger.info { "current page value $sortedPanelsPage" } - currentPage.value = sortedPanelsPage + currentPage.value = pageWithInjectedPanels screenScaleState.enableOverscrollArea(true) - screenScaleState.apply(scale) + if (isAnimated) { + screenScaleState.animateTo(scale.transformation.value.offset, scale.zoom.value) + } else { + screenScaleState.apply(scale) + } } private fun preloadImagesBetween(pageIndex: Int) { @@ -407,8 +514,8 @@ class PanelsReaderState( val imageJob = launchDownload(pageMetadata.value[index]) pageLoadScope.launch { val image = imageJob.await() - val scale = getScaleFor(image, screenScaleState.areaSize.value) - updateImageState(image, scale) + val scale = getScaleFor(image, screenScaleState.areaSize.value, 0) + updateImageState(image, scale, 0) } } } @@ -433,10 +540,16 @@ class PanelsReaderState( panelData = null ) - val imageSize = IntSize(originalImage.width, originalImage.height) - val (panels, duration) = measureTimedValue { + val containerSize = screenScaleState.areaSize.value + val fitToScreenSize = image.calculateSizeForArea(containerSize, true) + val originalImageSize = IntSize(originalImage.width, originalImage.height) + val edgeSampling = if (adaptiveBackground.value) { + originalImage.getEdgeSampling() + } else null + + val (panels, duration) = measureTimedValue { + try { - logger.info { "rf detr before run" } onnxRuntimeRfDetr.detect(originalImage).map { it.boundingBox } } catch (e: OnnxRuntimeException) { return@async PanelsPage( @@ -448,32 +561,18 @@ class PanelsReaderState( } logger.info { "page ${meta.pageNumber} panel detection completed in $duration" } - - val panelsArea = areaOfRects(panels.map { it.toRect() }) - val imageArea = originalImage.width * originalImage.height - val untrimmedRatio = panelsArea / imageArea - - val panelRatio = if (untrimmedRatio < .8f) { - val trim = originalImage.findTrim() - val imageArea = trim.width * trim.height - val ratio = panelsArea / imageArea - logger.info { "trimmed panels area coverage ${ratio * 100}%" } - ratio - } else { - logger.info { "untrimmed panels area coverage ${untrimmedRatio * 100}%" } - untrimmedRatio - } - val panelData = PanelData( panels = panels, - originalImageSize = imageSize, - panelCoversMajorityOfImage = panelRatio > .8f + originalImageSize = originalImageSize, + panelCoversMajorityOfImage = false // Placeholder for Phase 2 ) return@async PanelsPage( metadata = meta, imageResult = imageResult, - panelData = panelData + panelData = panelData, + edgeSampling = edgeSampling, + imageSize = fitToScreenSize ) } imageCache.put(pageId, loadJob) @@ -482,11 +581,12 @@ class PanelsReaderState( private suspend fun getScaleFor( page: PanelsPage, - containerSize: IntSize + containerSize: IntSize, + panelIdx: Int ): ScreenScaleState { val defaultScale = ScreenScaleState() defaultScale.setAreaSize(containerSize) - defaultScale.setZoom(0f) + defaultScale.setZoom(0f, updateBase = true) val image = page.imageResult?.image ?: return defaultScale val scaleState = ScreenScaleState() @@ -497,17 +597,17 @@ class PanelsReaderState( val panels = page.panelData?.panels if (panels.isNullOrEmpty()) { - scaleState.setZoom(0f) + scaleState.setZoom(0f, updateBase = true) } else { - val firstPanel = panels.first() + val targetPanel = panels.getOrNull(panelIdx) ?: panels.first() val imageSize = image.getOriginalImageSize().getOrNull() ?: return defaultScale val (offset, zoom) = getPanelOffsetAndZoom( imageSize = imageSize, areaSize = containerSize, targetSize = fitToScreenSize, - panel = firstPanel + panel = targetPanel ) - scaleState.setZoom(zoom) + scaleState.setZoom(zoom, updateBase = true) scaleState.setOffset(offset) } @@ -515,12 +615,7 @@ class PanelsReaderState( } private fun scrollToFit() { -// val areaSize = screenScaleState.areaSize.value -// val startX = 0 - areaSize.width.toFloat() -// val startY = 0 - areaSize.height.toFloat() - screenScaleState.setZoom(0f) - screenScaleState.scrollTo(Offset(0f, 0f)) - + screenScaleState.animateTo(Offset(0f, 0f), 0f) } private fun scrollToPanel( @@ -528,6 +623,7 @@ class PanelsReaderState( screenSize: IntSize, targetSize: IntSize, panel: ImageRect, + skipAnimation: Boolean = false, ) { val (offset, zoom) = getPanelOffsetAndZoom( imageSize = imageSize, @@ -535,8 +631,12 @@ class PanelsReaderState( targetSize = targetSize, panel = panel ) - screenScaleState.setZoom(zoom) - screenScaleState.scrollTo(offset) + if (skipAnimation) { + screenScaleState.setZoom(zoom, updateBase = true) + screenScaleState.setOffset(offset) + } else { + screenScaleState.animateTo(offset, zoom) + } } private fun getPanelOffsetAndZoom( @@ -548,42 +648,36 @@ class PanelsReaderState( val xScale: Float = targetSize.width.toFloat() / imageSize.width val yScale: Float = targetSize.height.toFloat() / imageSize.height - val bboxLeft: Float = panel.left.coerceAtLeast(0) * xScale - val bboxRight: Float = panel.right.coerceAtMost(imageSize.width) * xScale - val bboxBottom: Float = panel.bottom.coerceAtMost(imageSize.height) * yScale - val bboxTop: Float = panel.top.coerceAtLeast(0) * yScale - val bboxWidth: Float = bboxRight - bboxLeft - val bboxHeight: Float = bboxBottom - bboxTop + val panelCenterX = (panel.left + panel.width / 2f) * xScale + val panelCenterY = (panel.top + panel.height / 2f) * yScale + val imageCenterX = targetSize.width / 2f + val imageCenterY = targetSize.height / 2f + + val bboxWidth = panel.width * xScale + val bboxHeight = panel.height * yScale - val scale: Float = min( + val totalScale: Float = min( areaSize.width / bboxWidth, areaSize.height / bboxHeight ) - val fitToScreenScale = max( + val scaleFor100PercentZoom = max( areaSize.width.toFloat() / targetSize.width, areaSize.height.toFloat() / targetSize.height ) - val zoom: Float = scale / fitToScreenScale - - val bboxHalfWidth: Float = bboxWidth / 2.0f - val bboxHalfHeight: Float = bboxHeight / 2.0f - val imageHalfWidth: Float = targetSize.width / 2.0f - val imageHalfHeight: Float = targetSize.height / 2.0f - - val centerX: Float = (bboxLeft - imageHalfWidth) * -1.0f - val centerY: Float = (bboxTop - imageHalfHeight) * -1.0f - val offset = Offset( - (centerX - bboxHalfWidth) * scale, - (centerY - bboxHalfHeight) * scale - ) + val zoom: Float = totalScale / scaleFor100PercentZoom + + val offsetX = (imageCenterX - panelCenterX) * totalScale + val offsetY = (imageCenterY - panelCenterY) * totalScale - return offset to zoom + return Offset(offsetX, offsetY) to zoom } data class PanelsPage( val metadata: PageMetadata, val imageResult: ReaderImageResult?, val panelData: PanelData?, + val edgeSampling: EdgeSampling? = null, + val imageSize: IntSize? = null, ) data class PanelData( @@ -595,7 +689,6 @@ class PanelsReaderState( data class PageIndex( val page: Int, val panel: Int, - val isLastPanelZoomOutActive: Boolean, ) } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/BottomSheetSettingsOverlay.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/BottomSheetSettingsOverlay.kt index e486fc9b..5955e905 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/BottomSheetSettingsOverlay.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/BottomSheetSettingsOverlay.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi @@ -16,6 +17,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.add +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBars @@ -27,11 +29,12 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.rounded.Tune import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -52,6 +55,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.style.TextOverflow @@ -64,11 +68,13 @@ import snd.komelia.settings.model.ContinuousReadingDirection import snd.komelia.settings.model.LayoutScaleType import snd.komelia.settings.model.PageDisplayLayout import snd.komelia.settings.model.PagedReadingDirection +import snd.komelia.settings.model.PanelsFullPageDisplayMode import snd.komelia.settings.model.ReaderFlashColor import snd.komelia.settings.model.ReaderType import snd.komelia.settings.model.ReaderType.CONTINUOUS import snd.komelia.settings.model.ReaderType.PAGED import snd.komelia.settings.model.ReaderType.PANELS +import snd.komelia.ui.LocalAccentColor import snd.komelia.ui.LocalStrings import snd.komelia.ui.LocalWindowWidth import snd.komelia.ui.common.components.AppSliderDefaults @@ -118,54 +124,65 @@ fun BottomSheetSettingsOverlay( ) { val windowWidth = LocalWindowWidth.current + val accentColor = LocalAccentColor.current var showSettingsDialog by remember { mutableStateOf(false) } - Row( - modifier = Modifier - .background(MaterialTheme.colorScheme.surfaceVariant) - .fillMaxWidth() - .windowInsetsPadding( - WindowInsets.statusBars - .add(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)) - ), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton( - onClick = onBackPress, - modifier = Modifier.size(46.dp) - ) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, null) - } - book?.let { - Column( - Modifier.weight(1f) - .padding(horizontal = 10.dp) + Box(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceVariant) + .fillMaxWidth() + .windowInsetsPadding( + WindowInsets.statusBars + .add(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)) + ), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = onBackPress, + modifier = Modifier.size(46.dp) ) { - val titleStyle = - if (windowWidth == COMPACT) MaterialTheme.typography.titleMedium - else MaterialTheme.typography.titleLarge - - Text( - it.seriesTitle, - maxLines = 1, - style = titleStyle, - overflow = TextOverflow.Ellipsis - ) - Text( - it.metadata.title, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.basicMarquee(iterations = Int.MAX_VALUE) - ) + Icon(Icons.AutoMirrored.Filled.ArrowBack, null) + } + + book?.let { + Column( + Modifier.weight(1f) + .padding(horizontal = 10.dp) + ) { + val titleStyle = + if (windowWidth == COMPACT) MaterialTheme.typography.titleMedium + else MaterialTheme.typography.titleLarge + + Text( + it.seriesTitle, + maxLines = 1, + style = titleStyle, + overflow = TextOverflow.Ellipsis + ) + Text( + it.metadata.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.basicMarquee(iterations = Int.MAX_VALUE) + ) + } } } - FilledIconButton( - onClick = { showSettingsDialog = true }, -// shape = RoundedCornerShape(13.dp), - modifier = Modifier.size(46.dp) + FloatingActionButton( + onClick = { showSettingsDialog = true }, + containerColor = accentColor ?: MaterialTheme.colorScheme.primaryContainer, + contentColor = if (accentColor != null) { + if (accentColor.luminance() > 0.5f) Color.Black else Color.White + } else MaterialTheme.colorScheme.onPrimaryContainer, + shape = RoundedCornerShape(16.dp), + modifier = Modifier + .align(Alignment.BottomEnd) + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(bottom = 80.dp, end = 16.dp) ) { - Icon(Icons.Default.Settings, null) + Icon(Icons.Rounded.Tune, null) } } @@ -177,8 +194,6 @@ fun BottomSheetSettingsOverlay( ModalBottomSheet( onDismissRequest = { showSettingsDialog = false }, sheetState = sheetState, - dragHandle = {}, - scrimColor = Color.Transparent, containerColor = MaterialTheme.colorScheme.surface, ) { var selectedTab by remember { mutableStateOf(0) } @@ -308,6 +323,8 @@ private fun PagedModeSettings( ) { val strings = LocalStrings.current.pagedReader val scaleType = pageState.scaleType.collectAsState().value + val tapToZoom = pageState.tapToZoom.collectAsState().value + val adaptiveBackground = pageState.adaptiveBackground.collectAsState().value Column { Text(strings.scaleType) @@ -385,6 +402,19 @@ private fun PagedModeSettings( ) } + SwitchWithLabel( + checked = tapToZoom, + onCheckedChange = pageState::onTapToZoomChange, + label = { Text("Tap to zoom") }, + contentPadding = PaddingValues(horizontal = 10.dp), + ) + + SwitchWithLabel( + checked = adaptiveBackground, + onCheckedChange = pageState::onAdaptiveBackgroundChange, + label = { Text(strings.adaptiveBackground) }, + contentPadding = PaddingValues(horizontal = 10.dp), + ) } } @@ -395,6 +425,8 @@ private fun PanelsModeSettings( state: PanelsReaderState, ) { val strings = LocalStrings.current.pagedReader + val tapToZoom = state.tapToZoom.collectAsState().value + val adaptiveBackground = state.adaptiveBackground.collectAsState().value Column { val readingDirection = state.readingDirection.collectAsState().value @@ -413,6 +445,32 @@ private fun PanelsModeSettings( label = { Text(strings.forReadingDirection(PagedReadingDirection.LEFT_TO_RIGHT)) } ) } + + val displayMode = state.fullPageDisplayMode.collectAsState().value + Text("Show full page") + FlowRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + PanelsFullPageDisplayMode.entries.forEach { mode -> + InputChip( + selected = displayMode == mode, + onClick = { state.onFullPageDisplayModeChange(mode) }, + label = { Text(mode.name) } + ) + } + } + + SwitchWithLabel( + checked = tapToZoom, + onCheckedChange = state::onTapToZoomChange, + label = { Text("Tap to zoom") }, + contentPadding = PaddingValues(horizontal = 10.dp), + ) + + SwitchWithLabel( + checked = adaptiveBackground, + onCheckedChange = state::onAdaptiveBackgroundChange, + label = { Text(strings.adaptiveBackground) }, + contentPadding = PaddingValues(horizontal = 10.dp), + ) } } @@ -424,6 +482,7 @@ private fun ContinuousModeSettings( ) { val strings = LocalStrings.current.continuousReader val windowWidth = LocalWindowWidth.current + val accentColor = LocalAccentColor.current Column { val readingDirection = state.readingDirection.collectAsState().value Text(strings.readingDirection) @@ -459,7 +518,7 @@ private fun ContinuousModeSettings( onValueChange = state::onSidePaddingChange, steps = 15, valueRange = 0f..0.4f, - colors = AppSliderDefaults.colors() + colors = AppSliderDefaults.colors(accentColor = accentColor) ) } @@ -475,7 +534,7 @@ private fun ContinuousModeSettings( onValueChange = { state.onPageSpacingChange(it.roundToInt()) }, steps = 24, valueRange = 0f..250f, - colors = AppSliderDefaults.colors() + colors = AppSliderDefaults.colors(accentColor = accentColor) ) else -> Slider( @@ -483,7 +542,7 @@ private fun ContinuousModeSettings( onValueChange = { state.onPageSpacingChange(it.roundToInt()) }, steps = 49, valueRange = 0f..500f, - colors = AppSliderDefaults.colors() + colors = AppSliderDefaults.colors(accentColor = accentColor) ) } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/CommonImageSettings.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/CommonImageSettings.kt index b8301fd6..4c245733 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/CommonImageSettings.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/CommonImageSettings.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.unit.dp import snd.komelia.settings.model.ReaderFlashColor +import snd.komelia.ui.LocalAccentColor import snd.komelia.ui.LocalPlatform import snd.komelia.ui.LocalStrings import snd.komelia.ui.common.components.AppSliderDefaults @@ -61,6 +62,7 @@ fun CommonImageSettings( val strings = LocalStrings.current val readerStrings = strings.reader val platform = LocalPlatform.current + val accentColor = LocalAccentColor.current Column(modifier = modifier) { SwitchWithLabel( checked = stretchToFit, @@ -128,7 +130,7 @@ fun CommonImageSettings( onValueChange = { onFlashDurationChange(it.roundToLong()) }, steps = 13, valueRange = 100f..1500f, - colors = AppSliderDefaults.colors() + colors = AppSliderDefaults.colors(accentColor = accentColor) ) } @@ -146,7 +148,7 @@ fun CommonImageSettings( onValueChange = { onFlashEveryNPagesChange(it.roundToInt()) }, steps = 10, valueRange = 1f..10f, - colors = AppSliderDefaults.colors() + colors = AppSliderDefaults.colors(accentColor = accentColor) ) } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/SettingsSideMenu.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/SettingsSideMenu.kt index 59ee117c..6d71d681 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/SettingsSideMenu.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/reader/image/settings/SettingsSideMenu.kt @@ -55,6 +55,7 @@ import snd.komelia.settings.model.ContinuousReadingDirection import snd.komelia.settings.model.LayoutScaleType import snd.komelia.settings.model.PageDisplayLayout import snd.komelia.settings.model.PagedReadingDirection +import snd.komelia.settings.model.PanelsFullPageDisplayMode import snd.komelia.settings.model.ReaderFlashColor import snd.komelia.settings.model.ReaderType import snd.komelia.settings.model.ReaderType.CONTINUOUS @@ -173,10 +174,7 @@ fun SettingsSideMenuOverlay( PAGED -> PagedReaderSettingsContent(pagedReaderState) PANELS -> { if (panelsReaderState != null) { - PanelsReaderSettingsContent( - readingDirection = panelsReaderState.readingDirection.collectAsState().value, - onReadingDirectionChange = panelsReaderState::onReadingDirectionChange - ) + PanelsReaderSettingsContent(panelsReaderState) } } @@ -424,15 +422,34 @@ private fun ColumnScope.PagedReaderSettingsContent( contentPadding = PaddingValues(horizontal = 10.dp) ) } + + val tapToZoom = pageState.tapToZoom.collectAsState().value + val adaptiveBackground = pageState.adaptiveBackground.collectAsState().value + SwitchWithLabel( + checked = tapToZoom, + onCheckedChange = pageState::onTapToZoomChange, + label = { Text("Tap to zoom") }, + contentPadding = PaddingValues(horizontal = 10.dp) + ) + SwitchWithLabel( + checked = adaptiveBackground, + onCheckedChange = pageState::onAdaptiveBackgroundChange, + label = { Text(strings.adaptiveBackground) }, + contentPadding = PaddingValues(horizontal = 10.dp) + ) } } @Composable private fun PanelsReaderSettingsContent( - readingDirection: PagedReadingDirection, - onReadingDirectionChange: (PagedReadingDirection) -> Unit, + state: PanelsReaderState ) { val strings = LocalStrings.current.pagedReader + val readingDirection = state.readingDirection.collectAsState().value + val displayMode = state.fullPageDisplayMode.collectAsState().value + val tapToZoom = state.tapToZoom.collectAsState().value + val adaptiveBackground = state.adaptiveBackground.collectAsState().value + Column { DropdownChoiceMenu( @@ -443,11 +460,35 @@ private fun PanelsReaderSettingsContent( options = remember { PagedReadingDirection.entries.map { LabeledEntry(it, strings.forReadingDirection(it)) } }, - onOptionChange = { onReadingDirectionChange(it.value) }, + onOptionChange = { state.onReadingDirectionChange(it.value) }, inputFieldModifier = Modifier.fillMaxWidth(), label = { Text(strings.readingDirection) }, inputFieldColor = MaterialTheme.colorScheme.surfaceVariant ) + + DropdownChoiceMenu( + selectedOption = LabeledEntry(displayMode, displayMode.name), + options = remember { + PanelsFullPageDisplayMode.entries.map { LabeledEntry(it, it.name) } + }, + onOptionChange = { state.onFullPageDisplayModeChange(it.value) }, + inputFieldModifier = Modifier.fillMaxWidth(), + label = { Text("Show full page") }, + inputFieldColor = MaterialTheme.colorScheme.surfaceVariant + ) + + SwitchWithLabel( + checked = tapToZoom, + onCheckedChange = state::onTapToZoomChange, + label = { Text("Tap to zoom") }, + contentPadding = PaddingValues(horizontal = 10.dp) + ) + SwitchWithLabel( + checked = adaptiveBackground, + onCheckedChange = state::onAdaptiveBackgroundChange, + label = { Text(strings.adaptiveBackground) }, + contentPadding = PaddingValues(horizontal = 10.dp) + ) } } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/SeriesScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/SeriesScreen.kt index 89c6677b..950ee049 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/SeriesScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/SeriesScreen.kt @@ -30,6 +30,12 @@ import snd.komga.client.series.KomgaSeries import snd.komga.client.series.KomgaSeriesId import kotlin.jvm.Transient +import snd.komelia.ui.LocalAccentColor +import snd.komelia.ui.LocalPlatform +import snd.komelia.ui.LocalUseNewLibraryUI +import snd.komelia.ui.series.immersive.ImmersiveSeriesContent +import snd.komelia.ui.platform.PlatformType + fun seriesScreen(series: KomgaSeries): Screen = if (series.oneshot) OneshotScreen(series, BookSiblingsContext.Series) else SeriesScreen(series) @@ -73,6 +79,42 @@ class SeriesScreen( onDispose { vm.stopKomgaEventHandler() } } + val platform = LocalPlatform.current + val useNewUI = LocalUseNewLibraryUI.current + val series = vm.series.collectAsState().value + if (platform == PlatformType.MOBILE && useNewUI && series != null) { + ImmersiveSeriesContent( + series = series, + library = vm.library.collectAsState().value, + accentColor = LocalAccentColor.current, + onLibraryClick = { navigator.push(LibraryScreen(it.id)) }, + seriesMenuActions = vm.seriesMenuActions(), + onFilterClick = { filter -> navigator.push(LibraryScreen(series.libraryId, filter)) }, + currentTab = vm.currentTab, + onTabChange = vm::onTabChange, + booksState = vm.booksState, + onBookClick = { navigator push bookScreen(it) }, + onBookReadClick = { book, markProgress -> + navigator.parent?.push(readerScreen(book, markProgress)) + }, + collectionsState = vm.collectionsState, + onCollectionClick = { navigator.push(CollectionScreen(it.id)) }, + onSeriesClick = { s -> + navigator.push( + if (s.oneshot) OneshotScreen(s, BookSiblingsContext.Series) + else SeriesScreen(s, vm.currentTab) + ) + }, + onBackClick = { onBackPress(navigator, series.libraryId) }, + onDownload = vm::onDownload, + initiallyExpanded = vm.isExpanded, + onExpandChange = { vm.isExpanded = it } + ) + + BackPressHandler { onBackPress(navigator, series.libraryId) } + return + } + ScreenPullToRefreshBox(screenState = vm.state, onRefresh = vm::reload) { when (val state = vm.state.collectAsState().value) { is Error -> ErrorContent( diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/SeriesViewModel.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/SeriesViewModel.kt index b692219e..6ff50e87 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/SeriesViewModel.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/SeriesViewModel.kt @@ -61,6 +61,7 @@ class SeriesViewModel( val series = MutableStateFlow(series?.withSortedTags()) val library = MutableStateFlow(null) var currentTab by mutableStateOf(defaultTab) + var isExpanded by mutableStateOf(false) val cardWidth = settingsRepository.getCardWidth().map { it.dp } .stateIn(screenModelScope, Eagerly, defaultCardWidth.dp) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/immersive/ImmersiveSeriesContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/immersive/ImmersiveSeriesContent.kt new file mode 100644 index 00000000..ccdafa61 --- /dev/null +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/immersive/ImmersiveSeriesContent.kt @@ -0,0 +1,455 @@ +package snd.komelia.ui.series.immersive + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.lerp +import snd.komelia.image.coil.SeriesDefaultThumbnailRequest +import snd.komelia.komga.api.model.KomeliaBook +import snd.komelia.ui.LoadState +import snd.komelia.ui.LocalKomgaEvents +import snd.komga.client.sse.KomgaEvent.ThumbnailBookEvent +import snd.komga.client.sse.KomgaEvent.ThumbnailSeriesEvent +import snd.komelia.ui.collection.SeriesCollectionsContent +import snd.komelia.ui.collection.SeriesCollectionsState +import snd.komelia.ui.common.components.AppFilterChipDefaults +import snd.komelia.ui.common.images.ThumbnailImage +import snd.komelia.ui.common.immersive.ImmersiveDetailFab +import snd.komelia.ui.common.immersive.ImmersiveDetailScaffold +import snd.komelia.ui.common.menus.SeriesActionsMenu +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.clip +import snd.komelia.ui.common.menus.SeriesMenuActions +import snd.komelia.ui.common.menus.bulk.BooksBulkActionsContent +import snd.komelia.ui.common.menus.bulk.BottomPopupBulkActionsPanel +import snd.komelia.ui.common.menus.bulk.BulkActionsContainer +import snd.komelia.ui.dialogs.ConfirmationDialog +import snd.komelia.ui.dialogs.permissions.DownloadNotificationRequestDialog +import snd.komelia.ui.library.SeriesScreenFilter +import snd.komelia.ui.series.SeriesBooksState +import snd.komelia.ui.series.SeriesBooksState.BooksData +import snd.komelia.ui.series.SeriesViewModel.SeriesTab +import snd.komelia.ui.series.view.SeriesBooksContent +import snd.komelia.ui.series.view.SeriesChipTags +import snd.komelia.ui.series.view.SeriesDescriptionRow +import snd.komelia.ui.series.view.SeriesSummary +import snd.komga.client.collection.KomgaCollection +import snd.komga.client.library.KomgaLibrary +import snd.komga.client.series.KomgaSeries + +private enum class ImmersiveTab { BOOKS, COLLECTIONS, TAGS } + +@Composable +fun ImmersiveSeriesContent( + series: KomgaSeries, + library: KomgaLibrary?, + accentColor: Color?, + onLibraryClick: (KomgaLibrary) -> Unit, + seriesMenuActions: SeriesMenuActions, + onFilterClick: (SeriesScreenFilter) -> Unit, + currentTab: SeriesTab, + onTabChange: (SeriesTab) -> Unit, + booksState: SeriesBooksState, + onBookClick: (KomeliaBook) -> Unit, + onBookReadClick: (KomeliaBook, Boolean) -> Unit, + collectionsState: SeriesCollectionsState, + onCollectionClick: (KomgaCollection) -> Unit, + onSeriesClick: (KomgaSeries) -> Unit, + onBackClick: () -> Unit, + onDownload: () -> Unit, + initiallyExpanded: Boolean, + onExpandChange: (Boolean) -> Unit, +) { + val booksLoadState = booksState.state.collectAsState().value + val booksData = remember(booksLoadState) { + if (booksLoadState is LoadState.Success) booksLoadState.value else BooksData() + } + val bookMenuActions = remember { booksState.bookMenuActions() } + val bookBulkActions = remember { booksState.bookBulkMenuActions() } + val gridMinWidth = booksState.cardWidth.collectAsState().value + val scrollState = rememberLazyGridState() + + val selectionMode = booksData.selectionMode + val selectedBooks = booksData.selectedBooks + + // First unread book — used for Read Now action + val firstUnreadBook = remember(booksData.books) { + booksData.books.firstOrNull { it.readProgress == null || it.readProgress?.completed == false } + ?: booksData.books.firstOrNull() + } + + var showDownloadConfirmationDialog by remember { mutableStateOf(false) } + + // Local tab state — includes TAGS which has no VM counterpart + var immersiveTab by remember { + mutableStateOf( + when (currentTab) { + SeriesTab.BOOKS -> ImmersiveTab.BOOKS + SeriesTab.COLLECTIONS -> ImmersiveTab.COLLECTIONS + } + ) + } + + val onImmersiveTabChange: (ImmersiveTab) -> Unit = { tab -> + immersiveTab = tab + when (tab) { + ImmersiveTab.BOOKS -> onTabChange(SeriesTab.BOOKS) + ImmersiveTab.COLLECTIONS -> onTabChange(SeriesTab.COLLECTIONS) + ImmersiveTab.TAGS -> Unit + } + } + + // Keep in sync if something external changes the VM tab + LaunchedEffect(currentTab) { + if (immersiveTab != ImmersiveTab.TAGS) { + immersiveTab = when (currentTab) { + SeriesTab.BOOKS -> ImmersiveTab.BOOKS + SeriesTab.COLLECTIONS -> ImmersiveTab.COLLECTIONS + } + } + } + + val komgaEvents = LocalKomgaEvents.current + var coverData by remember(series.id) { mutableStateOf(SeriesDefaultThumbnailRequest(series.id)) } + LaunchedEffect(series.id) { + komgaEvents.collect { event -> + val eventSeriesId = when (event) { + is ThumbnailSeriesEvent -> event.seriesId + is ThumbnailBookEvent -> event.seriesId + else -> null + } + if (eventSeriesId == series.id) coverData = SeriesDefaultThumbnailRequest(series.id) + } + } + + ImmersiveDetailScaffold( + coverData = coverData, + coverKey = series.id.value, + cardColor = null, + immersive = true, + initiallyExpanded = initiallyExpanded, + onExpandChange = onExpandChange, + topBarContent = { + if (selectionMode) { + BulkActionsContainer( + onCancel = { booksState.setSelectionMode(false) }, + selectedCount = selectedBooks.size, + allSelected = booksData.books.size == selectedBooks.size, + onSelectAll = { + val allSelected = booksData.books.size == selectedBooks.size + if (allSelected) booksData.books.forEach { booksState.onBookSelect(it) } + else booksData.books + .filter { book -> selectedBooks.none { it.id == book.id } } + .forEach { booksState.onBookSelect(it) } + } + ) {} + } else { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp, end = 4.dp, top = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(36.dp) + .background(Color.Black.copy(alpha = 0.55f), CircleShape) + .clickable(onClick = onBackClick), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + tint = Color.White + ) + } + + var expandActions by remember { mutableStateOf(false) } + Box { + Box( + modifier = Modifier + .size(36.dp) + .background(Color.Black.copy(alpha = 0.55f), CircleShape) + .clickable { expandActions = true }, + contentAlignment = Alignment.Center + ) { + Icon(Icons.Rounded.MoreVert, contentDescription = null, tint = Color.White) + } + SeriesActionsMenu( + series = series, + actions = seriesMenuActions, + expanded = expandActions, + showEditOption = true, + showDownloadOption = false, + onDismissRequest = { expandActions = false }, + ) + } + } + } + }, + fabContent = { + ImmersiveDetailFab( + onReadClick = { firstUnreadBook?.let { onBookReadClick(it, true) } }, + onReadIncognitoClick = { firstUnreadBook?.let { onBookReadClick(it, false) } }, + onDownloadClick = { showDownloadConfirmationDialog = true }, + accentColor = accentColor, + showReadActions = false, + ) + }, + cardContent = { expandFraction -> + val thumbnailOffset = (126.dp * expandFraction).coerceAtLeast(0.dp) + + // Thumbnail metrics — must match ImmersiveDetailScaffold Layer 3 + val thumbnailTopGap = 20.dp + val thumbnailHeight = 110.dp / 0.703f // ≈ 156.5 dp + + val navBarBottom = with(LocalDensity.current) { + WindowInsets.navigationBars.getBottom(this).toDp() + } + LazyVerticalGrid( + state = scrollState, + columns = GridCells.Adaptive(gridMinWidth), + horizontalArrangement = Arrangement.spacedBy(15.dp), + contentPadding = PaddingValues(start = 10.dp, end = 10.dp, bottom = navBarBottom + 80.dp), + modifier = Modifier.fillMaxWidth(), + ) { + // Title + writers in a single item whose minimum height equals the thumbnail + // bottom when expanded — this pushes the description row below the thumbnail, + // avoiding Z-order overlap, while still scrolling with the rest of the content. + item(span = { GridItemSpan(maxLineSpan) }) { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = (thumbnailTopGap + thumbnailHeight) * expandFraction) + .padding( + start = 16.dp, + end = 16.dp, + top = lerp(8f, thumbnailTopGap.value, expandFraction).dp, + ) + ) { + if (expandFraction > 0.01f) { + Box( + modifier = Modifier + .padding(top = (thumbnailTopGap - 8.dp) * expandFraction) + .graphicsLayer { alpha = (expandFraction * 2f - 1f).coerceIn(0f, 1f) } + ) { + ThumbnailImage( + data = coverData, + cacheKey = series.id.value, + crossfade = false, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(width = 110.dp, height = thumbnailHeight) + .clip(RoundedCornerShape(8.dp)) + ) + } + } + + Column(modifier = Modifier.padding(start = thumbnailOffset)) { + Text( + text = series.metadata.title, + style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold), + ) + val writers = remember(series.booksMetadata.authors) { + series.booksMetadata.authors + .filter { it.role.lowercase() == "writer" } + .joinToString(", ") { it.name } + } + val year = series.booksMetadata.releaseDate?.year + val writersYearText = buildString { + if (writers.isNotEmpty()) append(writers) + if (year != null) { if (writers.isNotEmpty()) append(" "); append("($year)") } + } + if (writersYearText.isNotEmpty()) { + Text( + text = writersYearText, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(top = 2.dp), + ) + } + } + } + } + + // Description row (library, status, age rating, etc.) — full width + if (library != null) { + item(span = { GridItemSpan(maxLineSpan) }) { + SeriesDescriptionRow( + library = library, + onLibraryClick = onLibraryClick, + releaseDate = series.booksMetadata.releaseDate, + status = series.metadata.status, + ageRating = series.metadata.ageRating, + language = series.metadata.language, + readingDirection = series.metadata.readingDirection, + deleted = series.deleted || library.unavailable, + alternateTitles = series.metadata.alternateTitles, + onFilterClick = onFilterClick, + showReleaseYear = false, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } + + // Summary — full width + item(span = { GridItemSpan(maxLineSpan) }) { + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + SeriesSummary( + seriesSummary = series.metadata.summary, + bookSummary = series.booksMetadata.summary, + bookSummaryNumber = series.booksMetadata.summaryNumber, + ) + } + } + + // Divider + item(span = { GridItemSpan(maxLineSpan) }) { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + } + + // Tab row + item(span = { GridItemSpan(maxLineSpan) }) { + SeriesImmersiveTabRow( + currentTab = immersiveTab, + onTabChange = onImmersiveTabChange, + showCollectionsTab = collectionsState.collections.isNotEmpty(), + ) + } + + // Tab content + when (immersiveTab) { + ImmersiveTab.BOOKS -> SeriesBooksContent( + series = series, + onBookClick = onBookClick, + onBookReadClick = onBookReadClick, + scrollState = scrollState, + booksLoadState = booksLoadState, + onBooksLayoutChange = booksState::onBookLayoutChange, + onBooksPageSizeChange = booksState::onBookPageSizeChange, + onPageChange = booksState::onPageChange, + onBookSelect = booksState::onBookSelect, + booksFilterState = booksState.filterState, + bookContextMenuActions = bookMenuActions, + ) + + ImmersiveTab.COLLECTIONS -> item(span = { GridItemSpan(maxLineSpan) }) { + SeriesCollectionsContent( + collections = collectionsState.collections, + onCollectionClick = onCollectionClick, + onSeriesClick = onSeriesClick, + cardWidth = collectionsState.cardWidth.collectAsState().value, + ) + } + + ImmersiveTab.TAGS -> item(span = { GridItemSpan(maxLineSpan) }) { + Box(Modifier.padding(horizontal = 16.dp)) { + SeriesChipTags(series = series, onFilterClick = onFilterClick) + } + } + } + } + } + ) + + if (showDownloadConfirmationDialog) { + var permissionRequested by remember { mutableStateOf(false) } + DownloadNotificationRequestDialog { permissionRequested = true } + if (permissionRequested) { + ConfirmationDialog( + body = "Download series \"${series.metadata.title}\"?", + onDialogConfirm = { + onDownload() + showDownloadConfirmationDialog = false + }, + onDialogDismiss = { showDownloadConfirmationDialog = false } + ) + } + } + + if (selectionMode && selectedBooks.isNotEmpty()) { + BottomPopupBulkActionsPanel { + BooksBulkActionsContent( + books = selectedBooks, + actions = bookBulkActions, + compact = true + ) + } + } +} + +@Composable +private fun SeriesImmersiveTabRow( + currentTab: ImmersiveTab, + onTabChange: (ImmersiveTab) -> Unit, + showCollectionsTab: Boolean, +) { + val chipColors = AppFilterChipDefaults.filterChipColors() + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) { + FilterChip( + onClick = { onTabChange(ImmersiveTab.BOOKS) }, + selected = currentTab == ImmersiveTab.BOOKS, + label = { Text("Books") }, + colors = chipColors, + border = null, + ) + if (showCollectionsTab) { + FilterChip( + onClick = { onTabChange(ImmersiveTab.COLLECTIONS) }, + selected = currentTab == ImmersiveTab.COLLECTIONS, + label = { Text("Collections") }, + colors = chipColors, + border = null, + ) + } + FilterChip( + onClick = { onTabChange(ImmersiveTab.TAGS) }, + selected = currentTab == ImmersiveTab.TAGS, + label = { Text("Tags") }, + colors = chipColors, + border = null, + ) + } + HorizontalDivider() + } +} diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/list/SeriesListContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/list/SeriesListContent.kt index c7b2061d..cac19d0c 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/list/SeriesListContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/list/SeriesListContent.kt @@ -5,11 +5,19 @@ import androidx.compose.animation.animateContentSize import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator @@ -26,9 +34,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import snd.komelia.komga.api.model.KomeliaBook +import snd.komelia.ui.LocalUseNewLibraryUI import snd.komelia.ui.LocalWindowWidth +import snd.komelia.ui.common.cards.BookImageCard +import snd.komelia.ui.common.components.AppSuggestionChipDefaults import snd.komelia.ui.common.components.PageSizeSelectionDropdown import snd.komelia.ui.common.itemlist.SeriesLazyCardGrid +import snd.komelia.ui.common.menus.BookMenuActions import snd.komelia.ui.common.menus.SeriesMenuActions import snd.komelia.ui.common.menus.bulk.BottomPopupBulkActionsPanel import snd.komelia.ui.common.menus.bulk.BulkActionsContainer @@ -64,7 +78,13 @@ fun SeriesListContent( onPageSizeChange: (Int) -> Unit, minSize: Dp, + + keepReadingBooks: List = emptyList(), + bookMenuActions: BookMenuActions? = null, + onBookClick: (KomeliaBook) -> Unit = {}, + onBookReadClick: (KomeliaBook, Boolean) -> Unit = { _, _ -> }, ) { + val useNewLibraryUI = LocalUseNewLibraryUI.current Column { if (editMode) { BulkActionsToolbar( @@ -89,15 +109,46 @@ fun SeriesListContent( beforeContent = { AnimatedVisibility(!editMode) { - ToolBar( - seriesTotalCount = seriesTotalCount, - pageSize = pageSize, - onPageSizeChange = onPageSizeChange, - isLoading = isLoading, - filterState = filterState - ) + Column { + if (useNewLibraryUI && keepReadingBooks.isNotEmpty() && bookMenuActions != null) { + LibrarySectionHeader("Keep Reading") + val gridPadding = 10.dp + val density = LocalDensity.current + LazyRow( + modifier = Modifier.layout { measurable, constraints -> + val insetPx = with(density) { gridPadding.roundToPx() } + val placeable = measurable.measure( + constraints.copy(maxWidth = constraints.maxWidth + insetPx * 2) + ) + layout(constraints.maxWidth, placeable.height) { + placeable.place(-insetPx, 0) + } + }, + contentPadding = PaddingValues(horizontal = gridPadding), + horizontalArrangement = Arrangement.spacedBy(7.dp), + ) { + items(keepReadingBooks) { book -> + BookImageCard( + book = book, + onBookClick = { onBookClick(book) }, + onBookReadClick = { onBookReadClick(book, it) }, + bookMenuActions = bookMenuActions, + showSeriesTitle = true, + modifier = Modifier.width(minSize), + ) + } + } + } + if (useNewLibraryUI) LibrarySectionHeader("Browse") + ToolBar( + seriesTotalCount = seriesTotalCount, + pageSize = pageSize, + onPageSizeChange = onPageSizeChange, + isLoading = isLoading, + filterState = filterState + ) + } } - }, minSize = minSize, ) @@ -179,6 +230,7 @@ private fun ToolBar( SuggestionChip( onClick = {}, label = { Text("$seriesTotalCount series") }, + shape = AppSuggestionChipDefaults.shape(), ) Spacer(Modifier.weight(1f)) @@ -199,3 +251,12 @@ private fun ToolBar( } } } + +@Composable +private fun LibrarySectionHeader(label: String) { + Text( + label, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.ExtraBold), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + ) +} diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/view/BooksContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/view/BooksContent.kt index 61ffe6bc..d1bf8e71 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/view/BooksContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/view/BooksContent.kt @@ -4,6 +4,7 @@ import androidx.compose.animation.Animatable import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -47,6 +48,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -279,10 +281,15 @@ private fun BooksToolBar( Box( Modifier - .background( - if (booksLayout == LIST) MaterialTheme.colorScheme.surfaceVariant - else MaterialTheme.colorScheme.surface + .then( + if (booksLayout == LIST) Modifier.border( + Dp.Hairline, + MaterialTheme.colorScheme.outline, + RoundedCornerShape(8.dp) + ) + else Modifier ) + .clip(RoundedCornerShape(8.dp)) .clickable { onBooksLayoutChange(LIST) } .cursorForHand() .padding(10.dp) @@ -295,10 +302,15 @@ private fun BooksToolBar( Box( Modifier - .background( - if (booksLayout == GRID) MaterialTheme.colorScheme.surfaceVariant - else MaterialTheme.colorScheme.surface + .then( + if (booksLayout == GRID) Modifier.border( + Dp.Hairline, + MaterialTheme.colorScheme.outline, + RoundedCornerShape(8.dp) + ) + else Modifier ) + .clip(RoundedCornerShape(8.dp)) .clickable { onBooksLayoutChange(GRID) } .cursorForHand() .padding(10.dp) diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/view/SeriesDescriptionRow.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/view/SeriesDescriptionRow.kt index 02502351..278e982a 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/view/SeriesDescriptionRow.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/series/view/SeriesDescriptionRow.kt @@ -53,6 +53,7 @@ fun SeriesDescriptionRow( deleted: Boolean, alternateTitles: List, onFilterClick: (SeriesScreenFilter) -> Unit, + showReleaseYear: Boolean = true, modifier: Modifier ) { val strings = LocalStrings.current.seriesView @@ -62,8 +63,8 @@ fun SeriesDescriptionRow( horizontalAlignment = Alignment.Start ) { - if (releaseDate != null) - Text("Release Year: ${releaseDate.year}", fontSize = 10.sp) + if (showReleaseYear && releaseDate != null) + Text("Release Year: ${releaseDate.year}", style = MaterialTheme.typography.labelSmall) FlowRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { ElevatedButton( @@ -140,7 +141,7 @@ fun SeriesDescriptionRow( if (alternateTitles.isNotEmpty()) { SelectionContainer { Column { - Text("Alternative titles", fontWeight = FontWeight.Bold) + Text("Alternative titles", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) alternateTitles.forEach { Row { Text( diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/MobileSettingsScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/MobileSettingsScreen.kt index b2e9c22f..27d8fb91 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/MobileSettingsScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/MobileSettingsScreen.kt @@ -71,8 +71,6 @@ class MobileSettingsScreen : Screen { contentColor = MaterialTheme.colorScheme.surface, modifier = Modifier.weight(1f, false) ) - - Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) } } BackPressHandler { currentNavigator.pop() } diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsScreen.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsScreen.kt index e0d5efbc..6f04ab1d 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsScreen.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsScreen.kt @@ -28,9 +28,16 @@ class AppSettingsScreen : Screen { cardWidth = vm.cardWidth, onCardWidthChange = vm::onCardWidthChange, currentTheme = vm.currentTheme, - onThemeChange = vm::onAppThemeChange + onThemeChange = vm::onAppThemeChange, + accentColor = vm.accentColor, + onAccentColorChange = vm::onAccentColorChange, + useNewLibraryUI = vm.useNewLibraryUI, + onUseNewLibraryUIChange = vm::onUseNewLibraryUIChange, + cardLayoutBelow = vm.cardLayoutBelow, + onCardLayoutBelowChange = vm::onCardLayoutBelowChange, ) - } - } - } -} \ No newline at end of file + } + } + } + } + \ No newline at end of file diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsViewModel.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsViewModel.kt index c8933974..bb976078 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsViewModel.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppSettingsViewModel.kt @@ -3,6 +3,8 @@ package snd.komelia.ui.settings.appearance import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.model.StateScreenModel @@ -20,12 +22,20 @@ class AppSettingsViewModel( ) : StateScreenModel>(LoadState.Uninitialized) { var cardWidth by mutableStateOf(defaultCardWidth.dp) var currentTheme by mutableStateOf(AppTheme.DARK) + var accentColor by mutableStateOf(null) + var useNewLibraryUI by mutableStateOf(true) + var cardLayoutBelow by mutableStateOf(false) suspend fun initialize() { if (state.value !is LoadState.Uninitialized) return mutableState.value = LoadState.Loading cardWidth = settingsRepository.getCardWidth().map { it.dp }.first() currentTheme = settingsRepository.getAppTheme().first() + accentColor = settingsRepository.getAccentColor().first()?.let { Color(it.toInt()) } + useNewLibraryUI = settingsRepository.getUseNewLibraryUI().first() + cardLayoutBelow = settingsRepository.getCardLayoutBelow().first() + + settingsRepository.putNavBarColor(null) mutableState.value = LoadState.Success(Unit) } @@ -39,4 +49,19 @@ class AppSettingsViewModel( screenModelScope.launch { settingsRepository.putAppTheme(theme) } } + fun onAccentColorChange(color: Color?) { + this.accentColor = color + screenModelScope.launch { settingsRepository.putAccentColor(color?.toArgb()?.toLong()) } + } + + fun onUseNewLibraryUIChange(enabled: Boolean) { + this.useNewLibraryUI = enabled + screenModelScope.launch { settingsRepository.putUseNewLibraryUI(enabled) } + } + + fun onCardLayoutBelowChange(enabled: Boolean) { + this.cardLayoutBelow = enabled + screenModelScope.launch { settingsRepository.putCardLayoutBelow(enabled) } + } + } \ No newline at end of file diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppearanceSettingsContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppearanceSettingsContent.kt index f4877cfa..00ec20df 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppearanceSettingsContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/settings/appearance/AppearanceSettingsContent.kt @@ -1,42 +1,125 @@ package snd.komelia.ui.settings.appearance +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn -import androidx.compose.material3.Card +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import snd.komelia.settings.model.AppTheme +import snd.komelia.ui.LocalCardLayoutBelow import snd.komelia.ui.LocalStrings +import snd.komelia.ui.common.cards.ItemCard import snd.komelia.ui.common.components.AppSliderDefaults import snd.komelia.ui.common.components.DropdownChoiceMenu import snd.komelia.ui.common.components.LabeledEntry import snd.komelia.ui.platform.cursorForHand import kotlin.math.roundToInt +private val accentPresets: List> = listOf( + null to "Auto", + Color(0xFF800020.toInt()) to "Burgundy", + Color(0xFFE57373.toInt()) to "Muted Red", + Color(0xFF5783D4.toInt()) to "Secondary Blue", + Color(0xFF201F23.toInt()) to "Toolbar (Dark)", + Color(0xFFE1E1E1.toInt()) to "Toolbar (Light)", + Color(0xFF2D3436.toInt()) to "Charcoal", + Color(0xFF1A1A2E.toInt()) to "Navy", + Color(0xFF0D3B46.toInt()) to "D.Teal", + Color(0xFF1B4332.toInt()) to "Forest", + Color(0xFF3D1A78.toInt()) to "Violet", + Color(0xFF3B82F6.toInt()) to "Blue", + Color(0xFF14B8A6.toInt()) to "Teal", + Color(0xFF8B5CF6.toInt()) to "Purple", + Color(0xFFEC4899.toInt()) to "Pink", + Color(0xFFF97316.toInt()) to "Orange", + Color(0xFF22C55E.toInt()) to "Green", +) + @Composable fun AppearanceSettingsContent( cardWidth: Dp, onCardWidthChange: (Dp) -> Unit, currentTheme: AppTheme, onThemeChange: (AppTheme) -> Unit, + accentColor: Color?, + onAccentColorChange: (Color?) -> Unit, + useNewLibraryUI: Boolean, + onUseNewLibraryUIChange: (Boolean) -> Unit, + cardLayoutBelow: Boolean, + onCardLayoutBelowChange: (Boolean) -> Unit, ) { Column( verticalArrangement = Arrangement.spacedBy(10.dp), ) { val strings = LocalStrings.current.settings + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("New library UI", style = MaterialTheme.typography.bodyLarge) + Text( + "Floating nav bar, Keep Reading panel, and pill-shaped tabs", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = useNewLibraryUI, + onCheckedChange = onUseNewLibraryUIChange, + modifier = Modifier.cursorForHand(), + ) + } + + HorizontalDivider() + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Card layout", style = MaterialTheme.typography.bodyLarge) + Text( + "Show title and metadata below the thumbnail instead of on top", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = cardLayoutBelow, + onCheckedChange = onCardLayoutBelowChange, + modifier = Modifier.cursorForHand(), + ) + } + + HorizontalDivider() + DropdownChoiceMenu( label = { Text(strings.appTheme) }, selectedOption = LabeledEntry(currentTheme, strings.forAppTheme(currentTheme)), @@ -45,15 +128,30 @@ fun AppearanceSettingsContent( inputFieldModifier = Modifier.widthIn(min = 250.dp) ) + if (useNewLibraryUI) { + HorizontalDivider() + + DropdownChoiceMenu( + label = { Text("Accent Color (chips & tabs)") }, + selectedOption = accentPresets.find { it.first == accentColor } + ?.let { LabeledEntry(it.first, it.second) }, + options = accentPresets.map { LabeledEntry(it.first, it.second) }, + onOptionChange = { onAccentColorChange(it.value) }, + inputFieldModifier = Modifier.widthIn(min = 250.dp), + selectedOptionContent = { ColorLabel(it) }, + optionContent = { ColorLabel(it) } + ) + } + HorizontalDivider() Text(strings.imageCardSize, modifier = Modifier.padding(10.dp)) Slider( value = cardWidth.value, onValueChange = { onCardWidthChange(it.roundToInt().dp) }, - steps = 19, - valueRange = 150f..350f, - colors = AppSliderDefaults.colors(), + steps = 24, + valueRange = 100f..350f, + colors = AppSliderDefaults.colors(accentColor = accentColor), modifier = Modifier.cursorForHand().padding(end = 20.dp), ) Column( @@ -65,17 +163,74 @@ fun AppearanceSettingsContent( ) { Text("${cardWidth.value}") - Card( - Modifier - .width(cardWidth) - .aspectRatio(0.703f) - ) { - + CompositionLocalProvider(LocalCardLayoutBelow provides cardLayoutBelow) { + ItemCard( + modifier = Modifier.width(cardWidth), + image = { + Box( + Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Text("Thumbnail") + } + }, + content = { + if (cardLayoutBelow) { + Column( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Series Example", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Book Title Example", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + ) } - - } - } +} -} \ No newline at end of file +@Composable +private fun ColorLabel(entry: LabeledEntry) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + val swatchColor = entry.value ?: MaterialTheme.colorScheme.surfaceVariant + Box( + modifier = Modifier + .size(20.dp) + .clip(CircleShape) + .background(swatchColor) + .then( + if (entry.value == null) Modifier.border( + 1.dp, + MaterialTheme.colorScheme.onSurfaceVariant, + CircleShape + ) + else Modifier + ) + ) { + if (entry.value == null) { + Text( + "A", + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.align(Alignment.Center) + ) + } + } + Text(entry.label) + } +} diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/strings/AppStrings.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/strings/AppStrings.kt index 14a26a38..3739e42f 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/strings/AppStrings.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/strings/AppStrings.kt @@ -303,6 +303,7 @@ data class PagedReaderStrings( val layoutDoublePages: String, val layoutDoublePagesNoCover: String, val offsetPages: String, + val adaptiveBackground: String, ) { fun forScaleType(type: LayoutScaleType): String { return when (type) { diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/strings/EnStrings.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/strings/EnStrings.kt index 3f072500..5ad5b503 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/strings/EnStrings.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/strings/EnStrings.kt @@ -176,6 +176,7 @@ val EnStrings = AppStrings( layoutDoublePages = "Double pages", layoutDoublePagesNoCover = "Double pages (no cover)", offsetPages = "Offset pages", + adaptiveBackground = "Adaptive background", ), continuousReader = ContinuousReaderStrings( sidePadding = "Side padding", diff --git a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/topbar/NavigationMenuContent.kt b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/topbar/NavigationMenuContent.kt index e6d8b24d..c9e7a8f6 100644 --- a/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/topbar/NavigationMenuContent.kt +++ b/komelia-ui/src/commonMain/kotlin/snd/komelia/ui/topbar/NavigationMenuContent.kt @@ -49,9 +49,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.screen.Screen +import snd.komelia.ui.LocalAccentColor import snd.komelia.ui.LocalKomgaState import snd.komelia.ui.LocalOfflineMode import snd.komelia.ui.common.menus.LibraryActionsMenu @@ -255,10 +257,17 @@ private fun NavButton( actionButton: (@Composable () -> Unit)? = null, isSelected: Boolean, ) { + val accentColor = LocalAccentColor.current + val (backgroundColor, contentColor) = when { + isSelected && accentColor != null -> accentColor.copy(alpha = 0.15f) to accentColor + isSelected -> MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant + else -> Color.Transparent to MaterialTheme.colorScheme.onSurface + } + TextButton( onClick = onClick, contentPadding = PaddingValues(0.dp), - shape = RoundedCornerShape(10.dp) + shape = RoundedCornerShape(10.dp), ) { Row( horizontalArrangement = Arrangement.Start, @@ -266,16 +275,14 @@ private fun NavButton( modifier = Modifier .fillMaxWidth() .height(40.dp) - .background( - if (isSelected) MaterialTheme.colorScheme.surfaceVariant - else MaterialTheme.colorScheme.surface - ) + .background(backgroundColor) ) { if (icon != null) { Icon( icon, contentDescription = null, + tint = contentColor, modifier = Modifier.padding(10.dp, 0.dp, 20.dp, 0.dp) ) } else { @@ -283,7 +290,7 @@ private fun NavButton( } Column(modifier = Modifier.weight(1.0f)) { - Text(label, style = MaterialTheme.typography.labelLarge) + Text(label, style = MaterialTheme.typography.labelLarge, color = contentColor) if (errorLabel != null) { Text( text = errorLabel,