From 8e081307764f6ae62fe453de69b728b471305b0b Mon Sep 17 00:00:00 2001 From: rsa1999 <62109113+rsa1999@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:26:14 +0100 Subject: [PATCH] basics api client --- medmap/AppState.swift | 24 + .../ManagersAssembly.swift | 16 +- medmap/Managers/AppStateManager.swift | 64 ++ medmap/Managers/EventsManager.swift | 15 + medmap/Managers/GRDBDatabaseManager.swift | 219 ++++++ medmap/Managers/PreferencesManager.swift | 139 ++++ medmap/Models/APIError.swift | 110 +++ medmap/Models/IO/Base64ImageFileDTO.swift | 15 + .../ChangePrescriptionScheduleRequest.swift | 20 + medmap/Models/IO/CompanyDTO.swift | 15 + medmap/Models/IO/CreateCompanyRequest.swift | 15 + medmap/Models/IO/CreatePatientRequest.swift | 17 + .../Models/IO/CreatePrescriptionRequest.swift | 21 + .../Models/IO/CreateProfessionalRequest.swift | 16 + medmap/Models/IO/FrequencyType.swift | 20 + medmap/Models/IO/Gender.swift | 15 + medmap/Models/IO/ListPatientsRequest.swift | 12 + .../Models/IO/ListPrescriptionsRequest.swift | 16 + .../Models/IO/ListProfessionalsRequest.swift | 13 + medmap/Models/IO/LoginRequest.swift | 13 + medmap/Models/IO/LoginResponse.swift | 14 + medmap/Models/IO/PatientDTO.swift | 25 + .../IO/PatientsByProfessionalResponse.swift | 12 + medmap/Models/IO/PrescriptionResponse.swift | 19 + .../IO/PrescriptionScheduleResponse.swift | 23 + .../PrescriptionWithSchedulesResponse.swift | 14 + medmap/Models/IO/ProfessionalResponse.swift | 16 + medmap/Models/IO/Role.swift | 14 + medmap/Models/IO/ScheduleFilter.swift | 13 + medmap/Models/IO/ScheduleRequest.swift | 17 + medmap/Models/IO/SignupRequest.swift | 14 + medmap/Network/APIClient.swift | 628 ++++++++++++++++++ medmap/Network/AuthorizationInterceptor.swift | 64 ++ medmap/Network/FluxReaderAPI.swift | 86 +++ medmap/Network/NetworkLogger.swift | 113 ++++ medmap/Utilities/KeyPairManager.swift | 340 ++++++++++ medmap/Utilities/KeychainManager.swift | 126 ++++ medmap/Utilities/Log.swift | 20 + medmap/Utilities/NetworkMonitor.swift | 124 ++++ medmap/Utilities/SerializationUtils.swift | 43 ++ medmap/ViewModels/SplashScreenViewModel.swift | 69 ++ medmap/ViewModels/ViewModel.swift | 30 + medmap/Views/SplashScreenView.swift | 169 +++++ medmap/medmapApp.swift | 68 +- 44 files changed, 2851 insertions(+), 5 deletions(-) create mode 100755 medmap/AppState.swift create mode 100755 medmap/Managers/AppStateManager.swift create mode 100644 medmap/Managers/EventsManager.swift create mode 100644 medmap/Managers/GRDBDatabaseManager.swift create mode 100644 medmap/Managers/PreferencesManager.swift create mode 100644 medmap/Models/APIError.swift create mode 100644 medmap/Models/IO/Base64ImageFileDTO.swift create mode 100644 medmap/Models/IO/ChangePrescriptionScheduleRequest.swift create mode 100644 medmap/Models/IO/CompanyDTO.swift create mode 100644 medmap/Models/IO/CreateCompanyRequest.swift create mode 100644 medmap/Models/IO/CreatePatientRequest.swift create mode 100644 medmap/Models/IO/CreatePrescriptionRequest.swift create mode 100644 medmap/Models/IO/CreateProfessionalRequest.swift create mode 100644 medmap/Models/IO/FrequencyType.swift create mode 100644 medmap/Models/IO/Gender.swift create mode 100644 medmap/Models/IO/ListPatientsRequest.swift create mode 100644 medmap/Models/IO/ListPrescriptionsRequest.swift create mode 100644 medmap/Models/IO/ListProfessionalsRequest.swift create mode 100644 medmap/Models/IO/LoginRequest.swift create mode 100644 medmap/Models/IO/LoginResponse.swift create mode 100644 medmap/Models/IO/PatientDTO.swift create mode 100644 medmap/Models/IO/PatientsByProfessionalResponse.swift create mode 100644 medmap/Models/IO/PrescriptionResponse.swift create mode 100644 medmap/Models/IO/PrescriptionScheduleResponse.swift create mode 100644 medmap/Models/IO/PrescriptionWithSchedulesResponse.swift create mode 100644 medmap/Models/IO/ProfessionalResponse.swift create mode 100644 medmap/Models/IO/Role.swift create mode 100644 medmap/Models/IO/ScheduleFilter.swift create mode 100644 medmap/Models/IO/ScheduleRequest.swift create mode 100644 medmap/Models/IO/SignupRequest.swift create mode 100644 medmap/Network/APIClient.swift create mode 100644 medmap/Network/AuthorizationInterceptor.swift create mode 100644 medmap/Network/FluxReaderAPI.swift create mode 100644 medmap/Network/NetworkLogger.swift create mode 100644 medmap/Utilities/KeyPairManager.swift create mode 100644 medmap/Utilities/KeychainManager.swift create mode 100644 medmap/Utilities/Log.swift create mode 100644 medmap/Utilities/NetworkMonitor.swift create mode 100644 medmap/Utilities/SerializationUtils.swift create mode 100644 medmap/ViewModels/SplashScreenViewModel.swift create mode 100644 medmap/ViewModels/ViewModel.swift create mode 100644 medmap/Views/SplashScreenView.swift diff --git a/medmap/AppState.swift b/medmap/AppState.swift new file mode 100755 index 0000000..b37bb60 --- /dev/null +++ b/medmap/AppState.swift @@ -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 + + +} diff --git a/medmap/DependencyInjection/ManagersAssembly.swift b/medmap/DependencyInjection/ManagersAssembly.swift index 02a3845..c2d4987 100755 --- a/medmap/DependencyInjection/ManagersAssembly.swift +++ b/medmap/DependencyInjection/ManagersAssembly.swift @@ -7,9 +7,7 @@ final class ManagerAssembly: Assembly { func assemble(container: Container) { - /* - - + container.register(AppStateManager.self) { _ in return AppStateManager() } @@ -19,7 +17,19 @@ final class ManagerAssembly: Assembly { return GRDBDatabaseManager() } .inObjectScope(.container) + container.register(APIClient.self) { _ in + return APIClient() + } + .inObjectScope(.container) + container.register(EventsManager.self) { _ in + return EventsManager() + } + + /* + + + container.register(ComicsBooksManager.self) { _ in return ComicsBooksManager() } diff --git a/medmap/Managers/AppStateManager.swift b/medmap/Managers/AppStateManager.swift new file mode 100755 index 0000000..cb6cb91 --- /dev/null +++ b/medmap/Managers/AppStateManager.swift @@ -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 + } + } + +} diff --git a/medmap/Managers/EventsManager.swift b/medmap/Managers/EventsManager.swift new file mode 100644 index 0000000..dfc53fa --- /dev/null +++ b/medmap/Managers/EventsManager.swift @@ -0,0 +1,15 @@ +// Copyright © 2025 VOID Software SA. All rights reserved. + +import Combine +import Foundation +import UIKit +class EventsManager { + + let eventPublisher = PassthroughSubject() + // let comicsChannel = PassthroughSubject() + + let serialBackgroundQueue = DispatchQueue( + label: "net.desynced.FluxReader" + ) + +} diff --git a/medmap/Managers/GRDBDatabaseManager.swift b/medmap/Managers/GRDBDatabaseManager.swift new file mode 100644 index 0000000..e539aac --- /dev/null +++ b/medmap/Managers/GRDBDatabaseManager.swift @@ -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(_ updates: (Database) throws -> T) throws -> T { + guard let dbQueue = dbQueue else { + throw DatabaseError.notInitialized + } + return try dbQueue.write(updates) + } + + func read(_ 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 + } +} diff --git a/medmap/Managers/PreferencesManager.swift b/medmap/Managers/PreferencesManager.swift new file mode 100644 index 0000000..a0d2de4 --- /dev/null +++ b/medmap/Managers/PreferencesManager.swift @@ -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(_ 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(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() + } +} diff --git a/medmap/Models/APIError.swift b/medmap/Models/APIError.swift new file mode 100644 index 0000000..33292f2 --- /dev/null +++ b/medmap/Models/APIError.swift @@ -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 + } +} diff --git a/medmap/Models/IO/Base64ImageFileDTO.swift b/medmap/Models/IO/Base64ImageFileDTO.swift new file mode 100644 index 0000000..1db4902 --- /dev/null +++ b/medmap/Models/IO/Base64ImageFileDTO.swift @@ -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? +} \ No newline at end of file diff --git a/medmap/Models/IO/ChangePrescriptionScheduleRequest.swift b/medmap/Models/IO/ChangePrescriptionScheduleRequest.swift new file mode 100644 index 0000000..75de54a --- /dev/null +++ b/medmap/Models/IO/ChangePrescriptionScheduleRequest.swift @@ -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? +} diff --git a/medmap/Models/IO/CompanyDTO.swift b/medmap/Models/IO/CompanyDTO.swift new file mode 100644 index 0000000..f680419 --- /dev/null +++ b/medmap/Models/IO/CompanyDTO.swift @@ -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? +} diff --git a/medmap/Models/IO/CreateCompanyRequest.swift b/medmap/Models/IO/CreateCompanyRequest.swift new file mode 100644 index 0000000..51736b5 --- /dev/null +++ b/medmap/Models/IO/CreateCompanyRequest.swift @@ -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? +} \ No newline at end of file diff --git a/medmap/Models/IO/CreatePatientRequest.swift b/medmap/Models/IO/CreatePatientRequest.swift new file mode 100644 index 0000000..043cc7d --- /dev/null +++ b/medmap/Models/IO/CreatePatientRequest.swift @@ -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? +} diff --git a/medmap/Models/IO/CreatePrescriptionRequest.swift b/medmap/Models/IO/CreatePrescriptionRequest.swift new file mode 100644 index 0000000..ed6d902 --- /dev/null +++ b/medmap/Models/IO/CreatePrescriptionRequest.swift @@ -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 +} diff --git a/medmap/Models/IO/CreateProfessionalRequest.swift b/medmap/Models/IO/CreateProfessionalRequest.swift new file mode 100644 index 0000000..2115152 --- /dev/null +++ b/medmap/Models/IO/CreateProfessionalRequest.swift @@ -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? +} diff --git a/medmap/Models/IO/FrequencyType.swift b/medmap/Models/IO/FrequencyType.swift new file mode 100644 index 0000000..e1993cf --- /dev/null +++ b/medmap/Models/IO/FrequencyType.swift @@ -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" +} diff --git a/medmap/Models/IO/Gender.swift b/medmap/Models/IO/Gender.swift new file mode 100644 index 0000000..1dd020a --- /dev/null +++ b/medmap/Models/IO/Gender.swift @@ -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" +} \ No newline at end of file diff --git a/medmap/Models/IO/ListPatientsRequest.swift b/medmap/Models/IO/ListPatientsRequest.swift new file mode 100644 index 0000000..bd1bb55 --- /dev/null +++ b/medmap/Models/IO/ListPatientsRequest.swift @@ -0,0 +1,12 @@ +// +// ListPatientsRequest.swift +// medmap +// +// Created by Ricardo Araujo on 27/08/2026. +// + +import SwiftUI + +struct ListPatientsRequest: Encodable { + let professionalId: String +} diff --git a/medmap/Models/IO/ListPrescriptionsRequest.swift b/medmap/Models/IO/ListPrescriptionsRequest.swift new file mode 100644 index 0000000..9eda247 --- /dev/null +++ b/medmap/Models/IO/ListPrescriptionsRequest.swift @@ -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 +} diff --git a/medmap/Models/IO/ListProfessionalsRequest.swift b/medmap/Models/IO/ListProfessionalsRequest.swift new file mode 100644 index 0000000..3cff8ed --- /dev/null +++ b/medmap/Models/IO/ListProfessionalsRequest.swift @@ -0,0 +1,13 @@ +// +// ListProfessionalsRequest.swift +// medmap +// +// Created by Ricardo Araujo on 27/08/2026. +// + + +import SwiftUI + +struct ListProfessionalsRequest: Encodable { + let companyId: String +} diff --git a/medmap/Models/IO/LoginRequest.swift b/medmap/Models/IO/LoginRequest.swift new file mode 100644 index 0000000..9a5e813 --- /dev/null +++ b/medmap/Models/IO/LoginRequest.swift @@ -0,0 +1,13 @@ +// +// LoginRequest.swift +// medmap +// +// Created by Ricardo Araujo on 27/08/2026. +// + + + +struct LoginRequest: Encodable { + let email: String + let password: String +} \ No newline at end of file diff --git a/medmap/Models/IO/LoginResponse.swift b/medmap/Models/IO/LoginResponse.swift new file mode 100644 index 0000000..25193e6 --- /dev/null +++ b/medmap/Models/IO/LoginResponse.swift @@ -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 +} diff --git a/medmap/Models/IO/PatientDTO.swift b/medmap/Models/IO/PatientDTO.swift new file mode 100644 index 0000000..b398bf3 --- /dev/null +++ b/medmap/Models/IO/PatientDTO.swift @@ -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? +} diff --git a/medmap/Models/IO/PatientsByProfessionalResponse.swift b/medmap/Models/IO/PatientsByProfessionalResponse.swift new file mode 100644 index 0000000..824a0e2 --- /dev/null +++ b/medmap/Models/IO/PatientsByProfessionalResponse.swift @@ -0,0 +1,12 @@ +// +// PatientsByProfessionalResponse.swift +// medmap +// +// Created by Ricardo Araujo on 27/08/2026. +// + + + +struct PatientsByProfessionalResponse: Decodable { + let patients: [PatientDTO] +} \ No newline at end of file diff --git a/medmap/Models/IO/PrescriptionResponse.swift b/medmap/Models/IO/PrescriptionResponse.swift new file mode 100644 index 0000000..03c6437 --- /dev/null +++ b/medmap/Models/IO/PrescriptionResponse.swift @@ -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? +} diff --git a/medmap/Models/IO/PrescriptionScheduleResponse.swift b/medmap/Models/IO/PrescriptionScheduleResponse.swift new file mode 100644 index 0000000..3f64bbf --- /dev/null +++ b/medmap/Models/IO/PrescriptionScheduleResponse.swift @@ -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? +} diff --git a/medmap/Models/IO/PrescriptionWithSchedulesResponse.swift b/medmap/Models/IO/PrescriptionWithSchedulesResponse.swift new file mode 100644 index 0000000..b23c9d1 --- /dev/null +++ b/medmap/Models/IO/PrescriptionWithSchedulesResponse.swift @@ -0,0 +1,14 @@ +// +// PrescriptionWithSchedulesResponse.swift +// medmap +// +// Created by Ricardo Araujo on 27/08/2026. +// + + + + +struct PrescriptionWithSchedulesResponse: Decodable { + let prescription: PrescriptionResponse + let schedules: [PrescriptionScheduleResponse] +} \ No newline at end of file diff --git a/medmap/Models/IO/ProfessionalResponse.swift b/medmap/Models/IO/ProfessionalResponse.swift new file mode 100644 index 0000000..1664bf5 --- /dev/null +++ b/medmap/Models/IO/ProfessionalResponse.swift @@ -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? +} diff --git a/medmap/Models/IO/Role.swift b/medmap/Models/IO/Role.swift new file mode 100644 index 0000000..2c9ebee --- /dev/null +++ b/medmap/Models/IO/Role.swift @@ -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" +} diff --git a/medmap/Models/IO/ScheduleFilter.swift b/medmap/Models/IO/ScheduleFilter.swift new file mode 100644 index 0000000..d2e22ae --- /dev/null +++ b/medmap/Models/IO/ScheduleFilter.swift @@ -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" +} \ No newline at end of file diff --git a/medmap/Models/IO/ScheduleRequest.swift b/medmap/Models/IO/ScheduleRequest.swift new file mode 100644 index 0000000..5d4b790 --- /dev/null +++ b/medmap/Models/IO/ScheduleRequest.swift @@ -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 +} \ No newline at end of file diff --git a/medmap/Models/IO/SignupRequest.swift b/medmap/Models/IO/SignupRequest.swift new file mode 100644 index 0000000..872861b --- /dev/null +++ b/medmap/Models/IO/SignupRequest.swift @@ -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 +} \ No newline at end of file diff --git a/medmap/Network/APIClient.swift b/medmap/Network/APIClient.swift new file mode 100644 index 0000000..2cd0b82 --- /dev/null +++ b/medmap/Network/APIClient.swift @@ -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() + + // 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( + _ 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( + _ 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( + _ 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( + _ 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 + }() +} diff --git a/medmap/Network/AuthorizationInterceptor.swift b/medmap/Network/AuthorizationInterceptor.swift new file mode 100644 index 0000000..a82b99c --- /dev/null +++ b/medmap/Network/AuthorizationInterceptor.swift @@ -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) -> 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) + } + */ + } +} diff --git a/medmap/Network/FluxReaderAPI.swift b/medmap/Network/FluxReaderAPI.swift new file mode 100644 index 0000000..9646e78 --- /dev/null +++ b/medmap/Network/FluxReaderAPI.swift @@ -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) + } +} diff --git a/medmap/Network/NetworkLogger.swift b/medmap/Network/NetworkLogger.swift new file mode 100644 index 0000000..ac4531b --- /dev/null +++ b/medmap/Network/NetworkLogger.swift @@ -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: " + } + } + */ + } + + 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: " + } + } + } + + 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 "❓" + } + } +} diff --git a/medmap/Utilities/KeyPairManager.swift b/medmap/Utilities/KeyPairManager.swift new file mode 100644 index 0000000..02872b4 --- /dev/null +++ b/medmap/Utilities/KeyPairManager.swift @@ -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? + 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? + 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? + 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? + 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? + 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? + 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? + 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? + 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? + 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)") + } +} diff --git a/medmap/Utilities/KeychainManager.swift b/medmap/Utilities/KeychainManager.swift new file mode 100644 index 0000000..5aea586 --- /dev/null +++ b/medmap/Utilities/KeychainManager.swift @@ -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 + } +} diff --git a/medmap/Utilities/Log.swift b/medmap/Utilities/Log.swift new file mode 100644 index 0000000..5ffffad --- /dev/null +++ b/medmap/Utilities/Log.swift @@ -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 +} diff --git a/medmap/Utilities/NetworkMonitor.swift b/medmap/Utilities/NetworkMonitor.swift new file mode 100644 index 0000000..35220ba --- /dev/null +++ b/medmap/Utilities/NetworkMonitor.swift @@ -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 + } +} diff --git a/medmap/Utilities/SerializationUtils.swift b/medmap/Utilities/SerializationUtils.swift new file mode 100644 index 0000000..4c08959 --- /dev/null +++ b/medmap/Utilities/SerializationUtils.swift @@ -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(_ value: T) throws -> Data { + let encoder = PropertyListEncoder() + encoder.outputFormat = .binary + return try encoder.encode(value) + } + + static func toBase64BinaryPlist(_ value: T) throws -> String { + let data = try toBinaryPlist(value) + return data.base64EncodedString() + } + + static func fromBinaryPlist(_ data: Data, _ type: T.Type) throws -> T { + let decoder = PropertyListDecoder() + return try decoder.decode(type, from: data) + } + + static func fromBase64Plist(_ base64: String, _ type: T.Type) throws -> T { + guard let data = Data(base64Encoded: base64) else { + throw SerializationError.invalidBase64String + } + return try fromBinaryPlist(data, type) + } +} diff --git a/medmap/ViewModels/SplashScreenViewModel.swift b/medmap/ViewModels/SplashScreenViewModel.swift new file mode 100644 index 0000000..3e59ebb --- /dev/null +++ b/medmap/ViewModels/SplashScreenViewModel.swift @@ -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 + } + */ + + + + +} diff --git a/medmap/ViewModels/ViewModel.swift b/medmap/ViewModels/ViewModel.swift new file mode 100644 index 0000000..7fc86c9 --- /dev/null +++ b/medmap/ViewModels/ViewModel.swift @@ -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() + init () { + + + + } + + + +} diff --git a/medmap/Views/SplashScreenView.swift b/medmap/Views/SplashScreenView.swift new file mode 100644 index 0000000..d7ba5bf --- /dev/null +++ b/medmap/Views/SplashScreenView.swift @@ -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 } + } +} diff --git a/medmap/medmapApp.swift b/medmap/medmapApp.swift index ab1413c..f5c46c9 100644 --- a/medmap/medmapApp.swift +++ b/medmap/medmapApp.swift @@ -10,6 +10,11 @@ import SwiftData @main struct medmapApp: App { + + + @StateObject var appStateManager: AppStateManager = DIContainer.shared.resolve() + @Environment(\.scenePhase) private var scenePhase + var sharedModelContainer: ModelContainer = { let schema = Schema([ Item.self, @@ -25,8 +30,67 @@ struct medmapApp: App { var body: some Scene { 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(_ 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) + + + + } + } + }) + } + }