feat: ios mobil arayüz tasarımı
This commit is contained in:
88
ios/Bookibra/ViewModels/AddBooksViewModel.swift
Normal file
88
ios/Bookibra/ViewModels/AddBooksViewModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
53
ios/Bookibra/ViewModels/AuthViewModel.swift
Normal file
53
ios/Bookibra/ViewModels/AuthViewModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
55
ios/Bookibra/ViewModels/BookDetailViewModel.swift
Normal file
55
ios/Bookibra/ViewModels/BookDetailViewModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
39
ios/Bookibra/ViewModels/CategoryViewModel.swift
Normal file
39
ios/Bookibra/ViewModels/CategoryViewModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
61
ios/Bookibra/ViewModels/HomeViewModel.swift
Normal file
61
ios/Bookibra/ViewModels/HomeViewModel.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user