feat: ios mobil arayüz tasarımı
This commit is contained in:
129
ios/Bookibra/Services/APIClient.swift
Normal file
129
ios/Bookibra/Services/APIClient.swift
Normal file
@@ -0,0 +1,129 @@
|
||||
import Foundation
|
||||
|
||||
protocol APIClientProtocol {
|
||||
func get<T: Decodable>(path: String, queryItems: [URLQueryItem], token: String?) async throws -> T
|
||||
func post<T: Decodable, Body: Encodable>(path: String, body: Body, token: String?) async throws -> T
|
||||
}
|
||||
|
||||
enum APIError: LocalizedError {
|
||||
case invalidURL
|
||||
case invalidResponse
|
||||
case unauthorized
|
||||
case server(status: Int, message: String)
|
||||
case decoding(Error)
|
||||
case transport(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL: return "Geçersiz URL"
|
||||
case .invalidResponse: return "Geçersiz sunucu yanıtı"
|
||||
case .unauthorized: return "Oturum süresi doldu"
|
||||
case .server(_, let message): return message
|
||||
case .decoding: return "Sunucu verisi çözümlenemedi"
|
||||
case .transport(let error): return error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class APIClient: APIClientProtocol {
|
||||
private let baseURL: URL
|
||||
private let session: URLSession
|
||||
private let decoder: JSONDecoder
|
||||
private let encoder: JSONEncoder
|
||||
|
||||
init(baseURL: URL, session: URLSession = .shared) {
|
||||
self.baseURL = baseURL
|
||||
self.session = session
|
||||
self.decoder = JSONDecoder()
|
||||
self.encoder = JSONEncoder()
|
||||
}
|
||||
|
||||
func get<T: Decodable>(path: String, queryItems: [URLQueryItem] = [], token: String? = nil) async throws -> T {
|
||||
var request = try buildRequest(path: path, method: "GET", queryItems: queryItems, token: token)
|
||||
request.httpBody = nil
|
||||
return try await perform(request)
|
||||
}
|
||||
|
||||
func post<T: Decodable, Body: Encodable>(path: String, body: Body, token: String? = nil) async throws -> T {
|
||||
var request = try buildRequest(path: path, method: "POST", queryItems: [], token: token)
|
||||
request.httpBody = try encoder.encode(body)
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
return try await perform(request)
|
||||
}
|
||||
|
||||
private func buildRequest(path: String, method: String, queryItems: [URLQueryItem], token: String?) throws -> URLRequest {
|
||||
guard var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false) else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
if !queryItems.isEmpty {
|
||||
components.queryItems = queryItems
|
||||
}
|
||||
guard let url = components.url else { throw APIError.invalidURL }
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method
|
||||
request.timeoutInterval = 20
|
||||
if let token {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
private func perform<T: Decodable>(_ request: URLRequest) async throws -> T {
|
||||
#if DEBUG
|
||||
print("[API] \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
|
||||
#endif
|
||||
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
|
||||
|
||||
if http.statusCode == 401 { throw APIError.unauthorized }
|
||||
|
||||
guard (200...299).contains(http.statusCode) else {
|
||||
let message = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["message"] as? String ?? "Sunucu hatası"
|
||||
throw APIError.server(status: http.statusCode, message: message)
|
||||
}
|
||||
|
||||
do {
|
||||
return try decoder.decode(T.self, from: data)
|
||||
} catch {
|
||||
throw APIError.decoding(error)
|
||||
}
|
||||
} catch let error as APIError {
|
||||
throw error
|
||||
} catch {
|
||||
throw APIError.transport(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Bundle {
|
||||
var apiBaseURL: URL {
|
||||
let raw = (object(forInfoDictionaryKey: "API_BASE_URL") as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
|
||||
// 1) Normal case: full URL in Info.plist / xcconfig.
|
||||
if let url = URL(string: raw), let host = url.host, !host.isEmpty {
|
||||
return url
|
||||
}
|
||||
|
||||
// 2) If scheme is missing (e.g. "192.168.1.124:8080"), prepend http://.
|
||||
if !raw.isEmpty, !raw.contains("://"),
|
||||
let url = URL(string: "http://\(raw)"),
|
||||
let host = url.host, !host.isEmpty {
|
||||
return url
|
||||
}
|
||||
|
||||
// 3) Device-local fallback for current dev network.
|
||||
if let fallback = URL(string: "http://192.168.1.124:8080") {
|
||||
#if DEBUG
|
||||
print("[API] Invalid API_BASE_URL='\(raw)'. Falling back to \(fallback.absoluteString)")
|
||||
#endif
|
||||
return fallback
|
||||
}
|
||||
|
||||
// 4) Last resort.
|
||||
return URL(string: "http://localhost:8080")!
|
||||
}
|
||||
}
|
||||
42
ios/Bookibra/Services/AuthService.swift
Normal file
42
ios/Bookibra/Services/AuthService.swift
Normal file
@@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
|
||||
struct AuthResponse: Codable {
|
||||
struct User: Codable {
|
||||
let id: String
|
||||
let email: String
|
||||
}
|
||||
|
||||
let token: String
|
||||
let user: User
|
||||
}
|
||||
|
||||
protocol AuthServiceProtocol {
|
||||
func login(email: String, password: String) async throws -> AuthResponse
|
||||
func register(email: String, password: String) async throws -> AuthResponse
|
||||
func profile(token: String) async throws -> UserProfile
|
||||
}
|
||||
|
||||
final class AuthService: AuthServiceProtocol {
|
||||
private let client: APIClientProtocol
|
||||
|
||||
init(client: APIClientProtocol) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
func login(email: String, password: String) async throws -> AuthResponse {
|
||||
try await client.post(path: "api/auth/login", body: Credentials(email: email, password: password), token: nil)
|
||||
}
|
||||
|
||||
func register(email: String, password: String) async throws -> AuthResponse {
|
||||
try await client.post(path: "api/auth/register", body: Credentials(email: email, password: password), token: nil)
|
||||
}
|
||||
|
||||
func profile(token: String) async throws -> UserProfile {
|
||||
try await client.get(path: "api/auth/profile", queryItems: [], token: token)
|
||||
}
|
||||
}
|
||||
|
||||
private struct Credentials: Codable {
|
||||
let email: String
|
||||
let password: String
|
||||
}
|
||||
52
ios/Bookibra/Services/BooksService.swift
Normal file
52
ios/Bookibra/Services/BooksService.swift
Normal file
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
|
||||
protocol BooksServiceProtocol {
|
||||
func searchByTitle(_ title: String, locales: String) async throws -> [BookRemote]
|
||||
func searchByISBN(_ isbn: String, locales: String) async throws -> [BookRemote]
|
||||
func filter(title: String, year: String, locales: String) async throws -> [BookRemote]
|
||||
}
|
||||
|
||||
final class BooksService: BooksServiceProtocol {
|
||||
private let client: APIClientProtocol
|
||||
|
||||
init(client: APIClientProtocol) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
func searchByTitle(_ title: String, locales: String = "tr,en") async throws -> [BookRemote] {
|
||||
let response: BookSearchResponse = try await client.get(
|
||||
path: "api/books/title",
|
||||
queryItems: [
|
||||
.init(name: "title", value: title),
|
||||
.init(name: "locales", value: locales)
|
||||
],
|
||||
token: nil
|
||||
)
|
||||
return response.items
|
||||
}
|
||||
|
||||
func searchByISBN(_ isbn: String, locales: String = "tr,en") async throws -> [BookRemote] {
|
||||
let response: BookSearchResponse = try await client.get(
|
||||
path: "api/books/isbn/\(isbn)",
|
||||
queryItems: [
|
||||
.init(name: "locales", value: locales),
|
||||
.init(name: "withGemini", value: "false")
|
||||
],
|
||||
token: nil
|
||||
)
|
||||
return response.items
|
||||
}
|
||||
|
||||
func filter(title: String, year: String, locales: String = "tr,en") async throws -> [BookRemote] {
|
||||
let response: BookSearchResponse = try await client.get(
|
||||
path: "api/books/filter",
|
||||
queryItems: [
|
||||
.init(name: "title", value: title),
|
||||
.init(name: "published", value: year),
|
||||
.init(name: "locales", value: locales)
|
||||
],
|
||||
token: nil
|
||||
)
|
||||
return response.items
|
||||
}
|
||||
}
|
||||
34
ios/Bookibra/Services/ImageCache.swift
Normal file
34
ios/Bookibra/Services/ImageCache.swift
Normal file
@@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
protocol ImageCacheProtocol {
|
||||
func image(for url: URL) async throws -> UIImage
|
||||
}
|
||||
|
||||
final class ImageCache: ImageCacheProtocol {
|
||||
static let shared = ImageCache()
|
||||
|
||||
private let cache = NSCache<NSURL, UIImage>()
|
||||
private let session: URLSession
|
||||
|
||||
init(session: URLSession = .shared) {
|
||||
self.session = session
|
||||
cache.countLimit = 200
|
||||
}
|
||||
|
||||
func image(for url: URL) async throws -> UIImage {
|
||||
if let existing = cache.object(forKey: url as NSURL) {
|
||||
return existing
|
||||
}
|
||||
|
||||
let request = URLRequest(url: url, cachePolicy: .returnCacheDataElseLoad, timeoutInterval: 20)
|
||||
let (data, _) = try await session.data(for: request)
|
||||
|
||||
guard let image = UIImage(data: data) else {
|
||||
throw APIError.invalidResponse
|
||||
}
|
||||
|
||||
cache.setObject(image, forKey: url as NSURL)
|
||||
return image
|
||||
}
|
||||
}
|
||||
47
ios/Bookibra/Services/KeychainStore.swift
Normal file
47
ios/Bookibra/Services/KeychainStore.swift
Normal file
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
protocol KeychainStoreProtocol {
|
||||
func save(_ value: String, for key: String) -> Bool
|
||||
func read(for key: String) -> String?
|
||||
func delete(for key: String) -> Bool
|
||||
}
|
||||
|
||||
final class KeychainStore: KeychainStoreProtocol {
|
||||
func save(_ value: String, for key: String) -> Bool {
|
||||
let data = Data(value.utf8)
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrAccount: key,
|
||||
kSecValueData: data
|
||||
]
|
||||
|
||||
SecItemDelete(query as CFDictionary)
|
||||
return SecItemAdd(query as CFDictionary, nil) == errSecSuccess
|
||||
}
|
||||
|
||||
func read(for key: String) -> String? {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrAccount: key,
|
||||
kSecReturnData: true,
|
||||
kSecMatchLimit: kSecMatchLimitOne
|
||||
]
|
||||
|
||||
var item: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
|
||||
let data = item as? Data else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
func delete(for key: String) -> Bool {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrAccount: key
|
||||
]
|
||||
return SecItemDelete(query as CFDictionary) == errSecSuccess
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user