feat(ios): tab tabanlı navigasyon ve okuma durumu takibi ekle
This commit is contained in:
@@ -1,75 +1,46 @@
|
||||
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 {
|
||||
VStack(spacing: 12) {
|
||||
Picker("Mode", selection: $viewModel.mode) {
|
||||
ForEach(AddBooksViewModel.Mode.allCases, id: \.self) { mode in
|
||||
Text(mode.title).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
ScrollView {
|
||||
VStack(spacing: Theme.Spacing.medium) {
|
||||
progressHeader
|
||||
|
||||
Group {
|
||||
switch viewModel.mode {
|
||||
case .title:
|
||||
titleSearch
|
||||
case .scan:
|
||||
scanSearch
|
||||
case .filter:
|
||||
filterSearch
|
||||
}
|
||||
}
|
||||
|
||||
if let error = viewModel.errorMessage {
|
||||
NetworkErrorView(message: error) {
|
||||
Task { await viewModel.searchByTitle() }
|
||||
}
|
||||
}
|
||||
|
||||
List(viewModel.results, id: \.id) { book in
|
||||
Button {
|
||||
router.path.append(.detail(book))
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
AsyncImage(url: book.coverImageUrl) { phase in
|
||||
if let image = phase.image {
|
||||
image.resizable().scaledToFill()
|
||||
} else {
|
||||
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)
|
||||
if let year = book.publishedYear {
|
||||
Text("\(year)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.overlay {
|
||||
if viewModel.results.isEmpty, !viewModel.isLoading {
|
||||
Text(String(localized: "common.empty"))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
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"))
|
||||
@@ -80,36 +51,248 @@ struct AddBooksView: View {
|
||||
.controlSize(.large)
|
||||
}
|
||||
}
|
||||
.sensoryFeedback(.success, trigger: animateSuccess)
|
||||
}
|
||||
|
||||
private var titleSearch: some View {
|
||||
TextField(String(localized: "add.searchPlaceholder"), text: $viewModel.titleQuery)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onChange(of: viewModel.titleQuery) { _, _ in
|
||||
viewModel.titleChanged()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private var scanSearch: some View {
|
||||
BarcodeScannerView { isbn in
|
||||
Task { await viewModel.searchByISBN(isbn) }
|
||||
}
|
||||
.frame(height: 260)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
private var filterSearch: some View {
|
||||
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() }
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user