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,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")!
}
}

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

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

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

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