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
672 lines
24 KiB
Swift
672 lines
24 KiB
Swift
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)
|
||
}
|
||
}
|