Files
bookibra/ios/Bookibra/Views/AddBooks/AddBooksView.swift

299 lines
11 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import SwiftData
struct AddBooksView: View {
enum FlowStep: Int {
case source
case confirm
case categorize
case success
}
@EnvironmentObject private var router: AppRouter
@Environment(\.modelContext) private var modelContext
@ObservedObject var viewModel: AddBooksViewModel
@State private var step: FlowStep = .source
@State private var selectedBook: BookRemote?
@State private var selectedCategory = "Design"
@State private var selectedStatus: ReadingStatus = .wantToRead
@State private var manualISBN = ""
@State private var startReadingNow = false
@State private var animateSuccess = false
private let defaultCategories = ["Design", "Psychology", "Novels"]
var body: some View {
ScrollView {
VStack(spacing: Theme.Spacing.medium) {
progressHeader
switch step {
case .source:
sourceStep
case .confirm:
confirmStep
case .categorize:
categorizeStep
case .success:
successStep
}
}
.frame(maxWidth: .infinity, alignment: .top)
.padding(.bottom, 24)
}
.padding(.horizontal, 16)
.navigationTitle(String(localized: "add.title"))
.navigationBarTitleDisplayMode(.inline)
.overlay {
if viewModel.isLoading {
ProgressView()
.controlSize(.large)
}
}
.sensoryFeedback(.success, trigger: animateSuccess)
}
private var progressHeader: some View {
HStack(spacing: 8) {
ForEach(0..<4, id: \.self) { index in
Capsule()
.fill(index <= step.rawValue ? Color.accentColor : Color.gray.opacity(0.25))
.frame(height: 6)
.animation(.easeOut(duration: 0.2), value: step.rawValue)
}
}
.padding(.top, 4)
}
private var sourceStep: some View {
VStack(spacing: Theme.Spacing.medium) {
Picker("Mode", selection: $viewModel.mode) {
ForEach(AddBooksViewModel.Mode.allCases, id: \.self) { mode in
Text(mode.title).tag(mode)
}
}
.pickerStyle(.segmented)
Group {
switch viewModel.mode {
case .title:
TextField(String(localized: "add.searchPlaceholder"), text: $viewModel.titleQuery)
.textFieldStyle(.roundedBorder)
.onChange(of: viewModel.titleQuery) { _, _ in
viewModel.titleChanged()
}
case .scan:
VStack(spacing: 10) {
BarcodeScannerView { isbn in
Task { await viewModel.searchByISBN(isbn) }
}
.frame(height: 240)
.clipShape(RoundedRectangle(cornerRadius: 16))
HStack {
TextField("ISBN manuel gir", text: $manualISBN)
.textFieldStyle(.roundedBorder)
.keyboardType(.numberPad)
Button("Ara") {
Task { await viewModel.searchByISBN(manualISBN) }
}
.buttonStyle(.borderedProminent)
}
}
case .filter:
VStack(spacing: 8) {
TextField("Title", text: $viewModel.filterTitle)
.textFieldStyle(.roundedBorder)
TextField("YYYY", text: $viewModel.filterYear)
.textFieldStyle(.roundedBorder)
.keyboardType(.numberPad)
Button("Apply") {
Task { await viewModel.applyFilter() }
}
.buttonStyle(.borderedProminent)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
}
if let error = viewModel.errorMessage {
NetworkErrorView(message: error) {
Task { await viewModel.searchByTitle() }
}
}
if viewModel.results.isEmpty, !viewModel.isLoading {
EmptyStateView(
symbol: "magnifyingglass",
title: "Sonuç bulunamadı",
message: "Farklı bir başlık dene veya ISBN'i manuel gir.",
buttonTitle: nil,
action: nil
)
} else {
LazyVStack(spacing: 10) {
ForEach(viewModel.results, id: \.id) { book in
Button {
selectedBook = book
step = .confirm
} label: {
HStack(spacing: 12) {
AsyncImage(url: book.coverImageUrl) { image in
image.resizable().scaledToFill()
} placeholder: {
RoundedRectangle(cornerRadius: 8).fill(.gray.opacity(0.2))
}
.frame(width: 48, height: 72)
.clipShape(RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 4) {
Text(book.title)
.font(.headline)
.lineLimit(2)
Text(book.authors.joined(separator: ", "))
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.tertiary)
}
}
.buttonStyle(.plain)
.padding(12)
.background(Color.white.opacity(0.75), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
}
}
}
private var confirmStep: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.medium) {
if let book = selectedBook {
BookCardView(
title: book.title,
author: book.authors.first ?? "",
coverURL: book.coverImageUrl,
status: .wantToRead,
progress: nil
)
}
HStack {
Button("Geri") { step = .source }
.buttonStyle(.bordered)
Spacer()
Button("Bilgileri Onayla") { step = .categorize }
.buttonStyle(.borderedProminent)
}
}
}
private var categorizeStep: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.medium) {
Text("Kategori & Durum")
.font(.title3.weight(.semibold))
Picker("Kategori", selection: $selectedCategory) {
ForEach(defaultCategories, id: \.self) { category in
Text(category).tag(category)
}
}
.pickerStyle(.menu)
Picker("Durum", selection: $selectedStatus) {
ForEach(ReadingStatus.allCases) { status in
Text(status.title).tag(status)
}
}
.pickerStyle(.segmented)
Toggle("Hemen okumaya başla", isOn: $startReadingNow)
Button("Kitabı Kaydet") {
saveSelectedBook()
}
.buttonStyle(.borderedProminent)
.frame(maxWidth: .infinity, alignment: .trailing)
Button("Geri") { step = .confirm }
.buttonStyle(.bordered)
}
}
private var successStep: some View {
VStack(spacing: Theme.Spacing.large) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 72))
.foregroundStyle(.green)
.scaleEffect(animateSuccess ? 1 : 0.7)
.opacity(animateSuccess ? 1 : 0.4)
.animation(.spring(response: 0.5, dampingFraction: 0.7), value: animateSuccess)
Text("Kitap başarıyla eklendi")
.font(.title3.weight(.semibold))
HStack {
Button("Yeni Kitap Ekle") {
resetFlow()
}
.buttonStyle(.bordered)
Button("Kitaplığa Dön") {
router.selectedTab = .library
resetFlow()
}
.buttonStyle(.borderedProminent)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear { animateSuccess = true }
}
private func saveSelectedBook() {
guard let book = selectedBook else { return }
let progress = startReadingNow ? 0.1 : (selectedStatus == .finished ? 1 : 0)
let status = startReadingNow ? ReadingStatus.reading : selectedStatus
let local = LibraryBook(
title: book.title,
authorsString: book.authors.joined(separator: ", "),
coverUrlString: book.coverImageUrl?.absoluteString,
isbn10: book.isbn10,
isbn13: book.isbn13,
publishedYear: book.publishedYear,
categoriesString: selectedCategory,
summary: book.description,
language: book.language,
sourceLocale: book.sourceLocale,
remotePayloadJson: nil,
statusRaw: status.rawValue,
readingProgress: progress
)
modelContext.insert(local)
try? modelContext.save()
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
withAnimation(.easeInOut(duration: 0.25)) {
step = .success
animateSuccess = false
}
}
private func resetFlow() {
selectedBook = nil
selectedCategory = defaultCategories[0]
selectedStatus = .wantToRead
startReadingNow = false
manualISBN = ""
step = .source
animateSuccess = false
}
}