feat(ios): tab tabanlı navigasyon ve okuma durumu takibi ekle
This commit is contained in:
@@ -3,17 +3,25 @@ import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class AppRouter: ObservableObject {
|
||||
enum Tab: Hashable {
|
||||
case library
|
||||
case discover
|
||||
case stats
|
||||
case profile
|
||||
}
|
||||
|
||||
enum Route: Hashable {
|
||||
case addBooks
|
||||
case category(name: String)
|
||||
case detail(BookRemote)
|
||||
}
|
||||
|
||||
@Published var isAuthenticated = false
|
||||
@Published var selectedTab: Tab = .library
|
||||
@Published var path: [Route] = []
|
||||
|
||||
func resetToHome() {
|
||||
path.removeAll()
|
||||
selectedTab = .library
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,15 +33,13 @@ private struct RootView: View {
|
||||
NavigationStack(path: $router.path) {
|
||||
Group {
|
||||
if router.isAuthenticated {
|
||||
HomeView(viewModel: HomeViewModel())
|
||||
MainTabView()
|
||||
} else {
|
||||
AuthView(viewModel: AuthViewModel(authService: dependencies.authService, keychain: dependencies.keychain))
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: AppRouter.Route.self) { route in
|
||||
switch route {
|
||||
case .addBooks:
|
||||
AddBooksView(viewModel: AddBooksViewModel(booksService: dependencies.booksService))
|
||||
case .category(let name):
|
||||
CategoryListView(viewModel: CategoryViewModel(categoryName: name))
|
||||
case .detail(let book):
|
||||
@@ -70,3 +68,66 @@ private struct RootView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MainTabView: View {
|
||||
@EnvironmentObject private var router: AppRouter
|
||||
@Environment(\.dependencies) private var dependencies
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $router.selectedTab) {
|
||||
HomeView(viewModel: HomeViewModel())
|
||||
.tabItem {
|
||||
Label("Library", systemImage: "books.vertical")
|
||||
}
|
||||
.tag(AppRouter.Tab.library)
|
||||
|
||||
AddBooksView(viewModel: AddBooksViewModel(booksService: dependencies.booksService))
|
||||
.tabItem {
|
||||
Label("Discover", systemImage: "sparkle.magnifyingglass")
|
||||
}
|
||||
.tag(AppRouter.Tab.discover)
|
||||
|
||||
StatsPlaceholderView()
|
||||
.tabItem {
|
||||
Label("Stats", systemImage: "chart.line.uptrend.xyaxis")
|
||||
}
|
||||
.tag(AppRouter.Tab.stats)
|
||||
|
||||
ProfilePlaceholderView()
|
||||
.tabItem {
|
||||
Label("Profile", systemImage: "person.crop.circle")
|
||||
}
|
||||
.tag(AppRouter.Tab.profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct StatsPlaceholderView: View {
|
||||
var body: some View {
|
||||
ContentUnavailableView {
|
||||
Label("Stats", systemImage: "chart.bar.xaxis")
|
||||
} description: {
|
||||
Text("Reading analytics yakında eklenecek.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProfilePlaceholderView: View {
|
||||
@EnvironmentObject private var router: AppRouter
|
||||
@Environment(\.dependencies) private var dependencies
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section("Session") {
|
||||
Button("Logout", role: .destructive) {
|
||||
_ = dependencies.keychain.delete(for: AuthViewModel.tokenKey)
|
||||
router.isAuthenticated = false
|
||||
router.resetToHome()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Profile")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
68
ios/Bookibra/DesignSystem/Components/BookCardView.swift
Normal file
68
ios/Bookibra/DesignSystem/Components/BookCardView.swift
Normal file
@@ -0,0 +1,68 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BookCardView: View {
|
||||
let title: String
|
||||
let author: String
|
||||
let coverURL: URL?
|
||||
let status: ReadingStatus
|
||||
let progress: Double?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
|
||||
AsyncImage(url: coverURL) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image.resizable().scaledToFill()
|
||||
default:
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: Theme.Radius.image)
|
||||
.fill(Color.gray.opacity(0.22))
|
||||
Image(systemName: "book.closed")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 170)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.Radius.image, style: .continuous))
|
||||
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.85)
|
||||
|
||||
Text(author.isEmpty ? "Unknown Author" : author)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: Theme.Spacing.xSmall) {
|
||||
Image(systemName: status.symbol)
|
||||
Text(status.title)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 5)
|
||||
.foregroundStyle(status.color)
|
||||
.background(status.color.opacity(0.14), in: Capsule())
|
||||
|
||||
if let progress, status == .reading {
|
||||
ProgressView(value: progress)
|
||||
.tint(status.color)
|
||||
.animation(.easeOut(duration: 0.35), value: progress)
|
||||
.accessibilityLabel("Reading progress")
|
||||
.accessibilityValue("\(Int(progress * 100)) percent")
|
||||
}
|
||||
}
|
||||
.padding(Theme.Spacing.medium)
|
||||
.background(.background, in: RoundedRectangle(cornerRadius: Theme.Radius.card, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.Radius.card, style: .continuous)
|
||||
.stroke(Color.primary.opacity(0.06), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: Theme.Shadow.card.color, radius: Theme.Shadow.card.radius, y: Theme.Shadow.card.y)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(title), \(author), \(status.title)")
|
||||
}
|
||||
}
|
||||
34
ios/Bookibra/DesignSystem/Components/EmptyStateView.swift
Normal file
34
ios/Bookibra/DesignSystem/Components/EmptyStateView.swift
Normal file
@@ -0,0 +1,34 @@
|
||||
import SwiftUI
|
||||
|
||||
struct EmptyStateView: View {
|
||||
let symbol: String
|
||||
let title: String
|
||||
let message: String
|
||||
let buttonTitle: String?
|
||||
let action: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Theme.Spacing.medium) {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: 44, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(title)
|
||||
.font(.title3.weight(.semibold))
|
||||
|
||||
Text(message)
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
if let buttonTitle, let action {
|
||||
Button(buttonTitle, action: action)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
}
|
||||
@@ -26,4 +26,21 @@ enum Theme {
|
||||
static func headerSerif(size: CGFloat) -> Font {
|
||||
.custom("NewYork-Regular", size: size, relativeTo: .largeTitle)
|
||||
}
|
||||
|
||||
enum Spacing {
|
||||
static let xSmall: CGFloat = 6
|
||||
static let small: CGFloat = 10
|
||||
static let medium: CGFloat = 16
|
||||
static let large: CGFloat = 24
|
||||
}
|
||||
|
||||
enum Radius {
|
||||
static let card: CGFloat = 14
|
||||
static let image: CGFloat = 10
|
||||
static let pill: CGFloat = 999
|
||||
}
|
||||
|
||||
enum Shadow {
|
||||
static let card = (color: Color.black.opacity(0.14), radius: CGFloat(8), y: CGFloat(4))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ struct BookRemote: Codable, Identifiable, Hashable {
|
||||
let categories: [String]
|
||||
let publisher: String?
|
||||
let sourceLocale: String?
|
||||
let readingStatus: ReadingStatus?
|
||||
let readingProgress: Double?
|
||||
|
||||
init(
|
||||
remoteId: String? = nil,
|
||||
@@ -30,7 +32,9 @@ struct BookRemote: Codable, Identifiable, Hashable {
|
||||
pageCount: Int? = nil,
|
||||
categories: [String] = [],
|
||||
publisher: String? = nil,
|
||||
sourceLocale: String? = nil
|
||||
sourceLocale: String? = nil,
|
||||
readingStatus: ReadingStatus? = nil,
|
||||
readingProgress: Double? = nil
|
||||
) {
|
||||
self.remoteId = remoteId
|
||||
self.title = title
|
||||
@@ -45,6 +49,8 @@ struct BookRemote: Codable, Identifiable, Hashable {
|
||||
self.categories = categories
|
||||
self.publisher = publisher
|
||||
self.sourceLocale = sourceLocale
|
||||
self.readingStatus = readingStatus
|
||||
self.readingProgress = readingProgress
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ final class LibraryBook {
|
||||
var language: String?
|
||||
var sourceLocale: String?
|
||||
var remotePayloadJson: String?
|
||||
// Optional tutulur ki eski store'lar migration sırasında kırılmasın.
|
||||
var statusRaw: String?
|
||||
var readingProgress: Double?
|
||||
|
||||
init(
|
||||
localId: UUID = UUID(),
|
||||
@@ -30,7 +33,9 @@ final class LibraryBook {
|
||||
dateAdded: Date = .now,
|
||||
language: String? = nil,
|
||||
sourceLocale: String? = nil,
|
||||
remotePayloadJson: String? = nil
|
||||
remotePayloadJson: String? = nil,
|
||||
statusRaw: String = ReadingStatus.wantToRead.rawValue,
|
||||
readingProgress: Double = 0
|
||||
) {
|
||||
self.localId = localId
|
||||
self.title = title
|
||||
@@ -45,6 +50,8 @@ final class LibraryBook {
|
||||
self.language = language
|
||||
self.sourceLocale = sourceLocale
|
||||
self.remotePayloadJson = remotePayloadJson
|
||||
self.statusRaw = statusRaw
|
||||
self.readingProgress = min(max(readingProgress, 0), 1)
|
||||
}
|
||||
|
||||
var authors: [String] {
|
||||
@@ -60,4 +67,14 @@ final class LibraryBook {
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
var status: ReadingStatus {
|
||||
get { ReadingStatus(rawValue: statusRaw ?? "") ?? .wantToRead }
|
||||
set { statusRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
var readingProgressValue: Double {
|
||||
get { min(max(readingProgress ?? 0, 0), 1) }
|
||||
set { readingProgress = min(max(newValue, 0), 1) }
|
||||
}
|
||||
}
|
||||
|
||||
34
ios/Bookibra/Models/ReadingStatus.swift
Normal file
34
ios/Bookibra/Models/ReadingStatus.swift
Normal file
@@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
enum ReadingStatus: String, CaseIterable, Codable, Identifiable {
|
||||
case wantToRead
|
||||
case reading
|
||||
case finished
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .wantToRead: return "Want to Read"
|
||||
case .reading: return "Reading"
|
||||
case .finished: return "Finished"
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .wantToRead: return "bookmark"
|
||||
case .reading: return "book"
|
||||
case .finished: return "checkmark.circle"
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .wantToRead: return .orange
|
||||
case .reading: return .blue
|
||||
case .finished: return .green
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,9 @@ final class BookDetailViewModel: ObservableObject {
|
||||
summary: book.description,
|
||||
language: book.language,
|
||||
sourceLocale: book.sourceLocale,
|
||||
remotePayloadJson: nil
|
||||
remotePayloadJson: nil,
|
||||
statusRaw: (book.readingStatus ?? .wantToRead).rawValue,
|
||||
readingProgress: book.readingProgress ?? 0
|
||||
)
|
||||
context.insert(local)
|
||||
isInLibrary = true
|
||||
|
||||
@@ -55,7 +55,9 @@ final class HomeViewModel: ObservableObject {
|
||||
language: local.language,
|
||||
description: local.summary,
|
||||
categories: local.categories,
|
||||
sourceLocale: local.sourceLocale
|
||||
sourceLocale: local.sourceLocale,
|
||||
readingStatus: local.status,
|
||||
readingProgress: local.readingProgressValue
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user