feat: ios mobil arayüz tasarımı

This commit is contained in:
2026-02-11 18:06:35 +03:00
parent 69884db0ab
commit 261b2f58cc
42 changed files with 2501 additions and 0 deletions

View 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)
}
}
}

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

View 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())
}
}

View 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())
}
}

View 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)
}
}
}

View 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)
}
}