Compare commits
65 Commits
ios-v1.0.2
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a49cb5e75 | ||
|
|
1642434a79 | ||
|
|
02705dc6ed | ||
|
|
7413313100 | ||
|
|
b11f4ab6b4 | ||
|
|
3e4b1c0484 | ||
|
|
b5bc6ff3de | ||
|
|
8d4bba7964 | ||
|
|
2e5fe54615 | ||
|
|
81265510ef | ||
|
|
4d3c093612 | ||
|
|
937ba052fc | ||
|
|
479d201da9 | ||
|
|
1242cc7eb3 | ||
|
|
0b6dbeb042 | ||
|
|
c06877069f | ||
|
|
261c738fc0 | ||
|
|
5528abe4b0 | ||
|
|
09cdda2a07 | ||
|
|
718bfa6691 | ||
|
|
e11e866e27 | ||
|
|
23345e22e6 | ||
|
|
c7b3495a23 | ||
|
|
83a5910a59 | ||
|
|
0f6639aae7 | ||
|
|
88a25bc33e | ||
|
|
73ad4ece49 | ||
|
|
52f876d8e8 | ||
|
|
72eed89f59 | ||
|
|
12bb0db5f0 | ||
|
|
5ec1773768 | ||
|
|
fb8f1dfe25 | ||
|
|
3a2d113b1b | ||
|
|
0dcfdff65b | ||
|
|
1766011b47 | ||
|
|
a6f800b0d7 | ||
|
|
af9639af05 | ||
|
|
bfc08a2df2 | ||
|
|
dc3bc3ebf2 | ||
|
|
e9d7293d37 | ||
|
|
410af8f236 | ||
|
|
264c00c765 | ||
|
|
e4c72011eb | ||
|
|
6365b14ece | ||
|
|
7da5582075 | ||
|
|
dae841e317 | ||
|
|
16b2bfffa6 | ||
|
|
57be674f44 | ||
|
|
93390fab64 | ||
|
|
072517135f | ||
|
|
fe7c7acbb7 | ||
|
|
d4cce915d9 | ||
|
|
ac24e86f7d | ||
|
|
e9bb387f71 | ||
|
|
d7319b3f7c | ||
|
|
f380c85815 | ||
|
|
9d1b340b83 | ||
|
|
a307ddc9f5 | ||
|
|
004d1b6d9d | ||
|
|
7f20411f50 | ||
|
|
6e6c581904 | ||
|
|
cecedc8687 | ||
|
|
a88e98a436 | ||
|
|
d3ae86d55b | ||
|
|
5ad5c2dbce |
@@ -74,3 +74,6 @@ jobs:
|
||||
tags: |
|
||||
${{ secrets.DOCKER_USER }}/libnovel-scraper:latest
|
||||
${{ secrets.DOCKER_USER }}/libnovel-scraper:${{ gitea.sha }}
|
||||
build-args: |
|
||||
VERSION=${{ gitea.sha }}
|
||||
COMMIT=${{ gitea.sha }}
|
||||
|
||||
@@ -65,3 +65,6 @@ jobs:
|
||||
tags: |
|
||||
${{ secrets.DOCKER_USER }}/libnovel-ui:latest
|
||||
${{ secrets.DOCKER_USER }}/libnovel-ui:${{ gitea.sha }}
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ gitea.sha }}
|
||||
BUILD_COMMIT=${{ gitea.sha }}
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
paths:
|
||||
- "scraper/**"
|
||||
- "ui/**"
|
||||
- "docker-compose.yml"
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
# tRPC API helper notes:
|
||||
# Mutations: POST /api/trpc/<router>.<procedure>
|
||||
# Body: {"0":{"json":{...input...}}}
|
||||
# Header: x-api-key: <token>
|
||||
# Queries: GET /api/trpc/<router>.<procedure>?batch=1&input={"0":{"json":{...input...}}}
|
||||
# Header: x-api-key: <token>
|
||||
# Response on success: HTTP 200, body: [{"result":{"data":{"json":{...}}}}]
|
||||
|
||||
concurrency:
|
||||
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── production deploy (main/master only) ─────────────────────────────────────
|
||||
deploy-production:
|
||||
name: Deploy Production
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
gitea.event_name == 'push' &&
|
||||
(gitea.ref == 'refs/heads/main' || gitea.ref == 'refs/heads/master')
|
||||
steps:
|
||||
- name: Redeploy production stack
|
||||
run: |
|
||||
RESPONSE=$(curl -s -w "\n%{http_code}" \
|
||||
-X POST "${{ secrets.DOKPLOY_URL }}/api/trpc/compose.redeploy" \
|
||||
-H "x-api-key: ${{ secrets.DOKPLOY_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"0":{"json":{"composeId":"${{ secrets.DOKPLOY_COMPOSE_ID }}"}}}')
|
||||
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
||||
BODY=$(echo "$RESPONSE" | head -1)
|
||||
echo "Status: $HTTP_CODE"
|
||||
echo "Body: $BODY"
|
||||
[ "$HTTP_CODE" = "200" ] || { echo "Redeploy failed"; exit 1; }
|
||||
|
||||
# ── preview deploy (feature branches) ────────────────────────────────────────
|
||||
deploy-preview:
|
||||
name: Deploy Preview
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
gitea.event_name == 'push' &&
|
||||
gitea.ref != 'refs/heads/main' &&
|
||||
gitea.ref != 'refs/heads/master'
|
||||
steps:
|
||||
- name: Sanitize branch name
|
||||
id: branch
|
||||
run: |
|
||||
# Lowercase, replace non-alphanumeric with dashes, strip trailing dashes, max 20 chars
|
||||
SUFFIX=$(echo "${{ gitea.ref_name }}" \
|
||||
| tr '[:upper:]' '[:lower:]' \
|
||||
| sed 's/[^a-z0-9]/-/g' \
|
||||
| cut -c1-20 \
|
||||
| sed 's/-*$//')
|
||||
echo "suffix=$SUFFIX" >> $GITHUB_OUTPUT
|
||||
echo "Preview suffix: $SUFFIX"
|
||||
|
||||
- name: Create or redeploy isolated preview stack
|
||||
run: |
|
||||
# compose.isolatedDeployment creates a new isolated copy of the compose stack
|
||||
# suffixed with the branch name. If the stack already exists it redeploys it.
|
||||
RESPONSE=$(curl -s -w "\n%{http_code}" \
|
||||
-X POST "${{ secrets.DOKPLOY_URL }}/api/trpc/compose.isolatedDeployment" \
|
||||
-H "x-api-key: ${{ secrets.DOKPLOY_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"0\":{\"json\":{\"composeId\":\"${{ secrets.DOKPLOY_COMPOSE_ID }}\",\"suffix\":\"${{ steps.branch.outputs.suffix }}\"}}}")
|
||||
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
||||
BODY=$(echo "$RESPONSE" | head -1)
|
||||
echo "Status: $HTTP_CODE"
|
||||
echo "Body: $BODY"
|
||||
[ "$HTTP_CODE" = "200" ] || { echo "Preview deploy failed"; exit 1; }
|
||||
|
||||
# ── cleanup preview on PR close ───────────────────────────────────────────────
|
||||
cleanup-preview:
|
||||
name: Cleanup Preview
|
||||
runs-on: ubuntu-latest
|
||||
if: gitea.event_name == 'pull_request' && gitea.event.action == 'closed'
|
||||
steps:
|
||||
- name: Sanitize branch name
|
||||
id: branch
|
||||
run: |
|
||||
SUFFIX=$(echo "${{ gitea.head_ref }}" \
|
||||
| tr '[:upper:]' '[:lower:]' \
|
||||
| sed 's/[^a-z0-9]/-/g' \
|
||||
| cut -c1-20 \
|
||||
| sed 's/-*$//')
|
||||
echo "suffix=$SUFFIX" >> $GITHUB_OUTPUT
|
||||
echo "Cleaning up preview suffix: $SUFFIX"
|
||||
|
||||
- name: Search for preview compose stack by appName
|
||||
id: find
|
||||
run: |
|
||||
# compose.search is a tRPC query (GET). We search by appName pattern.
|
||||
# appName is set by Dokploy as "<base-appName>-<suffix>" for isolated deployments.
|
||||
INPUT=$(python3 -c "import json,sys; print(json.dumps({'0':{'json':{'appName':'libnovel-${{ steps.branch.outputs.suffix }}','limit':5,'offset':0}}}))")
|
||||
RESPONSE=$(curl -s -w "\n%{http_code}" -G \
|
||||
"${{ secrets.DOKPLOY_URL }}/api/trpc/compose.search" \
|
||||
-H "x-api-key: ${{ secrets.DOKPLOY_TOKEN }}" \
|
||||
--data-urlencode "batch=1" \
|
||||
--data-urlencode "input=$INPUT")
|
||||
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
||||
BODY=$(echo "$RESPONSE" | head -1)
|
||||
echo "Status: $HTTP_CODE"
|
||||
echo "Body: $BODY"
|
||||
# Extract the first composeId from the JSON response array
|
||||
COMPOSE_ID=$(echo "$BODY" | python3 -c "
|
||||
import json,sys
|
||||
data = json.load(sys.stdin)
|
||||
items = data[0]['result']['data']['json']['items']
|
||||
print(items[0]['composeId'] if items else '')
|
||||
" 2>/dev/null || echo "")
|
||||
echo "composeId=$COMPOSE_ID" >> $GITHUB_OUTPUT
|
||||
echo "Found composeId: $COMPOSE_ID"
|
||||
|
||||
- name: Delete preview stack
|
||||
if: steps.find.outputs.composeId != ''
|
||||
run: |
|
||||
RESPONSE=$(curl -s -w "\n%{http_code}" \
|
||||
-X POST "${{ secrets.DOKPLOY_URL }}/api/trpc/compose.delete" \
|
||||
-H "x-api-key: ${{ secrets.DOKPLOY_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"0\":{\"json\":{\"composeId\":\"${{ steps.find.outputs.composeId }}\",\"deleteVolumes\":true}}}")
|
||||
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
||||
BODY=$(echo "$RESPONSE" | head -1)
|
||||
echo "Status: $HTTP_CODE"
|
||||
echo "Body: $BODY"
|
||||
[ "$HTTP_CODE" = "200" ] || { echo "Delete failed"; exit 1; }
|
||||
@@ -1,106 +0,0 @@
|
||||
name: iOS Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "ios-v*"
|
||||
|
||||
concurrency:
|
||||
group: ios-macos-runner
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# ── archive & release to TestFlight ──────────────────────────────────────
|
||||
# Triggered only on ios-v* tags (e.g. ios-v1.0.0).
|
||||
# Required secrets:
|
||||
# APPLE_CERTIFICATE_BASE64 - Distribution certificate (.p12) base64-encoded
|
||||
# APPLE_CERTIFICATE_PASSWORD - Password for the .p12 file
|
||||
# APPLE_PROVISIONING_PROFILE_BASE64 - App Store distribution profile base64-encoded
|
||||
# KEYCHAIN_PASSWORD - Temporary keychain password (any random string)
|
||||
# ASC_KEY_ID - App Store Connect API key ID
|
||||
# ASC_ISSUER_ID - App Store Connect issuer ID
|
||||
# ASC_PRIVATE_KEY - Contents of the .p8 private key file
|
||||
# APPLE_TEAM_ID - 10-character Apple Developer team ID (GHZXC6FVMU)
|
||||
release:
|
||||
name: Release to TestFlight
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install just
|
||||
run: command -v just || brew install just
|
||||
|
||||
- name: Set build number from run number
|
||||
run: just ios-set-build-number ${{ gitea.run_number }}
|
||||
|
||||
- name: Import signing certificate
|
||||
env:
|
||||
CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
|
||||
CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
echo "$CERTIFICATE_BASE64" | base64 --decode > $RUNNER_TEMP/cert.p12
|
||||
security import $RUNNER_TEMP/cert.p12 \
|
||||
-P "$CERTIFICATE_PASSWORD" \
|
||||
-A -t cert -f pkcs12 \
|
||||
-k $KEYCHAIN_PATH
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
|
||||
- name: Import provisioning profile
|
||||
env:
|
||||
PROFILE_BASE64: ${{ secrets.APPLE_PROVISIONING_PROFILE_BASE64 }}
|
||||
run: |
|
||||
PP_PATH=$RUNNER_TEMP/profile.mobileprovision
|
||||
echo "$PROFILE_BASE64" | base64 --decode > $PP_PATH
|
||||
UUID=$(security cms -D -i "$PP_PATH" | plutil -extract UUID raw -)
|
||||
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision
|
||||
echo "PROFILE_UUID=$UUID" >> $GITHUB_ENV
|
||||
|
||||
- name: Write App Store Connect API key
|
||||
env:
|
||||
ASC_PRIVATE_KEY: ${{ secrets.ASC_PRIVATE_KEY }}
|
||||
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||||
run: |
|
||||
mkdir -p ~/private_keys
|
||||
echo "$ASC_PRIVATE_KEY" > ~/private_keys/AuthKey_$ASC_KEY_ID.p8
|
||||
|
||||
- name: Inject team ID into ExportOptions.plist
|
||||
env:
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
/usr/libexec/PlistBuddy -c \
|
||||
"Set :teamID $APPLE_TEAM_ID" \
|
||||
ios/LibNovel/ExportOptions.plist
|
||||
|
||||
- name: Archive
|
||||
env:
|
||||
USER: runner
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
PROFILE_UUID: ${{ env.PROFILE_UUID }}
|
||||
run: just ios-archive
|
||||
|
||||
- name: Export IPA
|
||||
run: just ios-export
|
||||
|
||||
- name: Upload to TestFlight
|
||||
env:
|
||||
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||
run: just ios-upload
|
||||
|
||||
- name: Upload IPA artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: LibNovel-${{ gitea.ref_name }}.ipa
|
||||
path: ${{ env.RUNNER_TEMP }}/ipa/LibNovel.ipa
|
||||
retention-days: 30
|
||||
|
||||
- name: Cleanup keychain
|
||||
if: always()
|
||||
run: security delete-keychain $RUNNER_TEMP/app-signing.keychain-db
|
||||
68
.gitea/workflows/release-scraper.yaml
Normal file
68
.gitea/workflows/release-scraper.yaml
Normal file
@@ -0,0 +1,68 @@
|
||||
name: Release / Scraper
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
concurrency:
|
||||
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── lint & test ──────────────────────────────────────────────────────────────
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: scraper/go.mod
|
||||
cache-dependency-path: scraper/go.sum
|
||||
|
||||
- name: go vet
|
||||
working-directory: scraper
|
||||
run: |
|
||||
go vet ./...
|
||||
go vet -tags integration ./...
|
||||
|
||||
- name: Run tests
|
||||
working-directory: scraper
|
||||
run: go test -short -race -count=1 -timeout=60s ./...
|
||||
|
||||
# ── docker build & push ──────────────────────────────────────────────────────
|
||||
docker:
|
||||
name: Docker
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ secrets.DOCKER_USER }}/libnovel-scraper
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: scraper
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta.outputs.version }}
|
||||
COMMIT=${{ gitea.sha }}
|
||||
@@ -66,3 +66,6 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ steps.meta.outputs.version }}
|
||||
BUILD_COMMIT=${{ gitea.sha }}
|
||||
|
||||
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()`
|
||||
- **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
|
||||
|
||||
## 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`.
|
||||
|
||||
@@ -35,6 +35,7 @@ services:
|
||||
mc mb --ignore-existing local/libnovel-chapters;
|
||||
mc mb --ignore-existing local/libnovel-audio;
|
||||
mc mb --ignore-existing local/libnovel-browse;
|
||||
mc mb --ignore-existing local/libnovel-avatars;
|
||||
echo 'buckets ready';
|
||||
"
|
||||
environment:
|
||||
@@ -81,6 +82,9 @@ services:
|
||||
build:
|
||||
context: ./scraper
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VERSION: "${GIT_TAG:-dev}"
|
||||
COMMIT: "${GIT_COMMIT:-unknown}"
|
||||
#container_name: libnovel-scraper
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
@@ -106,6 +110,7 @@ services:
|
||||
MINIO_BUCKET_CHAPTERS: "${MINIO_BUCKET_CHAPTERS:-libnovel-chapters}"
|
||||
MINIO_BUCKET_AUDIO: "${MINIO_BUCKET_AUDIO:-libnovel-audio}"
|
||||
MINIO_BUCKET_BROWSE: "${MINIO_BUCKET_BROWSE:-libnovel-browse}"
|
||||
MINIO_BUCKET_AVATARS: "${MINIO_BUCKET_AVATARS:-libnovel-avatars}"
|
||||
# Public endpoint used to sign presigned audio URLs so browsers can reach them.
|
||||
# Leave empty to use MINIO_ENDPOINT (fine for local dev).
|
||||
MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-}"
|
||||
@@ -129,6 +134,9 @@ services:
|
||||
build:
|
||||
context: ./ui
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
BUILD_VERSION: "${GIT_TAG:-dev}"
|
||||
BUILD_COMMIT: "${GIT_COMMIT:-unknown}"
|
||||
# container_name: libnovel-ui
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
@@ -147,7 +155,7 @@ services:
|
||||
ports:
|
||||
- "${UI_PORT:-5252}:3000"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/"]
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
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
|
||||
10
ios/LibNovel/.gitignore
vendored
Normal file
10
ios/LibNovel/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# Fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots/**/*.png
|
||||
fastlane/test_output
|
||||
fastlane/README.md
|
||||
|
||||
# Bundler
|
||||
.bundle
|
||||
vendor/bundle
|
||||
@@ -3,9 +3,9 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key>
|
||||
<string>app-store-connect</string>
|
||||
<string>app-store</string>
|
||||
<key>teamID</key>
|
||||
<string>$(DEVELOPMENT_TEAM)</string>
|
||||
<string>GHZXC6FVMU</string>
|
||||
<key>uploadBitcode</key>
|
||||
<false/>
|
||||
<key>uploadSymbols</key>
|
||||
@@ -14,7 +14,7 @@
|
||||
<string>manual</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>cc.kalekber.libnovel</key>
|
||||
<key>com.kalekber.LibNovel</key>
|
||||
<string>LibNovel Distribution</string>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
3
ios/LibNovel/Gemfile
Normal file
3
ios/LibNovel/Gemfile
Normal file
@@ -0,0 +1,3 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "fastlane"
|
||||
@@ -8,28 +8,44 @@
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
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 */; };
|
||||
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 */; };
|
||||
2A15157AD2AE2271675C3485 /* ChapterReaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */; };
|
||||
3521DFD5FCBBED7B90368829 /* LibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC338B05EA6DB22900712000 /* LibraryViewModel.swift */; };
|
||||
367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5C115992F1CE2326236765 /* RootTabView.swift */; };
|
||||
41FB51553F1F1AEBFEA91C0A /* String+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC6F837FF2E902E334ED72E /* String+App.swift */; };
|
||||
4BB2C76262D5BD5DAD0D5D28 /* LibNovelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C918833E173D6B44D06955 /* LibNovelTests.swift */; };
|
||||
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 937A589F84FD412BBB6FBC45 /* ProfileViewModel.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 */; };
|
||||
65CA672C02F367F72F18F8B8 /* AudioDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94730324A6BD9D6A772286BB /* AudioDownloadService.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 */; };
|
||||
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 */; };
|
||||
9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEF6782A2A28B2A485CBD48 /* AuthView.swift */; };
|
||||
9C19B17E746FE6A834E53AF3 /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F247DE25991F4DB98DF717AA /* UserProfileView.swift */; };
|
||||
A7485E99B9ACBCBCCD1EB7B2 /* CommentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16B9AFE90719BDBC718F0621 /* CommentsView.swift */; };
|
||||
A9B95BAD7CE2DCD1DDDABD4C /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB13E89E50529E3081533A66 /* AudioPlayerService.swift */; };
|
||||
BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */; };
|
||||
C807AD8D627CF6BED47D517C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AB2E843D93461074A89A171 /* HomeViewModel.swift */; };
|
||||
CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 09584EAB68A07B47F876A062 /* Kingfisher */; };
|
||||
DFA7EB1B0BD53F68FE1335C8 /* DownloadAudioButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35942111986E54CC0E83A391 /* DownloadAudioButton.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 */; };
|
||||
A1B2C3D4E5F6789012345678 /* String+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4E5F67890123456789A /* String+App.swift */; };
|
||||
ED54860A709FED5A8CBF4EEB /* AccountMenuSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD554706F61FE3DC061189F /* AccountMenuSheet.swift */; };
|
||||
EF3C57C400BF05CBEAC1F7FE /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6268D60803940CBD38FB921 /* HomeView.swift */; };
|
||||
F2AF05B9C8C23132A73ACDD3 /* CommonViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E89FD8F46747CA653C5203D /* CommonViews.swift */; };
|
||||
F4FDA3C44752EB979235C042 /* NavDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CAFB96D2500F34F0B0C860C /* NavDestination.swift */; };
|
||||
@@ -48,35 +64,51 @@
|
||||
/* End PBXContainerItemProxy 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; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
9D83BB88C4306BE7A4F947CB /* Color+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+App.swift"; sourceTree = "<group>"; };
|
||||
B2C3D4E5F67890123456789A /* String+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+App.swift"; sourceTree = "<group>"; };
|
||||
A75E148A48D47A5B37CA7FB3 /* VoiceSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSelectionView.swift; sourceTree = "<group>"; };
|
||||
AA9111BF29C75E8D60FCEDF6 /* DiscoverViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoverViewModel.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>"; };
|
||||
B593F179EC3E9112126B540B /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.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>"; };
|
||||
CB2489CA141D5E19373D0936 /* VoiceSelectionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSelectionViewModel.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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
FEC6F837FF2E902E334ED72E /* String+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+App.swift"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -107,10 +139,13 @@
|
||||
8E8AAA58A33084ADB8AEA80C /* Browse */,
|
||||
4EAB87A1ED4943A311F26F84 /* ChapterReader */,
|
||||
5D5809803A3D74FAE19DB218 /* Common */,
|
||||
9180FAFE96724B8AACFA9859 /* Components */,
|
||||
3881CBFE9730C6422BE6F03D /* Downloads */,
|
||||
811FC0F6B9C209D6EC8543BD /* Home */,
|
||||
FA994FD601E79EC811D822A4 /* Library */,
|
||||
89F2CB14192E7D7565A588E0 /* Player */,
|
||||
3DB66C5703A4CCAFFA1B7AFE /* Profile */,
|
||||
474BE4FC0353C2DD8D8425D1 /* Search */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
@@ -123,10 +158,23 @@
|
||||
path = Auth;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3881CBFE9730C6422BE6F03D /* Downloads */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
16ECDDD02E6A2F8562111538 /* DownloadQueueButton.swift */,
|
||||
8175390266E8C6CF1437A229 /* DownloadsView.swift */,
|
||||
);
|
||||
path = Downloads;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3DB66C5703A4CCAFFA1B7AFE /* Profile */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AAD554706F61FE3DC061189F /* AccountMenuSheet.swift */,
|
||||
775B5C22D6215D7A7C412E13 /* AvatarCropView.swift */,
|
||||
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */,
|
||||
F247DE25991F4DB98DF717AA /* UserProfileView.swift */,
|
||||
A75E148A48D47A5B37CA7FB3 /* VoiceSelectionView.swift */,
|
||||
);
|
||||
path = Profile;
|
||||
sourceTree = "<group>";
|
||||
@@ -139,10 +187,19 @@
|
||||
path = Networking;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
474BE4FC0353C2DD8D8425D1 /* Search */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
79133D9FA697D1909C8D3973 /* SearchView.swift */,
|
||||
);
|
||||
path = Search;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4EAB87A1ED4943A311F26F84 /* ChapterReader */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
81E3939152E23B4985FAF7E2 /* ChapterReaderView.swift */,
|
||||
35942111986E54CC0E83A391 /* DownloadAudioButton.swift */,
|
||||
);
|
||||
path = ChapterReader;
|
||||
sourceTree = "<group>";
|
||||
@@ -204,6 +261,14 @@
|
||||
path = Browse;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9180FAFE96724B8AACFA9859 /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F082F99F2EE05BD98C9EF2AA /* OfflineBanner.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9AF55E5D62F980C72431782A = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -237,9 +302,12 @@
|
||||
837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */,
|
||||
9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */,
|
||||
8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */,
|
||||
AA9111BF29C75E8D60FCEDF6 /* DiscoverViewModel.swift */,
|
||||
3AB2E843D93461074A89A171 /* HomeViewModel.swift */,
|
||||
FC338B05EA6DB22900712000 /* LibraryViewModel.swift */,
|
||||
937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */,
|
||||
10777FC4816A7067AF9C4797 /* UserProfileViewModel.swift */,
|
||||
CB2489CA141D5E19373D0936 /* VoiceSelectionViewModel.swift */,
|
||||
);
|
||||
path = ViewModels;
|
||||
sourceTree = "<group>";
|
||||
@@ -247,8 +315,11 @@
|
||||
DA6F6F625578875F3E74F1D3 /* Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
94730324A6BD9D6A772286BB /* AudioDownloadService.swift */,
|
||||
DB13E89E50529E3081533A66 /* AudioPlayerService.swift */,
|
||||
F219788AE5ACBD6F240674F5 /* AuthStore.swift */,
|
||||
1C0022D98CDAD0B11840AAAC /* BookVoicePreferences.swift */,
|
||||
1FA1B6D9FF31780095F5ACA8 /* NetworkMonitor.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
@@ -265,6 +336,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
39DE056C37FBC5EED8771821 /* BookDetailView.swift */,
|
||||
16B9AFE90719BDBC718F0621 /* CommentsView.swift */,
|
||||
);
|
||||
path = BookDetail;
|
||||
sourceTree = "<group>";
|
||||
@@ -274,7 +346,7 @@
|
||||
children = (
|
||||
9D83BB88C4306BE7A4F947CB /* Color+App.swift */,
|
||||
7CAFB96D2500F34F0B0C860C /* NavDestination.swift */,
|
||||
B2C3D4E5F67890123456789A /* String+App.swift */,
|
||||
FEC6F837FF2E902E334ED72E /* String+App.swift */,
|
||||
);
|
||||
path = Extensions;
|
||||
sourceTree = "<group>";
|
||||
@@ -387,19 +459,27 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
FB32F3772CA09684F00497F3 /* APIClient.swift in Sources */,
|
||||
ED54860A709FED5A8CBF4EEB /* AccountMenuSheet.swift in Sources */,
|
||||
65CA672C02F367F72F18F8B8 /* AudioDownloadService.swift in Sources */,
|
||||
A9B95BAD7CE2DCD1DDDABD4C /* AudioPlayerService.swift in Sources */,
|
||||
5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */,
|
||||
9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */,
|
||||
0B40E3DCE82EBEA7C4ECF148 /* AvatarCropView.swift in Sources */,
|
||||
749292A18C57FA41EC88A30B /* BookDetailView.swift in Sources */,
|
||||
7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */,
|
||||
1945DD2D0DF497FE66FAAF90 /* BookVoicePreferences.swift in Sources */,
|
||||
08DFB5F626BA769556C8D145 /* BrowseView.swift in Sources */,
|
||||
2790B8C051BE389D83645047 /* BrowseViewModel.swift in Sources */,
|
||||
FEFB5FDC2424D22914458001 /* ChapterReaderView.swift in Sources */,
|
||||
2A15157AD2AE2271675C3485 /* ChapterReaderViewModel.swift in Sources */,
|
||||
E2572692178FD17145FDAF77 /* Color+App.swift in Sources */,
|
||||
A1B2C3D4E5F6789012345678 /* String+App.swift in Sources */,
|
||||
A7485E99B9ACBCBCCD1EB7B2 /* CommentsView.swift in Sources */,
|
||||
F2AF05B9C8C23132A73ACDD3 /* CommonViews.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 */,
|
||||
C807AD8D627CF6BED47D517C /* HomeViewModel.swift in Sources */,
|
||||
032E049A4BB3CF0EA990C0CD /* LibNovelApp.swift in Sources */,
|
||||
@@ -407,10 +487,18 @@
|
||||
3521DFD5FCBBED7B90368829 /* LibraryViewModel.swift in Sources */,
|
||||
0A52BC1CE71BED9E75D20D35 /* Models.swift in Sources */,
|
||||
F4FDA3C44752EB979235C042 /* NavDestination.swift in Sources */,
|
||||
5F7409635F6563E44C836390 /* NetworkMonitor.swift in Sources */,
|
||||
62B42DB777F53856C57CB6AF /* OfflineBanner.swift in Sources */,
|
||||
BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */,
|
||||
64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */,
|
||||
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */,
|
||||
367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */,
|
||||
192F82518CB8763775E33B38 /* SearchView.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;
|
||||
};
|
||||
@@ -435,7 +523,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cc.kalekber.libnovel.tests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel.tests;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LibNovel.app/LibNovel";
|
||||
@@ -452,7 +540,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cc.kalekber.libnovel.tests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel.tests;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LibNovel.app/LibNovel";
|
||||
@@ -463,7 +551,8 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = GHZXC6FVMU;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = LibNovel/Resources/Info.plist;
|
||||
@@ -472,7 +561,8 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cc.kalekber.libnovel;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
@@ -549,7 +639,8 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
DEVELOPMENT_TEAM = GHZXC6FVMU;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = LibNovel/Resources/Info.plist;
|
||||
@@ -558,7 +649,8 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cc.kalekber.libnovel;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel;
|
||||
PROVISIONING_PROFILE = "af592c3a-f60b-4ac1-a14f-30b8a206017f";
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "18350c2bfa3935125b6f4e9817e7ed4508588c07142d420b8b8ee00640a57853",
|
||||
"originHash" : "ad75ae2d3b8d8b80d99635f65213a3c1092464aa54a86354f850b8317b6fa240",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "kingfisher",
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "LIBNOVEL_BASE_URL"
|
||||
value = "["isEnabled": true, "value": "https://v2.libnovel.kalekber.cc"]"
|
||||
value = "["value": "https://v2.libnovel.kalekber.cc", "isEnabled": true]"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
|
||||
@@ -4,12 +4,16 @@ import SwiftUI
|
||||
struct LibNovelApp: App {
|
||||
@StateObject private var authStore = AuthStore()
|
||||
@StateObject private var audioPlayer = AudioPlayerService()
|
||||
@StateObject private var downloadService = AudioDownloadService.shared
|
||||
@StateObject private var networkMonitor = NetworkMonitor()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environmentObject(authStore)
|
||||
.environmentObject(audioPlayer)
|
||||
.environmentObject(downloadService)
|
||||
.environmentObject(networkMonitor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,17 +8,15 @@ struct RootTabView: View {
|
||||
|
||||
@State private var selectedTab: Tab = .home
|
||||
@State private var showFullPlayer: Bool = false
|
||||
@State private var readerIsActive: Bool = false
|
||||
|
||||
/// Live drag offset while the user is dragging the full player down.
|
||||
@State private var fullPlayerDragOffset: CGFloat = 0
|
||||
|
||||
enum Tab: Hashable {
|
||||
case home, library, browse, profile
|
||||
case home, library, browse, search
|
||||
}
|
||||
|
||||
/// Height of the mini player bar (progress line 2pt + vertical padding 20pt + content ~44pt)
|
||||
private let miniPlayerBarHeight: CGFloat = AppLayout.miniPlayerBarHeight
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
TabView(selection: $selectedTab) {
|
||||
@@ -34,28 +32,22 @@ struct RootTabView: View {
|
||||
.tabItem { Label("Discover", systemImage: "sparkles") }
|
||||
.tag(Tab.browse)
|
||||
|
||||
ProfileView()
|
||||
.tabItem { Label("Profile", systemImage: "gear") }
|
||||
.tag(Tab.profile)
|
||||
}
|
||||
// Reserve space for the mini-player above the tab bar so scroll content
|
||||
// never slides beneath it.
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
if audioPlayer.isActive {
|
||||
Color.clear.frame(height: miniPlayerBarHeight)
|
||||
}
|
||||
SearchView()
|
||||
.tabItem { Label("Search", systemImage: "magnifyingglass") }
|
||||
.tag(Tab.search)
|
||||
}
|
||||
|
||||
// Mini-player pinned above the tab bar (hidden while full player is open)
|
||||
if audioPlayer.isActive && !showFullPlayer {
|
||||
MiniPlayerView(showFullPlayer: $showFullPlayer)
|
||||
.padding(.bottom, tabBarHeight)
|
||||
// Mini player bar — sits above the tab bar, hidden while full player is open
|
||||
// or while the chapter reader is active (it has its own audio chrome).
|
||||
if audioPlayer.isActive && !showFullPlayer && !readerIsActive {
|
||||
MiniPlayerBar(showFullPlayer: $showFullPlayer)
|
||||
// Lift above the tab bar (approx 49 pt on all devices)
|
||||
.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 as a custom overlay (not a sheet)
|
||||
// so it feels physically connected to the mini player bar.
|
||||
// Full player — slides up from the bottom as a custom overlay.
|
||||
if showFullPlayer {
|
||||
FullPlayerView(onDismiss: {
|
||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
|
||||
@@ -68,7 +60,6 @@ struct RootTabView: View {
|
||||
DragGesture(minimumDistance: 10)
|
||||
.onChanged { value in
|
||||
if value.translation.height > 0 {
|
||||
// Rubberband slightly so it doesn't feel locked
|
||||
fullPlayerDragOffset = value.translation.height
|
||||
}
|
||||
}
|
||||
@@ -92,14 +83,8 @@ struct RootTabView: View {
|
||||
}
|
||||
}
|
||||
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer)
|
||||
}
|
||||
|
||||
// Approximate safe-area-aware tab bar height
|
||||
private var tabBarHeight: CGFloat {
|
||||
let window = UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.first?.windows.first(where: \.isKeyWindow)
|
||||
let bottomInset = window?.safeAreaInsets.bottom ?? 0
|
||||
return 49 + bottomInset // 49pt is the standard iOS tab bar height
|
||||
.onPreferenceChange(HideMiniPlayerKey.self) { hide in
|
||||
readerIsActive = hide
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import SwiftUI
|
||||
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) // Browse with filters
|
||||
}
|
||||
|
||||
// MARK: - View extensions for shared navigation + error alert patterns
|
||||
@@ -13,24 +15,154 @@ extension View {
|
||||
/// Registers the app-wide navigation destinations for NavDestination values.
|
||||
/// Apply once per NavigationStack instead of repeating the switch in every tab.
|
||||
func appNavigationDestination() -> some View {
|
||||
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)
|
||||
}
|
||||
}
|
||||
modifier(AppNavigationDestinationModifier())
|
||||
}
|
||||
|
||||
/// Presents a standard "Error" alert driven by an optional String binding.
|
||||
/// 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 {
|
||||
alert("Error", isPresented: Binding(
|
||||
get: { error.wrappedValue != nil },
|
||||
set: { if !$0 { error.wrappedValue = nil } }
|
||||
)) {
|
||||
Button("OK") { error.wrappedValue = nil }
|
||||
} message: {
|
||||
Text(error.wrappedValue ?? "")
|
||||
self.modifier(ErrorAlertModifier(error: error))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Alert Modifier
|
||||
|
||||
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 ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
// Expose namespace to child views via environment
|
||||
.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 for 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: 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
|
||||
|
||||
/// Apply this to any cover image that should be a zoom source for book navigation.
|
||||
/// Falls back to a no-op on iOS 17 or when no namespace is available.
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Marks a cover image as the zoom source for a book's navigation transition.
|
||||
func bookCoverZoomSource(slug: String) -> some View {
|
||||
modifier(BookCoverZoomSource(slug: slug))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,11 +39,3 @@ extension String {
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - App-wide layout constants
|
||||
|
||||
enum AppLayout {
|
||||
/// Height of the persistent mini-player bar:
|
||||
/// 12pt vertical padding (top) + 56pt cover height + 12pt vertical padding (bottom) + 12pt horizontal margin.
|
||||
static let miniPlayerBarHeight: CGFloat = 92
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Book
|
||||
|
||||
@@ -72,27 +73,6 @@ struct ChapterIndexBrief: Codable, Hashable {
|
||||
let title: String
|
||||
}
|
||||
|
||||
// MARK: - Progress
|
||||
|
||||
struct ReadingProgress: Codable {
|
||||
var id: String?
|
||||
let sessionId: String
|
||||
var userId: String?
|
||||
let slug: String
|
||||
var chapter: Int
|
||||
var audioTime: Double?
|
||||
let updated: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case sessionId = "session_id"
|
||||
case userId = "user_id"
|
||||
case slug, chapter
|
||||
case audioTime = "audio_time"
|
||||
case updated
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - User Settings
|
||||
|
||||
struct UserSettings: Codable {
|
||||
@@ -107,6 +87,81 @@ struct UserSettings: Codable {
|
||||
static let `default` = UserSettings(id: nil, autoNext: false, voice: "af_bella", speed: 1.0)
|
||||
}
|
||||
|
||||
// MARK: - Reading Display Settings (local only — stored in 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.1, 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 // follows system
|
||||
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
|
||||
var lineSpacing: CGFloat
|
||||
var font: ReaderFont
|
||||
var theme: ReaderTheme
|
||||
var scrollMode: Bool
|
||||
|
||||
static let `default` = ReaderSettings(
|
||||
fontSize: 17,
|
||||
lineSpacing: 1.7,
|
||||
font: .system,
|
||||
theme: .white,
|
||||
scrollMode: false
|
||||
)
|
||||
|
||||
static let userDefaultsKey = "readerSettings"
|
||||
|
||||
static func load() -> ReaderSettings {
|
||||
guard let data = UserDefaults.standard.data(forKey: userDefaultsKey),
|
||||
let decoded = try? JSONDecoder().decode(ReaderSettings.self, from: data)
|
||||
else { return .default }
|
||||
return decoded
|
||||
}
|
||||
|
||||
func save() {
|
||||
if let data = try? JSONEncoder().encode(self) {
|
||||
UserDefaults.standard.set(data, forKey: ReaderSettings.userDefaultsKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - User
|
||||
|
||||
struct AppUser: Codable, Identifiable {
|
||||
@@ -114,15 +169,30 @@ struct AppUser: Codable, Identifiable {
|
||||
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(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
|
||||
}
|
||||
|
||||
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) ?? ""
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,12 +217,6 @@ struct RankingItem: Codable, Identifiable {
|
||||
|
||||
// MARK: - Home
|
||||
|
||||
struct HomeData {
|
||||
let continueReading: [ContinueReadingItem]
|
||||
let recentlyUpdated: [Book]
|
||||
let stats: HomeStats
|
||||
}
|
||||
|
||||
struct ContinueReadingItem: Identifiable {
|
||||
var id: String { book.id }
|
||||
let book: Book
|
||||
@@ -198,15 +262,134 @@ struct BookBrief: Codable {
|
||||
let cover: String
|
||||
}
|
||||
|
||||
// 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 // empty = top-level; non-empty = reply
|
||||
var replies: [BookComment]? // populated client-side from the API response
|
||||
|
||||
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
|
||||
case myVotes = "myVotes"
|
||||
case avatarUrls = "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: - User Profile (public)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subscription Feed
|
||||
|
||||
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: - Public User Library
|
||||
|
||||
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: - Audio
|
||||
|
||||
enum NextPrefetchStatus {
|
||||
case none, prefetching, prefetched, failed
|
||||
}
|
||||
|
||||
// MARK: - PocketBase list response
|
||||
|
||||
struct PBList<T: Codable>: Codable {
|
||||
let items: [T]
|
||||
let totalItems: Int
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ actor APIClient {
|
||||
|
||||
var baseURL: URL
|
||||
private var authCookie: String? // raw "libnovel_auth=<token>" header value
|
||||
private var sessionId: String? // anon session id (UUID)
|
||||
|
||||
// URLSession with persistent cookie storage
|
||||
private let session: URLSession = {
|
||||
@@ -51,10 +50,6 @@ actor APIClient {
|
||||
}
|
||||
}
|
||||
|
||||
func setSessionId(_ id: String) {
|
||||
sessionId = id
|
||||
}
|
||||
|
||||
// MARK: - Low-level request builder
|
||||
|
||||
private func makeRequest(_ path: String, method: String = "GET", body: Encodable? = nil) throws -> URLRequest {
|
||||
@@ -95,11 +90,17 @@ actor APIClient {
|
||||
}
|
||||
}
|
||||
|
||||
func fetchRaw(_ path: String, method: String = "GET", body: Encodable? = nil) async throws -> (Data, HTTPURLResponse) {
|
||||
/// Like `fetch` but discards the response body — use for endpoints that return 204 No Content.
|
||||
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 }
|
||||
return (data, http)
|
||||
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, \(data.count) bytes>"
|
||||
throw APIError.httpError(http.statusCode, rawBody)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Auth
|
||||
@@ -125,8 +126,8 @@ actor APIClient {
|
||||
}
|
||||
|
||||
func logout() async throws {
|
||||
let (_, _) = try await fetchRaw("/api/auth/logout", method: "POST")
|
||||
await setAuthCookie(nil)
|
||||
let _: EmptyResponse = try await fetch("/api/auth/logout", method: "POST")
|
||||
setAuthCookie(nil)
|
||||
}
|
||||
|
||||
// MARK: - Home
|
||||
@@ -163,13 +164,6 @@ actor APIClient {
|
||||
|
||||
// MARK: - Browse
|
||||
|
||||
struct BrowseParams: Encodable {
|
||||
let page: Int
|
||||
let genre: String
|
||||
let sort: String
|
||||
let status: String
|
||||
}
|
||||
|
||||
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)")
|
||||
@@ -195,6 +189,10 @@ actor APIClient {
|
||||
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)")
|
||||
@@ -283,6 +281,115 @@ actor APIClient {
|
||||
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" }
|
||||
}
|
||||
|
||||
/// Upload a profile avatar using a two-step presigned PUT flow:
|
||||
/// 1. POST /api/profile/avatar → get a presigned PUT URL + object key
|
||||
/// 2. PUT image bytes directly to MinIO via the presigned URL
|
||||
/// 3. PATCH /api/profile/avatar with the key to record it in PocketBase
|
||||
/// Returns the presigned GET URL for the uploaded avatar.
|
||||
func uploadAvatar(_ imageData: Data, mimeType: String = "image/jpeg") async throws -> String? {
|
||||
// Step 1: request a presigned PUT URL from the SvelteKit server
|
||||
let presign: AvatarPresignResponse = try await fetch(
|
||||
"/api/profile/avatar",
|
||||
method: "POST",
|
||||
body: ["mime_type": mimeType]
|
||||
)
|
||||
|
||||
// Step 2: PUT the image bytes directly to MinIO
|
||||
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 {
|
||||
let code = (putResp as? HTTPURLResponse)?.statusCode ?? 0
|
||||
throw APIError.httpError(code, "MinIO PUT failed")
|
||||
}
|
||||
|
||||
// Step 3: record the key in PocketBase and get back a presigned GET URL
|
||||
let result: AvatarResponse = try await fetch(
|
||||
"/api/profile/avatar",
|
||||
method: "PATCH",
|
||||
body: ["key": presign.key]
|
||||
)
|
||||
return result.avatarURL
|
||||
}
|
||||
|
||||
/// Fetches a fresh presigned GET URL for the current user's avatar.
|
||||
/// Returns nil if the user has no avatar set.
|
||||
/// Used on cold launch / session restore to convert the stored raw key into a viewable URL.
|
||||
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)")
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
struct VoteBody: Encodable { let vote: String }
|
||||
|
||||
/// Cast, change, or toggle-off a vote on a comment.
|
||||
/// Returns the updated BookComment (with refreshed upvotes/downvotes counts).
|
||||
func voteComment(commentId: String, vote: String) async throws -> BookComment {
|
||||
try await fetch("/api/comment/\(commentId)/vote", method: "POST", body: VoteBody(vote: vote))
|
||||
}
|
||||
|
||||
/// Delete a comment (and its replies) by ID. Only the owner can delete.
|
||||
func deleteComment(commentId: String) async throws {
|
||||
try await fetchVoid("/api/comment/\(commentId)", method: "DELETE")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Response types
|
||||
@@ -295,11 +402,21 @@ struct HomeDataResponse: Decodable {
|
||||
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) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,11 +470,6 @@ struct BrowseResponse: Decodable {
|
||||
let novels: [BrowseNovel]
|
||||
let page: Int
|
||||
let hasNext: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case novels, page
|
||||
case hasNext = "hasNext"
|
||||
}
|
||||
}
|
||||
|
||||
struct BrowseNovel: Decodable, Identifiable, Hashable {
|
||||
|
||||
@@ -2,22 +2,32 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>LibNovel</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>LibNovel</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>cc.kalekber.libnovel</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<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>1000</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>
|
||||
@@ -31,13 +41,5 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>LIBNOVEL_BASE_URL</key>
|
||||
<string>$(LIBNOVEL_BASE_URL)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import Foundation
|
||||
import AVFoundation
|
||||
import MediaPlayer
|
||||
import Combine
|
||||
import Kingfisher
|
||||
|
||||
// MARK: - PlaybackProgress
|
||||
// Isolated ObservableObject for high-frequency playback state (currentTime,
|
||||
@@ -64,6 +65,9 @@ final class AudioPlayerService: ObservableObject {
|
||||
@Published var prevChapter: Int? = nil
|
||||
|
||||
@Published var sleepTimer: SleepTimerOption? = nil
|
||||
/// Human-readable countdown string shown in the full player near the moon button.
|
||||
/// e.g. "38:12" for minute-based, "2 ch left" for chapter-based, "" when off.
|
||||
@Published var sleepTimerRemainingText: String = ""
|
||||
|
||||
@Published var nextPrefetchStatus: NextPrefetchStatus = .none
|
||||
@Published var nextAudioURL: String = ""
|
||||
@@ -75,20 +79,6 @@ final class AudioPlayerService: ObservableObject {
|
||||
default: return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Absolute previous chapter number (current - 1), or nil if at first chapter
|
||||
var absolutePrevChapter: Int? {
|
||||
guard chapter > 1 else { return nil }
|
||||
return chapter - 1
|
||||
}
|
||||
|
||||
/// Absolute next chapter number (current + 1), or nil if at last chapter
|
||||
var absoluteNextChapter: Int? {
|
||||
guard !chapters.isEmpty else { return nil }
|
||||
let maxChapter = chapters.map(\.number).max() ?? chapter
|
||||
guard chapter < maxChapter else { return nil }
|
||||
return chapter + 1
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
@@ -109,6 +99,10 @@ final class AudioPlayerService: ObservableObject {
|
||||
// Sleep timer tracking
|
||||
private var sleepTimerTask: Task<Void, Never>?
|
||||
private var sleepTimerStartChapter: Int = 0
|
||||
/// Absolute deadline for minute-based timers (nil when not active or chapter-based).
|
||||
private var sleepTimerDeadline: Date? = nil
|
||||
/// 1-second tick task that keeps sleepTimerRemainingText up-to-date.
|
||||
private var sleepTimerCountdownTask: Task<Void, Never>? = nil
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
@@ -196,31 +190,67 @@ final class AudioPlayerService: ObservableObject {
|
||||
}
|
||||
|
||||
func setSleepTimer(_ option: SleepTimerOption?) {
|
||||
// Cancel existing timer
|
||||
// Cancel existing timer + countdown
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerTask = nil
|
||||
sleepTimerCountdownTask?.cancel()
|
||||
sleepTimerCountdownTask = nil
|
||||
sleepTimerDeadline = nil
|
||||
|
||||
sleepTimer = option
|
||||
|
||||
guard let option else { return }
|
||||
guard let option else {
|
||||
sleepTimerRemainingText = ""
|
||||
return
|
||||
}
|
||||
|
||||
// Start timer based on option
|
||||
switch option {
|
||||
case .chapters(let count):
|
||||
sleepTimerStartChapter = chapter
|
||||
// Monitor chapter changes in handlePlaybackFinished
|
||||
// Update display immediately; chapter changes are tracked in handlePlaybackFinished.
|
||||
updateChapterTimerLabel(chaptersRemaining: count)
|
||||
|
||||
case .minutes(let minutes):
|
||||
let deadline = Date().addingTimeInterval(Double(minutes) * 60)
|
||||
sleepTimerDeadline = deadline
|
||||
// Stop playback when the deadline is reached.
|
||||
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 = ""
|
||||
}
|
||||
}
|
||||
// 1-second tick to keep the countdown label fresh.
|
||||
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 deadline = self.sleepTimerDeadline else { return }
|
||||
let remaining = max(0, deadline.timeIntervalSinceNow)
|
||||
self.sleepTimerRemainingText = Self.formatCountdown(remaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Set initial label without waiting for the first tick.
|
||||
sleepTimerRemainingText = Self.formatCountdown(Double(minutes) * 60)
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
let m = s / 60
|
||||
let sec = s % 60
|
||||
return "\(m):\(String(format: "%02d", sec))"
|
||||
}
|
||||
|
||||
func stop() {
|
||||
player?.pause()
|
||||
@@ -231,16 +261,31 @@ final class AudioPlayerService: ObservableObject {
|
||||
audioURL = ""
|
||||
status = .idle
|
||||
|
||||
// Cancel sleep timer
|
||||
// Cancel sleep timer + countdown
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerTask = nil
|
||||
sleepTimerCountdownTask?.cancel()
|
||||
sleepTimerCountdownTask = nil
|
||||
sleepTimerDeadline = nil
|
||||
sleepTimer = nil
|
||||
sleepTimerRemainingText = ""
|
||||
}
|
||||
|
||||
// MARK: - Audio generation
|
||||
|
||||
private func generateAudio() async {
|
||||
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 {
|
||||
// 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) {
|
||||
@@ -408,6 +453,9 @@ final class AudioPlayerService: ObservableObject {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
// Update the remaining chapters label.
|
||||
let remaining = count - chaptersPlayed
|
||||
updateChapterTimerLabel(chaptersRemaining: remaining)
|
||||
}
|
||||
|
||||
// Always notify the view that the chapter finished (it may update UI).
|
||||
@@ -473,14 +521,17 @@ final class AudioPlayerService: ObservableObject {
|
||||
|
||||
private func prefetchCoverArtwork(from urlString: String) {
|
||||
guard !urlString.isEmpty, let url = URL(string: urlString) else { return }
|
||||
URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
|
||||
guard let self, let data, let image = UIImage(data: data) else { return }
|
||||
let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
|
||||
Task { @MainActor in
|
||||
self.cachedCoverArtwork = artwork
|
||||
self.updateNowPlaying()
|
||||
KingfisherManager.shared.retrieveImage(with: url) { [weak self] result in
|
||||
guard let self else { return }
|
||||
if case .success(let value) = result {
|
||||
let image = value.image
|
||||
let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
|
||||
Task { @MainActor in
|
||||
self.cachedCoverArtwork = artwork
|
||||
self.updateNowPlaying()
|
||||
}
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Audio Session
|
||||
|
||||
@@ -85,7 +85,14 @@ final class AuthStore: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Token validation (on cold launch)
|
||||
// MARK: - Token validation
|
||||
|
||||
/// Re-validates the current session and refreshes `user` + `settings`.
|
||||
/// Call this after any operation that may change the user record (e.g. avatar upload).
|
||||
func validateToken() async {
|
||||
guard let token = loadToken() else { return }
|
||||
await validateToken(token)
|
||||
}
|
||||
|
||||
private func validateToken(_ token: String) async {
|
||||
await APIClient.shared.setAuthCookie(token)
|
||||
@@ -93,7 +100,20 @@ final class AuthStore: ObservableObject {
|
||||
do {
|
||||
async let me: AppUser = APIClient.shared.fetch("/api/auth/me")
|
||||
async let s: UserSettings = APIClient.shared.settings()
|
||||
let (restoredUser, restoredSettings) = try await (me, s)
|
||||
var (restoredUser, restoredSettings) = try await (me, s)
|
||||
// /api/auth/me returns the raw MinIO object key for avatar_url, not a presigned URL.
|
||||
// Exchange the key for a fresh presigned GET URL so KFImage can display it.
|
||||
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 {
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ final class BookDetailViewModel: ObservableObject {
|
||||
@Published var saved: Bool = false
|
||||
@Published var lastChapter: Int?
|
||||
@Published var isLoading = false
|
||||
@Published var chaptersLoading = false
|
||||
@Published var error: String?
|
||||
|
||||
init(slug: String) {
|
||||
|
||||
@@ -52,13 +52,17 @@ final class ChapterReaderViewModel: ObservableObject {
|
||||
} else {
|
||||
let nextChapter: Int? = content.next
|
||||
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(
|
||||
slug: slug,
|
||||
chapter: chapter,
|
||||
chapterTitle: content.chapter.title,
|
||||
bookTitle: content.book.title,
|
||||
coverURL: content.book.cover,
|
||||
voice: settings.voice,
|
||||
voice: voice,
|
||||
speed: settings.speed,
|
||||
chapters: content.chapters,
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ final class HomeViewModel: ObservableObject {
|
||||
@Published var continueReading: [ContinueReadingItem] = []
|
||||
@Published var recentlyUpdated: [Book] = []
|
||||
@Published var stats: HomeStats?
|
||||
@Published var subscriptionFeed: [SubscriptionFeedItem] = []
|
||||
@Published var isLoading = false
|
||||
@Published var error: String?
|
||||
|
||||
@@ -18,6 +19,7 @@ final class HomeViewModel: ObservableObject {
|
||||
}
|
||||
recentlyUpdated = data.recentlyUpdated
|
||||
stats = data.stats
|
||||
subscriptionFeed = data.subscriptionFeed
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
self.error = error.localizedDescription
|
||||
|
||||
87
ios/LibNovel/LibNovel/ViewModels/UserProfileViewModel.swift
Normal file
87
ios/LibNovel/LibNovel/ViewModels/UserProfileViewModel.swift
Normal file
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class UserProfileViewModel: ObservableObject {
|
||||
let username: String
|
||||
|
||||
@Published var profile: PublicUserProfile?
|
||||
@Published var currentlyReading: [PublicLibraryItem] = []
|
||||
@Published var library: [PublicLibraryItem] = []
|
||||
@Published var isLoading = false
|
||||
@Published var isTogglingSubscribe = false
|
||||
@Published var error: String?
|
||||
|
||||
init(username: String) {
|
||||
self.username = username
|
||||
}
|
||||
|
||||
func load() async {
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
async let profileFetch = APIClient.shared.fetchUserProfile(username: username)
|
||||
async let libraryFetch = APIClient.shared.fetchUserLibrary(username: username)
|
||||
let (p, lib) = try await (profileFetch, libraryFetch)
|
||||
profile = p
|
||||
currentlyReading = lib.currentlyReading
|
||||
library = lib.library
|
||||
} catch let apiError as APIError {
|
||||
switch apiError {
|
||||
case .httpError(404, _): error = "User not found."
|
||||
default: error = apiError.localizedDescription
|
||||
}
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func toggleSubscribe() async {
|
||||
guard let p = profile, !p.isSelf, !isTogglingSubscribe else { return }
|
||||
isTogglingSubscribe = true
|
||||
defer { isTogglingSubscribe = false }
|
||||
do {
|
||||
if p.isSubscribed {
|
||||
try await APIClient.shared.unsubscribeUser(username: username)
|
||||
profile = PublicUserProfile(
|
||||
id: p.id, username: p.username, avatarUrl: p.avatarUrl,
|
||||
created: p.created,
|
||||
followerCount: max(0, p.followerCount - 1),
|
||||
followingCount: p.followingCount,
|
||||
isSubscribed: false, isSelf: p.isSelf
|
||||
)
|
||||
} else {
|
||||
try await APIClient.shared.subscribeUser(username: username)
|
||||
profile = PublicUserProfile(
|
||||
id: p.id, username: p.username, avatarUrl: p.avatarUrl,
|
||||
created: p.created,
|
||||
followerCount: p.followerCount + 1,
|
||||
followingCount: p.followingCount,
|
||||
isSubscribed: true, isSelf: p.isSelf
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience memberwise init for PublicUserProfile (used in optimistic updates)
|
||||
|
||||
private extension PublicUserProfile {
|
||||
init(id: String, username: String, avatarUrl: String?, created: String,
|
||||
followerCount: Int, followingCount: Int, isSubscribed: Bool, isSelf: Bool) {
|
||||
// Encode then decode to go through the standard Decodable path without duplicating code
|
||||
var dict: [String: Any] = [
|
||||
"id": id, "username": username, "created": created,
|
||||
"followerCount": followerCount, "followingCount": followingCount,
|
||||
"isSubscribed": isSubscribed, "isSelf": isSelf
|
||||
]
|
||||
if let url = avatarUrl { dict["avatarUrl"] = url }
|
||||
let data = try! JSONSerialization.data(withJSONObject: dict)
|
||||
self = try! JSONDecoder().decode(PublicUserProfile.self, from: data)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,7 @@ struct AuthView: View {
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
}
|
||||
.onChange(of: mode) { _, _ in
|
||||
authStore.error = nil
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
struct BookDetailView: View {
|
||||
let slug: String
|
||||
@@ -6,8 +7,7 @@ struct BookDetailView: View {
|
||||
@EnvironmentObject var authStore: AuthStore
|
||||
@EnvironmentObject var audioPlayer: AudioPlayerService
|
||||
@State private var summaryExpanded = false
|
||||
@State private var chapterPage = 0
|
||||
private let pageSize = 50
|
||||
@State private var showChapters = false
|
||||
|
||||
init(slug: String) {
|
||||
self.slug = slug
|
||||
@@ -15,22 +15,40 @@ struct BookDetailView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
if vm.isLoading {
|
||||
ProgressView().frame(maxWidth: .infinity).padding(.top, 80)
|
||||
} else if let book = vm.book {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
heroSection(book: book)
|
||||
Divider().padding(.vertical, 8)
|
||||
chapterSection(book: book)
|
||||
VStack(spacing: 0) {
|
||||
OfflineBanner()
|
||||
|
||||
ZStack(alignment: .top) {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if vm.isLoading {
|
||||
ProgressView().frame(maxWidth: .infinity).padding(.top, 120)
|
||||
} else if let book = vm.book {
|
||||
heroSection(book: book)
|
||||
metaSection(book: book)
|
||||
Divider().padding(.horizontal)
|
||||
chaptersRow(book: book)
|
||||
Divider().padding(.horizontal)
|
||||
CommentsView(slug: slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea(edges: .top)
|
||||
}
|
||||
}
|
||||
.navigationTitle("")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.appNavigationDestination()
|
||||
.toolbar { bookmarkButton }
|
||||
.task { await vm.load() }
|
||||
.errorAlert($vm.error)
|
||||
.sheet(isPresented: $showChapters) {
|
||||
BookChaptersSheet(
|
||||
slug: slug,
|
||||
chapters: vm.chapters,
|
||||
lastChapter: vm.lastChapter,
|
||||
totalChapters: vm.book?.totalChapters ?? 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hero
|
||||
@@ -38,139 +56,191 @@ struct BookDetailView: View {
|
||||
@ViewBuilder
|
||||
private func heroSection(book: Book) -> some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
// Blurred cover background — use plain colour placeholder to avoid
|
||||
// the rounded-rect loading indicator showing through the blur.
|
||||
AsyncCoverImage(url: book.cover, isBackground: true)
|
||||
// Full-bleed blurred background
|
||||
KFImage(URL(string: book.cover))
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 260)
|
||||
.blur(radius: 20)
|
||||
.frame(height: 320)
|
||||
.blur(radius: 24)
|
||||
.clipped()
|
||||
.overlay(Color.black.opacity(0.45))
|
||||
.overlay(
|
||||
LinearGradient(
|
||||
colors: [.black.opacity(0.15), .black.opacity(0.68)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
|
||||
HStack(alignment: .bottom, spacing: 14) {
|
||||
AsyncCoverImage(url: book.cover)
|
||||
.frame(width: 110, height: 160)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.shadow(radius: 8)
|
||||
VStack(spacing: 16) {
|
||||
KFImage(URL(string: book.cover))
|
||||
.resizable()
|
||||
.placeholder {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(.systemGray5))
|
||||
}
|
||||
.scaledToFill()
|
||||
.frame(width: 130, height: 188)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(color: .black.opacity(0.55), radius: 18, x: 0, y: 10)
|
||||
.shadow(color: .black.opacity(0.3), radius: 6, x: 0, y: 3)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
VStack(spacing: 6) {
|
||||
Text(book.title)
|
||||
.font(.headline)
|
||||
.font(.title3.bold())
|
||||
.foregroundStyle(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(3)
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
Text(book.author)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
HStack {
|
||||
// TagChip(label: book.status).colorScheme(.dark)
|
||||
ForEach(book.genres.prefix(2), id: \.self) {
|
||||
TagChip(label: $0).colorScheme(.dark)
|
||||
.foregroundStyle(.white.opacity(0.75))
|
||||
}
|
||||
|
||||
if !book.genres.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(book.genres.prefix(3), id: \.self) { genre in
|
||||
TagChip(label: genre).colorScheme(.dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if !book.status.isEmpty {
|
||||
StatusBadge(status: book.status)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 16)
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
// Summary
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(book.summary)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(summaryExpanded ? nil : 4)
|
||||
if book.summary.count > 200 {
|
||||
Button(summaryExpanded ? "Less" : "More") {
|
||||
withAnimation { summaryExpanded.toggle() }
|
||||
}
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
|
||||
// CTA buttons
|
||||
HStack(spacing: 10) {
|
||||
if let last = vm.lastChapter, last > 0 {
|
||||
NavigationLink(value: NavDestination.chapter(slug, last)) {
|
||||
Label("Continue Ch.\(last)", systemImage: "play.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.amber)
|
||||
|
||||
NavigationLink(value: NavDestination.chapter(slug, 1)) {
|
||||
Label("From Ch.1", systemImage: "arrow.counterclockwise")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.secondary)
|
||||
} else {
|
||||
NavigationLink(value: NavDestination.chapter(slug, 1)) {
|
||||
Label("Start Reading", systemImage: "book.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.amber)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
.frame(minHeight: 320)
|
||||
}
|
||||
|
||||
// MARK: - Chapter list
|
||||
// MARK: - Meta section (stats + summary + CTAs)
|
||||
|
||||
@ViewBuilder
|
||||
private func chapterSection(book: Book) -> some View {
|
||||
let chapters = vm.chapters
|
||||
let total = chapters.count
|
||||
let start = chapterPage * pageSize
|
||||
let end = min(start + pageSize, total)
|
||||
let pageChapters = Array(chapters[start..<end])
|
||||
|
||||
private func metaSection(book: Book) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
Text("Chapters")
|
||||
.font(.title3.bold())
|
||||
Spacer()
|
||||
if total > 0 {
|
||||
Text("\(start + 1)–\(end) of \(total)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
// Quick stats row
|
||||
HStack(spacing: 0) {
|
||||
MetaStat(value: "\(book.totalChapters)", label: "Chapters", icon: "doc.text")
|
||||
Divider().frame(height: 36)
|
||||
MetaStat(
|
||||
value: book.status.capitalized.isEmpty ? "—" : book.status.capitalized,
|
||||
label: "Status", icon: "flag"
|
||||
)
|
||||
if book.ranking > 0 {
|
||||
Divider().frame(height: 36)
|
||||
MetaStat(value: "#\(book.ranking)", label: "Rank", icon: "chart.bar.fill")
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Divider().padding(.horizontal)
|
||||
|
||||
// Summary
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("About")
|
||||
.font(.headline)
|
||||
|
||||
Text(book.summary)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(summaryExpanded ? nil : 4)
|
||||
.animation(.easeInOut(duration: 0.2), value: summaryExpanded)
|
||||
|
||||
if book.summary.count > 200 {
|
||||
Button(summaryExpanded ? "Less" : "More") {
|
||||
withAnimation { summaryExpanded.toggle() }
|
||||
}
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.vertical, 16)
|
||||
|
||||
if vm.chaptersLoading {
|
||||
ProgressView().frame(maxWidth: .infinity).padding()
|
||||
} else {
|
||||
ForEach(pageChapters) { ch in
|
||||
NavigationLink(value: NavDestination.chapter(slug, ch.number)) {
|
||||
ChapterRow(chapter: ch, isCurrent: ch.number == vm.lastChapter)
|
||||
Divider().padding(.horizontal)
|
||||
|
||||
// CTA buttons
|
||||
HStack(spacing: 10) {
|
||||
if let last = vm.lastChapter, last > 0 {
|
||||
NavigationLink(value: NavDestination.chapter(slug, last)) {
|
||||
Label("Continue Ch.\(last)", systemImage: "play.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Divider().padding(.leading)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.amber)
|
||||
|
||||
// Pagination
|
||||
if total > pageSize {
|
||||
HStack {
|
||||
Button("Previous") { chapterPage -= 1 }
|
||||
.disabled(chapterPage == 0)
|
||||
Spacer()
|
||||
Button("Next") { chapterPage += 1 }
|
||||
.disabled(end >= total)
|
||||
NavigationLink(value: NavDestination.chapter(slug, 1)) {
|
||||
Label("From Ch.1", systemImage: "arrow.counterclockwise")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.secondary)
|
||||
} else {
|
||||
NavigationLink(value: NavDestination.chapter(slug, 1)) {
|
||||
Label("Start Reading", systemImage: "book.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.amber)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.padding()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Toolbar bookmark
|
||||
// MARK: - Compact chapters row (tap → sheet)
|
||||
|
||||
@ViewBuilder
|
||||
private func chaptersRow(book: Book) -> some View {
|
||||
Button {
|
||||
showChapters = true
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "list.number")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.amber)
|
||||
.frame(width: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Chapters")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
if !vm.chapters.isEmpty {
|
||||
let last = vm.lastChapter
|
||||
let total = vm.chapters.count
|
||||
Text(last != nil && last! > 0
|
||||
? "Reading Ch.\(last!) of \(total)"
|
||||
: "\(total) chapter\(total == 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)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: - Bookmark toolbar
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var bookmarkButton: some ToolbarContent {
|
||||
@@ -185,38 +255,454 @@ struct BookDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChapterRow: View {
|
||||
let chapter: ChapterIndex
|
||||
let isCurrent: Bool
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Chapter \(chapter.number)")
|
||||
.font(.subheadline)
|
||||
.fontWeight(isCurrent ? .bold : .regular)
|
||||
.foregroundStyle(isCurrent ? .amber : .primary)
|
||||
if !chapter.title.isEmpty && chapter.title != "Chapter \(chapter.number)" {
|
||||
Text(chapter.title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 12)
|
||||
HStack(spacing: 6) {
|
||||
if !chapter.dateLabel.isEmpty {
|
||||
Text(chapter.dateLabel)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.fixedSize()
|
||||
}
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
// 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 {
|
||||
let slug: String
|
||||
let chapters: [ChapterIndex]
|
||||
let lastChapter: Int?
|
||||
let totalChapters: Int
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject var downloadService: AudioDownloadService
|
||||
@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] {
|
||||
var result = chapters
|
||||
|
||||
if filterOfflineOnly {
|
||||
result = result.filter { ch in
|
||||
downloadService.isDownloaded(slug: slug, chapter: ch.number, voice: defaultVoice)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 10)
|
||||
.contentShape(Rectangle())
|
||||
|
||||
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 {
|
||||
NavigationStack {
|
||||
ZStack(alignment: .trailing) {
|
||||
// ── Main chapter list ──────────────────────────────────────
|
||||
List {
|
||||
// Offline downloads summary (shown when at least one chapter is downloaded)
|
||||
if downloadedCount > 0 || downloadingCount > 0 {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Offline Downloads")
|
||||
.font(.headline)
|
||||
Text("\(downloadedCount) of \(chapters.count) chapters")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
showingDownloadAll = true
|
||||
} label: {
|
||||
Label("Manage", systemImage: "arrow.down.circle")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.blue)
|
||||
}
|
||||
|
||||
if downloadingCount > 0 {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
Text("Downloading \(downloadingCount) \(downloadingCount == 1 ? "chapter" : "chapters")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Toggle("Show offline only", isOn: $filterOfflineOnly)
|
||||
.font(.subheadline)
|
||||
.tint(.amber)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(groups, id: \.label) { group in
|
||||
Section {
|
||||
ForEach(group.chapters, id: \.number) { ch in
|
||||
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 (\(filtered.count))")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
.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])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Individual chapter row with download status + NavigationLink
|
||||
|
||||
private struct BookChapterRow: View {
|
||||
let chapter: ChapterIndex
|
||||
let slug: String
|
||||
let isCurrent: Bool
|
||||
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 {
|
||||
NavigationLink(value: NavDestination.chapter(slug, chapter.number)) {
|
||||
HStack(spacing: 14) {
|
||||
// Number badge with optional download-progress arc ring
|
||||
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)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.listRowBackground(isCurrent ? Color.amber.opacity(0.08) : Color.clear)
|
||||
// Trailing swipe: Download / Cancel / Delete
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting components
|
||||
|
||||
private struct MetaStat: View {
|
||||
let value: String
|
||||
let label: String
|
||||
let icon: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.amber)
|
||||
Text(value)
|
||||
.font(.subheadline.bold())
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private struct StatusBadge: 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())
|
||||
}
|
||||
}
|
||||
|
||||
643
ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift
Normal file
643
ios/LibNovel/LibNovel/Views/BookDetail/CommentsView.swift
Normal file
@@ -0,0 +1,643 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - ViewModel
|
||||
|
||||
@MainActor
|
||||
class CommentsViewModel: ObservableObject {
|
||||
let slug: String
|
||||
|
||||
@Published var comments: [BookComment] = []
|
||||
@Published var myVotes: [String: String] = [:] // commentId → "up" | "down"
|
||||
@Published var avatarUrls: [String: String] = [:] // userId → presigned URL
|
||||
@Published var isLoading = true
|
||||
@Published var error: String?
|
||||
|
||||
@Published var newBody = ""
|
||||
@Published var isPosting = false
|
||||
@Published var postError: String?
|
||||
|
||||
@Published var sort: CommentSortOrder = .top
|
||||
|
||||
// Reply state
|
||||
@Published var replyingToId: String? = nil
|
||||
@Published var replyBody = ""
|
||||
@Published var isPostingReply = false
|
||||
@Published var replyError: String?
|
||||
|
||||
private var votingIds: Set<String> = []
|
||||
private var deletingIds: Set<String> = []
|
||||
|
||||
init(slug: String) {
|
||||
self.slug = slug
|
||||
}
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
let response = try await APIClient.shared.fetchComments(slug: slug, sort: sort.rawValue)
|
||||
comments = response.comments
|
||||
myVotes = response.myVotes
|
||||
avatarUrls = response.avatarUrls
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func postComment() async {
|
||||
let text = newBody.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty, !isPosting else { return }
|
||||
if text.count > 2000 {
|
||||
postError = "Comment too long (max 2000 characters)."
|
||||
return
|
||||
}
|
||||
isPosting = true
|
||||
postError = nil
|
||||
do {
|
||||
var created = try await APIClient.shared.postComment(slug: slug, body: text)
|
||||
created.replies = []
|
||||
comments.insert(created, at: 0)
|
||||
newBody = ""
|
||||
} catch let apiError as APIError {
|
||||
switch apiError {
|
||||
case .httpError(401, _): postError = "You must be logged in to comment."
|
||||
default: postError = apiError.localizedDescription
|
||||
}
|
||||
} catch {
|
||||
postError = error.localizedDescription
|
||||
}
|
||||
isPosting = false
|
||||
}
|
||||
|
||||
func postReply(parentId: String) async {
|
||||
let text = replyBody.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty, !isPostingReply else { return }
|
||||
if text.count > 2000 {
|
||||
replyError = "Reply too long (max 2000 characters)."
|
||||
return
|
||||
}
|
||||
isPostingReply = true
|
||||
replyError = nil
|
||||
do {
|
||||
let created = try await APIClient.shared.postComment(slug: slug, body: text, parentId: parentId)
|
||||
if let idx = comments.firstIndex(where: { $0.id == parentId }) {
|
||||
var parent = comments[idx]
|
||||
var replies = parent.replies ?? []
|
||||
replies.append(created)
|
||||
parent.replies = replies
|
||||
comments[idx] = parent
|
||||
}
|
||||
replyBody = ""
|
||||
replyingToId = nil
|
||||
} catch let apiError as APIError {
|
||||
switch apiError {
|
||||
case .httpError(401, _): replyError = "You must be logged in to reply."
|
||||
default: replyError = apiError.localizedDescription
|
||||
}
|
||||
} catch {
|
||||
replyError = error.localizedDescription
|
||||
}
|
||||
isPostingReply = false
|
||||
}
|
||||
|
||||
func deleteComment(commentId: String, parentId: String? = nil) async {
|
||||
guard !deletingIds.contains(commentId) else { return }
|
||||
deletingIds.insert(commentId)
|
||||
|
||||
// Optimistic removal — update the UI immediately before the network call
|
||||
var removedComment: BookComment?
|
||||
var removedAtIndex: Int?
|
||||
if let parentId {
|
||||
if let idx = comments.firstIndex(where: { $0.id == parentId }) {
|
||||
var parent = comments[idx]
|
||||
removedComment = parent.replies?.first(where: { $0.id == commentId })
|
||||
removedAtIndex = idx
|
||||
parent.replies = (parent.replies ?? []).filter { $0.id != commentId }
|
||||
comments[idx] = parent
|
||||
}
|
||||
} else {
|
||||
removedAtIndex = comments.firstIndex(where: { $0.id == commentId })
|
||||
removedComment = removedAtIndex.map { comments[$0] }
|
||||
comments.removeAll { $0.id == commentId }
|
||||
}
|
||||
|
||||
do {
|
||||
try await APIClient.shared.deleteComment(commentId: commentId)
|
||||
} catch {
|
||||
// Revert the optimistic removal on failure
|
||||
if let removed = removedComment {
|
||||
if let parentId, let idx = removedAtIndex {
|
||||
var parent = comments[idx]
|
||||
var replies = parent.replies ?? []
|
||||
replies.append(removed)
|
||||
replies.sort { $0.created < $1.created }
|
||||
parent.replies = replies
|
||||
comments[idx] = parent
|
||||
} else if let idx = removedAtIndex {
|
||||
comments.insert(removed, at: min(idx, comments.count))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deletingIds.remove(commentId)
|
||||
}
|
||||
|
||||
func vote(commentId: String, vote: String, parentId: String? = nil) async {
|
||||
guard !votingIds.contains(commentId) else { return }
|
||||
votingIds.insert(commentId)
|
||||
defer { votingIds.remove(commentId) }
|
||||
do {
|
||||
let updated = try await APIClient.shared.voteComment(commentId: commentId, vote: vote)
|
||||
if let parentId {
|
||||
if let idx = comments.firstIndex(where: { $0.id == parentId }) {
|
||||
var parent = comments[idx]
|
||||
if let rIdx = parent.replies?.firstIndex(where: { $0.id == commentId }) {
|
||||
parent.replies![rIdx] = updated
|
||||
}
|
||||
comments[idx] = parent
|
||||
}
|
||||
} else {
|
||||
if let idx = comments.firstIndex(where: { $0.id == commentId }) {
|
||||
var c = updated
|
||||
c.replies = comments[idx].replies
|
||||
comments[idx] = c
|
||||
}
|
||||
}
|
||||
let prev = myVotes[commentId]
|
||||
if prev == vote {
|
||||
myVotes.removeValue(forKey: commentId)
|
||||
} else {
|
||||
myVotes[commentId] = vote
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore vote errors
|
||||
}
|
||||
}
|
||||
|
||||
func isVoting(_ commentId: String) -> Bool { votingIds.contains(commentId) }
|
||||
func isDeleting(_ commentId: String) -> Bool { deletingIds.contains(commentId) }
|
||||
|
||||
func setSort(_ newSort: CommentSortOrder) {
|
||||
guard newSort != sort else { return }
|
||||
sort = newSort
|
||||
Task { await load() }
|
||||
}
|
||||
}
|
||||
|
||||
enum CommentSortOrder: String, CaseIterable {
|
||||
case top = "top"
|
||||
case new = "new"
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .top: return "Top"
|
||||
case .new: return "New"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CommentsView
|
||||
|
||||
struct CommentsView: View {
|
||||
@StateObject private var vm: CommentsViewModel
|
||||
@EnvironmentObject private var authStore: AuthStore
|
||||
|
||||
init(slug: String) {
|
||||
_vm = StateObject(wrappedValue: CommentsViewModel(slug: slug))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Section header + sort picker
|
||||
HStack {
|
||||
Text("Comments")
|
||||
.font(.headline)
|
||||
let total = vm.comments.reduce(0) { $0 + 1 + ($1.replies?.count ?? 0) }
|
||||
if !vm.isLoading && total > 0 {
|
||||
Text("(\(total))")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
// Sort picker
|
||||
if !vm.isLoading && !vm.comments.isEmpty {
|
||||
Picker("Sort", selection: Binding(
|
||||
get: { vm.sort },
|
||||
set: { vm.setSort($0) }
|
||||
)) {
|
||||
ForEach(CommentSortOrder.allCases, id: \.self) { s in
|
||||
Text(s.label).tag(s)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(width: 120)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 14)
|
||||
|
||||
Divider().padding(.horizontal)
|
||||
|
||||
// Post form
|
||||
postForm
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
Divider().padding(.horizontal)
|
||||
|
||||
// Comment list
|
||||
if vm.isLoading {
|
||||
loadingPlaceholder
|
||||
} else if let err = vm.error {
|
||||
Text(err)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.red)
|
||||
.padding()
|
||||
} else if vm.comments.isEmpty {
|
||||
Text("No comments yet. Be the first!")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding()
|
||||
} else {
|
||||
ForEach(vm.comments) { comment in
|
||||
commentThread(comment: comment)
|
||||
Divider().padding(.leading, 16)
|
||||
}
|
||||
}
|
||||
|
||||
Color.clear.frame(height: 16)
|
||||
}
|
||||
.task { await vm.load() }
|
||||
}
|
||||
|
||||
// MARK: - Comment thread (top-level + replies)
|
||||
|
||||
@ViewBuilder
|
||||
private func commentThread(comment: BookComment) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
CommentRow(
|
||||
comment: comment,
|
||||
myVote: vm.myVotes[comment.id],
|
||||
isVoting: vm.isVoting(comment.id),
|
||||
isDeleting: vm.isDeleting(comment.id),
|
||||
isOwner: authStore.user?.id == comment.userId,
|
||||
isLoggedIn: authStore.isAuthenticated,
|
||||
isReplyingTo: vm.replyingToId == comment.id,
|
||||
avatarUrl: vm.avatarUrls[comment.userId],
|
||||
onVote: { v in Task { await vm.vote(commentId: comment.id, vote: v) } },
|
||||
onDelete: { Task { await vm.deleteComment(commentId: comment.id) } },
|
||||
onReply: {
|
||||
if vm.replyingToId == comment.id {
|
||||
vm.replyingToId = nil
|
||||
vm.replyBody = ""
|
||||
vm.replyError = nil
|
||||
} else {
|
||||
vm.replyingToId = comment.id
|
||||
vm.replyBody = ""
|
||||
vm.replyError = nil
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Inline reply form
|
||||
if vm.replyingToId == comment.id {
|
||||
replyForm(parentId: comment.id)
|
||||
.padding(.leading, 32)
|
||||
.padding(.trailing, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
// Replies
|
||||
if let replies = comment.replies, !replies.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(replies) { reply in
|
||||
CommentRow(
|
||||
comment: reply,
|
||||
myVote: vm.myVotes[reply.id],
|
||||
isVoting: vm.isVoting(reply.id),
|
||||
isDeleting: vm.isDeleting(reply.id),
|
||||
isOwner: authStore.user?.id == reply.userId,
|
||||
isLoggedIn: authStore.isAuthenticated,
|
||||
isReplyingTo: false,
|
||||
isReply: true,
|
||||
avatarUrl: vm.avatarUrls[reply.userId],
|
||||
onVote: { v in Task { await vm.vote(commentId: reply.id, vote: v, parentId: comment.id) } },
|
||||
onDelete: { Task { await vm.deleteComment(commentId: reply.id, parentId: comment.id) } },
|
||||
onReply: nil
|
||||
)
|
||||
if reply.id != replies.last?.id {
|
||||
Divider().padding(.leading, 48)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.leading, 24)
|
||||
.overlay(alignment: .leading) {
|
||||
Rectangle()
|
||||
.fill(Color(.systemGray4))
|
||||
.frame(width: 2)
|
||||
.padding(.leading, 16)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reply form
|
||||
|
||||
@ViewBuilder
|
||||
private func replyForm(parentId: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if vm.replyBody.isEmpty {
|
||||
Text("Write a reply…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.top, 6)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
TextEditor(text: $vm.replyBody)
|
||||
.font(.caption)
|
||||
.frame(minHeight: 56, maxHeight: 120)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
HStack {
|
||||
let count = vm.replyBody.count
|
||||
Text("\(count)/2000")
|
||||
.font(.caption2)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(count > 2000 ? Color.red : Color.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let err = vm.replyError {
|
||||
Text(err).font(.caption2).foregroundStyle(.red).lineLimit(1)
|
||||
}
|
||||
|
||||
Button("Cancel") {
|
||||
vm.replyingToId = nil
|
||||
vm.replyBody = ""
|
||||
vm.replyError = nil
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Button {
|
||||
Task { await vm.postReply(parentId: parentId) }
|
||||
} label: {
|
||||
if vm.isPostingReply {
|
||||
ProgressView().controlSize(.mini)
|
||||
} else {
|
||||
Text("Reply").fontWeight(.semibold).font(.caption)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.amber)
|
||||
.controlSize(.mini)
|
||||
.disabled(vm.isPostingReply || vm.replyBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.replyBody.count > 2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Post form
|
||||
|
||||
@ViewBuilder
|
||||
private var postForm: some View {
|
||||
if authStore.isAuthenticated {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if vm.newBody.isEmpty {
|
||||
Text("Write a comment…")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.top, 8)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
TextEditor(text: $vm.newBody)
|
||||
.font(.subheadline)
|
||||
.frame(minHeight: 72, maxHeight: 160)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
HStack {
|
||||
let count = vm.newBody.count
|
||||
Text("\(count)/2000")
|
||||
.font(.caption2)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(count > 2000 ? Color.red : Color.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let err = vm.postError {
|
||||
Text(err)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await vm.postComment() }
|
||||
} label: {
|
||||
if vm.isPosting {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Post")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.amber)
|
||||
.controlSize(.small)
|
||||
.disabled(vm.isPosting || vm.newBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.newBody.count > 2000)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("Log in to leave a comment.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Loading skeleton
|
||||
|
||||
@ViewBuilder
|
||||
private var loadingPlaceholder: some View {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(0..<3, id: \.self) { _ in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color(.systemGray5))
|
||||
.frame(width: 100, height: 12)
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color(.systemGray6))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 12)
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color(.systemGray6))
|
||||
.frame(width: 200, height: 12)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.redacted(reason: .placeholder)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CommentRow
|
||||
|
||||
private struct CommentRow: View {
|
||||
let comment: BookComment
|
||||
let myVote: String?
|
||||
let isVoting: Bool
|
||||
let isDeleting: Bool
|
||||
let isOwner: Bool
|
||||
let isLoggedIn: Bool
|
||||
let isReplyingTo: Bool
|
||||
var isReply: Bool = false
|
||||
var avatarUrl: String? = nil
|
||||
let onVote: (String) -> Void
|
||||
let onDelete: () -> Void
|
||||
let onReply: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
// Avatar + Username + date
|
||||
HStack(spacing: 8) {
|
||||
avatarView
|
||||
NavigationLink(value: NavDestination.userProfile(comment.username.isEmpty ? "" : comment.username)) {
|
||||
Text(comment.username.isEmpty ? "Anonymous" : comment.username)
|
||||
.font(isReply ? .caption.weight(.medium) : .subheadline.weight(.medium))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(comment.username.isEmpty)
|
||||
Text("·")
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(formattedDate(comment.created))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// Body
|
||||
Text(comment.body)
|
||||
.font(isReply ? .caption : .subheadline)
|
||||
.foregroundStyle(.primary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
// Actions
|
||||
HStack(spacing: 14) {
|
||||
// Upvote
|
||||
Button { onVote("up") } label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: myVote == "up" ? "hand.thumbsup.fill" : "hand.thumbsup")
|
||||
.font(.caption)
|
||||
Text("\(comment.upvotes)")
|
||||
.font(.caption.monospacedDigit())
|
||||
}
|
||||
.foregroundStyle(myVote == "up" ? Color.amber : .secondary)
|
||||
}
|
||||
.disabled(isVoting)
|
||||
|
||||
// Downvote
|
||||
Button { onVote("down") } label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: myVote == "down" ? "hand.thumbsdown.fill" : "hand.thumbsdown")
|
||||
.font(.caption)
|
||||
Text("\(comment.downvotes)")
|
||||
.font(.caption.monospacedDigit())
|
||||
}
|
||||
.foregroundStyle(myVote == "down" ? .red : .secondary)
|
||||
}
|
||||
.disabled(isVoting)
|
||||
|
||||
// Reply button (top-level only, logged in)
|
||||
if let onReply, isLoggedIn {
|
||||
Button { onReply() } label: {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "arrowshape.turn.up.left")
|
||||
.font(.caption)
|
||||
Text("Reply")
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundStyle(isReplyingTo ? Color.amber : .secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Delete (owner only)
|
||||
if isOwner {
|
||||
Button(role: .destructive) { onDelete() } label: {
|
||||
Image(systemName: "trash")
|
||||
.font(.caption)
|
||||
}
|
||||
.disabled(isDeleting)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.opacity(isDeleting ? 0.5 : 1)
|
||||
.animation(.easeInOut(duration: 0.15), value: isDeleting)
|
||||
}
|
||||
|
||||
private var avatarSize: CGFloat { isReply ? 20 : 24 }
|
||||
|
||||
@ViewBuilder
|
||||
private var avatarView: some View {
|
||||
if let url = avatarUrl, let imageUrl = URL(string: url) {
|
||||
AsyncImage(url: imageUrl) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image.resizable().scaledToFill()
|
||||
default:
|
||||
initialsView
|
||||
}
|
||||
}
|
||||
.frame(width: avatarSize, height: avatarSize)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
initialsView
|
||||
}
|
||||
}
|
||||
|
||||
private var initialsView: some View {
|
||||
let name = comment.username.isEmpty ? "?" : comment.username
|
||||
let letters = String(name.prefix(2)).uppercased()
|
||||
return ZStack {
|
||||
Circle()
|
||||
.fill(Color(.systemGray4))
|
||||
.frame(width: avatarSize, height: avatarSize)
|
||||
Text(letters)
|
||||
.font(.system(size: avatarSize * 0.42, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func formattedDate(_ iso: String) -> String {
|
||||
// PocketBase returns "2006-01-02 15:04:05.999Z" format
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = formatter.date(from: iso) {
|
||||
let rel = RelativeDateTimeFormatter()
|
||||
rel.unitsStyle = .abbreviated
|
||||
return rel.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
// Fallback: try space-separated format
|
||||
let df = DateFormatter()
|
||||
df.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZ"
|
||||
if let date = df.date(from: iso) {
|
||||
let rel = RelativeDateTimeFormatter()
|
||||
rel.unitsStyle = .abbreviated
|
||||
return rel.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
return String(iso.prefix(10))
|
||||
}
|
||||
}
|
||||
@@ -1,165 +1,522 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BrowseView: View {
|
||||
@StateObject private var vm = BrowseViewModel()
|
||||
@State private var showFilters = false
|
||||
// MARK: - Discover View (Browse)
|
||||
// Serendipity-focused browsing with curated shelves.
|
||||
// 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 {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
// Search bar
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
|
||||
TextField("Search novels...", text: $vm.searchQuery)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.submitLabel(.search)
|
||||
.onSubmit { Task { await vm.search() } }
|
||||
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) {
|
||||
FilterChip(label: "Sort: \(vm.sort.capitalized)", isActive: vm.sort != "popular") {
|
||||
showFilters = true
|
||||
}
|
||||
FilterChip(label: "Genre: \(vm.genre == "all" ? "All" : vm.genre.capitalized)", isActive: vm.genre != "all") {
|
||||
showFilters = true
|
||||
}
|
||||
FilterChip(label: "Status: \(vm.status == "all" ? "All" : vm.status.capitalized)", isActive: vm.status != "all") {
|
||||
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 {
|
||||
OfflineBanner()
|
||||
|
||||
Group {
|
||||
if vm.isLoading && vm.trending.isEmpty {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let errorMsg = vm.error, vm.trending.isEmpty {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "wifi.slash")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(errMsg)
|
||||
Text(errorMsg)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
Button("Retry") { Task { await vm.loadFirstPage() } }
|
||||
Button("Retry") { Task { await vm.load() } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.amber)
|
||||
} else {
|
||||
EmptyStateView(icon: "magnifyingglass", title: "No results", message: "Try a different search or filter.")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], spacing: 16) {
|
||||
ForEach(vm.novels) { novel in
|
||||
NavigationLink(value: NavDestination.book(novel.slug)) {
|
||||
BrowseCard(novel: novel)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 32) {
|
||||
// Trending shelf
|
||||
if !vm.trending.isEmpty {
|
||||
DiscoverShelf(
|
||||
title: "Trending Now",
|
||||
novels: vm.trending,
|
||||
destination: .browseCategory(
|
||||
sort: "popular",
|
||||
genre: "all",
|
||||
status: "all",
|
||||
title: "Trending Now"
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// Infinite scroll trigger
|
||||
if vm.hasNext {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.onAppear { Task { await vm.loadNextPage() } }
|
||||
|
||||
// Top Rated shelf
|
||||
if !vm.topRated.isEmpty {
|
||||
DiscoverShelf(
|
||||
title: "Top Rated",
|
||||
novels: vm.topRated,
|
||||
destination: .browseCategory(
|
||||
sort: "rating",
|
||||
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")
|
||||
.appNavigationDestination()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
HStack(spacing: 16) {
|
||||
DownloadQueueButton()
|
||||
AvatarToolbarButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await vm.load() }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showGenreSheet) {
|
||||
GenrePickerSheet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Categories row (Apple Books–style single button)
|
||||
|
||||
private struct CategoriesRow: View {
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
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)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Discover")
|
||||
.appNavigationDestination()
|
||||
.sheet(isPresented: $showFilters) {
|
||||
BrowseFiltersView(vm: vm)
|
||||
}
|
||||
.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() } }
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationCornerRadius(20)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Filter chip
|
||||
|
||||
private struct FilterChip: View {
|
||||
private struct GenreTile: View {
|
||||
let label: String
|
||||
let isActive: Bool
|
||||
let action: () -> Void
|
||||
let icon: String
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(Color.amber)
|
||||
.frame(width: 24)
|
||||
Text(label)
|
||||
.font(.caption.bold())
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(isActive ? Color.amber.opacity(0.15) : Color(.systemGray6), in: Capsule())
|
||||
.foregroundStyle(isActive ? .amber : .primary)
|
||||
.overlay(Capsule().strokeBorder(isActive ? Color.amber : .clear, lineWidth: 1))
|
||||
.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: - Browse card
|
||||
// MARK: - Shelf card (card-style)
|
||||
|
||||
private struct BrowseCard: View {
|
||||
private struct DiscoverShelfCard: View {
|
||||
let novel: BrowseNovel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
AsyncCoverImage(url: novel.cover)
|
||||
.frame(height: 200)
|
||||
.frame(width: 120, height: 173) // 2:3 ratio
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.bookCoverZoomSource(slug: novel.slug)
|
||||
|
||||
if !novel.rank.isEmpty {
|
||||
Text(novel.rank)
|
||||
.font(.caption2.bold())
|
||||
.padding(.horizontal, 6).padding(.vertical, 3)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
Text(novel.title)
|
||||
.font(.caption.bold()).lineLimit(2)
|
||||
if !novel.chapters.isEmpty {
|
||||
Text(novel.chapters).font(.caption2).foregroundStyle(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(novel.title)
|
||||
.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 {
|
||||
@ObservedObject var vm: BrowseViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
|
||||
let sortOptions = ["popular", "new", "updated", "rating", "rank"]
|
||||
let genreOptions = ["all", "action", "fantasy", "romance", "sci-fi", "mystery",
|
||||
"horror", "comedy", "drama", "adventure", "martial arts",
|
||||
"cultivation", "magic", "supernatural", "historical", "slice of life"]
|
||||
let statusOptions = ["all", "ongoing", "completed"]
|
||||
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -80,3 +80,85 @@ struct TagChip: View {
|
||||
.background(Color(.systemGray5), in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Unified chip button (filter/sort chips across all screens)
|
||||
//
|
||||
// .filled → amber background when selected (genre filter chips in Library)
|
||||
// .outlined → amber border + tint when selected, grey background (sort chips, browse filter chips)
|
||||
|
||||
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(action: action) {
|
||||
Text(label)
|
||||
.font(chipFont)
|
||||
.padding(.horizontal, chipHPad)
|
||||
.padding(.vertical, 6)
|
||||
.background(background)
|
||||
.foregroundStyle(foregroundColor)
|
||||
.overlay(border)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var chipFont: Font {
|
||||
switch style {
|
||||
case .filled: return .caption.weight(isSelected ? .semibold : .regular)
|
||||
case .outlined: return .subheadline.weight(isSelected ? .semibold : .regular)
|
||||
}
|
||||
}
|
||||
|
||||
private var chipHPad: CGFloat { style == .outlined ? 14 : 12 }
|
||||
|
||||
@ViewBuilder
|
||||
private var background: 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 : .clear, lineWidth: 1.5))
|
||||
}
|
||||
}
|
||||
|
||||
private var foregroundColor: Color {
|
||||
switch style {
|
||||
case .filled: return isSelected ? .white : .primary
|
||||
case .outlined: return isSelected ? .amber : .primary
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var border: some View {
|
||||
// outlined style already has its border baked into `background`
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,62 +1,152 @@
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
struct HomeView: View {
|
||||
@StateObject private var vm = HomeViewModel()
|
||||
@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 {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 28) {
|
||||
VStack(spacing: 0) {
|
||||
OfflineBanner()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
|
||||
// Stats bar
|
||||
if let stats = vm.stats {
|
||||
HStack(spacing: 0) {
|
||||
StatCell(value: "\(stats.totalBooks)", label: "Books")
|
||||
Divider().frame(height: 32)
|
||||
StatCell(value: "\(stats.totalChapters)", label: "Chapters")
|
||||
Divider().frame(height: 32)
|
||||
StatCell(value: "\(stats.booksInProgress)", label: "In Progress")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
// Continue reading
|
||||
// Continue reading — all in-progress books as a horizontal shelf (Apple Books style)
|
||||
if !vm.continueReading.isEmpty {
|
||||
SectionHeader(title: "Continue Reading")
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ForEach(vm.continueReading) { item in
|
||||
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
||||
ContinueReadingCard(item: item)
|
||||
ShelfHeader(title: "Continue Reading")
|
||||
.padding(.top, 8)
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
ForEach(vm.continueReading) { item in
|
||||
NavigationLink(value: NavDestination.chapter(item.book.slug, item.chapter)) {
|
||||
ContinueReadingCard(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
ContinueReadingContextMenu(
|
||||
item: item,
|
||||
onMarkFinished: {
|
||||
Task { await markAsFinished(item.book) }
|
||||
},
|
||||
onRemove: {
|
||||
Task { await removeFromLibrary(item.book.slug) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.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)
|
||||
}
|
||||
|
||||
// Recently updated
|
||||
// Stats strip
|
||||
if let stats = vm.stats {
|
||||
StatsStrip(stats: stats)
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
// Recently updated shelf
|
||||
if !vm.recentlyUpdated.isEmpty {
|
||||
SectionHeader(title: "Recently Updated")
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], spacing: 16) {
|
||||
ForEach(vm.recentlyUpdated) { book in
|
||||
NavigationLink(value: NavDestination.book(book.slug)) {
|
||||
BookCard(book: book)
|
||||
ShelfHeader(title: "Recently Updated")
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
ForEach(vm.recentlyUpdated) { book in
|
||||
NavigationLink(value: NavDestination.book(book.slug)) {
|
||||
ShelfBookCard(book: book)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
ShareLink(item: shareURL(for: book)) {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
// Subscription feed shelf
|
||||
if !vm.subscriptionFeed.isEmpty {
|
||||
ShelfHeader(title: "From People You Follow")
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
ForEach(vm.subscriptionFeed) { item in
|
||||
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
||||
SubscriptionFeedCard(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
ShareLink(item: shareURL(for: item.book)) {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if vm.continueReading.isEmpty && vm.recentlyUpdated.isEmpty && !vm.isLoading {
|
||||
if vm.continueReading.isEmpty && vm.recentlyUpdated.isEmpty && vm.subscriptionFeed.isEmpty && !vm.isLoading {
|
||||
EmptyStateView(
|
||||
icon: "books.vertical",
|
||||
title: "Your library is empty",
|
||||
@@ -71,69 +161,291 @@ struct HomeView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 60)
|
||||
}
|
||||
|
||||
Color.clear.frame(height: 20)
|
||||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
.navigationTitle("Home")
|
||||
.navigationTitle("Reading Now")
|
||||
.appNavigationDestination()
|
||||
.refreshable { await vm.load() }
|
||||
.task { await vm.load() }
|
||||
.errorAlert($vm.error)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
HStack(spacing: 8) {
|
||||
DownloadQueueButton()
|
||||
Divider()
|
||||
.frame(height: 18)
|
||||
AvatarToolbarButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func markAsFinished(_ book: Book) async {
|
||||
do {
|
||||
try await APIClient.shared.setProgress(slug: book.slug, chapter: book.totalChapters)
|
||||
await vm.load() // Refresh home
|
||||
} catch {
|
||||
vm.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func removeFromLibrary(_ slug: String) async {
|
||||
do {
|
||||
try await APIClient.shared.deleteProgress(slug: slug)
|
||||
await vm.load() // Refresh home
|
||||
} catch {
|
||||
vm.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func shareURL(for book: Book) -> URL {
|
||||
let baseURL = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String
|
||||
?? "https://v2.libnovel.kalekber.cc"
|
||||
return URL(string: "\(baseURL)/books/\(book.slug)")!
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Horizontal shelf: continue reading card (Apple Books style)
|
||||
|
||||
private struct ContinueReadingCard: View {
|
||||
let item: ContinueReadingItem
|
||||
|
||||
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 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 {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Cover
|
||||
ZStack(alignment: .bottom) {
|
||||
AsyncCoverImage(url: item.book.cover)
|
||||
.frame(width: 130, height: 188)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.shadow(color: .black.opacity(0.22), radius: 8, y: 4)
|
||||
.bookCoverZoomSource(slug: item.book.slug)
|
||||
|
||||
// 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) {
|
||||
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: 130, alignment: .leading)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
// Progress bar — show at least a 4pt sliver so early chapters aren't invisible
|
||||
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: 130, height: 3)
|
||||
|
||||
// Progress label with smart rounding
|
||||
Text(progressText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(width: 130)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Horizontal shelf: recently updated book card
|
||||
|
||||
private struct ShelfBookCard: View {
|
||||
let book: Book
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
AsyncCoverImage(url: book.cover)
|
||||
.frame(width: 110, height: 158)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.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)
|
||||
.font(.caption.bold())
|
||||
.lineLimit(2)
|
||||
.frame(width: 110, alignment: .leading)
|
||||
|
||||
Text(book.author)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.frame(width: 110, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting components
|
||||
// MARK: - Horizontal shelf: subscription feed card
|
||||
|
||||
private struct StatCell: View {
|
||||
private struct SubscriptionFeedCard: View {
|
||||
let item: SubscriptionFeedItem
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
AsyncCoverImage(url: item.book.cover)
|
||||
.frame(width: 110, height: 158)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.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: 110, alignment: .leading)
|
||||
|
||||
// Tappable "via @username" attribution
|
||||
NavigationLink(value: NavDestination.userProfile(item.readerUsername)) {
|
||||
Text("via @\(item.readerUsername)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Color.amber)
|
||||
.lineLimit(1)
|
||||
.frame(width: 110, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stats strip (compact inline)
|
||||
|
||||
private struct StatsStrip: View {
|
||||
let stats: HomeStats
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
StatPill(icon: "books.vertical.fill", value: "\(stats.totalBooks)", label: "Books")
|
||||
Divider().frame(height: 28)
|
||||
StatPill(icon: "text.alignleft", value: "\(stats.totalChapters)", label: "Chapters")
|
||||
Divider().frame(height: 28)
|
||||
StatPill(icon: "bookmark.fill", value: "\(stats.booksInProgress)", label: "In Progress")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
}
|
||||
|
||||
private struct StatPill: View {
|
||||
let icon: String
|
||||
let value: String
|
||||
let label: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 2) {
|
||||
Text(value).font(.title2.bold()).foregroundStyle(.primary)
|
||||
Text(label).font(.caption).foregroundStyle(.secondary)
|
||||
VStack(spacing: 5) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(Color.amber)
|
||||
Text(value)
|
||||
.font(.subheadline.bold().monospacedDigit())
|
||||
.foregroundStyle(.primary)
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SectionHeader: View {
|
||||
let title: String
|
||||
var body: some View {
|
||||
Text(title)
|
||||
.font(.title3.bold())
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
// MARK: - Context menus
|
||||
|
||||
private struct ContinueReadingCard: View {
|
||||
private struct ContinueReadingContextMenu: View {
|
||||
let item: ContinueReadingItem
|
||||
let onMarkFinished: () -> Void
|
||||
let onRemove: () -> Void
|
||||
|
||||
private var isFinished: Bool {
|
||||
guard item.book.totalChapters > 0 else { return false }
|
||||
return item.chapter >= item.book.totalChapters
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
KFImage(URL(string: item.book.cover))
|
||||
.resizable()
|
||||
.placeholder { coverPlaceholder }
|
||||
.scaledToFill()
|
||||
.frame(width: 120, height: 170)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
Text("Ch.\(item.chapter)")
|
||||
.font(.caption2.bold())
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.padding(6)
|
||||
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")
|
||||
}
|
||||
Text(item.book.title)
|
||||
.font(.caption.bold())
|
||||
.lineLimit(2)
|
||||
.frame(width: 120, alignment: .leading)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Remove from library (destructive)
|
||||
Button(role: .destructive) {
|
||||
onRemove()
|
||||
} label: {
|
||||
Label("Remove from Library", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
private var coverPlaceholder: some View {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color(.systemGray5))
|
||||
.frame(width: 120, height: 170)
|
||||
.overlay(Image(systemName: "book.closed").foregroundStyle(.secondary))
|
||||
|
||||
private var shareURL: URL {
|
||||
let baseURL = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String
|
||||
?? "https://v2.libnovel.kalekber.cc"
|
||||
return URL(string: "\(baseURL)/books/\(item.book.slug)")!
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,77 +3,389 @@ import Kingfisher
|
||||
|
||||
struct LibraryView: View {
|
||||
@StateObject private var vm = LibraryViewModel()
|
||||
|
||||
@State private var sortOrder: SortOrder = .recentlyRead
|
||||
@State private var readingFilter: ReadingFilter = .all
|
||||
@State private var selectedGenre: String = "all"
|
||||
|
||||
enum SortOrder: String, CaseIterable {
|
||||
case recentlyRead = "Recent"
|
||||
case title = "Title"
|
||||
case author = "Author"
|
||||
case progress = "Progress"
|
||||
}
|
||||
|
||||
enum ReadingFilter: String, CaseIterable {
|
||||
case all = "All"
|
||||
case inProgress = "In Progress"
|
||||
case completed = "Completed"
|
||||
}
|
||||
|
||||
// All distinct genres across the library, sorted alphabetically.
|
||||
private var availableGenres: [String] {
|
||||
let all = vm.items.flatMap { $0.book.genres }
|
||||
let unique = Array(Set(all)).sorted()
|
||||
return unique
|
||||
}
|
||||
|
||||
private var filtered: [LibraryItem] {
|
||||
var result = vm.items
|
||||
|
||||
// 1. Reading filter
|
||||
switch readingFilter {
|
||||
case .all:
|
||||
break
|
||||
case .inProgress:
|
||||
result = result.filter { !isCompleted($0) }
|
||||
case .completed:
|
||||
result = result.filter { isCompleted($0) }
|
||||
}
|
||||
|
||||
// 2. Genre filter
|
||||
if selectedGenre != "all" {
|
||||
result = result.filter { $0.book.genres.contains(selectedGenre) }
|
||||
}
|
||||
|
||||
// 3. Sort
|
||||
switch sortOrder {
|
||||
case .recentlyRead:
|
||||
break // server returns by recency
|
||||
case .title:
|
||||
result = result.sorted { $0.book.title < $1.book.title }
|
||||
case .author:
|
||||
result = result.sorted { $0.book.author < $1.book.author }
|
||||
case .progress:
|
||||
result = result.sorted { ($0.lastChapter ?? 0) > ($1.lastChapter ?? 0) }
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private func isCompleted(_ item: LibraryItem) -> Bool {
|
||||
// Treat as completed if book status is "completed" OR
|
||||
// the user has read up to (or past) the total chapter count.
|
||||
if item.book.status.lowercased() == "completed",
|
||||
let ch = item.lastChapter,
|
||||
item.book.totalChapters > 0,
|
||||
ch >= item.book.totalChapters {
|
||||
return true
|
||||
}
|
||||
return item.book.status.lowercased() == "completed" && (item.lastChapter ?? 0) > 0
|
||||
}
|
||||
|
||||
private func markAsFinished(_ book: Book) async {
|
||||
do {
|
||||
try await APIClient.shared.setProgress(slug: book.slug, chapter: book.totalChapters)
|
||||
await vm.load() // Refresh library
|
||||
} catch {
|
||||
vm.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func removeFromLibrary(_ slug: String) async {
|
||||
do {
|
||||
try await APIClient.shared.deleteProgress(slug: slug)
|
||||
await vm.load() // Refresh library
|
||||
} catch {
|
||||
vm.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
LazyVGrid(
|
||||
columns: [GridItem(.adaptive(minimum: 150), spacing: 12)],
|
||||
spacing: 16
|
||||
) {
|
||||
ForEach(vm.items) { item in
|
||||
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
||||
LibraryCard(item: item)
|
||||
VStack(spacing: 0) {
|
||||
OfflineBanner()
|
||||
|
||||
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) {
|
||||
// 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)
|
||||
.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)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.navigationTitle("Library")
|
||||
.appNavigationDestination()
|
||||
.refreshable { await vm.load() }
|
||||
.task { await vm.load() }
|
||||
.errorAlert($vm.error)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
HStack(spacing: 16) {
|
||||
DownloadQueueButton()
|
||||
AvatarToolbarButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Library")
|
||||
.appNavigationDestination()
|
||||
.refreshable { await vm.load() }
|
||||
.task { await vm.load() }
|
||||
.errorAlert($vm.error)
|
||||
}
|
||||
|
||||
var emptyMessage: String {
|
||||
switch readingFilter {
|
||||
case .all:
|
||||
return selectedGenre == "all" ? "No books in your library." : "No \(selectedGenre.capitalized) books in your library."
|
||||
case .inProgress:
|
||||
return "No books in progress."
|
||||
case .completed:
|
||||
return "No completed books yet."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LibraryCard: View {
|
||||
let item: LibraryItem
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
KFImage(URL(string: item.book.cover))
|
||||
.resizable()
|
||||
.placeholder {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color(.systemGray5))
|
||||
.overlay(Image(systemName: "book.closed").foregroundStyle(.secondary))
|
||||
|
||||
// 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 {
|
||||
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)
|
||||
}
|
||||
.scaledToFill()
|
||||
.frame(height: 200)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
if let ch = item.lastChapter {
|
||||
Text("Ch.\(ch)")
|
||||
.font(.caption2.bold())
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.padding(6)
|
||||
}
|
||||
|
||||
// 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)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// 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("Remove from Library", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
Text(item.book.title)
|
||||
.font(.caption.bold())
|
||||
.lineLimit(2)
|
||||
Text(item.book.author)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
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
341
ios/LibNovel/LibNovel/Views/Profile/AccountMenuSheet.swift
Normal file
341
ios/LibNovel/LibNovel/Views/Profile/AccountMenuSheet.swift
Normal file
@@ -0,0 +1,341 @@
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
import Kingfisher
|
||||
|
||||
// MARK: - AvatarNavButton
|
||||
// Drop this into any NavigationStack toolbar to get an avatar button that opens the account sheet.
|
||||
//
|
||||
// Usage:
|
||||
// .toolbar { AvatarToolbarButton() }
|
||||
|
||||
struct AvatarToolbarButton: View {
|
||||
@EnvironmentObject private var authStore: AuthStore
|
||||
@State private var showAccount = false
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
showAccount = true
|
||||
} label: {
|
||||
AvatarThumb(urlString: authStore.user?.avatarURL, size: 30)
|
||||
}
|
||||
.sheet(isPresented: $showAccount) {
|
||||
AccountMenuSheet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AvatarThumb
|
||||
// Reusable small circular avatar (used by both toolbar button and the sheet header).
|
||||
|
||||
struct AvatarThumb: View {
|
||||
let urlString: String?
|
||||
let size: CGFloat
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let str = urlString, let url = URL(string: str) {
|
||||
KFImage(url)
|
||||
.placeholder { placeholderCircle }
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
placeholderCircle
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(Circle())
|
||||
.overlay(Circle().stroke(Color.amber.opacity(0.6), lineWidth: 1.5))
|
||||
}
|
||||
|
||||
private var placeholderCircle: some View {
|
||||
Circle()
|
||||
.fill(Color(.systemGray4))
|
||||
.overlay(
|
||||
Image(systemName: "person.fill")
|
||||
.font(.system(size: size * 0.5))
|
||||
.foregroundStyle(Color.amber)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AccountMenuSheet
|
||||
|
||||
struct AccountMenuSheet: View {
|
||||
@EnvironmentObject private var authStore: AuthStore
|
||||
@StateObject private var vm = ProfileViewModel()
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var showChangePassword = false
|
||||
|
||||
// Avatar upload
|
||||
@State private var photoPickerItem: PhotosPickerItem?
|
||||
@State private var pendingCropImage: UIImage?
|
||||
@State private var avatarURL: String? = nil
|
||||
@State private var avatarUploading = false
|
||||
@State private var avatarError: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
// ── User header ────────────────────────────────────────────
|
||||
Section {
|
||||
HStack(spacing: 16) {
|
||||
avatarPicker
|
||||
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") {
|
||||
voicePicker
|
||||
speedSlider
|
||||
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(.amber)
|
||||
}
|
||||
|
||||
// ── Sessions ───────────────────────────────────────────────
|
||||
Section("Active Sessions") {
|
||||
if vm.sessionsLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
ForEach(vm.sessions) { session in
|
||||
SessionRow(session: session) {
|
||||
Task { await vm.revokeSession(id: session.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Account ────────────────────────────────────────────────
|
||||
Section("Account") {
|
||||
Button("Change Password") { showChangePassword = true }
|
||||
Button("Sign Out", role: .destructive) {
|
||||
dismiss()
|
||||
Task { await authStore.logout() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Account")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.task { await vm.loadSessions() }
|
||||
.sheet(isPresented: $showChangePassword) {
|
||||
ChangePasswordView()
|
||||
}
|
||||
.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($vm.error)
|
||||
}
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
|
||||
// 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")
|
||||
avatarURL = url
|
||||
await authStore.validateToken()
|
||||
} catch {
|
||||
avatarError = "Upload failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Avatar picker
|
||||
|
||||
@ViewBuilder
|
||||
private var avatarPicker: some View {
|
||||
PhotosPicker(selection: $photoPickerItem,
|
||||
matching: .images,
|
||||
photoLibrary: .shared()) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color(.systemGray5))
|
||||
.frame(width: 72, height: 72)
|
||||
|
||||
if avatarUploading {
|
||||
ProgressView()
|
||||
.frame(width: 72, height: 72)
|
||||
} else if let urlStr = avatarURL ?? authStore.user?.avatarURL,
|
||||
let url = URL(string: urlStr) {
|
||||
KFImage(url)
|
||||
.placeholder {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 72, height: 72)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(.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)
|
||||
.onChange(of: photoPickerItem) { _, item in
|
||||
guard let item else { return }
|
||||
Task { await loadImageForCrop(item) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Voice picker
|
||||
|
||||
@ViewBuilder
|
||||
private var voicePicker: some View {
|
||||
Picker("TTS Voice", selection: Binding(
|
||||
get: { authStore.settings.voice },
|
||||
set: { newVoice in
|
||||
Task {
|
||||
var s = authStore.settings
|
||||
s.voice = newVoice
|
||||
await authStore.saveSettings(s)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
if vm.voices.isEmpty {
|
||||
Text("Default").tag("af_bella")
|
||||
} else {
|
||||
ForEach(vm.voices, id: \.self) { v in
|
||||
Text(v).tag(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await vm.loadVoices() }
|
||||
}
|
||||
|
||||
// MARK: - Speed slider
|
||||
|
||||
@ViewBuilder
|
||||
private var speedSlider: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text("Playback Speed")
|
||||
Spacer()
|
||||
Text("\(authStore.settings.speed, specifier: "%.1f")×")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
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(.amber)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session row (local copy — mirrors ProfileView.SessionRow)
|
||||
|
||||
private struct SessionRow: View {
|
||||
let session: UserSession
|
||||
let onRevoke: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Image(systemName: "iphone")
|
||||
Text(session.userAgent.isEmpty ? "Unknown device" : session.userAgent)
|
||||
.font(.subheadline)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if session.isCurrent {
|
||||
Text("This device")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.amber)
|
||||
} else {
|
||||
Button("Revoke", role: .destructive, action: onRevoke)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
Text("Last seen: \(session.lastSeen.prefix(10))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CropImageItem (Identifiable wrapper for the sheet)
|
||||
|
||||
private struct CropImageItem: Identifiable {
|
||||
let id = UUID()
|
||||
let image: UIImage
|
||||
}
|
||||
270
ios/LibNovel/LibNovel/Views/Profile/AvatarCropView.swift
Normal file
270
ios/LibNovel/LibNovel/Views/Profile/AvatarCropView.swift
Normal file
@@ -0,0 +1,270 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - AvatarCropView
|
||||
// A sheet that lets the user pan and pinch a photo to fill a 1:1 circular crop region.
|
||||
// Call: .sheet(item: $cropImage) { AvatarCropView(image: $0.image, onConfirm: { croppedData in … }) }
|
||||
|
||||
struct AvatarCropView: View {
|
||||
let image: UIImage
|
||||
let onConfirm: (Data) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
// Crop circle diameter (points)
|
||||
private let cropSize: CGFloat = 280
|
||||
|
||||
// Pan/zoom state — all in screen points, relative to the image's natural fill-fitted frame
|
||||
@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
|
||||
|
||||
// Container size captured from GeometryReader
|
||||
@State private var containerSize: CGSize = .zero
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
GeometryReader { geo in
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
// Draggable / pinchable image
|
||||
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(minScale(in: geo.size), proposed)
|
||||
}
|
||||
.onEnded { _ in
|
||||
lastScale = scale
|
||||
clampOffset(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()
|
||||
|
||||
// Dim overlay with transparent crop circle cut out
|
||||
CropOverlay(cropSize: cropSize, containerSize: geo.size)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.onAppear {
|
||||
containerSize = geo.size
|
||||
fitImageInitially(in: geo.size)
|
||||
}
|
||||
}
|
||||
.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(.amber)
|
||||
}
|
||||
}
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Initial fit
|
||||
|
||||
private func fitImageInitially(in size: CGSize) {
|
||||
// The image is displayed with .scaledToFill() in the container (size).
|
||||
// That means one dimension equals the container and the other overflows.
|
||||
// We want the image to be just large enough that the crop circle is fully
|
||||
// covered — i.e. the fill-fitted image's shorter displayed dimension >= cropSize.
|
||||
//
|
||||
// .scaledToFill fills the container, so the image already covers the container.
|
||||
// The minimum scale that covers the crop square is therefore 1.0 (image already
|
||||
// fills container which is >= cropSize on both axes).
|
||||
// We keep scale = 1.0 and centre the offset.
|
||||
scale = 1.0
|
||||
lastScale = 1.0
|
||||
offset = .zero
|
||||
lastOffset = .zero
|
||||
}
|
||||
|
||||
// MARK: - Clamping helpers
|
||||
|
||||
/// Minimum scale: the image (at .scaledToFill in container) must cover the crop square.
|
||||
/// At scale=1 the image already fills the container; cropSize <= container dimension,
|
||||
/// so 1.0 is always sufficient. We cap at 1.0 to prevent zooming out below fill.
|
||||
private func minScale(in containerSize: CGSize) -> CGFloat {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
/// The displayed (fill-fitted) image size in the container at the given user scale.
|
||||
private func displayedImageSize(in containerSize: CGSize, userScale: CGFloat) -> CGSize {
|
||||
let imgAspect = image.size.width / image.size.height
|
||||
let containerAspect = containerSize.width / containerSize.height
|
||||
|
||||
// .scaledToFill base size before user scale
|
||||
let baseWidth: CGFloat
|
||||
let baseHeight: CGFloat
|
||||
if imgAspect > containerAspect {
|
||||
// image is wider — height fills container
|
||||
baseHeight = containerSize.height
|
||||
baseWidth = baseHeight * imgAspect
|
||||
} else {
|
||||
// image is taller — width fills container
|
||||
baseWidth = containerSize.width
|
||||
baseHeight = baseWidth / imgAspect
|
||||
}
|
||||
return CGSize(width: baseWidth * userScale, height: baseHeight * userScale)
|
||||
}
|
||||
|
||||
/// Maximum offset so the crop square is always covered by the image.
|
||||
private func clampedOffset(_ proposed: CGSize, in containerSize: CGSize) -> CGSize {
|
||||
let displayed = displayedImageSize(in: containerSize, userScale: scale)
|
||||
// Half of how much the image overflows the container on each axis
|
||||
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))
|
||||
)
|
||||
}
|
||||
|
||||
private func clampOffset(in containerSize: CGSize) {
|
||||
offset = clampedOffset(offset, in: containerSize)
|
||||
}
|
||||
|
||||
// MARK: - Crop
|
||||
|
||||
private func confirmCrop() {
|
||||
let size = containerSize.width > 0 ? containerSize : CGSize(width: 390, height: 844)
|
||||
let outputSize = CGSize(width: 400, height: 400)
|
||||
|
||||
// --- Step 1: compute the fill-fitted base display size ---
|
||||
let imgAspect = image.size.width / image.size.height
|
||||
let containerAspect = size.width / size.height
|
||||
|
||||
let baseDisplayW: CGFloat
|
||||
let baseDisplayH: CGFloat
|
||||
if imgAspect > containerAspect {
|
||||
baseDisplayH = size.height
|
||||
baseDisplayW = baseDisplayH * imgAspect
|
||||
} else {
|
||||
baseDisplayW = size.width
|
||||
baseDisplayH = baseDisplayW / imgAspect
|
||||
}
|
||||
|
||||
// Displayed image size after user zoom
|
||||
let displayW = baseDisplayW * scale
|
||||
let displayH = baseDisplayH * scale
|
||||
|
||||
// --- Step 2: the crop square centre is the container centre ---
|
||||
// The image centre (after offset) in container coords:
|
||||
let imageCentreX = size.width / 2 + offset.width
|
||||
let imageCentreY = size.height / 2 + offset.height
|
||||
|
||||
// Top-left of the crop square in container coords:
|
||||
let cropOriginX = (size.width - cropSize) / 2
|
||||
let cropOriginY = (size.height - cropSize) / 2
|
||||
|
||||
// Top-left of the crop square relative to the image's top-left in display space:
|
||||
let imageOriginX = imageCentreX - displayW / 2
|
||||
let imageOriginY = imageCentreY - displayH / 2
|
||||
|
||||
let cropInImageX = cropOriginX - imageOriginX // pixels in display space
|
||||
let cropInImageY = cropOriginY - imageOriginY
|
||||
|
||||
// --- Step 3: convert display-space coords to image pixel coords ---
|
||||
let displayToPixelX = image.size.width / displayW
|
||||
let displayToPixelY = image.size.height / displayH
|
||||
|
||||
let pixelX = cropInImageX * displayToPixelX
|
||||
let pixelY = cropInImageY * displayToPixelY
|
||||
let pixelW = cropSize * displayToPixelX
|
||||
let pixelH = cropSize * displayToPixelY
|
||||
|
||||
let cropRect = CGRect(x: pixelX, y: pixelY, width: pixelW, height: pixelH)
|
||||
.intersection(CGRect(origin: .zero, size: image.size))
|
||||
|
||||
guard cropRect.width > 0, cropRect.height > 0 else {
|
||||
// Fallback: use entire image
|
||||
if let jpeg = image.jpegData(compressionQuality: 0.9) { onConfirm(jpeg) }
|
||||
return
|
||||
}
|
||||
|
||||
// --- Step 4: render cropped region into 400×400 ---
|
||||
let renderer = UIGraphicsImageRenderer(size: outputSize)
|
||||
let cropped = renderer.image { _ in
|
||||
// Draw only the cropRect portion of the image scaled to fill outputSize
|
||||
let destRect = CGRect(origin: .zero, size: outputSize)
|
||||
// UIImage.draw(in:) draws the full image; we use CGImage cropping instead
|
||||
if let cgImg = image.cgImage?.cropping(to: cropRect) {
|
||||
let croppedUI = UIImage(cgImage: cgImg, scale: image.scale, orientation: image.imageOrientation)
|
||||
croppedUI.draw(in: destRect)
|
||||
} else {
|
||||
image.draw(in: destRect)
|
||||
}
|
||||
}
|
||||
|
||||
if let jpeg = cropped.jpegData(compressionQuality: 0.9) {
|
||||
onConfirm(jpeg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Crop overlay
|
||||
|
||||
private struct CropOverlay: View {
|
||||
let cropSize: CGFloat
|
||||
let containerSize: CGSize
|
||||
|
||||
var body: some View {
|
||||
Canvas { context, size in
|
||||
// Fill entire canvas with semi-transparent black
|
||||
context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(.black.opacity(0.55)))
|
||||
// Cut out the crop circle in the centre
|
||||
let origin = CGPoint(
|
||||
x: (size.width - cropSize) / 2,
|
||||
y: (size.height - cropSize) / 2
|
||||
)
|
||||
let cropRect = CGRect(origin: origin, size: CGSize(width: cropSize, height: cropSize))
|
||||
context.blendMode = .destinationOut
|
||||
context.fill(Path(ellipseIn: cropRect), with: .color(.white))
|
||||
}
|
||||
.compositingGroup()
|
||||
.overlay {
|
||||
// Amber circle border around the crop region
|
||||
let origin = CGPoint(
|
||||
x: (containerSize.width - cropSize) / 2,
|
||||
y: (containerSize.height - cropSize) / 2
|
||||
)
|
||||
Circle()
|
||||
.stroke(Color.amber.opacity(0.8), lineWidth: 2)
|
||||
.frame(width: cropSize, height: cropSize)
|
||||
.position(
|
||||
x: origin.x + cropSize / 2,
|
||||
y: origin.y + cropSize / 2
|
||||
)
|
||||
}
|
||||
.frame(width: containerSize.width, height: containerSize.height)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,45 @@
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
import Kingfisher
|
||||
|
||||
struct ProfileView: View {
|
||||
@EnvironmentObject var authStore: AuthStore
|
||||
@StateObject private var vm = ProfileViewModel()
|
||||
@State private var showChangePassword = false
|
||||
@State private var showVoiceSelection = false
|
||||
@State private var showDownloads = false
|
||||
|
||||
// Avatar upload state
|
||||
@State private var photoPickerItem: PhotosPickerItem?
|
||||
@State private var pendingCropImage: UIImage? // image waiting to be cropped
|
||||
@State private var avatarURL: String? = nil
|
||||
@State private var avatarUploading = false
|
||||
@State private var avatarError: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
// User header
|
||||
// ── User header ────────────────────────────────────────────
|
||||
Section {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.amber)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 16) {
|
||||
avatarPicker
|
||||
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
|
||||
// ── Reading settings ───────────────────────────────────────
|
||||
Section("Reading Settings") {
|
||||
voicePicker
|
||||
speedSlider
|
||||
@@ -40,9 +54,22 @@ struct ProfileView: View {
|
||||
}
|
||||
))
|
||||
.tint(.amber)
|
||||
|
||||
Button {
|
||||
showDownloads = true
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Downloads")
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sessions
|
||||
// ── Sessions ───────────────────────────────────────────────
|
||||
Section("Active Sessions") {
|
||||
if vm.sessionsLoading {
|
||||
ProgressView()
|
||||
@@ -55,7 +82,7 @@ struct ProfileView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Account
|
||||
// ── Account ────────────────────────────────────────────────
|
||||
Section("Account") {
|
||||
Button("Change Password") { showChangePassword = true }
|
||||
Button("Sign Out", role: .destructive) {
|
||||
@@ -64,37 +91,148 @@ struct ProfileView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle("Profile")
|
||||
.task { await vm.loadSessions() }
|
||||
.task {
|
||||
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($vm.error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Avatar upload
|
||||
|
||||
/// Step 1: Load the raw image from the picker and show the crop sheet.
|
||||
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
|
||||
}
|
||||
|
||||
/// Step 2: Called by AvatarCropView once the user confirms. Upload the cropped JPEG.
|
||||
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")
|
||||
avatarURL = url
|
||||
// Refresh user record so the new avatar persists across sessions
|
||||
await authStore.validateToken()
|
||||
} catch {
|
||||
avatarError = "Upload failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Avatar picker
|
||||
|
||||
@ViewBuilder
|
||||
private var avatarPicker: some View {
|
||||
PhotosPicker(selection: $photoPickerItem,
|
||||
matching: .images,
|
||||
photoLibrary: .shared()) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color(.systemGray5))
|
||||
.frame(width: 72, height: 72)
|
||||
|
||||
if avatarUploading {
|
||||
ProgressView()
|
||||
.frame(width: 72, height: 72)
|
||||
} else if let urlStr = avatarURL ?? authStore.user?.avatarURL,
|
||||
let url = URL(string: urlStr) {
|
||||
KFImage(url)
|
||||
.placeholder {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 72, height: 72)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(.amber)
|
||||
.frame(width: 72, height: 72)
|
||||
}
|
||||
|
||||
// Camera overlay 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)
|
||||
.onChange(of: photoPickerItem) { _, item in
|
||||
guard let item else { return }
|
||||
Task { await loadImageForCrop(item) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Voice picker
|
||||
|
||||
@ViewBuilder
|
||||
private var voicePicker: some View {
|
||||
Picker("TTS Voice", selection: Binding(
|
||||
get: { authStore.settings.voice },
|
||||
set: { newVoice in
|
||||
Task {
|
||||
var s = authStore.settings
|
||||
s.voice = newVoice
|
||||
await authStore.saveSettings(s)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
if vm.voices.isEmpty {
|
||||
Text("Default").tag("af_bella")
|
||||
} else {
|
||||
ForEach(vm.voices, id: \.self) { v in
|
||||
Text(v).tag(v)
|
||||
}
|
||||
Button {
|
||||
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)
|
||||
}
|
||||
}
|
||||
.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
|
||||
@@ -215,3 +353,10 @@ struct ChangePasswordView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Crop image item (Identifiable wrapper for .sheet(item:))
|
||||
|
||||
private struct CropImageItem: Identifiable {
|
||||
let id = UUID()
|
||||
let image: UIImage
|
||||
}
|
||||
|
||||
197
ios/LibNovel/LibNovel/Views/Profile/UserProfileView.swift
Normal file
197
ios/LibNovel/LibNovel/Views/Profile/UserProfileView.swift
Normal file
@@ -0,0 +1,197 @@
|
||||
import SwiftUI
|
||||
|
||||
struct UserProfileView: View {
|
||||
let username: String
|
||||
|
||||
@StateObject private var vm: UserProfileViewModel
|
||||
@EnvironmentObject private var authStore: AuthStore
|
||||
|
||||
init(username: String) {
|
||||
self.username = username
|
||||
_vm = StateObject(wrappedValue: UserProfileViewModel(username: username))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if vm.isLoading && vm.profile == nil {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 60)
|
||||
} else if let profile = vm.profile {
|
||||
profileHeader(profile)
|
||||
.padding(.bottom, 28)
|
||||
|
||||
if !vm.currentlyReading.isEmpty {
|
||||
ShelfHeader(title: "Currently Reading")
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
ForEach(vm.currentlyReading) { item in
|
||||
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
||||
ProfileBookCard(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
if !vm.library.isEmpty {
|
||||
ShelfHeader(title: "Library")
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
ForEach(vm.library) { item in
|
||||
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
||||
ProfileBookCard(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
if vm.currentlyReading.isEmpty && vm.library.isEmpty && !vm.isLoading {
|
||||
EmptyStateView(
|
||||
icon: "books.vertical",
|
||||
title: "No books yet",
|
||||
message: "\(username) hasn't read anything yet."
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 20)
|
||||
}
|
||||
} else if let err = vm.error {
|
||||
EmptyStateView(icon: "exclamationmark.triangle", title: "Error", message: err)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 60)
|
||||
}
|
||||
|
||||
Color.clear.frame(height: 20)
|
||||
}
|
||||
}
|
||||
.navigationTitle("@\(username)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { await vm.load() }
|
||||
.refreshable { await vm.load() }
|
||||
.errorAlert($vm.error)
|
||||
}
|
||||
|
||||
// MARK: - Profile header
|
||||
|
||||
@ViewBuilder
|
||||
private func profileHeader(_ profile: PublicUserProfile) -> some View {
|
||||
VStack(alignment: .center, spacing: 16) {
|
||||
AvatarThumb(urlString: profile.avatarUrl, size: 80)
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Text("@\(profile.username)")
|
||||
.font(.title3.bold())
|
||||
if !profile.created.isEmpty {
|
||||
Text("Joined \(shortDate(profile.created))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Stats row
|
||||
HStack(spacing: 32) {
|
||||
VStack(spacing: 2) {
|
||||
Text("\(profile.followerCount)")
|
||||
.font(.subheadline.bold().monospacedDigit())
|
||||
Text("Followers")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
VStack(spacing: 2) {
|
||||
Text("\(profile.followingCount)")
|
||||
.font(.subheadline.bold().monospacedDigit())
|
||||
Text("Following")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Follow button — only shown for other users (not self)
|
||||
if !profile.isSelf && authStore.isAuthenticated {
|
||||
Button {
|
||||
Task { await vm.toggleSubscribe() }
|
||||
} label: {
|
||||
if vm.isTogglingSubscribe {
|
||||
ProgressView().controlSize(.small)
|
||||
.frame(width: 120, height: 34)
|
||||
} else if profile.isSubscribed {
|
||||
Label("Following", systemImage: "checkmark")
|
||||
.font(.subheadline.bold())
|
||||
.frame(width: 120, height: 34)
|
||||
} else {
|
||||
Text("Follow")
|
||||
.font(.subheadline.bold())
|
||||
.frame(width: 120, height: 34)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(profile.isSubscribed ? Color(.systemGray4) : .amber)
|
||||
.disabled(vm.isTogglingSubscribe)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 24)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
private func shortDate(_ iso: String) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZ"
|
||||
if let date = formatter.date(from: iso) {
|
||||
let out = DateFormatter()
|
||||
out.dateStyle = .medium
|
||||
out.timeStyle = .none
|
||||
return out.string(from: date)
|
||||
}
|
||||
return String(iso.prefix(10))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Book card for profile shelves
|
||||
|
||||
private struct ProfileBookCard: View {
|
||||
let item: PublicLibraryItem
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
AsyncCoverImage(url: item.book.cover)
|
||||
.frame(width: 110, height: 158)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.shadow(color: .black.opacity(0.12), radius: 4, y: 2)
|
||||
|
||||
// Chapter badge (if reading)
|
||||
if let ch = item.lastChapter, ch > 0 {
|
||||
Text("Ch.\(ch)")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(.black.opacity(0.85))
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 4)
|
||||
.background(Capsule().fill(Color.amber))
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
|
||||
Text(item.book.title)
|
||||
.font(.caption.bold())
|
||||
.lineLimit(2)
|
||||
.frame(width: 110, alignment: .leading)
|
||||
|
||||
Text(item.book.author)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.frame(width: 110, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
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())
|
||||
}
|
||||
286
ios/LibNovel/LibNovel/Views/Search/SearchView.swift
Normal file
286
ios/LibNovel/LibNovel/Views/Search/SearchView.swift
Normal file
@@ -0,0 +1,286 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - SearchView
|
||||
// Dedicated search tab for intentional, fuzzy search.
|
||||
// Live search as you type, shows recent searches when idle.
|
||||
|
||||
struct SearchView: View {
|
||||
@StateObject private var vm = SearchViewModel()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
OfflineBanner()
|
||||
|
||||
Group {
|
||||
// ── Content ─────────────────────────────────────────────────
|
||||
if vm.query.isEmpty && vm.results.isEmpty {
|
||||
idleContent
|
||||
} else if vm.isLoading && vm.results.isEmpty {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if vm.results.isEmpty && !vm.query.isEmpty {
|
||||
EmptyStateView(
|
||||
icon: "magnifyingglass",
|
||||
title: "No results",
|
||||
message: "Try a different title or author name."
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
resultsGrid
|
||||
}
|
||||
}
|
||||
}
|
||||
.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()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
HStack(spacing: 16) {
|
||||
DownloadQueueButton()
|
||||
AvatarToolbarButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Idle screen (recent searches)
|
||||
|
||||
@ViewBuilder
|
||||
private var idleContent: some View {
|
||||
if vm.recentSearches.isEmpty {
|
||||
// Empty state - prompt to search
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(.secondary.opacity(0.5))
|
||||
Text("Search for novels")
|
||||
.font(.title2.bold())
|
||||
.foregroundStyle(.primary)
|
||||
Text("Find your next favorite book by title, author, or genre")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
// Recent searches list
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
Text("Recent Searches")
|
||||
.font(.title3.bold())
|
||||
Spacer()
|
||||
Button("Clear") { vm.clearRecent() }
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
ForEach(vm.recentSearches, id: \.self) { term in
|
||||
Button {
|
||||
vm.query = term
|
||||
vm.submitSearch()
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "clock")
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 20)
|
||||
Text(term)
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.left")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
if term != vm.recentSearches.last {
|
||||
Divider()
|
||||
.padding(.leading, 44)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Results grid
|
||||
|
||||
@ViewBuilder
|
||||
private var resultsGrid: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 8) {
|
||||
// Result count
|
||||
HStack {
|
||||
Text("\(vm.results.count) result\(vm.results.count == 1 ? "" : "s")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Search novel card (compact 2-column)
|
||||
|
||||
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))
|
||||
.bookCoverZoomSource(slug: novel.slug)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(novel.title)
|
||||
.font(.subheadline.bold())
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SearchViewModel
|
||||
|
||||
@MainActor
|
||||
final class SearchViewModel: ObservableObject {
|
||||
@Published var query: String = ""
|
||||
@Published var results: [BrowseNovel] = []
|
||||
@Published var isLoading = false
|
||||
|
||||
// Persisted in UserDefaults (max 10 recent terms)
|
||||
@Published var recentSearches: [String] = []
|
||||
|
||||
private let recentKey = "searchRecentTerms"
|
||||
private var searchTask: Task<Void, Never>?
|
||||
|
||||
init() {
|
||||
recentSearches = (UserDefaults.standard.stringArray(forKey: recentKey) ?? [])
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
let term = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !term.isEmpty else { return }
|
||||
saveRecent(term)
|
||||
// Cancel debounce and search immediately
|
||||
searchTask?.cancel()
|
||||
Task { await runSearch(term) }
|
||||
}
|
||||
|
||||
func clear() {
|
||||
query = ""
|
||||
results = []
|
||||
searchTask?.cancel()
|
||||
}
|
||||
|
||||
func clearRecent() {
|
||||
recentSearches = []
|
||||
UserDefaults.standard.removeObject(forKey: recentKey)
|
||||
}
|
||||
|
||||
private func runSearch(_ term: String) async {
|
||||
let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
results = []
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
do {
|
||||
let result = try await APIClient.shared.search(query: trimmed)
|
||||
// Only update results if query hasn't changed
|
||||
if query.trimmingCharacters(in: .whitespacesAndNewlines) == trimmed {
|
||||
results = result.results
|
||||
}
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
results = []
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
36
ios/LibNovel/fastlane/Fastfile
Normal file
36
ios/LibNovel/fastlane/Fastfile
Normal file
@@ -0,0 +1,36 @@
|
||||
default_platform(:ios)
|
||||
|
||||
platform :ios do
|
||||
desc "Build and upload to TestFlight"
|
||||
lane :beta do
|
||||
# Generate Xcode project from project.yml (one level up from fastlane/)
|
||||
sh("cd .. && xcodegen generate --spec project.yml --project .")
|
||||
|
||||
# Set build number from CI run number (passed as env var)
|
||||
increment_build_number(
|
||||
build_number: ENV["BUILD_NUMBER"] || "1",
|
||||
xcodeproj: "LibNovel.xcodeproj"
|
||||
)
|
||||
|
||||
# Build the app - signing settings are in project.yml Release config
|
||||
build_app(
|
||||
scheme: "LibNovel",
|
||||
export_method: "app-store",
|
||||
clean: true,
|
||||
configuration: "Release",
|
||||
export_options: {
|
||||
method: "app-store",
|
||||
teamID: "GHZXC6FVMU",
|
||||
provisioningProfiles: {
|
||||
"com.kalekber.LibNovel" => "LibNovel Distribution"
|
||||
},
|
||||
signingStyle: "manual"
|
||||
}
|
||||
)
|
||||
|
||||
# Upload to TestFlight
|
||||
upload_to_testflight(
|
||||
skip_waiting_for_build_processing: true
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,6 @@
|
||||
name: LibNovel
|
||||
options:
|
||||
bundleIdPrefix: cc.kalekber
|
||||
bundleIdPrefix: com.kalekber
|
||||
deploymentTarget:
|
||||
iOS: "17.0"
|
||||
xcodeVersion: "16.0"
|
||||
@@ -44,11 +44,17 @@ targets:
|
||||
- package: Kingfisher
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: cc.kalekber.libnovel
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.kalekber.LibNovel
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
TARGETED_DEVICE_FAMILY: "1,2" # iPhone + iPad
|
||||
GENERATE_INFOPLIST_FILE: NO
|
||||
INFOPLIST_FILE: LibNovel/Resources/Info.plist
|
||||
configs:
|
||||
Release:
|
||||
CODE_SIGN_STYLE: Manual
|
||||
DEVELOPMENT_TEAM: GHZXC6FVMU
|
||||
CODE_SIGN_IDENTITY: "Apple Distribution"
|
||||
PROVISIONING_PROFILE: "af592c3a-f60b-4ac1-a14f-30b8a206017f"
|
||||
|
||||
LibNovelTests:
|
||||
type: bundle.unit-test
|
||||
@@ -60,7 +66,7 @@ targets:
|
||||
- target: LibNovel
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: cc.kalekber.libnovel.tests
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.kalekber.LibNovel.tests
|
||||
|
||||
schemes:
|
||||
LibNovel:
|
||||
|
||||
32
ios/LibNovel/test-build.sh
Executable file
32
ios/LibNovel/test-build.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Test script for local iOS build iteration
|
||||
# Run from ios/LibNovel directory
|
||||
|
||||
echo "=== Generating Xcode project ==="
|
||||
xcodegen generate --spec project.yml --project .
|
||||
|
||||
echo ""
|
||||
echo "=== Listing available provisioning profiles ==="
|
||||
ls -la ~/Library/MobileDevice/Provisioning\ Profiles/ || echo "No profiles found"
|
||||
|
||||
echo ""
|
||||
echo "=== Listing available signing identities ==="
|
||||
security find-identity -v -p codesigning
|
||||
|
||||
echo ""
|
||||
echo "=== Attempting archive build ==="
|
||||
xcodebuild archive \
|
||||
-project LibNovel.xcodeproj \
|
||||
-scheme LibNovel \
|
||||
-configuration Release \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-archivePath ./build/LibNovel.xcarchive \
|
||||
-allowProvisioningUpdates \
|
||||
CODE_SIGN_STYLE=Manual \
|
||||
CODE_SIGN_IDENTITY="Apple Distribution" \
|
||||
DEVELOPMENT_TEAM="GHZXC6FVMU"
|
||||
|
||||
echo ""
|
||||
echo "=== Build succeeded! ==="
|
||||
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
|
||||
10
justfile
10
justfile
@@ -130,16 +130,18 @@ ios-test: ios-gen ios-resolve
|
||||
# Archive a signed Release build (requires valid signing identity in keychain).
|
||||
# Output: {{runner_temp}}/LibNovel.xcarchive
|
||||
# Typically called from CI after importing certificate + provisioning profile.
|
||||
ios-archive: ios-gen ios-resolve
|
||||
# Usage: just ios-archive <team-id> <profile-uuid>
|
||||
ios-archive team_id profile_uuid: ios-gen ios-resolve
|
||||
cd {{ios_dir}} && xcodebuild archive \
|
||||
-project {{ios_scheme}}.xcodeproj \
|
||||
-scheme {{ios_scheme}} \
|
||||
-configuration Release \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-clonedSourcePackagesDirPath {{ios_spm}} \
|
||||
-archivePath {{runner_temp}}/LibNovel.xcarchive \
|
||||
CODE_SIGN_STYLE=Manual \
|
||||
DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \
|
||||
PROVISIONING_PROFILE="$PROFILE_UUID"
|
||||
CODE_SIGN_IDENTITY="Apple Distribution" \
|
||||
"PROVISIONING_PROFILE[sdk=iphoneos*]={{profile_uuid}}" \
|
||||
DEVELOPMENT_TEAM="{{team_id}}"
|
||||
|
||||
# Export an IPA from the archive produced by ios-archive.
|
||||
# Requires ios/LibNovel/ExportOptions.plist.
|
||||
|
||||
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"
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user