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,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()
|
||||
}
|
||||
|
||||
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
|
||||
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<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