feat: ios mobil arayüz tasarımı
This commit is contained in:
115
ios/Bookibra/Views/AddBooks/AddBooksView.swift
Normal file
115
ios/Bookibra/Views/AddBooks/AddBooksView.swift
Normal file
@@ -0,0 +1,115 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AddBooksView: View {
|
||||
@EnvironmentObject private var router: AppRouter
|
||||
@ObservedObject var viewModel: AddBooksViewModel
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.navigationTitle(String(localized: "add.title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.overlay {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.controlSize(.large)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var titleSearch: some View {
|
||||
TextField(String(localized: "add.searchPlaceholder"), text: $viewModel.titleQuery)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onChange(of: viewModel.titleQuery) { _, _ in
|
||||
viewModel.titleChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private var scanSearch: some View {
|
||||
BarcodeScannerView { isbn in
|
||||
Task { await viewModel.searchByISBN(isbn) }
|
||||
}
|
||||
.frame(height: 260)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
|
||||
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() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
154
ios/Bookibra/Views/AddBooks/BarcodeScannerView.swift
Normal file
154
ios/Bookibra/Views/AddBooks/BarcodeScannerView.swift
Normal file
@@ -0,0 +1,154 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
import VisionKit
|
||||
|
||||
struct BarcodeScannerView: View {
|
||||
let onScanned: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if DataScannerViewController.isSupported, DataScannerViewController.isAvailable {
|
||||
DataScannerRepresentable(onScanned: onScanned)
|
||||
} else {
|
||||
AVScannerRepresentable(onScanned: onScanned)
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(Color.white.opacity(0.75), lineWidth: 2)
|
||||
}
|
||||
.accessibilityLabel("ISBN barkod tarayıcı")
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
private struct DataScannerRepresentable: UIViewControllerRepresentable {
|
||||
let onScanned: (String) -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(onScanned: onScanned) }
|
||||
|
||||
func makeUIViewController(context: Context) -> DataScannerViewController {
|
||||
let vc = DataScannerViewController(
|
||||
recognizedDataTypes: [.barcode(symbologies: [.ean8, .ean13, .upce, .code128])],
|
||||
qualityLevel: .balanced,
|
||||
recognizesMultipleItems: false,
|
||||
isHighFrameRateTrackingEnabled: true,
|
||||
isPinchToZoomEnabled: true,
|
||||
isGuidanceEnabled: true,
|
||||
isHighlightingEnabled: true
|
||||
)
|
||||
vc.delegate = context.coordinator
|
||||
try? vc.startScanning()
|
||||
return vc
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: DataScannerViewController, context: Context) {}
|
||||
|
||||
final class Coordinator: NSObject, DataScannerViewControllerDelegate {
|
||||
let onScanned: (String) -> Void
|
||||
private var lastISBN: String?
|
||||
private var lastEmitAt: Date = .distantPast
|
||||
|
||||
init(onScanned: @escaping (String) -> Void) {
|
||||
self.onScanned = onScanned
|
||||
}
|
||||
|
||||
func dataScanner(_ dataScanner: DataScannerViewController, didTapOn item: RecognizedItem) {
|
||||
guard case .barcode(let code) = item,
|
||||
let payload = code.payloadStringValue,
|
||||
let normalized = ISBNNormalizer.normalize(payload) else { return }
|
||||
emitIfNeeded(normalized)
|
||||
}
|
||||
|
||||
func dataScanner(_ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) {
|
||||
guard let first = addedItems.first,
|
||||
case .barcode(let code) = first,
|
||||
let payload = code.payloadStringValue,
|
||||
let normalized = ISBNNormalizer.normalize(payload) else { return }
|
||||
emitIfNeeded(normalized)
|
||||
}
|
||||
|
||||
private func emitIfNeeded(_ isbn: String) {
|
||||
let now = Date()
|
||||
if lastISBN == isbn, now.timeIntervalSince(lastEmitAt) < 1.5 {
|
||||
return
|
||||
}
|
||||
lastISBN = isbn
|
||||
lastEmitAt = now
|
||||
onScanned(isbn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AVScannerRepresentable: UIViewRepresentable {
|
||||
let onScanned: (String) -> Void
|
||||
|
||||
func makeUIView(context: Context) -> ScannerPreviewView {
|
||||
let view = ScannerPreviewView()
|
||||
context.coordinator.configure(preview: view)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: ScannerPreviewView, context: Context) {}
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(onScanned: onScanned) }
|
||||
|
||||
final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
|
||||
private let session = AVCaptureSession()
|
||||
private let sessionQueue = DispatchQueue(label: "bookibra.av.capture.session")
|
||||
private let onScanned: (String) -> Void
|
||||
private var lastISBN: String?
|
||||
private var lastEmitAt: Date = .distantPast
|
||||
|
||||
init(onScanned: @escaping (String) -> Void) {
|
||||
self.onScanned = onScanned
|
||||
}
|
||||
|
||||
func configure(preview: ScannerPreviewView) {
|
||||
guard let device = AVCaptureDevice.default(for: .video),
|
||||
let input = try? AVCaptureDeviceInput(device: device) else { return }
|
||||
|
||||
if session.canAddInput(input) { session.addInput(input) }
|
||||
|
||||
let output = AVCaptureMetadataOutput()
|
||||
if session.canAddOutput(output) {
|
||||
session.addOutput(output)
|
||||
output.setMetadataObjectsDelegate(self, queue: .main)
|
||||
output.metadataObjectTypes = [.ean8, .ean13, .upce, .code128]
|
||||
}
|
||||
|
||||
preview.previewLayer.session = session
|
||||
preview.previewLayer.videoGravity = .resizeAspectFill
|
||||
sessionQueue.async { [session] in
|
||||
session.startRunning()
|
||||
}
|
||||
}
|
||||
|
||||
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
|
||||
guard let code = metadataObjects.compactMap({ $0 as? AVMetadataMachineReadableCodeObject }).first,
|
||||
let value = code.stringValue,
|
||||
let normalized = ISBNNormalizer.normalize(value) else { return }
|
||||
let now = Date()
|
||||
if lastISBN == normalized, now.timeIntervalSince(lastEmitAt) < 1.5 {
|
||||
return
|
||||
}
|
||||
lastISBN = normalized
|
||||
lastEmitAt = now
|
||||
onScanned(normalized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class ScannerPreviewView: UIView {
|
||||
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
|
||||
var previewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
|
||||
}
|
||||
|
||||
enum ISBNNormalizer {
|
||||
static func normalize(_ value: String) -> String? {
|
||||
let cleaned = value.uppercased().filter { $0.isNumber || $0 == "X" }
|
||||
if cleaned.count == 13 { return cleaned }
|
||||
if cleaned.count == 10 { return cleaned }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
71
ios/Bookibra/Views/Auth/AuthView.swift
Normal file
71
ios/Bookibra/Views/Auth/AuthView.swift
Normal file
@@ -0,0 +1,71 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AuthView: View {
|
||||
@EnvironmentObject private var router: AppRouter
|
||||
@ObservedObject var viewModel: AuthViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Spacer()
|
||||
|
||||
Text("Bookibra")
|
||||
.font(Theme.headerSerif(size: 48))
|
||||
.foregroundStyle(.black)
|
||||
|
||||
Picker("Mode", selection: $viewModel.mode) {
|
||||
Text(String(localized: "auth.login")).tag(AuthViewModel.Mode.login)
|
||||
Text(String(localized: "auth.register")).tag(AuthViewModel.Mode.register)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
VStack(spacing: 14) {
|
||||
TextField(String(localized: "auth.email"), text: $viewModel.email)
|
||||
.textInputAutocapitalization(.never)
|
||||
.keyboardType(.emailAddress)
|
||||
.padding()
|
||||
.background(Color.white, in: RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
SecureField(String(localized: "auth.password"), text: $viewModel.password)
|
||||
.padding()
|
||||
.background(Color.white, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
if let error = viewModel.errorMessage {
|
||||
Text(error)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
.padding(.horizontal, 24)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task {
|
||||
await viewModel.submit {
|
||||
router.isAuthenticated = true
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
} else {
|
||||
Text(String(localized: "auth.continue"))
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
}
|
||||
.background(Color.black)
|
||||
.clipShape(Capsule())
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.background(Theme.background.ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
71
ios/Bookibra/Views/Category/CategoryListView.swift
Normal file
71
ios/Bookibra/Views/Category/CategoryListView.swift
Normal file
@@ -0,0 +1,71 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct CategoryListView: View {
|
||||
@EnvironmentObject private var router: AppRouter
|
||||
@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)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
.navigationTitle(viewModel.categoryName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.background(Theme.background.ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
58
ios/Bookibra/Views/Detail/BookDetailView.swift
Normal file
58
ios/Bookibra/Views/Detail/BookDetailView.swift
Normal file
@@ -0,0 +1,58 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct BookDetailView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@ObservedObject var viewModel: BookDetailViewModel
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
AsyncImage(url: viewModel.book.coverImageUrl) { phase in
|
||||
if let image = phase.image {
|
||||
image.resizable().scaledToFill()
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 16).fill(.gray.opacity(0.2))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 320)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
|
||||
Text(viewModel.book.title)
|
||||
.font(.title.bold())
|
||||
Text(viewModel.book.authors.joined(separator: ", "))
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if let year = viewModel.book.publishedYear {
|
||||
Text("\(year)")
|
||||
.font(.subheadline)
|
||||
}
|
||||
|
||||
if !viewModel.book.categories.isEmpty {
|
||||
Text(viewModel.book.categories.joined(separator: " • "))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let description = viewModel.book.description {
|
||||
Text(description)
|
||||
.font(.body)
|
||||
}
|
||||
|
||||
PrimaryPillButton(
|
||||
title: viewModel.isInLibrary ? String(localized: "detail.remove") : String(localized: "detail.add")
|
||||
) {
|
||||
viewModel.toggleLibrary(context: modelContext)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
.background(Theme.background.ignoresSafeArea())
|
||||
.task {
|
||||
viewModel.refresh(context: modelContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
73
ios/Bookibra/Views/Home/HomeView.swift
Normal file
73
ios/Bookibra/Views/Home/HomeView.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct HomeView: View {
|
||||
@EnvironmentObject private var router: AppRouter
|
||||
@Environment(\.dependencies) private var dependencies
|
||||
@Query(sort: \LibraryBook.dateAdded, order: .reverse) private var libraryBooks: [LibraryBook]
|
||||
@StateObject private var viewModel: HomeViewModel
|
||||
|
||||
init(viewModel: HomeViewModel) {
|
||||
_viewModel = StateObject(wrappedValue: viewModel)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 28) {
|
||||
header
|
||||
|
||||
ForEach(viewModel.categories) { category in
|
||||
ShelfSectionView(
|
||||
title: category.name,
|
||||
books: category.books,
|
||||
gradient: viewModel.gradient(for: category.name),
|
||||
imageCache: dependencies.imageCache,
|
||||
onTapCategory: { router.path.append(.category(name: category.name)) },
|
||||
onTapBook: { router.path.append(.detail($0)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 120)
|
||||
}
|
||||
.background(Theme.background.ignoresSafeArea())
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
ZStack {
|
||||
BlurFogOverlay()
|
||||
.frame(height: 96)
|
||||
PrimaryPillButton(title: String(localized: "home.addBooks")) {
|
||||
router.path.append(.addBooks)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
.frame(height: 100)
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.refresh(from: libraryBooks)
|
||||
}
|
||||
.onChange(of: libraryBooks.map(\.localId)) { _, _ in
|
||||
viewModel.refresh(from: libraryBooks)
|
||||
}
|
||||
.task(id: libraryBooks.count) {
|
||||
viewModel.refresh(from: libraryBooks)
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(spacing: 4) {
|
||||
Text(String(localized: "home.myFavourite"))
|
||||
.font(.footnote.weight(.light))
|
||||
.kerning(1.2)
|
||||
.foregroundStyle(Color.black.opacity(0.7))
|
||||
|
||||
Text(String(localized: "home.books"))
|
||||
.font(Theme.headerSerif(size: 56).weight(.bold))
|
||||
.foregroundStyle(.black)
|
||||
.kerning(1)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 12)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user