Files
libnovel/ios/LibNovelV2/Views/Library/LibraryView.swift
Admin 7413313100
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
fix: update integration_test.go to match server.New signature (version, commit args)
2026-03-14 14:25:46 +05:00

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