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,88 @@
import Foundation
@MainActor
final class AddBooksViewModel: ObservableObject {
enum Mode: String, CaseIterable {
case title
case scan
case filter
var title: String {
switch self {
case .title: return String(localized: "add.searchByTitle")
case .scan: return String(localized: "add.scanBarcode")
case .filter: return String(localized: "add.filter")
}
}
}
@Published var mode: Mode = .title
@Published var titleQuery = ""
@Published var filterTitle = ""
@Published var filterYear = ""
@Published var results: [BookRemote] = []
@Published var isLoading = false
@Published var errorMessage: String?
private var debounceTask: Task<Void, Never>?
private let booksService: BooksServiceProtocol
init(booksService: BooksServiceProtocol) {
self.booksService = booksService
}
func titleChanged() {
debounceTask?.cancel()
let query = titleQuery.trimmingCharacters(in: .whitespacesAndNewlines)
guard query.count >= 2 else {
results = []
return
}
debounceTask = Task {
try? await Task.sleep(for: .milliseconds(400))
await searchByTitle(query)
}
}
func searchByTitle(_ query: String? = nil) async {
let value = query ?? titleQuery
guard !value.isEmpty else { return }
isLoading = true
defer { isLoading = false }
do {
results = try await booksService.searchByTitle(value, locales: "tr,en")
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
}
func searchByISBN(_ isbn: String) async {
guard !isbn.isEmpty else { return }
isLoading = true
defer { isLoading = false }
do {
results = try await booksService.searchByISBN(isbn, locales: "tr,en")
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
}
func applyFilter() async {
guard !filterTitle.isEmpty, !filterYear.isEmpty else { return }
isLoading = true
defer { isLoading = false }
do {
results = try await booksService.filter(title: filterTitle, year: filterYear, locales: "tr,en")
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
}
}

View File

@@ -0,0 +1,53 @@
import Foundation
@MainActor
final class AuthViewModel: ObservableObject {
enum Mode: String, CaseIterable {
case login
case register
}
static let tokenKey = "bookibra.jwt"
static let emailKey = "bookibra.email"
@Published var mode: Mode = .login
@Published var email = ""
@Published var password = ""
@Published var isLoading = false
@Published var errorMessage: String?
private let authService: AuthServiceProtocol
private let keychain: KeychainStoreProtocol
init(authService: AuthServiceProtocol, keychain: KeychainStoreProtocol) {
self.authService = authService
self.keychain = keychain
}
func submit(onSuccess: @escaping () -> Void) async {
guard !email.isEmpty, !password.isEmpty else {
errorMessage = "E-posta ve şifre gerekli"
return
}
isLoading = true
defer { isLoading = false }
do {
let response: AuthResponse
switch mode {
case .login:
response = try await authService.login(email: email, password: password)
case .register:
response = try await authService.register(email: email, password: password)
}
_ = try await authService.profile(token: response.token)
_ = keychain.save(response.token, for: Self.tokenKey)
_ = keychain.save(response.user.email, for: Self.emailKey)
onSuccess()
} catch {
errorMessage = error.localizedDescription
}
}
}

View File

@@ -0,0 +1,55 @@
import Foundation
import SwiftData
import UIKit
@MainActor
final class BookDetailViewModel: ObservableObject {
@Published var book: BookRemote
@Published var isInLibrary = false
init(book: BookRemote) {
self.book = book
}
func refresh(context: ModelContext) {
let all = (try? context.fetch(FetchDescriptor<LibraryBook>())) ?? []
isInLibrary = all.contains(where: { local in
match(local: local, remote: book)
})
}
func toggleLibrary(context: ModelContext) {
let all = (try? context.fetch(FetchDescriptor<LibraryBook>())) ?? []
if let existing = all.first(where: { match(local: $0, remote: book) }) {
context.delete(existing)
isInLibrary = false
UINotificationFeedbackGenerator().notificationOccurred(.warning)
} else {
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: book.categories.joined(separator: ", "),
summary: book.description,
language: book.language,
sourceLocale: book.sourceLocale,
remotePayloadJson: nil
)
context.insert(local)
isInLibrary = true
UINotificationFeedbackGenerator().notificationOccurred(.success)
}
try? context.save()
}
private func match(local: LibraryBook, remote: BookRemote) -> Bool {
if let lhs = local.isbn13, let rhs = remote.isbn13 { return lhs == rhs }
if let lhs = local.isbn10, let rhs = remote.isbn10 { return lhs == rhs }
return local.title == remote.title
}
}

View File

@@ -0,0 +1,39 @@
import Foundation
@MainActor
final class CategoryViewModel: ObservableObject {
enum SortOption: String, CaseIterable {
case recentlyAdded = "Recently Added"
case titleAZ = "Title A-Z"
case author = "Author"
}
let categoryName: String
@Published var searchText = ""
@Published var sortOption: SortOption = .recentlyAdded
init(categoryName: String) {
self.categoryName = categoryName
}
func books(from allBooks: [LibraryBook]) -> [LibraryBook] {
var filtered = allBooks.filter { $0.categories.contains(categoryName) || (categoryName == "Design" && $0.categories.isEmpty) }
if !searchText.isEmpty {
filtered = filtered.filter {
$0.title.localizedCaseInsensitiveContains(searchText)
|| $0.authorsString.localizedCaseInsensitiveContains(searchText)
}
}
switch sortOption {
case .recentlyAdded:
filtered.sort { $0.dateAdded > $1.dateAdded }
case .titleAZ:
filtered.sort { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
case .author:
filtered.sort { $0.authorsString.localizedCaseInsensitiveCompare($1.authorsString) == .orderedAscending }
}
return filtered
}
}

View File

@@ -0,0 +1,61 @@
import Foundation
import SwiftUI
struct ShelfCategory: Identifiable {
let id: String
let name: String
let books: [BookRemote]
}
@MainActor
final class HomeViewModel: ObservableObject {
@Published var categories: [ShelfCategory] = []
func refresh(from localBooks: [LibraryBook]) {
var map: [String: [BookRemote]] = [:]
for local in localBooks {
let targets = local.categories.isEmpty ? ["Design"] : local.categories
let remote = Self.makeRemote(from: local)
for name in targets {
map[name, default: []].append(remote)
}
}
let preferred = ["Design", "Psychology", "Novels"]
var built = preferred.map { name in
ShelfCategory(id: name, name: name, books: map[name] ?? [])
}
let extras = map.keys
.filter { !preferred.contains($0) }
.sorted()
.map { ShelfCategory(id: $0, name: $0, books: map[$0] ?? []) }
built.append(contentsOf: extras)
categories = built
}
func gradient(for name: String) -> LinearGradient {
switch name.lowercased() {
case "design": return Theme.designShelf
case "psychology": return Theme.psychologyShelf
case "novels": return Theme.novelsShelf
default: return Theme.novelsShelf
}
}
private static func makeRemote(from local: LibraryBook) -> BookRemote {
BookRemote(
title: local.title,
authors: local.authors,
publishedYear: local.publishedYear,
isbn10: local.isbn10,
isbn13: local.isbn13,
coverImageUrl: local.coverUrlString.flatMap(URL.init(string:)),
language: local.language,
description: local.summary,
categories: local.categories,
sourceLocale: local.sourceLocale
)
}
}