feat(ios): tab tabanlı navigasyon ve okuma durumu takibi ekle

This commit is contained in:
2026-02-11 18:26:17 +03:00
parent 52212f015b
commit 362b9b7d1b
16 changed files with 976 additions and 442 deletions

View File

@@ -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
}
}

View File

@@ -4,20 +4,62 @@ import VisionKit
struct BarcodeScannerView: View {
let onScanned: (String) -> Void
@State private var cameraAuthorized = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
var body: some View {
Group {
if DataScannerViewController.isSupported, DataScannerViewController.isAvailable {
DataScannerRepresentable(onScanned: onScanned)
if cameraAuthorized {
if DataScannerViewController.isSupported, DataScannerViewController.isAvailable {
DataScannerRepresentable(onScanned: onScanned)
} else {
AVScannerRepresentable(onScanned: onScanned)
}
} else {
AVScannerRepresentable(onScanned: onScanned)
scannerUnavailableView
}
}
.background(Color.black.opacity(0.85))
.overlay {
RoundedRectangle(cornerRadius: 16)
.stroke(Color.white.opacity(0.75), lineWidth: 2)
}
.accessibilityLabel("ISBN barkod tarayıcı")
.onAppear {
requestCameraPermissionIfNeeded()
}
}
private var scannerUnavailableView: some View {
VStack(spacing: 10) {
Image(systemName: "camera.viewfinder")
.font(.system(size: 30))
.foregroundStyle(.white.opacity(0.9))
Text("Kamera erişimi gerekli")
.font(.headline)
.foregroundStyle(.white)
Text("Barkod taramak için Ayarlar > Gizlilik > Kamera üzerinden izin verin.")
.font(.footnote)
.foregroundStyle(.white.opacity(0.85))
.multilineTextAlignment(.center)
.padding(.horizontal, 12)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func requestCameraPermissionIfNeeded() {
let status = AVCaptureDevice.authorizationStatus(for: .video)
switch status {
case .authorized:
cameraAuthorized = true
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { granted in
DispatchQueue.main.async {
cameraAuthorized = granted
}
}
default:
cameraAuthorized = false
}
}
}

View File

@@ -1,71 +1,111 @@
import SwiftUI
import SwiftData
import UIKit
struct CategoryListView: View {
@EnvironmentObject private var router: AppRouter
@Environment(\.modelContext) private var modelContext
@Query(sort: \LibraryBook.dateAdded, order: .reverse) private var allBooks: [LibraryBook]
@ObservedObject var viewModel: CategoryViewModel
var body: some View {
let books = viewModel.books(from: allBooks)
VStack {
HStack {
TextField("Search", text: $viewModel.searchText)
.textFieldStyle(.roundedBorder)
Menu {
ForEach(CategoryViewModel.SortOption.allCases, id: \.self) { option in
Button(option.rawValue) { viewModel.sortOption = option }
}
} label: {
Image(systemName: "arrow.up.arrow.down.circle")
.font(.title3)
}
}
.padding(.horizontal)
List {
controlRow
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 104), spacing: 12)], spacing: 16) {
ForEach(books, id: \.localId) { book in
let remote = BookRemote(
title: book.title,
authors: book.authors,
publishedYear: book.publishedYear,
isbn10: book.isbn10,
isbn13: book.isbn13,
coverImageUrl: book.coverUrlString.flatMap(URL.init(string:)),
language: book.language,
description: book.summary,
categories: book.categories
if books.isEmpty {
EmptyStateView(
symbol: "books.vertical",
title: "Bu kategoride kitap yok",
message: "Kitap ekleyerek bu rafı doldurabilirsin.",
buttonTitle: "Kitap Keşfet",
action: { router.selectedTab = .discover }
)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
} else {
ForEach(books, id: \.localId) { book in
let remote = BookRemote(
title: book.title,
authors: book.authors,
publishedYear: book.publishedYear,
isbn10: book.isbn10,
isbn13: book.isbn13,
coverImageUrl: book.coverUrlString.flatMap(URL.init(string:)),
language: book.language,
description: book.summary,
categories: book.categories,
readingStatus: book.status,
readingProgress: book.readingProgressValue
)
Button {
router.path.append(.detail(remote))
} label: {
BookCardView(
title: remote.title,
author: remote.authors.first ?? "",
coverURL: remote.coverImageUrl,
status: remote.readingStatus ?? .wantToRead,
progress: remote.readingProgress
)
Button {
router.path.append(.detail(remote))
} label: {
VStack(alignment: .leading, spacing: 6) {
AsyncImage(url: remote.coverImageUrl) { phase in
if let image = phase.image {
image.resizable().scaledToFill()
} else {
RoundedRectangle(cornerRadius: 10).fill(Color.gray.opacity(0.2))
}
}
.frame(height: 154)
.clipShape(RoundedRectangle(cornerRadius: 10))
Text(book.title)
.font(.caption)
.lineLimit(2)
.foregroundStyle(.primary)
}
}
}
.buttonStyle(.plain)
.listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
statusButton(.finished, for: book)
statusButton(.reading, for: book)
statusButton(.wantToRead, for: book)
}
}
.padding(.horizontal)
}
}
.scrollContentBackground(.hidden)
.background(Theme.background.ignoresSafeArea())
.navigationTitle(viewModel.categoryName)
.navigationBarTitleDisplayMode(.inline)
.background(Theme.background.ignoresSafeArea())
}
private var controlRow: some View {
HStack(spacing: 10) {
TextField("Kategori içinde ara", text: $viewModel.searchText)
.textFieldStyle(.roundedBorder)
Menu {
ForEach(CategoryViewModel.SortOption.allCases, id: \.self) { option in
Button(option.rawValue) { viewModel.sortOption = option }
}
} label: {
Label("Sırala", systemImage: "arrow.up.arrow.down.circle")
}
}
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
private func statusButton(_ status: ReadingStatus, for book: LibraryBook) -> some View {
Button {
withAnimation(.spring(duration: 0.3)) {
book.status = status
if status == .finished { book.readingProgressValue = 1 }
if status == .wantToRead { book.readingProgressValue = 0 }
try? modelContext.save()
}
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
} label: {
Label(status.title, systemImage: status.symbol)
}
.tint(color(for: status))
}
private func color(for status: ReadingStatus) -> Color {
switch status {
case .wantToRead: return .orange
case .reading: return .blue
case .finished: return .green
}
}
}

View File

@@ -36,7 +36,9 @@ struct HomeView: View {
BlurFogOverlay()
.frame(height: 96)
PrimaryPillButton(title: String(localized: "home.addBooks")) {
router.path.append(.addBooks)
withAnimation(.easeInOut(duration: 0.2)) {
router.selectedTab = .discover
}
}
.padding(.horizontal, 24)
.padding(.bottom, 12)