Compare commits

...

48 Commits

Author SHA1 Message Date
Admin
02705dc6ed fix(storage): strip http/https scheme from MinIO endpoint env vars
All checks were successful
CI / Scraper / Test (push) Successful in 10s
CI / Scraper / Lint (push) Successful in 12s
Release / Scraper / Test (push) Successful in 20s
CI / Scraper / Lint (pull_request) Successful in 11s
Release / UI / Build (push) Successful in 18s
CI / Scraper / Test (pull_request) Successful in 18s
CI / UI / Build (pull_request) Successful in 24s
CI / Scraper / Docker Push (push) Successful in 40s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
Release / UI / Docker (push) Successful in 37s
Release / Scraper / Docker (push) Successful in 54s
iOS CI / Build (pull_request) Successful in 3m14s
iOS CI / Test (pull_request) Successful in 13m12s
2026-03-14 15:19:02 +05:00
Admin
7413313100 fix: update integration_test.go to match server.New signature (version, commit args)
All checks were successful
CI / Scraper / Lint (push) Successful in 10s
CI / Scraper / Test (push) Successful in 14s
Release / Scraper / Test (push) Successful in 18s
CI / Scraper / Lint (pull_request) Successful in 18s
Release / UI / Build (push) Successful in 23s
CI / Scraper / Test (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 32s
Release / Scraper / Docker (push) Successful in 55s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 1m5s
Release / UI / Docker (push) Successful in 1m12s
iOS CI / Build (push) Successful in 4m18s
iOS CI / Build (pull_request) Successful in 4m25s
iOS CI / Test (push) Successful in 8m11s
iOS CI / Test (pull_request) Successful in 8m21s
2026-03-14 14:25:46 +05:00
Admin
b11f4ab6b4 fix: missing closing brace in setProgress function
Some checks failed
CI / Scraper / Lint (pull_request) Failing after 11s
CI / Scraper / Test (pull_request) Successful in 21s
CI / UI / Build (push) Successful in 24s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 17s
CI / UI / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (push) Successful in 32s
iOS CI / Build (pull_request) Successful in 1m58s
iOS CI / Test (pull_request) Successful in 5m4s
Release / Scraper / Test (push) Failing after 17s
Release / Scraper / Docker (push) Has been skipped
Release / UI / Build (push) Successful in 24s
Release / UI / Docker (push) Successful in 1m12s
2026-03-11 15:49:36 +05:00
Admin
3e4b1c0484 feat: add user profile views and library management
Some checks failed
CI / UI / Build (push) Failing after 11s
CI / Scraper / Lint (pull_request) Failing after 15s
CI / UI / Docker Push (push) Has been skipped
CI / Scraper / Test (pull_request) Successful in 20s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Failing after 17s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 3m40s
iOS CI / Build (pull_request) Successful in 1m49s
iOS CI / Test (push) Successful in 4m24s
iOS CI / Test (pull_request) Successful in 4m46s
- Add UserProfileView and UserProfileViewModel for iOS
- Implement user library API endpoint (/api/users/[username]/library)
- Add DELETE /api/progress/[slug] endpoint for removing books from library
- Integrate subscription feed in home API
- Update Xcode project with new profile components
2026-03-11 15:45:04 +05:00
Admin
b5bc6ff3de feat: user profiles, subscriptions, and subscription feed
Some checks failed
CI / Scraper / Lint (push) Failing after 8s
CI / Scraper / Test (push) Successful in 11s
CI / Scraper / Lint (pull_request) Failing after 7s
CI / Scraper / Test (pull_request) Successful in 9s
CI / Scraper / Docker Push (push) Has been skipped
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (push) Successful in 23s
CI / UI / Build (pull_request) Successful in 22s
CI / UI / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (push) Successful in 32s
iOS CI / Build (pull_request) Successful in 1m52s
iOS CI / Test (pull_request) Successful in 3m50s
- PocketBase: new user_subscriptions collection (follower_id, followee_id)
- pocketbase.ts: subscribe/unsubscribe/getFollowingIds/getPublicProfile/
  getUserPublicLibrary/getUserCurrentlyReading/getSubscriptionFeed helpers
- GET /api/users/[username] — public profile with subscription state
- POST/DELETE /api/users/[username]/subscribe — follow/unfollow
- /users/[username] — public profile page: avatar, stats, follow button,
  currently reading grid, full library grid
- CommentsSection: usernames are now links to /users/[username]
- Home page: 'From People You Follow' section powered by subscription feed
2026-03-10 22:27:18 +05:00
Admin
8d4bba7964 feat(web): add /books/[slug]/chapters listing page
Some checks failed
CI / Scraper / Lint (pull_request) Failing after 8s
CI / Scraper / Test (pull_request) Successful in 9s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 22s
CI / UI / Docker Push (pull_request) Has been skipped
CI / UI / Build (push) Successful in 36s
CI / UI / Docker Push (push) Successful in 29s
iOS CI / Build (push) Successful in 2m0s
iOS CI / Build (pull_request) Successful in 3m29s
iOS CI / Test (push) Successful in 5m45s
iOS CI / Test (pull_request) Successful in 5m8s
Full chapter index with client-side search, 100-chapter page groups,
jump-to-current banner, and amber highlight on reading chapter.
2026-03-10 22:06:33 +05:00
Admin
2e5fe54615 fix(ios): add SearchView and AccountMenuSheet to Xcode project 2026-03-10 21:57:39 +05:00
Admin
81265510ef feat: book detail refactor — compact chapters row + reader UX improvements
Some checks failed
CI / Scraper / Lint (pull_request) Failing after 6s
CI / Scraper / Test (pull_request) Successful in 18s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 16s
CI / UI / Build (push) Successful in 24s
CI / UI / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (push) Successful in 35s
iOS CI / Build (push) Successful in 4m34s
iOS CI / Build (pull_request) Successful in 4m48s
iOS CI / Test (push) Has started running
iOS CI / Test (pull_request) Successful in 5m59s
iOS BookDetailView: replace paginated inline chapter list with a single
tappable 'Chapters' row (showing reading progress) that opens
BookChaptersSheet — a searchable full-screen sheet with jump-to-current.

ChapterReaderView: hide tab bar in reader, swap back/Aa/ToC button order
(Aa left, ToC right, X rightmost), remove mini-player spacer (tab bar
and player are hidden).

HomeView: remove large HeroContinueCard, promote all continue-reading
items into a single horizontal shelf (Apple Books style) with progress
bar below each cover. NavigationLink now goes directly to the chapter.

Web +page.svelte: replace inline paginated chapter list with a compact
'Chapters' row linking to /books/[slug]/chapters. Admin scrape controls
are now a collapsible row inside the same card.
2026-03-10 21:51:18 +05:00
Admin
4d3c093612 feat(ios): replace profile tab with search tab, add avatar button opening account sheet
Some checks failed
CI / Scraper / Lint (pull_request) Failing after 6s
CI / Scraper / Test (pull_request) Successful in 19s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 23s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 1m44s
iOS CI / Build (pull_request) Successful in 1m36s
iOS CI / Test (push) Successful in 5m51s
iOS CI / Test (pull_request) Successful in 4m19s
2026-03-10 21:30:28 +05:00
Admin
937ba052fc fix(ios): rewrite avatar crop — correct pixel mapping and drag clamping
Some checks failed
CI / Scraper / Lint (pull_request) Failing after 13s
CI / Scraper / Test (pull_request) Successful in 18s
CI / UI / Build (pull_request) Successful in 18s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Successful in 2m1s
iOS CI / Test (pull_request) Successful in 5m49s
2026-03-10 21:14:46 +05:00
Admin
479d201da9 fix(ios): fix comment delete — use fetchVoid for 204 No Content response
Some checks failed
CI / Scraper / Lint (pull_request) Failing after 7s
CI / Scraper / Test (pull_request) Successful in 19s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 25s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Successful in 1m43s
iOS CI / Test (pull_request) Successful in 4m53s
2026-03-10 21:10:07 +05:00
Admin
1242cc7eb3 fix(ios): optimistic comment deletion with revert on failure
Some checks failed
CI / Scraper / Lint (pull_request) Failing after 10s
CI / UI / Build (pull_request) Successful in 16s
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Test (pull_request) Successful in 19s
CI / Scraper / Docker Push (pull_request) Has been skipped
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Successful in 1m56s
iOS CI / Test (pull_request) Successful in 5m27s
2026-03-10 21:02:50 +05:00
Admin
0b6dbeb042 ci: inject VERSION/COMMIT build-args into all docker build steps
Some checks failed
CI / Scraper / Lint (push) Failing after 9s
CI / Scraper / Test (push) Successful in 9s
CI / Scraper / Test (pull_request) Successful in 8s
CI / Scraper / Lint (pull_request) Failing after 11s
CI / UI / Build (push) Successful in 22s
Release / Scraper / Test (push) Failing after 11s
Release / Scraper / Docker (push) Has been skipped
CI / Scraper / Docker Push (push) Has been skipped
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
Release / UI / Build (push) Successful in 21s
CI / UI / Docker Push (push) Successful in 35s
Release / UI / Docker (push) Successful in 35s
iOS CI / Build (pull_request) Successful in 1m32s
iOS CI / Test (pull_request) Successful in 5m37s
All four workflows (ci-scraper, ci-ui, release-scraper, release-ui) now pass
build-args to docker/build-push-action. Release workflows use the semver tag
from docker/metadata-action outputs.version; CI workflows use the git SHA.
2026-03-10 20:23:07 +05:00
Admin
c06877069f fix: add missing DELETE handler and fix comment delete/vote URLs (web + iOS)
Some checks failed
CI / Scraper / Lint (pull_request) Failing after 12s
CI / Scraper / Test (pull_request) Successful in 18s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (push) Successful in 24s
CI / UI / Build (pull_request) Successful in 17s
CI / UI / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (push) Successful in 29s
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Successful in 1m26s
iOS CI / Test (pull_request) Successful in 4m56s
The /api/comments/[id] delete route was never created; the deleteComment helper
in pocketbase.ts existed but was unreachable. Added DELETE /api/comment/[id]
route handler alongside the existing vote route. Updated CommentsSection.svelte
and iOS APIClient to use /api/comment/{id} for both delete and (already fixed)
vote, keeping all comment-mutation endpoints under the singular /api/comment/
prefix to avoid SvelteKit route conflicts with /api/comments/[slug].
2026-03-10 20:20:24 +05:00
Admin
261c738fc0 feat: inject build version/commit into scraper and UI at docker build time
Some checks failed
CI / Scraper / Lint (push) Failing after 6s
CI / Scraper / Lint (pull_request) Failing after 6s
CI / Scraper / Test (push) Successful in 16s
CI / Scraper / Test (pull_request) Successful in 9s
CI / Scraper / Docker Push (push) Has been skipped
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (push) Successful in 28s
CI / UI / Build (pull_request) Successful in 22s
CI / UI / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (push) Successful in 36s
iOS CI / Build (pull_request) Successful in 1m41s
iOS CI / Test (pull_request) Successful in 4m7s
- Go scraper: Version/Commit vars in main.go, injected via -ldflags; Server struct + New() updated; GET /health and new GET /api/version expose them
- UI Dockerfile: ARG BUILD_VERSION/BUILD_COMMIT → ENV PUBLIC_BUILD_VERSION/PUBLIC_BUILD_COMMIT for SvelteKit
- Footer: shows version+short commit when not 'dev' (text-zinc-800, subtle)
- docker-compose: args blocks for scraper and ui build sections pass $GIT_TAG/$GIT_COMMIT
2026-03-10 20:18:13 +05:00
Admin
5528abe4b0 fix: resolve SvelteKit route conflict by moving vote endpoint to /api/comment/[id]/vote
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 9s
CI / UI / Build (push) Successful in 23s
CI / Scraper / Test (pull_request) Successful in 24s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 16s
CI / UI / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (push) Successful in 30s
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 19m9s
iOS CI / Test (pull_request) Has been cancelled
/api/comments/[id] and /api/comments/[slug] were ambiguous dynamic segments at
the same path level, causing a build error. Moved the vote handler to the
singular /api/comment/ prefix and updated all callers (web + iOS).
2026-03-10 20:12:46 +05:00
Admin
09cdda2a07 feat: add avatars to comments (web + iOS) with replies, delete, sort, and crop fix
Some checks failed
CI / Scraper / Test (push) Successful in 10s
CI / UI / Build (push) Failing after 9s
CI / Scraper / Lint (pull_request) Successful in 7s
CI / UI / Build (pull_request) Failing after 7s
CI / UI / Docker Push (push) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Lint (push) Successful in 28s
CI / Scraper / Test (pull_request) Successful in 20s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 39s
iOS CI / Build (push) Successful in 2m16s
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Successful in 5m35s
iOS CI / Test (pull_request) Successful in 5m50s
- Batch-resolve avatar presign URLs server-side in GET /api/comments/[slug];
  returns avatarUrls map alongside comments and myVotes
- CommentsSection.svelte: show avatar image or initials fallback (24px top-level,
  20px replies) next to each comment/reply username
- iOS CommentsResponse gains avatarUrls field; CommentsViewModel stores and
  populates it on load; CommentRow renders AsyncImage with initials fallback
- Also includes: comment replies (1-level nesting), delete, sort (Top/New),
  parent_id schema migration, and AvatarCropModal cropperjs fix
2026-03-10 20:05:31 +05:00
Admin
718bfa6691 fix(ui): bundle marked into server output via ssr.noExternal
Some checks failed
CI / UI / Build (push) Successful in 17s
CI / Scraper / Lint (pull_request) Successful in 16s
CI / Scraper / Test (pull_request) Successful in 18s
CI / UI / Build (pull_request) Successful in 16s
Release / Scraper / Test (push) Successful in 20s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
Release / UI / Build (push) Successful in 23s
CI / UI / Docker Push (push) Successful in 36s
Release / UI / Docker (push) Successful in 34s
Release / Scraper / Docker (push) Successful in 49s
iOS CI / Build (pull_request) Failing after 6m15s
iOS CI / Test (pull_request) Has been skipped
Production Docker image has no node_modules at runtime; marked was being
externalized by adapter-node (it is in dependencies), causing ERR_MODULE_NOT_FOUND
when +page.server.ts and +server.ts imported it. Adding it to ssr.noExternal
forces Vite to inline it into the server bundle.
2026-03-10 19:01:43 +05:00
Admin
e11e866e27 fix(ui): bundle marked by switching from async to sync API
Some checks failed
CI / Scraper / Test (pull_request) Successful in 12s
CI / Scraper / Lint (pull_request) Successful in 17s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (push) Successful in 27s
CI / UI / Build (pull_request) Successful in 18s
CI / UI / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (push) Successful in 30s
iOS CI / Build (pull_request) Failing after 1m58s
iOS CI / Test (pull_request) Has been skipped
marked({ async: true }) triggers a dynamic internal require inside marked
that vite/rollup treats as external, causing ERR_MODULE_NOT_FOUND at runtime
in the adapter-node Docker image which ships no node_modules.
Switching to the synchronous marked() call makes rollup inline the full
library into the server chunk.
2026-03-10 18:27:00 +05:00
Admin
23345e22e6 fix(ui): fix chapter page SSR crash by lazy-loading marked
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 13s
CI / UI / Build (push) Successful in 22s
CI / Scraper / Test (pull_request) Successful in 16s
Release / Scraper / Test (push) Successful in 15s
CI / UI / Build (pull_request) Successful in 24s
CI / Scraper / Docker Push (pull_request) Has been skipped
Release / UI / Build (push) Successful in 22s
CI / UI / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (push) Successful in 30s
Release / UI / Docker (push) Successful in 42s
Release / Scraper / Docker (push) Successful in 1m19s
iOS CI / Build (pull_request) Failing after 1m54s
iOS CI / Test (pull_request) Has been skipped
Static import of 'marked' was included in the SSR bundle causing
ERR_MODULE_NOT_FOUND on first server render. The import is only
ever used inside onMount (client-only fallback path), so replace
with a dynamic import() at the call site.
2026-03-10 18:20:35 +05:00
Admin
c7b3495a23 fix(ios): wire avatar upload to presign flow with crop UI and cold-launch fix
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 8s
CI / Scraper / Test (pull_request) Successful in 20s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 28s
CI / UI / Docker Push (pull_request) Has been skipped
Release / Scraper / Test (push) Successful in 9s
Release / UI / Build (push) Successful in 28s
Release / Scraper / Docker (push) Successful in 24s
Release / UI / Docker (push) Successful in 3m6s
iOS CI / Build (push) Failing after 2m3s
iOS CI / Test (push) Has been skipped
iOS CI / Build (pull_request) Failing after 59s
iOS CI / Test (pull_request) Has been skipped
- Add AvatarCropView: fullscreen pan/pinch sheet with circular crop overlay,
  outputs 400×400 JPEG at 0.9 quality matching the web crop modal
- ProfileView: picker now shows crop sheet before uploading instead of direct upload
- AuthStore.validateToken: exchange raw MinIO key from /api/auth/me for a
  presigned GET URL so avatar renders correctly on cold launch / re-login
- APIClient: add fetchAvatarPresignedURL() calling GET /api/profile/avatar
- Models: add memberwise init to AppUser for avatar URL replacement
2026-03-10 18:17:01 +05:00
Admin
83a5910a59 feat: book commenting system with upvote/downvote + fix profile SSR crash
Some checks failed
CI / Scraper / Test (push) Successful in 10s
CI / Scraper / Lint (push) Successful in 12s
CI / Scraper / Lint (pull_request) Successful in 8s
CI / Scraper / Test (pull_request) Successful in 19s
CI / UI / Build (push) Successful in 32s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 15s
CI / UI / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (push) Failing after 11s
CI / Scraper / Docker Push (push) Successful in 45s
iOS CI / Build (push) Failing after 2m44s
iOS CI / Test (push) Has been skipped
iOS CI / Build (pull_request) Failing after 5m46s
iOS CI / Test (pull_request) Has been skipped
- Add book_comments and comment_votes PocketBase collections (pb-init.sh + pocketbase.go EnsureCollections)
- Web: CommentsSection.svelte with post form, vote buttons, lazy-loaded per-book
- API routes: GET/POST /api/comments/[slug], POST /api/comments/[id]/vote
- iOS: BookComment + CommentsResponse models, fetchComments/postComment/voteComment in APIClient, CommentsView + CommentsViewModel wired into BookDetailView
- Fix profile page SSR crash (ERR_MODULE_NOT_FOUND cropperjs): lazy-load AvatarCropModal via dynamic import guarded by browser, move URL.createObjectURL into onMount
2026-03-10 18:05:41 +05:00
Admin
0f6639aae7 feat(ui): avatar crop modal, health endpoint, leaner Dockerfile
All checks were successful
CI / Scraper / Lint (pull_request) Successful in 8s
CI / Scraper / Test (pull_request) Successful in 17s
CI / UI / Build (pull_request) Successful in 24s
Release / Scraper / Test (push) Successful in 17s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / UI / Build (push) Successful in 46s
Release / UI / Build (push) Successful in 15s
Release / UI / Docker (push) Successful in 41s
Release / Scraper / Docker (push) Successful in 5m32s
CI / UI / Docker Push (push) Successful in 8m13s
iOS CI / Build (pull_request) Successful in 8m38s
iOS CI / Test (pull_request) Successful in 13m23s
- Add AvatarCropModal.svelte using cropperjs v1: 1:1 crop, 400×400 output,
  JPEG/WebP output, dark glassmorphic UI
- Rewrite profile page avatar upload to use presigned PUT flow (POST→PUT→PATCH)
  instead of sending raw FormData directly; crop modal opens on file select
- Add GET /health → {status:ok} for Docker healthcheck
- Simplify Dockerfile: drop runtime npm ci (adapter-node bundles all deps)
- Fix docker-compose UI healthcheck: /health route, 127.0.0.1 to avoid
  IPv6 localhost resolution failure in alpine busybox wget
2026-03-10 17:46:37 +05:00
Admin
88a25bc33e refactor(ios): cleanup pass — dead code removal and component consolidation
All checks were successful
CI / Scraper / Lint (pull_request) Successful in 16s
CI / Scraper / Test (pull_request) Successful in 20s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 21s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 7m18s
iOS CI / Build (pull_request) Successful in 5m22s
iOS CI / Test (push) Successful in 10m55s
iOS CI / Test (pull_request) Successful in 9m32s
- Remove dead structs: ReadingProgress, HomeData, PBList (Models.swift)
- Remove unused APIClient members: fetchRaw, setSessionId, BrowseParams; simplify logout(); drop no-op CodingKeys from BrowseResponse
- Remove absolutePrevChapter/absoluteNextChapter computed props (replaced by prevChapter/nextChapter); replace URLSession cover prefetch with Kingfisher
- Drop scrollOffset state var and dead subtitle block in ChapterRow (BookDetailView)
- Remove never-set chaptersLoading published prop (BookDetailViewModel)
- Add unified ChipButton(.filled/.outlined) to CommonViews replacing three near-identical pill chip types
- Replace FilterChipView+SortChip in LibraryView and FilterChip in BrowseView with ChipButton
- Replace inline KFImage usage in HomeView with AsyncCoverImage
- Fix deprecated .navigationBarHidden(true) → .toolbar(.hidden, for: .navigationBar) in AuthView
2026-03-10 17:13:45 +05:00
Admin
73ad4ece49 ci: add release-scraper workflow triggered on v* tags
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 16s
CI / Scraper / Test (pull_request) Successful in 16s
CI / UI / Build (pull_request) Successful in 23s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (pull_request) Failing after 11m25s
iOS CI / Test (pull_request) Has been skipped
2026-03-10 17:08:24 +05:00
Admin
52f876d8e8 feat: avatar upload via presigned PUT URL flow
Some checks failed
CI / Scraper / Lint (push) Successful in 11s
CI / Scraper / Lint (pull_request) Successful in 9s
CI / Scraper / Test (push) Successful in 19s
CI / UI / Build (push) Successful in 21s
CI / Scraper / Test (pull_request) Successful in 9s
CI / UI / Build (pull_request) Successful in 27s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 47s
CI / UI / Docker Push (pull_request) Has been skipped
Release / UI / Build (push) Successful in 21s
Release / UI / Docker (push) Successful in 5m1s
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
CI / UI / Docker Push (push) Failing after 9m10s
iOS CI / Build (pull_request) Failing after 4m3s
iOS CI / Test (pull_request) Has been skipped
- Go scraper: add PresignAvatarUploadURL/PresignAvatarURL/DeleteAvatar to
  Store interface, implement on HybridStore+MinioClient, register
  GET /api/presign/avatar-upload/{userId} and /api/presign/avatar/{userId}
- SvelteKit: replace direct AWS S3 SDK in minio.ts with presign calls to
  the Go scraper; rewrite avatar +server.ts (POST=presign, PATCH=record key)
- iOS: rewrite uploadAvatar() as 3-step presigned PUT flow; refactor
  chip components into shared ChipButton in CommonViews.swift
2026-03-10 17:05:43 +05:00
Admin
72eed89f59 fix(ios): expose public validateToken() overload for post-avatar-upload refresh
Some checks failed
CI / Scraper / Test (pull_request) Successful in 9s
CI / Scraper / Lint (pull_request) Successful in 12s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 31s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 4m36s
iOS CI / Build (pull_request) Successful in 4m29s
iOS CI / Test (push) Failing after 4m26s
iOS CI / Test (pull_request) Failing after 12m37s
2026-03-10 16:18:43 +05:00
Admin
12bb0db5f0 feat: add libnovel-avatars bucket to minio-init and avatar_url field to pb-init
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 8s
CI / Scraper / Test (pull_request) Successful in 9s
CI / UI / Build (pull_request) Successful in 15s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (pull_request) Failing after 1m37s
iOS CI / Test (pull_request) Has been skipped
2026-03-10 16:12:17 +05:00
Admin
5ec1773768 chore: remove iOS release and deploy workflows (releasing locally)
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 10s
CI / Scraper / Test (pull_request) Successful in 9s
CI / UI / Build (pull_request) Successful in 15s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Failing after 1m31s
iOS CI / Test (push) Has been skipped
iOS CI / Build (pull_request) Failing after 1m26s
iOS CI / Test (pull_request) Has been skipped
2026-03-10 16:09:16 +05:00
Admin
fb8f1dfe25 feat: add avatar upload support (MinIO bucket, PocketBase field, SvelteKit API, iOS ProfileView)
Some checks failed
CI / Scraper / Lint (push) Successful in 15s
CI / Scraper / Test (push) Successful in 19s
CI / UI / Build (push) Successful in 21s
CI / Scraper / Lint (pull_request) Successful in 14s
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
CI / Scraper / Test (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (pull_request) Failing after 1m32s
iOS CI / Test (pull_request) Has been skipped
CI / UI / Docker Push (push) Successful in 6m42s
CI / Scraper / Docker Push (push) Successful in 7m12s
2026-03-10 16:08:38 +05:00
Admin
3a2d113b1b feat(ios): polish Home screen and reader UI
All checks were successful
CI / UI / Build (pull_request) Successful in 16s
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Lint (pull_request) Successful in 20s
CI / Scraper / Test (pull_request) Successful in 19s
CI / Scraper / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 2m14s
iOS CI / Build (pull_request) Successful in 2m1s
iOS CI / Test (push) Successful in 5m27s
iOS CI / Test (pull_request) Successful in 8m54s
Home:
- Hero card redesign: deeper amber-tinted gradient, taller cover (96×138), inline progress bar + completion % text above CTA
- Continue Reading shelf: replace chapter badge with circular progress arc ring (amber) containing chapter number
- Stats strip: add SF Symbol icons (books, alignleft, bookmark) above each stat value

Reader:
- Strip duplicate chapter-number/date/title prefix that novelfire embeds at top of HTML body
- Bottom chrome: pill-shaped prev/next chapter buttons (amber fill for next, muted for prev), placeholder spacers keep Listen button centered
- Chapter title page: show 'N% through' alongside date label, animated swipe-hint arrow that pulses once on appear
- Progress bar: 2px → 1px for a more refined look
2026-03-10 15:25:01 +05:00
Admin
0dcfdff65b feat(ios): Apple Books full player layout redesign
Some checks failed
CI / Scraper / Test (pull_request) Successful in 8s
CI / Scraper / Lint (pull_request) Successful in 14s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 21s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 1m45s
iOS CI / Build (pull_request) Successful in 3m56s
iOS CI / Test (push) Failing after 3m41s
iOS CI / Test (pull_request) Failing after 10m29s
- Outer GeometryReader for adaptive cover sizing (min of width-56 / height*0.42)
- Generating state: cover dims with 0.45 overlay + spinner; seek bar stays visible at opacity 0.3
- Title block: left-aligned with auto-next infinity.circle toggle on far right
- Removed ellipsis menu, year/cache metadata row, showingSpeedMenu state var
- New PlayerSecondaryButton and PlayerChapterSkipButton private helper structs
- Bottom toolbar: AirPlay | Speed | chevron.down | list.bullet | moon (44pt touch targets)
- ignoresSafeArea moved to outermost GeometryReader level
2026-03-09 23:53:03 +05:00
Admin
1766011b47 feat(ios): reader ToC drawer, swipe chapters, scroll mode, library filters
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 12s
CI / Scraper / Test (pull_request) Successful in 15s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 25s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 1m39s
iOS CI / Build (pull_request) Successful in 1m29s
iOS CI / Test (push) Has been cancelled
iOS CI / Test (pull_request) Successful in 5m14s
- Reader: list.bullet ToC button in top chrome opens ChaptersListSheet
- Reader: swipe right on title page → prev chapter, swipe left on end page → next chapter
- Reader: scroll mode toggle in settings panel (ReaderSettings.scrollMode); ScrollReaderContent for continuous layout
- Library: segmented All/In Progress/Completed filter
- Library: genre filter chips derived from book.genres, amber fill when active
- Library: completed books show checkmark badge + Finished label
- Library: context-aware empty state messages per filter combination
- Player: prev/next chapter buttons in mini + full player, ±15s skip, sleep timer countdown, Chapter N of M label
- Player: ChaptersListSheet with 100-chapter blocks, jump bar, search filter
- Home/BookDetail/Browse: Apple Books-style redesign, zoom transitions
2026-03-09 23:46:19 +05:00
Admin
a6f800b0d7 Fix iOS bundle ID: change from cc.kalekber.libnovel to com.kalekber.LibNovel
All checks were successful
CI / Scraper / Lint (pull_request) Successful in 16s
CI / UI / Build (pull_request) Successful in 16s
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Test (pull_request) Successful in 19s
CI / Scraper / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 2m18s
iOS CI / Build (pull_request) Successful in 1m55s
iOS CI / Test (push) Successful in 4m56s
iOS CI / Test (pull_request) Successful in 5m28s
This matches the provisioning profile and App Store Connect configuration.
Also fixed ExportOptions.plist to use actual team ID instead of variable.
2026-03-09 19:59:11 +05:00
Admin
af9639af05 Fix iOS signing: use PROVISIONING_PROFILE (UUID) instead of PROVISIONING_PROFILE_SPECIFIER
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 9s
CI / Scraper / Test (pull_request) Successful in 16s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 23s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 1m49s
iOS CI / Test (pull_request) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 57s
iOS CI / Test (push) Successful in 8m50s
PROVISIONING_PROFILE accepts the UUID directly and finds the profile correctly.
PROVISIONING_PROFILE_SPECIFIER expects 'TEAM_ID/name' format which caused lookup failures.

Successfully tested locally - archive builds and signs correctly.
2026-03-09 19:33:36 +05:00
Admin
bfc08a2df2 Fix iOS signing: add CODE_SIGN_IDENTITY and PROVISIONING_PROFILE_SPECIFIER to project.yml
Some checks failed
CI / Scraper / Test (pull_request) Successful in 10s
CI / Scraper / Lint (pull_request) Successful in 14s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 22s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 1m56s
iOS CI / Build (pull_request) Successful in 1m23s
iOS Release / Release to TestFlight (push) Failing after 55s
iOS CI / Test (push) Successful in 4m14s
iOS CI / Test (pull_request) Successful in 8m9s
The issue was that these settings need to be target-specific in project.yml,
not passed globally via xcodebuild args (which would conflict with SPM dependencies).

Successfully tested locally - archive builds and signs correctly.
2026-03-09 19:14:01 +05:00
Admin
dc3bc3ebf2 Use provisioning profile UUID instead of name for manual signing
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 14s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Test (pull_request) Successful in 17s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 1m31s
iOS CI / Test (pull_request) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 57s
iOS CI / Test (push) Successful in 4m29s
2026-03-09 19:02:17 +05:00
Admin
e9d7293d37 Switch to manual code signing for CI (automatic signing requires Apple ID)
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 15s
CI / Scraper / Test (pull_request) Successful in 16s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 16s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 57s
2026-03-09 18:57:21 +05:00
Admin
410af8f236 Fix iOS build: use update_code_signing_settings and explicit -allowProvisioningUpdates flags
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 13s
CI / UI / Build (pull_request) Successful in 15s
CI / Scraper / Test (pull_request) Successful in 18s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 58s
2026-03-09 18:52:04 +05:00
Admin
264c00c765 fix: switch to automatic signing for archive, manual for export
Some checks failed
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
CI / Scraper / Test (pull_request) Successful in 15s
CI / Scraper / Lint (pull_request) Successful in 16s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS Release / Release to TestFlight (push) Failing after 1m3s
2026-03-09 18:37:15 +05:00
Admin
e4c72011eb fix: extract and use actual profile name from mobileprovision file
Some checks failed
CI / Scraper / Test (pull_request) Successful in 13s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Lint (pull_request) Successful in 18s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 1m3s
2026-03-09 18:27:29 +05:00
Admin
6365b14ece fix: use update_code_signing_settings to configure provisioning profile
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 13s
CI / UI / Build (pull_request) Successful in 17s
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Test (pull_request) Successful in 20s
CI / Scraper / Docker Push (pull_request) Has been skipped
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 1m1s
2026-03-09 18:23:44 +05:00
Admin
7da5582075 fix: add manual code signing args to build_app in fastlane
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 8s
CI / Scraper / Test (pull_request) Successful in 16s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 27s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 54s
2026-03-09 18:18:30 +05:00
Admin
dae841e317 fix: set USER and locale environment variables for xcodegen
Some checks failed
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
CI / Scraper / Test (pull_request) Successful in 16s
CI / UI / Build (pull_request) Successful in 18s
CI / Scraper / Lint (pull_request) Successful in 19s
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (pull_request) Has been skipped
iOS Release / Release to TestFlight (push) Failing after 43s
2026-03-09 18:16:25 +05:00
Admin
16b2bfffa6 fix: use correct path for xcodegen in fastlane
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 11s
CI / Scraper / Test (pull_request) Successful in 18s
CI / Scraper / Docker Push (pull_request) Has been skipped
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
CI / UI / Build (pull_request) Successful in 28s
CI / UI / Docker Push (pull_request) Has been skipped
iOS Release / Release to TestFlight (push) Failing after 25s
2026-03-09 18:14:33 +05:00
Admin
57be674f44 feat: use rbenv to install Ruby 3.2.2 for fastlane
Some checks failed
CI / Scraper / Lint (pull_request) Successful in 8s
CI / Scraper / Test (pull_request) Successful in 17s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 21s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 1m51s
2026-03-09 16:12:02 +05:00
Admin
93390fab64 fix: install fastlane via Homebrew instead of bundler
Some checks failed
CI / Scraper / Test (pull_request) Successful in 8s
CI / Scraper / Lint (pull_request) Successful in 19s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 23s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
iOS Release / Release to TestFlight (push) Failing after 11s
iOS CI / Test (pull_request) Has been cancelled
iOS CI / Build (pull_request) Has been cancelled
2026-03-09 16:11:16 +05:00
Admin
072517135f fix: use system Ruby instead of ruby/setup-ruby action
Some checks failed
CI / Scraper / Test (pull_request) Successful in 14s
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
CI / Scraper / Lint (pull_request) Successful in 17s
CI / Scraper / Docker Push (pull_request) Has been skipped
iOS CI / Build (pull_request) Has been cancelled
iOS CI / Test (pull_request) Has been cancelled
CI / UI / Build (pull_request) Successful in 22s
CI / UI / Docker Push (pull_request) Has been skipped
iOS Release / Release to TestFlight (push) Failing after 1m27s
2026-03-09 16:08:25 +05:00
145 changed files with 25174 additions and 1685 deletions

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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; }

View File

@@ -1,78 +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: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: true
working-directory: ios/LibNovel
- 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 $(security list-keychains -d user | tr -d '"' | xargs)
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" $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
- name: Build and upload to TestFlight
env:
APPLE_KEY_ID: ${{ secrets.ASC_KEY_ID }}
APPLE_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
APPLE_KEY_CONTENT: ${{ secrets.ASC_PRIVATE_KEY }}
BUILD_NUMBER: ${{ gitea.run_number }}
PROVISIONING_PROFILE_NAME: LibNovel Distribution
working-directory: ios/LibNovel
run: bundle exec fastlane beta
- name: Cleanup keychain
if: always()
run: security delete-keychain $RUNNER_TEMP/app-signing.keychain-db

View 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 }}

View File

@@ -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 }}

View 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.50.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 1620 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: 1214 pt for cards/chips, 10 pt for small badges, 2024 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.450.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

View File

@@ -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`.

View File

@@ -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
View 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

View File

@@ -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>

View File

@@ -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";
};

View File

@@ -1,5 +1,5 @@
{
"originHash" : "18350c2bfa3935125b6f4e9817e7ed4508588c07142d420b8b8ee00640a57853",
"originHash" : "ad75ae2d3b8d8b80d99635f65213a3c1092464aa54a86354f850b8317b6fa240",
"pins" : [
{
"identity" : "kingfisher",

View File

@@ -79,7 +79,7 @@
<EnvironmentVariables>
<EnvironmentVariable
key = "LIBNOVEL_BASE_URL"
value = "[&quot;isEnabled&quot;: true, &quot;value&quot;: &quot;https://v2.libnovel.kalekber.cc&quot;]"
value = "[&quot;value&quot;: &quot;https://v2.libnovel.kalekber.cc&quot;, &quot;isEnabled&quot;: true]"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>

View File

@@ -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)
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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))
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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>

View 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)
}

View File

@@ -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

View File

@@ -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 {

View 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")
}
}
}

View 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"
}
}
}

View File

@@ -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) {

View File

@@ -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,

View 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
}
}

View File

@@ -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

View 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)
}
}

View 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)
}
}
}

View File

@@ -96,7 +96,7 @@ struct AuthView: View {
Spacer()
}
.navigationBarHidden(true)
.toolbar(.hidden, for: .navigationBar)
}
.onChange(of: mode) { _, _ in
authStore.error = nil

View File

@@ -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. "1100").
@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 "1100", "101200", 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())
}
}

View 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))
}
}

View File

@@ -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 Booksstyle 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 {

View File

@@ -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
}
}

View File

@@ -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)
}
}

View 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))
}
}
}

View 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: " ")
}
}

View 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
}
}
}

View File

@@ -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)")!
}
}

View File

@@ -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

View 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
}

View 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)
}
}

View File

@@ -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
}

View 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)
}
}
}

View 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())
}

View 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)
}
}

View File

@@ -3,8 +3,8 @@ default_platform(:ios)
platform :ios do
desc "Build and upload to TestFlight"
lane :beta do
# Generate Xcode project from project.yml
sh("xcodegen generate --spec project.yml --project .")
# 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(
@@ -12,15 +12,19 @@ platform :ios do
xcodeproj: "LibNovel.xcodeproj"
)
# Build the app
# 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" => ENV["PROVISIONING_PROFILE_NAME"] || "LibNovel Distribution"
}
"com.kalekber.LibNovel" => "LibNovel Distribution"
},
signingStyle: "manual"
}
)

View File

@@ -53,6 +53,8 @@ targets:
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

32
ios/LibNovel/test-build.sh Executable file
View 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! ==="

View 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)
}
}

View 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)
}
}
}

View 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
}
}
}

View 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
}
}
}

View 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)
}
}

View 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 */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -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 = "[&quot;value&quot;: &quot;https://v2.libnovel.kalekber.cc&quot;, &quot;isEnabled&quot;: 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>

View 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
}

View 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
}()
}

View File

@@ -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 }
}

View File

@@ -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

View File

@@ -0,0 +1,3 @@
{
"info": { "author": "xcode", "version": 1 }
}

View 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>

View 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)
}

View 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")
}

View 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)
}
}

View 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)
}
}
}

View 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"
}
}
}

View 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
}
}

View 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)
}

View 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
)
}
}
}

View 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
}
}

View 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
}
}
}

View 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)
}
}

View 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("332 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 332 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)
}

View 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)
}
}

View 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)
}
}

View 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)
}
}

File diff suppressed because it is too large Load Diff

View 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))
}
}
}

View 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
}

View 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)")
}
}

View 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 // 01
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
)
}
}

File diff suppressed because it is too large Load Diff

View 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)
}
}

View 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)
}
}

View 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] : [])
}
}

View 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)
}
}

View 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

View File

@@ -0,0 +1,70 @@
name: LibNovelV2
options:
bundleIdPrefix: com.kalekber
deploymentTarget:
iOS: "17.0"
xcodeVersion: "16.0"
generateEmptyDirectories: true
indentWidth: 4
tabWidth: 4
usesTabs: false
settings:
base:
SWIFT_VERSION: "5.10"
ENABLE_PREVIEWS: YES
MARKETING_VERSION: "1.0.0"
CURRENT_PROJECT_VERSION: "1"
LIBNOVEL_BASE_URL: "https://v2.libnovel.kalekber.cc"
configs:
Debug:
SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG
Release:
SWIFT_ACTIVE_COMPILATION_CONDITIONS: ""
targets:
LibNovelV2:
type: application
platform: iOS
deploymentTarget: "17.0"
sources:
- path: .
excludes:
- "**/.DS_Store"
- "Resources/Info.plist"
- "Resources/Assets.xcassets"
- "features.md"
- "project.yml"
resources:
- path: Resources/Assets.xcassets
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.kalekber.LibNovelV2
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
TARGETED_DEVICE_FAMILY: "1,2" # iPhone + iPad
GENERATE_INFOPLIST_FILE: NO
INFOPLIST_FILE: Resources/Info.plist
configs:
Release:
CODE_SIGN_STYLE: Manual
DEVELOPMENT_TEAM: GHZXC6FVMU
CODE_SIGN_IDENTITY: "Apple Distribution"
PROVISIONING_PROFILE: "af592c3a-f60b-4ac1-a14f-30b8a206017f"
schemes:
LibNovelV2:
build:
targets:
LibNovelV2: all
run:
config: Debug
environmentVariables:
LIBNOVEL_BASE_URL:
value: "https://v2.libnovel.kalekber.cc"
isEnabled: true
profile:
config: Release
analyze:
config: Debug
archive:
config: Release

13
opencode.json Normal file
View 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"
]
}

View File

@@ -9,8 +9,14 @@ RUN go mod download
COPY . .
# Build-time version info — injected by docker-compose or CI via --build-arg.
ARG VERSION=dev
ARG COMMIT=unknown
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -o /scraper ./cmd/scraper
go build \
-ldflags="-s -w -X main.Version=${VERSION} -X main.Commit=${COMMIT}" \
-o /scraper ./cmd/scraper
# ── Runtime stage ──────────────────────────────────────────────────────────────
FROM alpine:3.21

View File

@@ -47,6 +47,13 @@ import (
"github.com/libnovel/scraper/internal/storage"
)
// Build-time version info — injected via -ldflags during docker build.
// Falls back to "dev" / "unknown" when built without -ldflags (local dev).
var (
Version = "dev"
Commit = "unknown"
)
func main() {
logLevel := slog.LevelInfo
if v := os.Getenv("LOG_LEVEL"); v != "" {
@@ -95,6 +102,7 @@ func run(log *slog.Logger) error {
BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "libnovel-chapters"),
BucketAudio: envOr("MINIO_BUCKET_AUDIO", "libnovel-audio"),
BucketBrowse: envOr("MINIO_BUCKET_BROWSE", "libnovel-browse"),
BucketAvatars: envOr("MINIO_BUCKET_AVATARS", "libnovel-avatars"),
}
pbCfg := storage.PocketBaseConfig{
BaseURL: envOr("POCKETBASE_URL", "http://localhost:8090"),
@@ -182,7 +190,7 @@ func run(log *slog.Logger) error {
"pocketbase_url", pbCfg.BaseURL,
"pocketbase_email", pbCfg.AdminEmail,
)
srv := server.New(addr, oCfg, nf, log, store, kokoroURL, kokoroVoice)
srv := server.New(addr, oCfg, nf, log, store, kokoroURL, kokoroVoice, Version, Commit)
return srv.ListenAndServe(ctx)
case "save-browse":

View File

@@ -151,6 +151,13 @@ func (s *mockStore) PresignChapter(_ context.Context, _ string, _ int, _ time.Du
func (s *mockStore) PresignAudio(_ context.Context, _ string, _ time.Duration) (string, error) {
return "", nil
}
func (s *mockStore) PresignAvatarUpload(_ context.Context, _, _ string) (string, string, error) {
return "", "", nil
}
func (s *mockStore) PresignAvatarURL(_ context.Context, _ string) (string, bool, error) {
return "", false, nil
}
func (s *mockStore) DeleteAvatar(_ context.Context, _ string) error { return nil }
func (s *mockStore) SaveBrowsePage(_ context.Context, _, _ string) error { return nil }
func (s *mockStore) GetBrowsePage(_ context.Context, _ string) (string, bool, error) {
return "", false, nil

Some files were not shown because too many files have changed in this diff Show More