Some checks failed
CI / Scraper / Test (pull_request) Failing after 8s
CI / UI / Build (pull_request) Failing after 8s
CI / Scraper / Lint (pull_request) Failing after 11s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 1s
iOS CI / Test (pull_request) Has been skipped
- Leftmost button: 15-second backward skip (gobackward.15) - Second button: previous chapter navigation (backward.end.fill) - Center: play/pause (unchanged) - Fourth button: next chapter navigation (forward.end.fill) - Rightmost button: 15-second forward skip (goforward.15) - Added prevChapter tracking in AudioPlayerService - Added skipToPrevChapter notification support - Chapter nav buttons disabled when no prev/next available (0.5 opacity) - Updated load() signature to accept prevChapter parameter - Auto-next properly tracks prevChapter when advancing chapters
225 lines
7.8 KiB
Swift
225 lines
7.8 KiB
Swift
import SwiftUI
|
||
|
||
struct BookDetailView: View {
|
||
let slug: String
|
||
@StateObject private var vm: BookDetailViewModel
|
||
@EnvironmentObject var authStore: AuthStore
|
||
@EnvironmentObject var audioPlayer: AudioPlayerService
|
||
@State private var summaryExpanded = false
|
||
@State private var chapterPage = 0
|
||
private let pageSize = 50
|
||
|
||
init(slug: String) {
|
||
self.slug = slug
|
||
_vm = StateObject(wrappedValue: BookDetailViewModel(slug: slug))
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle("")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar { bookmarkButton }
|
||
.task { await vm.load() }
|
||
.alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) {
|
||
Button("OK") { vm.error = nil }
|
||
} message: { Text(vm.error ?? "") }
|
||
}
|
||
|
||
// MARK: - Hero
|
||
|
||
@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)
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 260)
|
||
.blur(radius: 20)
|
||
.clipped()
|
||
.overlay(Color.black.opacity(0.45))
|
||
|
||
HStack(alignment: .bottom, spacing: 14) {
|
||
AsyncCoverImage(url: book.cover)
|
||
.frame(width: 110, height: 160)
|
||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||
.shadow(radius: 8)
|
||
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Text(book.title)
|
||
.font(.headline)
|
||
.foregroundStyle(.white)
|
||
.lineLimit(3)
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
Spacer(minLength: 0)
|
||
}
|
||
.padding(.horizontal)
|
||
.padding(.bottom, 16)
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// MARK: - Chapter list
|
||
|
||
@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])
|
||
|
||
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)
|
||
}
|
||
}
|
||
.padding(.horizontal)
|
||
.padding(.vertical, 10)
|
||
|
||
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)
|
||
}
|
||
.buttonStyle(.plain)
|
||
Divider().padding(.leading)
|
||
}
|
||
}
|
||
|
||
// Pagination
|
||
if total > pageSize {
|
||
HStack {
|
||
Button("Previous") { chapterPage -= 1 }
|
||
.disabled(chapterPage == 0)
|
||
Spacer()
|
||
Button("Next") { chapterPage += 1 }
|
||
.disabled(end >= total)
|
||
}
|
||
.buttonStyle(.bordered)
|
||
.padding()
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Toolbar bookmark
|
||
|
||
@ToolbarContentBuilder
|
||
private var bookmarkButton: some ToolbarContent {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button {
|
||
Task { await vm.toggleSaved() }
|
||
} label: {
|
||
Image(systemName: vm.saved ? "bookmark.fill" : "bookmark")
|
||
.foregroundStyle(vm.saved ? .amber : .primary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
.padding(.horizontal)
|
||
.padding(.vertical, 10)
|
||
.contentShape(Rectangle())
|
||
}
|
||
}
|