basics api client
This commit is contained in:
parent
c8e44d50e2
commit
8e08130776
24
medmap/AppState.swift
Executable file
24
medmap/AppState.swift
Executable file
@ -0,0 +1,24 @@
|
|||||||
|
// Copyright © 2025 VOID Software SA. All rights reserved.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
enum AppState: Equatable, Hashable {
|
||||||
|
|
||||||
|
static func == (lhs: AppState, rhs: AppState) -> Bool {
|
||||||
|
switch (lhs, rhs) {
|
||||||
|
case (.splashScreen, .splashScreen):
|
||||||
|
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case splashScreen
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@ -7,8 +7,6 @@ final class ManagerAssembly: Assembly {
|
|||||||
|
|
||||||
func assemble(container: Container) {
|
func assemble(container: Container) {
|
||||||
|
|
||||||
/*
|
|
||||||
|
|
||||||
|
|
||||||
container.register(AppStateManager.self) { _ in
|
container.register(AppStateManager.self) { _ in
|
||||||
return AppStateManager()
|
return AppStateManager()
|
||||||
@ -19,6 +17,18 @@ final class ManagerAssembly: Assembly {
|
|||||||
return GRDBDatabaseManager()
|
return GRDBDatabaseManager()
|
||||||
}
|
}
|
||||||
.inObjectScope(.container)
|
.inObjectScope(.container)
|
||||||
|
container.register(APIClient.self) { _ in
|
||||||
|
return APIClient()
|
||||||
|
}
|
||||||
|
.inObjectScope(.container)
|
||||||
|
|
||||||
|
container.register(EventsManager.self) { _ in
|
||||||
|
return EventsManager()
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
container.register(ComicsBooksManager.self) { _ in
|
container.register(ComicsBooksManager.self) { _ in
|
||||||
return ComicsBooksManager()
|
return ComicsBooksManager()
|
||||||
|
|||||||
64
medmap/Managers/AppStateManager.swift
Executable file
64
medmap/Managers/AppStateManager.swift
Executable file
@ -0,0 +1,64 @@
|
|||||||
|
// Copyright © 2025 VOID Software SA. All rights reserved.
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Swinject
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
class AppStateManager: ObservableObject {
|
||||||
|
|
||||||
|
@Published var currentAppState: AppState?
|
||||||
|
@Published var path = NavigationPath()
|
||||||
|
|
||||||
|
static let isProduction = false
|
||||||
|
|
||||||
|
private var internalStack: [AppState] = []
|
||||||
|
var currentBottomMenuPosition: Int = 0
|
||||||
|
|
||||||
|
func navigateTo(state: AppState) {
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
|
||||||
|
// If the requested state is already the current one, do nothing
|
||||||
|
if let currentState = self.currentAppState, currentState == state {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.internalStack.append(state)
|
||||||
|
|
||||||
|
self.path.append(state)
|
||||||
|
self.currentAppState = state
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func navigateBack() {
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard !self.path.isEmpty else { return }
|
||||||
|
|
||||||
|
if self.path.count <= 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.path.removeLast()
|
||||||
|
_ = self.internalStack.popLast()
|
||||||
|
|
||||||
|
if self.internalStack.isEmpty {
|
||||||
|
self.currentAppState = self.internalStack.last
|
||||||
|
} else {
|
||||||
|
self.currentAppState = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetNavigation() {
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.path = NavigationPath()
|
||||||
|
self.internalStack.removeAll()
|
||||||
|
self.currentAppState = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
15
medmap/Managers/EventsManager.swift
Normal file
15
medmap/Managers/EventsManager.swift
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
// Copyright © 2025 VOID Software SA. All rights reserved.
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
class EventsManager {
|
||||||
|
|
||||||
|
let eventPublisher = PassthroughSubject<String, Never>()
|
||||||
|
// let comicsChannel = PassthroughSubject<UIImage?, Never>()
|
||||||
|
|
||||||
|
let serialBackgroundQueue = DispatchQueue(
|
||||||
|
label: "net.desynced.FluxReader"
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
219
medmap/Managers/GRDBDatabaseManager.swift
Normal file
219
medmap/Managers/GRDBDatabaseManager.swift
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
//
|
||||||
|
// GRDBDatabaseManager.swift
|
||||||
|
// FluxRead
|
||||||
|
//
|
||||||
|
// Created on 16/12/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import GRDB
|
||||||
|
|
||||||
|
class GRDBDatabaseManager {
|
||||||
|
|
||||||
|
private var dbQueue: DatabaseQueue?
|
||||||
|
|
||||||
|
init() {
|
||||||
|
do {
|
||||||
|
try setupDatabase()
|
||||||
|
} catch {
|
||||||
|
print("❌ Failed to setup GRDB database: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Database Setup
|
||||||
|
|
||||||
|
private func setupDatabase() throws {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let documentsPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
|
let dbPath = documentsPath.appendingPathComponent("fluxread_grdb.sqlite").path
|
||||||
|
|
||||||
|
// Create database queue
|
||||||
|
dbQueue = try DatabaseQueue(path: dbPath)
|
||||||
|
|
||||||
|
// Create tables
|
||||||
|
try dbQueue?.write { db in
|
||||||
|
try createTables(db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createTables(_ db: Database) throws {
|
||||||
|
|
||||||
|
/*
|
||||||
|
try db.create(table: GRDBComicBookFile.databaseTableName, ifNotExists: true) { t in
|
||||||
|
|
||||||
|
t.column("fileHash", .text).primaryKey()
|
||||||
|
t.column("name", .text).notNull()
|
||||||
|
t.column("fileSize", .integer).notNull()
|
||||||
|
t.column("dateAdded", .datetime).notNull()
|
||||||
|
t.column("filePath", .text).notNull()
|
||||||
|
t.column("numberOfPages", .integer).notNull()
|
||||||
|
|
||||||
|
t.column("pageCount", .integer)
|
||||||
|
|
||||||
|
t.column("lastReadDate", .datetime).indexed()
|
||||||
|
t.column("issueDate", .datetime)
|
||||||
|
t.column("isRead", .boolean).notNull().defaults(to: false).indexed()
|
||||||
|
t.column("readCompletion", .integer).notNull().defaults(to: 0).indexed()
|
||||||
|
t.column("syncDate", .datetime).indexed()
|
||||||
|
t.column("estimatedProcessingDate", .datetime)
|
||||||
|
|
||||||
|
t.column("providerId", .text)
|
||||||
|
t.column("providerPath", .text)
|
||||||
|
t.column("isAvailableLocally", .boolean).indexed()
|
||||||
|
t.column("parentFileHash", .text).indexed()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
try db.create(table: GRDBComicBookPage.databaseTableName, ifNotExists: true) { t in
|
||||||
|
t.column("comicBookId", .text).notNull()
|
||||||
|
.indexed()
|
||||||
|
|
||||||
|
t.column("fileHash", .text).notNull()
|
||||||
|
t.column("pageNumber", .integer).notNull().indexed()
|
||||||
|
t.column("syncDate", .datetime).indexed()
|
||||||
|
t.primaryKey(["comicBookId", "fileHash"])
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
try db.create(table: GRDBComicBookPagePanel.databaseTableName, ifNotExists: true) { t in
|
||||||
|
t.column("comicBookId", .text).notNull()
|
||||||
|
.indexed()
|
||||||
|
t.column("comicBookPageId", .text).notNull()
|
||||||
|
.indexed()
|
||||||
|
t.column("panelNumberInPage", .integer).notNull()
|
||||||
|
t.column("pageNumber", .integer).notNull()
|
||||||
|
t.column("left", .integer).notNull()
|
||||||
|
t.column("top", .integer).notNull()
|
||||||
|
t.column("right", .integer).notNull()
|
||||||
|
t.column("bottom", .integer).notNull()
|
||||||
|
t.column("syncDate", .datetime).indexed()
|
||||||
|
t.primaryKey(["comicBookId", "comicBookPageId", "panelNumberInPage", "pageNumber"])
|
||||||
|
}
|
||||||
|
|
||||||
|
try db.create(table: GRDBComicBookTitle.databaseTableName, ifNotExists: true) { t in
|
||||||
|
t.column("id", .text).primaryKey()
|
||||||
|
t.column("name", .text).notNull()
|
||||||
|
t.column("age", .integer).notNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
try db.create(table: GRDBComicBookPageReadingStat.databaseTableName, ifNotExists: true) { t in
|
||||||
|
t.column("comicBookId", .text).notNull().indexed()
|
||||||
|
t.column("comicBookPageId", .text).notNull().indexed()
|
||||||
|
t.column("comicBookPageNumber", .integer).notNull().indexed()
|
||||||
|
t.column("startDate", .datetime).notNull()
|
||||||
|
t.column("endDate", .datetime).notNull()
|
||||||
|
t.column("numberOfSeconds", .integer).notNull()
|
||||||
|
t.column("syncDate", .datetime).indexed()
|
||||||
|
t.primaryKey(["comicBookId", "comicBookPageId", "comicBookPageNumber", "startDate"])
|
||||||
|
}
|
||||||
|
|
||||||
|
try db.create(table: LibraryImportProvider.databaseTableName, ifNotExists: true) { t in
|
||||||
|
t.column("providerId", .text).primaryKey()
|
||||||
|
t.column("connectionName", .text).notNull()
|
||||||
|
t.column("providerType", .integer).notNull()
|
||||||
|
t.column("hostName", .text).notNull()
|
||||||
|
t.column("port", .integer).notNull()
|
||||||
|
t.column("username", .text).notNull()
|
||||||
|
t.column("path", .text).notNull()
|
||||||
|
t.column("authenticationType", .integer).notNull()
|
||||||
|
|
||||||
|
}
|
||||||
|
try db.create(table: ComicBookMetaDataAttribute.databaseTableName, ifNotExists: true) { t in
|
||||||
|
t.column("attributeId", .integer).notNull()
|
||||||
|
t.column("attributeValue", .text).notNull()
|
||||||
|
t.column("attributeType", .integer).notNull()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
try db.create(table: ComicBookMetaDataAttributeRelation.databaseTableName, ifNotExists: true) { t in
|
||||||
|
t.column("attributeId", .integer).notNull()
|
||||||
|
t.column("comicBookId", .text).notNull()
|
||||||
|
t.primaryKey(["attributeId", "comicBookId"])
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
try db.create(table: ComicBookMetadata.databaseTableName, ifNotExists: true) { t in
|
||||||
|
t.column("comicBookId", .text).primaryKey()
|
||||||
|
t.column("creationDate", .datetime).notNull()
|
||||||
|
|
||||||
|
t.column("metadata", .text).notNull()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
try db.create(table: GRDBComicBookPagePanelText.databaseTableName, ifNotExists: true) { t in
|
||||||
|
|
||||||
|
t.column("comicBookId", .text).notNull()
|
||||||
|
t.column("comicBookPageId", .integer).notNull()
|
||||||
|
t.column("textBlockNumber", .integer).notNull()
|
||||||
|
t.column("textBlock", .text).notNull()
|
||||||
|
t.column("left", .double).notNull()
|
||||||
|
t.column("top", .double).notNull()
|
||||||
|
t.column("right", .double).notNull()
|
||||||
|
t.column("bottom", .double).notNull()
|
||||||
|
t.primaryKey(["comicBookId", "comicBookPageId", "textBlockNumber"])
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
try db.create(table: UserActivity.databaseTableName, ifNotExists: true) { t in
|
||||||
|
|
||||||
|
t.column("activityId", .integer).primaryKey()
|
||||||
|
|
||||||
|
t.column("activityType", .integer).notNull()
|
||||||
|
t.column("creationDate", .datetime).notNull()
|
||||||
|
t.column("activityDescription", .text)
|
||||||
|
t.column("comicId", .integer)
|
||||||
|
t.column("activityDetail", .text)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
try db.create(table: SavedSearch.databaseTableName, ifNotExists: true) { t in
|
||||||
|
|
||||||
|
t.column("id", .text).primaryKey()
|
||||||
|
t.column("query", .text).notNull()
|
||||||
|
t.column("attributeId", .integer).notNull().indexed()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
try db.create(table: SavedSearchComic.databaseTableName, ifNotExists: true) { t in
|
||||||
|
|
||||||
|
t.column("id", .text).primaryKey()
|
||||||
|
t.column("savedSearchId", .text).notNull().indexed()
|
||||||
|
t.column("comicId", .text).notNull()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Access
|
||||||
|
|
||||||
|
func getDatabase() -> DatabaseQueue? {
|
||||||
|
return dbQueue
|
||||||
|
}
|
||||||
|
|
||||||
|
func write<T>(_ updates: (Database) throws -> T) throws -> T {
|
||||||
|
guard let dbQueue = dbQueue else {
|
||||||
|
throw DatabaseError.notInitialized
|
||||||
|
}
|
||||||
|
return try dbQueue.write(updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
func read<T>(_ value: (Database) throws -> T) throws -> T {
|
||||||
|
guard let dbQueue = dbQueue else {
|
||||||
|
throw DatabaseError.notInitialized
|
||||||
|
}
|
||||||
|
return try dbQueue.read(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Error Types
|
||||||
|
|
||||||
|
enum DatabaseError: Error {
|
||||||
|
case notInitialized
|
||||||
|
case recordNotFound
|
||||||
|
case insertFailed
|
||||||
|
case updateFailed
|
||||||
|
case deleteFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
139
medmap/Managers/PreferencesManager.swift
Normal file
139
medmap/Managers/PreferencesManager.swift
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
//
|
||||||
|
// PreferencesManager.swift
|
||||||
|
// FluxRead
|
||||||
|
//
|
||||||
|
// Created by RSA on 17/01/2026.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class PreferencesManager {
|
||||||
|
|
||||||
|
private let defaults = UserDefaults.standard
|
||||||
|
|
||||||
|
// private var cachePreferences: AppSettings?
|
||||||
|
|
||||||
|
// MARK: - Set Values
|
||||||
|
|
||||||
|
func set(_ value: Any?, forKey key: String) {
|
||||||
|
defaults.set(value, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func set(_ value: Bool, forKey key: String) {
|
||||||
|
defaults.set(value, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func set(_ value: Int, forKey key: String) {
|
||||||
|
defaults.set(value, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func set(_ value: Double, forKey key: String) {
|
||||||
|
defaults.set(value, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func set(_ value: String, forKey key: String) {
|
||||||
|
defaults.set(value, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func set(_ value: Date, forKey key: String) {
|
||||||
|
defaults.set(value, forKey: key)
|
||||||
|
defaults.synchronize()
|
||||||
|
}
|
||||||
|
|
||||||
|
func set<T: Encodable>(_ value: T, forKey key: String) throws {
|
||||||
|
let data = try JSONEncoder().encode(value)
|
||||||
|
defaults.set(data, forKey: key)
|
||||||
|
defaults.synchronize()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Get Values
|
||||||
|
|
||||||
|
func string(forKey key: String) -> String? {
|
||||||
|
return defaults.string(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bool(forKey key: String) -> Bool {
|
||||||
|
return defaults.bool(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func int(forKey key: String) -> Int {
|
||||||
|
return defaults.integer(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func double(forKey key: String) -> Double {
|
||||||
|
return defaults.double(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func date(forKey key: String) -> Date? {
|
||||||
|
return defaults.object(forKey: key) as? Date
|
||||||
|
}
|
||||||
|
|
||||||
|
func data(forKey key: String) -> Data? {
|
||||||
|
return defaults.data(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func object<T: Decodable>(forKey key: String, as type: T.Type) throws -> T? {
|
||||||
|
guard let data = defaults.data(forKey: key) else { return nil }
|
||||||
|
return try JSONDecoder().decode(type, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Remove Values
|
||||||
|
|
||||||
|
func remove(forKey key: String) {
|
||||||
|
defaults.removeObject(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeAll() {
|
||||||
|
if let bundleID = Bundle.main.bundleIdentifier {
|
||||||
|
defaults.removePersistentDomain(forName: bundleID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Check Existence
|
||||||
|
|
||||||
|
func exists(forKey key: String) -> Bool {
|
||||||
|
return defaults.object(forKey: key) != nil
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
func getAppSettings() -> AppSettings {
|
||||||
|
do {
|
||||||
|
|
||||||
|
if let cacheSettings = self.cachePreferences {
|
||||||
|
|
||||||
|
return cacheSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
if let appSettings = try self.object(forKey: "appSettings", as: AppSettings.self) {
|
||||||
|
self.cachePreferences = appSettings
|
||||||
|
return appSettings
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
|
||||||
|
let defaultSettings = AppSettings()
|
||||||
|
|
||||||
|
self.saveAppSettings(defaultSettings)
|
||||||
|
return defaultSettings
|
||||||
|
|
||||||
|
print("❌ Failed to decode AppSettings: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return default settings if none exist or decoding fails
|
||||||
|
return AppSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveAppSettings(_ settings: AppSettings) {
|
||||||
|
do {
|
||||||
|
try self.set(settings, forKey: "appSettings")
|
||||||
|
self.cachePreferences = settings
|
||||||
|
} catch {
|
||||||
|
print("❌ Failed to save AppSettings: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Synchronize
|
||||||
|
*/
|
||||||
|
@discardableResult
|
||||||
|
func synchronize() -> Bool {
|
||||||
|
return defaults.synchronize()
|
||||||
|
}
|
||||||
|
}
|
||||||
110
medmap/Models/APIError.swift
Normal file
110
medmap/Models/APIError.swift
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
// Copyright © 2025 VOID Software SA. All rights reserved.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Custom error types for API operations
|
||||||
|
enum APIError: Error, LocalizedError, Equatable {
|
||||||
|
case invalidURL
|
||||||
|
case noData
|
||||||
|
case decodingFailed(Error)
|
||||||
|
case encodingFailed(Error)
|
||||||
|
case unauthorized
|
||||||
|
case forbidden
|
||||||
|
case notFound
|
||||||
|
case serverError(statusCode: Int)
|
||||||
|
case networkError(Error)
|
||||||
|
case unexpectedResponse
|
||||||
|
case timeout
|
||||||
|
case cancelled
|
||||||
|
case custom(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidURL:
|
||||||
|
return "Invalid URL"
|
||||||
|
case .noData:
|
||||||
|
return "No data received from server"
|
||||||
|
case .decodingFailed(let error):
|
||||||
|
return "Failed to decode response: \(error.localizedDescription)"
|
||||||
|
case .encodingFailed(let error):
|
||||||
|
return "Failed to encode parameters: \(error.localizedDescription)"
|
||||||
|
case .unauthorized:
|
||||||
|
return "Unauthorized. Please login again."
|
||||||
|
case .forbidden:
|
||||||
|
return "Access forbidden"
|
||||||
|
case .notFound:
|
||||||
|
return "Resource not found"
|
||||||
|
case .serverError(let statusCode):
|
||||||
|
return "Server error (Status: \(statusCode))"
|
||||||
|
case .networkError(let error):
|
||||||
|
return "Network error: \(error.localizedDescription)"
|
||||||
|
case .unexpectedResponse:
|
||||||
|
return "Unexpected response from server"
|
||||||
|
case .timeout:
|
||||||
|
return "Request timeout"
|
||||||
|
case .cancelled:
|
||||||
|
return "Request cancelled"
|
||||||
|
case .custom(let message):
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func == (lhs: APIError, rhs: APIError) -> Bool {
|
||||||
|
switch (lhs, rhs) {
|
||||||
|
case (.invalidURL, .invalidURL),
|
||||||
|
(.noData, .noData),
|
||||||
|
(.unauthorized, .unauthorized),
|
||||||
|
(.forbidden, .forbidden),
|
||||||
|
(.notFound, .notFound),
|
||||||
|
(.unexpectedResponse, .unexpectedResponse),
|
||||||
|
(.timeout, .timeout),
|
||||||
|
(.cancelled, .cancelled):
|
||||||
|
return true
|
||||||
|
case (.serverError(let lhsCode), .serverError(let rhsCode)):
|
||||||
|
return lhsCode == rhsCode
|
||||||
|
case (.custom(let lhsMessage), .custom(let rhsMessage)):
|
||||||
|
return lhsMessage == rhsMessage
|
||||||
|
case (.decodingFailed(let lhsError), .decodingFailed(let rhsError)):
|
||||||
|
return lhsError.localizedDescription == rhsError.localizedDescription
|
||||||
|
case (.encodingFailed(let lhsError), .encodingFailed(let rhsError)):
|
||||||
|
return lhsError.localizedDescription == rhsError.localizedDescription
|
||||||
|
case (.networkError(let lhsError), .networkError(let rhsError)):
|
||||||
|
return lhsError.localizedDescription == rhsError.localizedDescription
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detailed server error with request/response information
|
||||||
|
struct ServerError: Error, LocalizedError {
|
||||||
|
let statusCode: Int?
|
||||||
|
let message: String?
|
||||||
|
let data: Data?
|
||||||
|
let request: URLRequest?
|
||||||
|
let response: HTTPURLResponse?
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
if let message = message {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
if let statusCode = statusCode {
|
||||||
|
return "Server error: \(statusCode)"
|
||||||
|
}
|
||||||
|
return "Unknown server error"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
statusCode: Int? = nil,
|
||||||
|
message: String? = nil,
|
||||||
|
data: Data? = nil,
|
||||||
|
request: URLRequest? = nil,
|
||||||
|
response: HTTPURLResponse? = nil
|
||||||
|
) {
|
||||||
|
self.statusCode = statusCode
|
||||||
|
self.message = message
|
||||||
|
self.data = data
|
||||||
|
self.request = request
|
||||||
|
self.response = response
|
||||||
|
}
|
||||||
|
}
|
||||||
15
medmap/Models/IO/Base64ImageFileDTO.swift
Normal file
15
medmap/Models/IO/Base64ImageFileDTO.swift
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
//
|
||||||
|
// Base64ImageFileDTO.swift
|
||||||
|
// medmap
|
||||||
|
//
|
||||||
|
// Created by Ricardo Araujo on 27/08/2026.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
struct Base64ImageFileDTO: Codable {
|
||||||
|
let fileHash: String?
|
||||||
|
let file: String?
|
||||||
|
let imageWidth: Float?
|
||||||
|
let imageHeight: Float?
|
||||||
|
}
|
||||||
20
medmap/Models/IO/ChangePrescriptionScheduleRequest.swift
Normal file
20
medmap/Models/IO/ChangePrescriptionScheduleRequest.swift
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
//
|
||||||
|
// ChangePrescriptionScheduleRequest.swift
|
||||||
|
// medmap
|
||||||
|
//
|
||||||
|
// Created by Ricardo Araujo on 27/08/2026.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ChangePrescriptionScheduleRequest: Encodable {
|
||||||
|
let prescriptionId: String
|
||||||
|
let frequencyType: FrequencyType
|
||||||
|
let intervalValue: Int?
|
||||||
|
let intervalUnit: String?
|
||||||
|
let timesOfDay: [String]?
|
||||||
|
let doseAmount: Double
|
||||||
|
let doseUnit: String
|
||||||
|
let effectiveFrom: String // "yyyy-MM-dd"
|
||||||
|
let timezone: String?
|
||||||
|
}
|
||||||
15
medmap/Models/IO/CompanyDTO.swift
Normal file
15
medmap/Models/IO/CompanyDTO.swift
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
//
|
||||||
|
// CompanyDTO.swift
|
||||||
|
// medmap
|
||||||
|
//
|
||||||
|
// Created by Ricardo Araujo on 27/08/2026.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CompanyDTO: Decodable {
|
||||||
|
let companyId: String
|
||||||
|
let name: String
|
||||||
|
let contactEmail: String?
|
||||||
|
let companyLogo: String?
|
||||||
|
}
|
||||||
15
medmap/Models/IO/CreateCompanyRequest.swift
Normal file
15
medmap/Models/IO/CreateCompanyRequest.swift
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
//
|
||||||
|
// CreateCompanyRequest.swift
|
||||||
|
// medmap
|
||||||
|
//
|
||||||
|
// Created by Ricardo Araujo on 27/08/2026.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
struct CreateCompanyRequest: Encodable {
|
||||||
|
let name: String
|
||||||
|
let vatNumber: String?
|
||||||
|
let contactEmail: String?
|
||||||
|
let companyLogo: String?
|
||||||
|
}
|
||||||
17
medmap/Models/IO/CreatePatientRequest.swift
Normal file
17
medmap/Models/IO/CreatePatientRequest.swift
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
//
|
||||||
|
// CreatePatientRequest.swift
|
||||||
|
// medmap
|
||||||
|
//
|
||||||
|
// Created by Ricardo Araujo on 27/08/2026.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CreatePatientRequest: Encodable {
|
||||||
|
let name: String
|
||||||
|
let dateOfBirth: String? // "yyyy-MM-dd"
|
||||||
|
let timezone: String
|
||||||
|
let active: Bool?
|
||||||
|
let professionalId: String
|
||||||
|
let patientPhoto: Base64ImageFileDTO?
|
||||||
|
}
|
||||||
21
medmap/Models/IO/CreatePrescriptionRequest.swift
Normal file
21
medmap/Models/IO/CreatePrescriptionRequest.swift
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
//
|
||||||
|
// CreatePrescriptionRequest.swift
|
||||||
|
// medmap
|
||||||
|
//
|
||||||
|
// Created by Ricardo Araujo on 27/08/2026.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
|
struct CreatePrescriptionRequest: Encodable {
|
||||||
|
let patientId: String
|
||||||
|
let medicationId: String
|
||||||
|
let prescriberId: String
|
||||||
|
let startDate: String // "yyyy-MM-dd"
|
||||||
|
let endDate: String?
|
||||||
|
let prn: Bool
|
||||||
|
let instructions: String?
|
||||||
|
let timezone: String?
|
||||||
|
let schedule: ScheduleRequest
|
||||||
|
}
|
||||||
16
medmap/Models/IO/CreateProfessionalRequest.swift
Normal file
16
medmap/Models/IO/CreateProfessionalRequest.swift
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
//
|
||||||
|
// CreateProfessionalRequest.swift
|
||||||
|
// medmap
|
||||||
|
//
|
||||||
|
// Created by Ricardo Araujo on 27/08/2026.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
|
struct CreateProfessionalRequest: Encodable {
|
||||||
|
let name: String
|
||||||
|
let licenseNumber: String?
|
||||||
|
let companyId: String
|
||||||
|
let patientPhoto: Base64ImageFileDTO?
|
||||||
|
}
|
||||||
20
medmap/Models/IO/FrequencyType.swift
Normal file
20
medmap/Models/IO/FrequencyType.swift
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
//
|
||||||
|
// FrequencyType.swift
|
||||||
|
// medmap
|
||||||
|
//
|
||||||
|
// Created by Ricardo Araujo on 27/08/2026.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
enum FrequencyType: String, Codable {
|
||||||
|
case fixedTimes = "FIXED_TIMES"
|
||||||
|
case interval = "INTERVAL"
|
||||||
|
case onceDaily = "ONCE_DAILY"
|
||||||
|
case twiceDaily = "TWICE_DAILY"
|
||||||
|
case threeTimesDaily = "THREE_TIMES_DAILY"
|
||||||
|
case fourTimesDaily = "FOUR_TIMES_DAILY"
|
||||||
|
case alternateDays = "ALTERNATE_DAYS"
|
||||||
|
case weekly = "WEEKLY"
|
||||||
|
case asNeeded = "AS_NEEDED"
|
||||||
|
}
|
||||||
15
medmap/Models/IO/Gender.swift
Normal file
15
medmap/Models/IO/Gender.swift
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
//
|
||||||
|
// Gender.swift
|
||||||
|
// medmap
|
||||||
|
//
|
||||||
|
// Created by Ricardo Araujo on 27/08/2026.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
enum Gender: String, Codable {
|
||||||
|
case male = "MALE"
|
||||||
|
case female = "FEMALE"
|
||||||
|
case nonBinary = "NON_BINARY"
|
||||||
|
case other = "OTHER"
|
||||||
|
case preferNotToSay = "PREFER_NOT_TO_SAY"
|
||||||
|
}
|
||||||
12
medmap/Models/IO/ListPatientsRequest.swift
Normal file
12
medmap/Models/IO/ListPatientsRequest.swift
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
//
|
||||||
|
// ListPatientsRequest.swift
|
||||||
|
// medmap
|
||||||
|
//
|
||||||
|
// Created by Ricardo Araujo on 27/08/2026.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ListPatientsRequest: Encodable {
|
||||||
|
let professionalId: String
|
||||||
|
}
|
||||||
16
medmap/Models/IO/ListPrescriptionsRequest.swift
Normal file
16
medmap/Models/IO/ListPrescriptionsRequest.swift
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
//
|
||||||
|
// ListPrescriptionsRequest.swift
|
||||||
|
// medmap
|
||||||
|
//
|
||||||
|
// Created by Ricardo Araujo on 27/08/2026.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
|
struct ListPrescriptionsRequest: Encodable {
|
||||||
|
let patientId: String
|
||||||
|
let activeOnly: Bool
|
||||||
|
let scheduleFilter: ScheduleFilter
|
||||||
|
}
|
||||||
13
medmap/Models/IO/ListProfessionalsRequest.swift
Normal file
13
medmap/Models/IO/ListProfessionalsRequest.swift
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
//
|
||||||
|
// ListProfessionalsRequest.swift
|
||||||
|
// medmap
|
||||||
|
//
|
||||||
|
// Created by Ricardo Araujo on 27/08/2026.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ListProfessionalsRequest: Encodable {
|
||||||
|
let companyId: String
|
||||||
|
}
|
||||||
13
medmap/Models/IO/LoginRequest.swift
Normal file
13
medmap/Models/IO/LoginRequest.swift
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
//
|
||||||
|
// LoginRequest.swift
|
||||||
|
// medmap
|
||||||
|
//
|
||||||
|
// Created by Ricardo Araujo on 27/08/2026.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
struct LoginRequest: Encodable {
|
||||||
|
let email: String
|
||||||
|
let password: String
|
||||||
|
}
|
||||||
14
medmap/Models/IO/LoginResponse.swift
Normal file
14
medmap/Models/IO/LoginResponse.swift
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
//
|
||||||
|
// LoginResponse.swift
|
||||||
|
// medmap
|
||||||
|
//
|
||||||
|
// Created by Ricardo Araujo on 27/08/2026.
|
||||||
|
//
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
|
struct LoginResponse: Decodable {
|
||||||
|
let id: String
|
||||||
|
let email: String
|
||||||
|
let role: Role
|
||||||
|
}
|
||||||
25
medmap/Models/IO/PatientDTO.swift
Normal file
25
medmap/Models/IO/PatientDTO.swift
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
//
|
||||||
|
// PatientDTO.swift
|
||||||
|
// medmap
|
||||||
|
//
|
||||||
|
// Created by Ricardo Araujo on 27/08/2026.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
struct PatientDTO: Decodable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let email: String?
|
||||||
|
let phoneNumber: String?
|
||||||
|
let address: String?
|
||||||
|
let dateOfBirth: String?
|
||||||
|
let timezone: String
|
||||||
|
let gender: Gender?
|
||||||
|
let emergencyContactName: String?
|
||||||
|
let emergencyContactPhone: String?
|
||||||
|
let notes: String?
|
||||||
|
let professionalId: String
|
||||||
|
let photo: String?
|
||||||
|
}
|
||||||
12
medmap/Models/IO/PatientsByProfessionalResponse.swift
Normal file
12
medmap/Models/IO/PatientsByProfessionalResponse.swift
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
//
|
||||||
|
// PatientsByProfessionalResponse.swift
|
||||||
|
// medmap
|
||||||
|
//
|
||||||
|
// Created by Ricardo Araujo on 27/08/2026.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
struct PatientsByProfessionalResponse: Decodable {
|
||||||
|
let patients: [PatientDTO]
|
||||||
|
}
|
||||||
19
medmap/Models/IO/PrescriptionResponse.swift
Normal file
19
medmap/Models/IO/PrescriptionResponse.swift
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
//
|
||||||
|
// PrescriptionResponse.swift
|
||||||
|
// medmap
|
||||||
|
//
|
||||||
|
// Created by Ricardo Araujo on 27/08/2026.
|
||||||
|
//
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
|
struct PrescriptionResponse: Decodable {
|
||||||
|
let id: String
|
||||||
|
let startDate: String?
|
||||||
|
let endDate: String?
|
||||||
|
let status: String
|
||||||
|
let prn: Bool
|
||||||
|
let instructions: String?
|
||||||
|
let createdAt: Date?
|
||||||
|
let updatedAt: Date?
|
||||||
|
}
|
||||||
23
medmap/Models/IO/PrescriptionScheduleResponse.swift
Normal file
23
medmap/Models/IO/PrescriptionScheduleResponse.swift
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
//
|
||||||
|
// PrescriptionScheduleResponse.swift
|
||||||
|
// medmap
|
||||||
|
//
|
||||||
|
// Created by Ricardo Araujo on 27/08/2026.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
|
struct PrescriptionScheduleResponse: Decodable {
|
||||||
|
let id: String
|
||||||
|
let version: Int
|
||||||
|
let frequencyType: FrequencyType
|
||||||
|
let intervalValue: Int?
|
||||||
|
let intervalUnit: String?
|
||||||
|
let timesOfDay: [String]
|
||||||
|
let doseAmount: Double?
|
||||||
|
let doseUnit: String?
|
||||||
|
let effectiveFrom: Date
|
||||||
|
let effectiveTo: Date?
|
||||||
|
let createdAt: Date?
|
||||||
|
}
|
||||||
14
medmap/Models/IO/PrescriptionWithSchedulesResponse.swift
Normal file
14
medmap/Models/IO/PrescriptionWithSchedulesResponse.swift
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
//
|
||||||
|
// PrescriptionWithSchedulesResponse.swift
|
||||||
|
// medmap
|
||||||
|
//
|
||||||
|
// Created by Ricardo Araujo on 27/08/2026.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
struct PrescriptionWithSchedulesResponse: Decodable {
|
||||||
|
let prescription: PrescriptionResponse
|
||||||
|
let schedules: [PrescriptionScheduleResponse]
|
||||||
|
}
|
||||||
16
medmap/Models/IO/ProfessionalResponse.swift
Normal file
16
medmap/Models/IO/ProfessionalResponse.swift
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
//
|
||||||
|
// ProfessionalResponse.swift
|
||||||
|
// medmap
|
||||||
|
//
|
||||||
|
// Created by Ricardo Araujo on 27/08/2026.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ProfessionalResponse: Decodable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let licenseNumber: String?
|
||||||
|
let email: String?
|
||||||
|
let resourceId: String?
|
||||||
|
}
|
||||||
14
medmap/Models/IO/Role.swift
Normal file
14
medmap/Models/IO/Role.swift
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
//
|
||||||
|
// Role.swift
|
||||||
|
// medmap
|
||||||
|
//
|
||||||
|
// Created by Ricardo Araujo on 27/08/2026.
|
||||||
|
//
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
|
enum Role: String, Codable {
|
||||||
|
case patient = "PATIENT"
|
||||||
|
case professional = "PROFESSIONAL"
|
||||||
|
case admin = "ADMIN"
|
||||||
|
}
|
||||||
13
medmap/Models/IO/ScheduleFilter.swift
Normal file
13
medmap/Models/IO/ScheduleFilter.swift
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
//
|
||||||
|
// ScheduleFilter.swift
|
||||||
|
// medmap
|
||||||
|
//
|
||||||
|
// Created by Ricardo Araujo on 27/08/2026.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
enum ScheduleFilter: String, Codable {
|
||||||
|
case activeOnly = "ACTIVE_ONLY"
|
||||||
|
case all = "ALL"
|
||||||
|
}
|
||||||
17
medmap/Models/IO/ScheduleRequest.swift
Normal file
17
medmap/Models/IO/ScheduleRequest.swift
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
//
|
||||||
|
// ScheduleRequest.swift
|
||||||
|
// medmap
|
||||||
|
//
|
||||||
|
// Created by Ricardo Araujo on 27/08/2026.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
struct ScheduleRequest: Encodable {
|
||||||
|
let frequencyType: FrequencyType
|
||||||
|
let intervalValue: Int?
|
||||||
|
let intervalUnit: String?
|
||||||
|
let timesOfDay: [String]?
|
||||||
|
let doseAmount: Double
|
||||||
|
let doseUnit: String
|
||||||
|
}
|
||||||
14
medmap/Models/IO/SignupRequest.swift
Normal file
14
medmap/Models/IO/SignupRequest.swift
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
//
|
||||||
|
// SignupRequest.swift
|
||||||
|
// medmap
|
||||||
|
//
|
||||||
|
// Created by Ricardo Araujo on 27/08/2026.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
struct SignupRequest: Encodable {
|
||||||
|
let email: String
|
||||||
|
let password: String
|
||||||
|
let role: Role
|
||||||
|
}
|
||||||
628
medmap/Network/APIClient.swift
Normal file
628
medmap/Network/APIClient.swift
Normal file
@ -0,0 +1,628 @@
|
|||||||
|
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Alamofire
|
||||||
|
import Combine
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// Enhanced API Client with advanced features
|
||||||
|
class APIClient {
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
// < static let shared = APIClient()
|
||||||
|
|
||||||
|
private let session: Session
|
||||||
|
private let decoder: JSONDecoder
|
||||||
|
private let encoder: JSONEncoder
|
||||||
|
private let interceptor: AuthorizationInterceptor
|
||||||
|
private let logger = NetworkLogger.self
|
||||||
|
|
||||||
|
// Cache for images
|
||||||
|
private let imageCache = NSCache<NSString, UIImage>()
|
||||||
|
|
||||||
|
// Request tracking for cancellation
|
||||||
|
private var activeRequests: [String: DataRequest] = [:]
|
||||||
|
private let requestLock = NSLock()
|
||||||
|
|
||||||
|
// MARK: - Configuration
|
||||||
|
|
||||||
|
struct Configuration {
|
||||||
|
var timeout: TimeInterval = 30
|
||||||
|
var cachePolicy: URLRequest.CachePolicy = .reloadIgnoringLocalCacheData
|
||||||
|
var retryLimit: Int = 3
|
||||||
|
var logLevel: NetworkLogger.LogLevel = .debug
|
||||||
|
var enableCaching: Bool = true
|
||||||
|
var maxConcurrentRequests: Int = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
private(set) var configuration = Configuration()
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Custom decoder with flexible date handling
|
||||||
|
self.decoder = Self.createJSONDecoder()
|
||||||
|
self.encoder = Self.createJSONEncoder()
|
||||||
|
|
||||||
|
// Configure session
|
||||||
|
let sessionConfiguration = URLSessionConfiguration.default
|
||||||
|
sessionConfiguration.timeoutIntervalForRequest = configuration.timeout
|
||||||
|
sessionConfiguration.timeoutIntervalForResource = configuration.timeout * 2
|
||||||
|
sessionConfiguration.requestCachePolicy = configuration.cachePolicy
|
||||||
|
sessionConfiguration.httpMaximumConnectionsPerHost = configuration.maxConcurrentRequests
|
||||||
|
|
||||||
|
// Create interceptor
|
||||||
|
self.interceptor = AuthorizationInterceptor()
|
||||||
|
|
||||||
|
// Create session with event monitors
|
||||||
|
let eventMonitor = APIEventMonitor()
|
||||||
|
self.session = Session(
|
||||||
|
configuration: sessionConfiguration,
|
||||||
|
interceptor: interceptor,
|
||||||
|
eventMonitors: [eventMonitor]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Configure logger
|
||||||
|
logger.logLevel = configuration.logLevel
|
||||||
|
|
||||||
|
// Configure image cache
|
||||||
|
imageCache.countLimit = 100
|
||||||
|
imageCache.totalCostLimit = 50 * 1024 * 1024 // 50MB
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Configuration Methods
|
||||||
|
|
||||||
|
func configure(_ config: Configuration) {
|
||||||
|
self.configuration = config
|
||||||
|
logger.logLevel = config.logLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
func setAuthToken(_ token: String?) {
|
||||||
|
interceptor.setAccessToken(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - JSON Coders
|
||||||
|
|
||||||
|
private static func createJSONDecoder() -> JSONDecoder {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
|
||||||
|
// Flexible date decoding strategy
|
||||||
|
decoder.dateDecodingStrategy = .custom { decoder in
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
let dateStr = try container.decode(String.self)
|
||||||
|
|
||||||
|
// Handle microseconds format (6 decimal places)
|
||||||
|
if let date = parseISO8601WithMicroseconds(dateStr) {
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try multiple date formats
|
||||||
|
let formatters = [
|
||||||
|
ISO8601DateFormatter.internetDateTimeWithFractionalSeconds,
|
||||||
|
ISO8601DateFormatter.internetDateTime,
|
||||||
|
DateFormatter.iso8601Full,
|
||||||
|
DateFormatter.iso8601
|
||||||
|
]
|
||||||
|
|
||||||
|
for formatter in formatters {
|
||||||
|
if let date = (formatter as? ISO8601DateFormatter)?.date(from: dateStr) ??
|
||||||
|
(formatter as? DateFormatter)?.date(from: dateStr) {
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw DecodingError.dataCorruptedError(
|
||||||
|
in: container,
|
||||||
|
debugDescription: "Cannot decode date string: \(dateStr)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoder
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add this helper function
|
||||||
|
private static func parseISO8601WithMicroseconds(_ dateString: String) -> Date? {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||||
|
|
||||||
|
if let date = formatter.date(from: dateString) {
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try with timezone if present
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ"
|
||||||
|
return formatter.date(from: dateString)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func createJSONEncoder() -> JSONEncoder {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.dateEncodingStrategy = .iso8601
|
||||||
|
// encoder.keyEncodingStrategy = .convertToSnakeCase
|
||||||
|
return encoder
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Request Methods
|
||||||
|
|
||||||
|
/// Generic request with Encodable parameters and Decodable response
|
||||||
|
@discardableResult
|
||||||
|
func request<Parameters: Encodable, Response: Decodable>(
|
||||||
|
_ url: URLConvertible,
|
||||||
|
method: HTTPMethod = .get,
|
||||||
|
parameters: Parameters? = nil,
|
||||||
|
headers: HTTPHeaders? = nil,
|
||||||
|
requestId: String? = nil
|
||||||
|
) async throws -> Response {
|
||||||
|
let startTime = Date()
|
||||||
|
let id = requestId ?? UUID().uuidString
|
||||||
|
|
||||||
|
// Log JSON payload before sending
|
||||||
|
/*
|
||||||
|
if let parameters = parameters {
|
||||||
|
do {
|
||||||
|
let jsonData = try encoder.encode(parameters)
|
||||||
|
if let jsonString = String(data: jsonData, encoding: .utf8) {
|
||||||
|
fluxlog("📤 Request JSON Body:")
|
||||||
|
print(jsonString)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
fluxlog("⚠️ Failed to encode parameters for logging: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
let request = session.request(
|
||||||
|
url,
|
||||||
|
method: method,
|
||||||
|
parameters: parameters,
|
||||||
|
encoder: JSONParameterEncoder(encoder: encoder),
|
||||||
|
headers: headers,
|
||||||
|
interceptor: interceptor
|
||||||
|
)
|
||||||
|
.validate()
|
||||||
|
|
||||||
|
// Rest of the method remains the same
|
||||||
|
trackRequest(request, id: id)
|
||||||
|
defer { removeRequest(id: id) }
|
||||||
|
|
||||||
|
if let urlRequest = request.request {
|
||||||
|
logger.logRequest(urlRequest)
|
||||||
|
|
||||||
|
// Log the actual request body if available
|
||||||
|
if let httpBody = urlRequest.httpBody,
|
||||||
|
let bodyString = String(data: httpBody, encoding: .utf8) {
|
||||||
|
fluxlog("📤 Actual Request Body:")
|
||||||
|
print(bodyString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = await request.serializingDecodable(Response.self, decoder: decoder).response
|
||||||
|
let duration = Date().timeIntervalSince(startTime)
|
||||||
|
|
||||||
|
switch response.result {
|
||||||
|
case .success(let value):
|
||||||
|
if let httpResponse = response.response {
|
||||||
|
logger.logResponse(httpResponse, data: response.data, duration: duration)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
|
||||||
|
case .failure(let error):
|
||||||
|
logger.logError(error, request: response.request, data: response.data, duration: duration)
|
||||||
|
throw mapError(error, response: response.response, data: response.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request with dictionary parameters
|
||||||
|
@discardableResult
|
||||||
|
func request<Response: Decodable>(
|
||||||
|
_ url: URLConvertible,
|
||||||
|
method: HTTPMethod = .get,
|
||||||
|
parameters: [String: Any]? = nil,
|
||||||
|
encoding: ParameterEncoding = JSONEncoding.default,
|
||||||
|
headers: HTTPHeaders? = nil,
|
||||||
|
requestId: String? = nil
|
||||||
|
) async throws -> Response {
|
||||||
|
let startTime = Date()
|
||||||
|
let id = requestId ?? UUID().uuidString
|
||||||
|
|
||||||
|
let request = session.request(
|
||||||
|
url,
|
||||||
|
method: method,
|
||||||
|
parameters: parameters,
|
||||||
|
encoding: encoding,
|
||||||
|
headers: headers,
|
||||||
|
interceptor: interceptor
|
||||||
|
)
|
||||||
|
.validate()
|
||||||
|
|
||||||
|
trackRequest(request, id: id)
|
||||||
|
defer { removeRequest(id: id) }
|
||||||
|
|
||||||
|
if let urlRequest = request.request {
|
||||||
|
logger.logRequest(urlRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = await request.serializingDecodable(Response.self, decoder: decoder).response
|
||||||
|
let duration = Date().timeIntervalSince(startTime)
|
||||||
|
|
||||||
|
switch response.result {
|
||||||
|
case .success(let value):
|
||||||
|
if let httpResponse = response.response {
|
||||||
|
logger.logResponse(httpResponse, data: response.data, duration: duration)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
|
||||||
|
case .failure(let error):
|
||||||
|
logger.logError(error, request: response.request, data: response.data, duration: duration)
|
||||||
|
throw mapError(error, response: response.response, data: response.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request with no response body expected
|
||||||
|
func requestVoid<Parameters: Encodable>(
|
||||||
|
_ url: URLConvertible,
|
||||||
|
method: HTTPMethod = .get,
|
||||||
|
parameters: Parameters? = nil,
|
||||||
|
headers: HTTPHeaders? = nil,
|
||||||
|
requestId: String? = nil
|
||||||
|
) async throws {
|
||||||
|
let startTime = Date()
|
||||||
|
let id = requestId ?? UUID().uuidString
|
||||||
|
|
||||||
|
let request = session.request(
|
||||||
|
url,
|
||||||
|
method: method,
|
||||||
|
parameters: parameters,
|
||||||
|
encoder: JSONParameterEncoder(encoder: encoder),
|
||||||
|
headers: headers,
|
||||||
|
interceptor: interceptor
|
||||||
|
)
|
||||||
|
.validate()
|
||||||
|
|
||||||
|
trackRequest(request, id: id)
|
||||||
|
defer { removeRequest(id: id) }
|
||||||
|
|
||||||
|
if let urlRequest = request.request {
|
||||||
|
logger.logRequest(urlRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Empty.self with emptyResponseCodes including 200 to handle
|
||||||
|
// servers that return 200 OK with an empty body (zero-length data)
|
||||||
|
let response = await request
|
||||||
|
.serializingDecodable(
|
||||||
|
Empty.self,
|
||||||
|
emptyResponseCodes: [200, 201, 204, 205]
|
||||||
|
)
|
||||||
|
.response
|
||||||
|
let duration = Date().timeIntervalSince(startTime)
|
||||||
|
|
||||||
|
switch response.result {
|
||||||
|
case .success:
|
||||||
|
if let httpResponse = response.response {
|
||||||
|
logger.logResponse(httpResponse, data: response.data, duration: duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .failure(let error):
|
||||||
|
logger.logError(error, request: response.request, data: response.data, duration: duration)
|
||||||
|
throw mapError(error, response: response.response, data: response.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download image with caching
|
||||||
|
func downloadImage(
|
||||||
|
from url: URLConvertible,
|
||||||
|
cacheKey: String? = nil,
|
||||||
|
headers: HTTPHeaders? = nil,
|
||||||
|
requestId: String? = nil
|
||||||
|
) async throws -> UIImage {
|
||||||
|
// Check cache first
|
||||||
|
let key = cacheKey ?? (try? url.asURL().absoluteString) ?? UUID().uuidString
|
||||||
|
if configuration.enableCaching, let cachedImage = imageCache.object(forKey: key as NSString) {
|
||||||
|
return cachedImage
|
||||||
|
}
|
||||||
|
|
||||||
|
let startTime = Date()
|
||||||
|
let id = requestId ?? UUID().uuidString
|
||||||
|
|
||||||
|
let request = session.request(
|
||||||
|
url,
|
||||||
|
method: .get,
|
||||||
|
headers: headers,
|
||||||
|
interceptor: interceptor
|
||||||
|
)
|
||||||
|
.validate()
|
||||||
|
|
||||||
|
trackRequest(request, id: id)
|
||||||
|
defer { removeRequest(id: id) }
|
||||||
|
|
||||||
|
if let urlRequest = request.request {
|
||||||
|
logger.logRequest(urlRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = await request.serializingData().response
|
||||||
|
let duration = Date().timeIntervalSince(startTime)
|
||||||
|
|
||||||
|
switch response.result {
|
||||||
|
case .success(let data):
|
||||||
|
if let httpResponse = response.response {
|
||||||
|
logger.logResponse(httpResponse, data: data, duration: duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let image = UIImage(data: data) else {
|
||||||
|
throw APIError.decodingFailed(NSError(domain: "ImageDecoding", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to create UIImage from data"]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the image
|
||||||
|
if configuration.enableCaching {
|
||||||
|
imageCache.setObject(image, forKey: key as NSString, cost: data.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
return image
|
||||||
|
|
||||||
|
case .failure(let error):
|
||||||
|
logger.logError(error, request: response.request, data: response.data, duration: duration)
|
||||||
|
throw mapError(error, response: response.response, data: response.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download file to disk
|
||||||
|
func downloadFile(
|
||||||
|
from url: URLConvertible,
|
||||||
|
to destination: URL,
|
||||||
|
headers: HTTPHeaders? = nil,
|
||||||
|
progressHandler: ((Double) -> Void)? = nil
|
||||||
|
) async throws {
|
||||||
|
let request = session.download(
|
||||||
|
url,
|
||||||
|
method: .get,
|
||||||
|
headers: headers,
|
||||||
|
interceptor: interceptor,
|
||||||
|
to: { _, _ in (destination, [.removePreviousFile, .createIntermediateDirectories]) }
|
||||||
|
)
|
||||||
|
.validate()
|
||||||
|
|
||||||
|
if let progressHandler = progressHandler {
|
||||||
|
request.downloadProgress { progress in
|
||||||
|
progressHandler(progress.fractionCompleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = await request.serializingDownload(using: .url).response
|
||||||
|
|
||||||
|
switch response.result {
|
||||||
|
case .success:
|
||||||
|
break
|
||||||
|
case .failure(let error):
|
||||||
|
throw mapError(error, response: response.response, data: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload multipart form data
|
||||||
|
func upload<Response: Decodable>(
|
||||||
|
_ url: URLConvertible,
|
||||||
|
multipartFormData: @escaping (MultipartFormData) -> Void,
|
||||||
|
headers: HTTPHeaders? = nil,
|
||||||
|
progressHandler: ((Double) -> Void)? = nil
|
||||||
|
) async throws -> Response {
|
||||||
|
let startTime = Date()
|
||||||
|
|
||||||
|
let request = session.upload(
|
||||||
|
multipartFormData: multipartFormData,
|
||||||
|
to: url,
|
||||||
|
headers: headers,
|
||||||
|
interceptor: interceptor
|
||||||
|
)
|
||||||
|
.validate()
|
||||||
|
|
||||||
|
if let progressHandler = progressHandler {
|
||||||
|
request.uploadProgress { progress in
|
||||||
|
progressHandler(progress.fractionCompleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = await request.serializingDecodable(Response.self, decoder: decoder).response
|
||||||
|
let duration = Date().timeIntervalSince(startTime)
|
||||||
|
|
||||||
|
switch response.result {
|
||||||
|
case .success(let value):
|
||||||
|
if let httpResponse = response.response {
|
||||||
|
logger.logResponse(httpResponse, data: response.data, duration: duration)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
|
||||||
|
case .failure(let error):
|
||||||
|
logger.logError(error, request: response.request, data: response.data, duration: duration)
|
||||||
|
throw mapError(error, response: response.response, data: response.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream a local file as raw bytes in the request body (no multipart).
|
||||||
|
/// Matches the backend's `POST /upload?filename=…` endpoint which reads
|
||||||
|
/// directly from `HttpServletRequest.getInputStream()`.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - url: The endpoint URL (include query params if needed).
|
||||||
|
/// - fileURL: Local file to stream.
|
||||||
|
/// - headers: Optional extra headers. `Content-Type` defaults to
|
||||||
|
/// `application/octet-stream` if not provided.
|
||||||
|
/// - progressHandler: Called on the main queue with 0…1 fraction.
|
||||||
|
func uploadFile(
|
||||||
|
to url: URLConvertible,
|
||||||
|
fileURL: URL,
|
||||||
|
headers: HTTPHeaders? = nil,
|
||||||
|
progressHandler: ((Double) -> Void)? = nil
|
||||||
|
) async throws {
|
||||||
|
let startTime = Date()
|
||||||
|
|
||||||
|
var finalHeaders = headers ?? HTTPHeaders()
|
||||||
|
if finalHeaders["Content-Type"] == nil {
|
||||||
|
finalHeaders.add(.contentType("application/octet-stream"))
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = session.upload(
|
||||||
|
fileURL,
|
||||||
|
to: url,
|
||||||
|
method: .post,
|
||||||
|
headers: finalHeaders,
|
||||||
|
interceptor: interceptor
|
||||||
|
)
|
||||||
|
.validate()
|
||||||
|
|
||||||
|
if let progressHandler = progressHandler {
|
||||||
|
request.uploadProgress { progress in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
progressHandler(progress.fractionCompleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let urlRequest = request.request {
|
||||||
|
logger.logRequest(urlRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = await request
|
||||||
|
.serializingDecodable(Empty.self, emptyResponseCodes: [200, 201, 204, 205])
|
||||||
|
.response
|
||||||
|
let duration = Date().timeIntervalSince(startTime)
|
||||||
|
|
||||||
|
switch response.result {
|
||||||
|
case .success:
|
||||||
|
if let httpResponse = response.response {
|
||||||
|
logger.logResponse(httpResponse, data: response.data, duration: duration)
|
||||||
|
}
|
||||||
|
case .failure(let error):
|
||||||
|
logger.logError(error, request: response.request, data: response.data, duration: duration)
|
||||||
|
throw mapError(error, response: response.response, data: response.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Request Management
|
||||||
|
|
||||||
|
func cancelRequest(id: String) {
|
||||||
|
requestLock.lock()
|
||||||
|
defer { requestLock.unlock() }
|
||||||
|
activeRequests[id]?.cancel()
|
||||||
|
activeRequests.removeValue(forKey: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelAllRequests() {
|
||||||
|
requestLock.lock()
|
||||||
|
defer { requestLock.unlock() }
|
||||||
|
activeRequests.values.forEach { $0.cancel() }
|
||||||
|
activeRequests.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func trackRequest(_ request: DataRequest, id: String) {
|
||||||
|
requestLock.lock()
|
||||||
|
defer { requestLock.unlock() }
|
||||||
|
activeRequests[id] = request
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeRequest(id: String) {
|
||||||
|
requestLock.lock()
|
||||||
|
defer { requestLock.unlock() }
|
||||||
|
activeRequests.removeValue(forKey: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cache Management
|
||||||
|
|
||||||
|
func clearImageCache() {
|
||||||
|
imageCache.removeAllObjects()
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearImageCache(forKey key: String) {
|
||||||
|
imageCache.removeObject(forKey: key as NSString)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Error Mapping
|
||||||
|
|
||||||
|
private func mapError(_ error: AFError, response: HTTPURLResponse?, data: Data?) -> Error {
|
||||||
|
if error.isSessionTaskError {
|
||||||
|
if let underlyingError = error.underlyingError as? URLError {
|
||||||
|
switch underlyingError.code {
|
||||||
|
case .timedOut:
|
||||||
|
return APIError.timeout
|
||||||
|
case .cancelled:
|
||||||
|
return APIError.cancelled
|
||||||
|
default:
|
||||||
|
return APIError.networkError(underlyingError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle serialization errors first — e.g. empty body on 200 OK
|
||||||
|
if error.isResponseSerializationError {
|
||||||
|
// If the server responded with a 2xx, don't treat this as a real error
|
||||||
|
if let statusCode = response?.statusCode, (200..<300).contains(statusCode) {
|
||||||
|
return APIError.cancelled // treat as no-op, caller discards this
|
||||||
|
}
|
||||||
|
return APIError.decodingFailed(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let statusCode = response?.statusCode {
|
||||||
|
switch statusCode {
|
||||||
|
case 200..<300:
|
||||||
|
// 2xx with unexpected shape — not a real error
|
||||||
|
return APIError.cancelled
|
||||||
|
case 401:
|
||||||
|
return APIError.unauthorized
|
||||||
|
case 403:
|
||||||
|
return APIError.forbidden
|
||||||
|
case 404:
|
||||||
|
return APIError.notFound
|
||||||
|
case 500...599:
|
||||||
|
return APIError.serverError(statusCode: statusCode)
|
||||||
|
default:
|
||||||
|
return ServerError(statusCode: statusCode, data: data, response: response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return APIError.unexpectedResponse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Event Monitor
|
||||||
|
|
||||||
|
private class APIEventMonitor: EventMonitor {
|
||||||
|
func requestDidResume(_ request: Request) {
|
||||||
|
// Track metrics if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
func request(_ request: Request, didCreateTask task: URLSessionTask) {
|
||||||
|
// Track metrics if needed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Date Formatter Extensions
|
||||||
|
|
||||||
|
private extension ISO8601DateFormatter {
|
||||||
|
static let internetDateTime: ISO8601DateFormatter = {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime]
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
static let internetDateTimeWithFractionalSeconds: ISO8601DateFormatter = {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension DateFormatter {
|
||||||
|
static let iso8601: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
static let iso8601Full: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
}
|
||||||
64
medmap/Network/AuthorizationInterceptor.swift
Normal file
64
medmap/Network/AuthorizationInterceptor.swift
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
// Copyright © 2025 VOID Software SA. All rights reserved.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Alamofire
|
||||||
|
|
||||||
|
/// Request interceptor for handling authentication and token refresh
|
||||||
|
class AuthorizationInterceptor: RequestInterceptor {
|
||||||
|
|
||||||
|
private var accessToken: String?
|
||||||
|
private let lock = NSLock()
|
||||||
|
|
||||||
|
func setAccessToken(_ token: String?) {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
accessToken = token
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAccessToken() -> String? {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return accessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - RequestAdapter
|
||||||
|
|
||||||
|
func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
|
||||||
|
var urlRequest = urlRequest
|
||||||
|
/*
|
||||||
|
if let token = KeychainManager.shared.getDeviceToken() {
|
||||||
|
urlRequest.setValue(token, forHTTPHeaderField: "Authorization")
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
|
||||||
|
completion(.success(urlRequest))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - RequestRetrier
|
||||||
|
|
||||||
|
func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) {
|
||||||
|
|
||||||
|
completion(.doNotRetry)
|
||||||
|
/*
|
||||||
|
|
||||||
|
guard let response = request.task?.response as? HTTPURLResponse else {
|
||||||
|
completion(.doNotRetry)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry on 401 (implement token refresh logic here)
|
||||||
|
if response.statusCode == 401 {
|
||||||
|
// TODO: Implement token refresh logic
|
||||||
|
// For now, just don't retry
|
||||||
|
completion(.doNotRetry)
|
||||||
|
} else if response.statusCode >= 500 {
|
||||||
|
// Retry server errors with delay
|
||||||
|
completion(.retryWithDelay(2.0))
|
||||||
|
} else {
|
||||||
|
completion(.doNotRetry)
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
}
|
||||||
86
medmap/Network/FluxReaderAPI.swift
Normal file
86
medmap/Network/FluxReaderAPI.swift
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
//
|
||||||
|
// FluxReaderAPI.swift
|
||||||
|
// FluxRead
|
||||||
|
//
|
||||||
|
// Created by RSA on 13/01/2026.
|
||||||
|
//
|
||||||
|
import Foundation
|
||||||
|
import Alamofire
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
|
||||||
|
class MedMapAPI {
|
||||||
|
private let apiClient: APIClient = DIContainer.shared.resolve()
|
||||||
|
private let baseURL: String
|
||||||
|
|
||||||
|
init(baseURL: String = "https://fluxreader.desynced.net") {
|
||||||
|
self.baseURL = baseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Auth
|
||||||
|
|
||||||
|
func login(email: String, password: String) async throws -> LoginResponse {
|
||||||
|
let url = "\(baseURL)/api/auth/login"
|
||||||
|
return try await apiClient.request(url, method: .post, parameters: LoginRequest(email: email, password: password))
|
||||||
|
}
|
||||||
|
|
||||||
|
func signup(email: String, password: String, role: Role) async throws -> LoginResponse {
|
||||||
|
let url = "\(baseURL)/api/auth/signup"
|
||||||
|
return try await apiClient.request(url, method: .post, parameters: SignupRequest(email: email, password: password, role: role))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Companies
|
||||||
|
|
||||||
|
func createCompany(request: CreateCompanyRequest) async throws -> CompanyDTO {
|
||||||
|
let url = "\(baseURL)/api/companies"
|
||||||
|
return try await apiClient.request(url, method: .post, parameters: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Professionals
|
||||||
|
|
||||||
|
func createProfessional(request: CreateProfessionalRequest) async throws -> ProfessionalResponse {
|
||||||
|
let url = "\(baseURL)/api/professionals"
|
||||||
|
return try await apiClient.request(url, method: .post, parameters: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listProfessionals(companyId: String) async throws -> [ProfessionalResponse] {
|
||||||
|
let url = "\(baseURL)/api/professionals/list"
|
||||||
|
return try await apiClient.request(url, method: .post, parameters: ListProfessionalsRequest(companyId: companyId))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Patients
|
||||||
|
|
||||||
|
func createPatient(request: CreatePatientRequest) async throws -> PatientDTO {
|
||||||
|
let url = "\(baseURL)/api/patients"
|
||||||
|
return try await apiClient.request(url, method: .post, parameters: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPatient(id: String) async throws -> PatientDTO {
|
||||||
|
let url = "\(baseURL)/api/patients/\(id)"
|
||||||
|
return try await apiClient.request(url, method: .get)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listPatients(professionalId: String) async throws -> PatientsByProfessionalResponse {
|
||||||
|
let url = "\(baseURL)/api/patients/list"
|
||||||
|
return try await apiClient.request(url, method: .post, parameters: ListPatientsRequest(professionalId: professionalId))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Prescriptions
|
||||||
|
|
||||||
|
func createPrescription(request: CreatePrescriptionRequest) async throws -> PrescriptionResponse {
|
||||||
|
let url = "\(baseURL)/api/prescriptions/create"
|
||||||
|
return try await apiClient.request(url, method: .post, parameters: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func changePrescriptionSchedule(request: ChangePrescriptionScheduleRequest) async throws -> PrescriptionScheduleResponse {
|
||||||
|
let url = "\(baseURL)/api/prescriptions/schedule"
|
||||||
|
return try await apiClient.request(url, method: .put, parameters: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listPrescriptions(patientId: String, activeOnly: Bool = false, scheduleFilter: ScheduleFilter = .activeOnly) async throws -> [PrescriptionWithSchedulesResponse] {
|
||||||
|
let url = "\(baseURL)/api/prescriptions/list"
|
||||||
|
let request = ListPrescriptionsRequest(patientId: patientId, activeOnly: activeOnly, scheduleFilter: scheduleFilter)
|
||||||
|
return try await apiClient.request(url, method: .post, parameters: request)
|
||||||
|
}
|
||||||
|
}
|
||||||
113
medmap/Network/NetworkLogger.swift
Normal file
113
medmap/Network/NetworkLogger.swift
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import Foundation
|
||||||
|
import Alamofire
|
||||||
|
|
||||||
|
/// Advanced logging system for network requests and responses
|
||||||
|
enum NetworkLogger {
|
||||||
|
|
||||||
|
enum LogLevel {
|
||||||
|
case none
|
||||||
|
case minimal
|
||||||
|
case verbose
|
||||||
|
case debug
|
||||||
|
}
|
||||||
|
|
||||||
|
static var logLevel: LogLevel = .debug
|
||||||
|
|
||||||
|
static func logRequest(_ request: URLRequest) {
|
||||||
|
guard logLevel != .none else { return }
|
||||||
|
|
||||||
|
var log = "\n🌐 ===== HTTP REQUEST ====="
|
||||||
|
log += "\n📤 Method: \(request.httpMethod ?? "UNKNOWN")"
|
||||||
|
log += "\n🔗 URL: \(request.url?.absoluteString ?? "N/A")"
|
||||||
|
|
||||||
|
if logLevel == .verbose || logLevel == .debug {
|
||||||
|
if let headers = request.allHTTPHeaderFields, !headers.isEmpty {
|
||||||
|
log += "\n📋 Headers:"
|
||||||
|
headers.forEach { log += "\n \($0.key): \($0.value)" }
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
if let body = request.httpBody {
|
||||||
|
if let jsonString = String(data: body, encoding: .utf8) {
|
||||||
|
log += "\n📦 Body: \(jsonString)"
|
||||||
|
} else {
|
||||||
|
log += "\n📦 Body: <binary data, \(body.count) bytes>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
log += "\n========================\n"
|
||||||
|
fluxlog(log)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func logResponse(_ response: HTTPURLResponse, data: Data?, duration: TimeInterval) {
|
||||||
|
guard logLevel != .none else { return }
|
||||||
|
|
||||||
|
let statusEmoji = getStatusEmoji(for: response.statusCode)
|
||||||
|
|
||||||
|
var log = "\n✅ ===== HTTP RESPONSE ====="
|
||||||
|
log += "\n\(statusEmoji) Status: \(response.statusCode)"
|
||||||
|
log += "\n🔗 URL: \(response.url?.absoluteString ?? "N/A")"
|
||||||
|
log += "\n⏱️ Duration: \(String(format: "%.3f", duration))s"
|
||||||
|
|
||||||
|
if logLevel == .verbose || logLevel == .debug {
|
||||||
|
if !response.allHeaderFields.isEmpty {
|
||||||
|
log += "\n📋 Headers:"
|
||||||
|
response.allHeaderFields.forEach { log += "\n \($0.key): \($0.value)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if let data = data {
|
||||||
|
if let jsonString = String(data: data, encoding: .utf8) {
|
||||||
|
log += "\n📦 Response Data: \(jsonString.prefix(500))"
|
||||||
|
if jsonString.count > 500 {
|
||||||
|
log += "... (truncated)"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log += "\n📦 Response Data: <binary data, \(data.count) bytes>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log += "\n=========================\n"
|
||||||
|
fluxlog(log)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func logError(_ error: AFError, request: URLRequest?, data: Data?, duration: TimeInterval) {
|
||||||
|
guard logLevel != .none else { return }
|
||||||
|
|
||||||
|
var log = "\n❌ ===== HTTP ERROR ====="
|
||||||
|
log += "\n🔗 URL: \(request?.url?.absoluteString ?? "N/A")"
|
||||||
|
log += "\n⚠️ Error: \(error.localizedDescription)"
|
||||||
|
log += "\n⏱️ Duration: \(String(format: "%.3f", duration))s"
|
||||||
|
|
||||||
|
if let statusCode = error.responseCode {
|
||||||
|
log += "\n🔢 Status Code: \(statusCode)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ALWAYS log error response JSON (regardless of log level, without truncation)
|
||||||
|
if let data = data, let errorString = String(data: data, encoding: .utf8) {
|
||||||
|
log += "\n📦 Error Response JSON:"
|
||||||
|
log += "\n\(errorString)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log underlying error in verbose/debug mode
|
||||||
|
if logLevel == .verbose || logLevel == .debug {
|
||||||
|
if case .sessionTaskFailed(let underlyingError) = error {
|
||||||
|
log += "\n🐛 Underlying Error: \(underlyingError.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log += "\n======================\n"
|
||||||
|
fluxlog(log)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func getStatusEmoji(for statusCode: Int) -> String {
|
||||||
|
switch statusCode {
|
||||||
|
case 200...299: return "✅"
|
||||||
|
case 300...399: return "🔄"
|
||||||
|
case 400...499: return "⚠️"
|
||||||
|
case 500...599: return "🔥"
|
||||||
|
default: return "❓"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
340
medmap/Utilities/KeyPairManager.swift
Normal file
340
medmap/Utilities/KeyPairManager.swift
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
//
|
||||||
|
// KeyPairManager.swift
|
||||||
|
// FluxRead
|
||||||
|
//
|
||||||
|
// Created by RSA on 11/03/2026.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
// MARK: - KeyPairManager
|
||||||
|
/// Manages generation and secure storage of RSA / EC key pairs using the iOS Secure Enclave & Keychain.
|
||||||
|
final class KeyPairManager {
|
||||||
|
|
||||||
|
// MARK: - Configuration
|
||||||
|
|
||||||
|
enum KeyAlgorithm {
|
||||||
|
case rsa(bits: Int) // e.g. .rsa(bits: 2048)
|
||||||
|
case ecSecureEnclave // P-256 backed by Secure Enclave (recommended)
|
||||||
|
case ecSoftware // P-256 in software keychain (simulator / older devices)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct KeyPairConfig {
|
||||||
|
let tag: String // Unique reverse-DNS tag e.g. "com.myapp.userkey"
|
||||||
|
let algorithm: KeyAlgorithm
|
||||||
|
let requiresBiometrics: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Errors
|
||||||
|
|
||||||
|
enum KeyPairError: LocalizedError {
|
||||||
|
case generationFailed(OSStatus)
|
||||||
|
case publicKeyExportFailed(OSStatus)
|
||||||
|
case keyNotFound
|
||||||
|
case deletionFailed(OSStatus)
|
||||||
|
case unsupportedAlgorithm
|
||||||
|
case secureEnclaveUnavailable
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .generationFailed(let s): return "Key generation failed: \(s)"
|
||||||
|
case .publicKeyExportFailed(let s): return "Public key export failed: \(s)"
|
||||||
|
case .keyNotFound: return "Key not found in Keychain"
|
||||||
|
case .deletionFailed(let s): return "Key deletion failed: \(s)"
|
||||||
|
case .unsupportedAlgorithm: return "Algorithm not supported on this device"
|
||||||
|
case .secureEnclaveUnavailable: return "Secure Enclave is not available"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
/// Generate a key pair and store the **private key** securely in the Keychain / Secure Enclave.
|
||||||
|
/// - Returns: The public key as a DER-encoded `Data` blob ready to be sent to a server.
|
||||||
|
@discardableResult
|
||||||
|
func generateAndStoreKeyPair(config: KeyPairConfig) throws -> Data {
|
||||||
|
// Delete any existing key with the same tag first
|
||||||
|
try? deleteKeyPair(tag: config.tag)
|
||||||
|
|
||||||
|
let privateKey: SecKey
|
||||||
|
|
||||||
|
switch config.algorithm {
|
||||||
|
case .ecSecureEnclave:
|
||||||
|
privateKey = try generateSecureEnclaveKey(config: config)
|
||||||
|
case .ecSoftware:
|
||||||
|
privateKey = try generateECKey(config: config)
|
||||||
|
case .rsa(let bits):
|
||||||
|
privateKey = try generateRSAKey(bits: bits, config: config)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try exportPublicKey(from: privateKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve the stored **private key** from the Keychain.
|
||||||
|
func retrievePrivateKey(tag: String) throws -> SecKey {
|
||||||
|
let query = baseKeychainQuery(tag: tag)
|
||||||
|
var item: CFTypeRef?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||||
|
|
||||||
|
guard status == errSecSuccess, let key = item as! SecKey? else {
|
||||||
|
throw KeyPairError.keyNotFound
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export the **public key** (DER-encoded) for the stored key pair.
|
||||||
|
func exportPublicKeyData(tag: String) throws -> Data {
|
||||||
|
let privateKey = try retrievePrivateKey(tag: tag)
|
||||||
|
return try exportPublicKey(from: privateKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export the public key as a Base64-encoded PEM string.
|
||||||
|
func exportPublicKeyPEM(tag: String) throws -> String {
|
||||||
|
let der = try exportPublicKeyData(tag: tag)
|
||||||
|
let b64 = der.base64EncodedString(options: [.lineLength64Characters])
|
||||||
|
return "-----BEGIN PUBLIC KEY-----\n\(b64)\n-----END PUBLIC KEY-----"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete both keys associated with the given tag.
|
||||||
|
func deleteKeyPair(tag: String) throws {
|
||||||
|
let query = baseKeychainQuery(tag: tag)
|
||||||
|
let status = SecItemDelete(query as CFDictionary)
|
||||||
|
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||||
|
throw KeyPairError.deletionFailed(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private: Key Generation
|
||||||
|
|
||||||
|
private func generateSecureEnclaveKey(config: KeyPairConfig) throws -> SecKey {
|
||||||
|
guard SecureEnclave.isAvailable else {
|
||||||
|
throw KeyPairError.secureEnclaveUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
var accessError: Unmanaged<CFError>?
|
||||||
|
let flags: SecAccessControlCreateFlags = config.requiresBiometrics
|
||||||
|
? [.privateKeyUsage, .biometryCurrentSet]
|
||||||
|
: [.privateKeyUsage]
|
||||||
|
|
||||||
|
guard let access = SecAccessControlCreateWithFlags(
|
||||||
|
kCFAllocatorDefault,
|
||||||
|
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
||||||
|
flags,
|
||||||
|
&accessError
|
||||||
|
) else {
|
||||||
|
throw accessError!.takeRetainedValue() as Error
|
||||||
|
}
|
||||||
|
|
||||||
|
let attributes: [String: Any] = [
|
||||||
|
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
|
||||||
|
kSecAttrKeySizeInBits as String: 256,
|
||||||
|
kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
|
||||||
|
kSecPrivateKeyAttrs as String: [
|
||||||
|
kSecAttrIsPermanent as String: true,
|
||||||
|
kSecAttrApplicationTag as String: tagData(config.tag),
|
||||||
|
kSecAttrAccessControl as String: access
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
var error: Unmanaged<CFError>?
|
||||||
|
guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
|
||||||
|
throw error!.takeRetainedValue() as Error
|
||||||
|
}
|
||||||
|
return privateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
private func generateECKey(config: KeyPairConfig) throws -> SecKey {
|
||||||
|
var accessError: Unmanaged<CFError>?
|
||||||
|
let flags: SecAccessControlCreateFlags = config.requiresBiometrics
|
||||||
|
? [.biometryCurrentSet]
|
||||||
|
: []
|
||||||
|
|
||||||
|
guard let access = SecAccessControlCreateWithFlags(
|
||||||
|
kCFAllocatorDefault,
|
||||||
|
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
||||||
|
flags,
|
||||||
|
&accessError
|
||||||
|
) else {
|
||||||
|
throw accessError!.takeRetainedValue() as Error
|
||||||
|
}
|
||||||
|
|
||||||
|
let attributes: [String: Any] = [
|
||||||
|
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
|
||||||
|
kSecAttrKeySizeInBits as String: 256,
|
||||||
|
kSecPrivateKeyAttrs as String: [
|
||||||
|
kSecAttrIsPermanent as String: true,
|
||||||
|
kSecAttrApplicationTag as String: tagData(config.tag),
|
||||||
|
kSecAttrAccessControl as String: access
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
var error: Unmanaged<CFError>?
|
||||||
|
guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
|
||||||
|
throw error!.takeRetainedValue() as Error
|
||||||
|
}
|
||||||
|
return privateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
private func generateRSAKey(bits: Int, config: KeyPairConfig) throws -> SecKey {
|
||||||
|
var accessError: Unmanaged<CFError>?
|
||||||
|
let flags: SecAccessControlCreateFlags = config.requiresBiometrics
|
||||||
|
? [.biometryCurrentSet]
|
||||||
|
: []
|
||||||
|
|
||||||
|
guard let access = SecAccessControlCreateWithFlags(
|
||||||
|
kCFAllocatorDefault,
|
||||||
|
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
||||||
|
flags,
|
||||||
|
&accessError
|
||||||
|
) else {
|
||||||
|
throw accessError!.takeRetainedValue() as Error
|
||||||
|
}
|
||||||
|
|
||||||
|
let attributes: [String: Any] = [
|
||||||
|
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
|
||||||
|
kSecAttrKeySizeInBits as String: bits,
|
||||||
|
kSecPrivateKeyAttrs as String: [
|
||||||
|
kSecAttrIsPermanent as String: true,
|
||||||
|
kSecAttrApplicationTag as String: tagData(config.tag),
|
||||||
|
kSecAttrAccessControl as String: access
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
var error: Unmanaged<CFError>?
|
||||||
|
guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
|
||||||
|
throw error!.takeRetainedValue() as Error
|
||||||
|
}
|
||||||
|
return privateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private: Export
|
||||||
|
|
||||||
|
private func exportPublicKey(from privateKey: SecKey) throws -> Data {
|
||||||
|
guard let publicKey = SecKeyCopyPublicKey(privateKey) else {
|
||||||
|
throw KeyPairError.publicKeyExportFailed(errSecParam)
|
||||||
|
}
|
||||||
|
var error: Unmanaged<CFError>?
|
||||||
|
guard let data = SecKeyCopyExternalRepresentation(publicKey, &error) as Data? else {
|
||||||
|
throw error!.takeRetainedValue() as Error
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private: Helpers
|
||||||
|
|
||||||
|
private func tagData(_ tag: String) -> Data {
|
||||||
|
tag.data(using: .utf8)!
|
||||||
|
}
|
||||||
|
|
||||||
|
private func baseKeychainQuery(tag: String) -> [String: Any] {
|
||||||
|
[
|
||||||
|
kSecClass as String: kSecClassKey,
|
||||||
|
kSecAttrKeyClass as String: kSecAttrKeyClassPrivate,
|
||||||
|
kSecAttrApplicationTag as String: tagData(tag),
|
||||||
|
kSecReturnRef as String: true
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Convenience: Sign & Verify
|
||||||
|
|
||||||
|
extension KeyPairManager {
|
||||||
|
|
||||||
|
/// Sign arbitrary data with the stored private key.
|
||||||
|
func sign(data: Data, tag: String) throws -> Data {
|
||||||
|
let privateKey = try retrievePrivateKey(tag: tag)
|
||||||
|
let algorithm = SecKeyAlgorithm.ecdsaSignatureMessageX962SHA256
|
||||||
|
|
||||||
|
guard SecKeyIsAlgorithmSupported(privateKey, .sign, algorithm) else {
|
||||||
|
throw KeyPairError.unsupportedAlgorithm
|
||||||
|
}
|
||||||
|
|
||||||
|
var error: Unmanaged<CFError>?
|
||||||
|
guard let signature = SecKeyCreateSignature(
|
||||||
|
privateKey,
|
||||||
|
algorithm,
|
||||||
|
data as CFData,
|
||||||
|
&error
|
||||||
|
) as Data? else {
|
||||||
|
throw error!.takeRetainedValue() as Error
|
||||||
|
}
|
||||||
|
return signature
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a signature using the public key derived from the stored private key.
|
||||||
|
func verify(data: Data, signature: Data, tag: String) throws -> Bool {
|
||||||
|
let privateKey = try retrievePrivateKey(tag: tag)
|
||||||
|
guard let publicKey = SecKeyCopyPublicKey(privateKey) else {
|
||||||
|
throw KeyPairError.keyNotFound
|
||||||
|
}
|
||||||
|
let algorithm = SecKeyAlgorithm.ecdsaSignatureMessageX962SHA256
|
||||||
|
|
||||||
|
var error: Unmanaged<CFError>?
|
||||||
|
let valid = SecKeyVerifySignature(
|
||||||
|
publicKey,
|
||||||
|
algorithm,
|
||||||
|
data as CFData,
|
||||||
|
signature as CFData,
|
||||||
|
&error
|
||||||
|
)
|
||||||
|
return valid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Demo / Usage
|
||||||
|
|
||||||
|
/// Call this from your AppDelegate, a ViewModel, or a SwiftUI .task modifier.
|
||||||
|
func runKeyPairDemo() {
|
||||||
|
let manager = KeyPairManager()
|
||||||
|
|
||||||
|
// --- 1. Secure Enclave (recommended on real devices) ---
|
||||||
|
let seConfig = KeyPairManager.KeyPairConfig(
|
||||||
|
tag: "com.myapp.userkey.se",
|
||||||
|
algorithm: .ecSecureEnclave,
|
||||||
|
requiresBiometrics: false
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- 2. EC in software keychain (works on simulator too) ---
|
||||||
|
let ecConfig = KeyPairManager.KeyPairConfig(
|
||||||
|
tag: "com.myapp.userkey.ec",
|
||||||
|
algorithm: .ecSoftware,
|
||||||
|
requiresBiometrics: false
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- 3. RSA 2048 ---
|
||||||
|
let rsaConfig = KeyPairManager.KeyPairConfig(
|
||||||
|
tag: "com.myapp.userkey.rsa",
|
||||||
|
algorithm: .rsa(bits: 2048),
|
||||||
|
requiresBiometrics: false
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pick the appropriate config:
|
||||||
|
let config = SecureEnclave.isAvailable ? seConfig : ecConfig
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Generate & store
|
||||||
|
let publicKeyData = try manager.generateAndStoreKeyPair(config: config)
|
||||||
|
print("✅ Key pair generated. Public key (\(publicKeyData.count) bytes).")
|
||||||
|
|
||||||
|
// Export PEM for upload to server
|
||||||
|
let pem = try manager.exportPublicKeyPEM(tag: config.tag)
|
||||||
|
print("📤 Public Key PEM:\n\(pem)")
|
||||||
|
|
||||||
|
// Sign something
|
||||||
|
let payload = "Hello, secure world!".data(using: .utf8)!
|
||||||
|
let signature = try manager.sign(data: payload, tag: config.tag)
|
||||||
|
print("✍️ Signature (\(signature.count) bytes): \(signature.base64EncodedString())")
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
let valid = try manager.verify(data: payload, signature: signature, tag: config.tag)
|
||||||
|
print("🔍 Signature valid: \(valid)")
|
||||||
|
|
||||||
|
// Clean up (optional)
|
||||||
|
// try manager.deleteKeyPair(tag: config.tag)
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
print("❌ Error: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
126
medmap/Utilities/KeychainManager.swift
Normal file
126
medmap/Utilities/KeychainManager.swift
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
//
|
||||||
|
// KeychainManager.swift
|
||||||
|
// FluxRead
|
||||||
|
//
|
||||||
|
// Created by RSA on 13/01/2026.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
class KeychainManager {
|
||||||
|
static let shared = KeychainManager()
|
||||||
|
|
||||||
|
private let service = "com.fluxread.devicetoken"
|
||||||
|
private let account = "devUID"
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
/// Save device token to Keychain
|
||||||
|
func saveDeviceToken(_ token: String) throws {
|
||||||
|
guard let data = token.data(using: .utf8) else {
|
||||||
|
throw APIError.custom("Failed to encode token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete existing token first
|
||||||
|
try? deleteDeviceToken()
|
||||||
|
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: account,
|
||||||
|
kSecValueData as String: data,
|
||||||
|
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
|
||||||
|
]
|
||||||
|
|
||||||
|
let status = SecItemAdd(query as CFDictionary, nil)
|
||||||
|
|
||||||
|
guard status == errSecSuccess else {
|
||||||
|
throw APIError.custom("Failed to save token to Keychain: \(status)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve device token from Keychain
|
||||||
|
func getDeviceToken() -> String? {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: account,
|
||||||
|
kSecReturnData as String: true
|
||||||
|
]
|
||||||
|
|
||||||
|
var result: AnyObject?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
|
||||||
|
guard status == errSecSuccess,
|
||||||
|
let data = result as? Data,
|
||||||
|
let token = String(data: data, encoding: .utf8) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete device token from Keychain
|
||||||
|
func deleteDeviceToken() throws {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: account
|
||||||
|
]
|
||||||
|
|
||||||
|
let status = SecItemDelete(query as CFDictionary)
|
||||||
|
|
||||||
|
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||||
|
throw APIError.custom("Failed to delete token from Keychain: \(status)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - APNs Push Token
|
||||||
|
|
||||||
|
private let pushTokenAccount = "apnsPushToken"
|
||||||
|
|
||||||
|
/// Persist the hex-encoded APNs push token
|
||||||
|
func savePushToken(_ hexToken: String) throws {
|
||||||
|
guard let data = hexToken.data(using: .utf8) else {
|
||||||
|
throw APIError.custom("Failed to encode push token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert: delete then add
|
||||||
|
let deleteQuery: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: pushTokenAccount
|
||||||
|
]
|
||||||
|
SecItemDelete(deleteQuery as CFDictionary)
|
||||||
|
|
||||||
|
let addQuery: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: pushTokenAccount,
|
||||||
|
kSecValueData as String: data,
|
||||||
|
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
|
||||||
|
]
|
||||||
|
|
||||||
|
let status = SecItemAdd(addQuery as CFDictionary, nil)
|
||||||
|
guard status == errSecSuccess else {
|
||||||
|
throw APIError.custom("Failed to save push token: \(status)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve the previously stored APNs push token, or nil if not set
|
||||||
|
func getPushToken() -> String? {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: pushTokenAccount,
|
||||||
|
kSecReturnData as String: true
|
||||||
|
]
|
||||||
|
var result: AnyObject?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
guard status == errSecSuccess,
|
||||||
|
let data = result as? Data,
|
||||||
|
let token = String(data: data, encoding: .utf8) else { return nil }
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
}
|
||||||
20
medmap/Utilities/Log.swift
Normal file
20
medmap/Utilities/Log.swift
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// App-wide replacement for `print()`.
|
||||||
|
/// Drop-in: same call site syntax — `log("Something happened", value, separator: " | ")`
|
||||||
|
/// In Release builds all output is suppressed automatically.
|
||||||
|
func fluxlog(
|
||||||
|
_ items: Any...,
|
||||||
|
separator: String = " ",
|
||||||
|
terminator: String = "\n",
|
||||||
|
file: String = #file,
|
||||||
|
function: String = #function,
|
||||||
|
line: Int = #line
|
||||||
|
) {
|
||||||
|
#if DEBUG
|
||||||
|
let fileName = (file as NSString).lastPathComponent.replacingOccurrences(of: ".swift", with: "")
|
||||||
|
let prefix = "[\(fileName):\(line) \(function)]"
|
||||||
|
let message = items.map { "\($0)" }.joined(separator: separator)
|
||||||
|
Swift.print("\(prefix) \(message)", terminator: terminator)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
124
medmap/Utilities/NetworkMonitor.swift
Normal file
124
medmap/Utilities/NetworkMonitor.swift
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
//
|
||||||
|
// NetworkMonitor.swift
|
||||||
|
// FluxRead
|
||||||
|
//
|
||||||
|
// Created by RSA on 29/03/2026.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// MARK: - Connection Type
|
||||||
|
|
||||||
|
enum NetworkConnectionType {
|
||||||
|
case wifi
|
||||||
|
case cellular
|
||||||
|
case ethernet
|
||||||
|
case none
|
||||||
|
case unknown
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .wifi: return "Wi-Fi"
|
||||||
|
case .cellular: return "Cellular"
|
||||||
|
case .ethernet: return "Ethernet"
|
||||||
|
case .none: return "No Connection"
|
||||||
|
case .unknown: return "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True for any connection that is generally considered unmetered / fast.
|
||||||
|
var isWifi: Bool { self == .wifi || self == .ethernet }
|
||||||
|
|
||||||
|
/// True only when the device is on a metered cellular connection.
|
||||||
|
var isCellular: Bool { self == .cellular }
|
||||||
|
|
||||||
|
/// True when any network path is available.
|
||||||
|
var isConnected: Bool { self != .none }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - NetworkMonitor
|
||||||
|
|
||||||
|
/// Observes the device's active network path and exposes the current
|
||||||
|
/// connection type as both a `@Published` property (for SwiftUI / Combine)
|
||||||
|
/// and a synchronous getter.
|
||||||
|
///
|
||||||
|
/// Usage:
|
||||||
|
/// ```swift
|
||||||
|
/// // Singleton
|
||||||
|
/// let type = NetworkMonitor.shared.connectionType
|
||||||
|
///
|
||||||
|
/// // Injected instance (via DI)
|
||||||
|
/// monitor.$connectionType
|
||||||
|
/// .sink { print($0.description) }
|
||||||
|
/// .store(in: &cancellables)
|
||||||
|
/// ```
|
||||||
|
final class NetworkMonitor: ObservableObject {
|
||||||
|
|
||||||
|
// MARK: Singleton
|
||||||
|
|
||||||
|
static let shared = NetworkMonitor()
|
||||||
|
|
||||||
|
// MARK: Published State
|
||||||
|
|
||||||
|
/// The current network connection type. Updated on the main queue.
|
||||||
|
@Published private(set) var connectionType: NetworkConnectionType = .unknown
|
||||||
|
|
||||||
|
/// Convenience: `true` when any usable network path exists.
|
||||||
|
@Published private(set) var isConnected: Bool = false
|
||||||
|
|
||||||
|
// MARK: Private
|
||||||
|
|
||||||
|
private let monitor: NWPathMonitor
|
||||||
|
private let monitorQueue = DispatchQueue(label: "com.fluxread.networkmonitor", qos: .utility)
|
||||||
|
|
||||||
|
// MARK: Init
|
||||||
|
|
||||||
|
init() {
|
||||||
|
monitor = NWPathMonitor()
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
monitor.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Public API
|
||||||
|
|
||||||
|
/// Synchronously returns the current connection type based on the
|
||||||
|
/// latest path snapshot. Always safe to call from any thread.
|
||||||
|
var currentConnectionType: NetworkConnectionType {
|
||||||
|
return connectionType
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` when the active interface matches a given type.
|
||||||
|
func isUsing(_ interface: NWInterface.InterfaceType) -> Bool {
|
||||||
|
monitor.currentPath.usesInterfaceType(interface)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Private
|
||||||
|
|
||||||
|
private func start() {
|
||||||
|
monitor.pathUpdateHandler = { [weak self] path in
|
||||||
|
guard let self else { return }
|
||||||
|
let type = Self.resolve(path: path)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.connectionType = type
|
||||||
|
self.isConnected = path.status == .satisfied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
monitor.start(queue: monitorQueue)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps an `NWPath` to a `NetworkConnectionType`.
|
||||||
|
private static func resolve(path: NWPath) -> NetworkConnectionType {
|
||||||
|
guard path.status == .satisfied else { return .none }
|
||||||
|
|
||||||
|
if path.usesInterfaceType(.wifi) { return .wifi }
|
||||||
|
if path.usesInterfaceType(.cellular) { return .cellular }
|
||||||
|
if path.usesInterfaceType(.wiredEthernet) { return .ethernet }
|
||||||
|
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
43
medmap/Utilities/SerializationUtils.swift
Normal file
43
medmap/Utilities/SerializationUtils.swift
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
//
|
||||||
|
// SerializationUtils.swift
|
||||||
|
// FluxRead
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class SerializationUtils {
|
||||||
|
|
||||||
|
enum SerializationError: Error, LocalizedError {
|
||||||
|
case invalidBase64String
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidBase64String:
|
||||||
|
return "Provided string is not valid Base64."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func toBinaryPlist<T: Encodable>(_ value: T) throws -> Data {
|
||||||
|
let encoder = PropertyListEncoder()
|
||||||
|
encoder.outputFormat = .binary
|
||||||
|
return try encoder.encode(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func toBase64BinaryPlist<T: Encodable>(_ value: T) throws -> String {
|
||||||
|
let data = try toBinaryPlist(value)
|
||||||
|
return data.base64EncodedString()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fromBinaryPlist<T: Decodable>(_ data: Data, _ type: T.Type) throws -> T {
|
||||||
|
let decoder = PropertyListDecoder()
|
||||||
|
return try decoder.decode(type, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fromBase64Plist<T: Decodable>(_ base64: String, _ type: T.Type) throws -> T {
|
||||||
|
guard let data = Data(base64Encoded: base64) else {
|
||||||
|
throw SerializationError.invalidBase64String
|
||||||
|
}
|
||||||
|
return try fromBinaryPlist(data, type)
|
||||||
|
}
|
||||||
|
}
|
||||||
69
medmap/ViewModels/SplashScreenViewModel.swift
Normal file
69
medmap/ViewModels/SplashScreenViewModel.swift
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
//
|
||||||
|
// SplashScreenViewModel.swift
|
||||||
|
// FluxRead
|
||||||
|
//
|
||||||
|
// Created by RSA on 29/11/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import UIKit
|
||||||
|
class SplashScreenViewModel: ViewModel {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
private func showNextImageIfNeeded() {
|
||||||
|
guard !isShowingImages, !imageQueue.isEmpty else { return }
|
||||||
|
|
||||||
|
isShowingImages = true
|
||||||
|
let image = imageQueue.removeFirst()
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.currentImage = image
|
||||||
|
|
||||||
|
self.currentLoadingProgress = Double(self.loadedComics) / Double(self.numberOfComics)
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||||
|
self.isShowingImages = false
|
||||||
|
self.showNextImageIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
/// Lists all files in the cache directory whose names start with "fc"
|
||||||
|
func listFCFiles() -> [URL] {
|
||||||
|
let cacheDir = fileSystemManager.cacheDirectory
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
|
||||||
|
guard let contents = try? fileManager.contentsOfDirectory(
|
||||||
|
at: cacheDir,
|
||||||
|
includingPropertiesForKeys: [.isRegularFileKey],
|
||||||
|
options: [.skipsHiddenFiles]
|
||||||
|
) else {
|
||||||
|
print("⚠️ Could not read cache directory: \(cacheDir.path)")
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
let fcFiles = contents.filter { url in
|
||||||
|
url.lastPathComponent.lowercased().hasPrefix("fx")
|
||||||
|
}
|
||||||
|
|
||||||
|
print("📂 Found \(fcFiles.count) file(s) starting with 'fc' in cache:")
|
||||||
|
fcFiles.forEach { print(" • \($0.lastPathComponent)") }
|
||||||
|
|
||||||
|
return fcFiles
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
30
medmap/ViewModels/ViewModel.swift
Normal file
30
medmap/ViewModels/ViewModel.swift
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
//
|
||||||
|
// ViewModel.swift
|
||||||
|
// FluxRead
|
||||||
|
//
|
||||||
|
// Created by RSA on 29/11/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
class ViewModel: ObservableObject {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@Published var isLoading: Bool = false
|
||||||
|
|
||||||
|
var cancellables = Set<AnyCancellable>()
|
||||||
|
init () {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
169
medmap/Views/SplashScreenView.swift
Normal file
169
medmap/Views/SplashScreenView.swift
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
//
|
||||||
|
// SplashScreenView.swift
|
||||||
|
// FluxRead
|
||||||
|
//
|
||||||
|
// Created by RSA on 29/11/2025.
|
||||||
|
//
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
struct SplashScreenView: View {
|
||||||
|
|
||||||
|
@ObservedObject var viewModel: SplashScreenViewModel
|
||||||
|
|
||||||
|
@State private var glowPulse = false
|
||||||
|
@State private var logoScale = 0.85
|
||||||
|
@State private var logoOpacity = 0.0
|
||||||
|
@State private var ringRotate = 0.0
|
||||||
|
@State private var ringOpacity = 0.0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
|
||||||
|
// ── Deep black base ──────────────────────────────────────────
|
||||||
|
Color.black.ignoresSafeArea()
|
||||||
|
|
||||||
|
// ── Bottom-half warm particle field ──────────────────────────
|
||||||
|
SplashParticleField()
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.opacity(0.90)
|
||||||
|
|
||||||
|
// ── Radial glow anchored to centre top ───────────────────────
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
RadialGradient(
|
||||||
|
colors: [
|
||||||
|
Color.orange.opacity(glowPulse ? 0.22 : 0.10),
|
||||||
|
Color.yellow.opacity(glowPulse ? 0.10 : 0.04),
|
||||||
|
Color.clear
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: 0,
|
||||||
|
endRadius: 340
|
||||||
|
)
|
||||||
|
.frame(width: 680, height: 680)
|
||||||
|
.blur(radius: 48)
|
||||||
|
.scaleEffect(glowPulse ? 1.12 : 1.0)
|
||||||
|
.animation(
|
||||||
|
.easeInOut(duration: 3.8).repeatForever(autoreverses: true),
|
||||||
|
value: glowPulse
|
||||||
|
)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
// ── Orbiting arc ring around logo ────────────────────────────
|
||||||
|
ZStack {
|
||||||
|
// slow outer arc
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0.0, to: 0.28)
|
||||||
|
.stroke(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.orange.opacity(0.45), Color.clear],
|
||||||
|
startPoint: .leading, endPoint: .trailing
|
||||||
|
),
|
||||||
|
style: StrokeStyle(lineWidth: 1.0, lineCap: .round)
|
||||||
|
)
|
||||||
|
.frame(width: 210, height: 210)
|
||||||
|
.rotationEffect(.degrees(ringRotate))
|
||||||
|
.opacity(ringOpacity)
|
||||||
|
|
||||||
|
// faster inner arc
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0.55, to: 0.72)
|
||||||
|
.stroke(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.yellow.opacity(0.30), Color.clear],
|
||||||
|
startPoint: .leading, endPoint: .trailing
|
||||||
|
),
|
||||||
|
style: StrokeStyle(lineWidth: 0.6, lineCap: .round)
|
||||||
|
)
|
||||||
|
.frame(width: 175, height: 175)
|
||||||
|
.rotationEffect(.degrees(-ringRotate * 1.4))
|
||||||
|
.opacity(ringOpacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Logo centred ─────────────────────────────────────────────
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.onAppear {
|
||||||
|
// Logo entrance
|
||||||
|
withAnimation(.spring(response: 0.75, dampingFraction: 0.72).delay(0.1)) {
|
||||||
|
logoScale = 1.0
|
||||||
|
logoOpacity = 1.0
|
||||||
|
}
|
||||||
|
// Glow breath
|
||||||
|
glowPulse = true
|
||||||
|
|
||||||
|
// Ring fade-in then continuous rotation
|
||||||
|
withAnimation(.easeIn(duration: 0.8).delay(0.4)) {
|
||||||
|
ringOpacity = 1.0
|
||||||
|
}
|
||||||
|
withAnimation(.linear(duration: 12).repeatForever(autoreverses: false).delay(0.4)) {
|
||||||
|
ringRotate = 360
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Splash Particle Field
|
||||||
|
// Warm, slow-drifting particles — denser than the reader version, tinted orange/gold
|
||||||
|
|
||||||
|
struct SplashParticleField: View {
|
||||||
|
|
||||||
|
@State private var tick: Double = 0
|
||||||
|
private let timer = Timer.publish(every: 1.0 / 60.0, on: .main, in: .common).autoconnect()
|
||||||
|
|
||||||
|
private struct Particle {
|
||||||
|
let x, y, speed, phase: Double
|
||||||
|
let radius: CGFloat
|
||||||
|
let opacity: Double
|
||||||
|
let warm: Bool // warm (orange/gold) vs cool (white)
|
||||||
|
}
|
||||||
|
|
||||||
|
private let particles: [Particle] = (0..<120).map { i in
|
||||||
|
let seed = Double(i) * 1.618033
|
||||||
|
return Particle(
|
||||||
|
x: (seed * 0.7369).truncatingRemainder(dividingBy: 1.0),
|
||||||
|
y: (seed * 1.2345).truncatingRemainder(dividingBy: 1.0),
|
||||||
|
speed: Double.random(in: 8...28),
|
||||||
|
phase: Double.random(in: 0...Double.pi * 2),
|
||||||
|
radius: CGFloat(Double.random(in: 0.7...2.8)),
|
||||||
|
opacity: Double.random(in: 0.04...0.28),
|
||||||
|
warm: i % 3 != 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Canvas { ctx, size in
|
||||||
|
let t = tick
|
||||||
|
for p in particles {
|
||||||
|
let x = (p.x + t / p.speed).truncatingRemainder(dividingBy: 1.0) * size.width
|
||||||
|
let drift = sin(t / (p.speed * 0.6) + p.phase) * 22
|
||||||
|
let y = p.y * size.height + CGFloat(drift)
|
||||||
|
let pulse = 0.65 + 0.35 * sin(t / 2.5 + p.phase)
|
||||||
|
let alpha = p.opacity * pulse
|
||||||
|
let color: Color = p.warm
|
||||||
|
? Color(red: 1.0, green: 0.62, blue: 0.18).opacity(alpha)
|
||||||
|
: Color.white.opacity(alpha * 0.55)
|
||||||
|
|
||||||
|
ctx.fill(
|
||||||
|
Path(ellipseIn: CGRect(
|
||||||
|
x: x - p.radius, y: y - p.radius,
|
||||||
|
width: p.radius * 2, height: p.radius * 2
|
||||||
|
)),
|
||||||
|
with: .color(color)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onReceive(timer) { _ in tick += 1.0 / 60.0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,6 +10,11 @@ import SwiftData
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
struct medmapApp: App {
|
struct medmapApp: App {
|
||||||
|
|
||||||
|
|
||||||
|
@StateObject var appStateManager: AppStateManager = DIContainer.shared.resolve()
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
var sharedModelContainer: ModelContainer = {
|
var sharedModelContainer: ModelContainer = {
|
||||||
let schema = Schema([
|
let schema = Schema([
|
||||||
Item.self,
|
Item.self,
|
||||||
@ -25,8 +30,67 @@ struct medmapApp: App {
|
|||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
createNavigationStack(
|
||||||
|
SplashScreenView(viewModel: SplashScreenViewModel())
|
||||||
|
.environmentObject(appStateManager))
|
||||||
|
}
|
||||||
|
.onChange(of: scenePhase) { oldPhase, newPhase in
|
||||||
|
handleScenePhaseChange(from: oldPhase, to: newPhase)
|
||||||
}
|
}
|
||||||
.modelContainer(sharedModelContainer)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private func handleScenePhaseChange(from oldPhase: ScenePhase, to newPhase: ScenePhase) {
|
||||||
|
switch newPhase {
|
||||||
|
case .active:
|
||||||
|
print("🟢 App became active")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
case .inactive:
|
||||||
|
print("🟡 App became inactive")
|
||||||
|
// App is transitioning (e.g., notification center pulled down)
|
||||||
|
|
||||||
|
case .background:
|
||||||
|
print("🔴 App entered background")
|
||||||
|
handleAppEnteredBackground()
|
||||||
|
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleAppEnteredBackground() {
|
||||||
|
// Runs when app enters background
|
||||||
|
// Example: Save state, pause operations
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
func createNavigationStack<V: View>(_ view: V) -> some View {
|
||||||
|
|
||||||
|
return AnyView(NavigationStack(path: $appStateManager.path) {
|
||||||
|
|
||||||
|
view
|
||||||
|
.navigationDestination(for: AppState.self) { destination in
|
||||||
|
|
||||||
|
switch destination {
|
||||||
|
|
||||||
|
case .splashScreen:
|
||||||
|
|
||||||
|
SplashScreenView(viewModel: SplashScreenViewModel())
|
||||||
|
|
||||||
|
.navigationBarHidden(true)
|
||||||
|
|
||||||
|
.transition(.move(edge: .bottom))
|
||||||
|
.animation(.easeInOut, value: 10)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user