fix: update integration_test.go to match server.New signature (version, commit args)
All checks were successful
CI / Scraper / Lint (push) Successful in 10s
CI / Scraper / Test (push) Successful in 14s
Release / Scraper / Test (push) Successful in 18s
CI / Scraper / Lint (pull_request) Successful in 18s
Release / UI / Build (push) Successful in 23s
CI / Scraper / Test (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 32s
Release / Scraper / Docker (push) Successful in 55s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 1m5s
Release / UI / Docker (push) Successful in 1m12s
iOS CI / Build (push) Successful in 4m18s
iOS CI / Build (pull_request) Successful in 4m25s
iOS CI / Test (push) Successful in 8m11s
iOS CI / Test (pull_request) Successful in 8m21s
All checks were successful
CI / Scraper / Lint (push) Successful in 10s
CI / Scraper / Test (push) Successful in 14s
Release / Scraper / Test (push) Successful in 18s
CI / Scraper / Lint (pull_request) Successful in 18s
Release / UI / Build (push) Successful in 23s
CI / Scraper / Test (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 32s
Release / Scraper / Docker (push) Successful in 55s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 1m5s
Release / UI / Docker (push) Successful in 1m12s
iOS CI / Build (push) Successful in 4m18s
iOS CI / Build (pull_request) Successful in 4m25s
iOS CI / Test (push) Successful in 8m11s
iOS CI / Test (pull_request) Successful in 8m21s
This commit is contained in:
156
.opencode/skills/ios-ux/SKILL.md
Normal file
156
.opencode/skills/ios-ux/SKILL.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
---
|
||||||
|
name: ios-ux
|
||||||
|
description: iOS/SwiftUI UI & UX review and implementation guidelines for LibNovel. Enforces Apple HIG, iOS 17+ APIs, spring animations, haptics, accessibility, performance, and offline handling. Load this skill for any iOS view work.
|
||||||
|
compatibility: opencode
|
||||||
|
---
|
||||||
|
|
||||||
|
# iOS UI/UX Skill — LibNovel
|
||||||
|
|
||||||
|
Load this skill whenever working on SwiftUI views in `ios/`. It defines design standards, review process for screenshots, and implementation rules.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screenshot Review Process
|
||||||
|
|
||||||
|
When the user provides a screenshot of the app:
|
||||||
|
|
||||||
|
1. **Analyze first** — identify specific UI/UX issues across these categories:
|
||||||
|
- Visual hierarchy and spacing
|
||||||
|
- Typography (size, weight, contrast)
|
||||||
|
- Color and material usage
|
||||||
|
- Animation and interactivity gaps
|
||||||
|
- Accessibility problems
|
||||||
|
- Deprecated or non-native patterns
|
||||||
|
2. **Present a numbered list** of suggested improvements with brief rationale for each.
|
||||||
|
3. **Ask for confirmation** before writing any code: "Should I apply all of these, or only specific ones?"
|
||||||
|
4. Apply only what the user confirms.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design System
|
||||||
|
|
||||||
|
### Colors & Materials
|
||||||
|
- **Accent**: `Color.amber` (project-defined). Use for active state, selection indicators, progress fills, and CTAs.
|
||||||
|
- **Backgrounds**: Prefer `.regularMaterial`, `.ultraThinMaterial`, or `.thinMaterial` over hard-coded `Color.black.opacity(x)` or `Color(.systemBackground)`.
|
||||||
|
- **Dark overlays** (e.g. full-screen players): Use `KFImage` blurred background + `Color.black.opacity(0.5–0.6)` overlay. Never use a flat solid black background.
|
||||||
|
- **Semantic colors**: Use `.primary`, `.secondary`, `.tertiary` foreground styles. Avoid hard-coded `Color.white` except on dark material contexts (full-screen player).
|
||||||
|
- **No hardcoded color literals** — use `Color+App.swift` extensions or system semantic colors.
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
- Use the SF Pro system font via `.font(.title)`, `.font(.body)`, etc. — never hardcode font names except for intentional stylistic accents (e.g. "Snell Roundhand" for voice watermark).
|
||||||
|
- Apply `.fontWeight()` and `.fontDesign()` modifiers rather than custom font families.
|
||||||
|
- Support Dynamic Type — never hardcode a fixed font size as the sole option without a `.minimumScaleFactor` or system font size modifier.
|
||||||
|
- Hierarchy: title3.bold for primary labels, subheadline for secondary, caption/caption2 for metadata.
|
||||||
|
|
||||||
|
### Spacing & Layout
|
||||||
|
- Minimum touch target: **44×44 pt**. Use `.frame(minWidth: 44, minHeight: 44)` or `.contentShape(Rectangle())` on small icons.
|
||||||
|
- Prefer 16–20 pt horizontal padding on full-width containers; 12 pt for compact inner elements.
|
||||||
|
- Use `VStack(spacing:)` and `HStack(spacing:)` explicitly — never rely on default spacing for production UI.
|
||||||
|
- Corner radii: 12–14 pt for cards/chips, 10 pt for small badges, 20–24 pt for large cover art.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Animation Rules
|
||||||
|
|
||||||
|
### Spring Animations (default for all interactive transitions)
|
||||||
|
- Use `.spring(response:dampingFraction:)` for state-driven layout changes, selection feedback, and appear/disappear transitions.
|
||||||
|
- Recommended defaults:
|
||||||
|
- Interactive elements: `response: 0.3, dampingFraction: 0.7`
|
||||||
|
- Entrance animations: `response: 0.45–0.5, dampingFraction: 0.7`
|
||||||
|
- Quick snappy feedback: `response: 0.2, dampingFraction: 0.6`
|
||||||
|
- Reserve `.easeInOut` only for non-interactive, ambient animations (e.g. opacity pulses, generating overlays).
|
||||||
|
|
||||||
|
### SF Symbol Transitions
|
||||||
|
- Always use `contentTransition(.symbolEffect(.replace.downUp))` when a symbol name changes based on state (play/pause, checkmark/circle, etc.).
|
||||||
|
- Use `.symbolEffect(.variableColor.cumulative)` for continuous animations (waveform, loading indicators).
|
||||||
|
- Use `.symbolEffect(.bounce)` for one-shot entrance emphasis (e.g. completion checkmark appearing).
|
||||||
|
- Use `.symbolEffect(.pulse)` for error/warning states that need attention.
|
||||||
|
|
||||||
|
### Repeating Animations
|
||||||
|
- Use `phaseAnimator` for any looping animation that previously used manual `@State` + `withAnimation` chains.
|
||||||
|
- Do not use `Timer` publishers for UI animation — prefer `phaseAnimator` or `TimelineView`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Haptic Feedback
|
||||||
|
|
||||||
|
Add `UIImpactFeedbackGenerator` to every user-initiated interactive control:
|
||||||
|
- `.light` — toggle switches, selection chips, secondary actions, slider drag start.
|
||||||
|
- `.medium` — primary transport buttons (play/pause, chapter skip), significant confirmations.
|
||||||
|
- `.heavy` — destructive actions (only if no confirmation dialog).
|
||||||
|
|
||||||
|
Pattern:
|
||||||
|
```swift
|
||||||
|
Button {
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
// action
|
||||||
|
} label: { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Do **not** add haptics to:
|
||||||
|
- Programmatic state changes not directly triggered by a tap.
|
||||||
|
- Buttons inside `List` rows that already use swipe actions.
|
||||||
|
- Scroll events.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## iOS 17+ API Usage
|
||||||
|
|
||||||
|
Flag and replace any of the following deprecated patterns:
|
||||||
|
|
||||||
|
| Deprecated | Replace with |
|
||||||
|
|---|---|
|
||||||
|
| `NavigationView` | `NavigationStack` |
|
||||||
|
| `@StateObject` / `ObservableObject` (new types only) | `@Observable` macro |
|
||||||
|
| `DispatchQueue.main.async` | `await MainActor.run` or `@MainActor` |
|
||||||
|
| Manual `@State` animation chains for repeating loops | `phaseAnimator` |
|
||||||
|
| `.animation(_:)` without `value:` | `.animation(_:value:)` |
|
||||||
|
| `AnyView` wrapping for conditional content | `@ViewBuilder` + `Group` |
|
||||||
|
|
||||||
|
Do **not** refactor existing `ObservableObject` types to `@Observable` unless explicitly asked — only apply `@Observable` to new types.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
Every view must:
|
||||||
|
- Support VoiceOver: add `.accessibilityLabel()` to icon-only buttons and image views.
|
||||||
|
- Support Dynamic Type: test that text doesn't truncate at xxxLarge without a layout adjustment.
|
||||||
|
- Meet contrast ratio: text on tinted backgrounds must be legible — avoid `.opacity(0.25)` or lower for any user-readable text.
|
||||||
|
- Touch targets ≥ 44pt (see Spacing above).
|
||||||
|
- Interactive controls must have `.accessibilityAddTraits(.isButton)` if not using `Button`.
|
||||||
|
- Do not rely solely on color to convey state — pair color with icon or label.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Isolate high-frequency observers**: Any view that observes a `PlaybackProgress` (timer-tick updates) must be a separate sub-view that `@ObservedObject`-observes only the progress object — not the parent view. This prevents the entire parent from re-rendering every 0.5 seconds.
|
||||||
|
- **Avoid `id()` overuse**: Only use `.id()` to force view recreation when necessary (e.g. background image on track change). Prefer `onChange(of:)` for side effects.
|
||||||
|
- **Lazy containers**: Use `LazyVStack` / `LazyHStack` inside `ScrollView` for lists of 20+ items. `List` is inherently lazy and does not need this.
|
||||||
|
- **Image loading**: Always use `KFImage` (Kingfisher) with `.placeholder` for remote images. Never use `AsyncImage` for cover art — it has no disk cache.
|
||||||
|
- **Avoid `AnyView`**: It breaks structural identity and hurts diffing. Use `@ViewBuilder` or `Group { }` instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Offline & Error States
|
||||||
|
|
||||||
|
Every view that makes network calls must:
|
||||||
|
1. Wrap the body in a `VStack` with `OfflineBanner` at the top, gated on `networkMonitor.isConnected`.
|
||||||
|
2. Suppress network errors silently when offline via `ErrorAlertModifier` — do not show an alert when the device is offline.
|
||||||
|
3. Gate `.task` / `.onAppear` network calls: `guard networkMonitor.isConnected else { return }`.
|
||||||
|
4. Show a non-blocking inline empty state (not a full-screen error) for failed loads when online.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Checklist (before submitting any view change)
|
||||||
|
|
||||||
|
- [ ] All interactive elements ≥ 44pt touch target
|
||||||
|
- [ ] SF Symbol state changes use `contentTransition(.symbolEffect(...))`
|
||||||
|
- [ ] State-driven layout transitions use `.spring(response:dampingFraction:)`
|
||||||
|
- [ ] Tappable controls have haptic feedback
|
||||||
|
- [ ] No `NavigationView`, no `DispatchQueue.main.async`, no `.animation(_:)` without `value:`
|
||||||
|
- [ ] High-frequency observers are isolated sub-views
|
||||||
|
- [ ] Offline state handled with `OfflineBanner` + `NetworkMonitor`
|
||||||
|
- [ ] VoiceOver labels on icon-only buttons
|
||||||
|
- [ ] No hardcoded `Color.black` / `Color.white` / `Color(.systemBackground)` where a material applies
|
||||||
11
AGENTS.md
11
AGENTS.md
@@ -169,3 +169,14 @@ Kokoro and Browserless are **external services** — not in docker-compose.
|
|||||||
- **To add a new API endpoint**: add handler in the appropriate `handlers_*.go` file, register in `server.go` `ListenAndServe()`
|
- **To add a new API endpoint**: add handler in the appropriate `handlers_*.go` file, register in `server.go` `ListenAndServe()`
|
||||||
- **Storage changes**: update `Store` interface in `store.go`, implement on `HybridStore` (hybrid.go) and `PocketBaseStore`/`MinioClient` as needed; update mock in `orchestrator_test.go`
|
- **Storage changes**: update `Store` interface in `store.go`, implement on `HybridStore` (hybrid.go) and `PocketBaseStore`/`MinioClient` as needed; update mock in `orchestrator_test.go`
|
||||||
- **Skip**: `scraper/bin/` (compiled binary), MinIO/PocketBase data volumes
|
- **Skip**: `scraper/bin/` (compiled binary), MinIO/PocketBase data volumes
|
||||||
|
|
||||||
|
## iOS App
|
||||||
|
|
||||||
|
See `ios/AGENTS.md` for full iOS/SwiftUI conventions.
|
||||||
|
|
||||||
|
## Documentation Tools
|
||||||
|
|
||||||
|
This project has two MCP-backed documentation tools available. Use them proactively:
|
||||||
|
|
||||||
|
- **`context7`** — Live Apple SwiftUI/Swift docs, Go stdlib, SvelteKit, and any other library docs. Use before implementing anything non-trivial in Swift/SwiftUI. Example: `use context7 to look up NavigationStack`.
|
||||||
|
- **`gh_grep`** — Search real-world code on GitHub for implementation patterns. Example: `use gh_grep to find examples of background URLSession in Swift`.
|
||||||
|
|||||||
87
ios/AGENTS.md
Normal file
87
ios/AGENTS.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# LibNovel iOS App
|
||||||
|
|
||||||
|
SwiftUI app targeting iOS 17+. Consumes the Go scraper HTTP API for books, chapters, and audio. Uses MinIO presigned URLs for media playback and downloads.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
ios/LibNovel/LibNovel/
|
||||||
|
├── App/ # LibNovelApp.swift, ContentView.swift, RootTabView.swift
|
||||||
|
├── Models/ # Models.swift (all domain types)
|
||||||
|
├── Networking/ # APIClient.swift (URLSession-based HTTP client)
|
||||||
|
├── Services/ # AudioPlayerService, AudioDownloadService, AuthStore,
|
||||||
|
│ # BookVoicePreferences, NetworkMonitor
|
||||||
|
├── ViewModels/ # One per view/feature (HomeViewModel, BrowseViewModel, etc.)
|
||||||
|
├── Views/
|
||||||
|
│ ├── Auth/ # AuthView
|
||||||
|
│ ├── BookDetail/ # BookDetailView, CommentsView
|
||||||
|
│ ├── Browse/ # BrowseView (infinite scroll shelves)
|
||||||
|
│ ├── ChapterReader/ # ChapterReaderView, DownloadAudioButton
|
||||||
|
│ ├── Common/ # CommonViews (shared reusable components)
|
||||||
|
│ ├── Components/ # OfflineBanner
|
||||||
|
│ ├── Downloads/ # DownloadsView, DownloadQueueButton
|
||||||
|
│ ├── Home/ # HomeView
|
||||||
|
│ ├── Library/ # LibraryView (2-col grid, filters)
|
||||||
|
│ ├── Player/ # PlayerViews (floating FAB, compact, full-screen)
|
||||||
|
│ ├── Profile/ # ProfileView, VoiceSelectionView, UserProfileView, etc.
|
||||||
|
│ └── Search/ # SearchView
|
||||||
|
└── Extensions/ # NavDestination.swift, String+App.swift, Color+App.swift
|
||||||
|
```
|
||||||
|
|
||||||
|
## iOS / Swift Conventions
|
||||||
|
|
||||||
|
- **Deployment target**: iOS 17.0 — use iOS 17+ APIs freely.
|
||||||
|
- **Observable pattern**: The codebase currently uses `@StateObject` / `ObservableObject` / `@Published`. When adding new types, prefer the **`@Observable` macro** (iOS 17+) over `ObservableObject`. Do not refactor existing types unless explicitly asked.
|
||||||
|
- **Navigation**: Use `NavigationStack` (not `NavigationView`). Use `.navigationDestination(for:)` for type-safe routing.
|
||||||
|
- **Concurrency**: Use `async/await` and structured concurrency. Avoid callback-based APIs and `DispatchQueue.main.async` — prefer `@MainActor` or `await MainActor.run`.
|
||||||
|
- **State management**: Prefer `@State` + `@Binding` for local UI state. Use environment objects for app-wide services (authStore, audioPlayer, downloadService, networkMonitor).
|
||||||
|
- **SwiftData**: Not currently used. Do not introduce SwiftData without discussion.
|
||||||
|
- **SF Symbols**: Use `Image(systemName:)` for icons. No emoji in UI unless already present.
|
||||||
|
|
||||||
|
## Key Patterns
|
||||||
|
|
||||||
|
- **Download keys**: Use `::` as separator (e.g., `"slug::chapter-1::voice"`), never `-`. Slugs contain hyphens.
|
||||||
|
- **Voice fallback chain**: book override → global default → `"af_bella"`. See `BookVoicePreferences.voiceWithFallback()`.
|
||||||
|
- **Offline handling**: Wrap view bodies in `VStack` with `OfflineBanner` at top. Use `NetworkMonitor` (environment object) to gate network calls. Suppress network errors silently when offline via `ErrorAlertModifier`.
|
||||||
|
- **Audio playback priority**: local file → MinIO presigned URL → trigger TTS generation.
|
||||||
|
- **Progress display**: Show decimal % when < 10% (e.g., "3.4%"), rounded when >= 10% (e.g., "47%").
|
||||||
|
- **Cover images**: Always proxy via `/api/cover/{domain}/{slug}` — never link directly to source.
|
||||||
|
|
||||||
|
## Networking
|
||||||
|
|
||||||
|
`APIClient.swift` wraps all Go scraper API calls. When adding new endpoints:
|
||||||
|
|
||||||
|
1. Add a method to `APIClient`.
|
||||||
|
2. Keep error handling consistent — throw typed errors, let ViewModels catch and set `errorMessage`.
|
||||||
|
3. All requests are relative to `SCRAPER_API_URL` (configured at build time via xcconfig or environment).
|
||||||
|
|
||||||
|
## Using Documentation Tools
|
||||||
|
|
||||||
|
When writing or reviewing SwiftUI/Swift code:
|
||||||
|
|
||||||
|
- Use `context7` to look up current Apple SwiftUI/Swift documentation before implementing anything non-trivial. Apple's APIs evolve fast — do not rely on training data alone.
|
||||||
|
- Use `gh_grep` to find real-world Swift patterns when unsure how something is typically implemented.
|
||||||
|
|
||||||
|
Example prompts:
|
||||||
|
- "How does `.searchable` work in iOS 17? use context7"
|
||||||
|
- "Show me examples of `@Observable` with async tasks. use context7"
|
||||||
|
- "How do other apps implement background URLSession downloads in Swift? use gh_grep"
|
||||||
|
|
||||||
|
## UI/UX Skill
|
||||||
|
|
||||||
|
For any iOS view work, always load the `ios-ux` skill at the start of the task:
|
||||||
|
|
||||||
|
```
|
||||||
|
skill({ name: "ios-ux" })
|
||||||
|
```
|
||||||
|
|
||||||
|
This skill defines the full design system, animation rules, haptic feedback policy, accessibility checklist, performance guidelines, and offline handling requirements. It also governs how to handle screenshot-based reviews (analyze → suggest → confirm before applying).
|
||||||
|
|
||||||
|
## What to Avoid
|
||||||
|
|
||||||
|
- `NavigationView` — deprecated, use `NavigationStack`
|
||||||
|
- `ObservableObject` / `@Published` for new types — prefer `@Observable`
|
||||||
|
- `DispatchQueue.main.async` — prefer `@MainActor`
|
||||||
|
- Force unwrapping optionals
|
||||||
|
- Hardcoded color literals — use `Color+App.swift` extensions or semantic colors
|
||||||
|
- Adding new dependencies (SPM packages) without discussion
|
||||||
@@ -8,8 +8,13 @@
|
|||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
032E049A4BB3CF0EA990C0CD /* LibNovelApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F56C8E2BC3614530B81569D /* LibNovelApp.swift */; };
|
032E049A4BB3CF0EA990C0CD /* LibNovelApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F56C8E2BC3614530B81569D /* LibNovelApp.swift */; };
|
||||||
|
07FC69FB9DF3F6073564E489 /* DiscoverViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA9111BF29C75E8D60FCEDF6 /* DiscoverViewModel.swift */; };
|
||||||
08DFB5F626BA769556C8D145 /* BrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA3F0FCA383180EE4C93BBA /* BrowseView.swift */; };
|
08DFB5F626BA769556C8D145 /* BrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA3F0FCA383180EE4C93BBA /* BrowseView.swift */; };
|
||||||
0A52BC1CE71BED9E75D20D35 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 762E378B9BC2161A7AA2CC36 /* Models.swift */; };
|
0A52BC1CE71BED9E75D20D35 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 762E378B9BC2161A7AA2CC36 /* Models.swift */; };
|
||||||
|
0B40E3DCE82EBEA7C4ECF148 /* AvatarCropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 775B5C22D6215D7A7C412E13 /* AvatarCropView.swift */; };
|
||||||
|
192F82518CB8763775E33B38 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79133D9FA697D1909C8D3973 /* SearchView.swift */; };
|
||||||
|
1945DD2D0DF497FE66FAAF90 /* BookVoicePreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C0022D98CDAD0B11840AAAC /* BookVoicePreferences.swift */; };
|
||||||
|
1964D61094D4731227384F3A /* VoiceSelectionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB2489CA141D5E19373D0936 /* VoiceSelectionViewModel.swift */; };
|
||||||
2790B8C051BE389D83645047 /* BrowseViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */; };
|
2790B8C051BE389D83645047 /* BrowseViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */; };
|
||||||
2A15157AD2AE2271675C3485 /* ChapterReaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */; };
|
2A15157AD2AE2271675C3485 /* ChapterReaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */; };
|
||||||
3521DFD5FCBBED7B90368829 /* LibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC338B05EA6DB22900712000 /* LibraryViewModel.swift */; };
|
3521DFD5FCBBED7B90368829 /* LibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC338B05EA6DB22900712000 /* LibraryViewModel.swift */; };
|
||||||
@@ -18,24 +23,29 @@
|
|||||||
4BB2C76262D5BD5DAD0D5D28 /* LibNovelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C918833E173D6B44D06955 /* LibNovelTests.swift */; };
|
4BB2C76262D5BD5DAD0D5D28 /* LibNovelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C918833E173D6B44D06955 /* LibNovelTests.swift */; };
|
||||||
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */; };
|
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */; };
|
||||||
5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F219788AE5ACBD6F240674F5 /* AuthStore.swift */; };
|
5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F219788AE5ACBD6F240674F5 /* AuthStore.swift */; };
|
||||||
|
5F7409635F6563E44C836390 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA1B6D9FF31780095F5ACA8 /* NetworkMonitor.swift */; };
|
||||||
|
62B42DB777F53856C57CB6AF /* OfflineBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = F082F99F2EE05BD98C9EF2AA /* OfflineBanner.swift */; };
|
||||||
64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B17D50389C6C98FC78BDBC /* ProfileView.swift */; };
|
64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B17D50389C6C98FC78BDBC /* ProfileView.swift */; };
|
||||||
|
65CA672C02F367F72F18F8B8 /* AudioDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94730324A6BD9D6A772286BB /* AudioDownloadService.swift */; };
|
||||||
749292A18C57FA41EC88A30B /* BookDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39DE056C37FBC5EED8771821 /* BookDetailView.swift */; };
|
749292A18C57FA41EC88A30B /* BookDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39DE056C37FBC5EED8771821 /* BookDetailView.swift */; };
|
||||||
|
774CFCDA8A13311DF85FF051 /* DownloadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8175390266E8C6CF1437A229 /* DownloadsView.swift */; };
|
||||||
7C74C10317D389121922A5E3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5A776719B77EDDB5E44743B0 /* Assets.xcassets */; };
|
7C74C10317D389121922A5E3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5A776719B77EDDB5E44743B0 /* Assets.xcassets */; };
|
||||||
7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */; };
|
7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */; };
|
||||||
|
880D411C936F7BA92AF83383 /* DownloadQueueButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16ECDDD02E6A2F8562111538 /* DownloadQueueButton.swift */; };
|
||||||
|
8B02625CA1B93118B63E9C9D /* VoiceSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A75E148A48D47A5B37CA7FB3 /* VoiceSelectionView.swift */; };
|
||||||
|
9407F80F454D0248D5C779A6 /* UserProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10777FC4816A7067AF9C4797 /* UserProfileViewModel.swift */; };
|
||||||
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B820081FA4817765A39939A /* ContentView.swift */; };
|
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B820081FA4817765A39939A /* ContentView.swift */; };
|
||||||
9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEF6782A2A28B2A485CBD48 /* AuthView.swift */; };
|
9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEF6782A2A28B2A485CBD48 /* AuthView.swift */; };
|
||||||
A1C3F2B84D9E72A1BC054F17 /* CommentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2E8D1C74A3F91D0E5C72A38 /* CommentsView.swift */; };
|
9C19B17E746FE6A834E53AF3 /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F247DE25991F4DB98DF717AA /* UserProfileView.swift */; };
|
||||||
A2F1C3B84E9D71A0BC164F28 /* AccountMenuSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E9D2C85A4F02E1F63B5A49 /* AccountMenuSheet.swift */; };
|
A7485E99B9ACBCBCCD1EB7B2 /* CommentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16B9AFE90719BDBC718F0621 /* CommentsView.swift */; };
|
||||||
A9B95BAD7CE2DCD1DDDABD4C /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB13E89E50529E3081533A66 /* AudioPlayerService.swift */; };
|
A9B95BAD7CE2DCD1DDDABD4C /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB13E89E50529E3081533A66 /* AudioPlayerService.swift */; };
|
||||||
AA11BB22CC33DD44EE55FF66 /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF67 /* UserProfileView.swift */; };
|
|
||||||
BB22CC33DD44EE55FF66AA11 /* UserProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB22CC33DD44EE55FF66AA12 /* UserProfileViewModel.swift */; };
|
|
||||||
BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */; };
|
BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */; };
|
||||||
C3D7A2E15F8B04C9AB163D50 /* AvatarCropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */; };
|
|
||||||
C807AD8D627CF6BED47D517C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AB2E843D93461074A89A171 /* HomeViewModel.swift */; };
|
C807AD8D627CF6BED47D517C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AB2E843D93461074A89A171 /* HomeViewModel.swift */; };
|
||||||
CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 09584EAB68A07B47F876A062 /* Kingfisher */; };
|
CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 09584EAB68A07B47F876A062 /* Kingfisher */; };
|
||||||
D5E2A1C96F3B08D0F74C6B50 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F0A3B75E2D19C0E85A7B61 /* SearchView.swift */; };
|
DFA7EB1B0BD53F68FE1335C8 /* DownloadAudioButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35942111986E54CC0E83A391 /* DownloadAudioButton.swift */; };
|
||||||
E1F564399D1325F6A1B2B84F /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C21107BECA55C07416E0CB8B /* LibraryView.swift */; };
|
E1F564399D1325F6A1B2B84F /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C21107BECA55C07416E0CB8B /* LibraryView.swift */; };
|
||||||
E2572692178FD17145FDAF77 /* Color+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D83BB88C4306BE7A4F947CB /* Color+App.swift */; };
|
E2572692178FD17145FDAF77 /* Color+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D83BB88C4306BE7A4F947CB /* Color+App.swift */; };
|
||||||
|
ED54860A709FED5A8CBF4EEB /* AccountMenuSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD554706F61FE3DC061189F /* AccountMenuSheet.swift */; };
|
||||||
EF3C57C400BF05CBEAC1F7FE /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6268D60803940CBD38FB921 /* HomeView.swift */; };
|
EF3C57C400BF05CBEAC1F7FE /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6268D60803940CBD38FB921 /* HomeView.swift */; };
|
||||||
F2AF05B9C8C23132A73ACDD3 /* CommonViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E89FD8F46747CA653C5203D /* CommonViews.swift */; };
|
F2AF05B9C8C23132A73ACDD3 /* CommonViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E89FD8F46747CA653C5203D /* CommonViews.swift */; };
|
||||||
F4FDA3C44752EB979235C042 /* NavDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CAFB96D2500F34F0B0C860C /* NavDestination.swift */; };
|
F4FDA3C44752EB979235C042 /* NavDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CAFB96D2500F34F0B0C860C /* NavDestination.swift */; };
|
||||||
@@ -54,39 +64,49 @@
|
|||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
10777FC4816A7067AF9C4797 /* UserProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
16B9AFE90719BDBC718F0621 /* CommentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsView.swift; sourceTree = "<group>"; };
|
||||||
|
16ECDDD02E6A2F8562111538 /* DownloadQueueButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadQueueButton.swift; sourceTree = "<group>"; };
|
||||||
1B8BF3DB582A658386E402C7 /* LibNovel.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LibNovel.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
1B8BF3DB582A658386E402C7 /* LibNovel.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LibNovel.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
1C0022D98CDAD0B11840AAAC /* BookVoicePreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookVoicePreferences.swift; sourceTree = "<group>"; };
|
||||||
|
1FA1B6D9FF31780095F5ACA8 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = "<group>"; };
|
||||||
1FA3F0FCA383180EE4C93BBA /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = "<group>"; };
|
1FA3F0FCA383180EE4C93BBA /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = "<group>"; };
|
||||||
235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LibNovelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = LibNovelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
2D5C115992F1CE2326236765 /* RootTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootTabView.swift; sourceTree = "<group>"; };
|
2D5C115992F1CE2326236765 /* RootTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootTabView.swift; sourceTree = "<group>"; };
|
||||||
|
35942111986E54CC0E83A391 /* DownloadAudioButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadAudioButton.swift; sourceTree = "<group>"; };
|
||||||
39DE056C37FBC5EED8771821 /* BookDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailView.swift; sourceTree = "<group>"; };
|
39DE056C37FBC5EED8771821 /* BookDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailView.swift; sourceTree = "<group>"; };
|
||||||
3AB2E843D93461074A89A171 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
|
3AB2E843D93461074A89A171 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
|
||||||
4B820081FA4817765A39939A /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
4B820081FA4817765A39939A /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||||
4F56C8E2BC3614530B81569D /* LibNovelApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelApp.swift; sourceTree = "<group>"; };
|
4F56C8E2BC3614530B81569D /* LibNovelApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelApp.swift; sourceTree = "<group>"; };
|
||||||
5A776719B77EDDB5E44743B0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
5A776719B77EDDB5E44743B0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
762E378B9BC2161A7AA2CC36 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
|
762E378B9BC2161A7AA2CC36 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
|
||||||
|
775B5C22D6215D7A7C412E13 /* AvatarCropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarCropView.swift; sourceTree = "<group>"; };
|
||||||
|
79133D9FA697D1909C8D3973 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
|
||||||
7CAFB96D2500F34F0B0C860C /* NavDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavDestination.swift; sourceTree = "<group>"; };
|
7CAFB96D2500F34F0B0C860C /* NavDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavDestination.swift; sourceTree = "<group>"; };
|
||||||
7CEF6782A2A28B2A485CBD48 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = "<group>"; };
|
7CEF6782A2A28B2A485CBD48 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = "<group>"; };
|
||||||
|
8175390266E8C6CF1437A229 /* DownloadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsView.swift; sourceTree = "<group>"; };
|
||||||
81E3939152E23B4985FAF7E2 /* ChapterReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterReaderView.swift; sourceTree = "<group>"; };
|
81E3939152E23B4985FAF7E2 /* ChapterReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterReaderView.swift; sourceTree = "<group>"; };
|
||||||
837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailViewModel.swift; sourceTree = "<group>"; };
|
837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailViewModel.swift; sourceTree = "<group>"; };
|
||||||
8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterReaderViewModel.swift; sourceTree = "<group>"; };
|
8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterReaderViewModel.swift; sourceTree = "<group>"; };
|
||||||
8E89FD8F46747CA653C5203D /* CommonViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonViews.swift; sourceTree = "<group>"; };
|
8E89FD8F46747CA653C5203D /* CommonViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonViews.swift; sourceTree = "<group>"; };
|
||||||
937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = "<group>"; };
|
937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
94730324A6BD9D6A772286BB /* AudioDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioDownloadService.swift; sourceTree = "<group>"; };
|
||||||
9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseViewModel.swift; sourceTree = "<group>"; };
|
9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseViewModel.swift; sourceTree = "<group>"; };
|
||||||
9D83BB88C4306BE7A4F947CB /* Color+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+App.swift"; sourceTree = "<group>"; };
|
9D83BB88C4306BE7A4F947CB /* Color+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+App.swift"; sourceTree = "<group>"; };
|
||||||
AA11BB22CC33DD44EE55FF67 /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = "<group>"; };
|
A75E148A48D47A5B37CA7FB3 /* VoiceSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSelectionView.swift; sourceTree = "<group>"; };
|
||||||
B2E8D1C74A3F91D0E5C72A38 /* CommentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsView.swift; sourceTree = "<group>"; };
|
AA9111BF29C75E8D60FCEDF6 /* DiscoverViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoverViewModel.swift; sourceTree = "<group>"; };
|
||||||
B3E9D2C85A4F02E1F63B5A49 /* AccountMenuSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountMenuSheet.swift; sourceTree = "<group>"; };
|
AAD554706F61FE3DC061189F /* AccountMenuSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountMenuSheet.swift; sourceTree = "<group>"; };
|
||||||
B4C918833E173D6B44D06955 /* LibNovelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelTests.swift; sourceTree = "<group>"; };
|
B4C918833E173D6B44D06955 /* LibNovelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelTests.swift; sourceTree = "<group>"; };
|
||||||
B593F179EC3E9112126B540B /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
|
B593F179EC3E9112126B540B /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
|
||||||
BB22CC33DD44EE55FF66AA12 /* UserProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileViewModel.swift; sourceTree = "<group>"; };
|
|
||||||
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; };
|
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; };
|
||||||
C21107BECA55C07416E0CB8B /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = "<group>"; };
|
C21107BECA55C07416E0CB8B /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = "<group>"; };
|
||||||
C4F0A3B75E2D19C0E85A7B61 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
|
CB2489CA141D5E19373D0936 /* VoiceSelectionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSelectionViewModel.swift; sourceTree = "<group>"; };
|
||||||
D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarCropView.swift; sourceTree = "<group>"; };
|
|
||||||
D6268D60803940CBD38FB921 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
|
D6268D60803940CBD38FB921 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
|
||||||
DB13E89E50529E3081533A66 /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = "<group>"; };
|
DB13E89E50529E3081533A66 /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = "<group>"; };
|
||||||
DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViews.swift; sourceTree = "<group>"; };
|
DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViews.swift; sourceTree = "<group>"; };
|
||||||
|
F082F99F2EE05BD98C9EF2AA /* OfflineBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineBanner.swift; sourceTree = "<group>"; };
|
||||||
F219788AE5ACBD6F240674F5 /* AuthStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStore.swift; sourceTree = "<group>"; };
|
F219788AE5ACBD6F240674F5 /* AuthStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStore.swift; sourceTree = "<group>"; };
|
||||||
|
F247DE25991F4DB98DF717AA /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = "<group>"; };
|
||||||
FC338B05EA6DB22900712000 /* LibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModel.swift; sourceTree = "<group>"; };
|
FC338B05EA6DB22900712000 /* LibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModel.swift; sourceTree = "<group>"; };
|
||||||
FEC6F837FF2E902E334ED72E /* String+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+App.swift"; sourceTree = "<group>"; };
|
FEC6F837FF2E902E334ED72E /* String+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+App.swift"; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
@@ -119,11 +139,13 @@
|
|||||||
8E8AAA58A33084ADB8AEA80C /* Browse */,
|
8E8AAA58A33084ADB8AEA80C /* Browse */,
|
||||||
4EAB87A1ED4943A311F26F84 /* ChapterReader */,
|
4EAB87A1ED4943A311F26F84 /* ChapterReader */,
|
||||||
5D5809803A3D74FAE19DB218 /* Common */,
|
5D5809803A3D74FAE19DB218 /* Common */,
|
||||||
|
9180FAFE96724B8AACFA9859 /* Components */,
|
||||||
|
3881CBFE9730C6422BE6F03D /* Downloads */,
|
||||||
811FC0F6B9C209D6EC8543BD /* Home */,
|
811FC0F6B9C209D6EC8543BD /* Home */,
|
||||||
FA994FD601E79EC811D822A4 /* Library */,
|
FA994FD601E79EC811D822A4 /* Library */,
|
||||||
89F2CB14192E7D7565A588E0 /* Player */,
|
89F2CB14192E7D7565A588E0 /* Player */,
|
||||||
3DB66C5703A4CCAFFA1B7AFE /* Profile */,
|
3DB66C5703A4CCAFFA1B7AFE /* Profile */,
|
||||||
E6A2B4C07F1D38E0A95B3C72 /* Search */,
|
474BE4FC0353C2DD8D8425D1 /* Search */,
|
||||||
);
|
);
|
||||||
path = Views;
|
path = Views;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -136,13 +158,23 @@
|
|||||||
path = Auth;
|
path = Auth;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
3881CBFE9730C6422BE6F03D /* Downloads */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
16ECDDD02E6A2F8562111538 /* DownloadQueueButton.swift */,
|
||||||
|
8175390266E8C6CF1437A229 /* DownloadsView.swift */,
|
||||||
|
);
|
||||||
|
path = Downloads;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
3DB66C5703A4CCAFFA1B7AFE /* Profile */ = {
|
3DB66C5703A4CCAFFA1B7AFE /* Profile */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
AAD554706F61FE3DC061189F /* AccountMenuSheet.swift */,
|
||||||
|
775B5C22D6215D7A7C412E13 /* AvatarCropView.swift */,
|
||||||
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */,
|
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */,
|
||||||
D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */,
|
F247DE25991F4DB98DF717AA /* UserProfileView.swift */,
|
||||||
B3E9D2C85A4F02E1F63B5A49 /* AccountMenuSheet.swift */,
|
A75E148A48D47A5B37CA7FB3 /* VoiceSelectionView.swift */,
|
||||||
AA11BB22CC33DD44EE55FF67 /* UserProfileView.swift */,
|
|
||||||
);
|
);
|
||||||
path = Profile;
|
path = Profile;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -155,10 +187,19 @@
|
|||||||
path = Networking;
|
path = Networking;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
474BE4FC0353C2DD8D8425D1 /* Search */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
79133D9FA697D1909C8D3973 /* SearchView.swift */,
|
||||||
|
);
|
||||||
|
path = Search;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
4EAB87A1ED4943A311F26F84 /* ChapterReader */ = {
|
4EAB87A1ED4943A311F26F84 /* ChapterReader */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
81E3939152E23B4985FAF7E2 /* ChapterReaderView.swift */,
|
81E3939152E23B4985FAF7E2 /* ChapterReaderView.swift */,
|
||||||
|
35942111986E54CC0E83A391 /* DownloadAudioButton.swift */,
|
||||||
);
|
);
|
||||||
path = ChapterReader;
|
path = ChapterReader;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -220,6 +261,14 @@
|
|||||||
path = Browse;
|
path = Browse;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
9180FAFE96724B8AACFA9859 /* Components */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
F082F99F2EE05BD98C9EF2AA /* OfflineBanner.swift */,
|
||||||
|
);
|
||||||
|
path = Components;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
9AF55E5D62F980C72431782A = {
|
9AF55E5D62F980C72431782A = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -253,10 +302,12 @@
|
|||||||
837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */,
|
837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */,
|
||||||
9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */,
|
9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */,
|
||||||
8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */,
|
8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */,
|
||||||
|
AA9111BF29C75E8D60FCEDF6 /* DiscoverViewModel.swift */,
|
||||||
3AB2E843D93461074A89A171 /* HomeViewModel.swift */,
|
3AB2E843D93461074A89A171 /* HomeViewModel.swift */,
|
||||||
FC338B05EA6DB22900712000 /* LibraryViewModel.swift */,
|
FC338B05EA6DB22900712000 /* LibraryViewModel.swift */,
|
||||||
937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */,
|
937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */,
|
||||||
BB22CC33DD44EE55FF66AA12 /* UserProfileViewModel.swift */,
|
10777FC4816A7067AF9C4797 /* UserProfileViewModel.swift */,
|
||||||
|
CB2489CA141D5E19373D0936 /* VoiceSelectionViewModel.swift */,
|
||||||
);
|
);
|
||||||
path = ViewModels;
|
path = ViewModels;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -264,20 +315,15 @@
|
|||||||
DA6F6F625578875F3E74F1D3 /* Services */ = {
|
DA6F6F625578875F3E74F1D3 /* Services */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
94730324A6BD9D6A772286BB /* AudioDownloadService.swift */,
|
||||||
DB13E89E50529E3081533A66 /* AudioPlayerService.swift */,
|
DB13E89E50529E3081533A66 /* AudioPlayerService.swift */,
|
||||||
F219788AE5ACBD6F240674F5 /* AuthStore.swift */,
|
F219788AE5ACBD6F240674F5 /* AuthStore.swift */,
|
||||||
|
1C0022D98CDAD0B11840AAAC /* BookVoicePreferences.swift */,
|
||||||
|
1FA1B6D9FF31780095F5ACA8 /* NetworkMonitor.swift */,
|
||||||
);
|
);
|
||||||
path = Services;
|
path = Services;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
E6A2B4C07F1D38E0A95B3C72 /* Search */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
C4F0A3B75E2D19C0E85A7B61 /* SearchView.swift */,
|
|
||||||
);
|
|
||||||
path = Search;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
FA994FD601E79EC811D822A4 /* Library */ = {
|
FA994FD601E79EC811D822A4 /* Library */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -290,7 +336,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
39DE056C37FBC5EED8771821 /* BookDetailView.swift */,
|
39DE056C37FBC5EED8771821 /* BookDetailView.swift */,
|
||||||
B2E8D1C74A3F91D0E5C72A38 /* CommentsView.swift */,
|
16B9AFE90719BDBC718F0621 /* CommentsView.swift */,
|
||||||
);
|
);
|
||||||
path = BookDetail;
|
path = BookDetail;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -363,7 +409,7 @@
|
|||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = YES;
|
BuildIndependentTargetsInParallel = YES;
|
||||||
LastUpgradeCheck = 2630;
|
LastUpgradeCheck = 1600;
|
||||||
};
|
};
|
||||||
buildConfigurationList = D27899EE96A9AFCBBE62EA3C /* Build configuration list for PBXProject "LibNovel" */;
|
buildConfigurationList = D27899EE96A9AFCBBE62EA3C /* Build configuration list for PBXProject "LibNovel" */;
|
||||||
developmentRegion = en;
|
developmentRegion = en;
|
||||||
@@ -413,19 +459,27 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
FB32F3772CA09684F00497F3 /* APIClient.swift in Sources */,
|
FB32F3772CA09684F00497F3 /* APIClient.swift in Sources */,
|
||||||
|
ED54860A709FED5A8CBF4EEB /* AccountMenuSheet.swift in Sources */,
|
||||||
|
65CA672C02F367F72F18F8B8 /* AudioDownloadService.swift in Sources */,
|
||||||
A9B95BAD7CE2DCD1DDDABD4C /* AudioPlayerService.swift in Sources */,
|
A9B95BAD7CE2DCD1DDDABD4C /* AudioPlayerService.swift in Sources */,
|
||||||
5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */,
|
5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */,
|
||||||
9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */,
|
9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */,
|
||||||
|
0B40E3DCE82EBEA7C4ECF148 /* AvatarCropView.swift in Sources */,
|
||||||
749292A18C57FA41EC88A30B /* BookDetailView.swift in Sources */,
|
749292A18C57FA41EC88A30B /* BookDetailView.swift in Sources */,
|
||||||
A1C3F2B84D9E72A1BC054F17 /* CommentsView.swift in Sources */,
|
|
||||||
7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */,
|
7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */,
|
||||||
|
1945DD2D0DF497FE66FAAF90 /* BookVoicePreferences.swift in Sources */,
|
||||||
08DFB5F626BA769556C8D145 /* BrowseView.swift in Sources */,
|
08DFB5F626BA769556C8D145 /* BrowseView.swift in Sources */,
|
||||||
2790B8C051BE389D83645047 /* BrowseViewModel.swift in Sources */,
|
2790B8C051BE389D83645047 /* BrowseViewModel.swift in Sources */,
|
||||||
FEFB5FDC2424D22914458001 /* ChapterReaderView.swift in Sources */,
|
FEFB5FDC2424D22914458001 /* ChapterReaderView.swift in Sources */,
|
||||||
2A15157AD2AE2271675C3485 /* ChapterReaderViewModel.swift in Sources */,
|
2A15157AD2AE2271675C3485 /* ChapterReaderViewModel.swift in Sources */,
|
||||||
E2572692178FD17145FDAF77 /* Color+App.swift in Sources */,
|
E2572692178FD17145FDAF77 /* Color+App.swift in Sources */,
|
||||||
|
A7485E99B9ACBCBCCD1EB7B2 /* CommentsView.swift in Sources */,
|
||||||
F2AF05B9C8C23132A73ACDD3 /* CommonViews.swift in Sources */,
|
F2AF05B9C8C23132A73ACDD3 /* CommonViews.swift in Sources */,
|
||||||
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */,
|
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */,
|
||||||
|
07FC69FB9DF3F6073564E489 /* DiscoverViewModel.swift in Sources */,
|
||||||
|
DFA7EB1B0BD53F68FE1335C8 /* DownloadAudioButton.swift in Sources */,
|
||||||
|
880D411C936F7BA92AF83383 /* DownloadQueueButton.swift in Sources */,
|
||||||
|
774CFCDA8A13311DF85FF051 /* DownloadsView.swift in Sources */,
|
||||||
EF3C57C400BF05CBEAC1F7FE /* HomeView.swift in Sources */,
|
EF3C57C400BF05CBEAC1F7FE /* HomeView.swift in Sources */,
|
||||||
C807AD8D627CF6BED47D517C /* HomeViewModel.swift in Sources */,
|
C807AD8D627CF6BED47D517C /* HomeViewModel.swift in Sources */,
|
||||||
032E049A4BB3CF0EA990C0CD /* LibNovelApp.swift in Sources */,
|
032E049A4BB3CF0EA990C0CD /* LibNovelApp.swift in Sources */,
|
||||||
@@ -433,16 +487,18 @@
|
|||||||
3521DFD5FCBBED7B90368829 /* LibraryViewModel.swift in Sources */,
|
3521DFD5FCBBED7B90368829 /* LibraryViewModel.swift in Sources */,
|
||||||
0A52BC1CE71BED9E75D20D35 /* Models.swift in Sources */,
|
0A52BC1CE71BED9E75D20D35 /* Models.swift in Sources */,
|
||||||
F4FDA3C44752EB979235C042 /* NavDestination.swift in Sources */,
|
F4FDA3C44752EB979235C042 /* NavDestination.swift in Sources */,
|
||||||
|
5F7409635F6563E44C836390 /* NetworkMonitor.swift in Sources */,
|
||||||
|
62B42DB777F53856C57CB6AF /* OfflineBanner.swift in Sources */,
|
||||||
BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */,
|
BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */,
|
||||||
64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */,
|
64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */,
|
||||||
C3D7A2E15F8B04C9AB163D50 /* AvatarCropView.swift in Sources */,
|
|
||||||
A2F1C3B84E9D71A0BC164F28 /* AccountMenuSheet.swift in Sources */,
|
|
||||||
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */,
|
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */,
|
||||||
AA11BB22CC33DD44EE55FF66 /* UserProfileView.swift in Sources */,
|
|
||||||
BB22CC33DD44EE55FF66AA11 /* UserProfileViewModel.swift in Sources */,
|
|
||||||
367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */,
|
367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */,
|
||||||
D5E2A1C96F3B08D0F74C6B50 /* SearchView.swift in Sources */,
|
192F82518CB8763775E33B38 /* SearchView.swift in Sources */,
|
||||||
41FB51553F1F1AEBFEA91C0A /* String+App.swift in Sources */,
|
41FB51553F1F1AEBFEA91C0A /* String+App.swift in Sources */,
|
||||||
|
9C19B17E746FE6A834E53AF3 /* UserProfileView.swift in Sources */,
|
||||||
|
9407F80F454D0248D5C779A6 /* UserProfileViewModel.swift in Sources */,
|
||||||
|
8B02625CA1B93118B63E9C9D /* VoiceSelectionView.swift in Sources */,
|
||||||
|
1964D61094D4731227384F3A /* VoiceSelectionViewModel.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -497,17 +553,14 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
|
||||||
DEVELOPMENT_TEAM = GHZXC6FVMU;
|
DEVELOPMENT_TEAM = GHZXC6FVMU;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = LibNovel/Resources/Info.plist;
|
INFOPLIST_FILE = LibNovel/Resources/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books";
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel;
|
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel;
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -549,12 +602,11 @@
|
|||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 1000;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
GCC_DYNAMIC_NO_PIC = NO;
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
@@ -577,7 +629,6 @@
|
|||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.10;
|
SWIFT_VERSION = 5.10;
|
||||||
@@ -588,25 +639,18 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
DEVELOPMENT_TEAM = GHZXC6FVMU;
|
||||||
DEVELOPMENT_TEAM = "";
|
|
||||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = GHZXC6FVMU;
|
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = LibNovel/Resources/Info.plist;
|
INFOPLIST_FILE = LibNovel/Resources/Info.plist;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books";
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel;
|
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel;
|
||||||
PROVISIONING_PROFILE = "af592c3a-f60b-4ac1-a14f-30b8a206017f";
|
PROVISIONING_PROFILE = "af592c3a-f60b-4ac1-a14f-30b8a206017f";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
|
||||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "LibNovel Distribution";
|
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
};
|
};
|
||||||
@@ -646,12 +690,11 @@
|
|||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 1000;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
@@ -667,7 +710,6 @@
|
|||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "2630"
|
LastUpgradeVersion = "1600"
|
||||||
version = "1.3">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES">
|
buildImplicitDependencies = "YES"
|
||||||
|
runPostActionsOnFailure = "NO">
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry
|
<BuildActionEntry
|
||||||
buildForTesting = "YES"
|
buildForTesting = "YES"
|
||||||
@@ -26,7 +27,8 @@
|
|||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
onlyGenerateCoverageForSpecifiedTargets = "NO">
|
||||||
<MacroExpansion>
|
<MacroExpansion>
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
@@ -49,6 +51,8 @@
|
|||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</TestableReference>
|
</TestableReference>
|
||||||
</Testables>
|
</Testables>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
@@ -70,6 +74,8 @@
|
|||||||
ReferencedContainer = "container:LibNovel.xcodeproj">
|
ReferencedContainer = "container:LibNovel.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
<EnvironmentVariables>
|
<EnvironmentVariables>
|
||||||
<EnvironmentVariable
|
<EnvironmentVariable
|
||||||
key = "LIBNOVEL_BASE_URL"
|
key = "LIBNOVEL_BASE_URL"
|
||||||
@@ -94,6 +100,8 @@
|
|||||||
ReferencedContainer = "container:LibNovel.xcodeproj">
|
ReferencedContainer = "container:LibNovel.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
</ProfileAction>
|
</ProfileAction>
|
||||||
<AnalyzeAction
|
<AnalyzeAction
|
||||||
buildConfiguration = "Debug">
|
buildConfiguration = "Debug">
|
||||||
|
|||||||
@@ -4,12 +4,16 @@ import SwiftUI
|
|||||||
struct LibNovelApp: App {
|
struct LibNovelApp: App {
|
||||||
@StateObject private var authStore = AuthStore()
|
@StateObject private var authStore = AuthStore()
|
||||||
@StateObject private var audioPlayer = AudioPlayerService()
|
@StateObject private var audioPlayer = AudioPlayerService()
|
||||||
|
@StateObject private var downloadService = AudioDownloadService.shared
|
||||||
|
@StateObject private var networkMonitor = NetworkMonitor()
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
.environmentObject(authStore)
|
.environmentObject(authStore)
|
||||||
.environmentObject(audioPlayer)
|
.environmentObject(audioPlayer)
|
||||||
|
.environmentObject(downloadService)
|
||||||
|
.environmentObject(networkMonitor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ struct RootTabView: View {
|
|||||||
|
|
||||||
@State private var selectedTab: Tab = .home
|
@State private var selectedTab: Tab = .home
|
||||||
@State private var showFullPlayer: Bool = false
|
@State private var showFullPlayer: Bool = false
|
||||||
@State private var showCompactControls: Bool = false
|
@State private var readerIsActive: Bool = false
|
||||||
|
|
||||||
/// Live drag offset while the user is dragging the full player down.
|
/// Live drag offset while the user is dragging the full player down.
|
||||||
@State private var fullPlayerDragOffset: CGFloat = 0
|
@State private var fullPlayerDragOffset: CGFloat = 0
|
||||||
@@ -37,27 +37,17 @@ struct RootTabView: View {
|
|||||||
.tag(Tab.search)
|
.tag(Tab.search)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Floating circular player button (hidden while full player is open)
|
// Mini player bar — sits above the tab bar, hidden while full player is open
|
||||||
if audioPlayer.isActive && !showFullPlayer {
|
// or while the chapter reader is active (it has its own audio chrome).
|
||||||
ZStack {
|
if audioPlayer.isActive && !showFullPlayer && !readerIsActive {
|
||||||
// Compact controls overlay (bottom sheet)
|
MiniPlayerBar(showFullPlayer: $showFullPlayer)
|
||||||
if showCompactControls {
|
// Lift above the tab bar (approx 49 pt on all devices)
|
||||||
CompactPlayerControls(isPresented: $showCompactControls)
|
.padding(.bottom, 49)
|
||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
}
|
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive)
|
||||||
|
|
||||||
// Floating button (always on top)
|
|
||||||
FloatingPlayerButton(
|
|
||||||
showFullPlayer: $showFullPlayer,
|
|
||||||
showControls: $showCompactControls
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.transition(.scale.combined(with: .opacity))
|
|
||||||
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full player — slides up from the bottom as a custom overlay (not a sheet)
|
// Full player — slides up from the bottom as a custom overlay.
|
||||||
// so it feels physically connected to the mini player bar.
|
|
||||||
if showFullPlayer {
|
if showFullPlayer {
|
||||||
FullPlayerView(onDismiss: {
|
FullPlayerView(onDismiss: {
|
||||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
|
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
|
||||||
@@ -70,7 +60,6 @@ struct RootTabView: View {
|
|||||||
DragGesture(minimumDistance: 10)
|
DragGesture(minimumDistance: 10)
|
||||||
.onChanged { value in
|
.onChanged { value in
|
||||||
if value.translation.height > 0 {
|
if value.translation.height > 0 {
|
||||||
// Rubberband slightly so it doesn't feel locked
|
|
||||||
fullPlayerDragOffset = value.translation.height
|
fullPlayerDragOffset = value.translation.height
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,6 +83,8 @@ struct RootTabView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer)
|
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer)
|
||||||
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: showCompactControls)
|
.onPreferenceChange(HideMiniPlayerKey.self) { hide in
|
||||||
|
readerIsActive = hide
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ enum NavDestination: Hashable {
|
|||||||
case book(String) // slug
|
case book(String) // slug
|
||||||
case chapter(String, Int) // slug + chapter number
|
case chapter(String, Int) // slug + chapter number
|
||||||
case userProfile(String) // username
|
case userProfile(String) // username
|
||||||
|
case browseCategory(sort: String, genre: String, status: String, title: String) // Browse with filters
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - View extensions for shared navigation + error alert patterns
|
// MARK: - View extensions for shared navigation + error alert patterns
|
||||||
@@ -19,15 +20,58 @@ extension View {
|
|||||||
|
|
||||||
/// Presents a standard "Error" alert driven by an optional String binding.
|
/// Presents a standard "Error" alert driven by an optional String binding.
|
||||||
/// Dismissing the alert sets the binding back to nil.
|
/// Dismissing the alert sets the binding back to nil.
|
||||||
|
/// Silently suppresses network errors when offline (banner shows instead).
|
||||||
func errorAlert(_ error: Binding<String?>) -> some View {
|
func errorAlert(_ error: Binding<String?>) -> some View {
|
||||||
alert("Error", isPresented: Binding(
|
self.modifier(ErrorAlertModifier(error: error))
|
||||||
get: { error.wrappedValue != nil },
|
}
|
||||||
set: { if !$0 { error.wrappedValue = nil } }
|
}
|
||||||
)) {
|
|
||||||
Button("OK") { error.wrappedValue = nil }
|
// MARK: - Error Alert Modifier
|
||||||
} message: {
|
|
||||||
Text(error.wrappedValue ?? "")
|
private struct ErrorAlertModifier: ViewModifier {
|
||||||
|
@Binding var error: String?
|
||||||
|
@EnvironmentObject var networkMonitor: NetworkMonitor
|
||||||
|
|
||||||
|
private var shouldShowAlert: Bool {
|
||||||
|
guard let errorMessage = error else { return false }
|
||||||
|
|
||||||
|
// If offline, suppress common network error messages
|
||||||
|
if !networkMonitor.isConnected {
|
||||||
|
let networkKeywords = [
|
||||||
|
"internet",
|
||||||
|
"offline",
|
||||||
|
"network",
|
||||||
|
"connection",
|
||||||
|
"unreachable",
|
||||||
|
"timed out",
|
||||||
|
"no data"
|
||||||
|
]
|
||||||
|
|
||||||
|
let lowercased = errorMessage.lowercased()
|
||||||
|
let isNetworkError = networkKeywords.contains { lowercased.contains($0) }
|
||||||
|
|
||||||
|
if isNetworkError {
|
||||||
|
// Clear the error silently
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.error = nil
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.alert("Error", isPresented: Binding(
|
||||||
|
get: { shouldShowAlert },
|
||||||
|
set: { if !$0 { error = nil } }
|
||||||
|
)) {
|
||||||
|
Button("OK") { error = nil }
|
||||||
|
} message: {
|
||||||
|
Text(error ?? "")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +92,8 @@ private struct AppNavigationDestinationModifier: ViewModifier {
|
|||||||
ChapterReaderView(slug: slug, chapterNumber: n)
|
ChapterReaderView(slug: slug, chapterNumber: n)
|
||||||
case .userProfile(let username):
|
case .userProfile(let username):
|
||||||
UserProfileView(username: username)
|
UserProfileView(username: username)
|
||||||
|
case .browseCategory(let sort, let genre, let status, let title):
|
||||||
|
BrowseCategoryView(sort: sort, genre: genre, status: status, title: title)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Expose namespace to child views via environment
|
// Expose namespace to child views via environment
|
||||||
@@ -59,6 +105,8 @@ private struct AppNavigationDestinationModifier: ViewModifier {
|
|||||||
case .book(let slug): BookDetailView(slug: slug)
|
case .book(let slug): BookDetailView(slug: slug)
|
||||||
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
|
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
|
||||||
case .userProfile(let username): UserProfileView(username: username)
|
case .userProfile(let username): UserProfileView(username: username)
|
||||||
|
case .browseCategory(let sort, let genre, let status, let title):
|
||||||
|
BrowseCategoryView(sort: sort, genre: genre, status: status, title: title)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,6 +126,22 @@ extension EnvironmentValues {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Preference key: suppress mini player overlay (used by ChapterReaderView)
|
||||||
|
|
||||||
|
struct HideMiniPlayerKey: PreferenceKey {
|
||||||
|
static var defaultValue = false
|
||||||
|
static func reduce(value: inout Bool, nextValue: () -> Bool) {
|
||||||
|
value = value || nextValue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// Signal to the root overlay that the mini player should be hidden.
|
||||||
|
func hideMiniPlayer() -> some View {
|
||||||
|
preference(key: HideMiniPlayerKey.self, value: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Cover card zoom source modifier
|
// MARK: - Cover card zoom source modifier
|
||||||
|
|
||||||
/// Apply this to any cover image that should be a zoom source for book navigation.
|
/// Apply this to any cover image that should be a zoom source for book navigation.
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ actor APIClient {
|
|||||||
|
|
||||||
func logout() async throws {
|
func logout() async throws {
|
||||||
let _: EmptyResponse = try await fetch("/api/auth/logout", method: "POST")
|
let _: EmptyResponse = try await fetch("/api/auth/logout", method: "POST")
|
||||||
await setAuthCookie(nil)
|
setAuthCookie(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Home
|
// MARK: - Home
|
||||||
|
|||||||
318
ios/LibNovel/LibNovel/Services/AudioDownloadService.swift
Normal file
318
ios/LibNovel/LibNovel/Services/AudioDownloadService.swift
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - AudioDownloadService
|
||||||
|
// Manages offline TTS audio downloads with progress tracking and persistent storage.
|
||||||
|
// Downloads are saved to the app's Documents directory, organized by slug/chapter/voice.
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AudioDownloadService: NSObject, ObservableObject {
|
||||||
|
static let shared = AudioDownloadService()
|
||||||
|
|
||||||
|
// MARK: - Published State
|
||||||
|
|
||||||
|
@Published var downloads: [String: DownloadProgress] = [:] // key: "slug::chapter::voice"
|
||||||
|
@Published var downloadedChapters: Set<String> = [] // key: "slug::chapter::voice"
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private var session: URLSession!
|
||||||
|
private var activeTasks: [String: URLSessionDownloadTask] = [:]
|
||||||
|
private let fileManager = FileManager.default
|
||||||
|
private let metadataKey = "downloadedChaptersMetadata"
|
||||||
|
|
||||||
|
// MARK: - Init
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
super.init()
|
||||||
|
let config = URLSessionConfiguration.background(withIdentifier: "cc.kalekber.libnovel.audio-downloads")
|
||||||
|
config.isDiscretionary = false
|
||||||
|
config.sessionSendsLaunchEvents = true
|
||||||
|
session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
||||||
|
loadMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
/// Check if a chapter's audio is downloaded offline
|
||||||
|
func isDownloaded(slug: String, chapter: Int, voice: String) -> Bool {
|
||||||
|
let key = makeKey(slug: slug, chapter: chapter, voice: voice)
|
||||||
|
return downloadedChapters.contains(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the local file URL for a downloaded chapter (nil if not downloaded)
|
||||||
|
func localURL(slug: String, chapter: Int, voice: String) -> URL? {
|
||||||
|
guard isDownloaded(slug: slug, chapter: chapter, voice: voice) else { return nil }
|
||||||
|
return audioFileURL(slug: slug, chapter: chapter, voice: voice)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start downloading a chapter's audio
|
||||||
|
func download(slug: String, chapter: Int, voice: String) async throws {
|
||||||
|
let key = makeKey(slug: slug, chapter: chapter, voice: voice)
|
||||||
|
|
||||||
|
print("📥 AudioDownload: Starting download - slug: \(slug), chapter: \(chapter), voice: \(voice)")
|
||||||
|
|
||||||
|
// Already downloaded or in progress
|
||||||
|
if downloadedChapters.contains(key) {
|
||||||
|
print("⚠️ AudioDownload: Already downloaded - key: \(key)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if activeTasks[key] != nil {
|
||||||
|
print("⚠️ AudioDownload: Already in progress - key: \(key)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get presigned URL from API
|
||||||
|
print("🔗 AudioDownload: Fetching presigned URL...")
|
||||||
|
let urlString = try await APIClient.shared.presignAudio(slug: slug, chapter: chapter, voice: voice)
|
||||||
|
guard let url = URL(string: urlString) else {
|
||||||
|
print("❌ AudioDownload: Invalid URL - \(urlString)")
|
||||||
|
throw URLError(.badURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
print("🔗 AudioDownload: Presigned URL obtained: \(url.absoluteString)")
|
||||||
|
|
||||||
|
// Create download task
|
||||||
|
let task = session.downloadTask(with: url)
|
||||||
|
task.taskDescription = key // Use taskDescription to identify the download
|
||||||
|
activeTasks[key] = task
|
||||||
|
|
||||||
|
// Initialize progress tracking
|
||||||
|
downloads[key] = DownloadProgress(
|
||||||
|
slug: slug,
|
||||||
|
chapter: chapter,
|
||||||
|
voice: voice,
|
||||||
|
progress: 0,
|
||||||
|
totalBytes: 0,
|
||||||
|
downloadedBytes: 0,
|
||||||
|
status: .downloading
|
||||||
|
)
|
||||||
|
|
||||||
|
print("🚀 AudioDownload: Starting download task - key: \(key)")
|
||||||
|
task.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel an ongoing download
|
||||||
|
func cancelDownload(slug: String, chapter: Int, voice: String) {
|
||||||
|
let key = makeKey(slug: slug, chapter: chapter, voice: voice)
|
||||||
|
activeTasks[key]?.cancel()
|
||||||
|
activeTasks.removeValue(forKey: key)
|
||||||
|
downloads.removeValue(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a downloaded chapter
|
||||||
|
func deleteDownload(slug: String, chapter: Int, voice: String) throws {
|
||||||
|
let key = makeKey(slug: slug, chapter: chapter, voice: voice)
|
||||||
|
let fileURL = audioFileURL(slug: slug, chapter: chapter, voice: voice)
|
||||||
|
|
||||||
|
if fileManager.fileExists(atPath: fileURL.path) {
|
||||||
|
try fileManager.removeItem(at: fileURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadedChapters.remove(key)
|
||||||
|
downloads.removeValue(forKey: key)
|
||||||
|
saveMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get total storage used by downloads (in bytes)
|
||||||
|
func getTotalStorageUsed() -> Int64 {
|
||||||
|
guard let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let audioDir = documentsURL.appendingPathComponent("audio")
|
||||||
|
guard let enumerator = fileManager.enumerator(at: audioDir, includingPropertiesForKeys: [.fileSizeKey]) else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalSize: Int64 = 0
|
||||||
|
for case let fileURL as URL in enumerator {
|
||||||
|
if let fileSize = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize {
|
||||||
|
totalSize += Int64(fileSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return totalSize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete all downloads
|
||||||
|
func deleteAllDownloads() throws {
|
||||||
|
guard let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let audioDir = documentsURL.appendingPathComponent("audio")
|
||||||
|
if fileManager.fileExists(atPath: audioDir.path) {
|
||||||
|
try fileManager.removeItem(at: audioDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadedChapters.removeAll()
|
||||||
|
downloads.removeAll()
|
||||||
|
activeTasks.values.forEach { $0.cancel() }
|
||||||
|
activeTasks.removeAll()
|
||||||
|
saveMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get list of all book slugs that have offline downloads
|
||||||
|
func getOfflineBookSlugs() -> [String] {
|
||||||
|
let slugs = downloadedChapters.compactMap { key -> String? in
|
||||||
|
let components = key.split(separator: "::")
|
||||||
|
guard components.count == 3 else { return nil }
|
||||||
|
return String(components[0])
|
||||||
|
}
|
||||||
|
return Array(Set(slugs)).sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get count of downloaded chapters for a specific book
|
||||||
|
func getDownloadedChapterCount(for slug: String) -> Int {
|
||||||
|
return downloadedChapters.filter { key in
|
||||||
|
let components = key.split(separator: "::")
|
||||||
|
guard components.count == 3 else { return false }
|
||||||
|
return String(components[0]) == slug
|
||||||
|
}.count
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
/// Build the canonical download key used for both in-memory tracking and UserDefaults.
|
||||||
|
/// Uses `::` as separator so slugs that contain `-` are unambiguous.
|
||||||
|
func makeKey(slug: String, chapter: Int, voice: String) -> String {
|
||||||
|
"\(slug)::\(chapter)::\(voice)"
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private func audioFileURL(slug: String, chapter: Int, voice: String) -> URL {
|
||||||
|
guard let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
|
||||||
|
fatalError("Could not access documents directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
return documentsURL
|
||||||
|
.appendingPathComponent("audio")
|
||||||
|
.appendingPathComponent(slug)
|
||||||
|
.appendingPathComponent("\(chapter)-\(voice).mp3")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadMetadata() {
|
||||||
|
if let data = UserDefaults.standard.data(forKey: metadataKey),
|
||||||
|
let decoded = try? JSONDecoder().decode(Set<String>.self, from: data) {
|
||||||
|
downloadedChapters = decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveMetadata() {
|
||||||
|
if let encoded = try? JSONEncoder().encode(downloadedChapters) {
|
||||||
|
UserDefaults.standard.set(encoded, forKey: metadataKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - URLSessionDownloadDelegate
|
||||||
|
|
||||||
|
extension AudioDownloadService: URLSessionDownloadDelegate {
|
||||||
|
nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
|
||||||
|
guard let key = downloadTask.taskDescription else {
|
||||||
|
print("⚠️ AudioDownload: No task description")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print("✅ AudioDownload: Finished downloading - key: \(key)")
|
||||||
|
|
||||||
|
let components = key.split(separator: "::")
|
||||||
|
guard components.count == 3,
|
||||||
|
let chapter = Int(components[1]) else {
|
||||||
|
print("⚠️ AudioDownload: Invalid key format: \(key)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let slug = String(components[0])
|
||||||
|
let voice = String(components[2])
|
||||||
|
|
||||||
|
let destinationURL = audioFileURL(slug: slug, chapter: chapter, voice: voice)
|
||||||
|
|
||||||
|
print("📁 AudioDownload: Moving from \(location.path) to \(destinationURL.path)")
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Create directory if needed
|
||||||
|
let directory = destinationURL.deletingLastPathComponent()
|
||||||
|
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
// Move file from temp location to permanent storage
|
||||||
|
if fileManager.fileExists(atPath: destinationURL.path) {
|
||||||
|
print("📁 AudioDownload: Removing existing file at destination")
|
||||||
|
try fileManager.removeItem(at: destinationURL)
|
||||||
|
}
|
||||||
|
try fileManager.moveItem(at: location, to: destinationURL)
|
||||||
|
|
||||||
|
print("✅ AudioDownload: File moved successfully")
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
print("✅ AudioDownload: Marking as completed - key: \(key)")
|
||||||
|
self.downloadedChapters.insert(key)
|
||||||
|
self.downloads.removeValue(forKey: key) // Remove from active downloads
|
||||||
|
self.activeTasks.removeValue(forKey: key)
|
||||||
|
self.saveMetadata()
|
||||||
|
print("✅ AudioDownload: Metadata saved, downloadedChapters count: \(self.downloadedChapters.count)")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("❌ AudioDownload: Failed to move file - \(error.localizedDescription)")
|
||||||
|
Task { @MainActor in
|
||||||
|
self.downloads[key]?.status = .failed(error.localizedDescription)
|
||||||
|
self.activeTasks.removeValue(forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
|
||||||
|
guard let key = downloadTask.taskDescription else { return }
|
||||||
|
|
||||||
|
let progress = totalBytesExpectedToWrite > 0 ? Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) : 0
|
||||||
|
|
||||||
|
if Int(progress * 100) % 10 == 0 { // Log every 10%
|
||||||
|
print("📊 AudioDownload: Progress for \(key): \(Int(progress * 100))% (\(totalBytesWritten)/\(totalBytesExpectedToWrite) bytes)")
|
||||||
|
}
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
if var progressData = self.downloads[key] {
|
||||||
|
progressData.downloadedBytes = totalBytesWritten
|
||||||
|
progressData.totalBytes = totalBytesExpectedToWrite
|
||||||
|
progressData.progress = progress
|
||||||
|
self.downloads[key] = progressData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||||
|
guard let key = task.taskDescription else { return }
|
||||||
|
|
||||||
|
if let error = error {
|
||||||
|
let nsError = error as NSError
|
||||||
|
if nsError.code != NSURLErrorCancelled {
|
||||||
|
print("❌ AudioDownload: Task completed with error - key: \(key), error: \(error.localizedDescription)")
|
||||||
|
Task { @MainActor in
|
||||||
|
self.downloads[key]?.status = .failed(error.localizedDescription)
|
||||||
|
self.activeTasks.removeValue(forKey: key)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print("⚠️ AudioDownload: Task cancelled - key: \(key)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print("✅ AudioDownload: Task completed without error - key: \(key)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Supporting Types
|
||||||
|
|
||||||
|
struct DownloadProgress: Equatable {
|
||||||
|
let slug: String
|
||||||
|
let chapter: Int
|
||||||
|
let voice: String
|
||||||
|
var progress: Double
|
||||||
|
var totalBytes: Int64
|
||||||
|
var downloadedBytes: Int64
|
||||||
|
var status: DownloadStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DownloadStatus: Equatable {
|
||||||
|
case downloading
|
||||||
|
case completed
|
||||||
|
case failed(String)
|
||||||
|
}
|
||||||
@@ -275,6 +275,17 @@ final class AudioPlayerService: ObservableObject {
|
|||||||
|
|
||||||
private func generateAudio() async {
|
private func generateAudio() async {
|
||||||
guard !slug.isEmpty, chapter > 0 else { return }
|
guard !slug.isEmpty, chapter > 0 else { return }
|
||||||
|
|
||||||
|
// Check if audio is downloaded locally first
|
||||||
|
if let localURL = AudioDownloadService.shared.localURL(slug: slug, chapter: chapter, voice: voice) {
|
||||||
|
audioURL = localURL.absoluteString
|
||||||
|
status = .ready
|
||||||
|
generationProgress = 100
|
||||||
|
await playURL(localURL.absoluteString)
|
||||||
|
await prefetchNext()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
// Fast path: audio already in MinIO — get a presigned URL and play immediately.
|
// Fast path: audio already in MinIO — get a presigned URL and play immediately.
|
||||||
if let presignedURL = try? await APIClient.shared.presignAudio(slug: slug, chapter: chapter, voice: voice) {
|
if let presignedURL = try? await APIClient.shared.presignAudio(slug: slug, chapter: chapter, voice: voice) {
|
||||||
|
|||||||
73
ios/LibNovel/LibNovel/Services/BookVoicePreferences.swift
Normal file
73
ios/LibNovel/LibNovel/Services/BookVoicePreferences.swift
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Book Voice Preferences Service
|
||||||
|
// Manages per-book voice overrides with global fallback
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class BookVoicePreferences: ObservableObject {
|
||||||
|
static let shared = BookVoicePreferences()
|
||||||
|
|
||||||
|
@Published private(set) var bookVoices: [String: String] = [:] // slug -> voice
|
||||||
|
|
||||||
|
private let userDefaults = UserDefaults.standard
|
||||||
|
private let storageKey = "bookVoicePreferences"
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
loadPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
/// Get the voice for a specific book (returns nil if no override set)
|
||||||
|
func voice(for slug: String) -> String? {
|
||||||
|
return bookVoices[slug]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the voice for a book with fallback to global user voice
|
||||||
|
func voiceWithFallback(for slug: String, globalVoice: String) -> String {
|
||||||
|
return bookVoices[slug] ?? globalVoice
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a voice override for a specific book
|
||||||
|
func setVoice(_ voice: String, for slug: String) {
|
||||||
|
print("📚 BookVoicePreferences: Setting voice '\(voice)' for book '\(slug)'")
|
||||||
|
bookVoices[slug] = voice
|
||||||
|
savePreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove voice override for a book (will use global voice)
|
||||||
|
func removeVoice(for slug: String) {
|
||||||
|
print("📚 BookVoicePreferences: Removing voice override for book '\(slug)'")
|
||||||
|
bookVoices.removeValue(forKey: slug)
|
||||||
|
savePreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a book has a voice override
|
||||||
|
func hasOverride(for slug: String) -> Bool {
|
||||||
|
return bookVoices[slug] != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all book voice overrides
|
||||||
|
func clearAll() {
|
||||||
|
print("📚 BookVoicePreferences: Clearing all book voice overrides")
|
||||||
|
bookVoices.removeAll()
|
||||||
|
savePreferences()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Persistence
|
||||||
|
|
||||||
|
private func loadPreferences() {
|
||||||
|
if let data = userDefaults.data(forKey: storageKey),
|
||||||
|
let decoded = try? JSONDecoder().decode([String: String].self, from: data) {
|
||||||
|
bookVoices = decoded
|
||||||
|
print("📚 BookVoicePreferences: Loaded \(bookVoices.count) book voice overrides")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func savePreferences() {
|
||||||
|
if let encoded = try? JSONEncoder().encode(bookVoices) {
|
||||||
|
userDefaults.set(encoded, forKey: storageKey)
|
||||||
|
print("📚 BookVoicePreferences: Saved \(bookVoices.count) book voice overrides")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
ios/LibNovel/LibNovel/Services/NetworkMonitor.swift
Normal file
54
ios/LibNovel/LibNovel/Services/NetworkMonitor.swift
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
|
||||||
|
// MARK: - Network Monitor
|
||||||
|
// Monitors network connectivity and provides offline state across the app
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class NetworkMonitor: ObservableObject {
|
||||||
|
static let shared = NetworkMonitor()
|
||||||
|
|
||||||
|
@Published var isConnected: Bool = true
|
||||||
|
@Published var connectionType: NWInterface.InterfaceType?
|
||||||
|
|
||||||
|
private let monitor: NWPathMonitor
|
||||||
|
private let queue = DispatchQueue(label: "NetworkMonitor")
|
||||||
|
|
||||||
|
init() {
|
||||||
|
monitor = NWPathMonitor()
|
||||||
|
startMonitoring()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startMonitoring() {
|
||||||
|
monitor.pathUpdateHandler = { [weak self] path in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.isConnected = path.status == .satisfied
|
||||||
|
self?.connectionType = path.availableInterfaces.first?.type
|
||||||
|
|
||||||
|
if path.status == .satisfied {
|
||||||
|
print("🌐 Network: Connected (\(path.availableInterfaces.first?.type.debugDescription ?? "unknown"))")
|
||||||
|
} else {
|
||||||
|
print("📴 Network: Offline")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
monitor.start(queue: queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
monitor.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NWInterface.InterfaceType {
|
||||||
|
var debugDescription: String {
|
||||||
|
switch self {
|
||||||
|
case .wifi: return "Wi-Fi"
|
||||||
|
case .cellular: return "Cellular"
|
||||||
|
case .wiredEthernet: return "Ethernet"
|
||||||
|
case .loopback: return "Loopback"
|
||||||
|
case .other: return "Other"
|
||||||
|
@unknown default: return "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,13 +52,17 @@ final class ChapterReaderViewModel: ObservableObject {
|
|||||||
} else {
|
} else {
|
||||||
let nextChapter: Int? = content.next
|
let nextChapter: Int? = content.next
|
||||||
let prevChapter: Int? = content.prev
|
let prevChapter: Int? = content.prev
|
||||||
|
|
||||||
|
// Use per-book voice override, fallback to global voice
|
||||||
|
let voice = BookVoicePreferences.shared.voiceWithFallback(for: slug, globalVoice: settings.voice)
|
||||||
|
|
||||||
audioPlayer.load(
|
audioPlayer.load(
|
||||||
slug: slug,
|
slug: slug,
|
||||||
chapter: chapter,
|
chapter: chapter,
|
||||||
chapterTitle: content.chapter.title,
|
chapterTitle: content.chapter.title,
|
||||||
bookTitle: content.book.title,
|
bookTitle: content.book.title,
|
||||||
coverURL: content.book.cover,
|
coverURL: content.book.cover,
|
||||||
voice: settings.voice,
|
voice: voice,
|
||||||
speed: settings.speed,
|
speed: settings.speed,
|
||||||
chapters: content.chapters,
|
chapters: content.chapters,
|
||||||
nextChapter: nextChapter,
|
nextChapter: nextChapter,
|
||||||
|
|||||||
78
ios/LibNovel/LibNovel/ViewModels/DiscoverViewModel.swift
Normal file
78
ios/LibNovel/LibNovel/ViewModels/DiscoverViewModel.swift
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class DiscoverViewModel: ObservableObject {
|
||||||
|
@Published var trending: [BrowseNovel] = []
|
||||||
|
@Published var topRated: [BrowseNovel] = []
|
||||||
|
@Published var recentlyUpdated: [BrowseNovel] = []
|
||||||
|
@Published var newReleases: [BrowseNovel] = []
|
||||||
|
@Published var genreShelves: [GenreShelf] = []
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var error: String?
|
||||||
|
|
||||||
|
struct GenreShelf: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let genre: String
|
||||||
|
var novels: [BrowseNovel] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Popular genres to show as shelves
|
||||||
|
private let featuredGenres = [
|
||||||
|
("fantasy", "Fantasy"),
|
||||||
|
("romance", "Romance"),
|
||||||
|
("action", "Action"),
|
||||||
|
("sci-fi", "Sci-Fi"),
|
||||||
|
("mystery", "Mystery")
|
||||||
|
]
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
guard !isLoading else { return }
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
async let trendingTask = loadShelf(sort: "popular", limit: 20)
|
||||||
|
async let topRatedTask = loadShelf(sort: "rating", limit: 20)
|
||||||
|
async let recentlyUpdatedTask = loadShelf(sort: "updated", limit: 20)
|
||||||
|
async let newReleasesTask = loadShelf(sort: "new", limit: 20)
|
||||||
|
|
||||||
|
do {
|
||||||
|
trending = try await trendingTask
|
||||||
|
topRated = try await topRatedTask
|
||||||
|
recentlyUpdated = try await recentlyUpdatedTask
|
||||||
|
newReleases = try await newReleasesTask
|
||||||
|
|
||||||
|
// Load genre shelves
|
||||||
|
await loadGenreShelves()
|
||||||
|
} catch {
|
||||||
|
if !(error is CancellationError) {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadShelf(sort: String, genre: String = "all", status: String = "all", limit: Int = 20) async throws -> [BrowseNovel] {
|
||||||
|
let result = try await APIClient.shared.browse(page: 1, genre: genre, sort: sort, status: status)
|
||||||
|
return Array(result.novels.prefix(limit))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadGenreShelves() async {
|
||||||
|
var shelves: [GenreShelf] = []
|
||||||
|
|
||||||
|
for (genre, name) in featuredGenres {
|
||||||
|
do {
|
||||||
|
let novels = try await loadShelf(sort: "popular", genre: genre, limit: 15)
|
||||||
|
if !novels.isEmpty {
|
||||||
|
shelves.append(GenreShelf(id: genre, name: name, genre: genre, novels: novels))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip failed genres silently
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
genreShelves = shelves
|
||||||
|
}
|
||||||
|
}
|
||||||
127
ios/LibNovel/LibNovel/ViewModels/VoiceSelectionViewModel.swift
Normal file
127
ios/LibNovel/LibNovel/ViewModels/VoiceSelectionViewModel.swift
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import Foundation
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class VoiceSelectionViewModel: ObservableObject {
|
||||||
|
@Published var voices: [String] = []
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var error: String?
|
||||||
|
@Published var playingVoice: String?
|
||||||
|
|
||||||
|
private var audioPlayer: AVPlayer?
|
||||||
|
// Store the opaque token returned by the block-based addObserver so we can
|
||||||
|
// actually remove it later. removeObserver(self, ...) does nothing when the
|
||||||
|
// block-based API was used — the token is the observer, not `self`.
|
||||||
|
private var endObserverToken: NSObjectProtocol?
|
||||||
|
|
||||||
|
// Voice label formatting (matches web UI logic)
|
||||||
|
func voiceLabel(_ voice: String) -> String {
|
||||||
|
let parts = voice.split(separator: "_")
|
||||||
|
guard parts.count >= 2 else { return voice }
|
||||||
|
|
||||||
|
let prefix = String(parts[0])
|
||||||
|
let name = parts.dropFirst().map { $0.capitalized }.joined(separator: " ")
|
||||||
|
|
||||||
|
var info = ""
|
||||||
|
switch prefix {
|
||||||
|
case "af": info = "US F"
|
||||||
|
case "am": info = "US M"
|
||||||
|
case "bf": info = "UK F"
|
||||||
|
case "bm": info = "UK M"
|
||||||
|
default: info = prefix.uppercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
return "\(name) (\(info))"
|
||||||
|
}
|
||||||
|
|
||||||
|
func voiceId(_ voice: String) -> String { voice }
|
||||||
|
|
||||||
|
// Load available voices from API
|
||||||
|
func loadVoices() async {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
defer { isLoading = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let fetchedVoices = try await APIClient.shared.voices()
|
||||||
|
voices = fetchedVoices.isEmpty ? fallbackVoices() : fetchedVoices
|
||||||
|
} catch {
|
||||||
|
self.error = "Failed to load voices: \(error.localizedDescription)"
|
||||||
|
voices = fallbackVoices()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play voice sample
|
||||||
|
func playSample(_ voice: String) async {
|
||||||
|
if playingVoice == voice {
|
||||||
|
stopSample()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stopSample()
|
||||||
|
playingVoice = voice
|
||||||
|
|
||||||
|
do {
|
||||||
|
let presignedURL = try await APIClient.shared.presignVoiceSample(voice: voice)
|
||||||
|
guard let url = URL(string: presignedURL) else {
|
||||||
|
throw NSError(domain: "VoiceSelection", code: -1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Invalid URL"])
|
||||||
|
}
|
||||||
|
|
||||||
|
let playerItem = AVPlayerItem(url: url)
|
||||||
|
audioPlayer = AVPlayer(playerItem: playerItem)
|
||||||
|
|
||||||
|
// Block-based addObserver returns a token — store it so we can remove it.
|
||||||
|
endObserverToken = NotificationCenter.default.addObserver(
|
||||||
|
forName: .AVPlayerItemDidPlayToEndTime,
|
||||||
|
object: playerItem,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.stopSample()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
audioPlayer?.play()
|
||||||
|
} catch {
|
||||||
|
// Sample might not be generated yet — silently ignore.
|
||||||
|
print("Voice sample not available for \(voice): \(error)")
|
||||||
|
playingVoice = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop currently playing sample
|
||||||
|
func stopSample() {
|
||||||
|
audioPlayer?.pause()
|
||||||
|
audioPlayer = nil
|
||||||
|
playingVoice = nil
|
||||||
|
if let token = endObserverToken {
|
||||||
|
NotificationCenter.default.removeObserver(token)
|
||||||
|
endObserverToken = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fallbackVoices() -> [String] {
|
||||||
|
["af_bella", "af_sarah", "af_nicole",
|
||||||
|
"am_adam", "am_michael",
|
||||||
|
"bf_emma", "bf_isabella",
|
||||||
|
"bm_george", "bm_lewis",
|
||||||
|
"af_sky"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// deinit: must NOT dispatch a Task capturing self.
|
||||||
|
// A Task strongly retains self, which causes "deallocated with non-zero retain
|
||||||
|
// count 2" → SIGABRT. Instead capture just the two values we need (player and
|
||||||
|
// token) and clean up without touching self at all.
|
||||||
|
nonisolated deinit {
|
||||||
|
// Capture locals — self is going away, do not reference it after this point.
|
||||||
|
// audioPlayer and endObserverToken are actor-isolated, but we can read their
|
||||||
|
// stored value directly in deinit because deinit is the last exclusive owner.
|
||||||
|
// Suppress the "actor-isolated" warning with an unowned reference pattern:
|
||||||
|
// Swift SE-0371 allows nonisolated deinit to access stored properties directly.
|
||||||
|
audioPlayer?.pause()
|
||||||
|
if let token = endObserverToken {
|
||||||
|
NotificationCenter.default.removeObserver(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,22 +15,26 @@ struct BookDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .top) {
|
VStack(spacing: 0) {
|
||||||
ScrollView {
|
OfflineBanner()
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
|
||||||
if vm.isLoading {
|
ZStack(alignment: .top) {
|
||||||
ProgressView().frame(maxWidth: .infinity).padding(.top, 120)
|
ScrollView {
|
||||||
} else if let book = vm.book {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
heroSection(book: book)
|
if vm.isLoading {
|
||||||
metaSection(book: book)
|
ProgressView().frame(maxWidth: .infinity).padding(.top, 120)
|
||||||
Divider().padding(.horizontal)
|
} else if let book = vm.book {
|
||||||
chaptersRow(book: book)
|
heroSection(book: book)
|
||||||
Divider().padding(.horizontal)
|
metaSection(book: book)
|
||||||
CommentsView(slug: slug)
|
Divider().padding(.horizontal)
|
||||||
|
chaptersRow(book: book)
|
||||||
|
Divider().padding(.horizontal)
|
||||||
|
CommentsView(slug: slug)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.ignoresSafeArea(edges: .top)
|
||||||
}
|
}
|
||||||
.ignoresSafeArea(edges: .top)
|
|
||||||
}
|
}
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.appNavigationDestination()
|
.appNavigationDestination()
|
||||||
@@ -252,6 +256,9 @@ struct BookDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Chapters list sheet
|
// MARK: - Chapters list sheet
|
||||||
|
// Apple Books-style: chapters grouped into blocks of 100 with a right-edge jump bar.
|
||||||
|
// A .searchable bar filters by number or title; an "offline only" toggle shows downloaded chapters.
|
||||||
|
// Per-row download status (arc ring, labels, swipe actions) mirrors ChaptersListSheet in PlayerViews.
|
||||||
|
|
||||||
struct BookChaptersSheet: View {
|
struct BookChaptersSheet: View {
|
||||||
let slug: String
|
let slug: String
|
||||||
@@ -260,118 +267,182 @@ struct BookChaptersSheet: View {
|
|||||||
let totalChapters: Int
|
let totalChapters: Int
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var searchText = ""
|
@EnvironmentObject var downloadService: AudioDownloadService
|
||||||
@State private var scrollToCurrentOnAppear = true
|
@EnvironmentObject var audioPlayer: AudioPlayerService
|
||||||
|
|
||||||
|
@State private var searchText: String = ""
|
||||||
|
@State private var filterOfflineOnly = false
|
||||||
|
@State private var showingDownloadAll = false
|
||||||
|
/// The block label the jump bar is currently scrolling to (e.g. "1–100").
|
||||||
|
@State private var activeBlock: String? = nil
|
||||||
|
|
||||||
|
// MARK: Derived data
|
||||||
|
|
||||||
|
private var downloadedCount: Int {
|
||||||
|
chapters.filter { ch in
|
||||||
|
downloadService.isDownloaded(slug: slug, chapter: ch.number, voice: defaultVoice)
|
||||||
|
}.count
|
||||||
|
}
|
||||||
|
|
||||||
|
private var downloadingCount: Int {
|
||||||
|
downloadService.downloads.filter { key, _ in
|
||||||
|
key.hasPrefix("\(slug)::")
|
||||||
|
}.count
|
||||||
|
}
|
||||||
|
|
||||||
|
private var defaultVoice: String {
|
||||||
|
BookVoicePreferences.shared.voiceWithFallback(for: slug, globalVoice: audioPlayer.voice)
|
||||||
|
}
|
||||||
|
|
||||||
private var filtered: [ChapterIndex] {
|
private var filtered: [ChapterIndex] {
|
||||||
guard !searchText.isEmpty else { return chapters }
|
var result = chapters
|
||||||
let q = searchText.lowercased()
|
|
||||||
return chapters.filter {
|
if filterOfflineOnly {
|
||||||
"chapter \($0.number)".contains(q) ||
|
result = result.filter { ch in
|
||||||
$0.title.lowercased().contains(q)
|
downloadService.isDownloaded(slug: slug, chapter: ch.number, voice: defaultVoice)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !searchText.isEmpty {
|
||||||
|
let q = searchText.lowercased()
|
||||||
|
result = result.filter {
|
||||||
|
"\($0.number)".contains(q) ||
|
||||||
|
$0.title.lowercased().contains(q) ||
|
||||||
|
"chapter \($0.number)".contains(q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Chapters grouped into blocks of 100 with range labels "1–100", "101–200", etc.
|
||||||
|
/// When searching or filtering the jump bar is hidden and a flat "Results" group is used.
|
||||||
|
private var groups: [(label: String, chapters: [ChapterIndex])] {
|
||||||
|
guard searchText.isEmpty && !filterOfflineOnly else {
|
||||||
|
return filtered.isEmpty ? [] : [("Results", filtered)]
|
||||||
|
}
|
||||||
|
guard !filtered.isEmpty else { return [] }
|
||||||
|
let blockSize = 100
|
||||||
|
let minN = filtered.map(\.number).min() ?? 1
|
||||||
|
let maxN = filtered.map(\.number).max() ?? 1
|
||||||
|
let firstBlock = ((minN - 1) / blockSize) * blockSize + 1
|
||||||
|
var result: [(label: String, chapters: [ChapterIndex])] = []
|
||||||
|
var blockStart = firstBlock
|
||||||
|
while blockStart <= maxN {
|
||||||
|
let blockEnd = blockStart + blockSize - 1
|
||||||
|
let slice = filtered.filter { $0.number >= blockStart && $0.number <= blockEnd }
|
||||||
|
if !slice.isEmpty {
|
||||||
|
result.append(("\(blockStart)–\(blockEnd)", slice))
|
||||||
|
}
|
||||||
|
blockStart += blockSize
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private var jumpLabels: [String] { groups.map(\.label) }
|
||||||
|
|
||||||
|
// MARK: Body
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
VStack(spacing: 0) {
|
ZStack(alignment: .trailing) {
|
||||||
// Search bar
|
// ── Main chapter list ──────────────────────────────────────
|
||||||
HStack(spacing: 8) {
|
List {
|
||||||
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
|
// Offline downloads summary (shown when at least one chapter is downloaded)
|
||||||
TextField("Search chapters…", text: $searchText)
|
if downloadedCount > 0 || downloadingCount > 0 {
|
||||||
.autocorrectionDisabled()
|
Section {
|
||||||
if !searchText.isEmpty {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Button { searchText = "" } label: {
|
HStack {
|
||||||
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
}
|
Text("Offline Downloads")
|
||||||
}
|
.font(.headline)
|
||||||
}
|
Text("\(downloadedCount) of \(chapters.count) chapters")
|
||||||
.padding(10)
|
.font(.subheadline)
|
||||||
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10))
|
.foregroundStyle(.secondary)
|
||||||
.padding(.horizontal)
|
}
|
||||||
.padding(.vertical, 10)
|
|
||||||
|
|
||||||
Divider()
|
Spacer()
|
||||||
|
|
||||||
// Jump-to-current banner (shown when user has progress and not searching)
|
Button {
|
||||||
if let last = lastChapter, last > 0, searchText.isEmpty {
|
showingDownloadAll = true
|
||||||
Button {
|
} label: {
|
||||||
scrollToCurrentOnAppear = true
|
Label("Manage", systemImage: "arrow.down.circle")
|
||||||
} label: {
|
.font(.subheadline.weight(.semibold))
|
||||||
HStack(spacing: 8) {
|
}
|
||||||
Image(systemName: "arrow.down.circle.fill")
|
.buttonStyle(.bordered)
|
||||||
.foregroundStyle(.amber)
|
.tint(.blue)
|
||||||
Text("Jump to Ch.\(last)")
|
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
.foregroundStyle(.amber)
|
|
||||||
Spacer()
|
|
||||||
let pct = totalChapters > 0
|
|
||||||
? Int(Double(last) / Double(totalChapters) * 100)
|
|
||||||
: 0
|
|
||||||
Text("\(pct)% read")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
}
|
|
||||||
|
|
||||||
if chapters.isEmpty {
|
|
||||||
ProgressView()
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
} else if filtered.isEmpty {
|
|
||||||
VStack(spacing: 10) {
|
|
||||||
Image(systemName: "magnifyingglass")
|
|
||||||
.font(.largeTitle)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Text("No chapters match \"\(searchText)\"")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
} else {
|
|
||||||
ScrollViewReader { proxy in
|
|
||||||
List {
|
|
||||||
ForEach(filtered) { ch in
|
|
||||||
NavigationLink(value: NavDestination.chapter(slug, ch.number)) {
|
|
||||||
ChapterRow(
|
|
||||||
chapter: ch,
|
|
||||||
isCurrent: ch.number == lastChapter,
|
|
||||||
totalChapters: chapters.count
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 16))
|
|
||||||
.id(ch.number)
|
if downloadingCount > 0 {
|
||||||
}
|
HStack(spacing: 8) {
|
||||||
}
|
ProgressView()
|
||||||
.listStyle(.plain)
|
.scaleEffect(0.8)
|
||||||
.appNavigationDestination()
|
Text("Downloading \(downloadingCount) \(downloadingCount == 1 ? "chapter" : "chapters")")
|
||||||
.onAppear {
|
.font(.caption)
|
||||||
if scrollToCurrentOnAppear, let last = lastChapter, last > 0 {
|
.foregroundStyle(.secondary)
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
|
||||||
withAnimation {
|
|
||||||
proxy.scrollTo(last, anchor: .center)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
scrollToCurrentOnAppear = false
|
|
||||||
|
Toggle("Show offline only", isOn: $filterOfflineOnly)
|
||||||
|
.font(.subheadline)
|
||||||
|
.tint(.amber)
|
||||||
}
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
.onChange(of: scrollToCurrentOnAppear) { _, jump in
|
}
|
||||||
if jump, let last = lastChapter, last > 0 {
|
|
||||||
withAnimation {
|
ForEach(groups, id: \.label) { group in
|
||||||
proxy.scrollTo(last, anchor: .center)
|
Section {
|
||||||
}
|
ForEach(group.chapters, id: \.number) { ch in
|
||||||
scrollToCurrentOnAppear = false
|
BookChapterRow(
|
||||||
|
chapter: ch,
|
||||||
|
slug: slug,
|
||||||
|
isCurrent: ch.number == lastChapter,
|
||||||
|
voice: defaultVoice
|
||||||
|
)
|
||||||
|
.id(group.label)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
if searchText.isEmpty && !filterOfflineOnly {
|
||||||
|
Text(group.label)
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.id("header_\(group.label)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if chapters.isEmpty {
|
||||||
|
Section {
|
||||||
|
ProgressView()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 24)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.searchable(
|
||||||
|
text: $searchText,
|
||||||
|
placement: .navigationBarDrawer(displayMode: .always),
|
||||||
|
prompt: "Chapter number or title"
|
||||||
|
)
|
||||||
|
.scrollPosition(id: $activeBlock, anchor: .top)
|
||||||
|
.appNavigationDestination()
|
||||||
|
|
||||||
|
// ── Right-edge jump bar ────────────────────────────────────
|
||||||
|
if searchText.isEmpty && !filterOfflineOnly && jumpLabels.count > 1 {
|
||||||
|
BookChaptersJumpBar(
|
||||||
|
labels: jumpLabels,
|
||||||
|
currentChapter: lastChapter ?? 0,
|
||||||
|
groups: groups
|
||||||
|
) { label in
|
||||||
|
withAnimation { activeBlock = label }
|
||||||
|
}
|
||||||
|
.padding(.trailing, 4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Chapters")
|
.navigationTitle("Chapters (\(filtered.count))")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
@@ -379,63 +450,209 @@ struct BookChaptersSheet: View {
|
|||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Sheet to manage bulk downloads for this book
|
||||||
|
.sheet(isPresented: $showingDownloadAll) {
|
||||||
|
DownloadManagementSheet(
|
||||||
|
chapters: chapters.map { ChapterIndexBrief(number: $0.number, title: $0.title) },
|
||||||
|
slug: slug,
|
||||||
|
voice: Binding(
|
||||||
|
get: { defaultVoice },
|
||||||
|
set: { _ in } // voice changes handled inside DownloadManagementSheet
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.presentationDetents([.medium, .large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
}
|
||||||
|
// Scroll to the current chapter's block on first appear
|
||||||
|
.onAppear {
|
||||||
|
if let block = groups.first(where: { g in
|
||||||
|
g.chapters.contains(where: { $0.number == (lastChapter ?? 0) })
|
||||||
|
}) {
|
||||||
|
activeBlock = block.label
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.presentationDetents([.large])
|
.presentationDetents([.large])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Chapter row (reused by sheet)
|
// MARK: - Individual chapter row with download status + NavigationLink
|
||||||
|
|
||||||
private struct ChapterRow: View {
|
private struct BookChapterRow: View {
|
||||||
let chapter: ChapterIndex
|
let chapter: ChapterIndex
|
||||||
|
let slug: String
|
||||||
let isCurrent: Bool
|
let isCurrent: Bool
|
||||||
let totalChapters: Int
|
let voice: String
|
||||||
|
|
||||||
|
@EnvironmentObject var downloadService: AudioDownloadService
|
||||||
|
|
||||||
|
private var isDownloaded: Bool {
|
||||||
|
downloadService.isDownloaded(slug: slug, chapter: chapter.number, voice: voice)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var downloadProgress: DownloadProgress? {
|
||||||
|
let key = downloadService.makeKey(slug: slug, chapter: chapter.number, voice: voice)
|
||||||
|
return downloadService.downloads[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isDownloading: Bool { downloadProgress != nil }
|
||||||
|
|
||||||
|
private var displayTitle: String {
|
||||||
|
let stripped = chapter.title.strippingTrailingDate()
|
||||||
|
if stripped.isEmpty || stripped == "Chapter \(chapter.number)" {
|
||||||
|
return "Chapter \(chapter.number)"
|
||||||
|
}
|
||||||
|
return stripped
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
NavigationLink(value: NavDestination.chapter(slug, chapter.number)) {
|
||||||
// Number badge
|
HStack(spacing: 14) {
|
||||||
ZStack {
|
// Number badge with optional download-progress arc ring
|
||||||
Circle()
|
ZStack {
|
||||||
.fill(isCurrent ? Color.amber : Color(.systemGray6))
|
Circle()
|
||||||
Text("\(chapter.number)")
|
.fill(isCurrent ? Color.amber : Color(.systemGray5))
|
||||||
.font(.caption2.bold().monospacedDigit())
|
.frame(width: 40, height: 40)
|
||||||
.foregroundStyle(isCurrent ? .black : .secondary)
|
|
||||||
.minimumScaleFactor(0.6)
|
|
||||||
}
|
|
||||||
.frame(width: 32, height: 32)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
Text("\(chapter.number)")
|
||||||
let displayTitle: String = {
|
.font(.caption.bold().monospacedDigit())
|
||||||
let stripped = chapter.title.strippingTrailingDate()
|
.foregroundStyle(isCurrent ? .white : .secondary)
|
||||||
if stripped.isEmpty || stripped == "Chapter \(chapter.number)" {
|
.minimumScaleFactor(0.6)
|
||||||
return "Chapter \(chapter.number)"
|
.frame(width: 40, height: 40)
|
||||||
|
|
||||||
|
// In-progress download arc
|
||||||
|
if isDownloading, let progress = downloadProgress {
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: progress.progress)
|
||||||
|
.stroke(Color.blue, style: StrokeStyle(lineWidth: 2, lineCap: .round))
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.animation(.easeInOut(duration: 0.3), value: progress.progress)
|
||||||
}
|
}
|
||||||
return stripped
|
|
||||||
}()
|
|
||||||
|
|
||||||
Text(displayTitle)
|
|
||||||
.font(.subheadline)
|
|
||||||
.fontWeight(isCurrent ? .semibold : .regular)
|
|
||||||
.foregroundStyle(isCurrent ? .amber : .primary)
|
|
||||||
.lineLimit(1)
|
|
||||||
|
|
||||||
if !chapter.dateLabel.isEmpty {
|
|
||||||
Text(chapter.dateLabel)
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Title + status subtitle
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(displayTitle)
|
||||||
|
.font(.subheadline.weight(isCurrent ? .semibold : .regular))
|
||||||
|
.foregroundStyle(isCurrent ? .amber : .primary)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if isCurrent {
|
||||||
|
Label("Reading", systemImage: "bookmark.fill")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.amber)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isDownloading, let progress = downloadProgress {
|
||||||
|
Label("\(Int(progress.progress * 100))%", systemImage: "arrow.down.circle")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
} else if isDownloaded {
|
||||||
|
Label("Downloaded", systemImage: "checkmark.circle.fill")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
} else if !chapter.dateLabel.isEmpty {
|
||||||
|
Text(chapter.dateLabel)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 4)
|
||||||
}
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
Spacer(minLength: 8)
|
.contentShape(Rectangle())
|
||||||
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.listRowBackground(isCurrent ? Color.amber.opacity(0.08) : Color.clear)
|
||||||
.padding(.vertical, 10)
|
// Trailing swipe: Download / Cancel / Delete
|
||||||
.contentShape(Rectangle())
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||||
|
if isDownloaded {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
Task {
|
||||||
|
try? downloadService.deleteDownload(
|
||||||
|
slug: slug, chapter: chapter.number, voice: voice
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
} else if isDownloading {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
downloadService.cancelDownload(
|
||||||
|
slug: slug, chapter: chapter.number, voice: voice
|
||||||
|
)
|
||||||
|
} label: {
|
||||||
|
Label("Cancel", systemImage: "xmark")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
try? await downloadService.download(
|
||||||
|
slug: slug, chapter: chapter.number, voice: voice
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Download", systemImage: "arrow.down.circle")
|
||||||
|
}
|
||||||
|
.tint(.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Right-edge jump bar for BookChaptersSheet
|
||||||
|
// Mirrors the JumpBar in PlayerViews.swift but operates on ChapterIndex groups.
|
||||||
|
|
||||||
|
private struct BookChaptersJumpBar: View {
|
||||||
|
let labels: [String]
|
||||||
|
let currentChapter: Int
|
||||||
|
let groups: [(label: String, chapters: [ChapterIndex])]
|
||||||
|
let onSelect: (String) -> Void
|
||||||
|
|
||||||
|
@State private var isDragging = false
|
||||||
|
|
||||||
|
private func shortLabel(_ full: String) -> String {
|
||||||
|
full.components(separatedBy: "–").first ?? full
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentBlock: String? {
|
||||||
|
groups.first(where: { g in g.chapters.contains(where: { $0.number == currentChapter }) })?.label
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(labels, id: \.self) { label in
|
||||||
|
let isCurrent = label == currentBlock
|
||||||
|
Text(shortLabel(label))
|
||||||
|
.font(.system(size: 10, weight: isCurrent ? .bold : .regular))
|
||||||
|
.foregroundStyle(isCurrent ? Color.amber : Color.secondary)
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { onSelect(label) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(
|
||||||
|
Capsule()
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
.shadow(color: .black.opacity(0.15), radius: 4)
|
||||||
|
)
|
||||||
|
.gesture(
|
||||||
|
DragGesture(minimumDistance: 0, coordinateSpace: .local)
|
||||||
|
.onChanged { value in
|
||||||
|
isDragging = true
|
||||||
|
let itemHeight: CGFloat = 28
|
||||||
|
let index = Int(value.location.y / itemHeight)
|
||||||
|
let clamped = max(0, min(labels.count - 1, index))
|
||||||
|
onSelect(labels[clamped])
|
||||||
|
}
|
||||||
|
.onEnded { _ in isDragging = false }
|
||||||
|
)
|
||||||
|
.animation(.easeInOut(duration: 0.15), value: isDragging)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,152 +1,522 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct BrowseView: View {
|
// MARK: - Discover View (Browse)
|
||||||
@StateObject private var vm = BrowseViewModel()
|
// Serendipity-focused browsing with curated shelves.
|
||||||
@State private var showFilters = false
|
// No search bar — use the dedicated Search tab for that.
|
||||||
|
|
||||||
|
struct BrowseView: View {
|
||||||
|
@StateObject private var vm = DiscoverViewModel()
|
||||||
|
@State private var showGenreSheet = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Search bar
|
OfflineBanner()
|
||||||
HStack {
|
|
||||||
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
|
Group {
|
||||||
TextField("Search novels...", text: $vm.searchQuery)
|
if vm.isLoading && vm.trending.isEmpty {
|
||||||
.textInputAutocapitalization(.never)
|
ProgressView()
|
||||||
.autocorrectionDisabled()
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.submitLabel(.search)
|
} else if let errorMsg = vm.error, vm.trending.isEmpty {
|
||||||
.onSubmit { Task { await vm.search() } }
|
VStack(spacing: 16) {
|
||||||
if !vm.searchQuery.isEmpty {
|
|
||||||
Button { vm.clearSearch() } label: {
|
|
||||||
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(10)
|
|
||||||
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10))
|
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
|
|
||||||
// Filter chips row
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
ChipButton(label: "Sort: \(vm.sort.capitalized)", isSelected: vm.sort != "popular", style: .outlined) {
|
|
||||||
showFilters = true
|
|
||||||
}
|
|
||||||
ChipButton(label: "Genre: \(vm.genre == "all" ? "All" : vm.genre.capitalized)", isSelected: vm.genre != "all", style: .outlined) {
|
|
||||||
showFilters = true
|
|
||||||
}
|
|
||||||
ChipButton(label: "Status: \(vm.status == "all" ? "All" : vm.status.capitalized)", isSelected: vm.status != "all", style: .outlined) {
|
|
||||||
showFilters = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
|
||||||
.padding(.bottom, 4)
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
// Results
|
|
||||||
if vm.isLoading && vm.novels.isEmpty {
|
|
||||||
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
} else if vm.novels.isEmpty && !vm.isLoading {
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
if let errMsg = vm.error {
|
|
||||||
Image(systemName: "wifi.slash")
|
Image(systemName: "wifi.slash")
|
||||||
.font(.largeTitle)
|
.font(.largeTitle)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
Text(errMsg)
|
Text(errorMsg)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
Button("Retry") { Task { await vm.loadFirstPage() } }
|
Button("Retry") { Task { await vm.load() } }
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(.amber)
|
.tint(.amber)
|
||||||
} else {
|
|
||||||
EmptyStateView(icon: "magnifyingglass", title: "No results", message: "Try a different search or filter.")
|
|
||||||
}
|
}
|
||||||
}
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
} else {
|
||||||
} else {
|
ScrollView {
|
||||||
ScrollView {
|
VStack(alignment: .leading, spacing: 32) {
|
||||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], spacing: 16) {
|
// Trending shelf
|
||||||
ForEach(vm.novels) { novel in
|
if !vm.trending.isEmpty {
|
||||||
NavigationLink(value: NavDestination.book(novel.slug)) {
|
DiscoverShelf(
|
||||||
BrowseCard(novel: novel)
|
title: "Trending Now",
|
||||||
.bookCoverZoomSource(slug: novel.slug)
|
novels: vm.trending,
|
||||||
|
destination: .browseCategory(
|
||||||
|
sort: "popular",
|
||||||
|
genre: "all",
|
||||||
|
status: "all",
|
||||||
|
title: "Trending Now"
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
// Top Rated shelf
|
||||||
|
if !vm.topRated.isEmpty {
|
||||||
// Infinite scroll trigger
|
DiscoverShelf(
|
||||||
if vm.hasNext {
|
title: "Top Rated",
|
||||||
ProgressView()
|
novels: vm.topRated,
|
||||||
.frame(maxWidth: .infinity)
|
destination: .browseCategory(
|
||||||
.padding()
|
sort: "rating",
|
||||||
.onAppear { Task { await vm.loadNextPage() } }
|
genre: "all",
|
||||||
|
status: "all",
|
||||||
|
title: "Top Rated"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recently Updated shelf
|
||||||
|
if !vm.recentlyUpdated.isEmpty {
|
||||||
|
DiscoverShelf(
|
||||||
|
title: "Recently Updated",
|
||||||
|
novels: vm.recentlyUpdated,
|
||||||
|
destination: .browseCategory(
|
||||||
|
sort: "updated",
|
||||||
|
genre: "all",
|
||||||
|
status: "all",
|
||||||
|
title: "Recently Updated"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// New Releases shelf
|
||||||
|
if !vm.newReleases.isEmpty {
|
||||||
|
DiscoverShelf(
|
||||||
|
title: "New Releases",
|
||||||
|
novels: vm.newReleases,
|
||||||
|
destination: .browseCategory(
|
||||||
|
sort: "new",
|
||||||
|
genre: "all",
|
||||||
|
status: "all",
|
||||||
|
title: "New Releases"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categories button — replaces individual genre shelves
|
||||||
|
CategoriesRow(onTap: { showGenreSheet = true })
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
Color.clear.frame(height: 100)
|
||||||
}
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
.padding()
|
.refreshable { await vm.load() }
|
||||||
}
|
}
|
||||||
.refreshable { await vm.loadFirstPage() }
|
|
||||||
}
|
}
|
||||||
}
|
.navigationTitle("Discover")
|
||||||
.navigationTitle("Discover")
|
.appNavigationDestination()
|
||||||
.appNavigationDestination()
|
.toolbar {
|
||||||
.toolbar {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
HStack(spacing: 16) {
|
||||||
AvatarToolbarButton()
|
DownloadQueueButton()
|
||||||
|
AvatarToolbarButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.task { await vm.load() }
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showFilters) {
|
}
|
||||||
BrowseFiltersView(vm: vm)
|
.sheet(isPresented: $showGenreSheet) {
|
||||||
}
|
GenrePickerSheet()
|
||||||
.task { await vm.loadFirstPage() }
|
|
||||||
.onChange(of: vm.sort) { _, _ in Task { await vm.loadFirstPage() } }
|
|
||||||
.onChange(of: vm.genre) { _, _ in Task { await vm.loadFirstPage() } }
|
|
||||||
.onChange(of: vm.status) { _, _ in Task { await vm.loadFirstPage() } }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Browse card
|
// MARK: - Categories row (Apple Books–style single button)
|
||||||
|
|
||||||
|
private struct CategoriesRow: View {
|
||||||
|
let onTap: () -> Void
|
||||||
|
|
||||||
private struct BrowseCard: View {
|
|
||||||
let novel: BrowseNovel
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
Button(action: onTap) {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(Color.amber.opacity(0.15))
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
Image(systemName: "square.grid.2x2")
|
||||||
|
.font(.system(size: 20, weight: .medium))
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Browse by Genre")
|
||||||
|
.font(.body.weight(.semibold))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
Text("Action, Fantasy, Romance & more")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Genre picker sheet
|
||||||
|
|
||||||
|
private struct GenrePickerSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
private let genres: [(label: String, genre: String, icon: String)] = [
|
||||||
|
("Action", "action", "bolt.fill"),
|
||||||
|
("Fantasy", "fantasy", "wand.and.stars"),
|
||||||
|
("Romance", "romance", "heart.fill"),
|
||||||
|
("Sci-Fi", "sci-fi", "sparkles"),
|
||||||
|
("Mystery", "mystery", "magnifyingglass"),
|
||||||
|
("Horror", "horror", "moon.fill"),
|
||||||
|
("Comedy", "comedy", "face.smiling"),
|
||||||
|
("Adventure", "adventure", "map.fill"),
|
||||||
|
("Martial Arts", "martial arts", "figure.martial.arts"),
|
||||||
|
("Cultivation", "cultivation", "leaf.fill"),
|
||||||
|
("Historical", "historical", "building.columns.fill"),
|
||||||
|
("Slice of Life", "slice of life", "sun.max.fill"),
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
LazyVGrid(
|
||||||
|
columns: [
|
||||||
|
GridItem(.flexible(), spacing: 12),
|
||||||
|
GridItem(.flexible(), spacing: 12)
|
||||||
|
],
|
||||||
|
spacing: 12
|
||||||
|
) {
|
||||||
|
// "All" tile
|
||||||
|
NavigationLink(value: NavDestination.browseCategory(
|
||||||
|
sort: "popular", genre: "all", status: "all", title: "All Novels"
|
||||||
|
)) {
|
||||||
|
GenreTile(label: "All Novels", icon: "books.vertical.fill")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.simultaneousGesture(TapGesture().onEnded { dismiss() })
|
||||||
|
|
||||||
|
ForEach(genres, id: \.genre) { item in
|
||||||
|
NavigationLink(value: NavDestination.browseCategory(
|
||||||
|
sort: "popular",
|
||||||
|
genre: item.genre,
|
||||||
|
status: "all",
|
||||||
|
title: item.label
|
||||||
|
)) {
|
||||||
|
GenreTile(label: item.label, icon: item.icon)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.simultaneousGesture(TapGesture().onEnded { dismiss() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
}
|
||||||
|
.navigationTitle("Genres")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.appNavigationDestination()
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.medium, .large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
.presentationCornerRadius(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct GenreTile: View {
|
||||||
|
let label: String
|
||||||
|
let icon: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
.frame(width: 24)
|
||||||
|
Text(label)
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Discover Shelf (horizontal scrolling)
|
||||||
|
|
||||||
|
private struct DiscoverShelf: View {
|
||||||
|
let title: String
|
||||||
|
let novels: [BrowseNovel]
|
||||||
|
let destination: NavDestination
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
// Header with "See All" button
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
// Amber accent bar — matches ShelfHeader style used on Home and UserProfile
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.amber)
|
||||||
|
.frame(width: 3, height: 18)
|
||||||
|
Text(title)
|
||||||
|
.font(.title3.bold())
|
||||||
|
Spacer()
|
||||||
|
NavigationLink(value: destination) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text("See All")
|
||||||
|
.font(.subheadline)
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption.bold())
|
||||||
|
}
|
||||||
|
.foregroundStyle(.amber)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
// Horizontal scroll — leading padding aligns cards with header
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
ForEach(novels) { novel in
|
||||||
|
NavigationLink(value: NavDestination.book(novel.slug)) {
|
||||||
|
DiscoverShelfCard(novel: novel)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 4) // let shadows breathe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shelf card (card-style)
|
||||||
|
|
||||||
|
private struct DiscoverShelfCard: View {
|
||||||
|
let novel: BrowseNovel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: .topLeading) {
|
||||||
AsyncCoverImage(url: novel.cover)
|
AsyncCoverImage(url: novel.cover)
|
||||||
.frame(height: 200)
|
.frame(width: 120, height: 173) // 2:3 ratio
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.bookCoverZoomSource(slug: novel.slug)
|
||||||
|
|
||||||
if !novel.rank.isEmpty {
|
if !novel.rank.isEmpty {
|
||||||
Text(novel.rank)
|
Text(novel.rank)
|
||||||
.font(.caption2.bold())
|
.font(.caption2.bold())
|
||||||
.padding(.horizontal, 6).padding(.vertical, 3)
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 3)
|
||||||
.background(.ultraThinMaterial, in: Capsule())
|
.background(.ultraThinMaterial, in: Capsule())
|
||||||
.padding(6)
|
.padding(6)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(novel.title)
|
|
||||||
.font(.caption.bold()).lineLimit(2)
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
if !novel.chapters.isEmpty {
|
Text(novel.title)
|
||||||
Text(novel.chapters).font(.caption2).foregroundStyle(.secondary)
|
.font(.caption.bold())
|
||||||
|
.lineLimit(2)
|
||||||
|
.frame(width: 120, alignment: .leading)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
|
||||||
|
if !novel.chapters.isEmpty {
|
||||||
|
Text(novel.chapters)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.frame(width: 120, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
.frame(width: 136)
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Browse Category View (full grid for "See All")
|
||||||
|
|
||||||
|
struct BrowseCategoryView: View {
|
||||||
|
let sort: String
|
||||||
|
let genre: String
|
||||||
|
let status: String
|
||||||
|
let title: String
|
||||||
|
|
||||||
|
@StateObject private var vm: BrowseViewModel
|
||||||
|
@State private var showFilters = false
|
||||||
|
|
||||||
|
init(sort: String, genre: String, status: String, title: String) {
|
||||||
|
self.sort = sort
|
||||||
|
self.genre = genre
|
||||||
|
self.status = status
|
||||||
|
self.title = title
|
||||||
|
|
||||||
|
let viewModel = BrowseViewModel()
|
||||||
|
viewModel.sort = sort
|
||||||
|
viewModel.genre = genre
|
||||||
|
viewModel.status = status
|
||||||
|
_vm = StateObject(wrappedValue: viewModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if vm.isLoading && vm.novels.isEmpty {
|
||||||
|
ProgressView()
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else if let errorMsg = vm.error, vm.novels.isEmpty {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "wifi.slash")
|
||||||
|
.font(.largeTitle)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(errorMsg)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal)
|
||||||
|
Button("Retry") { Task { await vm.loadFirstPage() } }
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.amber)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
LazyVGrid(
|
||||||
|
columns: [
|
||||||
|
GridItem(.flexible(), spacing: 14),
|
||||||
|
GridItem(.flexible(), spacing: 14)
|
||||||
|
],
|
||||||
|
spacing: 14
|
||||||
|
) {
|
||||||
|
ForEach(vm.novels) { novel in
|
||||||
|
NavigationLink(value: NavDestination.book(novel.slug)) {
|
||||||
|
BrowseCategoryCard(novel: novel)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.onAppear {
|
||||||
|
// Infinite scroll
|
||||||
|
if novel.id == vm.novels.last?.id {
|
||||||
|
Task { await vm.loadNextPage() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top, 12)
|
||||||
|
.padding(.bottom, 100)
|
||||||
|
|
||||||
|
if vm.isLoading && !vm.novels.isEmpty {
|
||||||
|
ProgressView()
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable { await vm.loadFirstPage() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(title)
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.appNavigationDestination()
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
showFilters = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "slider.horizontal.3")
|
||||||
|
.foregroundStyle(.amber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showFilters) {
|
||||||
|
BrowseFiltersView(vm: vm)
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
if vm.novels.isEmpty {
|
||||||
|
await vm.loadFirstPage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Filters sheet
|
private struct BrowseCategoryCard: View {
|
||||||
|
let novel: BrowseNovel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
AsyncCoverImage(url: novel.cover)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.aspectRatio(2/3, contentMode: .fit)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.bookCoverZoomSource(slug: novel.slug)
|
||||||
|
|
||||||
|
if !novel.rank.isEmpty {
|
||||||
|
Text(novel.rank)
|
||||||
|
.font(.caption2.bold())
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(.ultraThinMaterial, in: Capsule())
|
||||||
|
.padding(6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(novel.title)
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.lineLimit(2)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
if !novel.author.isEmpty {
|
||||||
|
Text(novel.author)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !novel.chapters.isEmpty {
|
||||||
|
Text(novel.chapters)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Filters sheet (kept for future "See All" views)
|
||||||
|
|
||||||
struct BrowseFiltersView: View {
|
struct BrowseFiltersView: View {
|
||||||
@ObservedObject var vm: BrowseViewModel
|
@ObservedObject var vm: BrowseViewModel
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
let sortOptions = ["popular", "new", "updated", "rating", "rank"]
|
let sortOptions = ["popular", "new", "updated", "rating", "rank"]
|
||||||
let genreOptions = ["all", "action", "fantasy", "romance", "sci-fi", "mystery",
|
let genreOptions = ["all", "action", "fantasy", "romance", "sci-fi", "mystery",
|
||||||
"horror", "comedy", "drama", "adventure", "martial arts",
|
"horror", "comedy", "drama", "adventure", "martial arts",
|
||||||
"cultivation", "magic", "supernatural", "historical", "slice of life"]
|
"cultivation", "magic", "supernatural", "historical", "slice of life"]
|
||||||
let statusOptions = ["all", "ongoing", "completed"]
|
let statusOptions = ["all", "ongoing", "completed"]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Download Audio Button
|
||||||
|
// Shows download status and allows users to download/delete offline audio.
|
||||||
|
// Uses symbolEffect + spring animations for a modern, tactile feel.
|
||||||
|
|
||||||
|
struct DownloadAudioButton: View {
|
||||||
|
let slug: String
|
||||||
|
let chapter: Int
|
||||||
|
let voice: String
|
||||||
|
let theme: ReaderTheme
|
||||||
|
|
||||||
|
@StateObject private var downloadService = AudioDownloadService.shared
|
||||||
|
@State private var showDownloadMenu = false
|
||||||
|
@State private var bounceDownload = false
|
||||||
|
|
||||||
|
private var downloadKey: String {
|
||||||
|
AudioDownloadService.shared.makeKey(slug: slug, chapter: chapter, voice: voice)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isDownloaded: Bool {
|
||||||
|
downloadService.isDownloaded(slug: slug, chapter: chapter, voice: voice)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var downloadProgress: DownloadProgress? {
|
||||||
|
downloadService.downloads[downloadKey]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var accentColor: Color {
|
||||||
|
theme == .sepia ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
showDownloadMenu = true
|
||||||
|
} label: {
|
||||||
|
ZStack {
|
||||||
|
// Background pill
|
||||||
|
Circle()
|
||||||
|
.fill(backgroundFillColor)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
|
stateIcon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: isDownloaded)
|
||||||
|
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: downloadProgress?.status.isDownloading)
|
||||||
|
.confirmationDialog("Audio Download", isPresented: $showDownloadMenu) {
|
||||||
|
if isDownloaded {
|
||||||
|
Button("Delete Download", role: .destructive) {
|
||||||
|
Task {
|
||||||
|
try? await downloadService.deleteDownload(slug: slug, chapter: chapter, voice: voice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let progress = downloadProgress, case .downloading = progress.status {
|
||||||
|
Button("Cancel Download", role: .destructive) {
|
||||||
|
downloadService.cancelDownload(slug: slug, chapter: chapter, voice: voice)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button("Download for Offline") {
|
||||||
|
Task {
|
||||||
|
try? await downloadService.download(slug: slug, chapter: chapter, voice: voice)
|
||||||
|
}
|
||||||
|
withAnimation(.spring(response: 0.4, dampingFraction: 0.5)) { bounceDownload.toggle() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
if isDownloaded {
|
||||||
|
Text("This chapter's audio is downloaded for offline listening.")
|
||||||
|
} else if let progress = downloadProgress, case .downloading = progress.status {
|
||||||
|
Text("Downloading… \(Int(progress.progress * 100))%")
|
||||||
|
} else {
|
||||||
|
Text("Download this chapter's audio to listen offline without internet connection.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Background
|
||||||
|
|
||||||
|
private var backgroundFillColor: Color {
|
||||||
|
if isDownloaded {
|
||||||
|
return Color.green.opacity(0.15)
|
||||||
|
} else if let progress = downloadProgress, case .downloading = progress.status {
|
||||||
|
return accentColor.opacity(0.1)
|
||||||
|
} else if let progress = downloadProgress, case .failed = progress.status {
|
||||||
|
return Color.red.opacity(0.12)
|
||||||
|
} else {
|
||||||
|
return theme.textColor.opacity(0.07)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Icon
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var stateIcon: some View {
|
||||||
|
if isDownloaded {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 22))
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
.symbolEffect(.bounce, value: isDownloaded)
|
||||||
|
.transition(.scale.combined(with: .opacity))
|
||||||
|
|
||||||
|
} else if let progress = downloadProgress {
|
||||||
|
switch progress.status {
|
||||||
|
case .downloading:
|
||||||
|
ZStack {
|
||||||
|
// Track ring
|
||||||
|
Circle()
|
||||||
|
.stroke(accentColor.opacity(0.18), lineWidth: 2.5)
|
||||||
|
// Progress arc
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: progress.progress)
|
||||||
|
.stroke(
|
||||||
|
accentColor,
|
||||||
|
style: StrokeStyle(lineWidth: 2.5, lineCap: .round)
|
||||||
|
)
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: progress.progress)
|
||||||
|
// Down arrow
|
||||||
|
Image(systemName: "arrow.down")
|
||||||
|
.font(.system(size: 12, weight: .bold))
|
||||||
|
.foregroundStyle(accentColor)
|
||||||
|
}
|
||||||
|
.frame(width: 26, height: 26)
|
||||||
|
.transition(.scale.combined(with: .opacity))
|
||||||
|
|
||||||
|
case .failed:
|
||||||
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
|
.font(.system(size: 22))
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.symbolEffect(.pulse)
|
||||||
|
.transition(.scale.combined(with: .opacity))
|
||||||
|
|
||||||
|
case .completed:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Idle — not yet downloaded
|
||||||
|
Image(systemName: "arrow.down.circle")
|
||||||
|
.font(.system(size: 22))
|
||||||
|
.foregroundStyle(theme.textColor.opacity(0.55))
|
||||||
|
.symbolEffect(.bounce, value: bounceDownload)
|
||||||
|
.transition(.scale.combined(with: .opacity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension DownloadStatus {
|
||||||
|
var isDownloading: Bool {
|
||||||
|
if case .downloading = self { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -141,3 +141,24 @@ struct ChipButton: View {
|
|||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Shelf header (amber accent bar + title)
|
||||||
|
// Used by HomeView, UserProfileView, BrowseView's DiscoverShelf, and any future shelf screen.
|
||||||
|
// Call sites that need trailing content (e.g. a "See All" NavigationLink) wrap this in an HStack.
|
||||||
|
|
||||||
|
struct ShelfHeader: View {
|
||||||
|
let title: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
// 3-pt amber accent bar — the brand visual anchor for all shelf titles
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.amber)
|
||||||
|
.frame(width: 3, height: 18)
|
||||||
|
Text(title)
|
||||||
|
.font(.title3.bold())
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.bottom, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
32
ios/LibNovel/LibNovel/Views/Components/OfflineBanner.swift
Normal file
32
ios/LibNovel/LibNovel/Views/Components/OfflineBanner.swift
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Offline Banner
|
||||||
|
// Subtle banner shown at top of screen when network is unavailable
|
||||||
|
|
||||||
|
struct OfflineBanner: View {
|
||||||
|
@EnvironmentObject var networkMonitor: NetworkMonitor
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !networkMonitor.isConnected {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "wifi.slash")
|
||||||
|
.font(.caption)
|
||||||
|
Text("You're offline")
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
Spacer()
|
||||||
|
Text("Showing cached content")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color.orange.opacity(0.15))
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.orange.opacity(0.3))
|
||||||
|
.frame(height: 1)
|
||||||
|
}
|
||||||
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
340
ios/LibNovel/LibNovel/Views/Downloads/DownloadQueueButton.swift
Normal file
340
ios/LibNovel/LibNovel/Views/Downloads/DownloadQueueButton.swift
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Download Queue Toolbar Button
|
||||||
|
// Compact toolbar button that shows active download status and opens queue management sheet.
|
||||||
|
// Shows:
|
||||||
|
// - Download icon with badge count when downloads are active
|
||||||
|
// - Progress ring around icon
|
||||||
|
// - Taps opens DownloadQueueSheet for management
|
||||||
|
|
||||||
|
struct DownloadQueueButton: View {
|
||||||
|
@StateObject private var downloadService = AudioDownloadService.shared
|
||||||
|
@State private var showQueue = false
|
||||||
|
|
||||||
|
private var activeDownloads: [DownloadProgress] {
|
||||||
|
downloadService.downloads.values.filter { $0.status == .downloading }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasActiveDownloads: Bool {
|
||||||
|
!activeDownloads.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
private var averageProgress: Double {
|
||||||
|
guard !activeDownloads.isEmpty else { return 0 }
|
||||||
|
let total = activeDownloads.reduce(0.0) { $0 + $1.progress }
|
||||||
|
return total / Double(activeDownloads.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
showQueue = true
|
||||||
|
} label: {
|
||||||
|
ZStack {
|
||||||
|
// Progress ring (only shown when downloading)
|
||||||
|
if hasActiveDownloads {
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.amber.opacity(0.3), lineWidth: 2)
|
||||||
|
.frame(width: 30, height: 30)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: averageProgress)
|
||||||
|
.stroke(Color.amber, style: StrokeStyle(lineWidth: 2, lineCap: .round))
|
||||||
|
.frame(width: 30, height: 30)
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
.animation(.easeInOut(duration: 0.3), value: averageProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download icon
|
||||||
|
Image(systemName: hasActiveDownloads ? "arrow.down.circle.fill" : "arrow.down.circle")
|
||||||
|
.font(.system(size: 22))
|
||||||
|
.foregroundStyle(hasActiveDownloads ? .amber : .secondary)
|
||||||
|
.symbolRenderingMode(.hierarchical)
|
||||||
|
|
||||||
|
// Badge count (top-right corner)
|
||||||
|
if activeDownloads.count > 0 {
|
||||||
|
VStack {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Text("\(activeDownloads.count)")
|
||||||
|
.font(.system(size: 10, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(3)
|
||||||
|
.frame(minWidth: 16)
|
||||||
|
.background(Circle().fill(Color.red))
|
||||||
|
.offset(x: 6, y: -6)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(width: 30, height: 30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.opacity(hasActiveDownloads || downloadService.downloadedChapters.count > 0 ? 1 : 0.6)
|
||||||
|
.sheet(isPresented: $showQueue) {
|
||||||
|
DownloadQueueSheet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Download Queue Management Sheet
|
||||||
|
// Bottom sheet showing active downloads and quick management options
|
||||||
|
|
||||||
|
struct DownloadQueueSheet: View {
|
||||||
|
@StateObject private var downloadService = AudioDownloadService.shared
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
private var activeDownloads: [(key: String, value: DownloadProgress)] {
|
||||||
|
downloadService.downloads
|
||||||
|
.filter { $0.value.status == .downloading }
|
||||||
|
.sorted { $0.key < $1.key }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var failedDownloads: [(key: String, value: DownloadProgress)] {
|
||||||
|
downloadService.downloads.compactMap { key, value in
|
||||||
|
if case .failed = value.status {
|
||||||
|
return (key, value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
.sorted { $0.key < $1.key }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var totalDownloaded: Int {
|
||||||
|
downloadService.downloadedChapters.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if activeDownloads.isEmpty && failedDownloads.isEmpty && totalDownloaded == 0 {
|
||||||
|
emptyState
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
// Active downloads section
|
||||||
|
if !activeDownloads.isEmpty {
|
||||||
|
Section {
|
||||||
|
ForEach(activeDownloads, id: \.key) { key, progress in
|
||||||
|
ActiveDownloadRow(progress: progress)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
HStack {
|
||||||
|
Text("Downloading")
|
||||||
|
Spacer()
|
||||||
|
Text("\(activeDownloads.count)")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failed downloads section
|
||||||
|
if !failedDownloads.isEmpty {
|
||||||
|
Section("Failed") {
|
||||||
|
ForEach(failedDownloads, id: \.key) { key, progress in
|
||||||
|
FailedDownloadRow(progress: progress, key: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick stats section
|
||||||
|
Section {
|
||||||
|
NavigationLink {
|
||||||
|
DownloadsView()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
Text("Downloaded Chapters")
|
||||||
|
Spacer()
|
||||||
|
Text("\(totalDownloaded)")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "internaldrive")
|
||||||
|
.foregroundStyle(.amber)
|
||||||
|
Text("Storage Used")
|
||||||
|
Spacer()
|
||||||
|
Text(storageUsedFormatted)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel all option (only show if there are active downloads)
|
||||||
|
if !activeDownloads.isEmpty {
|
||||||
|
Section {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
activeDownloads.forEach { key, progress in
|
||||||
|
downloadService.cancelDownload(
|
||||||
|
slug: progress.slug,
|
||||||
|
chapter: progress.chapter,
|
||||||
|
voice: progress.voice
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Text("Cancel All Downloads")
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Download Queue")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Done") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.foregroundStyle(.amber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.medium, .large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Empty State
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var emptyState: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "arrow.down.circle")
|
||||||
|
.font(.system(size: 56))
|
||||||
|
.foregroundStyle(.secondary.opacity(0.5))
|
||||||
|
Text("No Active Downloads")
|
||||||
|
.font(.title2.bold())
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
Text("Audio chapters you download will appear here")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 40)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private var storageUsedFormatted: String {
|
||||||
|
let bytes = downloadService.getTotalStorageUsed()
|
||||||
|
return ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Active Download Row
|
||||||
|
|
||||||
|
private struct ActiveDownloadRow: View {
|
||||||
|
let progress: DownloadProgress
|
||||||
|
@StateObject private var downloadService = AudioDownloadService.shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// Book/Chapter info
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(formatSlug(progress.slug))
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.lineLimit(1)
|
||||||
|
Text("Chapter \(progress.chapter)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Progress indicator
|
||||||
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
|
Text("\(Int(progress.progress * 100))%")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.amber)
|
||||||
|
.monospacedDigit()
|
||||||
|
|
||||||
|
ProgressView(value: progress.progress)
|
||||||
|
.frame(width: 60)
|
||||||
|
.tint(.amber)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel button
|
||||||
|
Button {
|
||||||
|
downloadService.cancelDownload(
|
||||||
|
slug: progress.slug,
|
||||||
|
chapter: progress.chapter,
|
||||||
|
voice: progress.voice
|
||||||
|
)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatSlug(_ slug: String) -> String {
|
||||||
|
// Convert slug to readable title (e.g., "my-book-title" -> "My Book Title")
|
||||||
|
slug.split(separator: "-")
|
||||||
|
.map { $0.capitalized }
|
||||||
|
.joined(separator: " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Failed Download Row
|
||||||
|
|
||||||
|
private struct FailedDownloadRow: View {
|
||||||
|
let progress: DownloadProgress
|
||||||
|
let key: String
|
||||||
|
@StateObject private var downloadService = AudioDownloadService.shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(formatSlug(progress.slug))
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.lineLimit(1)
|
||||||
|
Text("Chapter \(progress.chapter)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Retry button
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
// Remove failed status
|
||||||
|
downloadService.downloads.removeValue(forKey: key)
|
||||||
|
// Retry download
|
||||||
|
try? await downloadService.download(
|
||||||
|
slug: progress.slug,
|
||||||
|
chapter: progress.chapter,
|
||||||
|
voice: progress.voice
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("Retry")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.amber)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color.amber.opacity(0.15), in: Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatSlug(_ slug: String) -> String {
|
||||||
|
slug.split(separator: "-")
|
||||||
|
.map { $0.capitalized }
|
||||||
|
.joined(separator: " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
216
ios/LibNovel/LibNovel/Views/Downloads/DownloadsView.swift
Normal file
216
ios/LibNovel/LibNovel/Views/Downloads/DownloadsView.swift
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Downloads Management View
|
||||||
|
// Shows all downloaded audio chapters and allows deletion
|
||||||
|
|
||||||
|
struct DownloadsView: View {
|
||||||
|
@StateObject private var downloadService = AudioDownloadService.shared
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
private var sortedDownloads: [(key: String, value: DownloadProgress)] {
|
||||||
|
downloadService.downloads.sorted { $0.key < $1.key }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var totalStorageFormatted: String {
|
||||||
|
let bytes = downloadService.getTotalStorageUsed()
|
||||||
|
return ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if downloadService.downloadedChapters.isEmpty && downloadService.downloads.isEmpty {
|
||||||
|
// Empty state
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "arrow.down.circle")
|
||||||
|
.font(.system(size: 56))
|
||||||
|
.foregroundStyle(.secondary.opacity(0.5))
|
||||||
|
Text("No Downloads")
|
||||||
|
.font(.title2.bold())
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
Text("Downloaded audio chapters will appear here for offline listening")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 40)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
// Storage info section
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "internaldrive")
|
||||||
|
.foregroundStyle(.amber)
|
||||||
|
Text("Total Storage Used")
|
||||||
|
Spacer()
|
||||||
|
Text(totalStorageFormatted)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active downloads
|
||||||
|
if !downloadService.downloads.isEmpty {
|
||||||
|
Section("Active Downloads") {
|
||||||
|
ForEach(sortedDownloads, id: \.key) { key, progress in
|
||||||
|
DownloadRow(progress: progress, key: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Downloaded chapters
|
||||||
|
if !downloadService.downloadedChapters.isEmpty {
|
||||||
|
Section("Downloaded (\(downloadService.downloadedChapters.count))") {
|
||||||
|
ForEach(Array(downloadService.downloadedChapters.sorted()), id: \.self) { key in
|
||||||
|
DownloadedChapterRow(key: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all button
|
||||||
|
if !downloadService.downloadedChapters.isEmpty {
|
||||||
|
Section {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
try? downloadService.deleteAllDownloads()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Text("Delete All Downloads")
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Downloads")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
.foregroundStyle(.amber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Download Row (in progress)
|
||||||
|
|
||||||
|
private struct DownloadRow: View {
|
||||||
|
let progress: DownloadProgress
|
||||||
|
let key: String
|
||||||
|
@StateObject private var downloadService = AudioDownloadService.shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Chapter \(progress.chapter)")
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
Text(progress.slug)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if progress.status == .downloading {
|
||||||
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
|
Text("\(Int(progress.progress * 100))%")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
ProgressView(value: progress.progress)
|
||||||
|
.frame(width: 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
downloadService.cancelDownload(slug: progress.slug, chapter: progress.chapter, voice: progress.voice)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
} else if case .failed(let error) = progress.status {
|
||||||
|
VStack(alignment: .trailing) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
Text("Failed")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Downloaded Chapter Row
|
||||||
|
|
||||||
|
private struct DownloadedChapterRow: View {
|
||||||
|
let key: String
|
||||||
|
@StateObject private var downloadService = AudioDownloadService.shared
|
||||||
|
|
||||||
|
private var components: (slug: String, chapter: String, voice: String) {
|
||||||
|
let parts = key.split(separator: "-")
|
||||||
|
if parts.count >= 3 {
|
||||||
|
return (String(parts[0]), String(parts[1]), parts[2...].joined(separator: "-"))
|
||||||
|
}
|
||||||
|
return ("", "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Chapter \(components.chapter)")
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(components.slug)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("•")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(formatVoice(components.voice))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
let parts = components
|
||||||
|
if let chapter = Int(parts.chapter) {
|
||||||
|
try? downloadService.deleteDownload(slug: parts.slug, chapter: chapter, voice: parts.voice)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatVoice(_ voice: String) -> String {
|
||||||
|
// Format voice name (e.g., "af_bella" -> "Bella (US F)")
|
||||||
|
let parts = voice.split(separator: "_")
|
||||||
|
guard parts.count == 2 else { return voice }
|
||||||
|
|
||||||
|
let prefix = String(parts[0])
|
||||||
|
let name = String(parts[1]).capitalized
|
||||||
|
|
||||||
|
let gender = prefix.hasSuffix("f") ? "F" : prefix.hasSuffix("m") ? "M" : ""
|
||||||
|
let accent = prefix.hasPrefix("af") ? "US" : prefix.hasPrefix("bf") || prefix.hasPrefix("bm") ? "UK" : ""
|
||||||
|
|
||||||
|
if !gender.isEmpty && !accent.isEmpty {
|
||||||
|
return "\(name) (\(accent) \(gender))"
|
||||||
|
} else if !gender.isEmpty {
|
||||||
|
return "\(name) (\(gender))"
|
||||||
|
} else {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,13 +3,25 @@ import SwiftUI
|
|||||||
struct HomeView: View {
|
struct HomeView: View {
|
||||||
@StateObject private var vm = HomeViewModel()
|
@StateObject private var vm = HomeViewModel()
|
||||||
@EnvironmentObject var authStore: AuthStore
|
@EnvironmentObject var authStore: AuthStore
|
||||||
|
@StateObject private var downloadService = AudioDownloadService.shared
|
||||||
|
|
||||||
|
private var offlineBooks: [Book] {
|
||||||
|
let offlineSlugs = downloadService.getOfflineBookSlugs()
|
||||||
|
// Filter continue reading items that have offline downloads
|
||||||
|
return vm.continueReading
|
||||||
|
.filter { offlineSlugs.contains($0.book.slug) }
|
||||||
|
.map { $0.book }
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ScrollView {
|
VStack(spacing: 0) {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
OfflineBanner()
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
|
||||||
// Continue reading — all in-progress books as a horizontal shelf (Apple Books style)
|
// Continue reading — all in-progress books as a horizontal shelf (Apple Books style)
|
||||||
if !vm.continueReading.isEmpty {
|
if !vm.continueReading.isEmpty {
|
||||||
ShelfHeader(title: "Continue Reading")
|
ShelfHeader(title: "Continue Reading")
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
@@ -38,6 +50,47 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
.padding(.bottom, 28)
|
.padding(.bottom, 28)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Offline books — books with downloaded chapters
|
||||||
|
if !offlineBooks.isEmpty {
|
||||||
|
HStack {
|
||||||
|
ShelfHeader(title: "Downloaded for Offline")
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "wifi.slash")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.trailing, 16)
|
||||||
|
}
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(alignment: .top, spacing: 14) {
|
||||||
|
ForEach(offlineBooks) { book in
|
||||||
|
NavigationLink(value: NavDestination.book(book.slug)) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
ShelfBookCard(book: book)
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "arrow.down.circle.fill")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
Text("\(downloadService.getDownloadedChapterCount(for: book.slug)) chapters")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.contextMenu {
|
||||||
|
ShareLink(item: shareURL(for: book)) {
|
||||||
|
Label("Share", systemImage: "square.and.arrow.up")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 28)
|
||||||
|
}
|
||||||
|
|
||||||
// Stats strip
|
// Stats strip
|
||||||
if let stats = vm.stats {
|
if let stats = vm.stats {
|
||||||
@@ -119,9 +172,15 @@ struct HomeView: View {
|
|||||||
.errorAlert($vm.error)
|
.errorAlert($vm.error)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
AvatarToolbarButton()
|
HStack(spacing: 8) {
|
||||||
|
DownloadQueueButton()
|
||||||
|
Divider()
|
||||||
|
.frame(height: 18)
|
||||||
|
AvatarToolbarButton()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,19 +209,6 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Shelf header
|
|
||||||
|
|
||||||
private struct ShelfHeader: View {
|
|
||||||
let title: String
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Text(title)
|
|
||||||
.font(.title3.bold())
|
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.bottom, 10)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Horizontal shelf: continue reading card (Apple Books style)
|
// MARK: - Horizontal shelf: continue reading card (Apple Books style)
|
||||||
|
|
||||||
private struct ContinueReadingCard: View {
|
private struct ContinueReadingCard: View {
|
||||||
@@ -172,29 +218,51 @@ private struct ContinueReadingCard: View {
|
|||||||
guard item.book.totalChapters > 0 else { return 0 }
|
guard item.book.totalChapters > 0 else { return 0 }
|
||||||
return min(1.0, Double(item.chapter) / Double(item.book.totalChapters))
|
return min(1.0, Double(item.chapter) / Double(item.book.totalChapters))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var progressText: String {
|
||||||
|
let percentage = progressFraction * 100
|
||||||
|
|
||||||
|
// For books with many chapters, show decimal precision when less than 10%
|
||||||
|
if percentage < 10 && percentage > 0 {
|
||||||
|
return String(format: "%.1f%% complete", percentage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, round to nearest integer (min 1% if any progress exists)
|
||||||
|
let rounded = max(1, Int(round(percentage)))
|
||||||
|
return "\(rounded)% complete"
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
// Cover
|
// Cover
|
||||||
ZStack(alignment: .bottomLeading) {
|
ZStack(alignment: .bottom) {
|
||||||
AsyncCoverImage(url: item.book.cover)
|
AsyncCoverImage(url: item.book.cover)
|
||||||
.frame(width: 130, height: 188)
|
.frame(width: 130, height: 188)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
.shadow(color: .black.opacity(0.18), radius: 6, y: 3)
|
.shadow(color: .black.opacity(0.22), radius: 8, y: 4)
|
||||||
.bookCoverZoomSource(slug: item.book.slug)
|
.bookCoverZoomSource(slug: item.book.slug)
|
||||||
|
|
||||||
// "Continue" pill badge at bottom-left
|
// Gradient scrim so badge is always readable
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.black.opacity(0), Color.black.opacity(0.55)],
|
||||||
|
startPoint: .center,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.frame(height: 60)
|
||||||
|
|
||||||
|
// "Continue" pill badge — centered at bottom over the scrim
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: "play.fill")
|
Image(systemName: "play.fill")
|
||||||
.font(.system(size: 8, weight: .bold))
|
.font(.system(size: 8, weight: .bold))
|
||||||
Text("Ch.\(item.chapter)")
|
Text("Ch.\(item.chapter)")
|
||||||
.font(.system(size: 10, weight: .bold))
|
.font(.system(size: 10, weight: .bold))
|
||||||
}
|
}
|
||||||
.foregroundStyle(.black.opacity(0.85))
|
.foregroundStyle(.white)
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 9)
|
||||||
.padding(.vertical, 5)
|
.padding(.vertical, 5)
|
||||||
.background(Capsule().fill(Color.amber))
|
.background(Capsule().fill(Color.amber))
|
||||||
.padding(8)
|
.padding(.bottom, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Title
|
// Title
|
||||||
@@ -210,15 +278,15 @@ private struct ContinueReadingCard: View {
|
|||||||
Capsule()
|
Capsule()
|
||||||
.fill(Color.secondary.opacity(0.2))
|
.fill(Color.secondary.opacity(0.2))
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(Color.amber.opacity(0.85))
|
.fill(Color.amber.opacity(0.9))
|
||||||
.frame(width: max(4, geo.size.width * progressFraction))
|
.frame(width: max(4, geo.size.width * progressFraction))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: 130, height: 3)
|
.frame(width: 130, height: 3)
|
||||||
|
|
||||||
// Percent label — floor at 1% so early chapters don't display "0%"
|
// Progress label with smart rounding
|
||||||
Text("\(max(1, Int(progressFraction * 100)))% complete")
|
Text(progressText)
|
||||||
.font(.caption2)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.frame(width: 130)
|
.frame(width: 130)
|
||||||
@@ -232,11 +300,22 @@ private struct ShelfBookCard: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
AsyncCoverImage(url: book.cover)
|
ZStack(alignment: .topTrailing) {
|
||||||
.frame(width: 110, height: 158)
|
AsyncCoverImage(url: book.cover)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.frame(width: 110, height: 158)
|
||||||
.shadow(color: .black.opacity(0.12), radius: 4, y: 2)
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
.bookCoverZoomSource(slug: book.slug)
|
.shadow(color: .black.opacity(0.12), radius: 4, y: 2)
|
||||||
|
.bookCoverZoomSource(slug: book.slug)
|
||||||
|
|
||||||
|
// Chapter count badge
|
||||||
|
Text("\(book.totalChapters) ch")
|
||||||
|
.font(.system(size: 9, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(Capsule().fill(Color.black.opacity(0.55)))
|
||||||
|
.padding(6)
|
||||||
|
}
|
||||||
|
|
||||||
Text(book.title)
|
Text(book.title)
|
||||||
.font(.caption.bold())
|
.font(.caption.bold())
|
||||||
@@ -308,10 +387,10 @@ private struct StatPill: View {
|
|||||||
let label: String
|
let label: String
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 5) {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 14, weight: .medium))
|
.font(.system(size: 17, weight: .semibold))
|
||||||
.foregroundStyle(Color.amber.opacity(0.8))
|
.foregroundStyle(Color.amber)
|
||||||
Text(value)
|
Text(value)
|
||||||
.font(.subheadline.bold().monospacedDigit())
|
.font(.subheadline.bold().monospacedDigit())
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(.primary)
|
||||||
|
|||||||
@@ -6,31 +6,30 @@ struct LibraryView: View {
|
|||||||
@State private var sortOrder: SortOrder = .recentlyRead
|
@State private var sortOrder: SortOrder = .recentlyRead
|
||||||
@State private var readingFilter: ReadingFilter = .all
|
@State private var readingFilter: ReadingFilter = .all
|
||||||
@State private var selectedGenre: String = "all"
|
@State private var selectedGenre: String = "all"
|
||||||
@State private var searchText = ""
|
|
||||||
|
|
||||||
enum SortOrder: String, CaseIterable {
|
enum SortOrder: String, CaseIterable {
|
||||||
case recentlyRead = "Recent"
|
case recentlyRead = "Recent"
|
||||||
case title = "Title"
|
case title = "Title"
|
||||||
case author = "Author"
|
case author = "Author"
|
||||||
case progress = "Progress"
|
case progress = "Progress"
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ReadingFilter: String, CaseIterable {
|
enum ReadingFilter: String, CaseIterable {
|
||||||
case all = "All"
|
case all = "All"
|
||||||
case inProgress = "In Progress"
|
case inProgress = "In Progress"
|
||||||
case completed = "Completed"
|
case completed = "Completed"
|
||||||
}
|
}
|
||||||
|
|
||||||
// All distinct genres across the library, sorted alphabetically.
|
// All distinct genres across the library, sorted alphabetically.
|
||||||
private var availableGenres: [String] {
|
private var availableGenres: [String] {
|
||||||
let all = vm.items.flatMap { $0.book.genres }
|
let all = vm.items.flatMap { $0.book.genres }
|
||||||
let unique = Array(Set(all)).sorted()
|
let unique = Array(Set(all)).sorted()
|
||||||
return unique
|
return unique
|
||||||
}
|
}
|
||||||
|
|
||||||
private var filtered: [LibraryItem] {
|
private var filtered: [LibraryItem] {
|
||||||
var result = vm.items
|
var result = vm.items
|
||||||
|
|
||||||
// 1. Reading filter
|
// 1. Reading filter
|
||||||
switch readingFilter {
|
switch readingFilter {
|
||||||
case .all:
|
case .all:
|
||||||
@@ -40,12 +39,12 @@ struct LibraryView: View {
|
|||||||
case .completed:
|
case .completed:
|
||||||
result = result.filter { isCompleted($0) }
|
result = result.filter { isCompleted($0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Genre filter
|
// 2. Genre filter
|
||||||
if selectedGenre != "all" {
|
if selectedGenre != "all" {
|
||||||
result = result.filter { $0.book.genres.contains(selectedGenre) }
|
result = result.filter { $0.book.genres.contains(selectedGenre) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Sort
|
// 3. Sort
|
||||||
switch sortOrder {
|
switch sortOrder {
|
||||||
case .recentlyRead:
|
case .recentlyRead:
|
||||||
@@ -57,18 +56,10 @@ struct LibraryView: View {
|
|||||||
case .progress:
|
case .progress:
|
||||||
result = result.sorted { ($0.lastChapter ?? 0) > ($1.lastChapter ?? 0) }
|
result = result.sorted { ($0.lastChapter ?? 0) > ($1.lastChapter ?? 0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Search
|
|
||||||
if !searchText.isEmpty {
|
|
||||||
result = result.filter {
|
|
||||||
$0.book.title.localizedCaseInsensitiveContains(searchText) ||
|
|
||||||
$0.book.author.localizedCaseInsensitiveContains(searchText)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isCompleted(_ item: LibraryItem) -> Bool {
|
private func isCompleted(_ item: LibraryItem) -> Bool {
|
||||||
// Treat as completed if book status is "completed" OR
|
// Treat as completed if book status is "completed" OR
|
||||||
// the user has read up to (or past) the total chapter count.
|
// the user has read up to (or past) the total chapter count.
|
||||||
@@ -80,180 +71,6 @@ struct LibraryView: View {
|
|||||||
}
|
}
|
||||||
return item.book.status.lowercased() == "completed" && (item.lastChapter ?? 0) > 0
|
return item.book.status.lowercased() == "completed" && (item.lastChapter ?? 0) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationStack {
|
|
||||||
Group {
|
|
||||||
if vm.isLoading && vm.items.isEmpty {
|
|
||||||
ProgressView()
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
} else if vm.items.isEmpty {
|
|
||||||
EmptyStateView(
|
|
||||||
icon: "bookmark",
|
|
||||||
title: "No saved books",
|
|
||||||
message: "Books you save or start reading will appear here."
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ScrollView {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
// Search bar
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: "magnifyingglass")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
TextField("Search library", text: $searchText)
|
|
||||||
.font(.subheadline)
|
|
||||||
if !searchText.isEmpty {
|
|
||||||
Button { searchText = "" } label: {
|
|
||||||
Image(systemName: "xmark.circle.fill")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10))
|
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.top, 8)
|
|
||||||
|
|
||||||
// Reading filter (All / In Progress / Completed)
|
|
||||||
Picker("", selection: $readingFilter) {
|
|
||||||
ForEach(ReadingFilter.allCases, id: \.self) { f in
|
|
||||||
Text(f.rawValue).tag(f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.top, 12)
|
|
||||||
|
|
||||||
// Genre filter chips (only shown when genres are available)
|
|
||||||
if !availableGenres.isEmpty {
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
// "All" chip
|
|
||||||
ChipButton(
|
|
||||||
label: "All",
|
|
||||||
isSelected: selectedGenre == "all",
|
|
||||||
style: .filled
|
|
||||||
) {
|
|
||||||
withAnimation { selectedGenre = "all" }
|
|
||||||
}
|
|
||||||
ForEach(availableGenres, id: \.self) { genre in
|
|
||||||
ChipButton(
|
|
||||||
label: genre.capitalized,
|
|
||||||
isSelected: selectedGenre == genre,
|
|
||||||
style: .filled
|
|
||||||
) {
|
|
||||||
withAnimation {
|
|
||||||
selectedGenre = selectedGenre == genre ? "all" : genre
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
|
||||||
.padding(.top, 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort chips
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
ForEach(SortOrder.allCases, id: \.self) { order in
|
|
||||||
ChipButton(
|
|
||||||
label: order.rawValue,
|
|
||||||
isSelected: sortOrder == order,
|
|
||||||
style: .outlined
|
|
||||||
) {
|
|
||||||
withAnimation { sortOrder = order }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
|
|
||||||
// Book count
|
|
||||||
Text("\(filtered.count) book\(filtered.count == 1 ? "" : "s")")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.bottom, 4)
|
|
||||||
|
|
||||||
if filtered.isEmpty {
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
Image(systemName: readingFilter == .completed ? "checkmark.circle" : "book")
|
|
||||||
.font(.system(size: 40))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Text(emptyMessage)
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.top, 60)
|
|
||||||
} else {
|
|
||||||
// 2-column grid (matches Discover)
|
|
||||||
LazyVGrid(
|
|
||||||
columns: [
|
|
||||||
GridItem(.flexible(), spacing: 12),
|
|
||||||
GridItem(.flexible(), spacing: 12)
|
|
||||||
],
|
|
||||||
spacing: 16
|
|
||||||
) {
|
|
||||||
ForEach(filtered) { item in
|
|
||||||
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
|
||||||
LibraryBookCard(item: item)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.contextMenu {
|
|
||||||
BookContextMenu(
|
|
||||||
book: item.book,
|
|
||||||
isFinished: isCompleted(item),
|
|
||||||
onMarkFinished: {
|
|
||||||
Task {
|
|
||||||
await markAsFinished(item.book)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onRemove: {
|
|
||||||
Task {
|
|
||||||
await removeFromLibrary(item.book.slug)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.top, 8)
|
|
||||||
.padding(.bottom, 24)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle("Library")
|
|
||||||
.appNavigationDestination()
|
|
||||||
.refreshable { await vm.load() }
|
|
||||||
.task { await vm.load() }
|
|
||||||
.errorAlert($vm.error)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
|
||||||
AvatarToolbarButton()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var emptyMessage: String {
|
|
||||||
switch readingFilter {
|
|
||||||
case .all:
|
|
||||||
return selectedGenre == "all" ? "No books match your search." : "No \(selectedGenre.capitalized) books in your library."
|
|
||||||
case .inProgress:
|
|
||||||
return "No books in progress."
|
|
||||||
case .completed:
|
|
||||||
return "No completed books yet."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func markAsFinished(_ book: Book) async {
|
private func markAsFinished(_ book: Book) async {
|
||||||
do {
|
do {
|
||||||
@@ -272,133 +89,303 @@ struct LibraryView: View {
|
|||||||
vm.error = error.localizedDescription
|
vm.error = error.localizedDescription
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Library book card (3-column)
|
|
||||||
|
|
||||||
private struct LibraryBookCard: View {
|
|
||||||
let item: LibraryItem
|
|
||||||
|
|
||||||
private var progressFraction: Double {
|
|
||||||
guard let ch = item.lastChapter, item.book.totalChapters > 0 else { return 0 }
|
|
||||||
return Double(ch) / Double(item.book.totalChapters)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var isCompleted: Bool {
|
|
||||||
progressFraction >= 1.0
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
NavigationStack {
|
||||||
ZStack(alignment: .topTrailing) {
|
VStack(spacing: 0) {
|
||||||
// Cover image
|
OfflineBanner()
|
||||||
KFImage(URL(string: item.book.cover))
|
|
||||||
.resizable()
|
Group {
|
||||||
.placeholder {
|
if vm.isLoading && vm.items.isEmpty {
|
||||||
RoundedRectangle(cornerRadius: 8)
|
ProgressView()
|
||||||
.fill(Color(.systemGray5))
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.overlay(
|
} else if vm.items.isEmpty {
|
||||||
Image(systemName: "book.closed")
|
EmptyStateView(
|
||||||
|
icon: "bookmark",
|
||||||
|
title: "No saved books",
|
||||||
|
message: "Books you save or start reading will appear here."
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Reading filter (All / In Progress / Completed)
|
||||||
|
Picker("", selection: $readingFilter) {
|
||||||
|
ForEach(ReadingFilter.allCases, id: \.self) { f in
|
||||||
|
Text(f.rawValue).tag(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top, 16)
|
||||||
|
|
||||||
|
// Genre filter chips (only shown when genres are available)
|
||||||
|
if !availableGenres.isEmpty {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
// "All" chip
|
||||||
|
ChipButton(
|
||||||
|
label: "All",
|
||||||
|
isSelected: selectedGenre == "all",
|
||||||
|
style: .filled
|
||||||
|
) {
|
||||||
|
withAnimation { selectedGenre = "all" }
|
||||||
|
}
|
||||||
|
ForEach(availableGenres, id: \.self) { genre in
|
||||||
|
ChipButton(
|
||||||
|
label: genre.capitalized,
|
||||||
|
isSelected: selectedGenre == genre,
|
||||||
|
style: .filled
|
||||||
|
) {
|
||||||
|
withAnimation {
|
||||||
|
selectedGenre = selectedGenre == genre ? "all" : genre
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
.padding(.top, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort chips
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(SortOrder.allCases, id: \.self) { order in
|
||||||
|
ChipButton(
|
||||||
|
label: order.rawValue,
|
||||||
|
isSelected: sortOrder == order,
|
||||||
|
style: .outlined
|
||||||
|
) {
|
||||||
|
withAnimation { sortOrder = order }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
|
||||||
|
// Book count
|
||||||
|
Text("\(filtered.count) book\(filtered.count == 1 ? "" : "s")")
|
||||||
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
|
if filtered.isEmpty {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: readingFilter == .completed ? "checkmark.circle" : "book")
|
||||||
|
.font(.system(size: 40))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(emptyMessage)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.top, 60)
|
||||||
|
} else {
|
||||||
|
// 2-column grid (matches Discover)
|
||||||
|
LazyVGrid(
|
||||||
|
columns: [
|
||||||
|
GridItem(.flexible(), spacing: 14),
|
||||||
|
GridItem(.flexible(), spacing: 14)
|
||||||
|
],
|
||||||
|
spacing: 14
|
||||||
|
) {
|
||||||
|
ForEach(filtered) { item in
|
||||||
|
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
||||||
|
LibraryBookCard(item: item)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.contextMenu {
|
||||||
|
BookContextMenu(
|
||||||
|
book: item.book,
|
||||||
|
isFinished: isCompleted(item),
|
||||||
|
onMarkFinished: {
|
||||||
|
Task {
|
||||||
|
await markAsFinished(item.book)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRemove: {
|
||||||
|
Task {
|
||||||
|
await removeFromLibrary(item.book.slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Library")
|
||||||
|
.appNavigationDestination()
|
||||||
|
.refreshable { await vm.load() }
|
||||||
|
.task { await vm.load() }
|
||||||
|
.errorAlert($vm.error)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
DownloadQueueButton()
|
||||||
|
AvatarToolbarButton()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.scaledToFill()
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.aspectRatio(2/3, contentMode: .fit)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
.shadow(color: .black.opacity(0.14), radius: 4, y: 2)
|
|
||||||
.bookCoverZoomSource(slug: item.book.slug)
|
|
||||||
|
|
||||||
// Progress arc or completed checkmark in top-right corner
|
|
||||||
if isCompleted {
|
|
||||||
Image(systemName: "checkmark.circle.fill")
|
|
||||||
.font(.system(size: 18, weight: .semibold))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.background(Circle().fill(Color.amber).padding(1))
|
|
||||||
.padding(5)
|
|
||||||
} else if progressFraction > 0 {
|
|
||||||
ProgressArc(fraction: progressFraction)
|
|
||||||
.frame(width: 28, height: 28)
|
|
||||||
.padding(4)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Title
|
|
||||||
Text(item.book.title)
|
var emptyMessage: String {
|
||||||
.font(.subheadline.bold())
|
switch readingFilter {
|
||||||
.lineLimit(2)
|
case .all:
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
return selectedGenre == "all" ? "No books in your library." : "No \(selectedGenre.capitalized) books in your library."
|
||||||
|
case .inProgress:
|
||||||
// Chapter badge if present
|
return "No books in progress."
|
||||||
if let ch = item.lastChapter {
|
case .completed:
|
||||||
Text(isCompleted ? "Finished" : "Ch.\(ch)")
|
return "No completed books yet."
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(isCompleted ? Color.amber : .secondary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Circular progress arc overlay
|
|
||||||
|
|
||||||
private struct ProgressArc: View {
|
|
||||||
let fraction: Double // 0...1
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
Circle()
|
|
||||||
.fill(.ultraThinMaterial)
|
|
||||||
|
|
||||||
Circle()
|
|
||||||
.trim(from: 0, to: fraction)
|
|
||||||
.stroke(Color.amber, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
|
|
||||||
.rotationEffect(.degrees(-90))
|
|
||||||
.animation(.easeInOut(duration: 0.5), value: fraction)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Book context menu
|
|
||||||
|
|
||||||
private struct BookContextMenu: View {
|
|
||||||
let book: Book
|
|
||||||
let isFinished: Bool
|
|
||||||
let onMarkFinished: () -> Void
|
|
||||||
let onRemove: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
// MARK: - Library book card (3-column)
|
||||||
Group {
|
|
||||||
// Share book
|
private struct LibraryBookCard: View {
|
||||||
ShareLink(item: shareURL) {
|
let item: LibraryItem
|
||||||
Label("Share", systemImage: "square.and.arrow.up")
|
|
||||||
|
private var progressFraction: Double {
|
||||||
|
guard let ch = item.lastChapter, item.book.totalChapters > 0 else { return 0 }
|
||||||
|
return Double(ch) / Double(item.book.totalChapters)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isCompleted: Bool {
|
||||||
|
progressFraction >= 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
// Cover image
|
||||||
|
KFImage(URL(string: item.book.cover))
|
||||||
|
.resizable()
|
||||||
|
.placeholder {
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(Color(.systemGray5))
|
||||||
|
.overlay(
|
||||||
|
Image(systemName: "book.closed")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.aspectRatio(2/3, contentMode: .fit)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.bookCoverZoomSource(slug: item.book.slug)
|
||||||
|
|
||||||
|
// Progress arc or completed checkmark in top-right corner
|
||||||
|
if isCompleted {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.background(Circle().fill(Color.amber).padding(1))
|
||||||
|
.padding(6)
|
||||||
|
} else if progressFraction > 0 {
|
||||||
|
ProgressArc(fraction: progressFraction)
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
.padding(5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title + chapter badge
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(item.book.title)
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.lineLimit(2)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
|
||||||
|
if let ch = item.lastChapter {
|
||||||
|
Text(isCompleted ? "Finished" : "Ch.\(ch)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(isCompleted ? Color.amber : .secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 10)
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
Divider()
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
// Mark as finished (only show if not already finished)
|
.shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2)
|
||||||
if !isFinished {
|
}
|
||||||
Button {
|
}
|
||||||
onMarkFinished()
|
|
||||||
|
// MARK: - Circular progress arc overlay
|
||||||
|
|
||||||
|
private struct ProgressArc: View {
|
||||||
|
let fraction: Double // 0...1
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: fraction)
|
||||||
|
.stroke(Color.amber, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
.animation(.easeInOut(duration: 0.5), value: fraction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Book context menu
|
||||||
|
|
||||||
|
private struct BookContextMenu: View {
|
||||||
|
let book: Book
|
||||||
|
let isFinished: Bool
|
||||||
|
let onMarkFinished: () -> Void
|
||||||
|
let onRemove: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
// Share book
|
||||||
|
ShareLink(item: shareURL) {
|
||||||
|
Label("Share", systemImage: "square.and.arrow.up")
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Mark as finished (only show if not already finished)
|
||||||
|
if !isFinished {
|
||||||
|
Button {
|
||||||
|
onMarkFinished()
|
||||||
|
} label: {
|
||||||
|
Label("Mark as Finished", systemImage: "checkmark.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Remove from library (destructive)
|
||||||
|
Button(role: .destructive) {
|
||||||
|
onRemove()
|
||||||
} label: {
|
} label: {
|
||||||
Label("Mark as Finished", systemImage: "checkmark.circle")
|
Label("Remove from Library", systemImage: "trash")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Divider()
|
|
||||||
|
private var shareURL: URL {
|
||||||
// Remove from library (destructive)
|
// Share the book detail page URL
|
||||||
Button(role: .destructive) {
|
let baseURL = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String
|
||||||
onRemove()
|
?? "https://v2.libnovel.kalekber.cc"
|
||||||
} label: {
|
return URL(string: "\(baseURL)/books/\(book.slug)")!
|
||||||
Label("Remove from Library", systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var shareURL: URL {
|
|
||||||
// Share the book detail page URL
|
|
||||||
let baseURL = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String
|
|
||||||
?? "https://v2.libnovel.kalekber.cc"
|
|
||||||
return URL(string: "\(baseURL)/books/\(book.slug)")!
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@ struct ProfileView: View {
|
|||||||
@EnvironmentObject var authStore: AuthStore
|
@EnvironmentObject var authStore: AuthStore
|
||||||
@StateObject private var vm = ProfileViewModel()
|
@StateObject private var vm = ProfileViewModel()
|
||||||
@State private var showChangePassword = false
|
@State private var showChangePassword = false
|
||||||
|
@State private var showVoiceSelection = false
|
||||||
|
@State private var showDownloads = false
|
||||||
|
|
||||||
// Avatar upload state
|
// Avatar upload state
|
||||||
@State private var photoPickerItem: PhotosPickerItem?
|
@State private var photoPickerItem: PhotosPickerItem?
|
||||||
@@ -52,6 +54,19 @@ struct ProfileView: View {
|
|||||||
}
|
}
|
||||||
))
|
))
|
||||||
.tint(.amber)
|
.tint(.amber)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showDownloads = true
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("Downloads")
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sessions ───────────────────────────────────────────────
|
// ── Sessions ───────────────────────────────────────────────
|
||||||
@@ -83,6 +98,12 @@ struct ProfileView: View {
|
|||||||
.sheet(isPresented: $showChangePassword) {
|
.sheet(isPresented: $showChangePassword) {
|
||||||
ChangePasswordView()
|
ChangePasswordView()
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showVoiceSelection) {
|
||||||
|
VoiceSelectionView(currentVoice: authStore.settings.voice)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showDownloads) {
|
||||||
|
DownloadsView()
|
||||||
|
}
|
||||||
.sheet(item: Binding(
|
.sheet(item: Binding(
|
||||||
get: { pendingCropImage.map { CropImageItem(image: $0) } },
|
get: { pendingCropImage.map { CropImageItem(image: $0) } },
|
||||||
set: { if $0 == nil { pendingCropImage = nil } }
|
set: { if $0 == nil { pendingCropImage = nil } }
|
||||||
@@ -191,25 +212,27 @@ struct ProfileView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var voicePicker: some View {
|
private var voicePicker: some View {
|
||||||
Picker("TTS Voice", selection: Binding(
|
Button {
|
||||||
get: { authStore.settings.voice },
|
showVoiceSelection = true
|
||||||
set: { newVoice in
|
} label: {
|
||||||
Task {
|
HStack {
|
||||||
var s = authStore.settings
|
Text("TTS Voice")
|
||||||
s.voice = newVoice
|
.foregroundStyle(.primary)
|
||||||
await authStore.saveSettings(s)
|
Spacer()
|
||||||
}
|
Text(formatVoiceLabel(authStore.settings.voice))
|
||||||
}
|
.foregroundStyle(.secondary)
|
||||||
)) {
|
Image(systemName: "chevron.right")
|
||||||
if vm.voices.isEmpty {
|
.font(.caption)
|
||||||
Text("Default").tag("af_bella")
|
.foregroundStyle(.tertiary)
|
||||||
} else {
|
|
||||||
ForEach(vm.voices, id: \.self) { v in
|
|
||||||
Text(v).tag(v)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task { await vm.loadVoices() }
|
}
|
||||||
|
|
||||||
|
private func formatVoiceLabel(_ voice: String) -> String {
|
||||||
|
let parts = voice.split(separator: "_")
|
||||||
|
guard parts.count >= 2 else { return voice }
|
||||||
|
let name = parts.dropFirst().map { $0.capitalized }.joined(separator: " ")
|
||||||
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Speed slider
|
// MARK: - Speed slider
|
||||||
|
|||||||
@@ -157,18 +157,6 @@ struct UserProfileView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Shelf header
|
|
||||||
|
|
||||||
private struct ShelfHeader: View {
|
|
||||||
let title: String
|
|
||||||
var body: some View {
|
|
||||||
Text(title)
|
|
||||||
.font(.title3.bold())
|
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.bottom, 10)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Book card for profile shelves
|
// MARK: - Book card for profile shelves
|
||||||
|
|
||||||
private struct ProfileBookCard: View {
|
private struct ProfileBookCard: View {
|
||||||
|
|||||||
158
ios/LibNovel/LibNovel/Views/Profile/VoiceSelectionView.swift
Normal file
158
ios/LibNovel/LibNovel/Views/Profile/VoiceSelectionView.swift
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct VoiceSelectionView: View {
|
||||||
|
@StateObject private var vm = VoiceSelectionViewModel()
|
||||||
|
@EnvironmentObject var authStore: AuthStore
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var selectedVoice: String
|
||||||
|
|
||||||
|
init(currentVoice: String) {
|
||||||
|
_selectedVoice = State(initialValue: currentVoice)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if vm.isLoading {
|
||||||
|
ProgressView("Loading voices...")
|
||||||
|
} else if let error = vm.error {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "exclamationmark.triangle")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.amber)
|
||||||
|
Text(error)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
} else {
|
||||||
|
voiceList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Select Voice")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Done") {
|
||||||
|
saveAndDismiss()
|
||||||
|
}
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.disabled(selectedVoice == authStore.settings.voice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await vm.loadVoices()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Voice List
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var voiceList: some View {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
ForEach(vm.voices, id: \.self) { voice in
|
||||||
|
VoiceRow(
|
||||||
|
voice: voice,
|
||||||
|
isSelected: voice == selectedVoice,
|
||||||
|
isPlaying: vm.playingVoice == voice,
|
||||||
|
voiceLabel: vm.voiceLabel(voice),
|
||||||
|
voiceId: vm.voiceId(voice),
|
||||||
|
onSelect: {
|
||||||
|
vm.stopSample()
|
||||||
|
selectedVoice = voice
|
||||||
|
},
|
||||||
|
onPlaySample: {
|
||||||
|
Task {
|
||||||
|
await vm.playSample(voice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Available Voices")
|
||||||
|
} footer: {
|
||||||
|
if selectedVoice != authStore.settings.voice {
|
||||||
|
Text("New voice will apply to next audio playback")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func saveAndDismiss() {
|
||||||
|
Task {
|
||||||
|
var settings = authStore.settings
|
||||||
|
settings.voice = selectedVoice
|
||||||
|
await authStore.saveSettings(settings)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Voice Row
|
||||||
|
|
||||||
|
private struct VoiceRow: View {
|
||||||
|
let voice: String
|
||||||
|
let isSelected: Bool
|
||||||
|
let isPlaying: Bool
|
||||||
|
let voiceLabel: String
|
||||||
|
let voiceId: String
|
||||||
|
let onSelect: () -> Void
|
||||||
|
let onPlaySample: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// Selection checkmark
|
||||||
|
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
||||||
|
.font(.system(size: 22))
|
||||||
|
.foregroundStyle(isSelected ? .amber : .secondary.opacity(0.3))
|
||||||
|
.frame(width: 28)
|
||||||
|
|
||||||
|
// Voice info
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(voiceLabel)
|
||||||
|
.font(.body)
|
||||||
|
.fontWeight(isSelected ? .semibold : .regular)
|
||||||
|
|
||||||
|
Text(voiceId)
|
||||||
|
.font(.caption)
|
||||||
|
.fontDesign(.monospaced)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Play sample button
|
||||||
|
Button {
|
||||||
|
onPlaySample()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: isPlaying ? "stop.circle.fill" : "play.circle.fill")
|
||||||
|
.font(.system(size: 28))
|
||||||
|
.foregroundStyle(isPlaying ? .red : .amber)
|
||||||
|
.contentTransition(.symbolEffect(.replace))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
onSelect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
VoiceSelectionView(currentVoice: "af_bella")
|
||||||
|
.environmentObject(AuthStore())
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - SearchView
|
// MARK: - SearchView
|
||||||
// Dedicated search tab modelled after Apple Books' Search screen.
|
// Dedicated search tab for intentional, fuzzy search.
|
||||||
// Shows a prominent search bar; while idle displays recent searches and
|
// Live search as you type, shows recent searches when idle.
|
||||||
// trending/popular novels; after a query shows a results grid.
|
|
||||||
|
|
||||||
struct SearchView: View {
|
struct SearchView: View {
|
||||||
@StateObject private var vm = SearchViewModel()
|
@StateObject private var vm = SearchViewModel()
|
||||||
@@ -11,129 +10,115 @@ struct SearchView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// ── Search bar ──────────────────────────────────────────────
|
OfflineBanner()
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: "magnifyingglass")
|
Group {
|
||||||
.foregroundStyle(.secondary)
|
// ── Content ─────────────────────────────────────────────────
|
||||||
TextField("Search novels, authors…", text: $vm.query)
|
if vm.query.isEmpty && vm.results.isEmpty {
|
||||||
.textInputAutocapitalization(.never)
|
|
||||||
.autocorrectionDisabled()
|
|
||||||
.submitLabel(.search)
|
|
||||||
.onSubmit { vm.submitSearch() }
|
|
||||||
if !vm.query.isEmpty {
|
|
||||||
Button { vm.clear() } label: {
|
|
||||||
Image(systemName: "xmark.circle.fill")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(10)
|
|
||||||
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10))
|
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.top, 8)
|
|
||||||
.padding(.bottom, 12)
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
// ── Content ─────────────────────────────────────────────────
|
|
||||||
if vm.query.isEmpty && vm.results.isEmpty {
|
|
||||||
idleContent
|
idleContent
|
||||||
} else if vm.isLoading {
|
} else if vm.isLoading && vm.results.isEmpty {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
} else if vm.results.isEmpty {
|
} else if vm.results.isEmpty && !vm.query.isEmpty {
|
||||||
EmptyStateView(
|
EmptyStateView(
|
||||||
icon: "magnifyingglass",
|
icon: "magnifyingglass",
|
||||||
title: "No results",
|
title: "No results",
|
||||||
message: "Try a different title or author name."
|
message: "Try a different title or author name."
|
||||||
)
|
)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
} else {
|
} else {
|
||||||
resultsGrid
|
resultsGrid
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Search")
|
.navigationTitle("Search")
|
||||||
|
.searchable(
|
||||||
|
text: $vm.query,
|
||||||
|
placement: .navigationBarDrawer(displayMode: .always),
|
||||||
|
prompt: "Search novels, authors…"
|
||||||
|
)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.onChange(of: vm.query) { _, newValue in
|
||||||
|
vm.onQueryChange(newValue)
|
||||||
|
}
|
||||||
|
.onSubmit(of: .search) {
|
||||||
|
vm.submitSearch()
|
||||||
|
}
|
||||||
.appNavigationDestination()
|
.appNavigationDestination()
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
AvatarToolbarButton()
|
HStack(spacing: 16) {
|
||||||
|
DownloadQueueButton()
|
||||||
|
AvatarToolbarButton()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Idle screen (recent searches + popular)
|
// MARK: - Idle screen (recent searches)
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var idleContent: some View {
|
private var idleContent: some View {
|
||||||
ScrollView {
|
if vm.recentSearches.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 24) {
|
// Empty state - prompt to search
|
||||||
// Recent searches
|
VStack(spacing: 16) {
|
||||||
if !vm.recentSearches.isEmpty {
|
Image(systemName: "magnifyingglass")
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
.font(.system(size: 56))
|
||||||
HStack {
|
.foregroundStyle(.secondary.opacity(0.5))
|
||||||
Text("Recent")
|
Text("Search for novels")
|
||||||
.font(.title3.bold())
|
.font(.title2.bold())
|
||||||
Spacer()
|
.foregroundStyle(.primary)
|
||||||
Button("Clear") { vm.clearRecent() }
|
Text("Find your next favorite book by title, author, or genre")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.amber)
|
.foregroundStyle(.secondary)
|
||||||
}
|
.multilineTextAlignment(.center)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal, 40)
|
||||||
.padding(.bottom, 10)
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
ForEach(vm.recentSearches, id: \.self) { term in
|
} else {
|
||||||
Button {
|
// Recent searches list
|
||||||
vm.query = term
|
ScrollView {
|
||||||
vm.submitSearch()
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
} label: {
|
HStack {
|
||||||
HStack {
|
Text("Recent Searches")
|
||||||
Image(systemName: "clock")
|
.font(.title3.bold())
|
||||||
.foregroundStyle(.secondary)
|
Spacer()
|
||||||
.frame(width: 20)
|
Button("Clear") { vm.clearRecent() }
|
||||||
Text(term)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(.amber)
|
||||||
Spacer()
|
}
|
||||||
Image(systemName: "arrow.up.left")
|
.padding(.horizontal)
|
||||||
.font(.caption)
|
.padding(.top, 16)
|
||||||
.foregroundStyle(.tertiary)
|
.padding(.bottom, 12)
|
||||||
}
|
|
||||||
.padding(.horizontal)
|
ForEach(vm.recentSearches, id: \.self) { term in
|
||||||
.padding(.vertical, 11)
|
Button {
|
||||||
}
|
vm.query = term
|
||||||
Divider().padding(.leading, 44)
|
vm.submitSearch()
|
||||||
}
|
} label: {
|
||||||
}
|
HStack(spacing: 12) {
|
||||||
}
|
Image(systemName: "clock")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
// Popular / trending novels (loaded from browse popular)
|
.frame(width: 20)
|
||||||
if !vm.popular.isEmpty {
|
Text(term)
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
.foregroundStyle(.primary)
|
||||||
Text("Popular")
|
Spacer()
|
||||||
.font(.title3.bold())
|
Image(systemName: "arrow.up.left")
|
||||||
.padding(.horizontal)
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
LazyVGrid(
|
}
|
||||||
columns: [
|
.padding(.horizontal)
|
||||||
GridItem(.flexible(), spacing: 12),
|
.padding(.vertical, 12)
|
||||||
GridItem(.flexible(), spacing: 12)
|
}
|
||||||
],
|
|
||||||
spacing: 16
|
if term != vm.recentSearches.last {
|
||||||
) {
|
Divider()
|
||||||
ForEach(vm.popular) { novel in
|
.padding(.leading, 44)
|
||||||
NavigationLink(value: NavDestination.book(novel.slug)) {
|
}
|
||||||
SearchNovelCard(novel: novel)
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Color.clear.frame(height: 20)
|
|
||||||
}
|
}
|
||||||
.padding(.top, 16)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,21 +127,34 @@ struct SearchView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var resultsGrid: some View {
|
private var resultsGrid: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVGrid(
|
VStack(spacing: 8) {
|
||||||
columns: [
|
// Result count
|
||||||
GridItem(.flexible(), spacing: 12),
|
HStack {
|
||||||
GridItem(.flexible(), spacing: 12)
|
Text("\(vm.results.count) result\(vm.results.count == 1 ? "" : "s")")
|
||||||
],
|
.font(.caption)
|
||||||
spacing: 16
|
.foregroundStyle(.secondary)
|
||||||
) {
|
Spacer()
|
||||||
ForEach(vm.results) { novel in
|
|
||||||
NavigationLink(value: NavDestination.book(novel.slug)) {
|
|
||||||
SearchNovelCard(novel: novel)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
LazyVGrid(
|
||||||
|
columns: [
|
||||||
|
GridItem(.flexible(), spacing: 14),
|
||||||
|
GridItem(.flexible(), spacing: 14)
|
||||||
|
],
|
||||||
|
spacing: 14
|
||||||
|
) {
|
||||||
|
ForEach(vm.results) { novel in
|
||||||
|
NavigationLink(value: NavDestination.book(novel.slug)) {
|
||||||
|
SearchNovelCard(novel: novel)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.bottom, 100)
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,19 +165,35 @@ private struct SearchNovelCard: View {
|
|||||||
let novel: BrowseNovel
|
let novel: BrowseNovel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
AsyncCoverImage(url: novel.cover)
|
AsyncCoverImage(url: novel.cover)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.aspectRatio(2/3, contentMode: .fit)
|
.aspectRatio(2/3, contentMode: .fit)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
.shadow(color: .black.opacity(0.12), radius: 4, y: 2)
|
|
||||||
.bookCoverZoomSource(slug: novel.slug)
|
.bookCoverZoomSource(slug: novel.slug)
|
||||||
|
|
||||||
Text(novel.title)
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
.font(.subheadline.bold())
|
Text(novel.title)
|
||||||
.lineLimit(2)
|
.font(.subheadline.bold())
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.lineLimit(2)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
|
||||||
|
if !novel.author.isEmpty {
|
||||||
|
Text(novel.author)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 10)
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,29 +203,50 @@ private struct SearchNovelCard: View {
|
|||||||
final class SearchViewModel: ObservableObject {
|
final class SearchViewModel: ObservableObject {
|
||||||
@Published var query: String = ""
|
@Published var query: String = ""
|
||||||
@Published var results: [BrowseNovel] = []
|
@Published var results: [BrowseNovel] = []
|
||||||
@Published var popular: [BrowseNovel] = []
|
|
||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
|
|
||||||
// Persisted in UserDefaults (max 10 recent terms)
|
// Persisted in UserDefaults (max 10 recent terms)
|
||||||
@Published var recentSearches: [String] = []
|
@Published var recentSearches: [String] = []
|
||||||
|
|
||||||
private let recentKey = "searchRecentTerms"
|
private let recentKey = "searchRecentTerms"
|
||||||
|
private var searchTask: Task<Void, Never>?
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
recentSearches = (UserDefaults.standard.stringArray(forKey: recentKey) ?? [])
|
recentSearches = (UserDefaults.standard.stringArray(forKey: recentKey) ?? [])
|
||||||
Task { await loadPopular() }
|
}
|
||||||
|
|
||||||
|
/// Called when query changes - implements debounced live search
|
||||||
|
func onQueryChange(_ newValue: String) {
|
||||||
|
// Cancel previous search task
|
||||||
|
searchTask?.cancel()
|
||||||
|
|
||||||
|
// If query is empty, clear results
|
||||||
|
guard !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||||
|
results = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce: wait 300ms before searching
|
||||||
|
searchTask = Task {
|
||||||
|
try? await Task.sleep(nanoseconds: 300_000_000) // 300ms
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await runSearch(newValue)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func submitSearch() {
|
func submitSearch() {
|
||||||
let term = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
let term = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !term.isEmpty else { return }
|
guard !term.isEmpty else { return }
|
||||||
saveRecent(term)
|
saveRecent(term)
|
||||||
|
// Cancel debounce and search immediately
|
||||||
|
searchTask?.cancel()
|
||||||
Task { await runSearch(term) }
|
Task { await runSearch(term) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func clear() {
|
func clear() {
|
||||||
query = ""
|
query = ""
|
||||||
results = []
|
results = []
|
||||||
|
searchTask?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearRecent() {
|
func clearRecent() {
|
||||||
@@ -220,23 +255,27 @@ final class SearchViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func runSearch(_ term: String) async {
|
private func runSearch(_ term: String) async {
|
||||||
|
let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else {
|
||||||
|
results = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
isLoading = true
|
isLoading = true
|
||||||
do {
|
do {
|
||||||
let result = try await APIClient.shared.search(query: term)
|
let result = try await APIClient.shared.search(query: trimmed)
|
||||||
results = result.results
|
// Only update results if query hasn't changed
|
||||||
|
if query.trimmingCharacters(in: .whitespacesAndNewlines) == trimmed {
|
||||||
|
results = result.results
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
results = []
|
if !(error is CancellationError) {
|
||||||
|
results = []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadPopular() async {
|
|
||||||
do {
|
|
||||||
let result = try await APIClient.shared.browse(page: 1, genre: "all", sort: "popular", status: "all")
|
|
||||||
popular = Array(result.novels.prefix(12))
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func saveRecent(_ term: String) {
|
private func saveRecent(_ term: String) {
|
||||||
var list = recentSearches.filter { $0 != term }
|
var list = recentSearches.filter { $0 != term }
|
||||||
list.insert(term, at: 0)
|
list.insert(term, at: 0)
|
||||||
|
|||||||
19
ios/LibNovelV2/App/ContentView.swift
Normal file
19
ios/LibNovelV2/App/ContentView.swift
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Root content view
|
||||||
|
// Switches between AuthView (unauthenticated) and RootTabView (authenticated).
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@EnvironmentObject var authStore: AuthStore
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if authStore.isAuthenticated {
|
||||||
|
RootTabView()
|
||||||
|
} else {
|
||||||
|
AuthView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.25), value: authStore.isAuthenticated)
|
||||||
|
}
|
||||||
|
}
|
||||||
21
ios/LibNovelV2/App/LibNovelV2App.swift
Normal file
21
ios/LibNovelV2/App/LibNovelV2App.swift
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct LibNovelV2App: App {
|
||||||
|
@StateObject private var authStore = AuthStore()
|
||||||
|
@StateObject private var audioPlayer = AudioPlayerService()
|
||||||
|
@StateObject private var downloadService = AudioDownloadService.shared
|
||||||
|
@StateObject private var networkMonitor = NetworkMonitor()
|
||||||
|
@StateObject private var bookVoicePrefs = BookVoicePreferences.shared
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
.environmentObject(authStore)
|
||||||
|
.environmentObject(audioPlayer)
|
||||||
|
.environmentObject(downloadService)
|
||||||
|
.environmentObject(networkMonitor)
|
||||||
|
.environmentObject(bookVoicePrefs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
90
ios/LibNovelV2/App/RootTabView.swift
Normal file
90
ios/LibNovelV2/App/RootTabView.swift
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Root tab container with persistent mini-player overlay
|
||||||
|
|
||||||
|
struct RootTabView: View {
|
||||||
|
@EnvironmentObject var authStore: AuthStore
|
||||||
|
@EnvironmentObject var audioPlayer: AudioPlayerService
|
||||||
|
|
||||||
|
@State private var selectedTab: Tab = .home
|
||||||
|
@State private var showFullPlayer: Bool = false
|
||||||
|
@State private var readerIsActive: Bool = false
|
||||||
|
@State private var fullPlayerDragOffset: CGFloat = 0
|
||||||
|
|
||||||
|
enum Tab: Hashable {
|
||||||
|
case home, library, browse, search, profile
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .bottom) {
|
||||||
|
TabView(selection: $selectedTab) {
|
||||||
|
HomeView()
|
||||||
|
.tabItem { Label("Home", systemImage: "house.fill") }
|
||||||
|
.tag(Tab.home)
|
||||||
|
|
||||||
|
LibraryView()
|
||||||
|
.tabItem { Label("Library", systemImage: "book.pages.fill") }
|
||||||
|
.tag(Tab.library)
|
||||||
|
|
||||||
|
BrowseView()
|
||||||
|
.tabItem { Label("Discover", systemImage: "sparkles") }
|
||||||
|
.tag(Tab.browse)
|
||||||
|
|
||||||
|
SearchView()
|
||||||
|
.tabItem { Label("Search", systemImage: "magnifyingglass") }
|
||||||
|
.tag(Tab.search)
|
||||||
|
|
||||||
|
ProfileView()
|
||||||
|
.tabItem { Label("Profile", systemImage: "person.fill") }
|
||||||
|
.tag(Tab.profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mini player bar — sits above the tab bar
|
||||||
|
if audioPlayer.isActive && !showFullPlayer && !readerIsActive {
|
||||||
|
MiniPlayerBar(showFullPlayer: $showFullPlayer)
|
||||||
|
.padding(.bottom, 49)
|
||||||
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
|
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full player — slides up from the bottom
|
||||||
|
if showFullPlayer {
|
||||||
|
FullPlayerView(onDismiss: {
|
||||||
|
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
|
||||||
|
showFullPlayer = false
|
||||||
|
fullPlayerDragOffset = 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.offset(y: max(fullPlayerDragOffset, 0))
|
||||||
|
.gesture(
|
||||||
|
DragGesture(minimumDistance: 10)
|
||||||
|
.onChanged { value in
|
||||||
|
if value.translation.height > 0 {
|
||||||
|
fullPlayerDragOffset = value.translation.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onEnded { value in
|
||||||
|
let velocity = value.predictedEndTranslation.height - value.translation.height
|
||||||
|
if value.translation.height > 120 || velocity > 400 {
|
||||||
|
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
|
||||||
|
showFullPlayer = false
|
||||||
|
fullPlayerDragOffset = 0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
|
||||||
|
fullPlayerDragOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.transition(.move(edge: .bottom))
|
||||||
|
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer)
|
||||||
|
.onPreferenceChange(HideMiniPlayerKey.self) { hide in
|
||||||
|
readerIsActive = hide
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
138
ios/LibNovelV2/Extensions/NavDestination.swift
Normal file
138
ios/LibNovelV2/Extensions/NavDestination.swift
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Navigation destination enum
|
||||||
|
|
||||||
|
enum NavDestination: Hashable {
|
||||||
|
case book(String) // slug
|
||||||
|
case chapter(String, Int) // slug + chapter number
|
||||||
|
case userProfile(String) // username
|
||||||
|
case browseCategory(sort: String, genre: String, status: String, title: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - View helpers
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// Registers app-wide navigationDestination for NavDestination values.
|
||||||
|
/// Apply once per NavigationStack.
|
||||||
|
func appNavigationDestination() -> some View {
|
||||||
|
modifier(AppNavigationDestinationModifier())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Standard "Error" alert driven by an optional String binding.
|
||||||
|
/// Suppresses network errors silently when offline (banner handles them).
|
||||||
|
func errorAlert(_ error: Binding<String?>) -> some View {
|
||||||
|
modifier(ErrorAlertModifier(error: error))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signal to the root overlay that the mini player should be hidden.
|
||||||
|
func hideMiniPlayer() -> some View {
|
||||||
|
preference(key: HideMiniPlayerKey.self, value: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks a cover image as the zoom source for a book navigation transition (iOS 18+).
|
||||||
|
func bookCoverZoomSource(slug: String) -> some View {
|
||||||
|
modifier(BookCoverZoomSource(slug: slug))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Error alert modifier
|
||||||
|
|
||||||
|
private struct ErrorAlertModifier: ViewModifier {
|
||||||
|
@Binding var error: String?
|
||||||
|
@EnvironmentObject var networkMonitor: NetworkMonitor
|
||||||
|
|
||||||
|
private var shouldShowAlert: Bool {
|
||||||
|
guard let msg = error else { return false }
|
||||||
|
if !networkMonitor.isConnected {
|
||||||
|
let keywords = ["internet", "offline", "network", "connection", "unreachable", "timed out", "no data"]
|
||||||
|
if keywords.contains(where: { msg.lowercased().contains($0) }) {
|
||||||
|
DispatchQueue.main.async { self.error = nil }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content.alert("Error", isPresented: Binding(
|
||||||
|
get: { shouldShowAlert },
|
||||||
|
set: { if !$0 { error = nil } }
|
||||||
|
)) {
|
||||||
|
Button("OK") { error = nil }
|
||||||
|
} message: {
|
||||||
|
Text(error ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Navigation destination modifier
|
||||||
|
|
||||||
|
private struct AppNavigationDestinationModifier: ViewModifier {
|
||||||
|
@Namespace private var zoomNamespace
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
if #available(iOS 18.0, *) {
|
||||||
|
content
|
||||||
|
.navigationDestination(for: NavDestination.self) { dest in
|
||||||
|
switch dest {
|
||||||
|
case .book(let slug):
|
||||||
|
BookDetailView(slug: slug)
|
||||||
|
.navigationTransition(.zoom(sourceID: slug, in: zoomNamespace))
|
||||||
|
case .chapter(let slug, let n):
|
||||||
|
ChapterReaderView(slug: slug, chapterNumber: n)
|
||||||
|
case .userProfile(let username):
|
||||||
|
UserProfileView(username: username)
|
||||||
|
case .browseCategory(let sort, let genre, let status, let title):
|
||||||
|
BrowseCategoryView(sort: sort, genre: genre, status: status, title: title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.environment(\.bookZoomNamespace, zoomNamespace)
|
||||||
|
} else {
|
||||||
|
content
|
||||||
|
.navigationDestination(for: NavDestination.self) { dest in
|
||||||
|
switch dest {
|
||||||
|
case .book(let slug): BookDetailView(slug: slug)
|
||||||
|
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
|
||||||
|
case .userProfile(let username): UserProfileView(username: username)
|
||||||
|
case .browseCategory(let sort, let genre, let status, let title):
|
||||||
|
BrowseCategoryView(sort: sort, genre: genre, status: status, title: title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Environment key: zoom namespace
|
||||||
|
|
||||||
|
struct BookZoomNamespaceKey: EnvironmentKey {
|
||||||
|
static var defaultValue: Namespace.ID? { nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EnvironmentValues {
|
||||||
|
var bookZoomNamespace: Namespace.ID? {
|
||||||
|
get { self[BookZoomNamespaceKey.self] }
|
||||||
|
set { self[BookZoomNamespaceKey.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preference key: hide mini player
|
||||||
|
|
||||||
|
struct HideMiniPlayerKey: PreferenceKey {
|
||||||
|
static var defaultValue = false
|
||||||
|
static func reduce(value: inout Bool, nextValue: () -> Bool) { value = value || nextValue() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cover zoom source modifier
|
||||||
|
|
||||||
|
struct BookCoverZoomSource: ViewModifier {
|
||||||
|
let slug: String
|
||||||
|
@Environment(\.bookZoomNamespace) private var namespace
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
if #available(iOS 18.0, *), let ns = namespace {
|
||||||
|
content.matchedTransitionSource(id: slug, in: ns)
|
||||||
|
} else {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
ios/LibNovelV2/Extensions/String+App.swift
Normal file
19
ios/LibNovelV2/Extensions/String+App.swift
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
/// Strips trailing date parentheticals from chapter titles.
|
||||||
|
/// Handles formats like:
|
||||||
|
/// " (January 5, 2025)"
|
||||||
|
/// " - Jan 01 2024"
|
||||||
|
func strippingTrailingDate() -> String {
|
||||||
|
let patterns = [
|
||||||
|
#"\s*\([A-Za-z]+ \d{1,2},\s+\d{4}\)\s*$"#,
|
||||||
|
#"\s*[-–]\s*\w+\s+\d{1,2}\s+\d{4}\s*$"#,
|
||||||
|
]
|
||||||
|
var result = self
|
||||||
|
for pattern in patterns {
|
||||||
|
result = result.replacingOccurrences(of: pattern, with: "", options: .regularExpression)
|
||||||
|
}
|
||||||
|
return result.trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
}
|
||||||
577
ios/LibNovelV2/LibNovelV2.xcodeproj/project.pbxproj
Normal file
577
ios/LibNovelV2/LibNovelV2.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 77;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
075C7E597E108D806195B2F0 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A6F099EE054F6EF867B19D9 /* HomeViewModel.swift */; };
|
||||||
|
280AC764BC30130EDB27A3F0 /* AudioDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72BA2BF82A660E953CBB526A /* AudioDownloadService.swift */; };
|
||||||
|
29D0FB039902E6691FBE40DA /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC1125FE0F6CD9F01F69B75 /* SearchViewModel.swift */; };
|
||||||
|
2FB2A044EBE6B90CFB51CF58 /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2634D20198A966396121230 /* LibraryView.swift */; };
|
||||||
|
30EE28A725E2FA69F8FFCEF8 /* BookDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E61714857FDAA22186D7A6C /* BookDetailViewModel.swift */; };
|
||||||
|
43034688B18F6F6CD65C5DE5 /* BrowseCategoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABE69B91683576A056DE99EC /* BrowseCategoryView.swift */; };
|
||||||
|
464782001051686356AF728B /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 736DA6CB7D7759E1791F6236 /* SearchView.swift */; };
|
||||||
|
4F72B63F12BB364C561B5B69 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378336A1684E738283821857 /* ContentView.swift */; };
|
||||||
|
5FCFCBFBEEFDFD2081068317 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F4A3006B972DFF660959FE3 /* APIClient.swift */; };
|
||||||
|
6340BF19FE12FCEBE9607889 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D37A1BCABF9787BA6E243C8F /* ProfileView.swift */; };
|
||||||
|
64B17B6E30F44E87F33B886B /* ChapterReaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4753E3FCFD2C6AEB0E58D5A1 /* ChapterReaderViewModel.swift */; };
|
||||||
|
7431E92F141CFFF28E891A11 /* BookDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C68D19B123EC191D53A694E /* BookDetailView.swift */; };
|
||||||
|
78F2392702ACB553CAFDB335 /* PlayerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71D006B236F6FE653131FFD2 /* PlayerViews.swift */; };
|
||||||
|
792042C137942BCF8CB99C4F /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 125054A25A37A42295D49B10 /* NetworkMonitor.swift */; };
|
||||||
|
7C59289066AFD8A999DB9A0A /* CommonViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EF859D4970913FEBA89CB0F /* CommonViews.swift */; };
|
||||||
|
9F4A645472DC48AD32D5EDCD /* ChapterReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F48756100041DE38F573449 /* ChapterReaderView.swift */; };
|
||||||
|
9FD80E1B54ED74F430064904 /* LibNovelV2App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D88622224F38A541CE9F8D /* LibNovelV2App.swift */; };
|
||||||
|
A753C2AE73CAA00BF1AB0EA4 /* NavDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 880A0B86A80386BEA76FF388 /* NavDestination.swift */; };
|
||||||
|
B1E2F3A4C5D6E7F8A9B0C1D2 /* String+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2D3E4F5A6B7C8D9E0F1A2B3 /* String+App.swift */; };
|
||||||
|
ABB16424CEED3C5E9AAC08B2 /* BrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C95D52A96318B6CAD22EB0 /* BrowseView.swift */; };
|
||||||
|
ACCA21E0EDF8BED26E193A76 /* DownloadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92BE9AB59740382D85BD5296 /* DownloadsView.swift */; };
|
||||||
|
ACE6D62D8E547A90380FB689 /* BookVoicePreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E76C0661FC6FAB3BAA86711 /* BookVoicePreferences.swift */; };
|
||||||
|
B4C6205A3A7A7A29EDA691FF /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C5E37231B0150A128C72D49 /* HomeView.swift */; };
|
||||||
|
B8C5C43F299C89CFAE4000F1 /* RootTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC088495FFC3053AAE0F124 /* RootTabView.swift */; };
|
||||||
|
BEE8DF9B5E6C35389FB07951 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D715E3B2A6FE40FB628ADD2D /* AuthView.swift */; };
|
||||||
|
C0EA8DBE751CB22F058CBF20 /* VoiceSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB713100B2B1F429924A107C /* VoiceSelectionView.swift */; };
|
||||||
|
DDBAD183F7974A6FDAECB93C /* LibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96B3C942AFBA555D43F56C53 /* LibraryViewModel.swift */; };
|
||||||
|
E64BCBBA92A983C3851754B5 /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 930C6A69F3E601E2297071CD /* AudioPlayerService.swift */; };
|
||||||
|
E8112B785D129C26FEC054AB /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1548C08BADD28B057A9DFD5F /* UserProfileView.swift */; };
|
||||||
|
F1DB9BC6DC6DFEEA010B7CDF /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F98B6C380A20E783F1F7A7DB /* AuthStore.swift */; };
|
||||||
|
F4DAA587A097C597A9841563 /* BrowseViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2B8078366569A958BE54D23 /* BrowseViewModel.swift */; };
|
||||||
|
FC954C552CC0BDFB619BF207 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4180EB2AEECC51E4A7F5231 /* Models.swift */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
06C95D52A96318B6CAD22EB0 /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = "<group>"; };
|
||||||
|
125054A25A37A42295D49B10 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = "<group>"; };
|
||||||
|
1548C08BADD28B057A9DFD5F /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = "<group>"; };
|
||||||
|
2E76C0661FC6FAB3BAA86711 /* BookVoicePreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookVoicePreferences.swift; sourceTree = "<group>"; };
|
||||||
|
378336A1684E738283821857 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||||
|
3C5E37231B0150A128C72D49 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
|
||||||
|
3EF859D4970913FEBA89CB0F /* CommonViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonViews.swift; sourceTree = "<group>"; };
|
||||||
|
4753E3FCFD2C6AEB0E58D5A1 /* ChapterReaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterReaderViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
4A6F099EE054F6EF867B19D9 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
5C68D19B123EC191D53A694E /* BookDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailView.swift; sourceTree = "<group>"; };
|
||||||
|
71D006B236F6FE653131FFD2 /* PlayerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViews.swift; sourceTree = "<group>"; };
|
||||||
|
72BA2BF82A660E953CBB526A /* AudioDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioDownloadService.swift; sourceTree = "<group>"; };
|
||||||
|
736DA6CB7D7759E1791F6236 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
|
||||||
|
7E61714857FDAA22186D7A6C /* BookDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
7F4A3006B972DFF660959FE3 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
|
||||||
|
84D88622224F38A541CE9F8D /* LibNovelV2App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelV2App.swift; sourceTree = "<group>"; };
|
||||||
|
880A0B86A80386BEA76FF388 /* NavDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavDestination.swift; sourceTree = "<group>"; };
|
||||||
|
C2D3E4F5A6B7C8D9E0F1A2B3 /* String+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+App.swift"; sourceTree = "<group>"; };
|
||||||
|
8F48756100041DE38F573449 /* ChapterReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterReaderView.swift; sourceTree = "<group>"; };
|
||||||
|
92BE9AB59740382D85BD5296 /* DownloadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsView.swift; sourceTree = "<group>"; };
|
||||||
|
930C6A69F3E601E2297071CD /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = "<group>"; };
|
||||||
|
94CB555099A941E16AD0531A /* LibNovelV2.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LibNovelV2.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
96B3C942AFBA555D43F56C53 /* LibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
ABE69B91683576A056DE99EC /* BrowseCategoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseCategoryView.swift; sourceTree = "<group>"; };
|
||||||
|
B4180EB2AEECC51E4A7F5231 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
|
||||||
|
BFC088495FFC3053AAE0F124 /* RootTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootTabView.swift; sourceTree = "<group>"; };
|
||||||
|
D37A1BCABF9787BA6E243C8F /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; };
|
||||||
|
D715E3B2A6FE40FB628ADD2D /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = "<group>"; };
|
||||||
|
DB713100B2B1F429924A107C /* VoiceSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSelectionView.swift; sourceTree = "<group>"; };
|
||||||
|
F2634D20198A966396121230 /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = "<group>"; };
|
||||||
|
F2B8078366569A958BE54D23 /* BrowseViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
F98B6C380A20E783F1F7A7DB /* AuthStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStore.swift; sourceTree = "<group>"; };
|
||||||
|
FCC1125FE0F6CD9F01F69B75 /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
03533E32FF0C2EAF1915AD15 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
94CB555099A941E16AD0531A /* LibNovelV2.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
19F98554C19DCB1FD6ED835E /* Services */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
72BA2BF82A660E953CBB526A /* AudioDownloadService.swift */,
|
||||||
|
930C6A69F3E601E2297071CD /* AudioPlayerService.swift */,
|
||||||
|
F98B6C380A20E783F1F7A7DB /* AuthStore.swift */,
|
||||||
|
2E76C0661FC6FAB3BAA86711 /* BookVoicePreferences.swift */,
|
||||||
|
125054A25A37A42295D49B10 /* NetworkMonitor.swift */,
|
||||||
|
);
|
||||||
|
path = Services;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
20E9B4B0C0EDDB3313149544 /* Common */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
3EF859D4970913FEBA89CB0F /* CommonViews.swift */,
|
||||||
|
);
|
||||||
|
path = Common;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
25D179F65B0041EE826DEF5B /* App */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
378336A1684E738283821857 /* ContentView.swift */,
|
||||||
|
84D88622224F38A541CE9F8D /* LibNovelV2App.swift */,
|
||||||
|
BFC088495FFC3053AAE0F124 /* RootTabView.swift */,
|
||||||
|
);
|
||||||
|
path = App;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
2F4B97A2A2234F71AE2C46B2 /* Home */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
3C5E37231B0150A128C72D49 /* HomeView.swift */,
|
||||||
|
);
|
||||||
|
path = Home;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
36240FA179A3701F15D1AAE1 /* Extensions */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
880A0B86A80386BEA76FF388 /* NavDestination.swift */,
|
||||||
|
C2D3E4F5A6B7C8D9E0F1A2B3 /* String+App.swift */,
|
||||||
|
);
|
||||||
|
path = Extensions;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
3A6125CA86E249F3D6DC7F8C /* BookDetail */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
5C68D19B123EC191D53A694E /* BookDetailView.swift */,
|
||||||
|
);
|
||||||
|
path = BookDetail;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
448620B67D4AEEEF2CAED3C0 /* Models */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B4180EB2AEECC51E4A7F5231 /* Models.swift */,
|
||||||
|
);
|
||||||
|
path = Models;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
716D22431B17611F7A418D9F /* Profile */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D37A1BCABF9787BA6E243C8F /* ProfileView.swift */,
|
||||||
|
1548C08BADD28B057A9DFD5F /* UserProfileView.swift */,
|
||||||
|
DB713100B2B1F429924A107C /* VoiceSelectionView.swift */,
|
||||||
|
);
|
||||||
|
path = Profile;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
8BCE05349B706BF8EE0E16DD /* LibNovelV2 */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
);
|
||||||
|
name = LibNovelV2;
|
||||||
|
path = .;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
9AFE0816FF2E9D8DBBA470BD /* Downloads */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
92BE9AB59740382D85BD5296 /* DownloadsView.swift */,
|
||||||
|
);
|
||||||
|
path = Downloads;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
9CFE23EEA1B9E264A36D0FC4 /* Search */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
736DA6CB7D7759E1791F6236 /* SearchView.swift */,
|
||||||
|
);
|
||||||
|
path = Search;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
9E5A2471B9D5ECAF6B65FD22 /* ViewModels */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
7E61714857FDAA22186D7A6C /* BookDetailViewModel.swift */,
|
||||||
|
F2B8078366569A958BE54D23 /* BrowseViewModel.swift */,
|
||||||
|
4753E3FCFD2C6AEB0E58D5A1 /* ChapterReaderViewModel.swift */,
|
||||||
|
4A6F099EE054F6EF867B19D9 /* HomeViewModel.swift */,
|
||||||
|
96B3C942AFBA555D43F56C53 /* LibraryViewModel.swift */,
|
||||||
|
FCC1125FE0F6CD9F01F69B75 /* SearchViewModel.swift */,
|
||||||
|
);
|
||||||
|
path = ViewModels;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A05A1FE213A8E179B2302EF2 /* Auth */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D715E3B2A6FE40FB628ADD2D /* AuthView.swift */,
|
||||||
|
);
|
||||||
|
path = Auth;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
AA1F8D9C3DA40A1ADCF2B432 = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
25D179F65B0041EE826DEF5B /* App */,
|
||||||
|
36240FA179A3701F15D1AAE1 /* Extensions */,
|
||||||
|
8BCE05349B706BF8EE0E16DD /* LibNovelV2 */,
|
||||||
|
448620B67D4AEEEF2CAED3C0 /* Models */,
|
||||||
|
AFDC950B142FEDA471F394EC /* Networking */,
|
||||||
|
C468271A8BC443D1B82A1BE0 /* Resources */,
|
||||||
|
19F98554C19DCB1FD6ED835E /* Services */,
|
||||||
|
9E5A2471B9D5ECAF6B65FD22 /* ViewModels */,
|
||||||
|
CBC1A32FA53E9B3D5E15995D /* Views */,
|
||||||
|
03533E32FF0C2EAF1915AD15 /* Products */,
|
||||||
|
);
|
||||||
|
indentWidth = 4;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
tabWidth = 4;
|
||||||
|
usesTabs = 0;
|
||||||
|
};
|
||||||
|
AF1FE530FDE94947D4966251 /* ChapterReader */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
8F48756100041DE38F573449 /* ChapterReaderView.swift */,
|
||||||
|
);
|
||||||
|
path = ChapterReader;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
AFDC950B142FEDA471F394EC /* Networking */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
7F4A3006B972DFF660959FE3 /* APIClient.swift */,
|
||||||
|
);
|
||||||
|
path = Networking;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
BFA030D1CE2D312C539318DA /* Browse */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
ABE69B91683576A056DE99EC /* BrowseCategoryView.swift */,
|
||||||
|
06C95D52A96318B6CAD22EB0 /* BrowseView.swift */,
|
||||||
|
);
|
||||||
|
path = Browse;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C468271A8BC443D1B82A1BE0 /* Resources */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
);
|
||||||
|
path = Resources;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
CBC1A32FA53E9B3D5E15995D /* Views */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A05A1FE213A8E179B2302EF2 /* Auth */,
|
||||||
|
3A6125CA86E249F3D6DC7F8C /* BookDetail */,
|
||||||
|
BFA030D1CE2D312C539318DA /* Browse */,
|
||||||
|
AF1FE530FDE94947D4966251 /* ChapterReader */,
|
||||||
|
20E9B4B0C0EDDB3313149544 /* Common */,
|
||||||
|
9AFE0816FF2E9D8DBBA470BD /* Downloads */,
|
||||||
|
2F4B97A2A2234F71AE2C46B2 /* Home */,
|
||||||
|
ED5843EA1B9CB1AD97664571 /* Library */,
|
||||||
|
F9025CCFC608DCEB21B4D9F5 /* Player */,
|
||||||
|
716D22431B17611F7A418D9F /* Profile */,
|
||||||
|
9CFE23EEA1B9E264A36D0FC4 /* Search */,
|
||||||
|
);
|
||||||
|
path = Views;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
ED5843EA1B9CB1AD97664571 /* Library */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
F2634D20198A966396121230 /* LibraryView.swift */,
|
||||||
|
);
|
||||||
|
path = Library;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
F9025CCFC608DCEB21B4D9F5 /* Player */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
71D006B236F6FE653131FFD2 /* PlayerViews.swift */,
|
||||||
|
);
|
||||||
|
path = Player;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
7EEA688C50B734EA22C04CF1 /* LibNovelV2 */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 38B2D5E78E086CB61602C375 /* Build configuration list for PBXNativeTarget "LibNovelV2" */;
|
||||||
|
buildPhases = (
|
||||||
|
BE6BEAD873B53447AABD2346 /* Sources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = LibNovelV2;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = LibNovelV2;
|
||||||
|
productReference = 94CB555099A941E16AD0531A /* LibNovelV2.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
1AC8476B8E9026EB9CE2B4FF /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = YES;
|
||||||
|
LastUpgradeCheck = 1600;
|
||||||
|
};
|
||||||
|
buildConfigurationList = 92AD4EEF6E109D5DC11B2A6F /* Build configuration list for PBXProject "LibNovelV2" */;
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
Base,
|
||||||
|
en,
|
||||||
|
);
|
||||||
|
mainGroup = AA1F8D9C3DA40A1ADCF2B432;
|
||||||
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
preferredProjectObjectVersion = 77;
|
||||||
|
productRefGroup = 03533E32FF0C2EAF1915AD15 /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
7EEA688C50B734EA22C04CF1 /* LibNovelV2 */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
BE6BEAD873B53447AABD2346 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
5FCFCBFBEEFDFD2081068317 /* APIClient.swift in Sources */,
|
||||||
|
280AC764BC30130EDB27A3F0 /* AudioDownloadService.swift in Sources */,
|
||||||
|
E64BCBBA92A983C3851754B5 /* AudioPlayerService.swift in Sources */,
|
||||||
|
F1DB9BC6DC6DFEEA010B7CDF /* AuthStore.swift in Sources */,
|
||||||
|
BEE8DF9B5E6C35389FB07951 /* AuthView.swift in Sources */,
|
||||||
|
7431E92F141CFFF28E891A11 /* BookDetailView.swift in Sources */,
|
||||||
|
30EE28A725E2FA69F8FFCEF8 /* BookDetailViewModel.swift in Sources */,
|
||||||
|
ACE6D62D8E547A90380FB689 /* BookVoicePreferences.swift in Sources */,
|
||||||
|
43034688B18F6F6CD65C5DE5 /* BrowseCategoryView.swift in Sources */,
|
||||||
|
ABB16424CEED3C5E9AAC08B2 /* BrowseView.swift in Sources */,
|
||||||
|
F4DAA587A097C597A9841563 /* BrowseViewModel.swift in Sources */,
|
||||||
|
9F4A645472DC48AD32D5EDCD /* ChapterReaderView.swift in Sources */,
|
||||||
|
64B17B6E30F44E87F33B886B /* ChapterReaderViewModel.swift in Sources */,
|
||||||
|
7C59289066AFD8A999DB9A0A /* CommonViews.swift in Sources */,
|
||||||
|
4F72B63F12BB364C561B5B69 /* ContentView.swift in Sources */,
|
||||||
|
ACCA21E0EDF8BED26E193A76 /* DownloadsView.swift in Sources */,
|
||||||
|
B4C6205A3A7A7A29EDA691FF /* HomeView.swift in Sources */,
|
||||||
|
075C7E597E108D806195B2F0 /* HomeViewModel.swift in Sources */,
|
||||||
|
9FD80E1B54ED74F430064904 /* LibNovelV2App.swift in Sources */,
|
||||||
|
2FB2A044EBE6B90CFB51CF58 /* LibraryView.swift in Sources */,
|
||||||
|
DDBAD183F7974A6FDAECB93C /* LibraryViewModel.swift in Sources */,
|
||||||
|
FC954C552CC0BDFB619BF207 /* Models.swift in Sources */,
|
||||||
|
A753C2AE73CAA00BF1AB0EA4 /* NavDestination.swift in Sources */,
|
||||||
|
B1E2F3A4C5D6E7F8A9B0C1D2 /* String+App.swift in Sources */,
|
||||||
|
792042C137942BCF8CB99C4F /* NetworkMonitor.swift in Sources */,
|
||||||
|
78F2392702ACB553CAFDB335 /* PlayerViews.swift in Sources */,
|
||||||
|
6340BF19FE12FCEBE9607889 /* ProfileView.swift in Sources */,
|
||||||
|
B8C5C43F299C89CFAE4000F1 /* RootTabView.swift in Sources */,
|
||||||
|
464782001051686356AF728B /* SearchView.swift in Sources */,
|
||||||
|
29D0FB039902E6691FBE40DA /* SearchViewModel.swift in Sources */,
|
||||||
|
E8112B785D129C26FEC054AB /* UserProfileView.swift in Sources */,
|
||||||
|
C0EA8DBE751CB22F058CBF20 /* VoiceSelectionView.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
019B1386650D49B9F4F6CCF7 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LIBNOVEL_BASE_URL = "https://v2.libnovel.kalekber.cc";
|
||||||
|
MARKETING_VERSION = 1.0.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
SWIFT_VERSION = 5.10;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
086D97837CBA0A9177D50BB2 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||||
|
CODE_SIGN_STYLE = Manual;
|
||||||
|
DEVELOPMENT_TEAM = GHZXC6FVMU;
|
||||||
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
INFOPLIST_FILE = Resources/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovelV2;
|
||||||
|
PROVISIONING_PROFILE = "af592c3a-f60b-4ac1-a14f-30b8a206017f";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
1A953D152E39A2F172BB4DE4 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
DEVELOPMENT_TEAM = GHZXC6FVMU;
|
||||||
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
INFOPLIST_FILE = Resources/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovelV2;
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
C91972DB753AE2CF04BED70E /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"DEBUG=1",
|
||||||
|
);
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LIBNOVEL_BASE_URL = "https://v2.libnovel.kalekber.cc";
|
||||||
|
MARKETING_VERSION = 1.0.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_VERSION = 5.10;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
38B2D5E78E086CB61602C375 /* Build configuration list for PBXNativeTarget "LibNovelV2" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
1A953D152E39A2F172BB4DE4 /* Debug */,
|
||||||
|
086D97837CBA0A9177D50BB2 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
|
92AD4EEF6E109D5DC11B2A6F /* Build configuration list for PBXProject "LibNovelV2" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
C91972DB753AE2CF04BED70E /* Debug */,
|
||||||
|
019B1386650D49B9F4F6CCF7 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = 1AC8476B8E9026EB9CE2B4FF /* Project object */;
|
||||||
|
}
|
||||||
7
ios/LibNovelV2/LibNovelV2.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
ios/LibNovelV2/LibNovelV2.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1600"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
runPostActionsOnFailure = "NO">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "7EEA688C50B734EA22C04CF1"
|
||||||
|
BuildableName = "LibNovelV2.app"
|
||||||
|
BlueprintName = "LibNovelV2"
|
||||||
|
ReferencedContainer = "container:LibNovelV2.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
onlyGenerateCoverageForSpecifiedTargets = "NO">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "7EEA688C50B734EA22C04CF1"
|
||||||
|
BuildableName = "LibNovelV2.app"
|
||||||
|
BlueprintName = "LibNovelV2"
|
||||||
|
ReferencedContainer = "container:LibNovelV2.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<Testables>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "7EEA688C50B734EA22C04CF1"
|
||||||
|
BuildableName = "LibNovelV2.app"
|
||||||
|
BlueprintName = "LibNovelV2"
|
||||||
|
ReferencedContainer = "container:LibNovelV2.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
|
<EnvironmentVariables>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "LIBNOVEL_BASE_URL"
|
||||||
|
value = "["value": "https://v2.libnovel.kalekber.cc", "isEnabled": true]"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
</EnvironmentVariables>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "7EEA688C50B734EA22C04CF1"
|
||||||
|
BuildableName = "LibNovelV2.app"
|
||||||
|
BlueprintName = "LibNovelV2"
|
||||||
|
ReferencedContainer = "container:LibNovelV2.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
417
ios/LibNovelV2/Models/Models.swift
Normal file
417
ios/LibNovelV2/Models/Models.swift
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Book
|
||||||
|
|
||||||
|
struct Book: Identifiable, Codable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let slug: String
|
||||||
|
let title: String
|
||||||
|
let author: String
|
||||||
|
let cover: String // proxied via /api/cover/...
|
||||||
|
let status: String
|
||||||
|
let genres: [String]
|
||||||
|
let summary: String
|
||||||
|
let totalChapters: Int
|
||||||
|
let sourceURL: String
|
||||||
|
let ranking: Int
|
||||||
|
let metaUpdated: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, slug, title, author, cover, status, genres, summary, ranking
|
||||||
|
case totalChapters = "total_chapters"
|
||||||
|
case sourceURL = "source_url"
|
||||||
|
case metaUpdated = "meta_updated"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(String.self, forKey: .id)
|
||||||
|
slug = try c.decode(String.self, forKey: .slug)
|
||||||
|
title = try c.decode(String.self, forKey: .title)
|
||||||
|
author = try c.decodeIfPresent(String.self, forKey: .author) ?? ""
|
||||||
|
cover = try c.decodeIfPresent(String.self, forKey: .cover) ?? ""
|
||||||
|
status = try c.decodeIfPresent(String.self, forKey: .status) ?? ""
|
||||||
|
totalChapters = try c.decodeIfPresent(Int.self, forKey: .totalChapters) ?? 0
|
||||||
|
sourceURL = try c.decodeIfPresent(String.self, forKey: .sourceURL) ?? ""
|
||||||
|
ranking = try c.decodeIfPresent(Int.self, forKey: .ranking) ?? 0
|
||||||
|
metaUpdated = try c.decodeIfPresent(String.self, forKey: .metaUpdated) ?? ""
|
||||||
|
summary = try c.decodeIfPresent(String.self, forKey: .summary) ?? ""
|
||||||
|
|
||||||
|
// genres can arrive as a JSON-encoded string or a real array
|
||||||
|
if let arr = try? c.decode([String].self, forKey: .genres) {
|
||||||
|
genres = arr
|
||||||
|
} else if let raw = try? c.decode(String.self, forKey: .genres),
|
||||||
|
let data = raw.data(using: .utf8),
|
||||||
|
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||||
|
genres = arr
|
||||||
|
} else {
|
||||||
|
genres = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Chapter index
|
||||||
|
|
||||||
|
struct ChapterIndex: Identifiable, Codable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let slug: String
|
||||||
|
let number: Int
|
||||||
|
let title: String
|
||||||
|
let dateLabel: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, slug, number, title
|
||||||
|
case dateLabel = "date_label"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChapterBrief: Identifiable, Codable, Hashable {
|
||||||
|
var id: Int { number }
|
||||||
|
let number: Int
|
||||||
|
let title: String
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full chapter response from /api/chapter-text/{slug}/{n}
|
||||||
|
struct ChapterResponse: Decodable {
|
||||||
|
struct BookBrief: Decodable {
|
||||||
|
let slug: String
|
||||||
|
let title: String
|
||||||
|
let cover: String
|
||||||
|
}
|
||||||
|
struct ChapterDetail: Decodable {
|
||||||
|
let number: Int
|
||||||
|
let title: String
|
||||||
|
let dateLabel: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case number, title
|
||||||
|
case dateLabel = "date_label"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let book: BookBrief
|
||||||
|
let chapter: ChapterDetail
|
||||||
|
let chapters: [ChapterBrief]
|
||||||
|
let html: String
|
||||||
|
let text: String
|
||||||
|
let prev: Int?
|
||||||
|
let next: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ranking
|
||||||
|
|
||||||
|
struct RankingItem: Codable, Identifiable {
|
||||||
|
var id: String { slug }
|
||||||
|
let rank: Int
|
||||||
|
let slug: String
|
||||||
|
let title: String
|
||||||
|
let author: String
|
||||||
|
let cover: String
|
||||||
|
let status: String
|
||||||
|
let genres: [String]
|
||||||
|
let sourceURL: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case rank, slug, title, author, cover, status, genres
|
||||||
|
case sourceURL = "source_url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Browse listing
|
||||||
|
|
||||||
|
struct NovelListing: Codable, Identifiable {
|
||||||
|
var id: String { slug }
|
||||||
|
let slug: String
|
||||||
|
let title: String
|
||||||
|
let author: String?
|
||||||
|
let cover: String?
|
||||||
|
let status: String?
|
||||||
|
let genres: [String]?
|
||||||
|
let rank: Int?
|
||||||
|
let rating: String?
|
||||||
|
let chapters: String? // e.g. "123 chapters"
|
||||||
|
let url: String?
|
||||||
|
let sourceURL: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case slug, title, author, cover, status, genres, rank, rating, chapters, url
|
||||||
|
case sourceURL = "source_url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Home
|
||||||
|
|
||||||
|
struct HomeStats: Codable {
|
||||||
|
let totalBooks: Int
|
||||||
|
let totalChapters: Int
|
||||||
|
let booksInProgress: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case totalBooks = "total_books"
|
||||||
|
case totalChapters = "total_chapters"
|
||||||
|
case booksInProgress = "books_in_progress"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ContinueReadingItem: Identifiable {
|
||||||
|
var id: String { book.id }
|
||||||
|
let book: Book
|
||||||
|
let chapter: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SubscriptionFeedItem: Identifiable, Decodable {
|
||||||
|
var id: String { book.id + readerUsername }
|
||||||
|
let book: Book
|
||||||
|
let readerUsername: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case book
|
||||||
|
case readerUsername = "readerUsername"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - User
|
||||||
|
|
||||||
|
struct AppUser: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let username: String
|
||||||
|
let role: String
|
||||||
|
let created: String
|
||||||
|
let avatarURL: String?
|
||||||
|
|
||||||
|
var isAdmin: Bool { role == "admin" }
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, username, role, created
|
||||||
|
case avatarURL = "avatar_url"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(String.self, forKey: .id)
|
||||||
|
username = try c.decode(String.self, forKey: .username)
|
||||||
|
role = try c.decodeIfPresent(String.self, forKey: .role) ?? "user"
|
||||||
|
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
|
||||||
|
avatarURL = try c.decodeIfPresent(String.self, forKey: .avatarURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(id: String, username: String, role: String, created: String, avatarURL: String?) {
|
||||||
|
self.id = id
|
||||||
|
self.username = username
|
||||||
|
self.role = role
|
||||||
|
self.created = created
|
||||||
|
self.avatarURL = avatarURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - User settings
|
||||||
|
|
||||||
|
struct UserSettings: Codable {
|
||||||
|
var autoNext: Bool
|
||||||
|
var voice: String
|
||||||
|
var speed: Double
|
||||||
|
|
||||||
|
static let `default` = UserSettings(autoNext: false, voice: "af_bella", speed: 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session
|
||||||
|
|
||||||
|
struct UserSession: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let userAgent: String
|
||||||
|
let ip: String
|
||||||
|
let createdAt: String
|
||||||
|
let lastSeen: String
|
||||||
|
var isCurrent: Bool
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, ip
|
||||||
|
case userAgent = "user_agent"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case lastSeen = "last_seen"
|
||||||
|
case isCurrent = "is_current"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Comments
|
||||||
|
|
||||||
|
struct BookComment: Identifiable, Codable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let slug: String
|
||||||
|
let userId: String
|
||||||
|
let username: String
|
||||||
|
let body: String
|
||||||
|
var upvotes: Int
|
||||||
|
var downvotes: Int
|
||||||
|
let created: String
|
||||||
|
let parentId: String
|
||||||
|
var replies: [BookComment]?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, slug, username, body, upvotes, downvotes, created, replies
|
||||||
|
case userId = "user_id"
|
||||||
|
case parentId = "parent_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(String.self, forKey: .id)
|
||||||
|
slug = try c.decodeIfPresent(String.self, forKey: .slug) ?? ""
|
||||||
|
userId = try c.decodeIfPresent(String.self, forKey: .userId) ?? ""
|
||||||
|
username = try c.decodeIfPresent(String.self, forKey: .username) ?? ""
|
||||||
|
body = try c.decodeIfPresent(String.self, forKey: .body) ?? ""
|
||||||
|
upvotes = try c.decodeIfPresent(Int.self, forKey: .upvotes) ?? 0
|
||||||
|
downvotes = try c.decodeIfPresent(Int.self, forKey: .downvotes) ?? 0
|
||||||
|
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
|
||||||
|
parentId = try c.decodeIfPresent(String.self, forKey: .parentId) ?? ""
|
||||||
|
replies = try c.decodeIfPresent([BookComment].self, forKey: .replies)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CommentsResponse: Decodable {
|
||||||
|
let comments: [BookComment]
|
||||||
|
let myVotes: [String: String]
|
||||||
|
let avatarUrls: [String: String]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case comments, myVotes, avatarUrls
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
comments = try c.decode([BookComment].self, forKey: .comments)
|
||||||
|
myVotes = try c.decodeIfPresent([String: String].self, forKey: .myVotes) ?? [:]
|
||||||
|
avatarUrls = try c.decodeIfPresent([String: String].self, forKey: .avatarUrls) ?? [:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public user profile
|
||||||
|
|
||||||
|
struct PublicUserProfile: Decodable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let username: String
|
||||||
|
let avatarUrl: String?
|
||||||
|
let created: String
|
||||||
|
let followerCount: Int
|
||||||
|
let followingCount: Int
|
||||||
|
let isSubscribed: Bool
|
||||||
|
let isSelf: Bool
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, username, created
|
||||||
|
case avatarUrl = "avatarUrl"
|
||||||
|
case followerCount = "followerCount"
|
||||||
|
case followingCount = "followingCount"
|
||||||
|
case isSubscribed = "isSubscribed"
|
||||||
|
case isSelf = "isSelf"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(String.self, forKey: .id)
|
||||||
|
username = try c.decode(String.self, forKey: .username)
|
||||||
|
avatarUrl = try c.decodeIfPresent(String.self, forKey: .avatarUrl)
|
||||||
|
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
|
||||||
|
followerCount = try c.decodeIfPresent(Int.self, forKey: .followerCount) ?? 0
|
||||||
|
followingCount = try c.decodeIfPresent(Int.self, forKey: .followingCount) ?? 0
|
||||||
|
isSubscribed = try c.decodeIfPresent(Bool.self, forKey: .isSubscribed) ?? false
|
||||||
|
isSelf = try c.decodeIfPresent(Bool.self, forKey: .isSelf) ?? false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PublicLibraryItem: Decodable, Identifiable {
|
||||||
|
var id: String { book.id }
|
||||||
|
let book: Book
|
||||||
|
let lastChapter: Int?
|
||||||
|
let saved: Bool
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case book
|
||||||
|
case lastChapter = "last_chapter"
|
||||||
|
case saved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PublicUserLibraryResponse: Decodable {
|
||||||
|
let currentlyReading: [PublicLibraryItem]
|
||||||
|
let library: [PublicLibraryItem]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case currentlyReading = "currently_reading"
|
||||||
|
case library
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reader Settings (local — UserDefaults)
|
||||||
|
|
||||||
|
enum ReaderTheme: String, CaseIterable, Codable {
|
||||||
|
case white, sepia, night
|
||||||
|
|
||||||
|
var backgroundColor: Color {
|
||||||
|
switch self {
|
||||||
|
case .white: return Color(.sRGB, white: 1.0, opacity: 1)
|
||||||
|
case .sepia: return Color(red: 0.97, green: 0.93, blue: 0.82)
|
||||||
|
case .night: return Color(red: 0.10, green: 0.10, blue: 0.12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var textColor: Color {
|
||||||
|
switch self {
|
||||||
|
case .white: return Color(.sRGB, white: 0.10, opacity: 1)
|
||||||
|
case .sepia: return Color(red: 0.25, green: 0.18, blue: 0.08)
|
||||||
|
case .night: return Color(red: 0.85, green: 0.85, blue: 0.87)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var colorScheme: ColorScheme? {
|
||||||
|
switch self {
|
||||||
|
case .white: return nil
|
||||||
|
case .sepia: return .light
|
||||||
|
case .night: return .dark
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ReaderFont: String, CaseIterable, Codable {
|
||||||
|
case system = "System"
|
||||||
|
case georgia = "Georgia"
|
||||||
|
case newYork = "New York"
|
||||||
|
|
||||||
|
var fontName: String? {
|
||||||
|
switch self {
|
||||||
|
case .system: return nil
|
||||||
|
case .georgia: return "Georgia"
|
||||||
|
case .newYork: return "NewYorkMedium-Regular"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ReaderSettings: Codable, Equatable {
|
||||||
|
var fontSize: CGFloat = 17
|
||||||
|
var lineSpacing: CGFloat = 1.7
|
||||||
|
var font: ReaderFont = .system
|
||||||
|
var theme: ReaderTheme = .white
|
||||||
|
var scrollMode: Bool = false
|
||||||
|
|
||||||
|
private static let key = "v2.readerSettings"
|
||||||
|
|
||||||
|
static func load() -> ReaderSettings {
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: key),
|
||||||
|
let decoded = try? JSONDecoder().decode(ReaderSettings.self, from: data)
|
||||||
|
else { return ReaderSettings() }
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
func save() {
|
||||||
|
if let data = try? JSONEncoder().encode(self) {
|
||||||
|
UserDefaults.standard.set(data, forKey: ReaderSettings.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Audio prefetch status
|
||||||
|
|
||||||
|
enum NextPrefetchStatus {
|
||||||
|
case none, prefetching, prefetched, failed
|
||||||
|
}
|
||||||
520
ios/LibNovelV2/Networking/APIClient.swift
Normal file
520
ios/LibNovelV2/Networking/APIClient.swift
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - API Client
|
||||||
|
// Communicates with the SvelteKit UI server (/api/* endpoints).
|
||||||
|
// Auth is carried via the libnovel_auth cookie (HMAC-signed token).
|
||||||
|
|
||||||
|
actor APIClient {
|
||||||
|
static let shared = APIClient()
|
||||||
|
|
||||||
|
var baseURL: URL
|
||||||
|
private var authCookie: String? // raw "libnovel_auth=<token>" header value
|
||||||
|
|
||||||
|
private let session: URLSession = {
|
||||||
|
let config = URLSessionConfiguration.default
|
||||||
|
config.httpCookieAcceptPolicy = .always
|
||||||
|
config.httpShouldSetCookies = true
|
||||||
|
config.httpCookieStorage = HTTPCookieStorage.shared
|
||||||
|
return URLSession(configuration: config)
|
||||||
|
}()
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
let urlString = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String
|
||||||
|
?? "https://v2.libnovel.kalekber.cc"
|
||||||
|
baseURL = URL(string: urlString)!
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Auth cookie management
|
||||||
|
|
||||||
|
func setAuthCookie(_ value: String?) {
|
||||||
|
authCookie = value
|
||||||
|
if let value {
|
||||||
|
let cookieProps: [HTTPCookiePropertyKey: Any] = [
|
||||||
|
.name: "libnovel_auth",
|
||||||
|
.value: value,
|
||||||
|
.domain: baseURL.host ?? "localhost",
|
||||||
|
.path: "/"
|
||||||
|
]
|
||||||
|
if let cookie = HTTPCookie(properties: cookieProps) {
|
||||||
|
HTTPCookieStorage.shared.setCookie(cookie)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let storage = HTTPCookieStorage.shared
|
||||||
|
storage.cookies(for: baseURL)?.forEach { storage.deleteCookie($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Low-level request builder
|
||||||
|
|
||||||
|
private func makeRequest(_ path: String, method: String = "GET", body: Encodable? = nil) throws -> URLRequest {
|
||||||
|
let urlString = baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
|
+ "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
|
guard let url = URL(string: urlString) else { throw APIError.invalidResponse }
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.httpMethod = method
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
if let body {
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
req.httpBody = try JSONEncoder().encode(body)
|
||||||
|
}
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Generic fetch
|
||||||
|
|
||||||
|
func fetch<T: Decodable>(_ path: String, method: String = "GET", body: Encodable? = nil) async throws -> T {
|
||||||
|
let req = try makeRequest(path, method: method, body: body)
|
||||||
|
let (data, response) = try await session.data(for: req)
|
||||||
|
guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
|
||||||
|
let rawBody = String(data: data, encoding: .utf8) ?? "<non-utf8, \(data.count) bytes>"
|
||||||
|
guard (200..<300).contains(http.statusCode) else {
|
||||||
|
if http.statusCode == 401 { throw APIError.unauthorized }
|
||||||
|
throw APIError.httpError(http.statusCode, rawBody)
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
return try JSONDecoder.apiDecoder.decode(T.self, from: data)
|
||||||
|
} catch {
|
||||||
|
throw APIError.decodingError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchVoid(_ path: String, method: String = "GET", body: Encodable? = nil) async throws {
|
||||||
|
let req = try makeRequest(path, method: method, body: body)
|
||||||
|
let (data, response) = try await session.data(for: req)
|
||||||
|
guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
|
||||||
|
guard (200..<300).contains(http.statusCode) else {
|
||||||
|
let rawBody = String(data: data, encoding: .utf8) ?? "<non-utf8, \(data.count) bytes>"
|
||||||
|
throw APIError.httpError(http.statusCode, rawBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Auth
|
||||||
|
|
||||||
|
private struct LoginRequest: Encodable {
|
||||||
|
let username: String
|
||||||
|
let password: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LoginResponse: Decodable {
|
||||||
|
let token: String
|
||||||
|
let user: AppUser
|
||||||
|
}
|
||||||
|
|
||||||
|
func login(username: String, password: String) async throws -> LoginResponse {
|
||||||
|
try await fetch("/api/auth/login", method: "POST",
|
||||||
|
body: LoginRequest(username: username, password: password))
|
||||||
|
}
|
||||||
|
|
||||||
|
func register(username: String, password: String) async throws -> LoginResponse {
|
||||||
|
try await fetch("/api/auth/register", method: "POST",
|
||||||
|
body: LoginRequest(username: username, password: password))
|
||||||
|
}
|
||||||
|
|
||||||
|
func logout() async throws {
|
||||||
|
let _: EmptyResponse = try await fetch("/api/auth/logout", method: "POST")
|
||||||
|
setAuthCookie(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Home
|
||||||
|
|
||||||
|
func homeData() async throws -> HomeDataResponse {
|
||||||
|
try await fetch("/api/home")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Library
|
||||||
|
|
||||||
|
func library() async throws -> [LibraryItem] {
|
||||||
|
try await fetch("/api/library")
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveBook(slug: String) async throws {
|
||||||
|
let _: EmptyResponse = try await fetch("/api/library/\(slug)", method: "POST")
|
||||||
|
}
|
||||||
|
|
||||||
|
func unsaveBook(slug: String) async throws {
|
||||||
|
let _: EmptyResponse = try await fetch("/api/library/\(slug)", method: "DELETE")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Book Detail
|
||||||
|
|
||||||
|
func bookDetail(slug: String) async throws -> BookDetailResponse {
|
||||||
|
try await fetch("/api/book/\(slug)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Chapter
|
||||||
|
|
||||||
|
func chapterContent(slug: String, chapter: Int) async throws -> ChapterResponse {
|
||||||
|
try await fetch("/api/chapter/\(slug)/\(chapter)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Browse
|
||||||
|
|
||||||
|
func browse(page: Int, genre: String = "all", sort: String = "popular", status: String = "all") async throws -> BrowseResponse {
|
||||||
|
let query = "?page=\(page)&genre=\(genre)&sort=\(sort)&status=\(status)"
|
||||||
|
return try await fetch("/api/browse-page\(query)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func search(query: String) async throws -> SearchResponse {
|
||||||
|
let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query
|
||||||
|
return try await fetch("/api/search?q=\(encoded)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ranking() async throws -> [RankingItem] {
|
||||||
|
try await fetch("/api/ranking")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Progress
|
||||||
|
|
||||||
|
func progress() async throws -> [ProgressEntry] {
|
||||||
|
try await fetch("/api/progress")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setProgress(slug: String, chapter: Int) async throws {
|
||||||
|
struct Body: Encodable { let chapter: Int }
|
||||||
|
let _: EmptyResponse = try await fetch("/api/progress/\(slug)", method: "POST", body: Body(chapter: chapter))
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteProgress(slug: String) async throws {
|
||||||
|
let _: EmptyResponse = try await fetch("/api/progress/\(slug)", method: "DELETE")
|
||||||
|
}
|
||||||
|
|
||||||
|
func audioTime(slug: String, chapter: Int) async throws -> Double? {
|
||||||
|
struct Response: Decodable {
|
||||||
|
let audioTime: Double?
|
||||||
|
enum CodingKeys: String, CodingKey { case audioTime = "audio_time" }
|
||||||
|
}
|
||||||
|
let r: Response = try await fetch("/api/progress/audio-time?slug=\(slug)&chapter=\(chapter)")
|
||||||
|
return r.audioTime
|
||||||
|
}
|
||||||
|
|
||||||
|
func setAudioTime(slug: String, chapter: Int, time: Double) async throws {
|
||||||
|
struct Body: Encodable {
|
||||||
|
let slug: String; let chapter: Int; let audioTime: Double
|
||||||
|
enum CodingKeys: String, CodingKey { case slug, chapter; case audioTime = "audio_time" }
|
||||||
|
}
|
||||||
|
let _: EmptyResponse = try await fetch("/api/progress/audio-time", method: "PATCH",
|
||||||
|
body: Body(slug: slug, chapter: chapter, audioTime: time))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Audio
|
||||||
|
|
||||||
|
func triggerAudio(slug: String, chapter: Int, voice: String, speed: Double) async throws -> AudioTriggerResponse {
|
||||||
|
struct Body: Encodable { let voice: String; let speed: Double }
|
||||||
|
return try await fetch("/api/audio/\(slug)/\(chapter)", method: "POST", body: Body(voice: voice, speed: speed))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll until the TTS job is done, failed, or the task is cancelled.
|
||||||
|
/// Returns the playback URL on success.
|
||||||
|
func pollAudioStatus(slug: String, chapter: Int, voice: String) async throws -> String {
|
||||||
|
let path = "/api/audio/status/\(slug)/\(chapter)?voice=\(voice)"
|
||||||
|
struct StatusResponse: Decodable {
|
||||||
|
let status: String
|
||||||
|
let url: String?
|
||||||
|
let error: String?
|
||||||
|
}
|
||||||
|
while true {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
let r: StatusResponse = try await fetch(path)
|
||||||
|
switch r.status {
|
||||||
|
case "done":
|
||||||
|
guard let url = r.url, !url.isEmpty else { throw URLError(.badServerResponse) }
|
||||||
|
return url
|
||||||
|
case "failed":
|
||||||
|
throw NSError(domain: "AudioGeneration", code: 0,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: r.error ?? "Audio generation failed"])
|
||||||
|
default:
|
||||||
|
try await Task.sleep(nanoseconds: 2_000_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func presignAudio(slug: String, chapter: Int, voice: String) async throws -> String {
|
||||||
|
struct Response: Decodable { let url: String }
|
||||||
|
let r: Response = try await fetch("/api/presign/audio?slug=\(slug)&chapter=\(chapter)&voice=\(voice)")
|
||||||
|
return r.url
|
||||||
|
}
|
||||||
|
|
||||||
|
func presignVoiceSample(voice: String) async throws -> String {
|
||||||
|
struct Response: Decodable { let url: String }
|
||||||
|
let r: Response = try await fetch("/api/presign/voice-sample?voice=\(voice)")
|
||||||
|
return r.url
|
||||||
|
}
|
||||||
|
|
||||||
|
func voices() async throws -> [String] {
|
||||||
|
struct Response: Decodable { let voices: [String] }
|
||||||
|
let r: Response = try await fetch("/api/voices")
|
||||||
|
return r.voices
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings
|
||||||
|
|
||||||
|
func settings() async throws -> UserSettings {
|
||||||
|
try await fetch("/api/settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateSettings(_ settings: UserSettings) async throws {
|
||||||
|
let _: EmptyResponse = try await fetch("/api/settings", method: "PUT", body: settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sessions
|
||||||
|
|
||||||
|
func sessions() async throws -> [UserSession] {
|
||||||
|
struct Response: Decodable { let sessions: [UserSession] }
|
||||||
|
let r: Response = try await fetch("/api/sessions")
|
||||||
|
return r.sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
func revokeSession(id: String) async throws {
|
||||||
|
let _: EmptyResponse = try await fetch("/api/sessions/\(id)", method: "DELETE")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Avatar
|
||||||
|
|
||||||
|
struct AvatarPresignResponse: Decodable {
|
||||||
|
let uploadURL: String
|
||||||
|
let key: String
|
||||||
|
enum CodingKeys: String, CodingKey { case uploadURL = "upload_url"; case key }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AvatarResponse: Decodable {
|
||||||
|
let avatarURL: String?
|
||||||
|
enum CodingKeys: String, CodingKey { case avatarURL = "avatar_url" }
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadAvatar(_ imageData: Data, mimeType: String = "image/jpeg") async throws -> String? {
|
||||||
|
let presign: AvatarPresignResponse = try await fetch(
|
||||||
|
"/api/profile/avatar", method: "POST", body: ["mime_type": mimeType])
|
||||||
|
|
||||||
|
guard let putURL = URL(string: presign.uploadURL) else { throw APIError.invalidResponse }
|
||||||
|
var putReq = URLRequest(url: putURL)
|
||||||
|
putReq.httpMethod = "PUT"
|
||||||
|
putReq.setValue(mimeType, forHTTPHeaderField: "Content-Type")
|
||||||
|
putReq.httpBody = imageData
|
||||||
|
let (_, putResp) = try await session.data(for: putReq)
|
||||||
|
guard let putHttp = putResp as? HTTPURLResponse, (200..<300).contains(putHttp.statusCode) else {
|
||||||
|
throw APIError.httpError((putResp as? HTTPURLResponse)?.statusCode ?? 0, "MinIO PUT failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: AvatarResponse = try await fetch("/api/profile/avatar", method: "PATCH", body: ["key": presign.key])
|
||||||
|
return result.avatarURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchAvatarPresignedURL() async throws -> String? {
|
||||||
|
let result: AvatarResponse = try await fetch("/api/profile/avatar")
|
||||||
|
return result.avatarURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - User Profiles & Subscriptions
|
||||||
|
|
||||||
|
func fetchUserProfile(username: String) async throws -> PublicUserProfile {
|
||||||
|
try await fetch("/api/users/\(username)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func subscribeUser(username: String) async throws -> Bool {
|
||||||
|
struct Response: Decodable { let subscribed: Bool }
|
||||||
|
let r: Response = try await fetch("/api/users/\(username)/subscribe", method: "POST")
|
||||||
|
return r.subscribed
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func unsubscribeUser(username: String) async throws -> Bool {
|
||||||
|
struct Response: Decodable { let subscribed: Bool }
|
||||||
|
let r: Response = try await fetch("/api/users/\(username)/subscribe", method: "DELETE")
|
||||||
|
return r.subscribed
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchUserLibrary(username: String) async throws -> PublicUserLibraryResponse {
|
||||||
|
try await fetch("/api/users/\(username)/library")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Comments
|
||||||
|
|
||||||
|
func fetchComments(slug: String, sort: String = "top") async throws -> CommentsResponse {
|
||||||
|
try await fetch("/api/comments/\(slug)?sort=\(sort)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PostCommentBody: Encodable {
|
||||||
|
let body: String
|
||||||
|
let parent_id: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
func postComment(slug: String, body: String, parentId: String? = nil) async throws -> BookComment {
|
||||||
|
try await fetch("/api/comments/\(slug)", method: "POST",
|
||||||
|
body: PostCommentBody(body: body, parent_id: parentId))
|
||||||
|
}
|
||||||
|
|
||||||
|
func voteComment(commentId: String, vote: String) async throws -> BookComment {
|
||||||
|
struct VoteBody: Encodable { let vote: String }
|
||||||
|
return try await fetch("/api/comment/\(commentId)/vote", method: "POST", body: VoteBody(vote: vote))
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteComment(commentId: String) async throws {
|
||||||
|
try await fetchVoid("/api/comment/\(commentId)", method: "DELETE")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Response types
|
||||||
|
|
||||||
|
struct HomeDataResponse: Decodable {
|
||||||
|
struct ContinueItem: Decodable {
|
||||||
|
let book: Book
|
||||||
|
let chapter: Int
|
||||||
|
}
|
||||||
|
let continueReading: [ContinueItem]
|
||||||
|
let recentlyUpdated: [Book]
|
||||||
|
let stats: HomeStats
|
||||||
|
let subscriptionFeed: [SubscriptionFeedItem]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case continueReading = "continue_reading"
|
||||||
|
case recentlyUpdated = "recently_updated"
|
||||||
|
case stats
|
||||||
|
case subscriptionFeed = "subscription_feed"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
continueReading = try c.decodeIfPresent([ContinueItem].self, forKey: .continueReading) ?? []
|
||||||
|
recentlyUpdated = try c.decodeIfPresent([Book].self, forKey: .recentlyUpdated) ?? []
|
||||||
|
stats = try c.decode(HomeStats.self, forKey: .stats)
|
||||||
|
subscriptionFeed = try c.decodeIfPresent([SubscriptionFeedItem].self, forKey: .subscriptionFeed) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LibraryItem: Decodable, Identifiable {
|
||||||
|
var id: String { book.id }
|
||||||
|
let book: Book
|
||||||
|
let savedAt: String
|
||||||
|
let lastChapter: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case book
|
||||||
|
case savedAt = "saved_at"
|
||||||
|
case lastChapter = "last_chapter"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BookDetailResponse: Decodable {
|
||||||
|
let book: Book
|
||||||
|
let chapters: [ChapterIndex]
|
||||||
|
let inLib: Bool
|
||||||
|
let saved: Bool
|
||||||
|
let lastChapter: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case book, chapters
|
||||||
|
case inLib = "in_lib"
|
||||||
|
case saved
|
||||||
|
case lastChapter = "last_chapter"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BrowseResponse: Decodable {
|
||||||
|
let novels: [BrowseNovel]
|
||||||
|
let page: Int
|
||||||
|
let hasNext: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BrowseNovel: Decodable, Identifiable, Hashable {
|
||||||
|
var id: String { slug.isEmpty ? url : slug }
|
||||||
|
let slug: String
|
||||||
|
let title: String
|
||||||
|
let cover: String
|
||||||
|
let rank: String
|
||||||
|
let rating: String
|
||||||
|
let chapters: String
|
||||||
|
let url: String
|
||||||
|
let author: String
|
||||||
|
let status: String
|
||||||
|
let genres: [String]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case slug, title, cover, rank, rating, chapters, url, author, status, genres
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
slug = try c.decodeIfPresent(String.self, forKey: .slug) ?? ""
|
||||||
|
title = try c.decode(String.self, forKey: .title)
|
||||||
|
cover = try c.decodeIfPresent(String.self, forKey: .cover) ?? ""
|
||||||
|
rank = try c.decodeIfPresent(String.self, forKey: .rank) ?? ""
|
||||||
|
rating = try c.decodeIfPresent(String.self, forKey: .rating) ?? ""
|
||||||
|
chapters = try c.decodeIfPresent(String.self, forKey: .chapters) ?? ""
|
||||||
|
url = try c.decodeIfPresent(String.self, forKey: .url) ?? ""
|
||||||
|
author = try c.decodeIfPresent(String.self, forKey: .author) ?? ""
|
||||||
|
status = try c.decodeIfPresent(String.self, forKey: .status) ?? ""
|
||||||
|
genres = try c.decodeIfPresent([String].self, forKey: .genres) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SearchResponse: Decodable {
|
||||||
|
let results: [BrowseNovel]
|
||||||
|
let localCount: Int
|
||||||
|
let remoteCount: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case results
|
||||||
|
case localCount = "local_count"
|
||||||
|
case remoteCount = "remote_count"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AudioTriggerResponse: Decodable {
|
||||||
|
let jobId: String?
|
||||||
|
let status: String?
|
||||||
|
let url: String?
|
||||||
|
let filename: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case jobId = "job_id"
|
||||||
|
case status, url, filename
|
||||||
|
}
|
||||||
|
|
||||||
|
var isAsync: Bool { jobId != nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProgressEntry: Decodable, Identifiable {
|
||||||
|
var id: String { slug }
|
||||||
|
let slug: String
|
||||||
|
let chapter: Int
|
||||||
|
let audioTime: Double?
|
||||||
|
let updated: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case slug, chapter, updated
|
||||||
|
case audioTime = "audio_time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EmptyResponse: Decodable {}
|
||||||
|
|
||||||
|
// MARK: - API Error
|
||||||
|
|
||||||
|
enum APIError: LocalizedError {
|
||||||
|
case invalidResponse
|
||||||
|
case httpError(Int, String)
|
||||||
|
case decodingError(Error)
|
||||||
|
case unauthorized
|
||||||
|
case networkError(Error)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidResponse: return "Invalid server response"
|
||||||
|
case .httpError(let code, let m): return "HTTP \(code): \(m)"
|
||||||
|
case .decodingError(let e): return "Decode error: \(e.localizedDescription)"
|
||||||
|
case .unauthorized: return "Not authenticated"
|
||||||
|
case .networkError(let e): return e.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - JSONDecoder helper
|
||||||
|
|
||||||
|
extension JSONDecoder {
|
||||||
|
static let apiDecoder: JSONDecoder = {
|
||||||
|
let d = JSONDecoder()
|
||||||
|
d.dateDecodingStrategy = .iso8601
|
||||||
|
return d
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"colors": [
|
||||||
|
{
|
||||||
|
"color": {
|
||||||
|
"color-space": "srgb",
|
||||||
|
"components": { "alpha": "1.000", "blue": "0.043", "green": "0.620", "red": "0.961" }
|
||||||
|
},
|
||||||
|
"idiom": "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info": { "author": "xcode", "version": 1 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"filename": "icon-1024.png",
|
||||||
|
"idiom": "universal",
|
||||||
|
"platform": "ios",
|
||||||
|
"size": "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info": { "author": "xcode", "version": 1 }
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
3
ios/LibNovelV2/Resources/Assets.xcassets/Contents.json
Normal file
3
ios/LibNovelV2/Resources/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"info": { "author": "xcode", "version": 1 }
|
||||||
|
}
|
||||||
45
ios/LibNovelV2/Resources/Info.plist
Normal file
45
ios/LibNovelV2/Resources/Info.plist
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>LibNovel</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>LibNovel</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>LIBNOVEL_BASE_URL</key>
|
||||||
|
<string>$(LIBNOVEL_BASE_URL)</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>audio</string>
|
||||||
|
<string>fetch</string>
|
||||||
|
<string>processing</string>
|
||||||
|
</array>
|
||||||
|
<key>UILaunchScreen</key>
|
||||||
|
<dict/>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
230
ios/LibNovelV2/Services/AudioDownloadService.swift
Normal file
230
ios/LibNovelV2/Services/AudioDownloadService.swift
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - AudioDownloadService
|
||||||
|
// Manages offline TTS audio downloads with progress tracking.
|
||||||
|
// Uses a background URLSession so downloads survive app suspension.
|
||||||
|
// Keys use "::" separator (slugs contain hyphens).
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AudioDownloadService: NSObject, ObservableObject {
|
||||||
|
static let shared = AudioDownloadService()
|
||||||
|
|
||||||
|
// MARK: - Published state
|
||||||
|
|
||||||
|
@Published var downloads: [String: DownloadProgress] = [:] // key: "slug::chapter::voice"
|
||||||
|
@Published var downloadedChapters: Set<String> = [] // key: "slug::chapter::voice"
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private var session: URLSession!
|
||||||
|
private var activeTasks: [String: URLSessionDownloadTask] = [:]
|
||||||
|
private let fileManager = FileManager.default
|
||||||
|
private let metadataKey = "v2.downloadedChapters"
|
||||||
|
|
||||||
|
// MARK: - Init
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
super.init()
|
||||||
|
let config = URLSessionConfiguration.background(
|
||||||
|
withIdentifier: "cc.kalekber.libnovel.v2.audio-downloads")
|
||||||
|
config.isDiscretionary = false
|
||||||
|
config.sessionSendsLaunchEvents = true
|
||||||
|
session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
||||||
|
loadMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
func isDownloaded(slug: String, chapter: Int, voice: String) -> Bool {
|
||||||
|
downloadedChapters.contains(makeKey(slug: slug, chapter: chapter, voice: voice))
|
||||||
|
}
|
||||||
|
|
||||||
|
func localURL(slug: String, chapter: Int, voice: String) -> URL? {
|
||||||
|
guard isDownloaded(slug: slug, chapter: chapter, voice: voice) else { return nil }
|
||||||
|
return audioFileURL(slug: slug, chapter: chapter, voice: voice)
|
||||||
|
}
|
||||||
|
|
||||||
|
func download(slug: String, chapter: Int, voice: String) async throws {
|
||||||
|
let key = makeKey(slug: slug, chapter: chapter, voice: voice)
|
||||||
|
guard !downloadedChapters.contains(key), activeTasks[key] == nil else { return }
|
||||||
|
|
||||||
|
let urlString = try await APIClient.shared.presignAudio(slug: slug, chapter: chapter, voice: voice)
|
||||||
|
guard let url = URL(string: urlString) else { throw URLError(.badURL) }
|
||||||
|
|
||||||
|
let task = session.downloadTask(with: url)
|
||||||
|
task.taskDescription = key
|
||||||
|
activeTasks[key] = task
|
||||||
|
|
||||||
|
downloads[key] = DownloadProgress(
|
||||||
|
slug: slug, chapter: chapter, voice: voice,
|
||||||
|
progress: 0, totalBytes: 0, downloadedBytes: 0, status: .downloading)
|
||||||
|
task.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelDownload(slug: String, chapter: Int, voice: String) {
|
||||||
|
let key = makeKey(slug: slug, chapter: chapter, voice: voice)
|
||||||
|
activeTasks[key]?.cancel()
|
||||||
|
activeTasks.removeValue(forKey: key)
|
||||||
|
downloads.removeValue(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteDownload(slug: String, chapter: Int, voice: String) throws {
|
||||||
|
let key = makeKey(slug: slug, chapter: chapter, voice: voice)
|
||||||
|
let fileURL = audioFileURL(slug: slug, chapter: chapter, voice: voice)
|
||||||
|
if fileManager.fileExists(atPath: fileURL.path) {
|
||||||
|
try fileManager.removeItem(at: fileURL)
|
||||||
|
}
|
||||||
|
downloadedChapters.remove(key)
|
||||||
|
downloads.removeValue(forKey: key)
|
||||||
|
saveMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteAllDownloads() throws {
|
||||||
|
if let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
|
||||||
|
let audioDir = docs.appendingPathComponent("audio")
|
||||||
|
if fileManager.fileExists(atPath: audioDir.path) {
|
||||||
|
try fileManager.removeItem(at: audioDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
downloadedChapters.removeAll()
|
||||||
|
downloads.removeAll()
|
||||||
|
activeTasks.values.forEach { $0.cancel() }
|
||||||
|
activeTasks.removeAll()
|
||||||
|
saveMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
func totalStorageUsed() -> Int64 {
|
||||||
|
guard let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { return 0 }
|
||||||
|
let audioDir = docs.appendingPathComponent("audio")
|
||||||
|
guard let enumerator = fileManager.enumerator(at: audioDir,
|
||||||
|
includingPropertiesForKeys: [.fileSizeKey]) else { return 0 }
|
||||||
|
var total: Int64 = 0
|
||||||
|
for case let url as URL in enumerator {
|
||||||
|
if let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize {
|
||||||
|
total += Int64(size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
func offlineBookSlugs() -> [String] {
|
||||||
|
Array(Set(downloadedChapters.compactMap { key -> String? in
|
||||||
|
let parts = key.split(separator: "::")
|
||||||
|
return parts.count == 3 ? String(parts[0]) : nil
|
||||||
|
})).sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadedChapterCount(for slug: String) -> Int {
|
||||||
|
downloadedChapters.filter { $0.hasPrefix("\(slug)::") }.count
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Key / path helpers
|
||||||
|
|
||||||
|
func makeKey(slug: String, chapter: Int, voice: String) -> String {
|
||||||
|
"\(slug)::\(chapter)::\(voice)"
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private func audioFileURL(slug: String, chapter: Int, voice: String) -> URL {
|
||||||
|
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
|
return docs
|
||||||
|
.appendingPathComponent("audio")
|
||||||
|
.appendingPathComponent(slug)
|
||||||
|
.appendingPathComponent("\(chapter)-\(voice).mp3")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Persistence
|
||||||
|
|
||||||
|
private func loadMetadata() {
|
||||||
|
if let data = UserDefaults.standard.data(forKey: metadataKey),
|
||||||
|
let decoded = try? JSONDecoder().decode(Set<String>.self, from: data) {
|
||||||
|
downloadedChapters = decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveMetadata() {
|
||||||
|
if let encoded = try? JSONEncoder().encode(downloadedChapters) {
|
||||||
|
UserDefaults.standard.set(encoded, forKey: metadataKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - URLSessionDownloadDelegate
|
||||||
|
|
||||||
|
extension AudioDownloadService: URLSessionDownloadDelegate {
|
||||||
|
|
||||||
|
nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
|
||||||
|
didFinishDownloadingTo location: URL) {
|
||||||
|
guard let key = downloadTask.taskDescription else { return }
|
||||||
|
let parts = key.split(separator: "::")
|
||||||
|
guard parts.count == 3, let chapter = Int(parts[1]) else { return }
|
||||||
|
let slug = String(parts[0])
|
||||||
|
let voice = String(parts[2])
|
||||||
|
let dest = audioFileURL(slug: slug, chapter: chapter, voice: voice)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let dir = dest.deletingLastPathComponent()
|
||||||
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
if FileManager.default.fileExists(atPath: dest.path) {
|
||||||
|
try FileManager.default.removeItem(at: dest)
|
||||||
|
}
|
||||||
|
try FileManager.default.moveItem(at: location, to: dest)
|
||||||
|
Task { @MainActor in
|
||||||
|
self.downloadedChapters.insert(key)
|
||||||
|
self.downloads.removeValue(forKey: key)
|
||||||
|
self.activeTasks.removeValue(forKey: key)
|
||||||
|
self.saveMetadata()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Task { @MainActor in
|
||||||
|
self.downloads[key]?.status = .failed(error.localizedDescription)
|
||||||
|
self.activeTasks.removeValue(forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
|
||||||
|
didWriteData _: Int64, totalBytesWritten: Int64,
|
||||||
|
totalBytesExpectedToWrite: Int64) {
|
||||||
|
guard let key = downloadTask.taskDescription else { return }
|
||||||
|
let progress = totalBytesExpectedToWrite > 0
|
||||||
|
? Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) : 0
|
||||||
|
Task { @MainActor in
|
||||||
|
if var p = self.downloads[key] {
|
||||||
|
p.downloadedBytes = totalBytesWritten
|
||||||
|
p.totalBytes = totalBytesExpectedToWrite
|
||||||
|
p.progress = progress
|
||||||
|
self.downloads[key] = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func urlSession(_ session: URLSession, task: URLSessionTask,
|
||||||
|
didCompleteWithError error: Error?) {
|
||||||
|
guard let key = task.taskDescription, let error else { return }
|
||||||
|
let nsErr = error as NSError
|
||||||
|
guard nsErr.code != NSURLErrorCancelled else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
self.downloads[key]?.status = .failed(error.localizedDescription)
|
||||||
|
self.activeTasks.removeValue(forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Supporting types
|
||||||
|
|
||||||
|
struct DownloadProgress: Equatable {
|
||||||
|
let slug: String
|
||||||
|
let chapter: Int
|
||||||
|
let voice: String
|
||||||
|
var progress: Double
|
||||||
|
var totalBytes: Int64
|
||||||
|
var downloadedBytes: Int64
|
||||||
|
var status: DownloadStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DownloadStatus: Equatable {
|
||||||
|
case downloading
|
||||||
|
case completed
|
||||||
|
case failed(String)
|
||||||
|
}
|
||||||
492
ios/LibNovelV2/Services/AudioPlayerService.swift
Normal file
492
ios/LibNovelV2/Services/AudioPlayerService.swift
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
import Foundation
|
||||||
|
import AVFoundation
|
||||||
|
import MediaPlayer
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - PlaybackProgress
|
||||||
|
// High-frequency playback state isolated into its own ObservableObject so that
|
||||||
|
// the 0.5-second time-observer ticks only invalidate views that explicitly
|
||||||
|
// subscribe to this object (seek bar, play/pause button), leaving menus and
|
||||||
|
// other stable UI untouched.
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class PlaybackProgress: ObservableObject {
|
||||||
|
@Published var currentTime: Double = 0
|
||||||
|
@Published var duration: Double = 0
|
||||||
|
@Published var isPlaying: Bool = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AudioPlayerService
|
||||||
|
// Central singleton owning AVPlayer, lock-screen controls (NowPlayingInfoCenter
|
||||||
|
// + MPRemoteCommandCenter), and next-chapter prefetch.
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AudioPlayerService: ObservableObject {
|
||||||
|
|
||||||
|
// MARK: - Published state
|
||||||
|
|
||||||
|
@Published var slug: String = ""
|
||||||
|
@Published var chapter: Int = 0
|
||||||
|
@Published var chapterTitle: String = ""
|
||||||
|
@Published var bookTitle: String = ""
|
||||||
|
@Published var coverURL: String = ""
|
||||||
|
@Published var voice: String = "af_bella"
|
||||||
|
@Published var speed: Double = 1.0
|
||||||
|
@Published var chapters: [ChapterBrief] = []
|
||||||
|
|
||||||
|
@Published var status: AudioPlayerStatus = .idle
|
||||||
|
@Published var audioURL: String = ""
|
||||||
|
@Published var errorMessage: String = ""
|
||||||
|
@Published var generationProgress: Double = 0
|
||||||
|
|
||||||
|
/// High-frequency playback state — subscribe directly to avoid re-rendering parents.
|
||||||
|
let progress = PlaybackProgress()
|
||||||
|
|
||||||
|
// Convenience forwarders for callers that don't need granular isolation.
|
||||||
|
var currentTime: Double { get { progress.currentTime } set { progress.currentTime = newValue } }
|
||||||
|
var duration: Double { get { progress.duration } set { progress.duration = newValue } }
|
||||||
|
var isPlaying: Bool { get { progress.isPlaying } set { progress.isPlaying = newValue } }
|
||||||
|
|
||||||
|
@Published var autoNext: Bool = false
|
||||||
|
@Published var nextChapter: Int? = nil
|
||||||
|
@Published var prevChapter: Int? = nil
|
||||||
|
|
||||||
|
@Published var sleepTimer: SleepTimerOption? = nil
|
||||||
|
@Published var sleepTimerRemainingText: String = ""
|
||||||
|
|
||||||
|
@Published var nextPrefetchStatus: NextPrefetchStatus = .none
|
||||||
|
@Published var nextAudioURL: String = ""
|
||||||
|
@Published var nextPrefetchedChapter: Int? = nil
|
||||||
|
|
||||||
|
var isActive: Bool {
|
||||||
|
if case .idle = status { return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private var player: AVPlayer?
|
||||||
|
private var playerItem: AVPlayerItem?
|
||||||
|
private var timeObserver: Any?
|
||||||
|
private var statusObserver: AnyCancellable?
|
||||||
|
private var durationObserver: AnyCancellable?
|
||||||
|
private var finishObserver: AnyCancellable?
|
||||||
|
private var generationTask: Task<Void, Never>?
|
||||||
|
private var prefetchTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
private var cachedCoverArtwork: MPMediaItemArtwork?
|
||||||
|
private var cachedCoverURL: String = ""
|
||||||
|
|
||||||
|
private var sleepTimerTask: Task<Void, Never>?
|
||||||
|
private var sleepTimerStartChapter: Int = 0
|
||||||
|
private var sleepTimerDeadline: Date? = nil
|
||||||
|
private var sleepTimerCountdownTask: Task<Void, Never>? = nil
|
||||||
|
|
||||||
|
// MARK: - Init
|
||||||
|
|
||||||
|
init() {
|
||||||
|
configureAudioSession()
|
||||||
|
setupRemoteCommandCenter()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
func load(slug: String, chapter: Int, chapterTitle: String,
|
||||||
|
bookTitle: String, coverURL: String, voice: String, speed: Double,
|
||||||
|
chapters: [ChapterBrief], nextChapter: Int?, prevChapter: Int?) {
|
||||||
|
generationTask?.cancel()
|
||||||
|
prefetchTask?.cancel()
|
||||||
|
stop()
|
||||||
|
|
||||||
|
self.slug = slug
|
||||||
|
self.chapter = chapter
|
||||||
|
self.chapterTitle = chapterTitle
|
||||||
|
self.bookTitle = bookTitle
|
||||||
|
self.coverURL = coverURL
|
||||||
|
self.voice = voice
|
||||||
|
self.speed = speed
|
||||||
|
self.chapters = chapters
|
||||||
|
self.nextChapter = nextChapter
|
||||||
|
self.prevChapter = prevChapter
|
||||||
|
self.nextPrefetchStatus = .none
|
||||||
|
self.nextAudioURL = ""
|
||||||
|
self.nextPrefetchedChapter = nil
|
||||||
|
|
||||||
|
if case .chapters = sleepTimer { sleepTimerStartChapter = chapter }
|
||||||
|
|
||||||
|
status = .generating
|
||||||
|
generationProgress = 0
|
||||||
|
|
||||||
|
if coverURL != cachedCoverURL {
|
||||||
|
cachedCoverArtwork = nil
|
||||||
|
cachedCoverURL = coverURL
|
||||||
|
Task { await prefetchCoverArtwork(from: coverURL) }
|
||||||
|
}
|
||||||
|
|
||||||
|
generationTask = Task { await generateAudio() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func play() {
|
||||||
|
player?.play()
|
||||||
|
player?.rate = Float(speed)
|
||||||
|
isPlaying = true
|
||||||
|
updateNowPlaying()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pause() {
|
||||||
|
player?.pause()
|
||||||
|
isPlaying = false
|
||||||
|
updateNowPlaying()
|
||||||
|
}
|
||||||
|
|
||||||
|
func togglePlayPause() {
|
||||||
|
isPlaying ? pause() : play()
|
||||||
|
}
|
||||||
|
|
||||||
|
func seek(to seconds: Double) {
|
||||||
|
let time = CMTime(seconds: seconds, preferredTimescale: 600)
|
||||||
|
currentTime = seconds
|
||||||
|
player?.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { @MainActor in self.updateNowPlaying() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func skip(by seconds: Double) {
|
||||||
|
seek(to: max(0, min(currentTime + seconds, duration)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSpeed(_ newSpeed: Double) {
|
||||||
|
speed = newSpeed
|
||||||
|
if isPlaying { player?.rate = Float(newSpeed) }
|
||||||
|
updateNowPlaying()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSleepTimer(_ option: SleepTimerOption?) {
|
||||||
|
sleepTimerTask?.cancel(); sleepTimerTask = nil
|
||||||
|
sleepTimerCountdownTask?.cancel(); sleepTimerCountdownTask = nil
|
||||||
|
sleepTimerDeadline = nil
|
||||||
|
sleepTimer = option
|
||||||
|
|
||||||
|
guard let option else { sleepTimerRemainingText = ""; return }
|
||||||
|
|
||||||
|
switch option {
|
||||||
|
case .chapters(let count):
|
||||||
|
sleepTimerStartChapter = chapter
|
||||||
|
updateChapterTimerLabel(chaptersRemaining: count)
|
||||||
|
|
||||||
|
case .minutes(let minutes):
|
||||||
|
let deadline = Date().addingTimeInterval(Double(minutes) * 60)
|
||||||
|
sleepTimerDeadline = deadline
|
||||||
|
sleepTimerTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(minutes) * 60 * 1_000_000_000)
|
||||||
|
guard let self, !Task.isCancelled else { return }
|
||||||
|
await MainActor.run { self.stop(); self.sleepTimer = nil; self.sleepTimerRemainingText = "" }
|
||||||
|
}
|
||||||
|
sleepTimerCountdownTask = Task { [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
|
guard let self, !Task.isCancelled else { return }
|
||||||
|
await MainActor.run {
|
||||||
|
guard let d = self.sleepTimerDeadline else { return }
|
||||||
|
self.sleepTimerRemainingText = Self.formatCountdown(max(0, d.timeIntervalSinceNow))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sleepTimerRemainingText = Self.formatCountdown(Double(minutes) * 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
player?.pause()
|
||||||
|
teardownPlayer()
|
||||||
|
isPlaying = false
|
||||||
|
currentTime = 0
|
||||||
|
duration = 0
|
||||||
|
audioURL = ""
|
||||||
|
status = .idle
|
||||||
|
sleepTimerTask?.cancel(); sleepTimerTask = nil
|
||||||
|
sleepTimerCountdownTask?.cancel(); sleepTimerCountdownTask = nil
|
||||||
|
sleepTimerDeadline = nil
|
||||||
|
sleepTimer = nil
|
||||||
|
sleepTimerRemainingText = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private helpers
|
||||||
|
|
||||||
|
private func updateChapterTimerLabel(chaptersRemaining: Int) {
|
||||||
|
sleepTimerRemainingText = chaptersRemaining == 1 ? "1 ch left" : "\(chaptersRemaining) ch left"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func formatCountdown(_ seconds: Double) -> String {
|
||||||
|
let s = Int(max(0, seconds))
|
||||||
|
return "\(s / 60):\(String(format: "%02d", s % 60))"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Audio generation
|
||||||
|
|
||||||
|
private func generateAudio() async {
|
||||||
|
guard !slug.isEmpty, chapter > 0 else { return }
|
||||||
|
|
||||||
|
// Local file first (offline download)
|
||||||
|
if let localURL = AudioDownloadService.shared.localURL(slug: slug, chapter: chapter, voice: voice) {
|
||||||
|
audioURL = localURL.absoluteString
|
||||||
|
status = .ready
|
||||||
|
generationProgress = 100
|
||||||
|
await playURL(localURL.absoluteString)
|
||||||
|
await prefetchNext()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Fast path: audio already in MinIO
|
||||||
|
if let presigned = try? await APIClient.shared.presignAudio(
|
||||||
|
slug: slug, chapter: chapter, voice: voice) {
|
||||||
|
audioURL = presigned
|
||||||
|
status = .ready
|
||||||
|
generationProgress = 100
|
||||||
|
await playURL(presigned)
|
||||||
|
await prefetchNext()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slow path: trigger TTS generation
|
||||||
|
status = .generating
|
||||||
|
generationProgress = 10
|
||||||
|
let trigger = try await APIClient.shared.triggerAudio(
|
||||||
|
slug: slug, chapter: chapter, voice: voice, speed: speed)
|
||||||
|
|
||||||
|
let playableURL: String
|
||||||
|
if trigger.isAsync {
|
||||||
|
generationProgress = 30
|
||||||
|
playableURL = try await APIClient.shared.pollAudioStatus(
|
||||||
|
slug: slug, chapter: chapter, voice: voice)
|
||||||
|
} else {
|
||||||
|
guard let url = trigger.url, !url.isEmpty else { throw URLError(.badServerResponse) }
|
||||||
|
playableURL = url
|
||||||
|
}
|
||||||
|
|
||||||
|
audioURL = playableURL
|
||||||
|
status = .ready
|
||||||
|
generationProgress = 100
|
||||||
|
await playURL(playableURL)
|
||||||
|
await prefetchNext()
|
||||||
|
} catch is CancellationError {
|
||||||
|
// Cancelled — no-op
|
||||||
|
} catch {
|
||||||
|
status = .error(error.localizedDescription)
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Prefetch next chapter
|
||||||
|
|
||||||
|
private func prefetchNext() async {
|
||||||
|
guard let next = nextChapter, !Task.isCancelled else { return }
|
||||||
|
nextPrefetchStatus = .prefetching
|
||||||
|
nextPrefetchedChapter = next
|
||||||
|
do {
|
||||||
|
if let presigned = try? await APIClient.shared.presignAudio(
|
||||||
|
slug: slug, chapter: next, voice: voice) {
|
||||||
|
nextAudioURL = presigned
|
||||||
|
nextPrefetchStatus = .prefetched
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let trigger = try await APIClient.shared.triggerAudio(
|
||||||
|
slug: slug, chapter: next, voice: voice, speed: speed)
|
||||||
|
let url: String
|
||||||
|
if trigger.isAsync {
|
||||||
|
url = try await APIClient.shared.pollAudioStatus(slug: slug, chapter: next, voice: voice)
|
||||||
|
} else {
|
||||||
|
guard let u = trigger.url, !u.isEmpty else { throw URLError(.badServerResponse) }
|
||||||
|
url = u
|
||||||
|
}
|
||||||
|
nextAudioURL = url
|
||||||
|
nextPrefetchStatus = .prefetched
|
||||||
|
} catch {
|
||||||
|
nextPrefetchStatus = .failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AVPlayer management
|
||||||
|
|
||||||
|
private func playURL(_ urlString: String) async {
|
||||||
|
let resolved: URL?
|
||||||
|
if urlString.hasPrefix("http://") || urlString.hasPrefix("https://") {
|
||||||
|
resolved = URL(string: urlString)
|
||||||
|
} else {
|
||||||
|
resolved = URL(string: urlString,
|
||||||
|
relativeTo: await APIClient.shared.baseURL)?.absoluteURL
|
||||||
|
}
|
||||||
|
guard let url = resolved else { return }
|
||||||
|
|
||||||
|
teardownPlayer()
|
||||||
|
let item = AVPlayerItem(url: url)
|
||||||
|
playerItem = item
|
||||||
|
player = AVPlayer(playerItem: item)
|
||||||
|
|
||||||
|
durationObserver = item.publisher(for: \.duration)
|
||||||
|
.receive(on: RunLoop.main)
|
||||||
|
.sink { [weak self] dur in
|
||||||
|
guard let self else { return }
|
||||||
|
let secs = dur.seconds
|
||||||
|
if secs.isFinite && secs > 0 { self.duration = secs; self.updateNowPlaying() }
|
||||||
|
}
|
||||||
|
|
||||||
|
statusObserver = item.publisher(for: \.status)
|
||||||
|
.receive(on: RunLoop.main)
|
||||||
|
.sink { [weak self] s in
|
||||||
|
guard let self else { return }
|
||||||
|
switch s {
|
||||||
|
case .readyToPlay:
|
||||||
|
self.player?.rate = Float(self.speed)
|
||||||
|
self.isPlaying = true
|
||||||
|
self.updateNowPlaying()
|
||||||
|
case .failed:
|
||||||
|
self.status = .error(item.error?.localizedDescription ?? "Playback failed")
|
||||||
|
self.errorMessage = item.error?.localizedDescription ?? "Playback failed"
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timeObserver = player?.addPeriodicTimeObserver(
|
||||||
|
forInterval: CMTime(seconds: 0.5, preferredTimescale: 600),
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] time in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
let secs = time.seconds
|
||||||
|
if secs.isFinite && secs >= 0 { self.currentTime = secs }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finishObserver = NotificationCenter.default
|
||||||
|
.publisher(for: AVPlayerItem.didPlayToEndTimeNotification, object: item)
|
||||||
|
.sink { [weak self] _ in Task { @MainActor in self?.handlePlaybackFinished() } }
|
||||||
|
|
||||||
|
player?.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func teardownPlayer() {
|
||||||
|
if let obs = timeObserver { player?.removeTimeObserver(obs) }
|
||||||
|
timeObserver = nil; statusObserver = nil; durationObserver = nil; finishObserver = nil
|
||||||
|
player = nil; playerItem = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handlePlaybackFinished() {
|
||||||
|
isPlaying = false
|
||||||
|
guard let next = nextChapter else { return }
|
||||||
|
|
||||||
|
// Chapter-based sleep timer
|
||||||
|
if case .chapters(let count) = sleepTimer {
|
||||||
|
let played = chapter - sleepTimerStartChapter + 1
|
||||||
|
if played >= count { stop(); return }
|
||||||
|
updateChapterTimerLabel(chaptersRemaining: count - played)
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .audioDidFinishChapter, object: nil,
|
||||||
|
userInfo: ["next": next, "autoNext": autoNext])
|
||||||
|
|
||||||
|
guard autoNext else { return }
|
||||||
|
|
||||||
|
let nextTitle = chapters.first(where: { $0.number == next })?.title ?? ""
|
||||||
|
let nextNextChapter = chapters.first(where: { $0.number > next })?.number
|
||||||
|
|
||||||
|
if nextPrefetchStatus == .prefetched, !nextAudioURL.isEmpty {
|
||||||
|
let url = nextAudioURL
|
||||||
|
chapter = next
|
||||||
|
chapterTitle = nextTitle
|
||||||
|
nextChapter = nextNextChapter
|
||||||
|
prevChapter = chapter
|
||||||
|
nextPrefetchStatus = .none
|
||||||
|
nextAudioURL = ""
|
||||||
|
nextPrefetchedChapter = nil
|
||||||
|
audioURL = url
|
||||||
|
status = .ready
|
||||||
|
generationProgress = 100
|
||||||
|
if case .chapters = sleepTimer { sleepTimerStartChapter = next }
|
||||||
|
generationTask = Task { await playURL(url); await prefetchNext() }
|
||||||
|
} else {
|
||||||
|
load(slug: slug, chapter: next, chapterTitle: nextTitle,
|
||||||
|
bookTitle: bookTitle, coverURL: coverURL,
|
||||||
|
voice: voice, speed: speed, chapters: chapters,
|
||||||
|
nextChapter: nextNextChapter, prevChapter: chapter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cover art (URLSession — no Kingfisher)
|
||||||
|
|
||||||
|
private func prefetchCoverArtwork(from urlString: String) async {
|
||||||
|
guard !urlString.isEmpty, let url = URL(string: urlString) else { return }
|
||||||
|
guard let (data, _) = try? await URLSession.shared.data(from: url),
|
||||||
|
let image = UIImage(data: data) else { return }
|
||||||
|
let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
|
||||||
|
cachedCoverArtwork = artwork
|
||||||
|
updateNowPlaying()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Audio session
|
||||||
|
|
||||||
|
private func configureAudioSession() {
|
||||||
|
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio)
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lock-screen controls
|
||||||
|
|
||||||
|
private func setupRemoteCommandCenter() {
|
||||||
|
let center = MPRemoteCommandCenter.shared()
|
||||||
|
center.playCommand.addTarget { [weak self] _ in self?.play(); return .success }
|
||||||
|
center.pauseCommand.addTarget { [weak self] _ in self?.pause(); return .success }
|
||||||
|
center.togglePlayPauseCommand.addTarget { [weak self] _ in self?.togglePlayPause(); return .success }
|
||||||
|
center.skipForwardCommand.preferredIntervals = [15]
|
||||||
|
center.skipForwardCommand.addTarget { [weak self] _ in self?.skip(by: 15); return .success }
|
||||||
|
center.skipBackwardCommand.preferredIntervals = [15]
|
||||||
|
center.skipBackwardCommand.addTarget { [weak self] _ in self?.skip(by: -15); return .success }
|
||||||
|
center.changePlaybackPositionCommand.addTarget { [weak self] event in
|
||||||
|
if let e = event as? MPChangePlaybackPositionCommandEvent { self?.seek(to: e.positionTime) }
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateNowPlaying() {
|
||||||
|
var info: [String: Any] = [
|
||||||
|
MPMediaItemPropertyTitle: chapterTitle.isEmpty ? "Chapter \(chapter)" : chapterTitle,
|
||||||
|
MPMediaItemPropertyArtist: bookTitle,
|
||||||
|
MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime,
|
||||||
|
MPMediaItemPropertyPlaybackDuration: duration,
|
||||||
|
MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? speed : 0.0
|
||||||
|
]
|
||||||
|
if let artwork = cachedCoverArtwork { info[MPMediaItemPropertyArtwork] = artwork }
|
||||||
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Supporting types
|
||||||
|
|
||||||
|
enum AudioPlayerStatus: Equatable {
|
||||||
|
case idle
|
||||||
|
case generating
|
||||||
|
case ready
|
||||||
|
case error(String)
|
||||||
|
|
||||||
|
static func == (lhs: AudioPlayerStatus, rhs: AudioPlayerStatus) -> Bool {
|
||||||
|
switch (lhs, rhs) {
|
||||||
|
case (.idle, .idle), (.generating, .generating), (.ready, .ready): return true
|
||||||
|
case (.error(let a), .error(let b)): return a == b
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SleepTimerOption: Equatable {
|
||||||
|
case chapters(Int)
|
||||||
|
case minutes(Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
static let audioDidFinishChapter = Notification.Name("v2.audioDidFinishChapter")
|
||||||
|
static let skipToNextChapter = Notification.Name("v2.skipToNextChapter")
|
||||||
|
static let skipToPrevChapter = Notification.Name("v2.skipToPrevChapter")
|
||||||
|
}
|
||||||
144
ios/LibNovelV2/Services/AuthStore.swift
Normal file
144
ios/LibNovelV2/Services/AuthStore.swift
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - AuthStore
|
||||||
|
// Owns the authenticated user, the HMAC auth token, and user settings.
|
||||||
|
// Persists the token to Keychain so the user stays logged in across launches.
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AuthStore: ObservableObject {
|
||||||
|
@Published var user: AppUser?
|
||||||
|
@Published var settings: UserSettings = .default
|
||||||
|
@Published var isLoading: Bool = false
|
||||||
|
@Published var error: String?
|
||||||
|
|
||||||
|
var isAuthenticated: Bool { user != nil }
|
||||||
|
|
||||||
|
private let keychainKey = "libnovel_v2_auth_token"
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if let token = loadToken() {
|
||||||
|
Task { await validateToken(token) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Login / Register
|
||||||
|
|
||||||
|
func login(username: String, password: String) async {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
do {
|
||||||
|
let response = try await APIClient.shared.login(username: username, password: password)
|
||||||
|
await APIClient.shared.setAuthCookie(response.token)
|
||||||
|
saveToken(response.token)
|
||||||
|
user = response.user
|
||||||
|
await loadSettings()
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func register(username: String, password: String) async {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
do {
|
||||||
|
let response = try await APIClient.shared.register(username: username, password: password)
|
||||||
|
await APIClient.shared.setAuthCookie(response.token)
|
||||||
|
saveToken(response.token)
|
||||||
|
user = response.user
|
||||||
|
await loadSettings()
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func logout() async {
|
||||||
|
do { try await APIClient.shared.logout() } catch {}
|
||||||
|
clearToken()
|
||||||
|
user = nil
|
||||||
|
settings = .default
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings
|
||||||
|
|
||||||
|
func loadSettings() async {
|
||||||
|
do { settings = try await APIClient.shared.settings() } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveSettings(_ updated: UserSettings) async {
|
||||||
|
do {
|
||||||
|
try await APIClient.shared.updateSettings(updated)
|
||||||
|
settings = updated
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Token validation
|
||||||
|
|
||||||
|
func validateToken() async {
|
||||||
|
guard let token = loadToken() else { return }
|
||||||
|
await validateToken(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func validateToken(_ token: String) async {
|
||||||
|
await APIClient.shared.setAuthCookie(token)
|
||||||
|
do {
|
||||||
|
async let me: AppUser = APIClient.shared.fetch("/api/auth/me")
|
||||||
|
async let s: UserSettings = APIClient.shared.settings()
|
||||||
|
var (restoredUser, restoredSettings) = try await (me, s)
|
||||||
|
// Exchange raw MinIO key for a presigned URL if needed.
|
||||||
|
if let key = restoredUser.avatarURL, !key.hasPrefix("http") {
|
||||||
|
if let presignedURL = try? await APIClient.shared.fetchAvatarPresignedURL() {
|
||||||
|
restoredUser = AppUser(
|
||||||
|
id: restoredUser.id,
|
||||||
|
username: restoredUser.username,
|
||||||
|
role: restoredUser.role,
|
||||||
|
created: restoredUser.created,
|
||||||
|
avatarURL: presignedURL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user = restoredUser
|
||||||
|
settings = restoredSettings
|
||||||
|
} catch let e as APIError {
|
||||||
|
if case .httpError(let code, _) = e, code == 401 { clearToken() }
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Keychain helpers
|
||||||
|
|
||||||
|
private func saveToken(_ token: String) {
|
||||||
|
let data = Data(token.utf8)
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrAccount as String: keychainKey,
|
||||||
|
kSecValueData as String: data
|
||||||
|
]
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
SecItemAdd(query as CFDictionary, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadToken() -> String? {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrAccount as String: keychainKey,
|
||||||
|
kSecReturnData as String: true,
|
||||||
|
kSecMatchLimit as String: kSecMatchLimitOne
|
||||||
|
]
|
||||||
|
var item: CFTypeRef?
|
||||||
|
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
|
||||||
|
let data = item as? Data else { return nil }
|
||||||
|
return String(data: data, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearToken() {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrAccount as String: keychainKey
|
||||||
|
]
|
||||||
|
SecItemDelete(query as CFDictionary)
|
||||||
|
}
|
||||||
|
}
|
||||||
59
ios/LibNovelV2/Services/BookVoicePreferences.swift
Normal file
59
ios/LibNovelV2/Services/BookVoicePreferences.swift
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - BookVoicePreferences
|
||||||
|
// Manages per-book voice overrides with global fallback.
|
||||||
|
// Persisted in UserDefaults as a slug → voice dictionary.
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class BookVoicePreferences: ObservableObject {
|
||||||
|
static let shared = BookVoicePreferences()
|
||||||
|
|
||||||
|
@Published private(set) var bookVoices: [String: String] = [:]
|
||||||
|
|
||||||
|
private let key = "v2.bookVoicePreferences"
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
if let data = UserDefaults.standard.data(forKey: key),
|
||||||
|
let decoded = try? JSONDecoder().decode([String: String].self, from: data) {
|
||||||
|
bookVoices = decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
func voice(for slug: String) -> String? {
|
||||||
|
bookVoices[slug]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Voice priority: book override → globalVoice → "af_bella"
|
||||||
|
func voiceWithFallback(for slug: String, globalVoice: String) -> String {
|
||||||
|
bookVoices[slug] ?? globalVoice
|
||||||
|
}
|
||||||
|
|
||||||
|
func setVoice(_ voice: String, for slug: String) {
|
||||||
|
bookVoices[slug] = voice
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeVoice(for slug: String) {
|
||||||
|
bookVoices.removeValue(forKey: slug)
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasOverride(for slug: String) -> Bool {
|
||||||
|
bookVoices[slug] != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearAll() {
|
||||||
|
bookVoices.removeAll()
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Persistence
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
if let encoded = try? JSONEncoder().encode(bookVoices) {
|
||||||
|
UserDefaults.standard.set(encoded, forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
ios/LibNovelV2/Services/NetworkMonitor.swift
Normal file
43
ios/LibNovelV2/Services/NetworkMonitor.swift
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
|
||||||
|
// MARK: - NetworkMonitor
|
||||||
|
// Monitors network connectivity. Inject as an environment object for offline UI.
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class NetworkMonitor: ObservableObject {
|
||||||
|
static let shared = NetworkMonitor()
|
||||||
|
|
||||||
|
@Published var isConnected: Bool = true
|
||||||
|
@Published var connectionType: NWInterface.InterfaceType?
|
||||||
|
|
||||||
|
private let monitor = NWPathMonitor()
|
||||||
|
private let queue = DispatchQueue(label: "cc.kalekber.libnovel.v2.network-monitor")
|
||||||
|
|
||||||
|
init() {
|
||||||
|
monitor.pathUpdateHandler = { [weak self] path in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.isConnected = path.status == .satisfied
|
||||||
|
self?.connectionType = path.availableInterfaces.first?.type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
monitor.start(queue: queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
monitor.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NWInterface.InterfaceType {
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .wifi: return "Wi-Fi"
|
||||||
|
case .cellular: return "Cellular"
|
||||||
|
case .wiredEthernet: return "Ethernet"
|
||||||
|
case .loopback: return "Loopback"
|
||||||
|
case .other: return "Other"
|
||||||
|
@unknown default: return "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
80
ios/LibNovelV2/ViewModels/BookDetailViewModel.swift
Normal file
80
ios/LibNovelV2/ViewModels/BookDetailViewModel.swift
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - BookDetailViewModel
|
||||||
|
// Loads book metadata, chapter index, save state, and reading progress.
|
||||||
|
// Uses @Observable (iOS 17+).
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class BookDetailViewModel {
|
||||||
|
let slug: String
|
||||||
|
|
||||||
|
var book: Book?
|
||||||
|
var chapters: [ChapterIndex] = []
|
||||||
|
var inLib: Bool = false
|
||||||
|
var saved: Bool = false
|
||||||
|
var lastChapter: Int?
|
||||||
|
|
||||||
|
var isLoading = false
|
||||||
|
var isSaving = false
|
||||||
|
var error: String?
|
||||||
|
|
||||||
|
init(slug: String) {
|
||||||
|
self.slug = slug
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Load
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
guard !isLoading else { return }
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
do {
|
||||||
|
let response = try await APIClient.shared.bookDetail(slug: slug)
|
||||||
|
book = response.book
|
||||||
|
chapters = response.chapters
|
||||||
|
inLib = response.inLib
|
||||||
|
saved = response.saved
|
||||||
|
lastChapter = response.lastChapter
|
||||||
|
} catch {
|
||||||
|
if !(error is CancellationError) {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Toggle saved (bookmark)
|
||||||
|
|
||||||
|
func toggleSaved() async {
|
||||||
|
guard !isSaving else { return }
|
||||||
|
isSaving = true
|
||||||
|
let targetSaved = !saved
|
||||||
|
saved = targetSaved // optimistic update
|
||||||
|
do {
|
||||||
|
if targetSaved {
|
||||||
|
try await APIClient.shared.saveBook(slug: slug)
|
||||||
|
if !inLib { inLib = true }
|
||||||
|
} else {
|
||||||
|
try await APIClient.shared.unsaveBook(slug: slug)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
saved = !targetSaved // revert on failure
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
isSaving = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Chapter helpers
|
||||||
|
|
||||||
|
/// Title stripped of trailing " - Month DD YYYY" date suffixes.
|
||||||
|
func displayTitle(for chapter: ChapterIndex) -> String {
|
||||||
|
let stripped = chapter.title.strippingTrailingDate()
|
||||||
|
if stripped.isEmpty || stripped == "Chapter \(chapter.number)" {
|
||||||
|
return "Chapter \(chapter.number)"
|
||||||
|
}
|
||||||
|
return stripped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
146
ios/LibNovelV2/ViewModels/BrowseViewModel.swift
Normal file
146
ios/LibNovelV2/ViewModels/BrowseViewModel.swift
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - BrowseViewModel
|
||||||
|
// Powers both the Discover shelves (BrowseView) and the full paginated grid (BrowseCategoryView).
|
||||||
|
// Uses @Observable (iOS 17+).
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class BrowseViewModel {
|
||||||
|
|
||||||
|
// MARK: - Discover shelves (BrowseView)
|
||||||
|
|
||||||
|
var trending: [BrowseNovel] = []
|
||||||
|
var newReleases: [BrowseNovel] = []
|
||||||
|
var recentlyUpdated: [BrowseNovel] = []
|
||||||
|
var ranking: [BrowseNovel] = []
|
||||||
|
|
||||||
|
// MARK: - Paginated grid (BrowseCategoryView)
|
||||||
|
|
||||||
|
var novels: [BrowseNovel] = []
|
||||||
|
var currentPage = 1
|
||||||
|
var hasNext = false
|
||||||
|
|
||||||
|
// Filter params (BrowseCategoryView sets these before calling loadFirstPage)
|
||||||
|
var sort: String = "popular"
|
||||||
|
var genre: String = "all"
|
||||||
|
var status: String = "all"
|
||||||
|
|
||||||
|
// MARK: - UI state
|
||||||
|
|
||||||
|
var isLoading = false
|
||||||
|
var isLoadingMore = false
|
||||||
|
var error: String?
|
||||||
|
|
||||||
|
// MARK: - Discover load (fetches multiple shelves in parallel)
|
||||||
|
|
||||||
|
func loadShelves() async {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
async let trendingTask = APIClient.shared.browse(page: 1, genre: "all", sort: "popular", status: "all")
|
||||||
|
async let newTask = APIClient.shared.browse(page: 1, genre: "all", sort: "new", status: "all")
|
||||||
|
async let updatedTask = APIClient.shared.browse(page: 1, genre: "all", sort: "update", status: "all")
|
||||||
|
async let rankingTask = APIClient.shared.ranking()
|
||||||
|
|
||||||
|
let (trendingResp, newResp, updatedResp, rankItems) = try await (
|
||||||
|
trendingTask, newTask, updatedTask, rankingTask
|
||||||
|
)
|
||||||
|
|
||||||
|
trending = Array(trendingResp.novels.prefix(12))
|
||||||
|
newReleases = Array(newResp.novels.prefix(12))
|
||||||
|
recentlyUpdated = Array(updatedResp.novels.prefix(12))
|
||||||
|
ranking = rankItems.prefix(12).map { item in
|
||||||
|
BrowseNovelFromRanking(item)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if !(error is CancellationError) {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Paginated category load
|
||||||
|
|
||||||
|
func loadFirstPage() async {
|
||||||
|
guard !isLoading else { return }
|
||||||
|
novels = []
|
||||||
|
currentPage = 1
|
||||||
|
hasNext = false
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
let resp = try await APIClient.shared.browse(
|
||||||
|
page: 1, genre: genre, sort: sort, status: status)
|
||||||
|
novels = resp.novels
|
||||||
|
currentPage = resp.page
|
||||||
|
hasNext = resp.hasNext
|
||||||
|
} catch {
|
||||||
|
if !(error is CancellationError) {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadNextPage() async {
|
||||||
|
guard hasNext, !isLoadingMore, !isLoading else { return }
|
||||||
|
isLoadingMore = true
|
||||||
|
|
||||||
|
let next = currentPage + 1
|
||||||
|
do {
|
||||||
|
let resp = try await APIClient.shared.browse(
|
||||||
|
page: next, genre: genre, sort: sort, status: status)
|
||||||
|
novels += resp.novels
|
||||||
|
currentPage = resp.page
|
||||||
|
hasNext = resp.hasNext
|
||||||
|
} catch {
|
||||||
|
// Silently ignore — user can scroll again
|
||||||
|
}
|
||||||
|
isLoadingMore = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ranking load (for rank sort mode)
|
||||||
|
|
||||||
|
func loadRanking() async {
|
||||||
|
guard !isLoading else { return }
|
||||||
|
novels = []
|
||||||
|
hasNext = false
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
let items = try await APIClient.shared.ranking()
|
||||||
|
novels = items.map { BrowseNovelFromRanking($0) }
|
||||||
|
} catch {
|
||||||
|
if !(error is CancellationError) {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - RankingItem → BrowseNovel adapter
|
||||||
|
|
||||||
|
private func BrowseNovelFromRanking(_ item: RankingItem) -> BrowseNovel {
|
||||||
|
// Synthesise a minimal JSON blob so we can decode via the standard init
|
||||||
|
let rankStr = "#\(item.rank)"
|
||||||
|
let dict: [String: Any] = [
|
||||||
|
"slug": item.slug,
|
||||||
|
"title": item.title,
|
||||||
|
"cover": item.cover,
|
||||||
|
"rank": rankStr,
|
||||||
|
"rating": "",
|
||||||
|
"chapters": "",
|
||||||
|
"url": item.sourceURL,
|
||||||
|
"author": item.author,
|
||||||
|
"status": item.status,
|
||||||
|
"genres": item.genres
|
||||||
|
]
|
||||||
|
let data = try! JSONSerialization.data(withJSONObject: dict)
|
||||||
|
return try! JSONDecoder.apiDecoder.decode(BrowseNovel.self, from: data)
|
||||||
|
}
|
||||||
69
ios/LibNovelV2/ViewModels/ChapterReaderViewModel.swift
Normal file
69
ios/LibNovelV2/ViewModels/ChapterReaderViewModel.swift
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - ChapterReaderViewModel
|
||||||
|
|
||||||
|
@Observable @MainActor
|
||||||
|
final class ChapterReaderViewModel {
|
||||||
|
let slug: String
|
||||||
|
private(set) var chapter: Int
|
||||||
|
|
||||||
|
var content: ChapterResponse?
|
||||||
|
var isLoading = false
|
||||||
|
var error: String?
|
||||||
|
|
||||||
|
init(slug: String, chapter: Int) {
|
||||||
|
self.slug = slug
|
||||||
|
self.chapter = chapter
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switch to a different chapter in-place; `chapter` change causes `.task(id: chapter)` to re-fire `load()`.
|
||||||
|
func switchChapter(to newChapter: Int) {
|
||||||
|
guard newChapter != chapter else { return }
|
||||||
|
chapter = newChapter
|
||||||
|
content = nil
|
||||||
|
error = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
do {
|
||||||
|
content = try await APIClient.shared.chapterContent(slug: slug, chapter: chapter)
|
||||||
|
try? await APIClient.shared.setProgress(slug: slug, chapter: chapter)
|
||||||
|
} catch {
|
||||||
|
if !(error is CancellationError) {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleAudio(audioPlayer: AudioPlayerService, settings: UserSettings) {
|
||||||
|
guard let content else { return }
|
||||||
|
|
||||||
|
let isCurrent = audioPlayer.isActive
|
||||||
|
&& audioPlayer.slug == slug
|
||||||
|
&& audioPlayer.chapter == chapter
|
||||||
|
|
||||||
|
if isCurrent {
|
||||||
|
audioPlayer.togglePlayPause()
|
||||||
|
} else {
|
||||||
|
let voice = BookVoicePreferences.shared.voiceWithFallback(
|
||||||
|
for: slug,
|
||||||
|
globalVoice: settings.voice
|
||||||
|
)
|
||||||
|
audioPlayer.load(
|
||||||
|
slug: slug,
|
||||||
|
chapter: chapter,
|
||||||
|
chapterTitle: content.chapter.title,
|
||||||
|
bookTitle: content.book.title,
|
||||||
|
coverURL: content.book.cover,
|
||||||
|
voice: voice,
|
||||||
|
speed: settings.speed,
|
||||||
|
chapters: content.chapters,
|
||||||
|
nextChapter: content.next,
|
||||||
|
prevChapter: content.prev
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
35
ios/LibNovelV2/ViewModels/HomeViewModel.swift
Normal file
35
ios/LibNovelV2/ViewModels/HomeViewModel.swift
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - HomeViewModel
|
||||||
|
// Fetches home-screen data: continue reading, recently updated, stats, subscription feed.
|
||||||
|
// Uses @Observable (iOS 17+).
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class HomeViewModel {
|
||||||
|
var continueReading: [ContinueReadingItem] = []
|
||||||
|
var recentlyUpdated: [Book] = []
|
||||||
|
var stats: HomeStats?
|
||||||
|
var subscriptionFeed: [SubscriptionFeedItem] = []
|
||||||
|
var isLoading = false
|
||||||
|
var error: String?
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
do {
|
||||||
|
let data = try await APIClient.shared.homeData()
|
||||||
|
continueReading = data.continueReading.map {
|
||||||
|
ContinueReadingItem(book: $0.book, chapter: $0.chapter)
|
||||||
|
}
|
||||||
|
recentlyUpdated = data.recentlyUpdated
|
||||||
|
stats = data.stats
|
||||||
|
subscriptionFeed = data.subscriptionFeed
|
||||||
|
} catch {
|
||||||
|
if !(error is CancellationError) {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
164
ios/LibNovelV2/ViewModels/LibraryViewModel.swift
Normal file
164
ios/LibNovelV2/ViewModels/LibraryViewModel.swift
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - LibraryViewModel
|
||||||
|
// Loads library items and exposes filtered/sorted views for LibraryView.
|
||||||
|
// Uses @Observable (iOS 17+).
|
||||||
|
|
||||||
|
enum LibrarySortOrder: String, CaseIterable {
|
||||||
|
case recent = "Recent"
|
||||||
|
case title = "Title"
|
||||||
|
case author = "Author"
|
||||||
|
case progress = "Progress"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LibraryReadingFilter: String, CaseIterable {
|
||||||
|
case all = "All"
|
||||||
|
case inProgress = "In Progress"
|
||||||
|
case completed = "Completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class LibraryViewModel {
|
||||||
|
// Raw data
|
||||||
|
var items: [LibraryItem] = []
|
||||||
|
var progressMap: [String: Int] = [:] // slug -> last chapter read
|
||||||
|
|
||||||
|
// Filter & sort state
|
||||||
|
var sortOrder: LibrarySortOrder = .recent
|
||||||
|
var readingFilter: LibraryReadingFilter = .all
|
||||||
|
var selectedGenre: String = "All"
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
var isLoading = false
|
||||||
|
var error: String?
|
||||||
|
|
||||||
|
// MARK: - Derived
|
||||||
|
|
||||||
|
var allGenres: [String] {
|
||||||
|
var seen = Set<String>()
|
||||||
|
var result: [String] = ["All"]
|
||||||
|
for item in items {
|
||||||
|
for genre in item.book.genres where !seen.contains(genre) {
|
||||||
|
seen.insert(genre)
|
||||||
|
result.append(genre)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
var filteredItems: [LibraryItem] {
|
||||||
|
var list = items
|
||||||
|
|
||||||
|
// Genre filter
|
||||||
|
if selectedGenre != "All" {
|
||||||
|
list = list.filter { $0.book.genres.contains(selectedGenre) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reading filter
|
||||||
|
switch readingFilter {
|
||||||
|
case .all:
|
||||||
|
break
|
||||||
|
case .inProgress:
|
||||||
|
list = list.filter { item in
|
||||||
|
let ch = progressMap[item.book.slug] ?? item.lastChapter ?? 0
|
||||||
|
return ch > 0 && ch < item.book.totalChapters
|
||||||
|
}
|
||||||
|
case .completed:
|
||||||
|
list = list.filter { item in
|
||||||
|
let ch = progressMap[item.book.slug] ?? item.lastChapter ?? 0
|
||||||
|
return item.book.totalChapters > 0 && ch >= item.book.totalChapters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
switch sortOrder {
|
||||||
|
case .recent:
|
||||||
|
// server already returns newest-saved first; preserve order
|
||||||
|
break
|
||||||
|
case .title:
|
||||||
|
list.sort { $0.book.title.localizedCaseInsensitiveCompare($1.book.title) == .orderedAscending }
|
||||||
|
case .author:
|
||||||
|
list.sort { $0.book.author.localizedCaseInsensitiveCompare($1.book.author) == .orderedAscending }
|
||||||
|
case .progress:
|
||||||
|
list.sort { a, b in
|
||||||
|
let pa = progressFraction(for: a)
|
||||||
|
let pb = progressFraction(for: b)
|
||||||
|
return pa > pb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Progress helpers
|
||||||
|
|
||||||
|
func lastChapter(for item: LibraryItem) -> Int {
|
||||||
|
progressMap[item.book.slug] ?? item.lastChapter ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func progressFraction(for item: LibraryItem) -> Double {
|
||||||
|
let total = item.book.totalChapters
|
||||||
|
guard total > 0 else { return 0 }
|
||||||
|
return Double(lastChapter(for: item)) / Double(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
func progressPercent(for item: LibraryItem) -> String {
|
||||||
|
let fraction = progressFraction(for: item)
|
||||||
|
let pct = fraction * 100
|
||||||
|
if pct < 10 {
|
||||||
|
return String(format: "%.1f%%", pct)
|
||||||
|
} else {
|
||||||
|
return String(format: "%.0f%%", pct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCompleted(for item: LibraryItem) -> Bool {
|
||||||
|
let total = item.book.totalChapters
|
||||||
|
guard total > 0 else { return false }
|
||||||
|
return lastChapter(for: item) >= total
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Load
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
do {
|
||||||
|
async let libraryTask = APIClient.shared.library()
|
||||||
|
async let progressTask = APIClient.shared.progress()
|
||||||
|
|
||||||
|
let (library, progressEntries) = try await (libraryTask, progressTask)
|
||||||
|
items = library
|
||||||
|
progressMap = Dictionary(uniqueKeysWithValues: progressEntries.map { ($0.slug, $0.chapter) })
|
||||||
|
} catch {
|
||||||
|
if !(error is CancellationError) {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Mutations
|
||||||
|
|
||||||
|
func removeFromLibrary(slug: String) async {
|
||||||
|
// Optimistic remove
|
||||||
|
items.removeAll { $0.book.slug == slug }
|
||||||
|
do {
|
||||||
|
try await APIClient.shared.unsaveBook(slug: slug)
|
||||||
|
} catch {
|
||||||
|
// Silently fail — user can pull-to-refresh to restore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func markFinished(item: LibraryItem) async {
|
||||||
|
let total = item.book.totalChapters
|
||||||
|
guard total > 0 else { return }
|
||||||
|
progressMap[item.book.slug] = total
|
||||||
|
do {
|
||||||
|
try await APIClient.shared.setProgress(slug: item.book.slug, chapter: total)
|
||||||
|
} catch {
|
||||||
|
// Silently fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
115
ios/LibNovelV2/ViewModels/SearchViewModel.swift
Normal file
115
ios/LibNovelV2/ViewModels/SearchViewModel.swift
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - SearchViewModel
|
||||||
|
// Debounced live search (300 ms) + recent searches persisted in UserDefaults.
|
||||||
|
// Uses @Observable (iOS 17+).
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class SearchViewModel {
|
||||||
|
var query: String = ""
|
||||||
|
var results: [BrowseNovel] = []
|
||||||
|
var localCount: Int = 0
|
||||||
|
var remoteCount: Int = 0
|
||||||
|
var isLoading = false
|
||||||
|
var error: String?
|
||||||
|
|
||||||
|
// Persisted recent searches (max 10, prefixed with "v2.")
|
||||||
|
var recentSearches: [String] = []
|
||||||
|
|
||||||
|
private let recentKey = "v2.searchRecentTerms"
|
||||||
|
private var searchTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init() {
|
||||||
|
recentSearches = UserDefaults.standard.stringArray(forKey: recentKey) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Query change (debounced)
|
||||||
|
|
||||||
|
func onQueryChange(_ newValue: String) {
|
||||||
|
searchTask?.cancel()
|
||||||
|
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else {
|
||||||
|
results = []
|
||||||
|
localCount = 0
|
||||||
|
remoteCount = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
searchTask = Task {
|
||||||
|
try? await Task.sleep(nanoseconds: 300_000_000) // 300 ms debounce
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await runSearch(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Submit (immediate, saves to recent)
|
||||||
|
|
||||||
|
func submitSearch() {
|
||||||
|
let term = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !term.isEmpty else { return }
|
||||||
|
saveRecent(term)
|
||||||
|
searchTask?.cancel()
|
||||||
|
searchTask = Task { await runSearch(term) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Recent search tap
|
||||||
|
|
||||||
|
func selectRecent(_ term: String) {
|
||||||
|
query = term
|
||||||
|
searchTask?.cancel()
|
||||||
|
searchTask = Task { await runSearch(term) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Clear
|
||||||
|
|
||||||
|
func clear() {
|
||||||
|
query = ""
|
||||||
|
results = []
|
||||||
|
localCount = 0
|
||||||
|
remoteCount = 0
|
||||||
|
error = nil
|
||||||
|
searchTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearRecent() {
|
||||||
|
recentSearches = []
|
||||||
|
UserDefaults.standard.removeObject(forKey: recentKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Core search
|
||||||
|
|
||||||
|
private func runSearch(_ term: String) async {
|
||||||
|
guard !term.isEmpty else {
|
||||||
|
results = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
do {
|
||||||
|
let response = try await APIClient.shared.search(query: term)
|
||||||
|
// Only update if the query hasn't changed since we started
|
||||||
|
let currentTrimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if currentTrimmed == term || currentTrimmed.isEmpty {
|
||||||
|
results = response.results
|
||||||
|
localCount = response.localCount
|
||||||
|
remoteCount = response.remoteCount
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if !(error is CancellationError) {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
results = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Persist recent
|
||||||
|
|
||||||
|
private func saveRecent(_ term: String) {
|
||||||
|
var list = recentSearches.filter { $0 != term }
|
||||||
|
list.insert(term, at: 0)
|
||||||
|
if list.count > 10 { list = Array(list.prefix(10)) }
|
||||||
|
recentSearches = list
|
||||||
|
UserDefaults.standard.set(list, forKey: recentKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
386
ios/LibNovelV2/Views/Auth/AuthView.swift
Normal file
386
ios/LibNovelV2/Views/Auth/AuthView.swift
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - AuthView
|
||||||
|
// Full-screen login / register view.
|
||||||
|
// Mirrors the web UI's login page: zinc-900 background, tab switcher with
|
||||||
|
// amber underline indicator, zinc-800 text fields with amber focus ring,
|
||||||
|
// amber CTA button, inline error banner, loading state.
|
||||||
|
|
||||||
|
struct AuthView: View {
|
||||||
|
@EnvironmentObject var authStore: AuthStore
|
||||||
|
@EnvironmentObject var networkMonitor: NetworkMonitor
|
||||||
|
|
||||||
|
@State private var mode: AuthMode = .login
|
||||||
|
|
||||||
|
// Login fields
|
||||||
|
@State private var loginUsername: String = ""
|
||||||
|
@State private var loginPassword: String = ""
|
||||||
|
|
||||||
|
// Register fields
|
||||||
|
@State private var regUsername: String = ""
|
||||||
|
@State private var regPassword: String = ""
|
||||||
|
@State private var regConfirm: String = ""
|
||||||
|
|
||||||
|
// Focus management
|
||||||
|
@FocusState private var focus: AuthField?
|
||||||
|
|
||||||
|
// Local validation error (client-side, e.g. password mismatch)
|
||||||
|
@State private var localError: String?
|
||||||
|
|
||||||
|
private var displayError: String? { localError ?? authStore.error }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.appBackground.ignoresSafeArea()
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Spacer(minLength: 60)
|
||||||
|
|
||||||
|
// ── Wordmark ──────────────────────────────────────────
|
||||||
|
wordmark
|
||||||
|
|
||||||
|
Spacer(minLength: 48)
|
||||||
|
|
||||||
|
// ── Card ──────────────────────────────────────────────
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
tabSwitcher
|
||||||
|
formContent
|
||||||
|
}
|
||||||
|
.background(Color.cardBackground)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
|
||||||
|
Spacer(minLength: 40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scrollDismissesKeyboard(.interactively)
|
||||||
|
}
|
||||||
|
.onChange(of: mode) { _, _ in
|
||||||
|
localError = nil
|
||||||
|
authStore.error = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Wordmark
|
||||||
|
|
||||||
|
private var wordmark: some View {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Image(systemName: "books.vertical.fill")
|
||||||
|
.font(.system(size: 44))
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
.symbolEffect(.bounce, value: mode)
|
||||||
|
|
||||||
|
Text("libnovel")
|
||||||
|
.font(.title.bold())
|
||||||
|
.fontDesign(.serif)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tab switcher
|
||||||
|
|
||||||
|
private var tabSwitcher: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
tabButton(label: "Sign in", tab: .login)
|
||||||
|
tabButton(label: "Create account", tab: .register)
|
||||||
|
}
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.cardBorder)
|
||||||
|
.frame(height: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func tabButton(label: String, tab: AuthMode) -> some View {
|
||||||
|
let isActive = mode == tab
|
||||||
|
Button {
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
||||||
|
mode = tab
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Text(label)
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundStyle(isActive ? Color.amber : Color.secondary)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
|
// Active underline indicator
|
||||||
|
Rectangle()
|
||||||
|
.fill(isActive ? Color.amber : Color.clear)
|
||||||
|
.frame(height: 2)
|
||||||
|
.offset(y: 1) // sits on top of the border
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityAddTraits(isActive ? [.isSelected] : [])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Form content
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var formContent: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
// Error banner
|
||||||
|
if let err = displayError {
|
||||||
|
errorBanner(err)
|
||||||
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case .login: loginForm
|
||||||
|
case .register: registerForm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: displayError)
|
||||||
|
.animation(.spring(response: 0.35, dampingFraction: 0.75), value: mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Error banner
|
||||||
|
|
||||||
|
private func errorBanner(_ message: String) -> some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(Color.errorText)
|
||||||
|
.font(.footnote)
|
||||||
|
Text(message)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(Color.errorText)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.errorBackground)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||||
|
.stroke(Color.errorBorder, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Login form
|
||||||
|
|
||||||
|
private var loginForm: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
AuthInputField(
|
||||||
|
label: "Username",
|
||||||
|
placeholder: "your_username",
|
||||||
|
text: $loginUsername,
|
||||||
|
contentType: .username,
|
||||||
|
keyboardType: .default,
|
||||||
|
focusState: $focus,
|
||||||
|
field: .loginUsername,
|
||||||
|
nextField: .loginPassword
|
||||||
|
)
|
||||||
|
|
||||||
|
AuthInputField(
|
||||||
|
label: "Password",
|
||||||
|
placeholder: "••••••••",
|
||||||
|
text: $loginPassword,
|
||||||
|
contentType: .password,
|
||||||
|
isSecure: true,
|
||||||
|
focusState: $focus,
|
||||||
|
field: .loginPassword,
|
||||||
|
onSubmit: submitLogin
|
||||||
|
)
|
||||||
|
|
||||||
|
ctaButton(label: "Sign in", action: submitLogin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Register form
|
||||||
|
|
||||||
|
private var registerForm: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
AuthInputField(
|
||||||
|
label: "Username",
|
||||||
|
placeholder: "your_username",
|
||||||
|
text: $regUsername,
|
||||||
|
contentType: .username,
|
||||||
|
focusState: $focus,
|
||||||
|
field: .regUsername,
|
||||||
|
nextField: .regPassword
|
||||||
|
)
|
||||||
|
Text("3–32 characters: letters, numbers, _ or -")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.leading, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
AuthInputField(
|
||||||
|
label: "Password",
|
||||||
|
placeholder: "••••••••",
|
||||||
|
text: $regPassword,
|
||||||
|
contentType: .newPassword,
|
||||||
|
isSecure: true,
|
||||||
|
focusState: $focus,
|
||||||
|
field: .regPassword,
|
||||||
|
nextField: .regConfirm
|
||||||
|
)
|
||||||
|
Text("At least 8 characters")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.leading, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthInputField(
|
||||||
|
label: "Confirm password",
|
||||||
|
placeholder: "••••••••",
|
||||||
|
text: $regConfirm,
|
||||||
|
contentType: .newPassword,
|
||||||
|
isSecure: true,
|
||||||
|
focusState: $focus,
|
||||||
|
field: .regConfirm,
|
||||||
|
onSubmit: submitRegister
|
||||||
|
)
|
||||||
|
|
||||||
|
ctaButton(label: "Create account", action: submitRegister)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CTA button
|
||||||
|
|
||||||
|
private func ctaButton(label: String, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
ZStack {
|
||||||
|
if authStore.isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.tint(Color(uiColor: .systemBackground))
|
||||||
|
} else {
|
||||||
|
Text(label)
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.foregroundStyle(Color.ctaText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 44)
|
||||||
|
}
|
||||||
|
.background(Color.amber)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
|
.disabled(authStore.isLoading || !networkMonitor.isConnected)
|
||||||
|
.opacity(authStore.isLoading ? 0.8 : 1)
|
||||||
|
.animation(.easeInOut(duration: 0.15), value: authStore.isLoading)
|
||||||
|
.accessibilityLabel(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func submitLogin() {
|
||||||
|
guard !authStore.isLoading else { return }
|
||||||
|
localError = nil
|
||||||
|
focus = nil
|
||||||
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||||
|
Task { await authStore.login(username: loginUsername, password: loginPassword) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func submitRegister() {
|
||||||
|
guard !authStore.isLoading else { return }
|
||||||
|
localError = nil
|
||||||
|
// Client-side validation
|
||||||
|
if regUsername.count < 3 || regUsername.count > 32 {
|
||||||
|
localError = "Username must be 3–32 characters."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if regPassword.count < 8 {
|
||||||
|
localError = "Password must be at least 8 characters."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if regPassword != regConfirm {
|
||||||
|
localError = "Passwords do not match."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
focus = nil
|
||||||
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||||
|
Task { await authStore.register(username: regUsername, password: regPassword) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Auth mode enum
|
||||||
|
|
||||||
|
private enum AuthMode: Equatable { case login, register }
|
||||||
|
|
||||||
|
// MARK: - Focus field enum
|
||||||
|
|
||||||
|
private enum AuthField: Hashable {
|
||||||
|
case loginUsername, loginPassword
|
||||||
|
case regUsername, regPassword, regConfirm
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AuthInputField component
|
||||||
|
|
||||||
|
private struct AuthInputField: View {
|
||||||
|
let label: String
|
||||||
|
let placeholder: String
|
||||||
|
@Binding var text: String
|
||||||
|
var contentType: UITextContentType? = nil
|
||||||
|
var keyboardType: UIKeyboardType = .default
|
||||||
|
var isSecure: Bool = false
|
||||||
|
@FocusState.Binding var focusState: AuthField?
|
||||||
|
let field: AuthField
|
||||||
|
var nextField: AuthField? = nil
|
||||||
|
var onSubmit: (() -> Void)? = nil
|
||||||
|
|
||||||
|
private var isFocused: Bool { focusState == field }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Group {
|
||||||
|
if isSecure {
|
||||||
|
SecureField(placeholder, text: $text)
|
||||||
|
} else {
|
||||||
|
TextField(placeholder, text: $text)
|
||||||
|
.keyboardType(keyboardType)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.textContentType(contentType)
|
||||||
|
.focused($focusState, equals: field)
|
||||||
|
.submitLabel(nextField != nil ? .next : .done)
|
||||||
|
.onSubmit {
|
||||||
|
if let next = nextField {
|
||||||
|
focusState = next
|
||||||
|
} else {
|
||||||
|
onSubmit?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.frame(height: 44)
|
||||||
|
.background(Color.fieldBackground)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||||
|
.stroke(
|
||||||
|
isFocused ? Color.amber : Color.cardBorder,
|
||||||
|
lineWidth: isFocused ? 1.5 : 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||||
|
.animation(.spring(response: 0.2, dampingFraction: 0.7), value: isFocused)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Local color helpers
|
||||||
|
|
||||||
|
private extension Color {
|
||||||
|
static let appBackground = Color(uiColor: UIColor { t in t.userInterfaceStyle == .dark ? UIColor(red: 0.09, green: 0.09, blue: 0.11, alpha: 1) : UIColor.systemGroupedBackground })
|
||||||
|
static let cardBackground = Color(uiColor: UIColor { t in t.userInterfaceStyle == .dark ? UIColor(red: 0.14, green: 0.14, blue: 0.16, alpha: 1) : UIColor.secondarySystemGroupedBackground })
|
||||||
|
static let cardBorder = Color(uiColor: UIColor { t in t.userInterfaceStyle == .dark ? UIColor(white: 0.25, alpha: 1) : UIColor.separator })
|
||||||
|
static let fieldBackground = Color(uiColor: UIColor { t in t.userInterfaceStyle == .dark ? UIColor(red: 0.11, green: 0.11, blue: 0.13, alpha: 1) : UIColor.secondarySystemBackground })
|
||||||
|
static let ctaText = Color(uiColor: UIColor(red: 0.11, green: 0.09, blue: 0.04, alpha: 1)) // zinc-900
|
||||||
|
static let errorBackground = Color(red: 0.40, green: 0.05, blue: 0.05).opacity(0.40)
|
||||||
|
static let errorBorder = Color(red: 0.70, green: 0.20, blue: 0.20).opacity(0.60)
|
||||||
|
static let errorText = Color(red: 0.98, green: 0.60, blue: 0.60)
|
||||||
|
}
|
||||||
671
ios/LibNovelV2/Views/BookDetail/BookDetailView.swift
Normal file
671
ios/LibNovelV2/Views/BookDetail/BookDetailView.swift
Normal file
@@ -0,0 +1,671 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - BookDetailView
|
||||||
|
// Displays book hero (blurred cover bg + cover art + title), meta stats,
|
||||||
|
// expandable summary, CTA buttons, chapters row (→ sheet), and bottom save toggle.
|
||||||
|
// Matches the web UI at ui/src/routes/books/[slug]/+page.svelte.
|
||||||
|
|
||||||
|
struct BookDetailView: View {
|
||||||
|
let slug: String
|
||||||
|
|
||||||
|
@State private var vm: BookDetailViewModel
|
||||||
|
@State private var showChapters = false
|
||||||
|
@State private var summaryExpanded = false
|
||||||
|
@EnvironmentObject private var networkMonitor: NetworkMonitor
|
||||||
|
@EnvironmentObject private var authStore: AuthStore
|
||||||
|
|
||||||
|
init(slug: String) {
|
||||||
|
self.slug = slug
|
||||||
|
_vm = State(initialValue: BookDetailViewModel(slug: slug))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
OfflineBanner()
|
||||||
|
|
||||||
|
Group {
|
||||||
|
if vm.isLoading && vm.book == nil {
|
||||||
|
loadingState
|
||||||
|
} else if let book = vm.book {
|
||||||
|
content(book: book)
|
||||||
|
} else if vm.error != nil {
|
||||||
|
errorState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1)))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.appNavigationDestination()
|
||||||
|
.toolbar { toolbarContent }
|
||||||
|
.task {
|
||||||
|
guard networkMonitor.isConnected else { return }
|
||||||
|
await vm.load()
|
||||||
|
}
|
||||||
|
.errorAlert($vm.error)
|
||||||
|
.sheet(isPresented: $showChapters) {
|
||||||
|
BookChaptersSheet(
|
||||||
|
slug: slug,
|
||||||
|
chapters: vm.chapters,
|
||||||
|
lastChapter: vm.lastChapter
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Main content
|
||||||
|
|
||||||
|
private func content(book: Book) -> some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
heroSection(book: book)
|
||||||
|
statsRow(book: book)
|
||||||
|
Divider()
|
||||||
|
.background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1)))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
summarySection(book: book)
|
||||||
|
Divider()
|
||||||
|
.background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1)))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
ctaButtons
|
||||||
|
Divider()
|
||||||
|
.background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1)))
|
||||||
|
chaptersRow
|
||||||
|
Divider()
|
||||||
|
.background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1)))
|
||||||
|
|
||||||
|
Color.clear.frame(height: 120)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ignoresSafeArea(edges: .top)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Hero
|
||||||
|
|
||||||
|
private func heroSection(book: Book) -> some View {
|
||||||
|
ZStack(alignment: .bottom) {
|
||||||
|
// Blurred cover background
|
||||||
|
AsyncCoverImage(url: book.cover, isBackground: true)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 340)
|
||||||
|
.blur(radius: 28)
|
||||||
|
.clipped()
|
||||||
|
.overlay(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color.black.opacity(0.2),
|
||||||
|
Color.black.opacity(0.72),
|
||||||
|
],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
// Cover art
|
||||||
|
AsyncCoverImage(url: book.cover)
|
||||||
|
.frame(width: 130, height: 188)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
|
.shadow(color: .black.opacity(0.55), radius: 18, x: 0, y: 10)
|
||||||
|
.shadow(color: .black.opacity(0.3), radius: 6, x: 0, y: 3)
|
||||||
|
|
||||||
|
// Title + author
|
||||||
|
VStack(spacing: 5) {
|
||||||
|
Text(book.title)
|
||||||
|
.font(.title3.bold())
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.lineLimit(3)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
|
||||||
|
if !book.author.isEmpty {
|
||||||
|
Text(book.author)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status badge + genre chips
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
if !book.status.isEmpty {
|
||||||
|
BookStatusBadge(status: book.status)
|
||||||
|
}
|
||||||
|
if !book.genres.isEmpty {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ForEach(book.genres.prefix(3), id: \.self) { genre in
|
||||||
|
Text(genre)
|
||||||
|
.font(.caption2.bold())
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(.ultraThinMaterial, in: Capsule())
|
||||||
|
.foregroundStyle(.white.opacity(0.9))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Not in library" badge
|
||||||
|
if !vm.inLib {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "icloud.and.arrow.down")
|
||||||
|
.font(.caption2)
|
||||||
|
Text("Not in library")
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.background(.regularMaterial, in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.bottom, 28)
|
||||||
|
}
|
||||||
|
.frame(minHeight: 340)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stats row
|
||||||
|
|
||||||
|
private func statsRow(book: Book) -> some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
BookMetaStat(
|
||||||
|
value: "\(vm.chapters.isEmpty ? book.totalChapters : vm.chapters.count)",
|
||||||
|
label: "Chapters",
|
||||||
|
icon: "doc.text"
|
||||||
|
)
|
||||||
|
Divider().frame(height: 36)
|
||||||
|
BookMetaStat(
|
||||||
|
value: book.status.isEmpty ? "—" : book.status.capitalized,
|
||||||
|
label: "Status",
|
||||||
|
icon: "flag"
|
||||||
|
)
|
||||||
|
if book.ranking > 0 {
|
||||||
|
Divider().frame(height: 36)
|
||||||
|
BookMetaStat(value: "#\(book.ranking)", label: "Rank", icon: "chart.bar.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Summary
|
||||||
|
|
||||||
|
private func summarySection(book: Book) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("About")
|
||||||
|
.font(.headline)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
|
if book.summary.isEmpty {
|
||||||
|
Text("No description available.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
} else {
|
||||||
|
Text(book.summary)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(summaryExpanded ? nil : 4)
|
||||||
|
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: summaryExpanded)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
|
if book.summary.count > 200 {
|
||||||
|
Button {
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
||||||
|
summaryExpanded.toggle()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(summaryExpanded ? "Less" : "More")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.frame(minWidth: 44, minHeight: 44)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CTA buttons
|
||||||
|
|
||||||
|
private var ctaButtons: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
if let last = vm.lastChapter, last > 0 {
|
||||||
|
// Continue reading
|
||||||
|
NavigationLink(value: NavDestination.chapter(slug, last)) {
|
||||||
|
Label("Continue Ch.\(last)", systemImage: "play.fill")
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 44)
|
||||||
|
.background(Color.amber)
|
||||||
|
.foregroundStyle(Color(uiColor: UIColor(red: 0.11, green: 0.09, blue: 0.04, alpha: 1)))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.simultaneousGesture(TapGesture().onEnded {
|
||||||
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start from ch.1
|
||||||
|
NavigationLink(value: NavDestination.chapter(slug, 1)) {
|
||||||
|
Label("Ch.1", systemImage: "arrow.counterclockwise")
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.frame(height: 44)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1)))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.simultaneousGesture(TapGesture().onEnded {
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Start reading
|
||||||
|
NavigationLink(value: NavDestination.chapter(slug, 1)) {
|
||||||
|
Label(vm.inLib ? "Start Reading" : "Preview Ch.1", systemImage: "book.fill")
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 44)
|
||||||
|
.background(vm.chapters.isEmpty ? Color.amber.opacity(0.4) : Color.amber)
|
||||||
|
.foregroundStyle(Color(uiColor: UIColor(red: 0.11, green: 0.09, blue: 0.04, alpha: 1)))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(vm.chapters.isEmpty)
|
||||||
|
.simultaneousGesture(TapGesture().onEnded {
|
||||||
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Chapters row
|
||||||
|
|
||||||
|
private var chaptersRow: some View {
|
||||||
|
Button {
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
showChapters = true
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "list.number")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
.frame(width: 28)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Chapters")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
|
||||||
|
let count = vm.chapters.count
|
||||||
|
if let last = vm.lastChapter, last > 0, count > 0 {
|
||||||
|
Text("Reading Ch.\(last) of \(count)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else if count > 0 {
|
||||||
|
Text("\(count) chapter\(count == 1 ? "" : "s")")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else if vm.isLoading {
|
||||||
|
Text("Loading…")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.frame(minHeight: 44)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("Chapters list")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Toolbar
|
||||||
|
|
||||||
|
@ToolbarContentBuilder
|
||||||
|
private var toolbarContent: some ToolbarContent {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
Task { await vm.toggleSaved() }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: vm.saved ? "bookmark.fill" : "bookmark")
|
||||||
|
.foregroundStyle(vm.saved ? Color.amber : .primary)
|
||||||
|
.contentTransition(.symbolEffect(.replace.downUp))
|
||||||
|
}
|
||||||
|
.disabled(vm.isSaving)
|
||||||
|
.accessibilityLabel(vm.saved ? "Remove from library" : "Save to library")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Loading / Error states
|
||||||
|
|
||||||
|
private var loadingState: some View {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
ProgressView()
|
||||||
|
.tint(Color.amber)
|
||||||
|
.scaleEffect(1.4)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var errorState: some View {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
EmptyStateView(
|
||||||
|
icon: "wifi.slash",
|
||||||
|
title: "Couldn't load book",
|
||||||
|
message: vm.error ?? "Something went wrong.",
|
||||||
|
ctaLabel: "Retry",
|
||||||
|
ctaAction: {
|
||||||
|
Task {
|
||||||
|
guard networkMonitor.isConnected else { return }
|
||||||
|
await vm.load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - BookChaptersSheet
|
||||||
|
// Shows all chapters in groups of 100 with a searchable list and right-edge jump bar.
|
||||||
|
|
||||||
|
struct BookChaptersSheet: View {
|
||||||
|
let slug: String
|
||||||
|
let chapters: [ChapterIndex]
|
||||||
|
let lastChapter: Int?
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var searchText = ""
|
||||||
|
|
||||||
|
private var filtered: [ChapterIndex] {
|
||||||
|
guard !searchText.isEmpty else { return chapters }
|
||||||
|
let q = searchText.lowercased()
|
||||||
|
return chapters.filter {
|
||||||
|
"\($0.number)".contains(q) || $0.title.lowercased().contains(q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chapters in blocks of 100, or a flat "Results" group when searching.
|
||||||
|
private var groups: [(label: String, chapters: [ChapterIndex])] {
|
||||||
|
guard searchText.isEmpty else {
|
||||||
|
return filtered.isEmpty ? [] : [("Results", filtered)]
|
||||||
|
}
|
||||||
|
guard !filtered.isEmpty else { return [] }
|
||||||
|
let blockSize = 100
|
||||||
|
let minN = filtered.map(\.number).min() ?? 1
|
||||||
|
let maxN = filtered.map(\.number).max() ?? 1
|
||||||
|
let firstBlock = ((minN - 1) / blockSize) * blockSize + 1
|
||||||
|
var result: [(label: String, chapters: [ChapterIndex])] = []
|
||||||
|
var blockStart = firstBlock
|
||||||
|
while blockStart <= maxN {
|
||||||
|
let blockEnd = blockStart + blockSize - 1
|
||||||
|
let slice = filtered.filter { $0.number >= blockStart && $0.number <= blockEnd }
|
||||||
|
if !slice.isEmpty { result.append(("\(blockStart)–\(blockEnd)", slice)) }
|
||||||
|
blockStart += blockSize
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
@State private var activeBlock: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ZStack(alignment: .trailing) {
|
||||||
|
List {
|
||||||
|
ForEach(groups, id: \.label) { group in
|
||||||
|
Section {
|
||||||
|
ForEach(group.chapters, id: \.number) { ch in
|
||||||
|
ChapterListRow(
|
||||||
|
chapter: ch,
|
||||||
|
slug: slug,
|
||||||
|
isCurrent: ch.number == lastChapter
|
||||||
|
)
|
||||||
|
.id(ch.number)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
if searchText.isEmpty {
|
||||||
|
Text(group.label)
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.id("header_\(group.label)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if chapters.isEmpty {
|
||||||
|
Section {
|
||||||
|
ProgressView()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 24)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1)))
|
||||||
|
.searchable(
|
||||||
|
text: $searchText,
|
||||||
|
placement: .navigationBarDrawer(displayMode: .always),
|
||||||
|
prompt: "Chapter number or title"
|
||||||
|
)
|
||||||
|
.scrollPosition(id: $activeBlock, anchor: .top)
|
||||||
|
.appNavigationDestination()
|
||||||
|
|
||||||
|
// Jump bar (hidden while searching)
|
||||||
|
if searchText.isEmpty && groups.count > 1 {
|
||||||
|
ChapterJumpBar(
|
||||||
|
labels: groups.map(\.label),
|
||||||
|
currentChapter: lastChapter ?? 0,
|
||||||
|
groups: groups
|
||||||
|
) { label in
|
||||||
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||||
|
activeBlock = label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.trailing, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Chapters (\(filtered.count))")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
// Scroll to current chapter's block on open
|
||||||
|
if let block = groups.first(where: { g in
|
||||||
|
g.chapters.contains(where: { $0.number == (lastChapter ?? 0) })
|
||||||
|
}) {
|
||||||
|
activeBlock = block.label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ChapterListRow
|
||||||
|
|
||||||
|
private struct ChapterListRow: View {
|
||||||
|
let chapter: ChapterIndex
|
||||||
|
let slug: String
|
||||||
|
let isCurrent: Bool
|
||||||
|
|
||||||
|
private var displayTitle: String {
|
||||||
|
let pattern = #"\s*[-–]\s*\w+\s+\d{1,2}\s+\d{4}\s*$"#
|
||||||
|
let stripped = (try? NSRegularExpression(pattern: pattern))?
|
||||||
|
.stringByReplacingMatches(
|
||||||
|
in: chapter.title,
|
||||||
|
range: NSRange(chapter.title.startIndex..., in: chapter.title),
|
||||||
|
withTemplate: ""
|
||||||
|
).trimmingCharacters(in: .whitespaces) ?? chapter.title
|
||||||
|
if stripped.isEmpty || stripped == "Chapter \(chapter.number)" {
|
||||||
|
return "Chapter \(chapter.number)"
|
||||||
|
}
|
||||||
|
return stripped
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationLink(value: NavDestination.chapter(slug, chapter.number)) {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
// Number badge
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(isCurrent ? Color.amber : Color(.systemGray5))
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
Text("\(chapter.number)")
|
||||||
|
.font(.caption.bold().monospacedDigit())
|
||||||
|
.foregroundStyle(isCurrent ? .white : .secondary)
|
||||||
|
.minimumScaleFactor(0.6)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(displayTitle)
|
||||||
|
.font(.subheadline.weight(isCurrent ? .semibold : .regular))
|
||||||
|
.foregroundStyle(isCurrent ? Color.amber : .primary)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
if isCurrent {
|
||||||
|
Label("Reading", systemImage: "bookmark.fill")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
} else if !chapter.dateLabel.isEmpty {
|
||||||
|
Text(chapter.dateLabel)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 4)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.listRowBackground(isCurrent ? Color.amber.opacity(0.08) : Color.clear)
|
||||||
|
.listRowSeparatorTint(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ChapterJumpBar
|
||||||
|
|
||||||
|
private struct ChapterJumpBar: View {
|
||||||
|
let labels: [String]
|
||||||
|
let currentChapter: Int
|
||||||
|
let groups: [(label: String, chapters: [ChapterIndex])]
|
||||||
|
let onSelect: (String) -> Void
|
||||||
|
|
||||||
|
private func shortLabel(_ full: String) -> String {
|
||||||
|
full.components(separatedBy: "–").first ?? full
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentBlock: String? {
|
||||||
|
groups.first(where: { g in g.chapters.contains(where: { $0.number == currentChapter }) })?.label
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(labels, id: \.self) { label in
|
||||||
|
let isCurrent = label == currentBlock
|
||||||
|
Text(shortLabel(label))
|
||||||
|
.font(.system(size: 10, weight: isCurrent ? .bold : .regular))
|
||||||
|
.foregroundStyle(isCurrent ? Color.amber : Color.secondary)
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { onSelect(label) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(
|
||||||
|
Capsule()
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
.shadow(color: .black.opacity(0.15), radius: 4)
|
||||||
|
)
|
||||||
|
.gesture(
|
||||||
|
DragGesture(minimumDistance: 0, coordinateSpace: .local)
|
||||||
|
.onChanged { value in
|
||||||
|
let itemHeight: CGFloat = 28
|
||||||
|
let index = Int(value.location.y / itemHeight)
|
||||||
|
let clamped = max(0, min(labels.count - 1, index))
|
||||||
|
onSelect(labels[clamped])
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - BookStatusBadge
|
||||||
|
|
||||||
|
private struct BookStatusBadge: View {
|
||||||
|
let status: String
|
||||||
|
|
||||||
|
private var color: Color {
|
||||||
|
switch status.lowercased() {
|
||||||
|
case "ongoing", "active": return .green
|
||||||
|
case "completed": return .blue
|
||||||
|
case "hiatus": return .orange
|
||||||
|
default: return .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Circle().fill(color).frame(width: 6, height: 6)
|
||||||
|
Text(status.capitalized)
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(color)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(color.opacity(0.12), in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - BookMetaStat
|
||||||
|
|
||||||
|
private struct BookMetaStat: View {
|
||||||
|
let value: String
|
||||||
|
let label: String
|
||||||
|
let icon: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
Text(value)
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.7)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
446
ios/LibNovelV2/Views/Browse/BrowseCategoryView.swift
Normal file
446
ios/LibNovelV2/Views/Browse/BrowseCategoryView.swift
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - BrowseCategoryView
|
||||||
|
// Full paginated grid for "See All" / genre deep-dives.
|
||||||
|
// Supports browse (infinite scroll) and rank (flat list) modes.
|
||||||
|
// Sort/genre/status can be adjusted via the filters sheet.
|
||||||
|
|
||||||
|
struct BrowseCategoryView: View {
|
||||||
|
let sort: String
|
||||||
|
let genre: String
|
||||||
|
let status: String
|
||||||
|
let title: String
|
||||||
|
|
||||||
|
@State private var vm = BrowseViewModel()
|
||||||
|
@State private var showFilters = false
|
||||||
|
@EnvironmentObject private var networkMonitor: NetworkMonitor
|
||||||
|
|
||||||
|
init(sort: String, genre: String, status: String, title: String) {
|
||||||
|
self.sort = sort
|
||||||
|
self.genre = genre
|
||||||
|
self.status = status
|
||||||
|
self.title = title
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isRankMode: Bool { sort == "rank" }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if vm.isLoading && vm.novels.isEmpty {
|
||||||
|
loadingState
|
||||||
|
} else if let err = vm.error, vm.novels.isEmpty {
|
||||||
|
errorState(message: err)
|
||||||
|
} else if vm.novels.isEmpty && !vm.isLoading {
|
||||||
|
emptyState
|
||||||
|
} else if isRankMode {
|
||||||
|
rankList
|
||||||
|
} else {
|
||||||
|
novelGrid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1)))
|
||||||
|
.navigationTitle(title)
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.appNavigationDestination()
|
||||||
|
.toolbar { toolbarContent }
|
||||||
|
.task {
|
||||||
|
guard networkMonitor.isConnected else { return }
|
||||||
|
vm.sort = sort
|
||||||
|
vm.genre = genre
|
||||||
|
vm.status = status
|
||||||
|
if vm.novels.isEmpty {
|
||||||
|
if isRankMode {
|
||||||
|
await vm.loadRanking()
|
||||||
|
} else {
|
||||||
|
await vm.loadFirstPage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: vm.sort) { _, _ in
|
||||||
|
Task { await refreshForFilters() }
|
||||||
|
}
|
||||||
|
.onChange(of: vm.genre) { _, _ in
|
||||||
|
Task { await refreshForFilters() }
|
||||||
|
}
|
||||||
|
.onChange(of: vm.status) { _, _ in
|
||||||
|
Task { await refreshForFilters() }
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showFilters) {
|
||||||
|
BrowseFiltersSheet(vm: vm)
|
||||||
|
}
|
||||||
|
.errorAlert($vm.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Grid view
|
||||||
|
|
||||||
|
private let columns = [
|
||||||
|
GridItem(.flexible(), spacing: 14),
|
||||||
|
GridItem(.flexible(), spacing: 14)
|
||||||
|
]
|
||||||
|
|
||||||
|
private var novelGrid: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVGrid(columns: columns, spacing: 14) {
|
||||||
|
ForEach(vm.novels) { novel in
|
||||||
|
NavigationLink(value: NavDestination.book(novel.slug)) {
|
||||||
|
BrowseCategoryCard(novel: novel)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.onAppear {
|
||||||
|
if novel.id == vm.novels.last?.id && vm.hasNext {
|
||||||
|
Task { await vm.loadNextPage() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 12)
|
||||||
|
|
||||||
|
// Load-more indicator
|
||||||
|
if vm.isLoadingMore {
|
||||||
|
ProgressView()
|
||||||
|
.padding(.vertical, 24)
|
||||||
|
.tint(Color.amber)
|
||||||
|
} else if !vm.hasNext && !vm.novels.isEmpty {
|
||||||
|
Text("All novels loaded")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.quaternary)
|
||||||
|
.padding(.vertical, 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
Color.clear.frame(height: 120)
|
||||||
|
}
|
||||||
|
.refreshable { await vm.loadFirstPage() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rank list view
|
||||||
|
|
||||||
|
private var rankList: some View {
|
||||||
|
List {
|
||||||
|
ForEach(vm.novels) { novel in
|
||||||
|
NavigationLink(value: NavDestination.book(novel.slug)) {
|
||||||
|
RankListRow(novel: novel)
|
||||||
|
}
|
||||||
|
.listRowBackground(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1)))
|
||||||
|
.listRowSeparatorTint(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.refreshable { await vm.loadRanking() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Loading / error / empty
|
||||||
|
|
||||||
|
private var loadingState: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVGrid(columns: columns, spacing: 14) {
|
||||||
|
ForEach(0..<10, id: \.self) { _ in
|
||||||
|
BrowseCategoryCardSkeleton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func errorState(message: String) -> some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Spacer()
|
||||||
|
EmptyStateView(
|
||||||
|
icon: "wifi.slash",
|
||||||
|
title: "Couldn't load",
|
||||||
|
message: message,
|
||||||
|
ctaLabel: "Retry",
|
||||||
|
ctaAction: {
|
||||||
|
Task {
|
||||||
|
if isRankMode { await vm.loadRanking() }
|
||||||
|
else { await vm.loadFirstPage() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyState: some View {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
EmptyStateView(
|
||||||
|
icon: "books.vertical",
|
||||||
|
title: "No novels found",
|
||||||
|
message: "Try different filters.",
|
||||||
|
ctaLabel: "Change Filters",
|
||||||
|
ctaAction: { showFilters = true }
|
||||||
|
)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Toolbar
|
||||||
|
|
||||||
|
@ToolbarContentBuilder
|
||||||
|
private var toolbarContent: some ToolbarContent {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
showFilters = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "slider.horizontal.3")
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Filter novels")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Filter change
|
||||||
|
|
||||||
|
private func refreshForFilters() async {
|
||||||
|
if vm.sort == "rank" {
|
||||||
|
await vm.loadRanking()
|
||||||
|
} else {
|
||||||
|
await vm.loadFirstPage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - BrowseCategoryCard
|
||||||
|
|
||||||
|
struct BrowseCategoryCard: View {
|
||||||
|
let novel: BrowseNovel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
AsyncCoverImage(url: novel.cover)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.aspectRatio(2/3, contentMode: .fit)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
|
.bookCoverZoomSource(slug: novel.slug)
|
||||||
|
|
||||||
|
if !novel.rank.isEmpty {
|
||||||
|
Text(novel.rank)
|
||||||
|
.font(.caption2.bold())
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(.ultraThinMaterial, in: Capsule())
|
||||||
|
.padding(6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(novel.title)
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.lineLimit(2)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
if !novel.author.isEmpty {
|
||||||
|
Text(novel.author)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !novel.chapters.isEmpty {
|
||||||
|
Text(novel.chapters)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1)))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
|
.shadow(color: .black.opacity(0.12), radius: 6, x: 0, y: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - BrowseCategoryCardSkeleton
|
||||||
|
|
||||||
|
private struct BrowseCategoryCardSkeleton: View {
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(Color(uiColor: UIColor(red: 0.18, green: 0.18, blue: 0.20, alpha: 1)))
|
||||||
|
.aspectRatio(2/3, contentMode: .fit)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(Color(uiColor: UIColor(red: 0.22, green: 0.22, blue: 0.25, alpha: 1)))
|
||||||
|
.frame(height: 14)
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(Color(uiColor: UIColor(red: 0.22, green: 0.22, blue: 0.25, alpha: 1)))
|
||||||
|
.frame(width: 80, height: 11)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
.background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1)))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - RankListRow
|
||||||
|
|
||||||
|
private struct RankListRow: View {
|
||||||
|
let novel: BrowseNovel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// Rank number
|
||||||
|
Text(novel.rank.isEmpty ? "–" : novel.rank)
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
.frame(width: 36, alignment: .trailing)
|
||||||
|
|
||||||
|
// Cover thumbnail
|
||||||
|
AsyncCoverImage(url: novel.cover)
|
||||||
|
.frame(width: 44, height: 62)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||||
|
|
||||||
|
// Title + meta
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(novel.title)
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.lineLimit(2)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
|
||||||
|
if !novel.author.isEmpty {
|
||||||
|
Text(novel.author)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if !novel.status.isEmpty {
|
||||||
|
TagChip(label: novel.status.capitalized)
|
||||||
|
}
|
||||||
|
if !novel.rating.isEmpty {
|
||||||
|
TagChip(label: "★ \(novel.rating)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.frame(minHeight: 44)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - BrowseFiltersSheet
|
||||||
|
|
||||||
|
struct BrowseFiltersSheet: View {
|
||||||
|
var vm: BrowseViewModel
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
private let sortOptions: [(value: String, label: String)] = [
|
||||||
|
("popular", "Popular"),
|
||||||
|
("new", "New"),
|
||||||
|
("update", "Updated"),
|
||||||
|
("rank", "Ranking"),
|
||||||
|
]
|
||||||
|
private let genreOptions: [(value: String, label: String)] = [
|
||||||
|
("all", "All Genres"),
|
||||||
|
("action", "Action"),
|
||||||
|
("adventure", "Adventure"),
|
||||||
|
("comedy", "Comedy"),
|
||||||
|
("drama", "Drama"),
|
||||||
|
("fantasy", "Fantasy"),
|
||||||
|
("harem", "Harem"),
|
||||||
|
("historical", "Historical"),
|
||||||
|
("horror", "Horror"),
|
||||||
|
("isekai", "Isekai"),
|
||||||
|
("martial-arts", "Martial Arts"),
|
||||||
|
("mystery", "Mystery"),
|
||||||
|
("psychological", "Psychological"),
|
||||||
|
("romance", "Romance"),
|
||||||
|
("sci-fi", "Sci-Fi"),
|
||||||
|
("system", "System"),
|
||||||
|
("xianxia", "Xianxia"),
|
||||||
|
]
|
||||||
|
private let statusOptions: [(value: String, label: String)] = [
|
||||||
|
("all", "All"),
|
||||||
|
("ongoing", "Ongoing"),
|
||||||
|
("completed", "Completed"),
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Sort") {
|
||||||
|
ForEach(sortOptions, id: \.value) { opt in
|
||||||
|
filterRow(label: opt.label, isSelected: vm.sort == opt.value) {
|
||||||
|
vm.sort = opt.value
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Genre") {
|
||||||
|
ForEach(genreOptions, id: \.value) { opt in
|
||||||
|
filterRow(label: opt.label, isSelected: vm.genre == opt.value) {
|
||||||
|
vm.genre = opt.value
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(vm.sort == "rank")
|
||||||
|
|
||||||
|
Section("Status") {
|
||||||
|
ForEach(statusOptions, id: \.value) { opt in
|
||||||
|
filterRow(label: opt.label, isSelected: vm.status == opt.value) {
|
||||||
|
vm.status = opt.value
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(vm.sort == "rank")
|
||||||
|
|
||||||
|
if vm.sort == "rank" {
|
||||||
|
Section {
|
||||||
|
Text("Genre & status filters apply to Browse only")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Filters")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.medium, .large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func filterRow(label: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
|
||||||
|
HStack {
|
||||||
|
Text(label)
|
||||||
|
Spacer()
|
||||||
|
if isSelected {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
.frame(minHeight: 44)
|
||||||
|
}
|
||||||
|
}
|
||||||
411
ios/LibNovelV2/Views/Browse/BrowseView.swift
Normal file
411
ios/LibNovelV2/Views/Browse/BrowseView.swift
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - BrowseView
|
||||||
|
// "Discover" tab: curated horizontal shelves (Trending, New, Updated, Ranking)
|
||||||
|
// plus a genre picker sheet. Mirrors the web UI's serendipitous browse experience.
|
||||||
|
|
||||||
|
struct BrowseView: View {
|
||||||
|
@State private var vm = BrowseViewModel()
|
||||||
|
@State private var showGenreSheet = false
|
||||||
|
@EnvironmentObject private var networkMonitor: NetworkMonitor
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
OfflineBanner()
|
||||||
|
|
||||||
|
Group {
|
||||||
|
if vm.isLoading && vm.trending.isEmpty {
|
||||||
|
loadingState
|
||||||
|
} else if let err = vm.error, vm.trending.isEmpty {
|
||||||
|
errorState(message: err)
|
||||||
|
} else {
|
||||||
|
shelvesContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1)))
|
||||||
|
.navigationTitle("Discover")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.appNavigationDestination()
|
||||||
|
.task {
|
||||||
|
guard networkMonitor.isConnected else { return }
|
||||||
|
if vm.trending.isEmpty { await vm.loadShelves() }
|
||||||
|
}
|
||||||
|
.refreshable { await vm.loadShelves() }
|
||||||
|
.errorAlert($vm.error)
|
||||||
|
.sheet(isPresented: $showGenreSheet) {
|
||||||
|
GenrePickerSheet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shelves content
|
||||||
|
|
||||||
|
private var shelvesContent: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 32) {
|
||||||
|
|
||||||
|
// Trending Now
|
||||||
|
if !vm.trending.isEmpty {
|
||||||
|
BrowseShelf(
|
||||||
|
title: "Trending Now",
|
||||||
|
novels: vm.trending,
|
||||||
|
destination: NavDestination.browseCategory(
|
||||||
|
sort: "popular", genre: "all", status: "all", title: "Trending Now"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// New Releases
|
||||||
|
if !vm.newReleases.isEmpty {
|
||||||
|
BrowseShelf(
|
||||||
|
title: "New Releases",
|
||||||
|
novels: vm.newReleases,
|
||||||
|
destination: NavDestination.browseCategory(
|
||||||
|
sort: "new", genre: "all", status: "all", title: "New Releases"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recently Updated
|
||||||
|
if !vm.recentlyUpdated.isEmpty {
|
||||||
|
BrowseShelf(
|
||||||
|
title: "Recently Updated",
|
||||||
|
novels: vm.recentlyUpdated,
|
||||||
|
destination: NavDestination.browseCategory(
|
||||||
|
sort: "update", genre: "all", status: "all", title: "Recently Updated"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rankings (list-style shelf)
|
||||||
|
if !vm.ranking.isEmpty {
|
||||||
|
BrowseShelf(
|
||||||
|
title: "Rankings",
|
||||||
|
novels: vm.ranking,
|
||||||
|
destination: NavDestination.browseCategory(
|
||||||
|
sort: "rank", genre: "all", status: "all", title: "Rankings"
|
||||||
|
),
|
||||||
|
showRank: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browse by Genre
|
||||||
|
CategoriesRow { showGenreSheet = true }
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
|
Color.clear.frame(height: 120)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Loading / error states
|
||||||
|
|
||||||
|
private var loadingState: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 32) {
|
||||||
|
ForEach(0..<3, id: \.self) { _ in
|
||||||
|
BrowseShelfSkeleton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func errorState(message: String) -> some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Spacer()
|
||||||
|
EmptyStateView(
|
||||||
|
icon: "wifi.slash",
|
||||||
|
title: "Couldn't load",
|
||||||
|
message: message,
|
||||||
|
ctaLabel: "Retry",
|
||||||
|
ctaAction: { Task { await vm.loadShelves() } }
|
||||||
|
)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - BrowseShelf
|
||||||
|
// Amber-accented header + horizontal card scroll + "See All" link.
|
||||||
|
|
||||||
|
struct BrowseShelf: View {
|
||||||
|
let title: String
|
||||||
|
let novels: [BrowseNovel]
|
||||||
|
let destination: NavDestination
|
||||||
|
var showRank: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
// Header row
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
||||||
|
.fill(Color.amber)
|
||||||
|
.frame(width: 3, height: 18)
|
||||||
|
Text(title)
|
||||||
|
.font(.title3.bold())
|
||||||
|
Spacer()
|
||||||
|
NavigationLink(value: destination) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text("See All")
|
||||||
|
.font(.subheadline)
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption.bold())
|
||||||
|
}
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
|
// Horizontal scroll
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
ForEach(novels) { novel in
|
||||||
|
NavigationLink(value: NavDestination.book(novel.slug)) {
|
||||||
|
BrowseShelfCard(novel: novel, showRank: showRank)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - BrowseShelfCard
|
||||||
|
|
||||||
|
struct BrowseShelfCard: View {
|
||||||
|
let novel: BrowseNovel
|
||||||
|
var showRank: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
AsyncCoverImage(url: novel.cover)
|
||||||
|
.frame(width: 120, height: 173)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
|
.bookCoverZoomSource(slug: novel.slug)
|
||||||
|
|
||||||
|
if showRank && !novel.rank.isEmpty {
|
||||||
|
Text(novel.rank)
|
||||||
|
.font(.caption2.bold())
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(.ultraThinMaterial, in: Capsule())
|
||||||
|
.padding(6)
|
||||||
|
} else if !novel.rank.isEmpty {
|
||||||
|
Text(novel.rank)
|
||||||
|
.font(.caption2.bold())
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(.ultraThinMaterial, in: Capsule())
|
||||||
|
.padding(6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(novel.title)
|
||||||
|
.font(.caption.bold())
|
||||||
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.frame(width: 120, alignment: .leading)
|
||||||
|
|
||||||
|
if !novel.author.isEmpty {
|
||||||
|
Text(novel.author)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.frame(width: 120, alignment: .leading)
|
||||||
|
} else if !novel.chapters.isEmpty {
|
||||||
|
Text(novel.chapters)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.frame(width: 120, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
.frame(width: 132)
|
||||||
|
.background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1)))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
|
.shadow(color: .black.opacity(0.12), radius: 6, x: 0, y: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - BrowseShelfSkeleton
|
||||||
|
|
||||||
|
struct BrowseShelfSkeleton: View {
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
// Header skeleton
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color.amber.opacity(0.3))
|
||||||
|
.frame(width: 3, height: 18)
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(Color(uiColor: UIColor(red: 0.22, green: 0.22, blue: 0.25, alpha: 1)))
|
||||||
|
.frame(width: 140, height: 20)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
|
// Cards skeleton
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ForEach(0..<5, id: \.self) { _ in
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.fill(Color(uiColor: UIColor(red: 0.18, green: 0.18, blue: 0.20, alpha: 1)))
|
||||||
|
.frame(width: 132, height: 220)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CategoriesRow
|
||||||
|
|
||||||
|
struct CategoriesRow: View {
|
||||||
|
let onTap: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: {
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
onTap()
|
||||||
|
}) {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||||
|
.fill(Color.amber.opacity(0.15))
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
Image(systemName: "square.grid.2x2")
|
||||||
|
.font(.system(size: 20, weight: .medium))
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Browse by Genre")
|
||||||
|
.font(.body.weight(.semibold))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
Text("Action, Fantasy, Romance & more")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1)))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("Browse by Genre")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - GenrePickerSheet
|
||||||
|
|
||||||
|
struct GenrePickerSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
private let genres: [(label: String, value: String, icon: String)] = [
|
||||||
|
("All Novels", "all", "books.vertical.fill"),
|
||||||
|
("Action", "action", "bolt.fill"),
|
||||||
|
("Adventure", "adventure", "map.fill"),
|
||||||
|
("Comedy", "comedy", "face.smiling.fill"),
|
||||||
|
("Drama", "drama", "theatermasks.fill"),
|
||||||
|
("Fantasy", "fantasy", "wand.and.stars"),
|
||||||
|
("Harem", "harem", "person.3.fill"),
|
||||||
|
("Historical", "historical", "building.columns.fill"),
|
||||||
|
("Horror", "horror", "moon.fill"),
|
||||||
|
("Isekai", "isekai", "globe.americas.fill"),
|
||||||
|
("Martial Arts", "martial-arts", "figure.martial.arts"),
|
||||||
|
("Mystery", "mystery", "magnifyingglass"),
|
||||||
|
("Psychological","psychological","brain.head.profile"),
|
||||||
|
("Romance", "romance", "heart.fill"),
|
||||||
|
("Sci-Fi", "sci-fi", "sparkles"),
|
||||||
|
("System", "system", "cpu"),
|
||||||
|
("Xianxia", "xianxia", "leaf.fill"),
|
||||||
|
]
|
||||||
|
|
||||||
|
private let columns = [
|
||||||
|
GridItem(.flexible(), spacing: 12),
|
||||||
|
GridItem(.flexible(), spacing: 12)
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
LazyVGrid(columns: columns, spacing: 12) {
|
||||||
|
ForEach(genres, id: \.value) { item in
|
||||||
|
NavigationLink(value: NavDestination.browseCategory(
|
||||||
|
sort: "popular",
|
||||||
|
genre: item.value,
|
||||||
|
status: "all",
|
||||||
|
title: item.label
|
||||||
|
)) {
|
||||||
|
GenreTile(label: item.label, icon: item.icon)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.simultaneousGesture(TapGesture().onEnded { dismiss() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
}
|
||||||
|
.navigationTitle("Genres")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.appNavigationDestination()
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.medium, .large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
.presentationCornerRadius(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - GenreTile
|
||||||
|
|
||||||
|
private struct GenreTile: View {
|
||||||
|
let label: String
|
||||||
|
let icon: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
.frame(width: 24)
|
||||||
|
Text(label)
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1)))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
.frame(minHeight: 44)
|
||||||
|
}
|
||||||
|
}
|
||||||
1232
ios/LibNovelV2/Views/ChapterReader/ChapterReaderView.swift
Normal file
1232
ios/LibNovelV2/Views/ChapterReader/ChapterReaderView.swift
Normal file
File diff suppressed because it is too large
Load Diff
235
ios/LibNovelV2/Views/Common/CommonViews.swift
Normal file
235
ios/LibNovelV2/Views/Common/CommonViews.swift
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - CommonViews
|
||||||
|
// Shared reusable components used across multiple screens.
|
||||||
|
// No external dependencies — images are loaded via URLSession with an in-memory cache.
|
||||||
|
|
||||||
|
// MARK: - Color extensions (design system tokens)
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
/// Amber-400 accent — #f59e0b
|
||||||
|
static let amber = Color(red: 0.961, green: 0.620, blue: 0.043)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AsyncCoverImage
|
||||||
|
// URLSession-backed cover image loader with in-memory cache.
|
||||||
|
// Displays a zinc-800 placeholder skeleton while loading, book-closed icon on failure.
|
||||||
|
|
||||||
|
private actor ImageCache {
|
||||||
|
static let shared = ImageCache()
|
||||||
|
private var cache: [URL: Data] = [:]
|
||||||
|
private var inFlight: [URL: Task<Data?, Never>] = [:]
|
||||||
|
|
||||||
|
func data(for url: URL) async -> Data? {
|
||||||
|
if let cached = cache[url] { return cached }
|
||||||
|
if let existing = inFlight[url] { return await existing.value }
|
||||||
|
|
||||||
|
let task = Task<Data?, Never> {
|
||||||
|
do {
|
||||||
|
let (d, _) = try await URLSession.shared.data(from: url)
|
||||||
|
return d
|
||||||
|
} catch { return nil }
|
||||||
|
}
|
||||||
|
inFlight[url] = task
|
||||||
|
let result = await task.value
|
||||||
|
inFlight.removeValue(forKey: url)
|
||||||
|
if let result { cache[url] = result }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AsyncCoverImage: View {
|
||||||
|
let url: String?
|
||||||
|
/// When true the placeholder is a plain colour fill (used for blurred hero backgrounds).
|
||||||
|
var isBackground: Bool = false
|
||||||
|
|
||||||
|
@State private var image: UIImage?
|
||||||
|
@State private var hasFailed = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let image {
|
||||||
|
Image(uiImage: image)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
} else if hasFailed {
|
||||||
|
placeholder
|
||||||
|
} else {
|
||||||
|
placeholder
|
||||||
|
.task(id: url) { await load() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var placeholder: some View {
|
||||||
|
if isBackground {
|
||||||
|
Color(uiColor: UIColor(red: 0.14, green: 0.14, blue: 0.16, alpha: 1))
|
||||||
|
} else {
|
||||||
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||||
|
.fill(Color(uiColor: UIColor(red: 0.14, green: 0.14, blue: 0.16, alpha: 1)))
|
||||||
|
.overlay(
|
||||||
|
Image(systemName: "book.closed")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
guard let urlString = url, let parsedURL = URL(string: urlString) else {
|
||||||
|
hasFailed = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let data = await ImageCache.shared.data(for: parsedURL),
|
||||||
|
let loaded = UIImage(data: data) else {
|
||||||
|
hasFailed = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
image = loaded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - EmptyStateView
|
||||||
|
|
||||||
|
struct EmptyStateView: View {
|
||||||
|
let icon: String
|
||||||
|
let title: String
|
||||||
|
let message: String
|
||||||
|
var ctaLabel: String? = nil
|
||||||
|
var ctaAction: (() -> Void)? = nil
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 52))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.symbolEffect(.pulse)
|
||||||
|
|
||||||
|
Text(title)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
|
||||||
|
Text(message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 40)
|
||||||
|
|
||||||
|
if let label = ctaLabel, let action = ctaAction {
|
||||||
|
Button(action: action) {
|
||||||
|
Text(label)
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.foregroundStyle(Color(uiColor: UIColor(red: 0.11, green: 0.09, blue: 0.04, alpha: 1)))
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.frame(height: 44)
|
||||||
|
.background(Color.amber)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ShelfHeader
|
||||||
|
// Amber accent-bar + bold title. Used by Home, Profile, Browse shelves.
|
||||||
|
|
||||||
|
struct ShelfHeader: View {
|
||||||
|
let title: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
||||||
|
.fill(Color.amber)
|
||||||
|
.frame(width: 3, height: 18)
|
||||||
|
Text(title)
|
||||||
|
.font(.title3.bold())
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.bottom, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ChipButton
|
||||||
|
// Unified selection chip (filled or outlined style).
|
||||||
|
|
||||||
|
enum ChipButtonStyle { case filled, outlined }
|
||||||
|
|
||||||
|
struct ChipButton: View {
|
||||||
|
let label: String
|
||||||
|
let isSelected: Bool
|
||||||
|
var style: ChipButtonStyle = .filled
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
action()
|
||||||
|
} label: {
|
||||||
|
Text(label)
|
||||||
|
.font(style == .filled
|
||||||
|
? .caption.weight(isSelected ? .semibold : .regular)
|
||||||
|
: .subheadline.weight(isSelected ? .semibold : .regular))
|
||||||
|
.padding(.horizontal, style == .filled ? 12 : 14)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.foregroundStyle(isSelected
|
||||||
|
? (style == .filled ? Color.white : Color.amber)
|
||||||
|
: Color.primary)
|
||||||
|
.background(chipBackground)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.frame(minWidth: 44, minHeight: 44)
|
||||||
|
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var chipBackground: some View {
|
||||||
|
switch style {
|
||||||
|
case .filled:
|
||||||
|
Capsule().fill(isSelected ? Color.amber : Color(.systemGray5))
|
||||||
|
case .outlined:
|
||||||
|
Capsule()
|
||||||
|
.fill(isSelected ? Color.amber.opacity(0.15) : Color(.systemGray6))
|
||||||
|
.overlay(Capsule().stroke(isSelected ? Color.amber : Color.clear, lineWidth: 1.5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - TagChip (read-only label)
|
||||||
|
|
||||||
|
struct TagChip: View {
|
||||||
|
let label: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption2.bold())
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color(.systemGray5), in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - OfflineBanner
|
||||||
|
// Shown at the top of any view when the device is offline.
|
||||||
|
|
||||||
|
struct OfflineBanner: View {
|
||||||
|
@EnvironmentObject var networkMonitor: NetworkMonitor
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !networkMonitor.isConnected {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "wifi.slash")
|
||||||
|
.font(.caption.bold())
|
||||||
|
Text("You're offline — showing cached content")
|
||||||
|
.font(.caption)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(.regularMaterial)
|
||||||
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
359
ios/LibNovelV2/Views/Downloads/DownloadsView.swift
Normal file
359
ios/LibNovelV2/Views/Downloads/DownloadsView.swift
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - DownloadsView
|
||||||
|
// Shows active downloads (in-progress), downloaded chapters grouped by book, and storage usage.
|
||||||
|
// Purely local — no network calls needed.
|
||||||
|
|
||||||
|
struct DownloadsView: View {
|
||||||
|
@ObservedObject private var downloadService = AudioDownloadService.shared
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
// Completed chapters grouped by slug, sorted alphabetically
|
||||||
|
private var groupedDownloads: [(slug: String, keys: [String])] {
|
||||||
|
let slugs = downloadService.offlineBookSlugs()
|
||||||
|
return slugs.map { slug in
|
||||||
|
let keys = downloadService.downloadedChapters
|
||||||
|
.filter { $0.hasPrefix("\(slug)::") }
|
||||||
|
.sorted { lhs, rhs in
|
||||||
|
let lhsChapter = chapterNumber(from: lhs)
|
||||||
|
let rhsChapter = chapterNumber(from: rhs)
|
||||||
|
return lhsChapter < rhsChapter
|
||||||
|
}
|
||||||
|
return (slug: slug, keys: keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var activeDownloads: [(key: String, progress: DownloadProgress)] {
|
||||||
|
downloadService.downloads
|
||||||
|
.sorted { $0.key < $1.key }
|
||||||
|
.map { (key: $0.key, progress: $0.value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var storageFormatted: String {
|
||||||
|
ByteCountFormatter.string(
|
||||||
|
fromByteCount: downloadService.totalStorageUsed(),
|
||||||
|
countStyle: .file
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasAnyContent: Bool {
|
||||||
|
!downloadService.downloadedChapters.isEmpty || !downloadService.downloads.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if hasAnyContent {
|
||||||
|
contentList
|
||||||
|
} else {
|
||||||
|
emptyState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1)))
|
||||||
|
.navigationTitle("Downloads")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Done") {
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Content list
|
||||||
|
|
||||||
|
private var contentList: some View {
|
||||||
|
List {
|
||||||
|
// Storage info
|
||||||
|
storageSection
|
||||||
|
|
||||||
|
// Active downloads
|
||||||
|
if !activeDownloads.isEmpty {
|
||||||
|
Section {
|
||||||
|
ForEach(activeDownloads, id: \.key) { item in
|
||||||
|
ActiveDownloadRow(key: item.key, progress: item.progress)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Downloading")
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
.textCase(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Completed, grouped by book
|
||||||
|
ForEach(groupedDownloads, id: \.slug) { group in
|
||||||
|
Section {
|
||||||
|
ForEach(group.keys, id: \.self) { key in
|
||||||
|
DownloadedChapterRow(key: key)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "book.closed.fill")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(group.slug)
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.textCase(nil)
|
||||||
|
Spacer()
|
||||||
|
Text("\(group.keys.count) ch.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textCase(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all
|
||||||
|
if !downloadService.downloadedChapters.isEmpty {
|
||||||
|
Section {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
|
||||||
|
try? downloadService.deleteAllDownloads()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Label("Delete All Downloads", systemImage: "trash.fill")
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Delete all downloaded audio chapters")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Storage section
|
||||||
|
|
||||||
|
private var storageSection: some View {
|
||||||
|
Section {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "internaldrive.fill")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
.frame(width: 28)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Storage Used")
|
||||||
|
.font(.subheadline)
|
||||||
|
Text(storageFormatted)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text("\(downloadService.downloadedChapters.count) chapters")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Empty state
|
||||||
|
|
||||||
|
private var emptyState: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Spacer()
|
||||||
|
EmptyStateView(
|
||||||
|
icon: "arrow.down.circle",
|
||||||
|
title: "No Downloads",
|
||||||
|
message: "Downloaded audio chapters appear here for offline listening."
|
||||||
|
)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func chapterNumber(from key: String) -> Int {
|
||||||
|
let parts = key.split(separator: "::")
|
||||||
|
guard parts.count >= 2, let n = Int(parts[1]) else { return 0 }
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ActiveDownloadRow
|
||||||
|
|
||||||
|
private struct ActiveDownloadRow: View {
|
||||||
|
let key: String
|
||||||
|
let progress: DownloadProgress
|
||||||
|
@ObservedObject private var downloadService = AudioDownloadService.shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// Icon with status
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(statusColor.opacity(0.15))
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
Image(systemName: statusIcon)
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.foregroundStyle(statusColor)
|
||||||
|
.contentTransition(.symbolEffect(.replace.downUp))
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text("Chapter \(progress.chapter)")
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(progress.slug)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text("·")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
Text(formatVoice(progress.voice))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Progress or error indicator
|
||||||
|
if case .failed(let msg) = progress.status {
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.symbolEffect(.pulse)
|
||||||
|
Text("Failed")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Download failed: \(msg)")
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
|
Text("\(Int(progress.progress * 100))%")
|
||||||
|
.font(.caption.monospacedDigit())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
ProgressView(value: progress.progress)
|
||||||
|
.tint(Color.amber)
|
||||||
|
.frame(width: 64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel button (only while downloading)
|
||||||
|
if progress.status == .downloading {
|
||||||
|
Button {
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
downloadService.cancelDownload(
|
||||||
|
slug: progress.slug,
|
||||||
|
chapter: progress.chapter,
|
||||||
|
voice: progress.voice
|
||||||
|
)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.frame(minWidth: 44, minHeight: 44)
|
||||||
|
.accessibilityLabel("Cancel download for chapter \(progress.chapter)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusColor: Color {
|
||||||
|
if case .failed = progress.status { return .red }
|
||||||
|
return Color.amber
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusIcon: String {
|
||||||
|
if case .failed = progress.status { return "exclamationmark.triangle" }
|
||||||
|
return "arrow.down"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - DownloadedChapterRow
|
||||||
|
|
||||||
|
private struct DownloadedChapterRow: View {
|
||||||
|
let key: String
|
||||||
|
@ObservedObject private var downloadService = AudioDownloadService.shared
|
||||||
|
|
||||||
|
// Parse "slug::chapterNumber::voice" — v2 keys use "::" separator
|
||||||
|
private var components: (slug: String, chapter: Int, voice: String) {
|
||||||
|
let parts = key.split(separator: "::")
|
||||||
|
guard parts.count == 3, let chapter = Int(parts[1]) else {
|
||||||
|
return ("", 0, "")
|
||||||
|
}
|
||||||
|
return (String(parts[0]), chapter, String(parts[2]))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let c = components
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// Checkmark badge
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.green.opacity(0.15))
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
}
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text("Chapter \(c.chapter)")
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text(formatVoice(c.voice))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "waveform")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.accessibilityLabel("Chapter \(c.chapter), voice \(formatVoice(c.voice)), downloaded")
|
||||||
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||||
|
try? downloadService.deleteDownload(
|
||||||
|
slug: c.slug,
|
||||||
|
chapter: c.chapter,
|
||||||
|
voice: c.voice
|
||||||
|
)
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shared voice formatter
|
||||||
|
|
||||||
|
private func formatVoice(_ voice: String) -> String {
|
||||||
|
let parts = voice.split(separator: "_")
|
||||||
|
guard parts.count == 2 else { return voice }
|
||||||
|
let prefix = String(parts[0])
|
||||||
|
let name = String(parts[1]).capitalized
|
||||||
|
let gender = prefix.hasSuffix("f") ? "F" : prefix.hasSuffix("m") ? "M" : ""
|
||||||
|
let accent = prefix.hasPrefix("af") || prefix.hasPrefix("am") ? "US"
|
||||||
|
: prefix.hasPrefix("bf") || prefix.hasPrefix("bm") ? "UK"
|
||||||
|
: ""
|
||||||
|
if !gender.isEmpty && !accent.isEmpty { return "\(name) (\(accent) \(gender))" }
|
||||||
|
if !gender.isEmpty { return "\(name) (\(gender))" }
|
||||||
|
return name
|
||||||
|
}
|
||||||
384
ios/LibNovelV2/Views/Home/HomeView.swift
Normal file
384
ios/LibNovelV2/Views/Home/HomeView.swift
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - HomeView
|
||||||
|
// "Reading Now" tab: stats bar + Continue Reading shelf + Recently Updated shelf
|
||||||
|
// + Subscription Feed shelf + empty state.
|
||||||
|
// Design mirrors the web UI home page (zinc-900 bg, amber accents, horizontal shelves).
|
||||||
|
|
||||||
|
struct HomeView: View {
|
||||||
|
@State private var vm = HomeViewModel()
|
||||||
|
@EnvironmentObject var networkMonitor: NetworkMonitor
|
||||||
|
@EnvironmentObject var authStore: AuthStore
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
OfflineBanner()
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 0) {
|
||||||
|
|
||||||
|
|
||||||
|
// ── Stats bar ───────────────────────────────────────
|
||||||
|
if let stats = vm.stats {
|
||||||
|
StatsBar(stats: stats)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 16)
|
||||||
|
.padding(.bottom, 28)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Continue Reading ────────────────────────────────
|
||||||
|
if !vm.continueReading.isEmpty {
|
||||||
|
ShelfHeader(title: "Continue Reading")
|
||||||
|
horizontalShelf {
|
||||||
|
ForEach(vm.continueReading) { item in
|
||||||
|
NavigationLink(value: NavDestination.chapter(item.book.slug, item.chapter)) {
|
||||||
|
ContinueReadingCard(item: item)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.contextMenu {
|
||||||
|
continueReadingContextMenu(item: item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Recently Updated ────────────────────────────────
|
||||||
|
if !vm.recentlyUpdated.isEmpty {
|
||||||
|
ShelfHeader(title: "Recently Updated")
|
||||||
|
horizontalShelf {
|
||||||
|
ForEach(vm.recentlyUpdated) { book in
|
||||||
|
NavigationLink(value: NavDestination.book(book.slug)) {
|
||||||
|
ShelfBookCard(book: book)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Subscription Feed ───────────────────────────────
|
||||||
|
if !vm.subscriptionFeed.isEmpty {
|
||||||
|
ShelfHeader(title: "From People You Follow")
|
||||||
|
horizontalShelf {
|
||||||
|
ForEach(vm.subscriptionFeed) { item in
|
||||||
|
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
||||||
|
SubscriptionFeedCard(item: item)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Empty state ─────────────────────────────────────
|
||||||
|
if !vm.isLoading &&
|
||||||
|
vm.continueReading.isEmpty &&
|
||||||
|
vm.recentlyUpdated.isEmpty &&
|
||||||
|
vm.subscriptionFeed.isEmpty {
|
||||||
|
EmptyStateView(
|
||||||
|
icon: "books.vertical",
|
||||||
|
title: "Your library is empty",
|
||||||
|
message: "Head to Discover to find novels to read.",
|
||||||
|
ctaLabel: "Discover Novels",
|
||||||
|
ctaAction: nil // tab switching handled externally
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.top, 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loading indicator ───────────────────────────────
|
||||||
|
if vm.isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.top, 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
Color.clear.frame(height: 24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable { await vm.load() }
|
||||||
|
}
|
||||||
|
.navigationTitle("Reading Now")
|
||||||
|
.appNavigationDestination()
|
||||||
|
.task {
|
||||||
|
guard networkMonitor.isConnected else { return }
|
||||||
|
await vm.load()
|
||||||
|
}
|
||||||
|
.errorAlert($vm.error)
|
||||||
|
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: vm.isLoading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Horizontal shelf wrapper
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func horizontalShelf<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
LazyHStack(alignment: .top, spacing: 14) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 28)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Context menu for continue reading cards
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func continueReadingContextMenu(item: ContinueReadingItem) -> some View {
|
||||||
|
let isFinished = item.book.totalChapters > 0 && item.chapter >= item.book.totalChapters
|
||||||
|
|
||||||
|
ShareLink(item: shareURL(for: item.book)) {
|
||||||
|
Label("Share", systemImage: "square.and.arrow.up")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isFinished {
|
||||||
|
Button {
|
||||||
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||||
|
Task { await markAsFinished(item.book) }
|
||||||
|
} label: {
|
||||||
|
Label("Mark as Finished", systemImage: "checkmark.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(role: .destructive) {
|
||||||
|
Task { await removeFromLibrary(item.book.slug) }
|
||||||
|
} label: {
|
||||||
|
Label("Remove from Library", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func markAsFinished(_ book: Book) async {
|
||||||
|
do {
|
||||||
|
try await APIClient.shared.setProgress(slug: book.slug, chapter: book.totalChapters)
|
||||||
|
await vm.load()
|
||||||
|
} catch {
|
||||||
|
vm.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeFromLibrary(_ slug: String) async {
|
||||||
|
do {
|
||||||
|
try await APIClient.shared.deleteProgress(slug: slug)
|
||||||
|
await vm.load()
|
||||||
|
} catch {
|
||||||
|
vm.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shareURL(for book: Book) -> URL {
|
||||||
|
let base = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String
|
||||||
|
?? "https://v2.libnovel.kalekber.cc"
|
||||||
|
return URL(string: "\(base)/books/\(book.slug)")!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stats bar
|
||||||
|
// Three amber-value cards: Books / Chapters / In Progress
|
||||||
|
|
||||||
|
private struct StatsBar: View {
|
||||||
|
let stats: HomeStats
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
StatCard(
|
||||||
|
icon: "books.vertical.fill",
|
||||||
|
value: "\(stats.totalBooks)",
|
||||||
|
label: "Books"
|
||||||
|
)
|
||||||
|
StatCard(
|
||||||
|
icon: "text.alignleft",
|
||||||
|
value: stats.totalChapters.formatted(),
|
||||||
|
label: "Chapters"
|
||||||
|
)
|
||||||
|
StatCard(
|
||||||
|
icon: "bookmark.fill",
|
||||||
|
value: "\(stats.booksInProgress)",
|
||||||
|
label: "In Progress"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct StatCard: View {
|
||||||
|
let icon: String
|
||||||
|
let value: String
|
||||||
|
let label: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 5) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
Text(value)
|
||||||
|
.font(.title3.bold().monospacedDigit())
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Continue Reading card (Apple Books style with progress bar)
|
||||||
|
|
||||||
|
private struct ContinueReadingCard: View {
|
||||||
|
let item: ContinueReadingItem
|
||||||
|
|
||||||
|
private static let cardWidth: CGFloat = 130
|
||||||
|
private static let cardHeight: CGFloat = 188 // 2:3 aspect
|
||||||
|
|
||||||
|
private var progressFraction: Double {
|
||||||
|
guard item.book.totalChapters > 0 else { return 0 }
|
||||||
|
return min(1.0, Double(item.chapter) / Double(item.book.totalChapters))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var progressText: String {
|
||||||
|
let pct = progressFraction * 100
|
||||||
|
if pct > 0 && pct < 10 {
|
||||||
|
return String(format: "%.1f%% complete", pct)
|
||||||
|
}
|
||||||
|
return "\(max(1, Int(round(pct))))% complete"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
// Cover with gradient scrim + chapter badge
|
||||||
|
ZStack(alignment: .bottom) {
|
||||||
|
AsyncCoverImage(url: item.book.cover)
|
||||||
|
.frame(width: Self.cardWidth, height: Self.cardHeight)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
|
.shadow(color: .black.opacity(0.22), radius: 8, y: 4)
|
||||||
|
.bookCoverZoomSource(slug: item.book.slug)
|
||||||
|
|
||||||
|
// Gradient scrim
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.clear, .black.opacity(0.55)],
|
||||||
|
startPoint: .center,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
|
.frame(height: 60)
|
||||||
|
|
||||||
|
// Chapter pill
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "play.fill")
|
||||||
|
.font(.system(size: 8, weight: .bold))
|
||||||
|
Text("Ch.\(item.chapter)")
|
||||||
|
.font(.system(size: 10, weight: .bold))
|
||||||
|
}
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 9)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.background(Capsule().fill(Color.amber))
|
||||||
|
.padding(.bottom, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title
|
||||||
|
Text(item.book.title)
|
||||||
|
.font(.caption.bold())
|
||||||
|
.lineLimit(2)
|
||||||
|
.frame(width: Self.cardWidth, alignment: .leading)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
|
||||||
|
// Progress bar (min 4pt sliver so early chapters are visible)
|
||||||
|
GeometryReader { geo in
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
Capsule().fill(Color.secondary.opacity(0.2))
|
||||||
|
Capsule()
|
||||||
|
.fill(Color.amber.opacity(0.9))
|
||||||
|
.frame(width: max(4, geo.size.width * progressFraction))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: Self.cardWidth, height: 3)
|
||||||
|
|
||||||
|
Text(progressText)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(width: Self.cardWidth)
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel("\(item.book.title), chapter \(item.chapter), \(progressText)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shelf book card (recently updated)
|
||||||
|
|
||||||
|
private struct ShelfBookCard: View {
|
||||||
|
let book: Book
|
||||||
|
private static let cardWidth: CGFloat = 110
|
||||||
|
private static let cardHeight: CGFloat = 158
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
AsyncCoverImage(url: book.cover)
|
||||||
|
.frame(width: Self.cardWidth, height: Self.cardHeight)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||||
|
.shadow(color: .black.opacity(0.12), radius: 4, y: 2)
|
||||||
|
.bookCoverZoomSource(slug: book.slug)
|
||||||
|
|
||||||
|
Text("\(book.totalChapters) ch")
|
||||||
|
.font(.system(size: 9, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(Capsule().fill(Color.black.opacity(0.55)))
|
||||||
|
.padding(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(book.title)
|
||||||
|
.font(.caption.bold())
|
||||||
|
.lineLimit(2)
|
||||||
|
.frame(width: Self.cardWidth, alignment: .leading)
|
||||||
|
|
||||||
|
Text(book.author)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.frame(width: Self.cardWidth, alignment: .leading)
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel("\(book.title) by \(book.author), \(book.totalChapters) chapters")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subscription feed card
|
||||||
|
|
||||||
|
private struct SubscriptionFeedCard: View {
|
||||||
|
let item: SubscriptionFeedItem
|
||||||
|
private static let cardWidth: CGFloat = 110
|
||||||
|
private static let cardHeight: CGFloat = 158
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
AsyncCoverImage(url: item.book.cover)
|
||||||
|
.frame(width: Self.cardWidth, height: Self.cardHeight)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||||
|
.shadow(color: .black.opacity(0.12), radius: 4, y: 2)
|
||||||
|
.bookCoverZoomSource(slug: item.book.slug)
|
||||||
|
|
||||||
|
Text(item.book.title)
|
||||||
|
.font(.caption.bold())
|
||||||
|
.lineLimit(2)
|
||||||
|
.frame(width: Self.cardWidth, alignment: .leading)
|
||||||
|
|
||||||
|
NavigationLink(value: NavDestination.userProfile(item.readerUsername)) {
|
||||||
|
Text("via @\(item.readerUsername)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
.lineLimit(1)
|
||||||
|
.frame(width: Self.cardWidth, alignment: .leading)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityLabel("\(item.book.title), via \(item.readerUsername)")
|
||||||
|
}
|
||||||
|
}
|
||||||
325
ios/LibNovelV2/Views/Library/LibraryView.swift
Normal file
325
ios/LibNovelV2/Views/Library/LibraryView.swift
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - LibraryView
|
||||||
|
// 2-column grid of saved books with progress overlay, genre/sort/reading-status filters.
|
||||||
|
|
||||||
|
struct LibraryView: View {
|
||||||
|
@State private var viewModel = LibraryViewModel()
|
||||||
|
@EnvironmentObject private var networkMonitor: NetworkMonitor
|
||||||
|
|
||||||
|
// Sort sheet
|
||||||
|
@State private var showingSortSheet = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
OfflineBanner()
|
||||||
|
|
||||||
|
// Filter bar
|
||||||
|
filterBar
|
||||||
|
|
||||||
|
if viewModel.isLoading && viewModel.items.isEmpty {
|
||||||
|
loadingState
|
||||||
|
} else if viewModel.filteredItems.isEmpty && !viewModel.isLoading {
|
||||||
|
emptyState
|
||||||
|
} else {
|
||||||
|
bookGrid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1)))
|
||||||
|
.navigationTitle("Library")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.toolbar { toolbarContent }
|
||||||
|
.appNavigationDestination()
|
||||||
|
.task {
|
||||||
|
guard networkMonitor.isConnected else { return }
|
||||||
|
await viewModel.load()
|
||||||
|
}
|
||||||
|
.refreshable { await viewModel.load() }
|
||||||
|
.errorAlert($viewModel.error)
|
||||||
|
.confirmationDialog("Sort By", isPresented: $showingSortSheet, titleVisibility: .visible) {
|
||||||
|
ForEach(LibrarySortOrder.allCases, id: \.self) { order in
|
||||||
|
Button(order.rawValue) {
|
||||||
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
||||||
|
viewModel.sortOrder = order
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Filter bar
|
||||||
|
|
||||||
|
private var filterBar: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Reading filter chips
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(LibraryReadingFilter.allCases, id: \.self) { filter in
|
||||||
|
ChipButton(label: filter.rawValue,
|
||||||
|
isSelected: viewModel.readingFilter == filter,
|
||||||
|
style: .filled) {
|
||||||
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
||||||
|
viewModel.readingFilter = filter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Genre chips (only show if there are genres)
|
||||||
|
if viewModel.allGenres.count > 1 {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(viewModel.allGenres, id: \.self) { genre in
|
||||||
|
ChipButton(label: genre,
|
||||||
|
isSelected: viewModel.selectedGenre == genre,
|
||||||
|
style: .outlined) {
|
||||||
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
||||||
|
viewModel.selectedGenre = genre
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Book grid
|
||||||
|
|
||||||
|
private let columns = [
|
||||||
|
GridItem(.flexible(), spacing: 12),
|
||||||
|
GridItem(.flexible(), spacing: 12)
|
||||||
|
]
|
||||||
|
|
||||||
|
private var bookGrid: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVGrid(columns: columns, spacing: 16) {
|
||||||
|
ForEach(viewModel.filteredItems) { item in
|
||||||
|
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
||||||
|
LibraryBookCard(
|
||||||
|
item: item,
|
||||||
|
progress: viewModel.progressFraction(for: item),
|
||||||
|
progressLabel: viewModel.progressPercent(for: item),
|
||||||
|
isCompleted: viewModel.isCompleted(for: item),
|
||||||
|
lastChapter: viewModel.lastChapter(for: item)
|
||||||
|
)
|
||||||
|
.bookCoverZoomSource(slug: item.book.slug)
|
||||||
|
.contextMenu {
|
||||||
|
contextMenu(for: item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 16)
|
||||||
|
.padding(.bottom, 120) // clear mini player
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Context menu
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func contextMenu(for item: LibraryItem) -> some View {
|
||||||
|
Button {
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
// Share: nothing to share without a URL from API, placeholder
|
||||||
|
} label: {
|
||||||
|
Label("Share", systemImage: "square.and.arrow.up")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !viewModel.isCompleted(for: item) {
|
||||||
|
Button {
|
||||||
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||||
|
Task { await viewModel.markFinished(item: item) }
|
||||||
|
} label: {
|
||||||
|
Label("Mark as Finished", systemImage: "checkmark.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(role: .destructive) {
|
||||||
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||||
|
Task { await viewModel.removeFromLibrary(slug: item.book.slug) }
|
||||||
|
} label: {
|
||||||
|
Label("Remove from Library", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Toolbar
|
||||||
|
|
||||||
|
@ToolbarContentBuilder
|
||||||
|
private var toolbarContent: some ToolbarContent {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
showingSortSheet = true
|
||||||
|
} label: {
|
||||||
|
Label("Sort", systemImage: "arrow.up.arrow.down")
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Sort library")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Loading state
|
||||||
|
|
||||||
|
private var loadingState: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVGrid(columns: columns, spacing: 16) {
|
||||||
|
ForEach(0..<8, id: \.self) { _ in
|
||||||
|
LibraryBookCardSkeleton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Empty state
|
||||||
|
|
||||||
|
private var emptyState: some View {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
EmptyStateView(
|
||||||
|
icon: "books.vertical",
|
||||||
|
title: viewModel.items.isEmpty ? "Your library is empty" : "No books match",
|
||||||
|
message: viewModel.items.isEmpty
|
||||||
|
? "Browse and save books to build your collection."
|
||||||
|
: "Try a different filter or genre.",
|
||||||
|
ctaLabel: viewModel.items.isEmpty ? "Browse Books" : nil,
|
||||||
|
ctaAction: nil
|
||||||
|
)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - LibraryBookCard
|
||||||
|
|
||||||
|
struct LibraryBookCard: View {
|
||||||
|
let item: LibraryItem
|
||||||
|
let progress: Double // 0…1
|
||||||
|
let progressLabel: String // "47%" or "3.4%"
|
||||||
|
let isCompleted: Bool
|
||||||
|
let lastChapter: Int
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
// Cover with progress arc overlay
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
AsyncCoverImage(url: item.book.cover)
|
||||||
|
.aspectRatio(2/3, contentMode: .fill)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
|
||||||
|
if isCompleted {
|
||||||
|
completedBadge
|
||||||
|
} else if progress > 0 {
|
||||||
|
progressArcBadge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title
|
||||||
|
Text(item.book.title)
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.lineLimit(2)
|
||||||
|
|
||||||
|
// Chapter subtitle
|
||||||
|
if lastChapter > 0 {
|
||||||
|
Text(isCompleted ? "Completed" : "Ch. \(lastChapter)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(isCompleted ? Color.amber : .secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Completed badge
|
||||||
|
|
||||||
|
private var completedBadge: some View {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
.padding(6)
|
||||||
|
.background(.regularMaterial, in: Circle())
|
||||||
|
.padding(6)
|
||||||
|
.accessibilityLabel("Completed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Progress arc
|
||||||
|
|
||||||
|
private var progressArcBadge: some View {
|
||||||
|
ZStack {
|
||||||
|
// Track
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.white.opacity(0.25), lineWidth: 3)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
|
||||||
|
// Fill
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: progress)
|
||||||
|
.stroke(Color.amber, style: StrokeStyle(lineWidth: 3, lineCap: .round))
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.animation(.spring(response: 0.5, dampingFraction: 0.7), value: progress)
|
||||||
|
|
||||||
|
Text(progressLabel)
|
||||||
|
.font(.system(size: 7, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
.padding(6)
|
||||||
|
.background(.ultraThinMaterial, in: Circle())
|
||||||
|
.padding(6)
|
||||||
|
.accessibilityLabel("Progress: \(progressLabel)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - LibraryBookCardSkeleton
|
||||||
|
// Shimmer placeholder used while data is loading.
|
||||||
|
|
||||||
|
struct LibraryBookCardSkeleton: View {
|
||||||
|
@State private var phase: Double = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
|
.fill(shimmerGradient)
|
||||||
|
.aspectRatio(2/3, contentMode: .fill)
|
||||||
|
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(shimmerGradient)
|
||||||
|
.frame(height: 12)
|
||||||
|
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(shimmerGradient)
|
||||||
|
.frame(width: 60, height: 10)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: true)) {
|
||||||
|
phase = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shimmerGradient: LinearGradient {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(uiColor: UIColor(red: 0.15, green: 0.15, blue: 0.17, alpha: 1)),
|
||||||
|
Color(uiColor: UIColor(red: 0.22, green: 0.22, blue: 0.25, alpha: 1)),
|
||||||
|
Color(uiColor: UIColor(red: 0.15, green: 0.15, blue: 0.17, alpha: 1))
|
||||||
|
],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
1826
ios/LibNovelV2/Views/Player/PlayerViews.swift
Normal file
1826
ios/LibNovelV2/Views/Player/PlayerViews.swift
Normal file
File diff suppressed because it is too large
Load Diff
702
ios/LibNovelV2/Views/Profile/ProfileView.swift
Normal file
702
ios/LibNovelV2/Views/Profile/ProfileView.swift
Normal file
@@ -0,0 +1,702 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import PhotosUI
|
||||||
|
|
||||||
|
// MARK: - ProfileViewModel
|
||||||
|
// Loads and manages active sessions. Uses @Observable (iOS 17+).
|
||||||
|
|
||||||
|
@Observable @MainActor
|
||||||
|
final class ProfileViewModel {
|
||||||
|
var sessions: [UserSession] = []
|
||||||
|
var sessionsLoading = false
|
||||||
|
var error: String?
|
||||||
|
|
||||||
|
func loadSessions() async {
|
||||||
|
sessionsLoading = true
|
||||||
|
error = nil
|
||||||
|
do {
|
||||||
|
sessions = try await APIClient.shared.sessions()
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
sessionsLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func revokeSession(id: String) async {
|
||||||
|
do {
|
||||||
|
try await APIClient.shared.revokeSession(id: id)
|
||||||
|
sessions.removeAll { $0.id == id }
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ProfileView
|
||||||
|
// Full-screen profile/account management tab.
|
||||||
|
|
||||||
|
struct ProfileView: View {
|
||||||
|
@EnvironmentObject private var authStore: AuthStore
|
||||||
|
@EnvironmentObject private var networkMonitor: NetworkMonitor
|
||||||
|
@State private var vm = ProfileViewModel()
|
||||||
|
|
||||||
|
@State private var showChangePassword = false
|
||||||
|
@State private var showVoiceSelection = false
|
||||||
|
@State private var showDownloads = false
|
||||||
|
|
||||||
|
// Avatar upload
|
||||||
|
@State private var photoPickerItem: PhotosPickerItem?
|
||||||
|
@State private var pendingCropImage: UIImage?
|
||||||
|
@State private var localAvatarURL: String?
|
||||||
|
@State private var avatarUploading = false
|
||||||
|
@State private var avatarError: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
OfflineBanner()
|
||||||
|
|
||||||
|
List {
|
||||||
|
// ── User header ─────────────────────────────────────────
|
||||||
|
Section {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
avatarPickerView
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(authStore.user?.username ?? "")
|
||||||
|
.font(.headline)
|
||||||
|
Text(authStore.user?.role.capitalized ?? "")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if let err = avatarError {
|
||||||
|
Text(err)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reading settings ─────────────────────────────────────
|
||||||
|
Section("Reading Settings") {
|
||||||
|
// Voice picker row — opens VoiceSelectionView sheet
|
||||||
|
Button {
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
showVoiceSelection = true
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("TTS Voice")
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
Spacer()
|
||||||
|
Text(formatVoiceLabel(authStore.settings.voice))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityLabel("TTS Voice: \(formatVoiceLabel(authStore.settings.voice)). Tap to change.")
|
||||||
|
|
||||||
|
// Speed slider
|
||||||
|
speedSliderRow
|
||||||
|
|
||||||
|
// Auto-advance toggle
|
||||||
|
Toggle("Auto-advance chapter", isOn: Binding(
|
||||||
|
get: { authStore.settings.autoNext },
|
||||||
|
set: { newVal in
|
||||||
|
Task {
|
||||||
|
var s = authStore.settings
|
||||||
|
s.autoNext = newVal
|
||||||
|
await authStore.saveSettings(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
))
|
||||||
|
.tint(Color.amber)
|
||||||
|
|
||||||
|
// Downloads row
|
||||||
|
Button {
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
showDownloads = true
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("Downloads")
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Active sessions ──────────────────────────────────────
|
||||||
|
Section("Active Sessions") {
|
||||||
|
if vm.sessionsLoading {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
ProgressView()
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
} else if vm.sessions.isEmpty {
|
||||||
|
Text("No sessions found")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(vm.sessions) { session in
|
||||||
|
SessionRow(session: session) {
|
||||||
|
Task { await vm.revokeSession(id: session.id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Account ───────────────────────────────────────────────
|
||||||
|
Section("Account") {
|
||||||
|
Button("Change Password") {
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
showChangePassword = true
|
||||||
|
}
|
||||||
|
Button("Sign Out", role: .destructive) {
|
||||||
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||||
|
Task { await authStore.logout() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
}
|
||||||
|
.background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1)))
|
||||||
|
.navigationTitle("Profile")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.task {
|
||||||
|
guard networkMonitor.isConnected else { return }
|
||||||
|
await vm.loadSessions()
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showChangePassword) {
|
||||||
|
ChangePasswordView()
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showVoiceSelection) {
|
||||||
|
VoiceSelectionView(currentVoice: authStore.settings.voice)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showDownloads) {
|
||||||
|
DownloadsView()
|
||||||
|
}
|
||||||
|
.sheet(item: Binding(
|
||||||
|
get: { pendingCropImage.map { CropImageItem(image: $0) } },
|
||||||
|
set: { if $0 == nil { pendingCropImage = nil } }
|
||||||
|
)) { item in
|
||||||
|
AvatarCropView(image: item.image) { croppedData in
|
||||||
|
pendingCropImage = nil
|
||||||
|
Task { await uploadCroppedData(croppedData) }
|
||||||
|
} onCancel: {
|
||||||
|
pendingCropImage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.errorAlert(Binding(
|
||||||
|
get: { vm.error },
|
||||||
|
set: { vm.error = $0 }
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Avatar upload
|
||||||
|
|
||||||
|
private func loadImageForCrop(_ item: PhotosPickerItem) async {
|
||||||
|
guard let data = try? await item.loadTransferable(type: Data.self),
|
||||||
|
let image = UIImage(data: data) else {
|
||||||
|
avatarError = "Could not read image"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingCropImage = image
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uploadCroppedData(_ data: Data) async {
|
||||||
|
avatarUploading = true
|
||||||
|
avatarError = nil
|
||||||
|
defer { avatarUploading = false }
|
||||||
|
do {
|
||||||
|
let url = try await APIClient.shared.uploadAvatar(data, mimeType: "image/jpeg")
|
||||||
|
localAvatarURL = url
|
||||||
|
await authStore.validateToken()
|
||||||
|
} catch {
|
||||||
|
avatarError = "Upload failed: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Avatar picker view
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var avatarPickerView: some View {
|
||||||
|
PhotosPicker(selection: $photoPickerItem,
|
||||||
|
matching: .images,
|
||||||
|
photoLibrary: .shared()) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color(uiColor: .systemGray5))
|
||||||
|
.frame(width: 72, height: 72)
|
||||||
|
|
||||||
|
if avatarUploading {
|
||||||
|
ProgressView()
|
||||||
|
.frame(width: 72, height: 72)
|
||||||
|
} else {
|
||||||
|
let urlStr = localAvatarURL ?? authStore.user?.avatarURL
|
||||||
|
if let urlStr, !urlStr.isEmpty {
|
||||||
|
AsyncImage(url: URL(string: urlStr)) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .success(let img):
|
||||||
|
img.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(width: 72, height: 72)
|
||||||
|
.clipShape(Circle())
|
||||||
|
default:
|
||||||
|
Image(systemName: "person.circle.fill")
|
||||||
|
.font(.system(size: 52))
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
.frame(width: 72, height: 72)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Image(systemName: "person.circle.fill")
|
||||||
|
.font(.system(size: 52))
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
.frame(width: 72, height: 72)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camera badge
|
||||||
|
if !avatarUploading {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.amber)
|
||||||
|
.frame(width: 22, height: 22)
|
||||||
|
Image(systemName: "camera.fill")
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundStyle(.black)
|
||||||
|
}
|
||||||
|
.offset(x: 2, y: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 72, height: 72)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("Change avatar photo")
|
||||||
|
.onChange(of: photoPickerItem) { _, item in
|
||||||
|
guard let item else { return }
|
||||||
|
Task { await loadImageForCrop(item) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Speed slider row
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var speedSliderRow: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Text("Playback Speed")
|
||||||
|
Spacer()
|
||||||
|
Text("\(authStore.settings.speed, specifier: "%.2g")×")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
Slider(
|
||||||
|
value: Binding(
|
||||||
|
get: { authStore.settings.speed },
|
||||||
|
set: { newSpeed in
|
||||||
|
Task {
|
||||||
|
var s = authStore.settings
|
||||||
|
s.speed = newSpeed
|
||||||
|
await authStore.saveSettings(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
in: 0.5...2.0, step: 0.25
|
||||||
|
)
|
||||||
|
.tint(Color.amber)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func formatVoiceLabel(_ voice: String) -> String {
|
||||||
|
let parts = voice.split(separator: "_")
|
||||||
|
guard parts.count >= 2 else { return voice }
|
||||||
|
return parts.dropFirst().map { $0.capitalized }.joined(separator: " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SessionRow
|
||||||
|
|
||||||
|
private struct SessionRow: View {
|
||||||
|
let session: UserSession
|
||||||
|
let onRevoke: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "iphone")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
Text(session.userAgent.isEmpty ? "Unknown device" : session.userAgent)
|
||||||
|
.font(.subheadline)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
if session.isCurrent {
|
||||||
|
Text("This device")
|
||||||
|
.font(.caption2.bold())
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color.amber.opacity(0.12), in: Capsule())
|
||||||
|
} else {
|
||||||
|
Button("Revoke", role: .destructive, action: onRevoke)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("Last seen \(session.lastSeen.prefix(10))")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CropImageItem
|
||||||
|
|
||||||
|
private struct CropImageItem: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let image: UIImage
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ChangePasswordView
|
||||||
|
|
||||||
|
struct ChangePasswordView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@EnvironmentObject private var authStore: AuthStore
|
||||||
|
|
||||||
|
@State private var current = ""
|
||||||
|
@State private var newPwd = ""
|
||||||
|
@State private var confirm = ""
|
||||||
|
@State private var isLoading = false
|
||||||
|
@State private var error: String?
|
||||||
|
@State private var success = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
SecureField("Current password", text: $current)
|
||||||
|
SecureField("New password", text: $newPwd)
|
||||||
|
SecureField("Confirm new password", text: $confirm)
|
||||||
|
}
|
||||||
|
if let error {
|
||||||
|
Section {
|
||||||
|
Text(error)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if success {
|
||||||
|
Section {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
Text("Password changed successfully")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Change Password")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
if isLoading {
|
||||||
|
ProgressView()
|
||||||
|
} else {
|
||||||
|
Button("Save") { save() }
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
.disabled(current.isEmpty || newPwd.count < 4 || newPwd != confirm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
guard newPwd == confirm else { error = "Passwords do not match"; return }
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
struct Body: Encodable { let currentPassword, newPassword: String }
|
||||||
|
let _: EmptyResponse = try await APIClient.shared.fetch(
|
||||||
|
"/api/auth/change-password", method: "POST",
|
||||||
|
body: Body(currentPassword: current, newPassword: newPwd)
|
||||||
|
)
|
||||||
|
success = true
|
||||||
|
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
||||||
|
dismiss()
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AvatarToolbarButton
|
||||||
|
// Drop-in toolbar button showing the user's avatar. Opens the profile tab or an account sheet.
|
||||||
|
|
||||||
|
struct AvatarToolbarButton: View {
|
||||||
|
@EnvironmentObject private var authStore: AuthStore
|
||||||
|
@State private var showAccount = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
showAccount = true
|
||||||
|
} label: {
|
||||||
|
AvatarThumb(urlString: authStore.user?.avatarURL, size: 30)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Account")
|
||||||
|
.sheet(isPresented: $showAccount) {
|
||||||
|
ProfileView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AvatarThumb
|
||||||
|
// Small circular avatar used in toolbars and list headers.
|
||||||
|
|
||||||
|
struct AvatarThumb: View {
|
||||||
|
let urlString: String?
|
||||||
|
let size: CGFloat
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let str = urlString, let url = URL(string: str) {
|
||||||
|
AsyncImage(url: url) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .success(let img):
|
||||||
|
img.resizable().scaledToFill()
|
||||||
|
default:
|
||||||
|
placeholderFill
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
placeholderFill
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.clipShape(Circle())
|
||||||
|
.overlay(Circle().stroke(Color.amber.opacity(0.6), lineWidth: 1.5))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var placeholderFill: some View {
|
||||||
|
Circle()
|
||||||
|
.fill(Color(uiColor: .systemGray4))
|
||||||
|
.overlay(
|
||||||
|
Image(systemName: "person.fill")
|
||||||
|
.font(.system(size: size * 0.5))
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AvatarCropView
|
||||||
|
// Sheet that lets the user pan and pinch a photo to fill a 1:1 circular crop region.
|
||||||
|
|
||||||
|
struct AvatarCropView: View {
|
||||||
|
let image: UIImage
|
||||||
|
let onConfirm: (Data) -> Void
|
||||||
|
let onCancel: () -> Void
|
||||||
|
|
||||||
|
private let cropSize: CGFloat = 280
|
||||||
|
|
||||||
|
@State private var scale: CGFloat = 1.0
|
||||||
|
@State private var lastScale: CGFloat = 1.0
|
||||||
|
@State private var offset: CGSize = .zero
|
||||||
|
@State private var lastOffset: CGSize = .zero
|
||||||
|
@State private var containerSize: CGSize = .zero
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
GeometryReader { geo in
|
||||||
|
ZStack {
|
||||||
|
Color.black.ignoresSafeArea()
|
||||||
|
|
||||||
|
Image(uiImage: image)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(width: geo.size.width, height: geo.size.height)
|
||||||
|
.scaleEffect(scale, anchor: .center)
|
||||||
|
.offset(offset)
|
||||||
|
.gesture(
|
||||||
|
SimultaneousGesture(
|
||||||
|
MagnificationGesture()
|
||||||
|
.onChanged { value in
|
||||||
|
let proposed = lastScale * value
|
||||||
|
scale = max(1.0, proposed)
|
||||||
|
}
|
||||||
|
.onEnded { _ in
|
||||||
|
lastScale = scale
|
||||||
|
offset = clampedOffset(offset, in: geo.size)
|
||||||
|
lastOffset = offset
|
||||||
|
},
|
||||||
|
DragGesture()
|
||||||
|
.onChanged { value in
|
||||||
|
let proposed = CGSize(
|
||||||
|
width: lastOffset.width + value.translation.width,
|
||||||
|
height: lastOffset.height + value.translation.height
|
||||||
|
)
|
||||||
|
offset = clampedOffset(proposed, in: geo.size)
|
||||||
|
}
|
||||||
|
.onEnded { _ in lastOffset = offset }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.clipped()
|
||||||
|
|
||||||
|
CropOverlay(cropSize: cropSize, containerSize: geo.size)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
containerSize = geo.size
|
||||||
|
scale = 1.0; lastScale = 1.0
|
||||||
|
offset = .zero; lastOffset = .zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Crop Photo")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("Cancel", action: onCancel)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Use Photo") { confirmCrop() }
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Clamp helpers
|
||||||
|
|
||||||
|
private func displayedImageSize(in containerSize: CGSize, userScale: CGFloat) -> CGSize {
|
||||||
|
let imgAspect = image.size.width / image.size.height
|
||||||
|
let conAspect = containerSize.width / containerSize.height
|
||||||
|
let baseW: CGFloat
|
||||||
|
let baseH: CGFloat
|
||||||
|
if imgAspect > conAspect {
|
||||||
|
baseH = containerSize.height; baseW = baseH * imgAspect
|
||||||
|
} else {
|
||||||
|
baseW = containerSize.width; baseH = baseW / imgAspect
|
||||||
|
}
|
||||||
|
return CGSize(width: baseW * userScale, height: baseH * userScale)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clampedOffset(_ proposed: CGSize, in containerSize: CGSize) -> CGSize {
|
||||||
|
let displayed = displayedImageSize(in: containerSize, userScale: scale)
|
||||||
|
let maxX = max(0, (displayed.width - cropSize) / 2)
|
||||||
|
let maxY = max(0, (displayed.height - cropSize) / 2)
|
||||||
|
return CGSize(
|
||||||
|
width: min(maxX, max(-maxX, proposed.width)),
|
||||||
|
height: min(maxY, max(-maxY, proposed.height))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Confirm crop
|
||||||
|
|
||||||
|
private func confirmCrop() {
|
||||||
|
let size = containerSize.width > 0 ? containerSize : CGSize(width: 390, height: 844)
|
||||||
|
let outputSize = CGSize(width: 400, height: 400)
|
||||||
|
|
||||||
|
let imgAspect = image.size.width / image.size.height
|
||||||
|
let conAspect = size.width / size.height
|
||||||
|
let baseDisplayW: CGFloat
|
||||||
|
let baseDisplayH: CGFloat
|
||||||
|
if imgAspect > conAspect {
|
||||||
|
baseDisplayH = size.height; baseDisplayW = baseDisplayH * imgAspect
|
||||||
|
} else {
|
||||||
|
baseDisplayW = size.width; baseDisplayH = baseDisplayW / imgAspect
|
||||||
|
}
|
||||||
|
let displayW = baseDisplayW * scale
|
||||||
|
let displayH = baseDisplayH * scale
|
||||||
|
|
||||||
|
let imageCentreX = size.width / 2 + offset.width
|
||||||
|
let imageCentreY = size.height / 2 + offset.height
|
||||||
|
let cropOriginX = (size.width - cropSize) / 2
|
||||||
|
let cropOriginY = (size.height - cropSize) / 2
|
||||||
|
let imageOriginX = imageCentreX - displayW / 2
|
||||||
|
let imageOriginY = imageCentreY - displayH / 2
|
||||||
|
let cropInImageX = cropOriginX - imageOriginX
|
||||||
|
let cropInImageY = cropOriginY - imageOriginY
|
||||||
|
|
||||||
|
let dtpX = image.size.width / displayW
|
||||||
|
let dtpY = image.size.height / displayH
|
||||||
|
let cropRect = CGRect(
|
||||||
|
x: cropInImageX * dtpX, y: cropInImageY * dtpY,
|
||||||
|
width: cropSize * dtpX, height: cropSize * dtpY
|
||||||
|
).intersection(CGRect(origin: .zero, size: image.size))
|
||||||
|
|
||||||
|
guard cropRect.width > 0, cropRect.height > 0 else {
|
||||||
|
if let jpeg = image.jpegData(compressionQuality: 0.9) { onConfirm(jpeg) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: outputSize)
|
||||||
|
let cropped = renderer.image { _ in
|
||||||
|
if let cgImg = image.cgImage?.cropping(to: cropRect) {
|
||||||
|
UIImage(cgImage: cgImg, scale: image.scale,
|
||||||
|
orientation: image.imageOrientation)
|
||||||
|
.draw(in: CGRect(origin: .zero, size: outputSize))
|
||||||
|
} else {
|
||||||
|
image.draw(in: CGRect(origin: .zero, size: outputSize))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let jpeg = cropped.jpegData(compressionQuality: 0.9) { onConfirm(jpeg) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CropOverlay (internal)
|
||||||
|
|
||||||
|
private struct CropOverlay: View {
|
||||||
|
let cropSize: CGFloat
|
||||||
|
let containerSize: CGSize
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Canvas { context, size in
|
||||||
|
context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(.black.opacity(0.55)))
|
||||||
|
let origin = CGPoint(x: (size.width - cropSize) / 2, y: (size.height - cropSize) / 2)
|
||||||
|
let rect = CGRect(origin: origin, size: CGSize(width: cropSize, height: cropSize))
|
||||||
|
context.blendMode = .destinationOut
|
||||||
|
context.fill(Path(ellipseIn: rect), with: .color(.white))
|
||||||
|
}
|
||||||
|
.compositingGroup()
|
||||||
|
.overlay {
|
||||||
|
let ox = (containerSize.width - cropSize) / 2
|
||||||
|
let oy = (containerSize.height - cropSize) / 2
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.amber.opacity(0.8), lineWidth: 2)
|
||||||
|
.frame(width: cropSize, height: cropSize)
|
||||||
|
.position(x: ox + cropSize / 2, y: oy + cropSize / 2)
|
||||||
|
}
|
||||||
|
.frame(width: containerSize.width, height: containerSize.height)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
13
ios/LibNovelV2/Views/Profile/UserProfileView.swift
Normal file
13
ios/LibNovelV2/Views/Profile/UserProfileView.swift
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// Public user profile — shown when navigating to another user's page.
|
||||||
|
// Displays their public library and follower info.
|
||||||
|
// NOTE: This is distinct from ProfileView (self-account management tab).
|
||||||
|
struct UserProfileView: View {
|
||||||
|
let username: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(username)
|
||||||
|
.navigationTitle(username)
|
||||||
|
}
|
||||||
|
}
|
||||||
189
ios/LibNovelV2/Views/Profile/VoiceSelectionView.swift
Normal file
189
ios/LibNovelV2/Views/Profile/VoiceSelectionView.swift
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - VoiceSelectionView
|
||||||
|
// Sheet for selecting TTS voice. Loads voices from the API, plays sample audio, and
|
||||||
|
// saves the selection back to user settings on confirm.
|
||||||
|
// VoiceSelectionViewModel is defined in PlayerViews.swift (shared with the full player).
|
||||||
|
|
||||||
|
struct VoiceSelectionView: View {
|
||||||
|
@EnvironmentObject private var authStore: AuthStore
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var selectedVoice: String
|
||||||
|
@State private var vm = VoiceSelectionViewModel()
|
||||||
|
|
||||||
|
init(currentVoice: String) {
|
||||||
|
_selectedVoice = State(initialValue: currentVoice)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if vm.isLoading {
|
||||||
|
loadingState
|
||||||
|
} else if let error = vm.error {
|
||||||
|
errorState(error)
|
||||||
|
} else {
|
||||||
|
voiceList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1)))
|
||||||
|
.navigationTitle("Select Voice")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") {
|
||||||
|
vm.stopSample()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Done") { saveAndDismiss() }
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
.disabled(selectedVoice == authStore.settings.voice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await vm.loadVoices() }
|
||||||
|
.onDisappear { vm.stopSample() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - States
|
||||||
|
|
||||||
|
private var loadingState: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(1.3)
|
||||||
|
Text("Loading voices…")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func errorState(_ message: String) -> some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "exclamationmark.triangle")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
.symbolEffect(.pulse)
|
||||||
|
Text(message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
Button("Retry") { Task { await vm.loadVoices() } }
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Voice list
|
||||||
|
|
||||||
|
private var voiceList: some View {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
ForEach(vm.voices, id: \.self) { voice in
|
||||||
|
VoiceSelectionRow(
|
||||||
|
voice: voice,
|
||||||
|
isSelected: voice == selectedVoice,
|
||||||
|
isPlaying: vm.playingVoice == voice,
|
||||||
|
voiceLabel: vm.voiceLabel(voice),
|
||||||
|
voiceId: vm.voiceId(voice),
|
||||||
|
onSelect: {
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
vm.stopSample()
|
||||||
|
selectedVoice = voice
|
||||||
|
},
|
||||||
|
onPlaySample: {
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
Task { await vm.playSample(voice) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Available Voices")
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textCase(nil)
|
||||||
|
} footer: {
|
||||||
|
if selectedVoice != authStore.settings.voice {
|
||||||
|
Text("New voice will apply to the next audio playback.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Save
|
||||||
|
|
||||||
|
private func saveAndDismiss() {
|
||||||
|
vm.stopSample()
|
||||||
|
Task {
|
||||||
|
var s = authStore.settings
|
||||||
|
s.voice = selectedVoice
|
||||||
|
await authStore.saveSettings(s)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - VoiceSelectionRow
|
||||||
|
|
||||||
|
private struct VoiceSelectionRow: View {
|
||||||
|
let voice: String
|
||||||
|
let isSelected: Bool
|
||||||
|
let isPlaying: Bool
|
||||||
|
let voiceLabel: String
|
||||||
|
let voiceId: String
|
||||||
|
let onSelect: () -> Void
|
||||||
|
let onPlaySample: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// Selection indicator
|
||||||
|
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
||||||
|
.font(.system(size: 22))
|
||||||
|
.foregroundStyle(isSelected ? Color.amber : Color.secondary.opacity(0.4))
|
||||||
|
.frame(width: 28)
|
||||||
|
.contentTransition(.symbolEffect(.replace.downUp))
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
|
||||||
|
// Voice name + id
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(voiceLabel)
|
||||||
|
.font(.body)
|
||||||
|
.fontWeight(isSelected ? .semibold : .regular)
|
||||||
|
Text(voiceId)
|
||||||
|
.font(.caption)
|
||||||
|
.fontDesign(.monospaced)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Play sample button
|
||||||
|
Button {
|
||||||
|
onPlaySample()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: isPlaying ? "stop.circle.fill" : "play.circle.fill")
|
||||||
|
.font(.system(size: 28))
|
||||||
|
.foregroundStyle(isPlaying ? Color.red : Color.amber)
|
||||||
|
.contentTransition(.symbolEffect(.replace.downUp))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.frame(minWidth: 44, minHeight: 44)
|
||||||
|
.accessibilityLabel(isPlaying ? "Stop sample for \(voiceLabel)" : "Play sample for \(voiceLabel)")
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { onSelect() }
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
|
||||||
|
}
|
||||||
|
}
|
||||||
255
ios/LibNovelV2/Views/Search/SearchView.swift
Normal file
255
ios/LibNovelV2/Views/Search/SearchView.swift
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - SearchView
|
||||||
|
// Full-screen search tab.
|
||||||
|
// Idle: recent searches list (or prompt if empty).
|
||||||
|
// Active: debounced live results in a 2-col grid with local/remote count header.
|
||||||
|
|
||||||
|
struct SearchView: View {
|
||||||
|
@State private var vm = SearchViewModel()
|
||||||
|
@EnvironmentObject private var networkMonitor: NetworkMonitor
|
||||||
|
|
||||||
|
private let columns = [
|
||||||
|
GridItem(.flexible(), spacing: 14),
|
||||||
|
GridItem(.flexible(), spacing: 14),
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
OfflineBanner()
|
||||||
|
|
||||||
|
Group {
|
||||||
|
if vm.isLoading {
|
||||||
|
loadingState
|
||||||
|
} else if !vm.query.isEmpty && vm.results.isEmpty {
|
||||||
|
emptyResultsState
|
||||||
|
} else if !vm.results.isEmpty {
|
||||||
|
resultsGrid
|
||||||
|
} else {
|
||||||
|
idleState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
.appNavigationDestination()
|
||||||
|
.background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1)))
|
||||||
|
.navigationTitle("Search")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.searchable(
|
||||||
|
text: $vm.query,
|
||||||
|
placement: .navigationBarDrawer(displayMode: .always),
|
||||||
|
prompt: "Search novels, authors…"
|
||||||
|
)
|
||||||
|
.onChange(of: vm.query) { _, newValue in
|
||||||
|
guard networkMonitor.isConnected else { return }
|
||||||
|
vm.onQueryChange(newValue)
|
||||||
|
}
|
||||||
|
.onSubmit(of: .search) {
|
||||||
|
guard networkMonitor.isConnected else { return }
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
vm.submitSearch()
|
||||||
|
}
|
||||||
|
.errorAlert($vm.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Idle state
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var idleState: some View {
|
||||||
|
if vm.recentSearches.isEmpty {
|
||||||
|
emptyIdleState
|
||||||
|
} else {
|
||||||
|
recentSearchesList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyIdleState: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
Text("Search for novels")
|
||||||
|
.font(.title3.bold())
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
Text("Find books by title, author, or genre")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 40)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var recentSearchesList: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
Text("Recent Searches")
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
vm.clearRecent()
|
||||||
|
} label: {
|
||||||
|
Text("Clear")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
}
|
||||||
|
.frame(minWidth: 44, minHeight: 44)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 12)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
|
ForEach(vm.recentSearches, id: \.self) { term in
|
||||||
|
Button {
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
vm.selectRecent(term)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "clock")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.frame(width: 24)
|
||||||
|
Text(term)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "arrow.up.left")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.frame(minHeight: 44)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.padding(.leading, 52)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color.clear.frame(height: 120)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Loading state
|
||||||
|
|
||||||
|
private var loadingState: some View {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
ProgressView()
|
||||||
|
.tint(Color.amber)
|
||||||
|
.scaleEffect(1.4)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Empty results state
|
||||||
|
|
||||||
|
private var emptyResultsState: some View {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
EmptyStateView(
|
||||||
|
icon: "magnifyingglass",
|
||||||
|
title: "No results",
|
||||||
|
message: "Nothing matched \"\(vm.query)\". Try a different term."
|
||||||
|
)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Results grid
|
||||||
|
|
||||||
|
private var resultsGrid: some View {
|
||||||
|
ScrollView {
|
||||||
|
// Count header
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text("\(vm.results.count) results")
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
|
||||||
|
if vm.localCount > 0 || vm.remoteCount > 0 {
|
||||||
|
Text("·")
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
if vm.localCount > 0 {
|
||||||
|
Text("\(vm.localCount) in library")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.amber)
|
||||||
|
}
|
||||||
|
if vm.localCount > 0 && vm.remoteCount > 0 {
|
||||||
|
Text("+")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
if vm.remoteCount > 0 {
|
||||||
|
Text("\(vm.remoteCount) online")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 12)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
|
LazyVGrid(columns: columns, spacing: 14) {
|
||||||
|
ForEach(vm.results) { novel in
|
||||||
|
NavigationLink(value: NavDestination.book(novel.slug)) {
|
||||||
|
SearchNovelCard(novel: novel)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 4)
|
||||||
|
|
||||||
|
Color.clear.frame(height: 120)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SearchNovelCard
|
||||||
|
|
||||||
|
private struct SearchNovelCard: View {
|
||||||
|
let novel: BrowseNovel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
AsyncCoverImage(url: novel.cover)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.aspectRatio(2/3, contentMode: .fit)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
|
.bookCoverZoomSource(slug: novel.slug)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(novel.title)
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.lineLimit(2)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
if !novel.author.isEmpty {
|
||||||
|
Text(novel.author)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1)))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
|
.shadow(color: .black.opacity(0.12), radius: 6, x: 0, y: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
57
ios/LibNovelV2/features.md
Normal file
57
ios/LibNovelV2/features.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# LibNovel v2 iOS — Feature Tracker
|
||||||
|
|
||||||
|
Design reference: `ui/src/routes/` (SvelteKit web UI)
|
||||||
|
All new code lives in `ios/LibNovelV2/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status legend
|
||||||
|
- ✅ Done
|
||||||
|
- 🔨 In progress
|
||||||
|
- ⏳ Not started
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
| # | Feature | Files | Status |
|
||||||
|
|---|---------|-------|--------|
|
||||||
|
| 1 | Directory scaffold | `ios/LibNovelV2/` tree | ✅ |
|
||||||
|
| 2 | Models | `Models/Models.swift` | ✅ |
|
||||||
|
| 3 | Networking | `Networking/APIClient.swift` | ✅ |
|
||||||
|
| 4 | Services | `AuthStore`, `AudioPlayerService`, `AudioDownloadService`, `NetworkMonitor`, `BookVoicePreferences` | ✅ |
|
||||||
|
| 4b | App entry + RootTabView + stub views | `App/LibNovelV2App.swift`, `App/ContentView.swift`, `App/RootTabView.swift`, `Extensions/NavDestination.swift` | ✅ |
|
||||||
|
| 5 | Auth / Login | `Views/Auth/AuthView.swift` | ✅ |
|
||||||
|
| 6 | Home screen | `Views/Home/HomeView.swift`, `ViewModels/HomeViewModel.swift`, `Views/Common/CommonViews.swift` | ✅ |
|
||||||
|
| 7 | Library screen | `Views/Library/LibraryView.swift`, `ViewModels/LibraryViewModel.swift` | ✅ |
|
||||||
|
| 8 | Browse / Discover | `Views/Browse/BrowseView.swift`, `Views/Browse/BrowseCategoryView.swift`, `ViewModels/BrowseViewModel.swift` | ✅ |
|
||||||
|
| 9 | Search | `Views/Search/SearchView.swift`, `ViewModels/SearchViewModel.swift` | ✅ |
|
||||||
|
| 10 | Book Detail | `Views/BookDetail/BookDetailView.swift`, `ViewModels/BookDetailViewModel.swift` | ✅ |
|
||||||
|
| 11 | Chapter Reader | `Views/ChapterReader/ChapterReaderView.swift`, `ViewModels/ChapterReaderViewModel.swift` | ✅ |
|
||||||
|
| 12 | Audio mini-player + full player | `Views/Player/PlayerViews.swift` | ✅ |
|
||||||
|
| 13 | Downloads screen | `Views/Downloads/DownloadsView.swift` | ✅ |
|
||||||
|
| 14 | Profile / Account | `Views/Profile/ProfileView.swift`, `Views/Profile/VoiceSelectionView.swift` | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design system recap
|
||||||
|
|
||||||
|
| Token | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Main bg | `zinc-900` `#18181b` |
|
||||||
|
| Card bg | `zinc-800` `#27272a` |
|
||||||
|
| Border | `zinc-700` `#3f3f46` |
|
||||||
|
| Primary text | `zinc-100` `#f4f4f5` |
|
||||||
|
| Secondary text | `zinc-400` `#a1a1aa` |
|
||||||
|
| Accent / CTA | `amber-400` `#f59e0b` |
|
||||||
|
|
||||||
|
## Key patterns (quick ref)
|
||||||
|
|
||||||
|
- **Cover images**: always proxy via `/api/cover/{domain}/{slug}`
|
||||||
|
- **Download keys**: `slug::chapterN::voice` (`::` separator — slugs contain `-`)
|
||||||
|
- **Voice fallback**: book override → global default → `"af_bella"`
|
||||||
|
- **Offline**: `NetworkMonitor` env object + `OfflineBanner` at top of every networked view
|
||||||
|
- **Observable**: new types use `@Observable`; existing services use `ObservableObject`
|
||||||
|
- **Navigation**: `NavigationStack` + `NavDestination` enum + `.appNavigationDestination()`
|
||||||
|
- **Haptics**: `.light` for selection, `.medium` for primary actions
|
||||||
|
- **Animations**: `.spring(response:dampingFraction:)` for all interactive transitions
|
||||||
70
ios/LibNovelV2/project.yml
Normal file
70
ios/LibNovelV2/project.yml
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
name: LibNovelV2
|
||||||
|
options:
|
||||||
|
bundleIdPrefix: com.kalekber
|
||||||
|
deploymentTarget:
|
||||||
|
iOS: "17.0"
|
||||||
|
xcodeVersion: "16.0"
|
||||||
|
generateEmptyDirectories: true
|
||||||
|
indentWidth: 4
|
||||||
|
tabWidth: 4
|
||||||
|
usesTabs: false
|
||||||
|
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
SWIFT_VERSION: "5.10"
|
||||||
|
ENABLE_PREVIEWS: YES
|
||||||
|
MARKETING_VERSION: "1.0.0"
|
||||||
|
CURRENT_PROJECT_VERSION: "1"
|
||||||
|
LIBNOVEL_BASE_URL: "https://v2.libnovel.kalekber.cc"
|
||||||
|
configs:
|
||||||
|
Debug:
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG
|
||||||
|
Release:
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS: ""
|
||||||
|
|
||||||
|
targets:
|
||||||
|
LibNovelV2:
|
||||||
|
type: application
|
||||||
|
platform: iOS
|
||||||
|
deploymentTarget: "17.0"
|
||||||
|
sources:
|
||||||
|
- path: .
|
||||||
|
excludes:
|
||||||
|
- "**/.DS_Store"
|
||||||
|
- "Resources/Info.plist"
|
||||||
|
- "Resources/Assets.xcassets"
|
||||||
|
- "features.md"
|
||||||
|
- "project.yml"
|
||||||
|
resources:
|
||||||
|
- path: Resources/Assets.xcassets
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: com.kalekber.LibNovelV2
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
|
TARGETED_DEVICE_FAMILY: "1,2" # iPhone + iPad
|
||||||
|
GENERATE_INFOPLIST_FILE: NO
|
||||||
|
INFOPLIST_FILE: Resources/Info.plist
|
||||||
|
configs:
|
||||||
|
Release:
|
||||||
|
CODE_SIGN_STYLE: Manual
|
||||||
|
DEVELOPMENT_TEAM: GHZXC6FVMU
|
||||||
|
CODE_SIGN_IDENTITY: "Apple Distribution"
|
||||||
|
PROVISIONING_PROFILE: "af592c3a-f60b-4ac1-a14f-30b8a206017f"
|
||||||
|
|
||||||
|
schemes:
|
||||||
|
LibNovelV2:
|
||||||
|
build:
|
||||||
|
targets:
|
||||||
|
LibNovelV2: all
|
||||||
|
run:
|
||||||
|
config: Debug
|
||||||
|
environmentVariables:
|
||||||
|
LIBNOVEL_BASE_URL:
|
||||||
|
value: "https://v2.libnovel.kalekber.cc"
|
||||||
|
isEnabled: true
|
||||||
|
profile:
|
||||||
|
config: Release
|
||||||
|
analyze:
|
||||||
|
config: Debug
|
||||||
|
archive:
|
||||||
|
config: Release
|
||||||
13
opencode.json
Normal file
13
opencode.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"mcp": {
|
||||||
|
"gh_grep": {
|
||||||
|
"type": "remote",
|
||||||
|
"url": "https://mcp.grep.app",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"instructions": [
|
||||||
|
"ios/AGENTS.md"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -93,7 +93,7 @@ func startTestServer(t *testing.T, store storage.Store) string {
|
|||||||
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||||||
|
|
||||||
// nopScraper satisfies scraper.NovelScraper without hitting the network.
|
// nopScraper satisfies scraper.NovelScraper without hitting the network.
|
||||||
srv := New(addr, orchestrator.Config{}, nopScraper{}, log, store, "", "af_bella")
|
srv := New(addr, orchestrator.Config{}, nopScraper{}, log, store, "", "af_bella", "", "")
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
t.Cleanup(cancel)
|
t.Cleanup(cancel)
|
||||||
|
|||||||
Reference in New Issue
Block a user