basics api client

This commit is contained in:
rsa1999 2026-08-27 23:26:14 +01:00
parent c8e44d50e2
commit 8e08130776
44 changed files with 2851 additions and 5 deletions

24
medmap/AppState.swift Executable file
View 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
}

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@ -0,0 +1,12 @@
//
// ListPatientsRequest.swift
// medmap
//
// Created by Ricardo Araujo on 27/08/2026.
//
import SwiftUI
struct ListPatientsRequest: Encodable {
let professionalId: String
}

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

View File

@ -0,0 +1,13 @@
//
// ListProfessionalsRequest.swift
// medmap
//
// Created by Ricardo Araujo on 27/08/2026.
//
import SwiftUI
struct ListProfessionalsRequest: Encodable {
let companyId: String
}

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

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

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

View File

@ -0,0 +1,12 @@
//
// PatientsByProfessionalResponse.swift
// medmap
//
// Created by Ricardo Araujo on 27/08/2026.
//
struct PatientsByProfessionalResponse: Decodable {
let patients: [PatientDTO]
}

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

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

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

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

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

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

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

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

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

View 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)
}
*/
}
}

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

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

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

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

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

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

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

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

View 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 () {
}
}

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

View File

@ -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)
}
}
})
}
}