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
326 lines
11 KiB
Swift
326 lines
11 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - LibraryView
|
|
// 2-column grid of saved books with progress overlay, genre/sort/reading-status filters.
|
|
|
|
struct LibraryView: View {
|
|
@State private var viewModel = LibraryViewModel()
|
|
@EnvironmentObject private var networkMonitor: NetworkMonitor
|
|
|
|
// Sort sheet
|
|
@State private var showingSortSheet = false
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack(spacing: 0) {
|
|
OfflineBanner()
|
|
|
|
// Filter bar
|
|
filterBar
|
|
|
|
if viewModel.isLoading && viewModel.items.isEmpty {
|
|
loadingState
|
|
} else if viewModel.filteredItems.isEmpty && !viewModel.isLoading {
|
|
emptyState
|
|
} else {
|
|
bookGrid
|
|
}
|
|
}
|
|
.background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1)))
|
|
.navigationTitle("Library")
|
|
.navigationBarTitleDisplayMode(.large)
|
|
.toolbar { toolbarContent }
|
|
.appNavigationDestination()
|
|
.task {
|
|
guard networkMonitor.isConnected else { return }
|
|
await viewModel.load()
|
|
}
|
|
.refreshable { await viewModel.load() }
|
|
.errorAlert($viewModel.error)
|
|
.confirmationDialog("Sort By", isPresented: $showingSortSheet, titleVisibility: .visible) {
|
|
ForEach(LibrarySortOrder.allCases, id: \.self) { order in
|
|
Button(order.rawValue) {
|
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
|
viewModel.sortOrder = order
|
|
}
|
|
}
|
|
}
|
|
Button("Cancel", role: .cancel) {}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Filter bar
|
|
|
|
private var filterBar: some View {
|
|
VStack(spacing: 0) {
|
|
// Reading filter chips
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
ForEach(LibraryReadingFilter.allCases, id: \.self) { filter in
|
|
ChipButton(label: filter.rawValue,
|
|
isSelected: viewModel.readingFilter == filter,
|
|
style: .filled) {
|
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
|
viewModel.readingFilter = filter
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
}
|
|
|
|
// Genre chips (only show if there are genres)
|
|
if viewModel.allGenres.count > 1 {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
ForEach(viewModel.allGenres, id: \.self) { genre in
|
|
ChipButton(label: genre,
|
|
isSelected: viewModel.selectedGenre == genre,
|
|
style: .outlined) {
|
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
|
viewModel.selectedGenre = genre
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.bottom, 8)
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
.background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1)))
|
|
}
|
|
}
|
|
|
|
// MARK: - Book grid
|
|
|
|
private let columns = [
|
|
GridItem(.flexible(), spacing: 12),
|
|
GridItem(.flexible(), spacing: 12)
|
|
]
|
|
|
|
private var bookGrid: some View {
|
|
ScrollView {
|
|
LazyVGrid(columns: columns, spacing: 16) {
|
|
ForEach(viewModel.filteredItems) { item in
|
|
NavigationLink(value: NavDestination.book(item.book.slug)) {
|
|
LibraryBookCard(
|
|
item: item,
|
|
progress: viewModel.progressFraction(for: item),
|
|
progressLabel: viewModel.progressPercent(for: item),
|
|
isCompleted: viewModel.isCompleted(for: item),
|
|
lastChapter: viewModel.lastChapter(for: item)
|
|
)
|
|
.bookCoverZoomSource(slug: item.book.slug)
|
|
.contextMenu {
|
|
contextMenu(for: item)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 16)
|
|
.padding(.bottom, 120) // clear mini player
|
|
}
|
|
}
|
|
|
|
// MARK: - Context menu
|
|
|
|
@ViewBuilder
|
|
private func contextMenu(for item: LibraryItem) -> some View {
|
|
Button {
|
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
// Share: nothing to share without a URL from API, placeholder
|
|
} label: {
|
|
Label("Share", systemImage: "square.and.arrow.up")
|
|
}
|
|
|
|
if !viewModel.isCompleted(for: item) {
|
|
Button {
|
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
|
Task { await viewModel.markFinished(item: item) }
|
|
} label: {
|
|
Label("Mark as Finished", systemImage: "checkmark.circle")
|
|
}
|
|
}
|
|
|
|
Button(role: .destructive) {
|
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
|
Task { await viewModel.removeFromLibrary(slug: item.book.slug) }
|
|
} label: {
|
|
Label("Remove from Library", systemImage: "trash")
|
|
}
|
|
}
|
|
|
|
// MARK: - Toolbar
|
|
|
|
@ToolbarContentBuilder
|
|
private var toolbarContent: some ToolbarContent {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button {
|
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
showingSortSheet = true
|
|
} label: {
|
|
Label("Sort", systemImage: "arrow.up.arrow.down")
|
|
.labelStyle(.iconOnly)
|
|
}
|
|
.accessibilityLabel("Sort library")
|
|
}
|
|
}
|
|
|
|
// MARK: - Loading state
|
|
|
|
private var loadingState: some View {
|
|
ScrollView {
|
|
LazyVGrid(columns: columns, spacing: 16) {
|
|
ForEach(0..<8, id: \.self) { _ in
|
|
LibraryBookCardSkeleton()
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 16)
|
|
}
|
|
}
|
|
|
|
// MARK: - Empty state
|
|
|
|
private var emptyState: some View {
|
|
VStack {
|
|
Spacer()
|
|
EmptyStateView(
|
|
icon: "books.vertical",
|
|
title: viewModel.items.isEmpty ? "Your library is empty" : "No books match",
|
|
message: viewModel.items.isEmpty
|
|
? "Browse and save books to build your collection."
|
|
: "Try a different filter or genre.",
|
|
ctaLabel: viewModel.items.isEmpty ? "Browse Books" : nil,
|
|
ctaAction: nil
|
|
)
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - LibraryBookCard
|
|
|
|
struct LibraryBookCard: View {
|
|
let item: LibraryItem
|
|
let progress: Double // 0…1
|
|
let progressLabel: String // "47%" or "3.4%"
|
|
let isCompleted: Bool
|
|
let lastChapter: Int
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
// Cover with progress arc overlay
|
|
ZStack(alignment: .topTrailing) {
|
|
AsyncCoverImage(url: item.book.cover)
|
|
.aspectRatio(2/3, contentMode: .fill)
|
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
|
|
if isCompleted {
|
|
completedBadge
|
|
} else if progress > 0 {
|
|
progressArcBadge
|
|
}
|
|
}
|
|
|
|
// Title
|
|
Text(item.book.title)
|
|
.font(.caption.bold())
|
|
.foregroundStyle(.primary)
|
|
.lineLimit(2)
|
|
|
|
// Chapter subtitle
|
|
if lastChapter > 0 {
|
|
Text(isCompleted ? "Completed" : "Ch. \(lastChapter)")
|
|
.font(.caption2)
|
|
.foregroundStyle(isCompleted ? Color.amber : .secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Completed badge
|
|
|
|
private var completedBadge: some View {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.font(.title3)
|
|
.foregroundStyle(Color.amber)
|
|
.padding(6)
|
|
.background(.regularMaterial, in: Circle())
|
|
.padding(6)
|
|
.accessibilityLabel("Completed")
|
|
}
|
|
|
|
// MARK: - Progress arc
|
|
|
|
private var progressArcBadge: some View {
|
|
ZStack {
|
|
// Track
|
|
Circle()
|
|
.stroke(Color.white.opacity(0.25), lineWidth: 3)
|
|
.frame(width: 32, height: 32)
|
|
|
|
// Fill
|
|
Circle()
|
|
.trim(from: 0, to: progress)
|
|
.stroke(Color.amber, style: StrokeStyle(lineWidth: 3, lineCap: .round))
|
|
.rotationEffect(.degrees(-90))
|
|
.frame(width: 32, height: 32)
|
|
.animation(.spring(response: 0.5, dampingFraction: 0.7), value: progress)
|
|
|
|
Text(progressLabel)
|
|
.font(.system(size: 7, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
}
|
|
.padding(6)
|
|
.background(.ultraThinMaterial, in: Circle())
|
|
.padding(6)
|
|
.accessibilityLabel("Progress: \(progressLabel)")
|
|
}
|
|
}
|
|
|
|
// MARK: - LibraryBookCardSkeleton
|
|
// Shimmer placeholder used while data is loading.
|
|
|
|
struct LibraryBookCardSkeleton: View {
|
|
@State private var phase: Double = 0
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.fill(shimmerGradient)
|
|
.aspectRatio(2/3, contentMode: .fill)
|
|
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(shimmerGradient)
|
|
.frame(height: 12)
|
|
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(shimmerGradient)
|
|
.frame(width: 60, height: 10)
|
|
}
|
|
.onAppear {
|
|
withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: true)) {
|
|
phase = 1
|
|
}
|
|
}
|
|
}
|
|
|
|
private var shimmerGradient: LinearGradient {
|
|
LinearGradient(
|
|
colors: [
|
|
Color(uiColor: UIColor(red: 0.15, green: 0.15, blue: 0.17, alpha: 1)),
|
|
Color(uiColor: UIColor(red: 0.22, green: 0.22, blue: 0.25, alpha: 1)),
|
|
Color(uiColor: UIColor(red: 0.15, green: 0.15, blue: 0.17, alpha: 1))
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
}
|
|
}
|