MOB-IOS-001: Arquitectura iOS MedTime¶
Identificador: MOB-IOS-001 Version: 1.0.0 Fecha: 2025-12-08 Autor: SpecQueen Technical Division Estado: Aprobado
- MOB-IOS-001: Arquitectura iOS MedTime
- 1. Introduccion
- 2. Arquitectura General
- 3. Capa Domain
- 4. Capa Data
- 5. Capa Presentation
- 6. Persistencia Local
- 7. Networking
- 8. Cifrado E2E
- 9. Offline-First Engine
- 10. Push Notifications
- 11. Widgets
- 12. Apple Watch
- 13. Testing
- 14. Dependencies
- 15. Build y CI/CD
- 16. Anexo A: Diagrama de Arquitectura
- 17. Anexo B: Checklist de Implementacion
1. Introduccion¶
1.1. Proposito¶
Este documento define la arquitectura tecnica de la aplicacion iOS de MedTime. Establece los patrones, estructuras y decisiones arquitectonicas que guian el desarrollo de la app.
MedTime iOS es una aplicacion de gestion de medicamentos que prioriza:
- Privacidad: Arquitectura Zero-Knowledge donde el servidor no puede leer datos del usuario
- Offline-First: Funcionalidad completa sin conexion a internet
- Seguridad: Cifrado E2E de todos los datos sensibles (PHI)
- UX: Interfaz intuitiva con SwiftUI moderno
1.2. Alcance¶
Este documento cubre:
- Arquitectura de software (Clean Architecture)
- Patrones de diseno (MVVM, Repository, Coordinator)
- Persistencia local (SwiftData, KeyChain)
- Networking (URLSession, async/await)
- Cifrado (CryptoKit)
- Sincronizacion offline
- Extensiones (Widgets, Watch)
- Testing
1.3. Requisitos del Sistema¶
| Requisito | Valor | Justificacion |
|---|---|---|
| iOS Minimo | 15.0 | SwiftUI improvements, async/await |
| Swift | 5.9+ | Macros, Observation framework |
| Xcode | 15.0+ | SwiftData, visionOS preview |
| Dispositivos | iPhone, iPad | Universal app |
| Watch | watchOS 9.0+ | Companion y standalone |
1.4. Referencias¶
| Documento | Descripcion |
|---|---|
| 01-arquitectura-tecnica.md | Arquitectura base Clean Architecture |
| 04-seguridad-cliente.md | Seguridad Zero-Knowledge, E2E |
| 07-testing-strategy.md | Estrategia de testing |
| MTS-OFF-001 | Modo offline |
| MTS-WCH-001 | Apple Watch |
| MTS-WDG-001 | Widgets |
2. Arquitectura General¶
2.1. Clean Architecture Overview¶
MedTime iOS implementa Clean Architecture con tres capas principales:
┌─────────────────────────────────────────────────────────────────┐
│ PRESENTATION LAYER │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ Views │ │ ViewModels │ │ Coordinators │ │
│ │ (SwiftUI) │ │ (MVVM) │ │ (Navigation) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ DOMAIN LAYER │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ Entities │ │ Use Cases │ │ Repository Protocols │ │
│ │ (Models) │ │ (Interactor)│ │ (Ports) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ DATA LAYER │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │Repositories │ │Data Sources │ │ Mappers │ │
│ │ (Adapters) │ │(Local/Remote│ │ (DTO <-> Entity) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Flujo de dependencias:
Presentation → Domain ← Data
- Presentation depende de Domain (usa Use Cases)
- Data depende de Domain (implementa Repository Protocols)
- Domain NO depende de nadie (capa central independiente)
2.2. Estructura de Proyecto¶
MedTime/
├── App/
│ ├── MedTimeApp.swift # Entry point
│ ├── AppDelegate.swift # Push notifications, lifecycle
│ ├── SceneDelegate.swift # Scene management
│ └── DependencyContainer.swift # DI container
│
├── Domain/
│ ├── Entities/
│ │ ├── Medication.swift
│ │ ├── Schedule.swift
│ │ ├── DoseLog.swift
│ │ ├── User.swift
│ │ ├── ClinicalAnalysis.swift
│ │ └── Prescription.swift
│ │
│ ├── UseCases/
│ │ ├── Medications/
│ │ │ ├── GetMedicationsUseCase.swift
│ │ │ ├── AddMedicationUseCase.swift
│ │ │ ├── UpdateMedicationUseCase.swift
│ │ │ └── DeleteMedicationUseCase.swift
│ │ ├── Schedules/
│ │ │ ├── GetSchedulesUseCase.swift
│ │ │ └── UpdateScheduleUseCase.swift
│ │ ├── DoseLogs/
│ │ │ ├── LogDoseUseCase.swift
│ │ │ └── GetDoseHistoryUseCase.swift
│ │ ├── Sync/
│ │ │ ├── SyncDataUseCase.swift
│ │ │ └── ResolveConflictUseCase.swift
│ │ └── Auth/
│ │ ├── LoginUseCase.swift
│ │ ├── LogoutUseCase.swift
│ │ └── RefreshTokenUseCase.swift
│ │
│ ├── Repositories/
│ │ ├── MedicationRepositoryProtocol.swift
│ │ ├── ScheduleRepositoryProtocol.swift
│ │ ├── DoseLogRepositoryProtocol.swift
│ │ ├── UserRepositoryProtocol.swift
│ │ ├── SyncRepositoryProtocol.swift
│ │ └── AuthRepositoryProtocol.swift
│ │
│ └── Errors/
│ ├── DomainError.swift
│ ├── ValidationError.swift
│ └── SyncError.swift
│
├── Data/
│ ├── Repositories/
│ │ ├── MedicationRepository.swift
│ │ ├── ScheduleRepository.swift
│ │ ├── DoseLogRepository.swift
│ │ ├── UserRepository.swift
│ │ ├── SyncRepository.swift
│ │ └── AuthRepository.swift
│ │
│ ├── DataSources/
│ │ ├── Local/
│ │ │ ├── SwiftDataSource.swift
│ │ │ ├── KeyChainDataSource.swift
│ │ │ └── UserDefaultsDataSource.swift
│ │ └── Remote/
│ │ ├── APIClient.swift
│ │ ├── AuthAPIDataSource.swift
│ │ ├── MedicationAPIDataSource.swift
│ │ ├── SyncAPIDataSource.swift
│ │ └── CatalogAPIDataSource.swift
│ │
│ ├── Models/
│ │ ├── DTOs/
│ │ │ ├── MedicationDTO.swift
│ │ │ ├── ScheduleDTO.swift
│ │ │ ├── SyncBlobDTO.swift
│ │ │ └── AuthResponseDTO.swift
│ │ └── Persistence/
│ │ ├── MedicationEntity.swift # SwiftData
│ │ ├── ScheduleEntity.swift
│ │ ├── DoseLogEntity.swift
│ │ └── SyncQueueEntity.swift
│ │
│ └── Mappers/
│ ├── MedicationMapper.swift
│ ├── ScheduleMapper.swift
│ ├── DoseLogMapper.swift
│ └── SyncBlobMapper.swift
│
├── Presentation/
│ ├── Screens/
│ │ ├── Home/
│ │ │ ├── HomeView.swift
│ │ │ ├── HomeViewModel.swift
│ │ │ └── Components/
│ │ │ ├── TodayDosesCard.swift
│ │ │ ├── NextDoseWidget.swift
│ │ │ └── AdherenceRing.swift
│ │ ├── Medications/
│ │ │ ├── MedicationListView.swift
│ │ │ ├── MedicationDetailView.swift
│ │ │ ├── AddMedicationView.swift
│ │ │ └── MedicationViewModel.swift
│ │ ├── Calendar/
│ │ │ ├── CalendarView.swift
│ │ │ ├── DayDetailView.swift
│ │ │ └── CalendarViewModel.swift
│ │ ├── Profile/
│ │ │ ├── ProfileView.swift
│ │ │ ├── SettingsView.swift
│ │ │ └── ProfileViewModel.swift
│ │ └── Auth/
│ │ ├── LoginView.swift
│ │ ├── RegisterView.swift
│ │ └── AuthViewModel.swift
│ │
│ ├── Navigation/
│ │ ├── AppCoordinator.swift
│ │ ├── TabCoordinator.swift
│ │ ├── AuthCoordinator.swift
│ │ └── NavigationPath+Extensions.swift
│ │
│ ├── Components/
│ │ ├── MedicationCard.swift
│ │ ├── DoseButton.swift
│ │ ├── TimeSlotPicker.swift
│ │ ├── LoadingView.swift
│ │ └── ErrorView.swift
│ │
│ └── Theme/
│ ├── Colors.swift
│ ├── Typography.swift
│ ├── Spacing.swift
│ └── Icons.swift
│
├── Core/
│ ├── Crypto/
│ │ ├── CryptoManager.swift
│ │ ├── KeyDerivation.swift
│ │ ├── BlobEncryption.swift
│ │ └── SecureRandom.swift
│ │
│ ├── Sync/
│ │ ├── SyncManager.swift
│ │ ├── SyncQueue.swift
│ │ ├── ConflictResolver.swift
│ │ └── NetworkMonitor.swift
│ │
│ ├── Notifications/
│ │ ├── NotificationManager.swift
│ │ ├── NotificationScheduler.swift
│ │ └── NotificationActions.swift
│ │
│ └── Extensions/
│ ├── Date+Extensions.swift
│ ├── String+Extensions.swift
│ ├── Data+Extensions.swift
│ └── Publisher+Extensions.swift
│
├── Extensions/
│ ├── Widget/
│ │ ├── MedTimeWidget.swift
│ │ ├── NextDoseWidget.swift
│ │ ├── AdherenceWidget.swift
│ │ └── WidgetTimelineProvider.swift
│ │
│ └── Watch/
│ ├── WatchApp.swift
│ ├── WatchConnectivityManager.swift
│ ├── ComplicationProvider.swift
│ └── WatchViews/
│ ├── WatchHomeView.swift
│ └── WatchDoseView.swift
│
├── Resources/
│ ├── Assets.xcassets/
│ ├── Localizable.strings
│ ├── InfoPlist.strings
│ └── PrivacyInfo.xcprivacy
│
└── Tests/
├── UnitTests/
│ ├── Domain/
│ ├── Data/
│ └── Presentation/
├── UITests/
│ └── Flows/
└── SnapshotTests/
└── Screens/
2.3. Dependency Injection¶
MedTime utiliza Factory pattern para DI (ligero, type-safe, SwiftUI-friendly):
// DependencyContainer.swift
import Factory
extension Container {
// MARK: - Data Sources
var swiftDataSource: Factory<SwiftDataSourceProtocol> {
self { SwiftDataSource.shared }
.singleton
}
var keyChainDataSource: Factory<KeyChainDataSourceProtocol> {
self { KeyChainDataSource() }
.singleton
}
var apiClient: Factory<APIClientProtocol> {
self { APIClient(baseURL: Config.apiBaseURL) }
.singleton
}
// MARK: - Repositories
var medicationRepository: Factory<MedicationRepositoryProtocol> {
self {
MedicationRepository(
localDataSource: self.swiftDataSource(),
remoteDataSource: MedicationAPIDataSource(client: self.apiClient()),
cryptoManager: self.cryptoManager()
)
}
}
var syncRepository: Factory<SyncRepositoryProtocol> {
self {
SyncRepository(
localDataSource: self.swiftDataSource(),
remoteDataSource: SyncAPIDataSource(client: self.apiClient()),
cryptoManager: self.cryptoManager()
)
}
}
// MARK: - Use Cases
var getMedicationsUseCase: Factory<GetMedicationsUseCaseProtocol> {
self { GetMedicationsUseCase(repository: self.medicationRepository()) }
}
var syncDataUseCase: Factory<SyncDataUseCaseProtocol> {
self {
SyncDataUseCase(
syncRepository: self.syncRepository(),
conflictResolver: self.conflictResolver()
)
}
}
// MARK: - Core Services
var cryptoManager: Factory<CryptoManagerProtocol> {
self {
CryptoManager(keyChain: self.keyChainDataSource())
}
.singleton
}
var syncManager: Factory<SyncManagerProtocol> {
self {
SyncManager(
syncUseCase: self.syncDataUseCase(),
networkMonitor: self.networkMonitor()
)
}
.singleton
}
var networkMonitor: Factory<NetworkMonitorProtocol> {
self { NetworkMonitor() }
.singleton
}
}
// Usage in ViewModel
final class MedicationViewModel: ObservableObject {
@Injected(\.getMedicationsUseCase) private var getMedicationsUseCase
@Injected(\.syncManager) private var syncManager
@Published var medications: [Medication] = []
@Published var isLoading = false
@Published var error: DomainError?
@MainActor
func loadMedications() async {
isLoading = true
defer { isLoading = false }
do {
medications = try await getMedicationsUseCase.execute()
} catch let error as DomainError {
self.error = error
} catch {
self.error = .unknown(error)
}
}
}
2.4. Principios SOLID¶
| Principio | Aplicacion en MedTime |
|---|---|
| Single Responsibility | Cada UseCase tiene una sola responsabilidad |
| Open/Closed | Protocols permiten extension sin modificacion |
| Liskov Substitution | Repositories son intercambiables (mock/real) |
| Interface Segregation | Protocols pequenos y especificos |
| Dependency Inversion | Domain define protocols, Data implementa |
3. Capa Domain¶
3.1. Entities¶
Las entities son modelos de negocio puros, sin dependencias de frameworks:
// Domain/Entities/Medication.swift
import Foundation
struct Medication: Identifiable, Equatable, Hashable {
let id: UUID
var name: String
var genericName: String?
var dosage: Dosage
var form: MedicationForm
var route: AdministrationRoute
var frequency: DosageFrequency
var instructions: String?
var startDate: Date
var endDate: Date?
var isActive: Bool
var requiresPrescription: Bool
var inventory: Inventory?
var reminders: [Reminder]
let createdAt: Date
var updatedAt: Date
var version: Int64
struct Dosage: Equatable, Hashable {
var amount: Double
var unit: DosageUnit
var displayString: String {
"\(amount.formatted()) \(unit.abbreviation)"
}
}
struct Inventory: Equatable, Hashable {
var currentQuantity: Double
var minimumQuantity: Double
var packageSize: Int?
var expirationDate: Date?
var isLow: Bool {
currentQuantity <= minimumQuantity
}
var daysRemaining: Int? {
// Calculado basado en frecuencia
nil
}
}
}
// Domain/Entities/DoseLog.swift
struct DoseLog: Identifiable, Equatable {
let id: UUID
let medicationId: UUID
let scheduledTime: Date
var takenTime: Date?
var status: DoseStatus
var skipReason: SkipReason?
var notes: String?
let createdAt: Date
var updatedAt: Date
var version: Int64
enum DoseStatus: String, CaseIterable {
case pending
case taken
case skipped
case missed
case late
}
enum SkipReason: String, CaseIterable {
case sideEffects
case forgotToTake
case outOfMedication
case doctorAdvised
case feltBetter
case other
}
}
// Domain/Entities/Schedule.swift
struct Schedule: Identifiable, Equatable {
let id: UUID
let medicationId: UUID
var times: [ScheduleTime]
var daysOfWeek: Set<DayOfWeek>? // nil = every day
var isActive: Bool
let createdAt: Date
var updatedAt: Date
var version: Int64
struct ScheduleTime: Equatable, Hashable {
var hour: Int
var minute: Int
var label: String? // "Morning", "With breakfast"
var date: Date {
Calendar.current.date(
from: DateComponents(hour: hour, minute: minute)
) ?? Date()
}
}
}
// Enums compartidos
enum MedicationForm: String, CaseIterable, Codable {
case tablet, capsule, liquid, injection, cream, patch, inhaler, drops, other
var icon: String {
switch self {
case .tablet: return "pill"
case .capsule: return "capsule"
case .liquid: return "drop"
case .injection: return "syringe"
case .cream: return "tube"
case .patch: return "bandage"
case .inhaler: return "wind"
case .drops: return "drop"
case .other: return "pills"
}
}
}
enum DosageUnit: String, CaseIterable, Codable {
case mg, g, ml, units, drops, puffs, tablets, capsules
var abbreviation: String { rawValue }
}
enum AdministrationRoute: String, CaseIterable, Codable {
case oral, sublingual, topical, intravenous, intramuscular
case subcutaneous, inhaled, rectal, ophthalmic, otic, nasal
}
enum DosageFrequency: Equatable, Hashable, Codable {
case timesPerDay(Int)
case everyXHours(Int)
case custom(times: [Date])
case asNeeded
var displayString: String {
switch self {
case .timesPerDay(let n):
return "\(n)x al dia"
case .everyXHours(let h):
return "Cada \(h) horas"
case .custom:
return "Horario personalizado"
case .asNeeded:
return "Segun necesidad"
}
}
}
enum DayOfWeek: Int, CaseIterable, Codable {
case sunday = 1, monday, tuesday, wednesday, thursday, friday, saturday
}
3.2. Use Cases¶
Los Use Cases encapsulan logica de negocio:
// Domain/UseCases/Medications/GetMedicationsUseCase.swift
protocol GetMedicationsUseCaseProtocol {
func execute(filter: MedicationFilter?) async throws -> [Medication]
}
struct MedicationFilter {
var isActive: Bool?
var searchText: String?
var form: MedicationForm?
}
final class GetMedicationsUseCase: GetMedicationsUseCaseProtocol {
private let repository: MedicationRepositoryProtocol
init(repository: MedicationRepositoryProtocol) {
self.repository = repository
}
func execute(filter: MedicationFilter? = nil) async throws -> [Medication] {
var medications = try await repository.getAllMedications()
if let filter {
if let isActive = filter.isActive {
medications = medications.filter { $0.isActive == isActive }
}
if let searchText = filter.searchText, !searchText.isEmpty {
medications = medications.filter {
$0.name.localizedCaseInsensitiveContains(searchText) ||
($0.genericName?.localizedCaseInsensitiveContains(searchText) ?? false)
}
}
if let form = filter.form {
medications = medications.filter { $0.form == form }
}
}
return medications.sorted { $0.name < $1.name }
}
}
// Domain/UseCases/DoseLogs/LogDoseUseCase.swift
protocol LogDoseUseCaseProtocol {
func execute(
medicationId: UUID,
scheduledTime: Date,
status: DoseLog.DoseStatus,
takenTime: Date?,
skipReason: DoseLog.SkipReason?,
notes: String?
) async throws -> DoseLog
}
final class LogDoseUseCase: LogDoseUseCaseProtocol {
private let doseLogRepository: DoseLogRepositoryProtocol
private let medicationRepository: MedicationRepositoryProtocol
private let syncManager: SyncManagerProtocol
init(
doseLogRepository: DoseLogRepositoryProtocol,
medicationRepository: MedicationRepositoryProtocol,
syncManager: SyncManagerProtocol
) {
self.doseLogRepository = doseLogRepository
self.medicationRepository = medicationRepository
self.syncManager = syncManager
}
func execute(
medicationId: UUID,
scheduledTime: Date,
status: DoseLog.DoseStatus,
takenTime: Date?,
skipReason: DoseLog.SkipReason?,
notes: String?
) async throws -> DoseLog {
// Validar que el medicamento existe
guard let medication = try await medicationRepository.getMedication(by: medicationId) else {
throw DomainError.medicationNotFound(medicationId)
}
// Crear el log
let doseLog = DoseLog(
id: UUID(),
medicationId: medicationId,
scheduledTime: scheduledTime,
takenTime: takenTime ?? (status == .taken ? Date() : nil),
status: status,
skipReason: skipReason,
notes: notes,
createdAt: Date(),
updatedAt: Date(),
version: 1
)
// Guardar localmente
let savedLog = try await doseLogRepository.save(doseLog)
// Actualizar inventario si se tomo la dosis
if status == .taken, var inventory = medication.inventory {
inventory.currentQuantity -= medication.dosage.amount
var updatedMedication = medication
updatedMedication.inventory = inventory
updatedMedication.updatedAt = Date()
updatedMedication.version += 1
try await medicationRepository.save(updatedMedication)
}
// Encolar para sync
await syncManager.enqueue(.doseLog(savedLog))
return savedLog
}
}
// Domain/UseCases/Sync/SyncDataUseCase.swift
protocol SyncDataUseCaseProtocol {
func execute() async throws -> SyncResult
func resolveConflict(_ conflict: SyncConflict, resolution: ConflictResolution) async throws
}
struct SyncResult {
let uploaded: Int
let downloaded: Int
let conflicts: [SyncConflict]
let errors: [SyncError]
}
struct SyncConflict {
let entityType: EntityType
let entityId: UUID
let localVersion: Int64
let serverVersion: Int64
let localData: Data
let serverData: Data
}
enum ConflictResolution {
case keepLocal
case keepServer
case merge(Data)
}
final class SyncDataUseCase: SyncDataUseCaseProtocol {
private let syncRepository: SyncRepositoryProtocol
private let conflictResolver: ConflictResolverProtocol
init(
syncRepository: SyncRepositoryProtocol,
conflictResolver: ConflictResolverProtocol
) {
self.syncRepository = syncRepository
self.conflictResolver = conflictResolver
}
func execute() async throws -> SyncResult {
// 1. Obtener cambios locales pendientes
let pendingChanges = try await syncRepository.getPendingChanges()
// 2. Obtener version del servidor
let serverVersion = try await syncRepository.getServerVersion()
let localVersion = try await syncRepository.getLocalVersion()
var uploaded = 0
var downloaded = 0
var conflicts: [SyncConflict] = []
var errors: [SyncError] = []
// 3. Subir cambios locales
for change in pendingChanges {
do {
try await syncRepository.push(change)
uploaded += 1
} catch let error as SyncError {
if case .conflict(let conflict) = error {
conflicts.append(conflict)
} else {
errors.append(error)
}
}
}
// 4. Descargar cambios del servidor
if serverVersion > localVersion {
let serverChanges = try await syncRepository.pull(since: localVersion)
for change in serverChanges {
do {
try await syncRepository.applyServerChange(change)
downloaded += 1
} catch let error as SyncError {
errors.append(error)
}
}
}
// 5. Auto-resolver conflictos simples (LWW)
for conflict in conflicts {
if await conflictResolver.canAutoResolve(conflict) {
let resolution = await conflictResolver.autoResolve(conflict)
try await resolveConflict(conflict, resolution: resolution)
}
}
return SyncResult(
uploaded: uploaded,
downloaded: downloaded,
conflicts: conflicts.filter { !conflictResolver.canAutoResolve($0) },
errors: errors
)
}
func resolveConflict(_ conflict: SyncConflict, resolution: ConflictResolution) async throws {
try await syncRepository.resolveConflict(conflict, resolution: resolution)
}
}
3.3. Repository Protocols¶
Los Repository Protocols definen contratos que la capa Data debe implementar:
// Domain/Repositories/MedicationRepositoryProtocol.swift
protocol MedicationRepositoryProtocol {
func getAllMedications() async throws -> [Medication]
func getMedication(by id: UUID) async throws -> Medication?
func save(_ medication: Medication) async throws -> Medication
func delete(_ medication: Medication) async throws
func search(query: String) async throws -> [Medication]
}
// Domain/Repositories/DoseLogRepositoryProtocol.swift
protocol DoseLogRepositoryProtocol {
func getLogs(for medicationId: UUID, dateRange: DateInterval?) async throws -> [DoseLog]
func getLog(by id: UUID) async throws -> DoseLog?
func save(_ log: DoseLog) async throws -> DoseLog
func delete(_ log: DoseLog) async throws
func getPendingDoses(for date: Date) async throws -> [DoseLog]
func getAdherenceStats(for medicationId: UUID, days: Int) async throws -> AdherenceStats
}
struct AdherenceStats {
let totalDoses: Int
let takenDoses: Int
let missedDoses: Int
let skippedDoses: Int
var adherenceRate: Double {
guard totalDoses > 0 else { return 0 }
return Double(takenDoses) / Double(totalDoses)
}
}
// Domain/Repositories/SyncRepositoryProtocol.swift
protocol SyncRepositoryProtocol {
func getPendingChanges() async throws -> [SyncChange]
func getServerVersion() async throws -> Int64
func getLocalVersion() async throws -> Int64
func push(_ change: SyncChange) async throws
func pull(since version: Int64) async throws -> [SyncChange]
func applyServerChange(_ change: SyncChange) async throws
func resolveConflict(_ conflict: SyncConflict, resolution: ConflictResolution) async throws
}
struct SyncChange {
let entityType: EntityType
let entityId: UUID
let operation: SyncOperation
let encryptedBlob: Data
let version: Int64
let timestamp: Date
}
enum SyncOperation: String {
case create, update, delete
}
enum EntityType: String, CaseIterable {
case medication, schedule, doseLog, clinicalAnalysis, prescription
}
3.4. Error Handling¶
// Domain/Errors/DomainError.swift
enum DomainError: Error, LocalizedError {
// Entity errors
case medicationNotFound(UUID)
case scheduleNotFound(UUID)
case doseLogNotFound(UUID)
case userNotFound
// Validation errors
case invalidDosage(String)
case invalidSchedule(String)
case invalidDateRange
// Business rule violations
case medicationAlreadyExists(name: String)
case cannotDeleteActiveMedication
case scheduleConflict(existingTime: Date)
// Sync errors
case syncFailed(underlying: Error)
case conflictDetected(SyncConflict)
case offlineOperationFailed
// Crypto errors
case encryptionFailed
case decryptionFailed
case keyNotFound
// Generic
case unknown(Error)
var errorDescription: String? {
switch self {
case .medicationNotFound(let id):
return "Medicamento no encontrado: \(id)"
case .invalidDosage(let reason):
return "Dosificacion invalida: \(reason)"
case .syncFailed(let error):
return "Error de sincronizacion: \(error.localizedDescription)"
case .encryptionFailed:
return "Error al cifrar datos"
case .decryptionFailed:
return "Error al descifrar datos"
// ... otros casos
default:
return "Error desconocido"
}
}
var recoverySuggestion: String? {
switch self {
case .syncFailed:
return "Verifica tu conexion a internet e intenta de nuevo"
case .offlineOperationFailed:
return "Esta operacion se completara cuando tengas conexion"
case .decryptionFailed:
return "Verifica que tu sesion este activa"
default:
return nil
}
}
}
4. Capa Data¶
4.1. Repository Implementations¶
// Data/Repositories/MedicationRepository.swift
final class MedicationRepository: MedicationRepositoryProtocol {
private let localDataSource: SwiftDataSourceProtocol
private let remoteDataSource: MedicationAPIDataSourceProtocol
private let cryptoManager: CryptoManagerProtocol
private let mapper: MedicationMapper
init(
localDataSource: SwiftDataSourceProtocol,
remoteDataSource: MedicationAPIDataSourceProtocol,
cryptoManager: CryptoManagerProtocol,
mapper: MedicationMapper = MedicationMapper()
) {
self.localDataSource = localDataSource
self.remoteDataSource = remoteDataSource
self.cryptoManager = cryptoManager
self.mapper = mapper
}
func getAllMedications() async throws -> [Medication] {
// Siempre lee de local (offline-first)
let entities = try await localDataSource.fetchAll(MedicationEntity.self)
return try entities.map { entity in
// Descifrar blob
let decryptedData = try cryptoManager.decrypt(entity.encryptedBlob)
return try mapper.toDomain(entity, decryptedData: decryptedData)
}
}
func getMedication(by id: UUID) async throws -> Medication? {
guard let entity = try await localDataSource.fetch(
MedicationEntity.self,
predicate: #Predicate { $0.id == id }
).first else {
return nil
}
let decryptedData = try cryptoManager.decrypt(entity.encryptedBlob)
return try mapper.toDomain(entity, decryptedData: decryptedData)
}
func save(_ medication: Medication) async throws -> Medication {
// 1. Cifrar datos sensibles
let sensitiveData = try mapper.toSensitiveData(medication)
let encryptedBlob = try cryptoManager.encrypt(sensitiveData)
// 2. Crear/actualizar entity
let entity = mapper.toEntity(medication, encryptedBlob: encryptedBlob)
// 3. Guardar localmente
try await localDataSource.save(entity)
// 4. Marcar para sync
try await localDataSource.addToSyncQueue(
entityType: .medication,
entityId: medication.id,
operation: .update,
encryptedBlob: encryptedBlob
)
return medication
}
func delete(_ medication: Medication) async throws {
// 1. Soft delete local
try await localDataSource.delete(
MedicationEntity.self,
predicate: #Predicate { $0.id == medication.id }
)
// 2. Marcar para sync
try await localDataSource.addToSyncQueue(
entityType: .medication,
entityId: medication.id,
operation: .delete,
encryptedBlob: Data() // Vacio para delete
)
}
func search(query: String) async throws -> [Medication] {
// Busqueda local en campos no cifrados
let entities = try await localDataSource.fetch(
MedicationEntity.self,
predicate: #Predicate {
$0.nameSearchable.localizedStandardContains(query)
}
)
return try entities.map { entity in
let decryptedData = try cryptoManager.decrypt(entity.encryptedBlob)
return try mapper.toDomain(entity, decryptedData: decryptedData)
}
}
}
4.2. Data Sources¶
// Data/DataSources/Local/SwiftDataSource.swift
protocol SwiftDataSourceProtocol {
func fetchAll<T: PersistentModel>(_ type: T.Type) async throws -> [T]
func fetch<T: PersistentModel>(_ type: T.Type, predicate: Predicate<T>?) async throws -> [T]
func save<T: PersistentModel>(_ entity: T) async throws
func delete<T: PersistentModel>(_ type: T.Type, predicate: Predicate<T>) async throws
func addToSyncQueue(entityType: EntityType, entityId: UUID, operation: SyncOperation, encryptedBlob: Data) async throws
}
@MainActor
final class SwiftDataSource: SwiftDataSourceProtocol {
static let shared = SwiftDataSource()
private let modelContainer: ModelContainer
private let modelContext: ModelContext
private init() {
let schema = Schema([
MedicationEntity.self,
ScheduleEntity.self,
DoseLogEntity.self,
SyncQueueEntity.self,
CacheEntity.self
])
let modelConfiguration = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
cloudKitDatabase: .none // No iCloud - usamos nuestro sync
)
do {
modelContainer = try ModelContainer(
for: schema,
configurations: [modelConfiguration]
)
modelContext = modelContainer.mainContext
} catch {
fatalError("Failed to create ModelContainer: \(error)")
}
}
func fetchAll<T: PersistentModel>(_ type: T.Type) async throws -> [T] {
let descriptor = FetchDescriptor<T>()
return try modelContext.fetch(descriptor)
}
func fetch<T: PersistentModel>(_ type: T.Type, predicate: Predicate<T>?) async throws -> [T] {
var descriptor = FetchDescriptor<T>()
descriptor.predicate = predicate
return try modelContext.fetch(descriptor)
}
func save<T: PersistentModel>(_ entity: T) async throws {
modelContext.insert(entity)
try modelContext.save()
}
func delete<T: PersistentModel>(_ type: T.Type, predicate: Predicate<T>) async throws {
let entities = try await fetch(type, predicate: predicate)
for entity in entities {
modelContext.delete(entity)
}
try modelContext.save()
}
func addToSyncQueue(
entityType: EntityType,
entityId: UUID,
operation: SyncOperation,
encryptedBlob: Data
) async throws {
let queueItem = SyncQueueEntity(
id: UUID(),
entityType: entityType.rawValue,
entityId: entityId,
operation: operation.rawValue,
encryptedBlob: encryptedBlob,
createdAt: Date(),
retryCount: 0
)
modelContext.insert(queueItem)
try modelContext.save()
}
}
// Data/DataSources/Remote/APIClient.swift
protocol APIClientProtocol {
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T
func request(_ endpoint: Endpoint) async throws
func upload(_ data: Data, to endpoint: Endpoint) async throws -> UploadResponse
}
final class APIClient: APIClientProtocol {
private let baseURL: URL
private let session: URLSession
private let authInterceptor: AuthInterceptorProtocol
private let retryInterceptor: RetryInterceptorProtocol
private let decoder: JSONDecoder
private let encoder: JSONEncoder
init(
baseURL: URL,
session: URLSession = .shared,
authInterceptor: AuthInterceptorProtocol = AuthInterceptor(),
retryInterceptor: RetryInterceptorProtocol = RetryInterceptor()
) {
self.baseURL = baseURL
self.session = session
self.authInterceptor = authInterceptor
self.retryInterceptor = retryInterceptor
self.decoder = JSONDecoder()
self.decoder.dateDecodingStrategy = .iso8601
self.encoder = JSONEncoder()
self.encoder.dateEncodingStrategy = .iso8601
}
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
var request = try buildRequest(for: endpoint)
request = try await authInterceptor.intercept(request)
let (data, response) = try await retryInterceptor.execute {
try await self.session.data(for: request)
}
try validateResponse(response)
return try decoder.decode(T.self, from: data)
}
func request(_ endpoint: Endpoint) async throws {
var request = try buildRequest(for: endpoint)
request = try await authInterceptor.intercept(request)
let (_, response) = try await retryInterceptor.execute {
try await self.session.data(for: request)
}
try validateResponse(response)
}
func upload(_ data: Data, to endpoint: Endpoint) async throws -> UploadResponse {
var request = try buildRequest(for: endpoint)
request = try await authInterceptor.intercept(request)
request.httpBody = data
let (responseData, response) = try await session.data(for: request)
try validateResponse(response)
return try decoder.decode(UploadResponse.self, from: responseData)
}
private func buildRequest(for endpoint: Endpoint) throws -> URLRequest {
guard let url = URL(string: endpoint.path, relativeTo: baseURL) else {
throw APIError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = endpoint.method.rawValue
request.allHTTPHeaderFields = endpoint.headers
if let body = endpoint.body {
request.httpBody = try encoder.encode(body)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
return request
}
private func validateResponse(_ response: URLResponse) throws {
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
switch httpResponse.statusCode {
case 200...299:
return
case 401:
throw APIError.unauthorized
case 403:
throw APIError.forbidden
case 404:
throw APIError.notFound
case 409:
throw APIError.conflict
case 429:
throw APIError.rateLimited(retryAfter: httpResponse.value(forHTTPHeaderField: "Retry-After"))
case 500...599:
throw APIError.serverError(httpResponse.statusCode)
default:
throw APIError.httpError(httpResponse.statusCode)
}
}
}
// Endpoint definition
struct Endpoint {
let path: String
let method: HTTPMethod
let headers: [String: String]
let body: Encodable?
enum HTTPMethod: String {
case GET, POST, PUT, PATCH, DELETE
}
}
// API-specific data sources
final class SyncAPIDataSource {
private let client: APIClientProtocol
init(client: APIClientProtocol) {
self.client = client
}
func push(blobs: [SyncBlobDTO]) async throws -> PushResponse {
let endpoint = Endpoint(
path: "/v1/sync/push",
method: .POST,
headers: [:],
body: PushRequest(blobs: blobs)
)
return try await client.request(endpoint)
}
func pull(since version: Int64, deviceId: UUID) async throws -> PullResponse {
let endpoint = Endpoint(
path: "/v1/sync/pull?since_version=\(version)&device_id=\(deviceId)",
method: .GET,
headers: [:],
body: nil
)
return try await client.request(endpoint)
}
}
4.3. Mappers¶
// Data/Mappers/MedicationMapper.swift
struct MedicationMapper {
func toDomain(_ entity: MedicationEntity, decryptedData: Data) throws -> Medication {
// Decodificar datos sensibles del blob descifrado
let sensitiveData = try JSONDecoder().decode(
MedicationSensitiveData.self,
from: decryptedData
)
return Medication(
id: entity.id,
name: sensitiveData.name,
genericName: sensitiveData.genericName,
dosage: Medication.Dosage(
amount: sensitiveData.dosageAmount,
unit: DosageUnit(rawValue: sensitiveData.dosageUnit) ?? .mg
),
form: MedicationForm(rawValue: entity.form) ?? .tablet,
route: AdministrationRoute(rawValue: entity.route) ?? .oral,
frequency: decodeFrequency(sensitiveData.frequency),
instructions: sensitiveData.instructions,
startDate: entity.startDate,
endDate: entity.endDate,
isActive: entity.isActive,
requiresPrescription: sensitiveData.requiresPrescription,
inventory: sensitiveData.inventory.map { inv in
Medication.Inventory(
currentQuantity: inv.currentQuantity,
minimumQuantity: inv.minimumQuantity,
packageSize: inv.packageSize,
expirationDate: inv.expirationDate
)
},
reminders: [], // Loaded separately
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
version: entity.version
)
}
func toEntity(_ medication: Medication, encryptedBlob: Data) -> MedicationEntity {
MedicationEntity(
id: medication.id,
nameSearchable: medication.name.lowercased(), // Para busqueda
form: medication.form.rawValue,
route: medication.route.rawValue,
startDate: medication.startDate,
endDate: medication.endDate,
isActive: medication.isActive,
encryptedBlob: encryptedBlob,
createdAt: medication.createdAt,
updatedAt: medication.updatedAt,
version: medication.version
)
}
func toSensitiveData(_ medication: Medication) throws -> Data {
let sensitiveData = MedicationSensitiveData(
name: medication.name,
genericName: medication.genericName,
dosageAmount: medication.dosage.amount,
dosageUnit: medication.dosage.unit.rawValue,
frequency: encodeFrequency(medication.frequency),
instructions: medication.instructions,
requiresPrescription: medication.requiresPrescription,
inventory: medication.inventory.map { inv in
MedicationSensitiveData.InventoryData(
currentQuantity: inv.currentQuantity,
minimumQuantity: inv.minimumQuantity,
packageSize: inv.packageSize,
expirationDate: inv.expirationDate
)
}
)
return try JSONEncoder().encode(sensitiveData)
}
private func encodeFrequency(_ frequency: DosageFrequency) -> String {
// Encode to JSON string for storage
switch frequency {
case .timesPerDay(let n):
return "times_per_day:\(n)"
case .everyXHours(let h):
return "every_x_hours:\(h)"
case .asNeeded:
return "as_needed"
case .custom(let times):
let timestamps = times.map { $0.timeIntervalSince1970 }
return "custom:\(timestamps.map(String.init).joined(separator: ","))"
}
}
private func decodeFrequency(_ encoded: String) -> DosageFrequency {
let parts = encoded.split(separator: ":")
guard let type = parts.first else { return .asNeeded }
switch type {
case "times_per_day":
return .timesPerDay(Int(parts[1]) ?? 1)
case "every_x_hours":
return .everyXHours(Int(parts[1]) ?? 8)
case "as_needed":
return .asNeeded
case "custom":
// Parse timestamps
return .asNeeded // Simplified
default:
return .asNeeded
}
}
}
// Internal DTO for sensitive data
private struct MedicationSensitiveData: Codable {
let name: String
let genericName: String?
let dosageAmount: Double
let dosageUnit: String
let frequency: String
let instructions: String?
let requiresPrescription: Bool
let inventory: InventoryData?
struct InventoryData: Codable {
let currentQuantity: Double
let minimumQuantity: Double
let packageSize: Int?
let expirationDate: Date?
}
}
4.4. Cache Strategy¶
// Data/Cache/CacheManager.swift
protocol CacheManagerProtocol {
func get<T: Codable>(_ key: String) -> T?
func set<T: Codable>(_ value: T, for key: String, ttl: TimeInterval?)
func remove(_ key: String)
func clear()
}
final class CacheManager: CacheManagerProtocol {
private let localDataSource: SwiftDataSourceProtocol
private let memoryCache = NSCache<NSString, CacheEntry>()
init(localDataSource: SwiftDataSourceProtocol) {
self.localDataSource = localDataSource
memoryCache.countLimit = 100
memoryCache.totalCostLimit = 50 * 1024 * 1024 // 50MB
}
func get<T: Codable>(_ key: String) -> T? {
// 1. Check memory cache
if let entry = memoryCache.object(forKey: key as NSString),
!entry.isExpired {
return try? JSONDecoder().decode(T.self, from: entry.data)
}
// 2. Check disk cache (async wrapper needed in real impl)
// Simplified for illustration
return nil
}
func set<T: Codable>(_ value: T, for key: String, ttl: TimeInterval? = nil) {
guard let data = try? JSONEncoder().encode(value) else { return }
let entry = CacheEntry(
data: data,
expiresAt: ttl.map { Date().addingTimeInterval($0) }
)
memoryCache.setObject(entry, forKey: key as NSString, cost: data.count)
// Also persist to disk for offline
Task {
try? await localDataSource.save(
CacheEntity(key: key, data: data, expiresAt: entry.expiresAt)
)
}
}
func remove(_ key: String) {
memoryCache.removeObject(forKey: key as NSString)
}
func clear() {
memoryCache.removeAllObjects()
}
}
private class CacheEntry: NSObject {
let data: Data
let expiresAt: Date?
init(data: Data, expiresAt: Date?) {
self.data = data
self.expiresAt = expiresAt
}
var isExpired: Bool {
guard let expiresAt else { return false }
return Date() > expiresAt
}
}
5. Capa Presentation¶
5.1. SwiftUI Views¶
// Presentation/Screens/Home/HomeView.swift
import SwiftUI
struct HomeView: View {
@StateObject private var viewModel = HomeViewModel()
@Environment(\.scenePhase) private var scenePhase
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: Spacing.large) {
// Header con saludo
GreetingHeader(userName: viewModel.userName)
// Siguiente dosis
if let nextDose = viewModel.nextDose {
NextDoseCard(
dose: nextDose,
onTakeDose: { viewModel.takeDose(nextDose) },
onSkipDose: { viewModel.showSkipSheet = true }
)
}
// Dosis del dia
TodayDosesSection(
doses: viewModel.todayDoses,
onTakeDose: viewModel.takeDose,
onViewAll: { viewModel.showAllDoses = true }
)
// Adherencia semanal
AdherenceCard(stats: viewModel.weeklyAdherence)
// Alertas
if !viewModel.alerts.isEmpty {
AlertsSection(alerts: viewModel.alerts)
}
}
.padding()
}
.navigationTitle("MedTime")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
viewModel.showProfile = true
} label: {
Image(systemName: "person.circle")
}
}
}
.refreshable {
await viewModel.refresh()
}
.sheet(isPresented: $viewModel.showSkipSheet) {
SkipDoseSheet(
onSkip: viewModel.skipDose
)
}
.sheet(isPresented: $viewModel.showProfile) {
ProfileView()
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
Task { await viewModel.refresh() }
}
}
}
.task {
await viewModel.loadInitialData()
}
}
}
// Presentation/Screens/Home/Components/NextDoseCard.swift
struct NextDoseCard: View {
let dose: ScheduledDose
let onTakeDose: () -> Void
let onSkipDose: () -> Void
@State private var timeRemaining: TimeInterval = 0
let timer = Timer.publish(every: 60, on: .main, in: .common).autoconnect()
var body: some View {
VStack(alignment: .leading, spacing: Spacing.medium) {
HStack {
Text("Siguiente dosis")
.font(.headline)
.foregroundStyle(.secondary)
Spacer()
Text(timeRemainingText)
.font(.caption)
.foregroundStyle(timeRemaining < 300 ? .red : .secondary)
}
HStack(spacing: Spacing.medium) {
// Medication icon
Image(systemName: dose.medication.form.icon)
.font(.title)
.foregroundStyle(.accent)
.frame(width: 50, height: 50)
.background(Color.accent.opacity(0.1))
.clipShape(Circle())
VStack(alignment: .leading, spacing: 4) {
Text(dose.medication.name)
.font(.title3)
.fontWeight(.semibold)
Text(dose.medication.dosage.displayString)
.font(.subheadline)
.foregroundStyle(.secondary)
Text(dose.scheduledTime, style: .time)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
HStack(spacing: Spacing.medium) {
Button(action: onSkipDose) {
Label("Omitir", systemImage: "xmark")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
Button(action: onTakeDose) {
Label("Tomar", systemImage: "checkmark")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
}
}
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.05), radius: 10, y: 4)
.onReceive(timer) { _ in
updateTimeRemaining()
}
.onAppear {
updateTimeRemaining()
}
}
private var timeRemainingText: String {
if timeRemaining < 0 {
return "Atrasado \(abs(Int(timeRemaining / 60))) min"
} else if timeRemaining < 3600 {
return "En \(Int(timeRemaining / 60)) min"
} else {
return "En \(Int(timeRemaining / 3600)) h"
}
}
private func updateTimeRemaining() {
timeRemaining = dose.scheduledTime.timeIntervalSinceNow
}
}
// Presentation/Screens/Medications/MedicationListView.swift
struct MedicationListView: View {
@StateObject private var viewModel = MedicationListViewModel()
@State private var searchText = ""
@State private var showAddMedication = false
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading {
ProgressView()
} else if viewModel.medications.isEmpty {
EmptyMedicationsView(onAdd: { showAddMedication = true })
} else {
List {
ForEach(filteredMedications) { medication in
NavigationLink(value: medication) {
MedicationRow(medication: medication)
}
}
.onDelete(perform: viewModel.deleteMedications)
}
.listStyle(.insetGrouped)
}
}
.navigationTitle("Medicamentos")
.navigationDestination(for: Medication.self) { medication in
MedicationDetailView(medication: medication)
}
.searchable(text: $searchText, prompt: "Buscar medicamento")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showAddMedication = true
} label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showAddMedication) {
AddMedicationView()
}
.alert(
"Error",
isPresented: $viewModel.showError,
presenting: viewModel.error
) { _ in
Button("OK") {}
} message: { error in
Text(error.localizedDescription)
}
}
.task {
await viewModel.loadMedications()
}
}
private var filteredMedications: [Medication] {
if searchText.isEmpty {
return viewModel.medications
}
return viewModel.medications.filter {
$0.name.localizedCaseInsensitiveContains(searchText) ||
($0.genericName?.localizedCaseInsensitiveContains(searchText) ?? false)
}
}
}
5.2. ViewModels¶
// Presentation/Screens/Home/HomeViewModel.swift
import Foundation
import Combine
import Factory
@MainActor
final class HomeViewModel: ObservableObject {
// Dependencies
@Injected(\.getMedicationsUseCase) private var getMedicationsUseCase
@Injected(\.getSchedulesUseCase) private var getSchedulesUseCase
@Injected(\.logDoseUseCase) private var logDoseUseCase
@Injected(\.getAdherenceUseCase) private var getAdherenceUseCase
@Injected(\.syncManager) private var syncManager
// Published state
@Published var userName: String = ""
@Published var nextDose: ScheduledDose?
@Published var todayDoses: [ScheduledDose] = []
@Published var weeklyAdherence: AdherenceStats?
@Published var alerts: [Alert] = []
@Published var isLoading = false
@Published var error: DomainError?
@Published var showError = false
@Published var showSkipSheet = false
@Published var showAllDoses = false
@Published var showProfile = false
private var cancellables = Set<AnyCancellable>()
init() {
setupBindings()
}
private func setupBindings() {
// Escuchar cambios de sync
syncManager.syncStatePublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
if case .completed = state {
Task { await self?.refresh() }
}
}
.store(in: &cancellables)
}
func loadInitialData() async {
isLoading = true
defer { isLoading = false }
do {
async let medicationsTask = getMedicationsUseCase.execute(filter: .init(isActive: true))
async let schedulesTask = getSchedulesUseCase.execute()
async let adherenceTask = getAdherenceUseCase.execute(days: 7)
let (medications, schedules, adherence) = try await (
medicationsTask,
schedulesTask,
adherenceTask
)
// Combinar medicamentos y schedules para crear dosis programadas
todayDoses = buildTodayDoses(medications: medications, schedules: schedules)
nextDose = todayDoses.first { $0.status == .pending && $0.scheduledTime > Date() }
weeklyAdherence = adherence
// Cargar alertas
alerts = buildAlerts(medications: medications)
} catch let domainError as DomainError {
self.error = domainError
self.showError = true
} catch {
self.error = .unknown(error)
self.showError = true
}
}
func refresh() async {
// Trigger sync primero
await syncManager.sync()
// Recargar datos
await loadInitialData()
}
func takeDose(_ dose: ScheduledDose) {
Task {
do {
_ = try await logDoseUseCase.execute(
medicationId: dose.medication.id,
scheduledTime: dose.scheduledTime,
status: .taken,
takenTime: Date(),
skipReason: nil,
notes: nil
)
// Actualizar UI
await loadInitialData()
} catch let domainError as DomainError {
self.error = domainError
self.showError = true
} catch {
self.error = .unknown(error)
self.showError = true
}
}
}
func skipDose(reason: DoseLog.SkipReason, notes: String?) {
guard let dose = nextDose else { return }
Task {
do {
_ = try await logDoseUseCase.execute(
medicationId: dose.medication.id,
scheduledTime: dose.scheduledTime,
status: .skipped,
takenTime: nil,
skipReason: reason,
notes: notes
)
showSkipSheet = false
await loadInitialData()
} catch let domainError as DomainError {
self.error = domainError
self.showError = true
} catch {
self.error = .unknown(error)
self.showError = true
}
}
}
private func buildTodayDoses(medications: [Medication], schedules: [Schedule]) -> [ScheduledDose] {
// Combinar medicamentos con sus schedules para el dia actual
var doses: [ScheduledDose] = []
let today = Calendar.current.startOfDay(for: Date())
for medication in medications {
guard let schedule = schedules.first(where: { $0.medicationId == medication.id }),
schedule.isActive else { continue }
for time in schedule.times {
let scheduledTime = Calendar.current.date(
bySettingHour: time.hour,
minute: time.minute,
second: 0,
of: today
) ?? today
doses.append(ScheduledDose(
id: UUID(),
medication: medication,
scheduledTime: scheduledTime,
status: .pending // Would check against dose logs
))
}
}
return doses.sorted { $0.scheduledTime < $1.scheduledTime }
}
private func buildAlerts(medications: [Medication]) -> [Alert] {
var alerts: [Alert] = []
for medication in medications {
// Low inventory
if let inventory = medication.inventory, inventory.isLow {
alerts.append(Alert(
type: .lowInventory,
medication: medication,
message: "Quedan \(Int(inventory.currentQuantity)) \(medication.dosage.unit.abbreviation)"
))
}
// Expiring soon
if let expirationDate = medication.inventory?.expirationDate,
expirationDate < Date().addingTimeInterval(30 * 24 * 3600) {
alerts.append(Alert(
type: .expiringSoon,
medication: medication,
message: "Expira el \(expirationDate.formatted(date: .abbreviated, time: .omitted))"
))
}
}
return alerts
}
}
struct ScheduledDose: Identifiable {
let id: UUID
let medication: Medication
let scheduledTime: Date
var status: DoseLog.DoseStatus
}
struct Alert: Identifiable {
let id = UUID()
let type: AlertType
let medication: Medication
let message: String
enum AlertType {
case lowInventory
case expiringSoon
case missedDose
case interaction
}
}
5.3. Navigation¶
// Presentation/Navigation/AppCoordinator.swift
import SwiftUI
@MainActor
final class AppCoordinator: ObservableObject {
@Published var selectedTab: Tab = .home
@Published var authState: AuthState = .loading
@Published var path = NavigationPath()
@Injected(\.authUseCase) private var authUseCase
enum Tab {
case home, medications, calendar, profile
}
enum AuthState {
case loading
case authenticated(User)
case unauthenticated
}
init() {
checkAuthState()
}
func checkAuthState() {
Task {
do {
if let user = try await authUseCase.getCurrentUser() {
authState = .authenticated(user)
} else {
authState = .unauthenticated
}
} catch {
authState = .unauthenticated
}
}
}
func logout() {
Task {
try? await authUseCase.logout()
authState = .unauthenticated
}
}
}
// Root View
struct RootView: View {
@StateObject private var coordinator = AppCoordinator()
var body: some View {
Group {
switch coordinator.authState {
case .loading:
LoadingView()
case .authenticated:
MainTabView()
.environmentObject(coordinator)
case .unauthenticated:
AuthView()
.environmentObject(coordinator)
}
}
}
}
struct MainTabView: View {
@EnvironmentObject var coordinator: AppCoordinator
var body: some View {
TabView(selection: $coordinator.selectedTab) {
HomeView()
.tabItem {
Label("Inicio", systemImage: "house")
}
.tag(AppCoordinator.Tab.home)
MedicationListView()
.tabItem {
Label("Medicamentos", systemImage: "pills")
}
.tag(AppCoordinator.Tab.medications)
CalendarView()
.tabItem {
Label("Calendario", systemImage: "calendar")
}
.tag(AppCoordinator.Tab.calendar)
ProfileView()
.tabItem {
Label("Perfil", systemImage: "person")
}
.tag(AppCoordinator.Tab.profile)
}
}
}
5.4. UI State Management¶
// Presentation/State/ViewState.swift
enum ViewState<T> {
case idle
case loading
case loaded(T)
case error(Error)
var isLoading: Bool {
if case .loading = self { return true }
return false
}
var value: T? {
if case .loaded(let value) = self { return value }
return nil
}
var error: Error? {
if case .error(let error) = self { return error }
return nil
}
}
// Usage in ViewModel
@MainActor
final class MedicationDetailViewModel: ObservableObject {
@Published var state: ViewState<MedicationDetail> = .idle
func load(medicationId: UUID) async {
state = .loading
do {
let detail = try await loadMedicationDetail(medicationId)
state = .loaded(detail)
} catch {
state = .error(error)
}
}
}
// Generic StateView
struct StateView<T, Content: View, LoadingView: View, ErrorView: View>: View {
let state: ViewState<T>
@ViewBuilder let content: (T) -> Content
@ViewBuilder let loading: () -> LoadingView
@ViewBuilder let error: (Error) -> ErrorView
var body: some View {
switch state {
case .idle:
EmptyView()
case .loading:
loading()
case .loaded(let value):
content(value)
case .error(let err):
error(err)
}
}
}
6. Persistencia Local¶
6.1. SwiftData Models¶
// Data/Models/Persistence/MedicationEntity.swift
import SwiftData
@Model
final class MedicationEntity {
@Attribute(.unique) var id: UUID
var nameSearchable: String // Para busqueda (no cifrado)
var form: String
var route: String
var startDate: Date
var endDate: Date?
var isActive: Bool
var encryptedBlob: Data // Datos sensibles cifrados
var createdAt: Date
var updatedAt: Date
var version: Int64
// Relationships
@Relationship(deleteRule: .cascade, inverse: \ScheduleEntity.medication)
var schedules: [ScheduleEntity]?
@Relationship(deleteRule: .cascade, inverse: \DoseLogEntity.medication)
var doseLogs: [DoseLogEntity]?
init(
id: UUID,
nameSearchable: String,
form: String,
route: String,
startDate: Date,
endDate: Date?,
isActive: Bool,
encryptedBlob: Data,
createdAt: Date,
updatedAt: Date,
version: Int64
) {
self.id = id
self.nameSearchable = nameSearchable
self.form = form
self.route = route
self.startDate = startDate
self.endDate = endDate
self.isActive = isActive
self.encryptedBlob = encryptedBlob
self.createdAt = createdAt
self.updatedAt = updatedAt
self.version = version
}
}
@Model
final class ScheduleEntity {
@Attribute(.unique) var id: UUID
var medicationId: UUID
var timesJson: Data // Serialized [ScheduleTime]
var daysOfWeekJson: Data? // Serialized Set<DayOfWeek>
var isActive: Bool
var createdAt: Date
var updatedAt: Date
var version: Int64
var medication: MedicationEntity?
init(
id: UUID,
medicationId: UUID,
timesJson: Data,
daysOfWeekJson: Data?,
isActive: Bool,
createdAt: Date,
updatedAt: Date,
version: Int64
) {
self.id = id
self.medicationId = medicationId
self.timesJson = timesJson
self.daysOfWeekJson = daysOfWeekJson
self.isActive = isActive
self.createdAt = createdAt
self.updatedAt = updatedAt
self.version = version
}
}
@Model
final class DoseLogEntity {
@Attribute(.unique) var id: UUID
var medicationId: UUID
var scheduledTime: Date
var takenTime: Date?
var status: String
var skipReason: String?
var encryptedNotes: Data? // Notes cifradas
var createdAt: Date
var updatedAt: Date
var version: Int64
var medication: MedicationEntity?
init(
id: UUID,
medicationId: UUID,
scheduledTime: Date,
takenTime: Date?,
status: String,
skipReason: String?,
encryptedNotes: Data?,
createdAt: Date,
updatedAt: Date,
version: Int64
) {
self.id = id
self.medicationId = medicationId
self.scheduledTime = scheduledTime
self.takenTime = takenTime
self.status = status
self.skipReason = skipReason
self.encryptedNotes = encryptedNotes
self.createdAt = createdAt
self.updatedAt = updatedAt
self.version = version
}
}
@Model
final class SyncQueueEntity {
@Attribute(.unique) var id: UUID
var entityType: String
var entityId: UUID
var operation: String
var encryptedBlob: Data
var createdAt: Date
var retryCount: Int
var lastError: String?
init(
id: UUID,
entityType: String,
entityId: UUID,
operation: String,
encryptedBlob: Data,
createdAt: Date,
retryCount: Int,
lastError: String? = nil
) {
self.id = id
self.entityType = entityType
self.entityId = entityId
self.operation = operation
self.encryptedBlob = encryptedBlob
self.createdAt = createdAt
self.retryCount = retryCount
self.lastError = lastError
}
}
6.2. KeyChain Storage¶
// Core/Crypto/KeyChainDataSource.swift
import Security
protocol KeyChainDataSourceProtocol {
func save(_ data: Data, for key: String) throws
func get(_ key: String) throws -> Data?
func delete(_ key: String) throws
func exists(_ key: String) -> Bool
}
final class KeyChainDataSource: KeyChainDataSourceProtocol {
private let service: String
private let accessGroup: String?
init(
service: String = Bundle.main.bundleIdentifier ?? "com.medtime",
accessGroup: String? = nil
) {
self.service = service
self.accessGroup = accessGroup
}
func save(_ data: Data, for key: String) throws {
// Eliminar existente primero
try? delete(key)
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
]
if let accessGroup {
query[kSecAttrAccessGroup as String] = accessGroup
}
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeyChainError.saveFailed(status)
}
}
func get(_ key: String) throws -> Data? {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
if let accessGroup {
query[kSecAttrAccessGroup as String] = accessGroup
}
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
switch status {
case errSecSuccess:
return result as? Data
case errSecItemNotFound:
return nil
default:
throw KeyChainError.readFailed(status)
}
}
func delete(_ key: String) throws {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
if let accessGroup {
query[kSecAttrAccessGroup as String] = accessGroup
}
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeyChainError.deleteFailed(status)
}
}
func exists(_ key: String) -> Bool {
do {
return try get(key) != nil
} catch {
return false
}
}
}
enum KeyChainError: Error {
case saveFailed(OSStatus)
case readFailed(OSStatus)
case deleteFailed(OSStatus)
}
// Keys used in KeyChain
enum KeyChainKey: String {
case masterKey = "medtime.master_key"
case authToken = "medtime.auth_token"
case refreshToken = "medtime.refresh_token"
case deviceId = "medtime.device_id"
case biometricKey = "medtime.biometric_key"
}
6.3. UserDefaults¶
// Data/DataSources/Local/UserDefaultsDataSource.swift
import Foundation
protocol UserDefaultsDataSourceProtocol {
func get<T: Codable>(_ key: UserDefaultsKey) -> T?
func set<T: Codable>(_ value: T, for key: UserDefaultsKey)
func remove(_ key: UserDefaultsKey)
}
enum UserDefaultsKey: String {
// Preferences
case notificationsEnabled = "notifications_enabled"
case soundEnabled = "sound_enabled"
case hapticEnabled = "haptic_enabled"
case reminderMinutesBefore = "reminder_minutes_before"
// App State
case lastSyncVersion = "last_sync_version"
case lastSyncDate = "last_sync_date"
case onboardingCompleted = "onboarding_completed"
case privacyConsentVersion = "privacy_consent_version"
// Cache
case cachedUserName = "cached_user_name"
case cachedTier = "cached_tier"
}
final class UserDefaultsDataSource: UserDefaultsDataSourceProtocol {
private let defaults: UserDefaults
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
init(suiteName: String? = nil) {
if let suiteName {
self.defaults = UserDefaults(suiteName: suiteName) ?? .standard
} else {
self.defaults = .standard
}
}
func get<T: Codable>(_ key: UserDefaultsKey) -> T? {
guard let data = defaults.data(forKey: key.rawValue) else {
return nil
}
return try? decoder.decode(T.self, from: data)
}
func set<T: Codable>(_ value: T, for key: UserDefaultsKey) {
guard let data = try? encoder.encode(value) else { return }
defaults.set(data, forKey: key.rawValue)
}
func remove(_ key: UserDefaultsKey) {
defaults.removeObject(forKey: key.rawValue)
}
}
// Convenience property wrappers
@propertyWrapper
struct UserDefault<T: Codable> {
let key: UserDefaultsKey
let defaultValue: T
let dataSource: UserDefaultsDataSourceProtocol
var wrappedValue: T {
get { dataSource.get(key) ?? defaultValue }
set { dataSource.set(newValue, for: key) }
}
}
6.4. Migraciones¶
// Data/Migrations/MigrationManager.swift
import SwiftData
final class MigrationManager {
static let currentSchemaVersion = 1
static func migrate(container: ModelContainer) async throws {
let storedVersion = UserDefaults.standard.integer(forKey: "schema_version")
guard storedVersion < currentSchemaVersion else { return }
// Run migrations sequentially
if storedVersion < 1 {
try await migrateToV1(container: container)
}
// Update stored version
UserDefaults.standard.set(currentSchemaVersion, forKey: "schema_version")
}
private static func migrateToV1(container: ModelContainer) async throws {
// Initial schema - no migration needed
}
}
// Schema versioning with SwiftData
enum SchemaV1: VersionedSchema {
static var versionIdentifier: Schema.Version = .init(1, 0, 0)
static var models: [any PersistentModel.Type] {
[
MedicationEntity.self,
ScheduleEntity.self,
DoseLogEntity.self,
SyncQueueEntity.self,
CacheEntity.self
]
}
}
// Future migrations would add SchemaV2, etc.
7. Networking¶
7.1. API Client¶
(Ver seccion 4.2 para implementacion completa de APIClient)
7.2. Request/Response¶
// Data/DataSources/Remote/Endpoints.swift
enum APIEndpoint {
// Auth
case login(email: String, password: String)
case register(email: String, password: String, name: String)
case refreshToken
case logout
// Sync
case syncPush(blobs: [SyncBlobDTO])
case syncPull(sinceVersion: Int64, deviceId: UUID)
// Catalog
case searchDrugs(query: String, page: Int, pageSize: Int)
case getDrug(id: UUID)
case getCatalogBundle
// Studies
case getStudiesCatalog
case getStudyUpdates(sinceVersion: String)
var endpoint: Endpoint {
switch self {
case .login(let email, let password):
return Endpoint(
path: "/v1/auth/login",
method: .POST,
headers: [:],
body: LoginRequest(email: email, password: password)
)
case .register(let email, let password, let name):
return Endpoint(
path: "/v1/auth/register",
method: .POST,
headers: [:],
body: RegisterRequest(email: email, password: password, name: name)
)
case .refreshToken:
return Endpoint(
path: "/v1/auth/refresh",
method: .POST,
headers: [:],
body: nil
)
case .logout:
return Endpoint(
path: "/v1/auth/logout",
method: .POST,
headers: [:],
body: nil
)
case .syncPush(let blobs):
return Endpoint(
path: "/v1/sync/push",
method: .POST,
headers: [:],
body: PushRequest(blobs: blobs)
)
case .syncPull(let sinceVersion, let deviceId):
return Endpoint(
path: "/v1/sync/pull?since_version=\(sinceVersion)&device_id=\(deviceId)",
method: .GET,
headers: [:],
body: nil
)
case .searchDrugs(let query, let page, let pageSize):
let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query
return Endpoint(
path: "/v1/catalog/drugs?q=\(encodedQuery)&page=\(page)&page_size=\(pageSize)",
method: .GET,
headers: ["X-Privacy-Consent": "accepted"],
body: nil
)
case .getDrug(let id):
return Endpoint(
path: "/v1/catalog/drugs/\(id)",
method: .GET,
headers: [:],
body: nil
)
case .getCatalogBundle:
return Endpoint(
path: "/v1/catalog/bundle",
method: .GET,
headers: [:],
body: nil
)
case .getStudiesCatalog:
return Endpoint(
path: "/v1/catalog/studies",
method: .GET,
headers: [:],
body: nil
)
case .getStudyUpdates(let sinceVersion):
return Endpoint(
path: "/v1/catalog/studies/updates?since_version=\(sinceVersion)",
method: .GET,
headers: [:],
body: nil
)
}
}
}
// DTOs
struct LoginRequest: Encodable {
let email: String
let password: String
}
struct RegisterRequest: Encodable {
let email: String
let password: String
let name: String
}
struct PushRequest: Encodable {
let blobs: [SyncBlobDTO]
}
struct SyncBlobDTO: Codable {
let entityId: UUID
let entityType: String
let encryptedBlob: Data
let clientVersion: Int64
let isDeleted: Bool
enum CodingKeys: String, CodingKey {
case entityId = "entity_id"
case entityType = "entity_type"
case encryptedBlob = "encrypted_blob"
case clientVersion = "client_version"
case isDeleted = "is_deleted"
}
}
struct PullResponse: Decodable {
let blobs: [SyncBlobDTO]
let serverVersion: Int64
let hasMore: Bool
enum CodingKeys: String, CodingKey {
case blobs
case serverVersion = "server_version"
case hasMore = "has_more"
}
}
struct PushResponse: Decodable {
let accepted: [AcceptedBlob]
let rejected: [RejectedBlob]
let serverVersion: Int64
struct AcceptedBlob: Decodable {
let entityId: UUID
let newVersion: Int64
enum CodingKeys: String, CodingKey {
case entityId = "entity_id"
case newVersion = "new_version"
}
}
struct RejectedBlob: Decodable {
let entityId: UUID
let reason: String
let serverVersion: Int64
enum CodingKeys: String, CodingKey {
case entityId = "entity_id"
case reason
case serverVersion = "server_version"
}
}
enum CodingKeys: String, CodingKey {
case accepted
case rejected
case serverVersion = "server_version"
}
}
7.3. Interceptors¶
// Data/DataSources/Remote/Interceptors/AuthInterceptor.swift
protocol AuthInterceptorProtocol {
func intercept(_ request: URLRequest) async throws -> URLRequest
}
final class AuthInterceptor: AuthInterceptorProtocol {
private let keyChain: KeyChainDataSourceProtocol
private let tokenRefresher: TokenRefresherProtocol
init(
keyChain: KeyChainDataSourceProtocol = KeyChainDataSource(),
tokenRefresher: TokenRefresherProtocol? = nil
) {
self.keyChain = keyChain
self.tokenRefresher = tokenRefresher ?? TokenRefresher(keyChain: keyChain)
}
func intercept(_ request: URLRequest) async throws -> URLRequest {
var request = request
// Verificar si el endpoint requiere auth
guard !isPublicEndpoint(request.url) else {
return request
}
// Obtener token
guard let tokenData = try keyChain.get(KeyChainKey.authToken.rawValue),
let token = String(data: tokenData, encoding: .utf8) else {
throw APIError.unauthorized
}
// Verificar expiracion
if isTokenExpired(token) {
let newToken = try await tokenRefresher.refresh()
request.setValue("Bearer \(newToken)", forHTTPHeaderField: "Authorization")
} else {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
return request
}
private func isPublicEndpoint(_ url: URL?) -> Bool {
guard let path = url?.path else { return false }
let publicPaths = [
"/v1/auth/login",
"/v1/auth/register",
"/v1/catalog/bundle"
]
return publicPaths.contains { path.hasPrefix($0) }
}
private func isTokenExpired(_ token: String) -> Bool {
// Decode JWT and check exp claim
guard let payload = decodeJWTPayload(token),
let exp = payload["exp"] as? TimeInterval else {
return true
}
return Date(timeIntervalSince1970: exp) < Date()
}
private func decodeJWTPayload(_ token: String) -> [String: Any]? {
let parts = token.split(separator: ".")
guard parts.count == 3,
let payloadData = Data(base64Encoded: String(parts[1]).base64Padded) else {
return nil
}
return try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any]
}
}
// Data/DataSources/Remote/Interceptors/RetryInterceptor.swift
protocol RetryInterceptorProtocol {
func execute<T>(_ operation: () async throws -> T) async throws -> T
}
final class RetryInterceptor: RetryInterceptorProtocol {
private let maxRetries: Int
private let retryDelay: TimeInterval
init(maxRetries: Int = 3, retryDelay: TimeInterval = 1.0) {
self.maxRetries = maxRetries
self.retryDelay = retryDelay
}
func execute<T>(_ operation: () async throws -> T) async throws -> T {
var lastError: Error?
for attempt in 0..<maxRetries {
do {
return try await operation()
} catch let error as APIError {
lastError = error
// No retry for these errors
switch error {
case .unauthorized, .forbidden, .notFound, .conflict:
throw error
case .rateLimited(let retryAfter):
if let retryAfter, let seconds = Int(retryAfter) {
try await Task.sleep(nanoseconds: UInt64(seconds) * 1_000_000_000)
}
continue
case .serverError, .httpError, .networkError:
// Exponential backoff
let delay = retryDelay * pow(2.0, Double(attempt))
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
continue
default:
throw error
}
} catch {
lastError = error
// Exponential backoff for unknown errors
let delay = retryDelay * pow(2.0, Double(attempt))
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
}
}
throw lastError ?? APIError.unknown
}
}
7.4. Error Mapping¶
// Data/DataSources/Remote/APIError.swift
enum APIError: Error, LocalizedError {
case invalidURL
case invalidResponse
case unauthorized
case forbidden
case notFound
case conflict
case rateLimited(retryAfter: String?)
case serverError(Int)
case httpError(Int)
case networkError(Error)
case decodingError(Error)
case unknown
var errorDescription: String? {
switch self {
case .invalidURL:
return "URL invalida"
case .invalidResponse:
return "Respuesta invalida del servidor"
case .unauthorized:
return "Sesion expirada. Inicia sesion nuevamente."
case .forbidden:
return "No tienes permiso para esta accion"
case .notFound:
return "Recurso no encontrado"
case .conflict:
return "Conflicto de datos. Sincroniza e intenta de nuevo."
case .rateLimited:
return "Demasiadas solicitudes. Espera un momento."
case .serverError(let code):
return "Error del servidor (\(code))"
case .httpError(let code):
return "Error HTTP (\(code))"
case .networkError:
return "Error de red. Verifica tu conexion."
case .decodingError:
return "Error al procesar datos"
case .unknown:
return "Error desconocido"
}
}
var isRetryable: Bool {
switch self {
case .serverError, .networkError, .rateLimited:
return true
default:
return false
}
}
}
// Mapping to DomainError
extension APIError {
func toDomainError() -> DomainError {
switch self {
case .unauthorized:
return .userNotFound
case .conflict:
return .syncFailed(underlying: self)
case .networkError:
return .offlineOperationFailed
default:
return .unknown(self)
}
}
}
8. Cifrado E2E¶
8.1. CryptoKit Implementation¶
// Core/Crypto/CryptoManager.swift
import CryptoKit
import Foundation
protocol CryptoManagerProtocol {
func generateMasterKey() throws -> SymmetricKey
func deriveMasterKey(from password: String, salt: Data) throws -> SymmetricKey
func encrypt(_ data: Data) throws -> Data
func decrypt(_ encryptedData: Data) throws -> Data
func hash(_ data: Data) -> String
}
final class CryptoManager: CryptoManagerProtocol {
private let keyChain: KeyChainDataSourceProtocol
private var masterKey: SymmetricKey?
// Crypto constants
private let keySize = SymmetricKeySize.bits256
private let saltSize = 32
private let iterationCount = 100_000
init(keyChain: KeyChainDataSourceProtocol) {
self.keyChain = keyChain
loadMasterKey()
}
// MARK: - Key Generation
func generateMasterKey() throws -> SymmetricKey {
let key = SymmetricKey(size: keySize)
try saveMasterKey(key)
self.masterKey = key
return key
}
func deriveMasterKey(from password: String, salt: Data) throws -> SymmetricKey {
guard let passwordData = password.data(using: .utf8) else {
throw CryptoError.invalidPassword
}
// PBKDF2 derivation
var derivedKey = Data(count: 32)
let result = derivedKey.withUnsafeMutableBytes { derivedKeyBytes in
salt.withUnsafeBytes { saltBytes in
passwordData.withUnsafeBytes { passwordBytes in
CCKeyDerivationPBKDF(
CCPBKDFAlgorithm(kCCPBKDF2),
passwordBytes.baseAddress?.assumingMemoryBound(to: Int8.self),
passwordData.count,
saltBytes.baseAddress?.assumingMemoryBound(to: UInt8.self),
salt.count,
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
UInt32(iterationCount),
derivedKeyBytes.baseAddress?.assumingMemoryBound(to: UInt8.self),
32
)
}
}
}
guard result == kCCSuccess else {
throw CryptoError.keyDerivationFailed
}
let key = SymmetricKey(data: derivedKey)
try saveMasterKey(key)
self.masterKey = key
return key
}
// MARK: - Encryption
func encrypt(_ data: Data) throws -> Data {
guard let key = masterKey else {
throw CryptoError.keyNotFound
}
// Generate random nonce
let nonce = try AES.GCM.Nonce()
// Encrypt with AES-GCM
let sealedBox = try AES.GCM.seal(data, using: key, nonce: nonce)
// Return combined: nonce + ciphertext + tag
guard let combined = sealedBox.combined else {
throw CryptoError.encryptionFailed
}
return combined
}
func decrypt(_ encryptedData: Data) throws -> Data {
guard let key = masterKey else {
throw CryptoError.keyNotFound
}
// Parse sealed box from combined data
let sealedBox = try AES.GCM.SealedBox(combined: encryptedData)
// Decrypt
let decryptedData = try AES.GCM.open(sealedBox, using: key)
return decryptedData
}
// MARK: - Hashing
func hash(_ data: Data) -> String {
let digest = SHA256.hash(data: data)
return digest.map { String(format: "%02x", $0) }.joined()
}
// MARK: - Private
private func loadMasterKey() {
guard let keyData = try? keyChain.get(KeyChainKey.masterKey.rawValue) else {
return
}
masterKey = SymmetricKey(data: keyData)
}
private func saveMasterKey(_ key: SymmetricKey) throws {
let keyData = key.withUnsafeBytes { Data($0) }
try keyChain.save(keyData, for: KeyChainKey.masterKey.rawValue)
}
}
enum CryptoError: Error {
case keyNotFound
case keyDerivationFailed
case encryptionFailed
case decryptionFailed
case invalidPassword
case invalidData
case notInitialized
}
8.2. Patron de Inicializacion Segura (Actor-Based)¶
CRITICO: El CryptoManager debe garantizar inicializacion thread-safe para evitar race conditions durante acceso concurrente. Usar patron actor con ensureInitialized().
// Core/Crypto/CryptoManagerActor.swift
import CryptoKit
import Foundation
import Swift_Argon2 // Swift-Argon2 library
/// Actor-based CryptoManager para inicializacion thread-safe
/// Resuelve PERF-004: race condition en inicializacion concurrente
actor CryptoManagerActor: CryptoManagerProtocol {
// MARK: - State
private enum InitializationState {
case uninitialized
case initializing(Task<Void, Error>)
case initialized(SymmetricKey)
case failed(Error)
}
private var state: InitializationState = .uninitialized
private let keyChain: KeyChainDataSourceProtocol
// Argon2id parameters (Decision del Director #2 - DV2)
private let argon2Memory: UInt32 = 65536 // 64 MiB
private let argon2Iterations: UInt32 = 3 // t=3
private let argon2Parallelism: UInt32 = 4 // p=4
private let keySize = 32 // 256 bits
// MARK: - Init
init(keyChain: KeyChainDataSourceProtocol) {
self.keyChain = keyChain
}
// MARK: - Public API
/// Garantiza que el CryptoManager esta inicializado antes de cualquier operacion
/// Patron: ensureInitialized() - llamar antes de encrypt/decrypt
func ensureInitialized() async throws {
switch state {
case .uninitialized:
// Iniciar inicializacion
let task = Task {
try await self.performInitialization()
}
state = .initializing(task)
try await task.value
case .initializing(let task):
// Esperar inicializacion en progreso (evita race condition)
try await task.value
case .initialized:
// Ya inicializado, no-op
return
case .failed(let error):
// Re-throw error previo
throw error
}
}
func encrypt(_ data: Data) async throws -> Data {
try await ensureInitialized()
guard case .initialized(let key) = state else {
throw CryptoError.notInitialized
}
let nonce = try AES.GCM.Nonce()
let sealedBox = try AES.GCM.seal(data, using: key, nonce: nonce)
guard let combined = sealedBox.combined else {
throw CryptoError.encryptionFailed
}
return combined
}
func decrypt(_ encryptedData: Data) async throws -> Data {
try await ensureInitialized()
guard case .initialized(let key) = state else {
throw CryptoError.notInitialized
}
let sealedBox = try AES.GCM.SealedBox(combined: encryptedData)
return try AES.GCM.open(sealedBox, using: key)
}
func deriveMasterKey(from password: String, salt: Data) async throws -> SymmetricKey {
guard let passwordData = password.data(using: .utf8) else {
throw CryptoError.invalidPassword
}
// Argon2id key derivation (reemplaza PBKDF2)
let derivedKey = try Argon2.hash(
password: passwordData,
salt: salt,
iterations: argon2Iterations,
memory: argon2Memory,
parallelism: argon2Parallelism,
length: UInt32(keySize),
variant: .id
)
let key = SymmetricKey(data: derivedKey)
try await saveMasterKey(key)
state = .initialized(key)
return key
}
func generateMasterKey() async throws -> SymmetricKey {
let key = SymmetricKey(size: .bits256)
try await saveMasterKey(key)
state = .initialized(key)
return key
}
nonisolated func hash(_ data: Data) -> String {
let digest = SHA256.hash(data: data)
return digest.map { String(format: "%02x", $0) }.joined()
}
// MARK: - Private
private func performInitialization() async throws {
do {
if let keyData = try? keyChain.get(KeyChainKey.masterKey.rawValue) {
let key = SymmetricKey(data: keyData)
state = .initialized(key)
} else {
// No key found - waiting for setup or derivation
state = .uninitialized
}
} catch {
state = .failed(error)
throw error
}
}
private func saveMasterKey(_ key: SymmetricKey) async throws {
let keyData = key.withUnsafeBytes { Data($0) }
try keyChain.save(keyData, for: KeyChainKey.masterKey.rawValue)
}
}
// MARK: - Usage Example
/*
// Uso correcto del CryptoManagerActor
class MedicationRepository {
private let crypto: CryptoManagerActor
func saveMedication(_ med: Medication) async throws {
// ensureInitialized() es llamado automaticamente por encrypt()
let encrypted = try await crypto.encrypt(med.toData())
try await database.save(encrypted)
}
}
// Multiples llamadas concurrentes son seguras:
async let result1 = crypto.encrypt(data1)
async let result2 = crypto.encrypt(data2)
async let result3 = crypto.encrypt(data3)
// Todas esperan la misma inicializacion sin race condition
*/
8.3. Key Management¶
// Core/Crypto/KeyManager.swift
protocol KeyManagerProtocol {
func setupKeys(password: String) async throws
func unlockWithBiometrics() async throws -> Bool
func changePassword(from oldPassword: String, to newPassword: String) async throws
func deleteAllKeys() throws
}
final class KeyManager: KeyManagerProtocol {
private let cryptoManager: CryptoManagerProtocol
private let keyChain: KeyChainDataSourceProtocol
private let biometricAuth: BiometricAuthProtocol
init(
cryptoManager: CryptoManagerProtocol,
keyChain: KeyChainDataSourceProtocol,
biometricAuth: BiometricAuthProtocol
) {
self.cryptoManager = cryptoManager
self.keyChain = keyChain
self.biometricAuth = biometricAuth
}
func setupKeys(password: String) async throws {
// Generate random salt
var salt = Data(count: 32)
let result = salt.withUnsafeMutableBytes {
SecRandomCopyBytes(kSecRandomDefault, 32, $0.baseAddress!)
}
guard result == errSecSuccess else {
throw CryptoError.keyDerivationFailed
}
// Save salt
try keyChain.save(salt, for: "master_key_salt")
// Derive and save master key
_ = try cryptoManager.deriveMasterKey(from: password, salt: salt)
// Optionally enable biometrics
if await biometricAuth.isAvailable() {
try await enableBiometrics(password: password)
}
}
func unlockWithBiometrics() async throws -> Bool {
guard await biometricAuth.isAvailable() else {
return false
}
let success = await biometricAuth.authenticate(reason: "Desbloquear MedTime")
if success {
// Load encrypted password from keychain (protected by biometric)
if let encryptedPassword = try keyChain.get(KeyChainKey.biometricKey.rawValue),
let salt = try keyChain.get("master_key_salt") {
// The biometric key is the password itself, stored with biometric protection
let password = String(data: encryptedPassword, encoding: .utf8) ?? ""
_ = try cryptoManager.deriveMasterKey(from: password, salt: salt)
return true
}
}
return false
}
func changePassword(from oldPassword: String, to newPassword: String) async throws {
// Verify old password
guard let salt = try keyChain.get("master_key_salt") else {
throw CryptoError.keyNotFound
}
// Re-derive with old password to verify
_ = try cryptoManager.deriveMasterKey(from: oldPassword, salt: salt)
// Generate new salt
var newSalt = Data(count: 32)
_ = newSalt.withUnsafeMutableBytes {
SecRandomCopyBytes(kSecRandomDefault, 32, $0.baseAddress!)
}
// Re-encrypt all data with new key
// This would involve:
// 1. Decrypt all blobs with old key
// 2. Generate new key
// 3. Re-encrypt all blobs with new key
// 4. Save new salt
try keyChain.save(newSalt, for: "master_key_salt")
_ = try cryptoManager.deriveMasterKey(from: newPassword, salt: newSalt)
// Update biometric key if enabled
if await biometricAuth.isAvailable() {
try await enableBiometrics(password: newPassword)
}
}
func deleteAllKeys() throws {
try keyChain.delete(KeyChainKey.masterKey.rawValue)
try keyChain.delete("master_key_salt")
try keyChain.delete(KeyChainKey.biometricKey.rawValue)
}
private func enableBiometrics(password: String) async throws {
// Store password in keychain with biometric protection
guard let passwordData = password.data(using: .utf8) else {
throw CryptoError.invalidPassword
}
// This requires special keychain access control
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: KeyChainKey.biometricKey.rawValue,
kSecValueData as String: passwordData,
kSecAttrAccessControl as String: try createBiometricAccessControl()
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeyChainError.saveFailed(status)
}
}
private func createBiometricAccessControl() throws -> SecAccessControl {
var error: Unmanaged<CFError>?
guard let access = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.biometryCurrentSet,
&error
) else {
throw error?.takeRetainedValue() ?? CryptoError.keyDerivationFailed
}
return access
}
}
8.4. Blob Encryption¶
// Core/Crypto/BlobEncryption.swift
protocol BlobEncryptionProtocol {
func encryptBlob<T: Encodable>(_ object: T) throws -> Data
func decryptBlob<T: Decodable>(_ data: Data, as type: T.Type) throws -> T
}
final class BlobEncryption: BlobEncryptionProtocol {
private let cryptoManager: CryptoManagerProtocol
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
init(cryptoManager: CryptoManagerProtocol) {
self.cryptoManager = cryptoManager
encoder.dateEncodingStrategy = .iso8601
decoder.dateDecodingStrategy = .iso8601
}
func encryptBlob<T: Encodable>(_ object: T) throws -> Data {
// 1. Serialize to JSON
let jsonData = try encoder.encode(object)
// 2. Compress (optional, for large blobs)
let compressedData = try compress(jsonData)
// 3. Encrypt
let encryptedData = try cryptoManager.encrypt(compressedData)
return encryptedData
}
func decryptBlob<T: Decodable>(_ data: Data, as type: T.Type) throws -> T {
// 1. Decrypt
let decryptedData = try cryptoManager.decrypt(data)
// 2. Decompress
let decompressedData = try decompress(decryptedData)
// 3. Deserialize
let object = try decoder.decode(type, from: decompressedData)
return object
}
private func compress(_ data: Data) throws -> Data {
// Use built-in compression
guard let compressed = try? (data as NSData).compressed(using: .lzfse) else {
return data // Return uncompressed if compression fails
}
return compressed as Data
}
private func decompress(_ data: Data) throws -> Data {
// Try to decompress, return original if fails (might not be compressed)
guard let decompressed = try? (data as NSData).decompressed(using: .lzfse) else {
return data
}
return decompressed as Data
}
}
8.5. Zero-Knowledge Flows¶
// Core/Crypto/ZeroKnowledgeManager.swift
/// Manages Zero-Knowledge architecture flows
/// The server NEVER sees plaintext PHI data
final class ZeroKnowledgeManager {
private let blobEncryption: BlobEncryptionProtocol
private let syncRepository: SyncRepositoryProtocol
init(
blobEncryption: BlobEncryptionProtocol,
syncRepository: SyncRepositoryProtocol
) {
self.blobEncryption = blobEncryption
self.syncRepository = syncRepository
}
/// Prepare data for server sync (encrypt locally first)
func prepareForSync<T: Encodable & Identifiable>(
_ entity: T,
entityType: EntityType,
operation: SyncOperation
) throws -> SyncBlobDTO where T.ID == UUID {
// Encrypt the entire entity
let encryptedBlob = try blobEncryption.encryptBlob(entity)
return SyncBlobDTO(
entityId: entity.id,
entityType: entityType.rawValue,
encryptedBlob: encryptedBlob,
clientVersion: 1,
isDeleted: operation == .delete
)
}
/// Process data received from server (decrypt locally)
func processFromServer<T: Decodable>(
_ blob: SyncBlobDTO,
as type: T.Type
) throws -> T {
try blobEncryption.decryptBlob(blob.encryptedBlob, as: type)
}
/// Verify that server cannot read data
/// This is called during development/testing to ensure compliance
func verifyZeroKnowledge(blob: SyncBlobDTO) -> ZeroKnowledgeVerification {
// Server should only see:
// - entityId (UUID)
// - entityType (string)
// - encryptedBlob (opaque bytes)
// - timestamps
// - version numbers
let serverVisibleFields: Set<String> = [
"entity_id",
"entity_type",
"encrypted_blob",
"client_version",
"is_deleted"
]
// Verify blob is encrypted (starts with valid AES-GCM header)
let isEncrypted = blob.encryptedBlob.count >= 28 // Minimum: 12 nonce + 16 tag
return ZeroKnowledgeVerification(
isCompliant: isEncrypted,
serverVisibleFields: serverVisibleFields,
phiExposed: !isEncrypted
)
}
}
struct ZeroKnowledgeVerification {
let isCompliant: Bool
let serverVisibleFields: Set<String>
let phiExposed: Bool
}
9. Offline-First Engine¶
9.1. Sync Manager¶
// Core/Sync/SyncManager.swift
import Combine
protocol SyncManagerProtocol {
var syncStatePublisher: AnyPublisher<SyncState, Never> { get }
func sync() async
func enqueue(_ operation: SyncOperation) async
func retryFailed() async
}
enum SyncState {
case idle
case syncing(progress: Double)
case completed(result: SyncResult)
case failed(error: Error)
case offline
}
final class SyncManager: SyncManagerProtocol {
private let syncUseCase: SyncDataUseCaseProtocol
private let networkMonitor: NetworkMonitorProtocol
private let syncQueue: SyncQueueProtocol
private let stateSubject = CurrentValueSubject<SyncState, Never>(.idle)
var syncStatePublisher: AnyPublisher<SyncState, Never> {
stateSubject.eraseToAnyPublisher()
}
private var cancellables = Set<AnyCancellable>()
private var syncTask: Task<Void, Never>?
init(
syncUseCase: SyncDataUseCaseProtocol,
networkMonitor: NetworkMonitorProtocol,
syncQueue: SyncQueueProtocol = SyncQueue()
) {
self.syncUseCase = syncUseCase
self.networkMonitor = networkMonitor
self.syncQueue = syncQueue
setupNetworkObserver()
}
private func setupNetworkObserver() {
networkMonitor.isConnectedPublisher
.removeDuplicates()
.sink { [weak self] isConnected in
if isConnected {
Task { await self?.sync() }
} else {
self?.stateSubject.send(.offline)
}
}
.store(in: &cancellables)
}
func sync() async {
// Cancel existing sync
syncTask?.cancel()
// Check network
guard networkMonitor.isConnected else {
stateSubject.send(.offline)
return
}
stateSubject.send(.syncing(progress: 0))
syncTask = Task {
do {
let result = try await syncUseCase.execute()
stateSubject.send(.completed(result: result))
} catch {
stateSubject.send(.failed(error: error))
}
}
await syncTask?.value
}
func enqueue(_ operation: SyncOperation) async {
await syncQueue.add(operation)
// Auto-sync if connected
if networkMonitor.isConnected {
await sync()
}
}
func retryFailed() async {
await syncQueue.retryFailed()
await sync()
}
}
9.2. Conflict Resolution¶
// Core/Sync/ConflictResolver.swift
protocol ConflictResolverProtocol {
func canAutoResolve(_ conflict: SyncConflict) -> Bool
func autoResolve(_ conflict: SyncConflict) async -> ConflictResolution
func merge(_ conflict: SyncConflict) async throws -> Data
}
/// Last-Write-Wins conflict resolver with smart merging
final class ConflictResolver: ConflictResolverProtocol {
func canAutoResolve(_ conflict: SyncConflict) -> Bool {
// Auto-resolve if:
// 1. Version difference is 1 (simple sequential edit)
// 2. Changes are in different fields
// 3. One side is a delete
let versionDiff = abs(conflict.serverVersion - conflict.localVersion)
return versionDiff <= 1
}
func autoResolve(_ conflict: SyncConflict) async -> ConflictResolution {
// Last-Write-Wins by default
// Compare timestamps if available
// For now, prefer server version to ensure consistency
return .keepServer
}
func merge(_ conflict: SyncConflict) async throws -> Data {
// Attempt field-level merge
// This is entity-specific
switch conflict.entityType {
case .medication:
return try await mergeMedication(conflict)
case .doseLog:
// Dose logs are append-only, shouldn't conflict
return conflict.serverData
default:
// Default to server
return conflict.serverData
}
}
private func mergeMedication(_ conflict: SyncConflict) async throws -> Data {
// Decode both versions
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let local = try decoder.decode(MedicationMergeDTO.self, from: conflict.localData)
let server = try decoder.decode(MedicationMergeDTO.self, from: conflict.serverData)
// Merge strategy: take most recent value for each field
var merged = MedicationMergeDTO(
name: local.nameUpdatedAt > server.nameUpdatedAt ? local.name : server.name,
nameUpdatedAt: max(local.nameUpdatedAt, server.nameUpdatedAt),
dosage: local.dosageUpdatedAt > server.dosageUpdatedAt ? local.dosage : server.dosage,
dosageUpdatedAt: max(local.dosageUpdatedAt, server.dosageUpdatedAt),
// ... other fields
version: max(local.version, server.version) + 1
)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
return try encoder.encode(merged)
}
}
// Internal DTO for merging
private struct MedicationMergeDTO: Codable {
var name: String
var nameUpdatedAt: Date
var dosage: String
var dosageUpdatedAt: Date
var version: Int64
}
9.3. Operation Queue¶
// Core/Sync/SyncQueue.swift
protocol SyncQueueProtocol {
func add(_ operation: SyncOperation) async
func getAll() async -> [SyncOperation]
func remove(_ id: UUID) async
func retryFailed() async
func clear() async
}
actor SyncQueue: SyncQueueProtocol {
private let localDataSource: SwiftDataSourceProtocol
private let maxRetries = 3
init(localDataSource: SwiftDataSourceProtocol = SwiftDataSource.shared) {
self.localDataSource = localDataSource
}
func add(_ operation: SyncOperation) async {
let entity = SyncQueueEntity(
id: UUID(),
entityType: operation.entityType.rawValue,
entityId: operation.entityId,
operation: operation.operation.rawValue,
encryptedBlob: operation.encryptedBlob,
createdAt: Date(),
retryCount: 0
)
try? await localDataSource.save(entity)
}
func getAll() async -> [SyncOperation] {
let entities = (try? await localDataSource.fetchAll(SyncQueueEntity.self)) ?? []
return entities
.filter { $0.retryCount < maxRetries }
.sorted { $0.createdAt < $1.createdAt }
.compactMap { entity in
guard let entityType = EntityType(rawValue: entity.entityType),
let operation = SyncOperation(rawValue: entity.operation) else {
return nil
}
return SyncOperation(
entityType: entityType,
entityId: entity.entityId,
operation: operation,
encryptedBlob: entity.encryptedBlob
)
}
}
func remove(_ id: UUID) async {
try? await localDataSource.delete(
SyncQueueEntity.self,
predicate: #Predicate { $0.entityId == id }
)
}
func retryFailed() async {
// Reset retry counts for failed operations
let entities = (try? await localDataSource.fetchAll(SyncQueueEntity.self)) ?? []
for var entity in entities where entity.retryCount >= maxRetries {
entity.retryCount = 0
try? await localDataSource.save(entity)
}
}
func clear() async {
let entities = (try? await localDataSource.fetchAll(SyncQueueEntity.self)) ?? []
for entity in entities {
try? await localDataSource.delete(
SyncQueueEntity.self,
predicate: #Predicate { $0.id == entity.id }
)
}
}
}
struct SyncOperation: Equatable {
let entityType: EntityType
let entityId: UUID
let operation: OperationType
let encryptedBlob: Data
enum OperationType: String {
case create, update, delete
}
}
9.4. Network Monitor¶
// Core/Sync/NetworkMonitor.swift
import Network
import Combine
protocol NetworkMonitorProtocol {
var isConnected: Bool { get }
var isConnectedPublisher: AnyPublisher<Bool, Never> { get }
var connectionType: ConnectionType { get }
}
enum ConnectionType {
case wifi
case cellular
case ethernet
case unknown
case none
}
final class NetworkMonitor: NetworkMonitorProtocol {
private let monitor: NWPathMonitor
private let queue = DispatchQueue(label: "NetworkMonitor")
private let connectedSubject = CurrentValueSubject<Bool, Never>(false)
private var _connectionType: ConnectionType = .none
var isConnected: Bool { connectedSubject.value }
var isConnectedPublisher: AnyPublisher<Bool, Never> {
connectedSubject.eraseToAnyPublisher()
}
var connectionType: ConnectionType { _connectionType }
init() {
monitor = NWPathMonitor()
startMonitoring()
}
deinit {
monitor.cancel()
}
private func startMonitoring() {
monitor.pathUpdateHandler = { [weak self] path in
let isConnected = path.status == .satisfied
self?.connectedSubject.send(isConnected)
self?._connectionType = self?.getConnectionType(path) ?? .none
}
monitor.start(queue: queue)
}
private func getConnectionType(_ path: NWPath) -> ConnectionType {
if path.usesInterfaceType(.wifi) {
return .wifi
} else if path.usesInterfaceType(.cellular) {
return .cellular
} else if path.usesInterfaceType(.wiredEthernet) {
return .ethernet
} else if path.status == .satisfied {
return .unknown
}
return .none
}
}
10. Push Notifications¶
10.1. APNs Registration¶
// Core/Notifications/NotificationManager.swift
import UserNotifications
import UIKit
protocol NotificationManagerProtocol {
func requestAuthorization() async throws -> Bool
func registerForRemoteNotifications() async throws -> String
func scheduleLocalNotification(_ notification: MedTimeNotification) async throws
func cancelNotification(id: String) async
func cancelAllNotifications() async
}
final class NotificationManager: NSObject, NotificationManagerProtocol {
private let center = UNUserNotificationCenter.current()
private var deviceTokenContinuation: CheckedContinuation<String, Error>?
override init() {
super.init()
center.delegate = self
}
func requestAuthorization() async throws -> Bool {
let options: UNAuthorizationOptions = [.alert, .sound, .badge, .criticalAlert]
return try await center.requestAuthorization(options: options)
}
func registerForRemoteNotifications() async throws -> String {
return try await withCheckedThrowingContinuation { continuation in
self.deviceTokenContinuation = continuation
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
// Timeout after 30 seconds
Task {
try await Task.sleep(nanoseconds: 30_000_000_000)
if self.deviceTokenContinuation != nil {
self.deviceTokenContinuation?.resume(throwing: NotificationError.registrationTimeout)
self.deviceTokenContinuation = nil
}
}
}
}
// Called from AppDelegate
func didRegisterForRemoteNotifications(deviceToken: Data) {
let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
deviceTokenContinuation?.resume(returning: token)
deviceTokenContinuation = nil
}
func didFailToRegisterForRemoteNotifications(error: Error) {
deviceTokenContinuation?.resume(throwing: error)
deviceTokenContinuation = nil
}
func scheduleLocalNotification(_ notification: MedTimeNotification) async throws {
let content = UNMutableNotificationContent()
content.title = notification.title
content.body = notification.body
content.sound = notification.isCritical ? .defaultCritical : .default
content.categoryIdentifier = notification.category.rawValue
content.userInfo = notification.userInfo
if let badge = notification.badge {
content.badge = NSNumber(value: badge)
}
let trigger: UNNotificationTrigger
switch notification.trigger {
case .date(let date):
let components = Calendar.current.dateComponents(
[.year, .month, .day, .hour, .minute],
from: date
)
trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
case .interval(let seconds):
trigger = UNTimeIntervalNotificationTrigger(timeInterval: seconds, repeats: false)
case .daily(let hour, let minute):
var components = DateComponents()
components.hour = hour
components.minute = minute
trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)
}
let request = UNNotificationRequest(
identifier: notification.id,
content: content,
trigger: trigger
)
try await center.add(request)
}
func cancelNotification(id: String) async {
center.removePendingNotificationRequests(withIdentifiers: [id])
center.removeDeliveredNotifications(withIdentifiers: [id])
}
func cancelAllNotifications() async {
center.removeAllPendingNotificationRequests()
center.removeAllDeliveredNotifications()
}
}
extension NotificationManager: UNUserNotificationCenterDelegate {
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
// Show notification even when app is in foreground
return [.banner, .sound, .badge]
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse
) async {
let userInfo = response.notification.request.content.userInfo
let actionId = response.actionIdentifier
// Handle notification action
await handleNotificationAction(actionId: actionId, userInfo: userInfo)
}
private func handleNotificationAction(actionId: String, userInfo: [AnyHashable: Any]) async {
// Post notification for app to handle
NotificationCenter.default.post(
name: .didReceiveNotificationAction,
object: nil,
userInfo: [
"actionId": actionId,
"userInfo": userInfo
]
)
}
}
struct MedTimeNotification {
let id: String
let title: String
let body: String
let category: NotificationCategory
let trigger: NotificationTrigger
let isCritical: Bool
let badge: Int?
let userInfo: [String: Any]
enum NotificationCategory: String {
case doseReminder = "DOSE_REMINDER"
case missedDose = "MISSED_DOSE"
case lowInventory = "LOW_INVENTORY"
case refillReminder = "REFILL_REMINDER"
case interaction = "INTERACTION_ALERT"
}
enum NotificationTrigger {
case date(Date)
case interval(TimeInterval)
case daily(hour: Int, minute: Int)
}
}
enum NotificationError: Error {
case registrationTimeout
case authorizationDenied
}
extension Notification.Name {
static let didReceiveNotificationAction = Notification.Name("didReceiveNotificationAction")
}
10.2. Notification Handling¶
// Core/Notifications/NotificationScheduler.swift
protocol NotificationSchedulerProtocol {
func scheduleReminders(for medication: Medication, schedule: Schedule) async throws
func cancelReminders(for medicationId: UUID) async
func rescheduleAllReminders() async throws
}
final class NotificationScheduler: NotificationSchedulerProtocol {
private let notificationManager: NotificationManagerProtocol
private let scheduleRepository: ScheduleRepositoryProtocol
private let medicationRepository: MedicationRepositoryProtocol
private let userDefaults: UserDefaultsDataSourceProtocol
init(
notificationManager: NotificationManagerProtocol,
scheduleRepository: ScheduleRepositoryProtocol,
medicationRepository: MedicationRepositoryProtocol,
userDefaults: UserDefaultsDataSourceProtocol
) {
self.notificationManager = notificationManager
self.scheduleRepository = scheduleRepository
self.medicationRepository = medicationRepository
self.userDefaults = userDefaults
}
func scheduleReminders(for medication: Medication, schedule: Schedule) async throws {
// Cancel existing reminders
await cancelReminders(for: medication.id)
guard schedule.isActive else { return }
let reminderMinutes: Int = userDefaults.get(.reminderMinutesBefore) ?? 5
for time in schedule.times {
// Schedule for next 7 days
for dayOffset in 0..<7 {
guard let doseDate = calculateDoseDate(
time: time,
dayOffset: dayOffset,
daysOfWeek: schedule.daysOfWeek
) else { continue }
// Reminder notification
let reminderDate = doseDate.addingTimeInterval(-Double(reminderMinutes * 60))
if reminderDate > Date() {
let notification = MedTimeNotification(
id: "reminder-\(medication.id)-\(doseDate.timeIntervalSince1970)",
title: "Hora de tomar \(medication.name)",
body: "\(medication.dosage.displayString) en \(reminderMinutes) minutos",
category: .doseReminder,
trigger: .date(reminderDate),
isCritical: false,
badge: 1,
userInfo: [
"medicationId": medication.id.uuidString,
"scheduledTime": doseDate.timeIntervalSince1970,
"type": "reminder"
]
)
try await notificationManager.scheduleLocalNotification(notification)
}
// Missed dose notification (15 min after scheduled time)
let missedDate = doseDate.addingTimeInterval(15 * 60)
if missedDate > Date() {
let notification = MedTimeNotification(
id: "missed-\(medication.id)-\(doseDate.timeIntervalSince1970)",
title: "Dosis pendiente",
body: "No has registrado \(medication.name)",
category: .missedDose,
trigger: .date(missedDate),
isCritical: true,
badge: 1,
userInfo: [
"medicationId": medication.id.uuidString,
"scheduledTime": doseDate.timeIntervalSince1970,
"type": "missed"
]
)
try await notificationManager.scheduleLocalNotification(notification)
}
}
}
}
func cancelReminders(for medicationId: UUID) async {
// Cancel all notifications for this medication
// We'd need to track scheduled notification IDs
// For now, pattern match on ID prefix
let center = UNUserNotificationCenter.current()
let requests = await center.pendingNotificationRequests()
let idsToCancel = requests
.filter { $0.identifier.contains(medicationId.uuidString) }
.map { $0.identifier }
center.removePendingNotificationRequests(withIdentifiers: idsToCancel)
}
func rescheduleAllReminders() async throws {
// Cancel all existing
await notificationManager.cancelAllNotifications()
// Get all active medications and schedules
let medications = try await medicationRepository.getAllMedications()
.filter { $0.isActive }
for medication in medications {
if let schedule = try await scheduleRepository.getSchedule(for: medication.id) {
try await scheduleReminders(for: medication, schedule: schedule)
}
}
}
private func calculateDoseDate(
time: Schedule.ScheduleTime,
dayOffset: Int,
daysOfWeek: Set<DayOfWeek>?
) -> Date? {
let calendar = Calendar.current
var date = calendar.startOfDay(for: Date())
date = calendar.date(byAdding: .day, value: dayOffset, to: date)!
date = calendar.date(bySettingHour: time.hour, minute: time.minute, second: 0, of: date)!
// Check day of week filter
if let daysOfWeek {
let weekday = calendar.component(.weekday, from: date)
guard let dayOfWeek = DayOfWeek(rawValue: weekday),
daysOfWeek.contains(dayOfWeek) else {
return nil
}
}
return date
}
}
10.3. Rich Notifications¶
// Core/Notifications/NotificationActions.swift
import UserNotifications
enum NotificationActionIdentifier: String {
case takeDose = "TAKE_DOSE_ACTION"
case skipDose = "SKIP_DOSE_ACTION"
case snooze = "SNOOZE_ACTION"
case viewDetails = "VIEW_DETAILS_ACTION"
}
final class NotificationActionsSetup {
static func registerCategories() {
let center = UNUserNotificationCenter.current()
// Dose Reminder Category
let takeAction = UNNotificationAction(
identifier: NotificationActionIdentifier.takeDose.rawValue,
title: "Tomar",
options: [.foreground]
)
let skipAction = UNNotificationAction(
identifier: NotificationActionIdentifier.skipDose.rawValue,
title: "Omitir",
options: [.destructive]
)
let snoozeAction = UNNotificationAction(
identifier: NotificationActionIdentifier.snooze.rawValue,
title: "Recordar en 10 min",
options: []
)
let doseReminderCategory = UNNotificationCategory(
identifier: MedTimeNotification.NotificationCategory.doseReminder.rawValue,
actions: [takeAction, snoozeAction, skipAction],
intentIdentifiers: [],
options: [.customDismissAction]
)
// Missed Dose Category
let viewAction = UNNotificationAction(
identifier: NotificationActionIdentifier.viewDetails.rawValue,
title: "Ver detalles",
options: [.foreground]
)
let missedDoseCategory = UNNotificationCategory(
identifier: MedTimeNotification.NotificationCategory.missedDose.rawValue,
actions: [takeAction, viewAction, skipAction],
intentIdentifiers: [],
options: [.customDismissAction]
)
// Register all categories
center.setNotificationCategories([
doseReminderCategory,
missedDoseCategory
])
}
}
10.4. Actions¶
// Core/Notifications/NotificationActionHandler.swift
protocol NotificationActionHandlerProtocol {
func handle(actionId: String, userInfo: [AnyHashable: Any]) async
}
final class NotificationActionHandler: NotificationActionHandlerProtocol {
private let logDoseUseCase: LogDoseUseCaseProtocol
private let notificationScheduler: NotificationSchedulerProtocol
init(
logDoseUseCase: LogDoseUseCaseProtocol,
notificationScheduler: NotificationSchedulerProtocol
) {
self.logDoseUseCase = logDoseUseCase
self.notificationScheduler = notificationScheduler
}
func handle(actionId: String, userInfo: [AnyHashable: Any]) async {
guard let medicationIdString = userInfo["medicationId"] as? String,
let medicationId = UUID(uuidString: medicationIdString),
let scheduledTimestamp = userInfo["scheduledTime"] as? TimeInterval else {
return
}
let scheduledTime = Date(timeIntervalSince1970: scheduledTimestamp)
switch NotificationActionIdentifier(rawValue: actionId) {
case .takeDose:
do {
_ = try await logDoseUseCase.execute(
medicationId: medicationId,
scheduledTime: scheduledTime,
status: .taken,
takenTime: Date(),
skipReason: nil,
notes: nil
)
} catch {
// Handle error - maybe show local notification
}
case .skipDose:
do {
_ = try await logDoseUseCase.execute(
medicationId: medicationId,
scheduledTime: scheduledTime,
status: .skipped,
takenTime: nil,
skipReason: .other,
notes: nil
)
} catch {
// Handle error
}
case .snooze:
// Reschedule notification for 10 minutes later
// Would need medication info to do this properly
break
case .viewDetails:
// Navigate to medication detail
// Post notification for navigation
NotificationCenter.default.post(
name: .navigateToMedication,
object: nil,
userInfo: ["medicationId": medicationId]
)
case .none:
break
}
}
}
extension Notification.Name {
static let navigateToMedication = Notification.Name("navigateToMedication")
}
11. Widgets¶
11.1. WidgetKit Integration¶
// Extensions/Widget/MedTimeWidget.swift
import WidgetKit
import SwiftUI
@main
struct MedTimeWidgetBundle: WidgetBundle {
var body: some Widget {
NextDoseWidget()
AdherenceWidget()
}
}
struct NextDoseWidget: Widget {
let kind: String = "NextDoseWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: NextDoseTimelineProvider()) { entry in
NextDoseWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Siguiente Dosis")
.description("Muestra tu proxima dosis programada")
.supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular])
}
}
struct AdherenceWidget: Widget {
let kind: String = "AdherenceWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: AdherenceTimelineProvider()) { entry in
AdherenceWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Adherencia")
.description("Tu progreso de adherencia semanal")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
11.2. Timeline Provider¶
// Extensions/Widget/NextDoseTimelineProvider.swift
import WidgetKit
struct NextDoseEntry: TimelineEntry {
let date: Date
let medication: WidgetMedication?
let scheduledTime: Date?
let configuration: ConfigurationIntent?
}
struct WidgetMedication {
let name: String
let dosage: String
let form: String
}
struct NextDoseTimelineProvider: TimelineProvider {
private let sharedDataManager = WidgetSharedDataManager()
func placeholder(in context: Context) -> NextDoseEntry {
NextDoseEntry(
date: Date(),
medication: WidgetMedication(name: "Medicamento", dosage: "100mg", form: "tablet"),
scheduledTime: Date().addingTimeInterval(3600),
configuration: nil
)
}
func getSnapshot(in context: Context, completion: @escaping (NextDoseEntry) -> Void) {
let entry = getLatestEntry()
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<NextDoseEntry>) -> Void) {
var entries: [NextDoseEntry] = []
let currentDate = Date()
// Generate entries for the next 6 hours, updating hourly
for hourOffset in 0..<6 {
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
let entry = getEntryFor(date: entryDate)
entries.append(entry)
}
// Update every 30 minutes
let nextUpdateDate = Calendar.current.date(byAdding: .minute, value: 30, to: currentDate)!
let timeline = Timeline(entries: entries, policy: .after(nextUpdateDate))
completion(timeline)
}
private func getLatestEntry() -> NextDoseEntry {
getEntryFor(date: Date())
}
private func getEntryFor(date: Date) -> NextDoseEntry {
guard let data = sharedDataManager.getNextDose() else {
return NextDoseEntry(
date: date,
medication: nil,
scheduledTime: nil,
configuration: nil
)
}
return NextDoseEntry(
date: date,
medication: WidgetMedication(
name: data.medicationName,
dosage: data.dosage,
form: data.form
),
scheduledTime: data.scheduledTime,
configuration: nil
)
}
}
// Adherence Timeline Provider
struct AdherenceEntry: TimelineEntry {
let date: Date
let adherenceRate: Double
let takenCount: Int
let totalCount: Int
let streakDays: Int
}
struct AdherenceTimelineProvider: TimelineProvider {
private let sharedDataManager = WidgetSharedDataManager()
func placeholder(in context: Context) -> AdherenceEntry {
AdherenceEntry(date: Date(), adherenceRate: 0.85, takenCount: 17, totalCount: 20, streakDays: 7)
}
func getSnapshot(in context: Context, completion: @escaping (AdherenceEntry) -> Void) {
let entry = getLatestEntry()
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<AdherenceEntry>) -> Void) {
let entry = getLatestEntry()
// Update every hour
let nextUpdateDate = Calendar.current.date(byAdding: .hour, value: 1, to: Date())!
let timeline = Timeline(entries: [entry], policy: .after(nextUpdateDate))
completion(timeline)
}
private func getLatestEntry() -> AdherenceEntry {
guard let data = sharedDataManager.getAdherenceData() else {
return AdherenceEntry(date: Date(), adherenceRate: 0, takenCount: 0, totalCount: 0, streakDays: 0)
}
return AdherenceEntry(
date: Date(),
adherenceRate: data.rate,
takenCount: data.taken,
totalCount: data.total,
streakDays: data.streak
)
}
}
11.3. App Groups¶
// Extensions/Widget/WidgetSharedDataManager.swift
import Foundation
struct WidgetSharedDataManager {
private let suiteName = "group.com.medtime.shared"
private let nextDoseKey = "widget_next_dose"
private let adherenceKey = "widget_adherence"
private var sharedDefaults: UserDefaults? {
UserDefaults(suiteName: suiteName)
}
// MARK: - Write (from main app)
func updateNextDose(_ data: NextDoseData) {
guard let encoded = try? JSONEncoder().encode(data) else { return }
sharedDefaults?.set(encoded, forKey: nextDoseKey)
// Reload widget timelines
WidgetCenter.shared.reloadTimelines(ofKind: "NextDoseWidget")
}
func updateAdherence(_ data: AdherenceData) {
guard let encoded = try? JSONEncoder().encode(data) else { return }
sharedDefaults?.set(encoded, forKey: adherenceKey)
WidgetCenter.shared.reloadTimelines(ofKind: "AdherenceWidget")
}
// MARK: - Read (from widget)
func getNextDose() -> NextDoseData? {
guard let data = sharedDefaults?.data(forKey: nextDoseKey) else { return nil }
return try? JSONDecoder().decode(NextDoseData.self, from: data)
}
func getAdherenceData() -> AdherenceData? {
guard let data = sharedDefaults?.data(forKey: adherenceKey) else { return nil }
return try? JSONDecoder().decode(AdherenceData.self, from: data)
}
}
struct NextDoseData: Codable {
let medicationName: String
let dosage: String
let form: String
let scheduledTime: Date
}
struct AdherenceData: Codable {
let rate: Double
let taken: Int
let total: Int
let streak: Int
let lastUpdated: Date
}
11.4. Widget Types¶
// Extensions/Widget/Views/NextDoseWidgetView.swift
import SwiftUI
import WidgetKit
struct NextDoseWidgetView: View {
@Environment(\.widgetFamily) var family
var entry: NextDoseEntry
var body: some View {
switch family {
case .systemSmall:
SmallNextDoseView(entry: entry)
case .systemMedium:
MediumNextDoseView(entry: entry)
case .accessoryRectangular:
AccessoryNextDoseView(entry: entry)
default:
SmallNextDoseView(entry: entry)
}
}
}
struct SmallNextDoseView: View {
let entry: NextDoseEntry
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "pills.fill")
.foregroundStyle(.accent)
Text("Siguiente")
.font(.caption)
.foregroundStyle(.secondary)
}
if let medication = entry.medication,
let time = entry.scheduledTime {
Text(medication.name)
.font(.headline)
.lineLimit(1)
Text(medication.dosage)
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
Text(time, style: .time)
.font(.title2)
.fontWeight(.bold)
} else {
Text("No hay dosis programadas")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.padding()
}
}
struct MediumNextDoseView: View {
let entry: NextDoseEntry
var body: some View {
HStack(spacing: 16) {
// Icon
VStack {
Image(systemName: entry.medication?.form == "tablet" ? "pill.fill" : "cross.vial.fill")
.font(.largeTitle)
.foregroundStyle(.accent)
}
.frame(width: 60)
// Info
VStack(alignment: .leading, spacing: 4) {
Text("Siguiente dosis")
.font(.caption)
.foregroundStyle(.secondary)
if let medication = entry.medication {
Text(medication.name)
.font(.headline)
Text(medication.dosage)
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
Text("Sin dosis programadas")
.font(.subheadline)
}
}
Spacer()
// Time
if let time = entry.scheduledTime {
VStack {
Text(time, style: .time)
.font(.title)
.fontWeight(.bold)
Text(timeUntil(time))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.padding()
}
private func timeUntil(_ date: Date) -> String {
let interval = date.timeIntervalSinceNow
if interval < 0 {
return "Ahora"
} else if interval < 3600 {
return "En \(Int(interval / 60)) min"
} else {
return "En \(Int(interval / 3600)) h"
}
}
}
struct AccessoryNextDoseView: View {
let entry: NextDoseEntry
var body: some View {
HStack {
Image(systemName: "pills")
VStack(alignment: .leading) {
if let medication = entry.medication,
let time = entry.scheduledTime {
Text(medication.name)
.font(.headline)
Text(time, style: .time)
.font(.caption)
} else {
Text("Sin dosis")
}
}
}
}
}
// Extensions/Widget/Views/AdherenceWidgetView.swift
struct AdherenceWidgetView: View {
@Environment(\.widgetFamily) var family
var entry: AdherenceEntry
var body: some View {
switch family {
case .systemSmall:
SmallAdherenceView(entry: entry)
case .systemMedium:
MediumAdherenceView(entry: entry)
default:
SmallAdherenceView(entry: entry)
}
}
}
struct SmallAdherenceView: View {
let entry: AdherenceEntry
var body: some View {
VStack(spacing: 8) {
Text("Adherencia")
.font(.caption)
.foregroundStyle(.secondary)
ZStack {
Circle()
.stroke(Color.gray.opacity(0.3), lineWidth: 8)
Circle()
.trim(from: 0, to: entry.adherenceRate)
.stroke(adherenceColor, style: StrokeStyle(lineWidth: 8, lineCap: .round))
.rotationEffect(.degrees(-90))
VStack {
Text("\(Int(entry.adherenceRate * 100))%")
.font(.title2)
.fontWeight(.bold)
Text("\(entry.takenCount)/\(entry.totalCount)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.padding(8)
if entry.streakDays > 0 {
Label("\(entry.streakDays) dias", systemImage: "flame.fill")
.font(.caption2)
.foregroundStyle(.orange)
}
}
.padding()
}
private var adherenceColor: Color {
if entry.adherenceRate >= 0.9 { return .green }
if entry.adherenceRate >= 0.7 { return .yellow }
return .red
}
}
struct MediumAdherenceView: View {
let entry: AdherenceEntry
var body: some View {
HStack(spacing: 20) {
// Ring chart
ZStack {
Circle()
.stroke(Color.gray.opacity(0.3), lineWidth: 10)
Circle()
.trim(from: 0, to: entry.adherenceRate)
.stroke(
entry.adherenceRate >= 0.9 ? Color.green : (entry.adherenceRate >= 0.7 ? Color.yellow : Color.red),
style: StrokeStyle(lineWidth: 10, lineCap: .round)
)
.rotationEffect(.degrees(-90))
Text("\(Int(entry.adherenceRate * 100))%")
.font(.title)
.fontWeight(.bold)
}
.frame(width: 80, height: 80)
// Stats
VStack(alignment: .leading, spacing: 8) {
Text("Esta semana")
.font(.headline)
HStack {
StatItem(value: "\(entry.takenCount)", label: "Tomadas", color: .green)
StatItem(value: "\(entry.totalCount - entry.takenCount)", label: "Pendientes", color: .orange)
}
if entry.streakDays > 0 {
Label("\(entry.streakDays) dias de racha", systemImage: "flame.fill")
.font(.caption)
.foregroundStyle(.orange)
}
}
}
.padding()
}
}
struct StatItem: View {
let value: String
let label: String
let color: Color
var body: some View {
VStack(alignment: .leading) {
Text(value)
.font(.title3)
.fontWeight(.bold)
.foregroundStyle(color)
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
12. Apple Watch¶
12.1. WatchConnectivity¶
// Extensions/Watch/WatchConnectivityManager.swift
import WatchConnectivity
protocol WatchConnectivityManagerProtocol {
var isReachable: Bool { get }
func sendMessage(_ message: [String: Any]) async throws
func transferUserInfo(_ userInfo: [String: Any])
func updateApplicationContext(_ context: [String: Any]) throws
}
final class WatchConnectivityManager: NSObject, WatchConnectivityManagerProtocol {
static let shared = WatchConnectivityManager()
private let session: WCSession
private var messageHandlers: [(String, ([String: Any]) -> Void)] = []
var isReachable: Bool {
session.isReachable
}
private override init() {
session = WCSession.default
super.init()
if WCSession.isSupported() {
session.delegate = self
session.activate()
}
}
func sendMessage(_ message: [String: Any]) async throws {
guard session.isReachable else {
throw WatchError.notReachable
}
return try await withCheckedThrowingContinuation { continuation in
session.sendMessage(message, replyHandler: { _ in
continuation.resume()
}, errorHandler: { error in
continuation.resume(throwing: error)
})
}
}
func transferUserInfo(_ userInfo: [String: Any]) {
session.transferUserInfo(userInfo)
}
func updateApplicationContext(_ context: [String: Any]) throws {
try session.updateApplicationContext(context)
}
func registerHandler(for type: String, handler: @escaping ([String: Any]) -> Void) {
messageHandlers.append((type, handler))
}
}
extension WatchConnectivityManager: WCSessionDelegate {
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
// Handle activation
}
#if os(iOS)
func sessionDidBecomeInactive(_ session: WCSession) {}
func sessionDidDeactivate(_ session: WCSession) {
session.activate()
}
#endif
func session(_ session: WCSession, didReceiveMessage message: [String : Any]) {
guard let type = message["type"] as? String else { return }
for (handlerType, handler) in messageHandlers where handlerType == type {
handler(message)
}
}
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) {
// Handle user info transfer
NotificationCenter.default.post(
name: .didReceiveWatchUserInfo,
object: nil,
userInfo: userInfo
)
}
}
enum WatchError: Error {
case notReachable
case notSupported
}
extension Notification.Name {
static let didReceiveWatchUserInfo = Notification.Name("didReceiveWatchUserInfo")
}
12.2. Complications¶
// Extensions/Watch/ComplicationProvider.swift
import ClockKit
import SwiftUI
final class ComplicationController: NSObject, CLKComplicationDataSource {
func getComplicationDescriptors(handler: @escaping ([CLKComplicationDescriptor]) -> Void) {
let descriptors = [
CLKComplicationDescriptor(
identifier: "MedTimeNextDose",
displayName: "Siguiente Dosis",
supportedFamilies: [
.modularSmall,
.modularLarge,
.utilitarianSmall,
.utilitarianSmallFlat,
.utilitarianLarge,
.circularSmall,
.graphicCorner,
.graphicCircular,
.graphicRectangular,
.graphicExtraLarge
]
)
]
handler(descriptors)
}
func getCurrentTimelineEntry(
for complication: CLKComplication,
withHandler handler: @escaping (CLKComplicationTimelineEntry?) -> Void
) {
let entry = createTimelineEntry(for: complication, date: Date())
handler(entry)
}
func getTimelineEntries(
for complication: CLKComplication,
after date: Date,
limit: Int,
withHandler handler: @escaping ([CLKComplicationTimelineEntry]?) -> Void
) {
var entries: [CLKComplicationTimelineEntry] = []
// Generate entries for upcoming doses
// Would fetch from shared data
handler(entries)
}
private func createTimelineEntry(
for complication: CLKComplication,
date: Date
) -> CLKComplicationTimelineEntry? {
guard let template = createTemplate(for: complication.family) else {
return nil
}
return CLKComplicationTimelineEntry(date: date, complicationTemplate: template)
}
private func createTemplate(for family: CLKComplicationFamily) -> CLKComplicationTemplate? {
// Get data from shared storage
let data = WatchSharedData.shared.getNextDose()
switch family {
case .graphicCircular:
return CLKComplicationTemplateGraphicCircularStackText(
line1TextProvider: CLKSimpleTextProvider(text: data?.medicationName ?? "MedTime"),
line2TextProvider: CLKTimeTextProvider(date: data?.scheduledTime ?? Date())
)
case .graphicRectangular:
return CLKComplicationTemplateGraphicRectangularStandardBody(
headerTextProvider: CLKSimpleTextProvider(text: "Siguiente dosis"),
body1TextProvider: CLKSimpleTextProvider(text: data?.medicationName ?? "Sin dosis"),
body2TextProvider: data != nil ? CLKTimeTextProvider(date: data!.scheduledTime) : nil
)
case .modularSmall:
return CLKComplicationTemplateModularSmallStackText(
line1TextProvider: CLKSimpleTextProvider(text: "💊"),
line2TextProvider: data != nil ? CLKTimeTextProvider(date: data!.scheduledTime) : CLKSimpleTextProvider(text: "--:--")
)
default:
return nil
}
}
}
12.3. Independent Mode¶
// Extensions/Watch/WatchApp.swift
import SwiftUI
import WatchKit
@main
struct MedTimeWatchApp: App {
@StateObject private var sessionManager = WatchSessionManager()
var body: some Scene {
WindowGroup {
WatchContentView()
.environmentObject(sessionManager)
}
}
}
struct WatchContentView: View {
@EnvironmentObject var sessionManager: WatchSessionManager
@State private var selectedTab = 0
var body: some View {
TabView(selection: $selectedTab) {
WatchHomeView()
.tag(0)
WatchMedicationsView()
.tag(1)
WatchHistoryView()
.tag(2)
}
.tabViewStyle(.page)
}
}
// Watch Home View
struct WatchHomeView: View {
@EnvironmentObject var sessionManager: WatchSessionManager
var body: some View {
ScrollView {
VStack(spacing: 12) {
if let nextDose = sessionManager.nextDose {
NextDoseWatchCard(dose: nextDose)
} else {
Text("No hay dosis pendientes")
.foregroundStyle(.secondary)
}
// Today's progress
HStack {
Text("Hoy")
.font(.headline)
Spacer()
Text("\(sessionManager.takenToday)/\(sessionManager.totalToday)")
.foregroundStyle(.secondary)
}
ProgressView(value: Double(sessionManager.takenToday), total: Double(sessionManager.totalToday))
.tint(.green)
}
.padding()
}
.navigationTitle("MedTime")
}
}
struct NextDoseWatchCard: View {
let dose: WatchDose
@State private var showingConfirmation = false
var body: some View {
VStack(spacing: 8) {
Text(dose.medicationName)
.font(.headline)
Text(dose.dosage)
.font(.caption)
.foregroundStyle(.secondary)
Text(dose.scheduledTime, style: .time)
.font(.title2)
Button("Tomar") {
showingConfirmation = true
}
.buttonStyle(.borderedProminent)
.tint(.green)
}
.padding()
.background(Color(.darkGray).opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 12))
.confirmationDialog("Confirmar dosis", isPresented: $showingConfirmation) {
Button("Confirmar") {
// Log dose
}
Button("Cancelar", role: .cancel) {}
}
}
}
// Session Manager for Watch
@MainActor
final class WatchSessionManager: ObservableObject {
@Published var nextDose: WatchDose?
@Published var takenToday: Int = 0
@Published var totalToday: Int = 0
@Published var medications: [WatchMedication] = []
private let connectivityManager = WatchConnectivityManager.shared
private let localStorage = WatchLocalStorage()
init() {
loadLocalData()
setupConnectivity()
}
private func loadLocalData() {
// Load from local storage (for standalone mode)
if let data = localStorage.getNextDose() {
nextDose = data
}
let stats = localStorage.getTodayStats()
takenToday = stats.taken
totalToday = stats.total
medications = localStorage.getMedications()
}
private func setupConnectivity() {
connectivityManager.registerHandler(for: "sync") { [weak self] message in
Task { @MainActor in
self?.handleSyncMessage(message)
}
}
}
private func handleSyncMessage(_ message: [String: Any]) {
// Update local data from phone
if let doseData = message["nextDose"] as? [String: Any] {
nextDose = WatchDose(from: doseData)
localStorage.saveNextDose(nextDose!)
}
if let taken = message["takenToday"] as? Int,
let total = message["totalToday"] as? Int {
takenToday = taken
totalToday = total
localStorage.saveTodayStats(taken: taken, total: total)
}
}
func logDose(_ dose: WatchDose) async {
// Save locally
localStorage.logDose(dose)
takenToday += 1
// Sync to phone if reachable
if connectivityManager.isReachable {
try? await connectivityManager.sendMessage([
"type": "doseLogged",
"medicationId": dose.medicationId,
"scheduledTime": dose.scheduledTime.timeIntervalSince1970,
"takenTime": Date().timeIntervalSince1970
])
}
// Update next dose
loadLocalData()
}
}
struct WatchDose {
let medicationId: String
let medicationName: String
let dosage: String
let scheduledTime: Date
init(from dict: [String: Any]) {
medicationId = dict["medicationId"] as? String ?? ""
medicationName = dict["medicationName"] as? String ?? ""
dosage = dict["dosage"] as? String ?? ""
scheduledTime = Date(timeIntervalSince1970: dict["scheduledTime"] as? TimeInterval ?? 0)
}
}
struct WatchMedication: Identifiable {
let id: String
let name: String
let dosage: String
let nextDoseTime: Date?
}
12.4. Health Sync¶
// Extensions/Watch/HealthKitManager.swift
import HealthKit
protocol HealthKitManagerProtocol {
func requestAuthorization() async throws -> Bool
func saveMedicationLog(medication: String, date: Date) async throws
func syncWithHealth() async throws
}
final class HealthKitManager: HealthKitManagerProtocol {
private let healthStore = HKHealthStore()
func requestAuthorization() async throws -> Bool {
guard HKHealthStore.isHealthDataAvailable() else {
throw HealthKitError.notAvailable
}
// Types we want to write
let writeTypes: Set<HKSampleType> = [
// Medication logs would go here if HealthKit supported them
// For now, we could use workout or other types
]
// Types we want to read
let readTypes: Set<HKObjectType> = [
HKObjectType.quantityType(forIdentifier: .heartRate)!,
HKObjectType.categoryType(forIdentifier: .sleepAnalysis)!
]
try await healthStore.requestAuthorization(toShare: writeTypes, read: readTypes)
return true
}
func saveMedicationLog(medication: String, date: Date) async throws {
// HealthKit doesn't have a native medication type
// We could use HKCDADocument for clinical data
// Or integrate with Apple's medication tracking when available
}
func syncWithHealth() async throws {
// Sync relevant health data that might affect medication
// e.g., sleep patterns for sleep aids
// e.g., heart rate for cardiac medications
}
}
enum HealthKitError: Error {
case notAvailable
case authorizationDenied
}
13. Testing¶
13.1. Unit Tests¶
// Tests/UnitTests/Domain/GetMedicationsUseCaseTests.swift
import XCTest
@testable import MedTime
final class GetMedicationsUseCaseTests: XCTestCase {
var sut: GetMedicationsUseCase!
var mockRepository: MockMedicationRepository!
override func setUp() {
super.setUp()
mockRepository = MockMedicationRepository()
sut = GetMedicationsUseCase(repository: mockRepository)
}
override func tearDown() {
sut = nil
mockRepository = nil
super.tearDown()
}
func test_execute_returnsMedications() async throws {
// Given
let medications = [
Medication.stub(name: "Aspirin"),
Medication.stub(name: "Ibuprofen")
]
mockRepository.medicationsToReturn = medications
// When
let result = try await sut.execute(filter: nil)
// Then
XCTAssertEqual(result.count, 2)
XCTAssertEqual(result[0].name, "Aspirin")
XCTAssertEqual(result[1].name, "Ibuprofen")
}
func test_execute_withActiveFilter_returnsOnlyActive() async throws {
// Given
let medications = [
Medication.stub(name: "Active Med", isActive: true),
Medication.stub(name: "Inactive Med", isActive: false)
]
mockRepository.medicationsToReturn = medications
// When
let result = try await sut.execute(filter: MedicationFilter(isActive: true))
// Then
XCTAssertEqual(result.count, 1)
XCTAssertEqual(result[0].name, "Active Med")
}
func test_execute_withSearchFilter_filtersbyName() async throws {
// Given
let medications = [
Medication.stub(name: "Aspirin"),
Medication.stub(name: "Ibuprofen"),
Medication.stub(name: "Paracetamol")
]
mockRepository.medicationsToReturn = medications
// When
let result = try await sut.execute(filter: MedicationFilter(searchText: "ibu"))
// Then
XCTAssertEqual(result.count, 1)
XCTAssertEqual(result[0].name, "Ibuprofen")
}
func test_execute_repositoryThrows_propagatesError() async {
// Given
mockRepository.errorToThrow = DomainError.unknown(NSError(domain: "", code: 0))
// When/Then
do {
_ = try await sut.execute(filter: nil)
XCTFail("Expected error to be thrown")
} catch {
XCTAssertTrue(error is DomainError)
}
}
}
// Tests/UnitTests/Data/MedicationRepositoryTests.swift
final class MedicationRepositoryTests: XCTestCase {
var sut: MedicationRepository!
var mockLocalDataSource: MockSwiftDataSource!
var mockRemoteDataSource: MockMedicationAPIDataSource!
var mockCryptoManager: MockCryptoManager!
override func setUp() {
super.setUp()
mockLocalDataSource = MockSwiftDataSource()
mockRemoteDataSource = MockMedicationAPIDataSource()
mockCryptoManager = MockCryptoManager()
sut = MedicationRepository(
localDataSource: mockLocalDataSource,
remoteDataSource: mockRemoteDataSource,
cryptoManager: mockCryptoManager
)
}
func test_getAllMedications_decryptsBlobs() async throws {
// Given
let entity = MedicationEntity.stub(
id: UUID(),
encryptedBlob: "encrypted".data(using: .utf8)!
)
mockLocalDataSource.entitiesToReturn = [entity]
mockCryptoManager.decryptedData = validMedicationJSON()
// When
let result = try await sut.getAllMedications()
// Then
XCTAssertEqual(result.count, 1)
XCTAssertTrue(mockCryptoManager.decryptCalled)
}
func test_save_encryptsAndSavesLocally() async throws {
// Given
let medication = Medication.stub()
mockCryptoManager.encryptedData = "encrypted".data(using: .utf8)!
// When
_ = try await sut.save(medication)
// Then
XCTAssertTrue(mockCryptoManager.encryptCalled)
XCTAssertTrue(mockLocalDataSource.saveCalled)
}
func test_save_addsToSyncQueue() async throws {
// Given
let medication = Medication.stub()
mockCryptoManager.encryptedData = "encrypted".data(using: .utf8)!
// When
_ = try await sut.save(medication)
// Then
XCTAssertTrue(mockLocalDataSource.addToSyncQueueCalled)
}
private func validMedicationJSON() -> Data {
"""
{
"name": "Test Med",
"genericName": null,
"dosageAmount": 100,
"dosageUnit": "mg",
"frequency": "times_per_day:2",
"instructions": null,
"requiresPrescription": false,
"inventory": null
}
""".data(using: .utf8)!
}
}
13.2. UI Tests¶
// Tests/UITests/Flows/MedicationFlowUITests.swift
import XCTest
final class MedicationFlowUITests: XCTestCase {
var app: XCUIApplication!
override func setUp() {
super.setUp()
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments = ["--uitesting"]
app.launch()
}
func test_addMedication_completesSuccessfully() {
// Navigate to medications tab
app.tabBars.buttons["Medicamentos"].tap()
// Tap add button
app.navigationBars.buttons["plus"].tap()
// Fill in medication name
let nameField = app.textFields["medication_name"]
nameField.tap()
nameField.typeText("Aspirina")
// Select dosage
app.buttons["dosage_field"].tap()
app.textFields["dosage_amount"].tap()
app.textFields["dosage_amount"].typeText("100")
app.buttons["mg"].tap()
app.buttons["Listo"].tap()
// Select form
app.buttons["form_picker"].tap()
app.buttons["Tableta"].tap()
// Select frequency
app.buttons["frequency_picker"].tap()
app.buttons["2 veces al dia"].tap()
// Save
app.buttons["Guardar"].tap()
// Verify medication appears in list
XCTAssertTrue(app.staticTexts["Aspirina"].exists)
}
func test_takeDose_updatesUI() {
// Navigate to home
app.tabBars.buttons["Inicio"].tap()
// Find next dose card
let doseCard = app.otherElements["next_dose_card"]
XCTAssertTrue(doseCard.exists)
// Tap take button
doseCard.buttons["Tomar"].tap()
// Verify success feedback
XCTAssertTrue(app.staticTexts["Dosis registrada"].waitForExistence(timeout: 2))
}
func test_skipDose_showsReasonPicker() {
// Navigate to home
app.tabBars.buttons["Inicio"].tap()
// Find next dose card
let doseCard = app.otherElements["next_dose_card"]
// Tap skip button
doseCard.buttons["Omitir"].tap()
// Verify reason picker appears
XCTAssertTrue(app.sheets.element.exists)
XCTAssertTrue(app.buttons["Efectos secundarios"].exists)
}
}
// Tests/UITests/Flows/AuthFlowUITests.swift
final class AuthFlowUITests: XCTestCase {
var app: XCUIApplication!
override func setUp() {
super.setUp()
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments = ["--uitesting", "--logged-out"]
app.launch()
}
func test_login_withValidCredentials_navigatesToHome() {
// Enter email
let emailField = app.textFields["email_field"]
emailField.tap()
emailField.typeText("test@example.com")
// Enter password
let passwordField = app.secureTextFields["password_field"]
passwordField.tap()
passwordField.typeText("password123")
// Tap login
app.buttons["Iniciar sesion"].tap()
// Verify navigation to home
XCTAssertTrue(app.tabBars.buttons["Inicio"].waitForExistence(timeout: 5))
}
func test_login_withInvalidCredentials_showsError() {
// Enter email
let emailField = app.textFields["email_field"]
emailField.tap()
emailField.typeText("invalid@example.com")
// Enter password
let passwordField = app.secureTextFields["password_field"]
passwordField.tap()
passwordField.typeText("wrongpassword")
// Tap login
app.buttons["Iniciar sesion"].tap()
// Verify error message
XCTAssertTrue(app.staticTexts["Credenciales invalidas"].waitForExistence(timeout: 5))
}
}
13.3. Snapshot Tests¶
// Tests/SnapshotTests/Screens/HomeViewSnapshotTests.swift
import XCTest
import SnapshotTesting
@testable import MedTime
final class HomeViewSnapshotTests: XCTestCase {
override func setUp() {
super.setUp()
// isRecording = true // Uncomment to record new snapshots
}
func test_homeView_withDoses() {
let viewModel = HomeViewModel()
viewModel.nextDose = .stub()
viewModel.todayDoses = [.stub(), .stub(), .stub()]
viewModel.weeklyAdherence = AdherenceStats(totalDoses: 20, takenDoses: 18, missedDoses: 1, skippedDoses: 1)
let view = HomeView()
.environmentObject(viewModel)
assertSnapshot(matching: view, as: .image(layout: .device(config: .iPhone13)))
}
func test_homeView_empty() {
let viewModel = HomeViewModel()
viewModel.nextDose = nil
viewModel.todayDoses = []
let view = HomeView()
.environmentObject(viewModel)
assertSnapshot(matching: view, as: .image(layout: .device(config: .iPhone13)))
}
func test_medicationCard_variants() {
let medications = [
Medication.stub(name: "Aspirina", form: .tablet),
Medication.stub(name: "Jarabe para la tos", form: .liquid),
Medication.stub(name: "Insulina", form: .injection)
]
for medication in medications {
let view = MedicationCard(medication: medication)
assertSnapshot(
matching: view,
as: .image(layout: .fixed(width: 350, height: 100)),
named: medication.form.rawValue
)
}
}
func test_adherenceWidget_allSizes() {
let entry = AdherenceEntry(
date: Date(),
adherenceRate: 0.85,
takenCount: 17,
totalCount: 20,
streakDays: 7
)
// Small
assertSnapshot(
matching: SmallAdherenceView(entry: entry),
as: .image(layout: .fixed(width: 155, height: 155)),
named: "small"
)
// Medium
assertSnapshot(
matching: MediumAdherenceView(entry: entry),
as: .image(layout: .fixed(width: 329, height: 155)),
named: "medium"
)
}
}
13.4. Mock Strategies¶
// Tests/Mocks/MockMedicationRepository.swift
@testable import MedTime
final class MockMedicationRepository: MedicationRepositoryProtocol {
var medicationsToReturn: [Medication] = []
var medicationToReturn: Medication?
var errorToThrow: Error?
var savedMedications: [Medication] = []
var deletedMedications: [Medication] = []
func getAllMedications() async throws -> [Medication] {
if let error = errorToThrow { throw error }
return medicationsToReturn
}
func getMedication(by id: UUID) async throws -> Medication? {
if let error = errorToThrow { throw error }
return medicationToReturn ?? medicationsToReturn.first { $0.id == id }
}
func save(_ medication: Medication) async throws -> Medication {
if let error = errorToThrow { throw error }
savedMedications.append(medication)
return medication
}
func delete(_ medication: Medication) async throws {
if let error = errorToThrow { throw error }
deletedMedications.append(medication)
}
func search(query: String) async throws -> [Medication] {
if let error = errorToThrow { throw error }
return medicationsToReturn.filter { $0.name.contains(query) }
}
}
// Tests/Mocks/MockCryptoManager.swift
final class MockCryptoManager: CryptoManagerProtocol {
var encryptCalled = false
var decryptCalled = false
var encryptedData: Data = Data()
var decryptedData: Data = Data()
var errorToThrow: Error?
func generateMasterKey() throws -> SymmetricKey {
SymmetricKey(size: .bits256)
}
func deriveMasterKey(from password: String, salt: Data) throws -> SymmetricKey {
SymmetricKey(size: .bits256)
}
func encrypt(_ data: Data) throws -> Data {
encryptCalled = true
if let error = errorToThrow { throw error }
return encryptedData
}
func decrypt(_ encryptedData: Data) throws -> Data {
decryptCalled = true
if let error = errorToThrow { throw error }
return decryptedData
}
func hash(_ data: Data) -> String {
"mock_hash"
}
}
// Tests/Mocks/MockSwiftDataSource.swift
final class MockSwiftDataSource: SwiftDataSourceProtocol {
var entitiesToReturn: [any PersistentModel] = []
var saveCalled = false
var deleteCalled = false
var addToSyncQueueCalled = false
var errorToThrow: Error?
func fetchAll<T: PersistentModel>(_ type: T.Type) async throws -> [T] {
if let error = errorToThrow { throw error }
return entitiesToReturn as? [T] ?? []
}
func fetch<T: PersistentModel>(_ type: T.Type, predicate: Predicate<T>?) async throws -> [T] {
if let error = errorToThrow { throw error }
return entitiesToReturn as? [T] ?? []
}
func save<T: PersistentModel>(_ entity: T) async throws {
saveCalled = true
if let error = errorToThrow { throw error }
}
func delete<T: PersistentModel>(_ type: T.Type, predicate: Predicate<T>) async throws {
deleteCalled = true
if let error = errorToThrow { throw error }
}
func addToSyncQueue(entityType: EntityType, entityId: UUID, operation: SyncOperation, encryptedBlob: Data) async throws {
addToSyncQueueCalled = true
if let error = errorToThrow { throw error }
}
}
// Tests/Stubs/Medication+Stub.swift
extension Medication {
static func stub(
id: UUID = UUID(),
name: String = "Test Medication",
genericName: String? = nil,
dosageAmount: Double = 100,
dosageUnit: DosageUnit = .mg,
form: MedicationForm = .tablet,
route: AdministrationRoute = .oral,
frequency: DosageFrequency = .timesPerDay(2),
isActive: Bool = true
) -> Medication {
Medication(
id: id,
name: name,
genericName: genericName,
dosage: Dosage(amount: dosageAmount, unit: dosageUnit),
form: form,
route: route,
frequency: frequency,
instructions: nil,
startDate: Date(),
endDate: nil,
isActive: isActive,
requiresPrescription: false,
inventory: nil,
reminders: [],
createdAt: Date(),
updatedAt: Date(),
version: 1
)
}
}
14. Dependencies¶
14.1. Swift Package Manager¶
// Package.swift (or via Xcode Project Settings)
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "MedTime",
platforms: [
.iOS(.v15),
.watchOS(.v9)
],
dependencies: [
// Dependency Injection
.package(url: "https://github.com/hmlongco/Factory", from: "2.3.0"),
// Networking (optional, for advanced features)
// Using native URLSession primarily
// Testing
.package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.15.0"),
// Linting (development only)
// SwiftLint via build phase
],
targets: [
.target(
name: "MedTime",
dependencies: [
.product(name: "Factory", package: "Factory")
]
),
.testTarget(
name: "MedTimeTests",
dependencies: [
"MedTime",
.product(name: "SnapshotTesting", package: "swift-snapshot-testing")
]
)
]
)
14.2. Version Constraints¶
| Dependency | Version | Razon |
|---|---|---|
| Factory | 2.3+ | DI container ligero |
| SnapshotTesting | 1.15+ | Testing visual |
| SwiftLint | 0.54+ | Code quality (dev) |
Politica de Dependencias:
- Minimizar dependencias externas - Preferir APIs nativas de Apple
- No incluir networking libraries - URLSession es suficiente
- No incluir Realm/CoreData wrappers - SwiftData es nativo
- No incluir crypto libraries - CryptoKit es nativo
- Auditar seguridad - Revisar CVEs antes de adoptar
14.3. Jailbreak Detection (DV2-P3: SEC-BAJO-003)¶
// Core/Security/JailbreakDetector.swift
import Foundation
import UIKit
/// Detecta si el dispositivo esta jailbroken
/// Comportamiento por defecto: BLOCK (bloquear app si detectado)
final class JailbreakDetector {
enum DetectionResult {
case secure // No jailbreak detectado
case jailbroken // Jailbreak detectado
case suspicious // Indicadores sospechosos
}
enum SecurityAction {
case block // Bloquear app completamente (DEFAULT)
case warn // Mostrar warning, permitir continuar
case log // Solo log, para debug
case none // No action (solo para testing)
}
// CONFIGURACION POR DEFECTO: BLOCK
static let defaultAction: SecurityAction = .block
/// Detecta jailbreak con multiple heuristicas
static func detect() -> DetectionResult {
var score = 0
let checks: [(String, Bool)] = [
("Cydia check", checkCydiaApp()),
("Suspicious paths", checkSuspiciousPaths()),
("Dylib injection", checkDylibInjection()),
("Fork check", checkForkAbility()),
("Symlink check", checkSymlinks()),
("Sandbox check", checkSandboxIntegrity())
]
for (name, detected) in checks {
if detected {
score += 1
Logger.security.warning("Jailbreak indicator: \(name)")
}
}
// Clasificacion por score
if score >= 3 {
return .jailbroken
} else if score > 0 {
return .suspicious
} else {
return .secure
}
}
// MARK: - Detection Methods
private static func checkCydiaApp() -> Bool {
// Check Cydia y otros package managers
let apps = [
"/Applications/Cydia.app",
"/Applications/blackra1n.app",
"/Applications/FakeCarrier.app",
"/Applications/Icy.app",
"/Applications/IntelliScreen.app",
"/Applications/MxTube.app",
"/Applications/RockApp.app",
"/Applications/SBSettings.app",
"/Applications/WinterBoard.app"
]
return apps.contains { FileManager.default.fileExists(atPath: $0) }
}
private static func checkSuspiciousPaths() -> Bool {
// Paths comunes en dispositivos jailbroken
let paths = [
"/usr/sbin/sshd",
"/bin/bash",
"/etc/apt",
"/private/var/lib/apt/",
"/private/var/lib/cydia",
"/private/var/stash",
"/private/var/mobile/Library/SBSettings/Themes",
"/Library/MobileSubstrate/MobileSubstrate.dylib",
"/var/cache/apt",
"/var/lib/apt",
"/var/lib/cydia",
"/usr/libexec/sftp-server",
"/usr/bin/sshd",
"/usr/libexec/ssh-keysign"
]
return paths.contains { FileManager.default.fileExists(atPath: $0) }
}
private static func checkDylibInjection() -> Bool {
// Verificar si MobileSubstrate esta cargado
#if !targetEnvironment(simulator)
let libraries = [
"MobileSubstrate",
"SubstrateLoader",
"SubstrateInserter"
]
for library in libraries {
if let handle = dlopen("/usr/lib/\(library).dylib", RTLD_LAZY) {
dlclose(handle)
return true
}
}
#endif
return false
}
private static func checkForkAbility() -> Bool {
// En sandbox normal, fork() debe fallar
#if !targetEnvironment(simulator)
let pid = fork()
if pid >= 0 {
// fork() tuvo exito - NOT NORMAL en iOS
return true
}
#endif
return false
}
private static func checkSymlinks() -> Bool {
// Check symlinks sospechosos
do {
let path = "/Applications"
let attributes = try FileManager.default.attributesOfItem(atPath: path)
if attributes[.type] as? FileAttributeType == .typeSymbolicLink {
return true
}
} catch {
// Si no podemos leer, asumir seguro
}
return false
}
private static func checkSandboxIntegrity() -> Bool {
// Intento de escribir fuera del sandbox
let testPath = "/private/test_jailbreak.txt"
do {
try "test".write(toFile: testPath, atomically: true, encoding: .utf8)
try? FileManager.default.removeItem(atPath: testPath)
return true // Escritura exitosa = jailbreak
} catch {
return false // Escritura fallo = sandbox OK
}
}
// MARK: - Action Handler
static func enforcePolicy(result: DetectionResult, action: SecurityAction = defaultAction) {
switch (result, action) {
case (.secure, _):
Logger.security.info("Device integrity: SECURE")
return
case (.jailbroken, .block):
Logger.security.critical("Jailbreak detected - BLOCKING APP")
showBlockScreen()
case (.jailbroken, .warn):
Logger.security.warning("Jailbreak detected - WARNING USER")
showWarning()
case (.jailbroken, .log):
Logger.security.warning("Jailbreak detected - LOGGED ONLY")
case (.suspicious, .block):
Logger.security.error("Suspicious indicators - BLOCKING APP")
showBlockScreen()
case (.suspicious, .warn):
Logger.security.warning("Suspicious indicators - WARNING USER")
showWarning()
case (.suspicious, .log):
Logger.security.info("Suspicious indicators - LOGGED ONLY")
case (_, .none):
Logger.security.debug("Detection disabled for testing")
}
}
private static func showBlockScreen() {
// Mostrar pantalla de bloqueo permanente
DispatchQueue.main.async {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first {
let blockVC = JailbreakBlockViewController()
window.rootViewController = blockVC
window.makeKeyAndVisible()
}
}
}
private static func showWarning() {
// Mostrar alerta de advertencia
DispatchQueue.main.async {
let alert = UIAlertController(
title: "Security Warning",
message: "This device appears to be jailbroken. MedTime cannot guarantee the security of your health data on modified devices.",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "I Understand", style: .destructive))
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootVC = windowScene.windows.first?.rootViewController {
rootVC.present(alert, animated: true)
}
}
}
}
// UI para pantalla de bloqueo
final class JailbreakBlockViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemRed
let stackView = UIStackView()
stackView.axis = .vertical
stackView.spacing = 20
stackView.alignment = .center
stackView.translatesAutoresizingMaskIntoConstraints = false
let icon = UIImageView(image: UIImage(systemName: "exclamationmark.shield.fill"))
icon.tintColor = .white
icon.contentMode = .scaleAspectFit
icon.translatesAutoresizingMaskIntoConstraints = false
icon.heightAnchor.constraint(equalToConstant: 100).isActive = true
let title = UILabel()
title.text = "Security Alert"
title.font = .systemFont(ofSize: 28, weight: .bold)
title.textColor = .white
title.textAlignment = .center
let message = UILabel()
message.text = "This device has been modified (jailbroken). For your safety, MedTime cannot run on modified devices as it may compromise your health data security."
message.font = .systemFont(ofSize: 16)
message.textColor = .white
message.textAlignment = .center
message.numberOfLines = 0
stackView.addArrangedSubview(icon)
stackView.addArrangedSubview(title)
stackView.addArrangedSubview(message)
view.addSubview(stackView)
NSLayoutConstraint.activate([
stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 40),
stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -40)
])
}
}
// INTEGRACION EN APP LAUNCH
// App/MedTimeApp.swift
@main
struct MedTimeApp: App {
init() {
// JAILBREAK CHECK AL INICIO
let detectionResult = JailbreakDetector.detect()
JailbreakDetector.enforcePolicy(result: detectionResult)
// Continue con inicializacion normal...
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Configuracion de Comportamiento:
| Ambiente | Action | Justificacion |
|---|---|---|
| Production | BLOCK | PHI critico, zero-tolerance |
| Staging | WARN | Permitir testing en dev devices |
| Debug | LOG | Facilitar desarrollo |
| Unit Tests | NONE | No interferir con tests |
Variables de Ambiente (en xcconfig):
// Production.xcconfig
JAILBREAK_ACTION = BLOCK
// Staging.xcconfig
JAILBREAK_ACTION = WARN
// Debug.xcconfig
JAILBREAK_ACTION = LOG
Justificacion de BLOCK como Default:
- HIPAA Compliance: Dispositivos modificados no garantizan integridad
- Keychain Vulnerabilities: Jailbreak permite acceso a Keychain
- SSL Pinning Bypass: Ataques MITM posibles
- Zero-Knowledge Compromise: Cliente comprometido = arquitectura falla
Metricas de Telemetria:
// Log cada deteccion (sin PII)
struct JailbreakTelemetry: Codable {
let result: String // "secure", "suspicious", "jailbroken"
let action: String // "block", "warn", "log"
let timestamp: Date
let osVersion: String
let deviceModel: String
// NO incluir UDID ni identificadores
}
14.4. Security Audit¶
// Scripts/audit_dependencies.sh
#!/bin/bash
# Check for known vulnerabilities in dependencies
# Run: ./Scripts/audit_dependencies.sh
echo "Auditing Swift Package dependencies..."
# Export resolved packages
swift package show-dependencies --format json > dependencies.json
# Check each dependency against known CVEs
# (In production, use tools like Snyk, Dependabot, or OSV)
echo "Dependencies audit complete."
echo "Review dependencies.json for detailed list."
# Verify no private/internal APIs are used
echo "Checking for private API usage..."
grep -r "UIApplication.shared" --include="*.swift" | grep -v "// MARK: - Allowed"
echo "Audit complete."
15. Build y CI/CD¶
15.1. Configuraciones¶
# .xcconfig files for different environments
# Development.xcconfig
PRODUCT_BUNDLE_IDENTIFIER = com.medtime.dev
DISPLAY_NAME = MedTime Dev
API_BASE_URL = https://api-dev.medtime.app/v1
ENABLE_DEBUG_LOGGING = YES
# Staging.xcconfig
PRODUCT_BUNDLE_IDENTIFIER = com.medtime.staging
DISPLAY_NAME = MedTime Staging
API_BASE_URL = https://api-staging.medtime.app/v1
ENABLE_DEBUG_LOGGING = YES
# Production.xcconfig
PRODUCT_BUNDLE_IDENTIFIER = com.medtime
DISPLAY_NAME = MedTime
API_BASE_URL = https://api.medtime.app/v1
ENABLE_DEBUG_LOGGING = NO
15.2. Fastlane¶
# fastlane/Fastfile
default_platform(:ios)
platform :ios do
desc "Run tests"
lane :test do
run_tests(
scheme: "MedTime",
device: "iPhone 15",
code_coverage: true,
output_directory: "./fastlane/test_output"
)
end
desc "Build for TestFlight"
lane :beta do
setup_ci if ENV['CI']
match(type: "appstore", readonly: true)
increment_build_number(
build_number: ENV['BUILD_NUMBER'] || Time.now.strftime("%Y%m%d%H%M")
)
build_app(
scheme: "MedTime",
configuration: "Release",
export_method: "app-store"
)
upload_to_testflight(
skip_waiting_for_build_processing: true
)
slack(
message: "MedTime Beta #{get_version_number} (#{get_build_number}) uploaded to TestFlight",
success: true
)
end
desc "Release to App Store"
lane :release do
setup_ci if ENV['CI']
match(type: "appstore", readonly: true)
build_app(
scheme: "MedTime",
configuration: "Release",
export_method: "app-store"
)
upload_to_app_store(
submit_for_review: true,
automatic_release: false,
force: true,
precheck_include_in_app_purchases: false,
submission_information: {
add_id_info_uses_idfa: false
}
)
end
desc "Build for screenshots"
lane :screenshots do
capture_screenshots(scheme: "MedTimeUITests")
frame_screenshots
end
error do |lane, exception|
slack(
message: "Error in lane #{lane}: #{exception.message}",
success: false
)
end
end
15.3. App Store¶
# fastlane/metadata/en-US/description.txt
MedTime - Your intelligent medication companion
Never miss a dose again. MedTime helps you manage your medications with:
- Smart reminders that adapt to your schedule
- Easy dose tracking with one tap
- Family sharing for caregivers
- Offline mode - works without internet
- Complete privacy - your data stays encrypted on your device
Key Features:
- Add unlimited medications
- Customizable reminder schedules
- Interaction warnings
- Adherence statistics and streaks
- Apple Watch companion app
- Home Screen widgets
- Export reports for your doctor
Privacy First:
MedTime uses end-to-end encryption. Your health data is encrypted on your device and never visible to our servers. Your privacy is our priority.
# fastlane/metadata/en-US/keywords.txt
medication,reminder,pill,health,tracking,adherence,prescription,medicine,drug,pharmacy
# fastlane/metadata/en-US/privacy_url.txt
https://medtime.app/privacy
# fastlane/metadata/en-US/support_url.txt
https://medtime.app/support
16. Anexo A: Diagrama de Arquitectura¶
┌──────────────────────────────────────────────────────────────────────────┐
│ MedTime iOS │
├──────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ PRESENTATION LAYER │ │
│ │ ┌──────────────┐ ┌───────────────┐ ┌─────────────────────────┐ │ │
│ │ │ SwiftUI │ │ ViewModels │ │ Coordinators │ │ │
│ │ │ Views │──│ (MVVM) │──│ (Navigation) │ │ │
│ │ └──────────────┘ └───────────────┘ └─────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ DOMAIN LAYER │ │
│ │ ┌──────────────┐ ┌───────────────┐ ┌─────────────────────────┐ │ │
│ │ │ Entities │ │ Use Cases │ │ Repository Protocols │ │ │
│ │ │ (Models) │ │ (Interactor) │ │ (Ports) │ │ │
│ │ └──────────────┘ └───────────────┘ └─────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ DATA LAYER │ │
│ │ ┌──────────────┐ ┌───────────────┐ ┌─────────────────────────┐ │ │
│ │ │ Repositories │ │ Data Sources │ │ Mappers │ │ │
│ │ │ (Adapters) │ │ Local/Remote │ │ (DTO <-> Entity) │ │ │
│ │ └──────────────┘ └───────────────┘ └─────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ CORE LAYER │ │
│ │ ┌───────────┐ ┌────────────┐ ┌───────────┐ ┌────────────────┐ │ │
│ │ │ Crypto │ │ Sync │ │ Network │ │ Notifications │ │ │
│ │ │ (E2E/ZK) │ │ Manager │ │ Monitor │ │ Manager │ │ │
│ │ └───────────┘ └────────────┘ └───────────┘ └────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │ │ │ │
│ ┌────────────┴──────────────┴──────────────┘ │
│ ▼ ▼ │
│ ┌──────────────────────┐ ┌──────────────────────────────────────┐ │
│ │ LOCAL STORAGE │ │ REMOTE (Server) │ │
│ │ ┌────────────────┐ │ │ ┌─────────────────────────────────┐ │ │
│ │ │ SwiftData │ │ │ │ API Client (URLSession) │ │ │
│ │ │ (SQLite) │ │ │ │ ┌────────────────────────────┐ │ │ │
│ │ └────────────────┘ │ │ │ │ Encrypted Blobs Only │ │ │ │
│ │ ┌────────────────┐ │ │ │ │ (Zero-Knowledge) │ │ │ │
│ │ │ KeyChain │ │ │ │ └────────────────────────────┘ │ │ │
│ │ │ (Encryption │ │ │ └─────────────────────────────────┘ │ │
│ │ │ Keys) │ │ └──────────────────────────────────────┘ │
│ │ └────────────────┘ │ │
│ │ ┌────────────────┐ │ │
│ │ │ UserDefaults │ │ │
│ │ │ (Settings) │ │ │
│ │ └────────────────┘ │ │
│ └──────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ EXTENSIONS │ │
│ │ ┌──────────────┐ ┌───────────────┐ ┌─────────────────────────┐ │ │
│ │ │ Widgets │ │ Apple Watch │ │ Notifications │ │ │
│ │ │ (WidgetKit) │ │ (WatchKit) │ │ (UNUserNotif) │ │ │
│ │ └──────────────┘ └───────────────┘ └─────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────┘
17. Anexo B: Checklist de Implementacion¶
17.1. Fase 1: Setup Inicial¶
- Crear proyecto Xcode con targets (iOS, Widget, Watch)
- Configurar SPM dependencies
- Setup SwiftData schema
- Configurar KeyChain wrapper
- Implementar DI container
17.2. Fase 2: Domain Layer¶
- Definir entities
- Implementar use cases core
- Definir repository protocols
- Implementar error handling
17.3. Fase 3: Data Layer¶
- Implementar SwiftData data source
- Implementar API client
- Crear mappers
- Implementar repositories
17.4. Fase 4: Crypto¶
- Implementar CryptoManager
- Key derivation (Argon2id)
- Blob encryption/decryption
- KeyChain integration
17.5. Fase 5: Sync Engine¶
- Implementar SyncManager
- Queue de operaciones
- Conflict resolution
- Network monitor
17.6. Fase 6: Presentation¶
- Implementar navigation
- Crear ViewModels
- Diseñar screens principales
- Implementar components
17.7. Fase 7: Notifications¶
- APNs registration
- Local notifications
- Notification actions
- Background refresh
17.8. Fase 8: Extensions¶
- Widget timeline providers
- Widget views
- Watch app
- Watch connectivity
17.9. Fase 9: Testing¶
- Unit tests (80%+ coverage)
- UI tests
- Snapshot tests
- Performance tests
17.10. Fase 10: Release¶
- App Store metadata
- Screenshots
- Privacy policy
- Beta testing
- Export Compliance validation
18. Anexo C: Export Compliance (DV2-P2)¶
Agregado: OPS-MEDIO-001 - Validacion de Export Compliance para App Store.
18.1. Declaracion de Uso de Criptografia¶
MedTime utiliza criptografia para proteger datos de salud (PHI). Esta seccion documenta el cumplimiento con las regulaciones de exportacion de EE.UU. (BIS/EAR) y los requisitos de App Store Connect.
18.1.1. Algoritmos Criptograficos Utilizados¶
| Algoritmo | Proposito | Categoria EAR | Exempt |
|---|---|---|---|
| AES-256-GCM | Cifrado de blobs de datos | 5A002 | Si* |
| Argon2id | Derivacion de claves | 5A002 | Si* |
| SHA-256 | Checksums, HKDF | No controlado | N/A |
| ECDH P-256 | Intercambio de claves | 5A002 | Si* |
| ECDSA P-256 | Firmas digitales | 5A002 | Si* |
*Exento bajo EAR 740.17(b)(1) - Proteccion de datos del usuario.
18.1.2. App Store Connect - Export Compliance¶
<!-- Info.plist - Export Compliance Settings -->
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<!-- Alternativa si se require clasificacion -->
<key>ITSEncryptionExportComplianceCode</key>
<string>XXXXXXXXXX</string>
Justificacion de Exencion (EAR 740.17(b)(1)):
La criptografia en MedTime esta exenta de requisitos de clasificacion porque:
- Uso exclusivo para proteccion de datos del usuario: El cifrado E2E protege
PHI del usuario y no se utiliza para propositos gubernamentales, militares o PHI del usuario y no se utiliza para propositos gubernamentales, militares o de inteligencia.
- Implementacion estandar: Utilizamos CryptoKit de Apple que ya esta aprobado
y distribuido en iOS. y distribuido en iOS.
-
Sin backdoors: No existe acceso administrativo a datos cifrados.
-
Datos en transito y reposo: El cifrado protege datos personales de salud
durante almacenamiento y transmision. durante almacenamiento y transmision.
18.2. Checklist de Compliance Pre-Submission¶
# export-compliance-checklist.yml
export_compliance:
version: "1.0"
app: "MedTime"
date_reviewed: "YYYY-MM-DD"
questionnaire:
- question: "Does your app use encryption?"
answer: true
justification: "AES-256-GCM for user data protection"
- question: "Does your app qualify for any exemptions?"
answer: true
exemption: "EAR 740.17(b)(1)"
reason: "Personal data protection only"
- question: "Is the encryption available to the user?"
answer: false
reason: "Encryption is transparent, user cannot modify algorithms"
- question: "Is your app designed for government/military use?"
answer: false
- question: "Does your app use third-party encryption?"
answer: true
libraries:
- name: "Apple CryptoKit"
purpose: "AES-256-GCM, SHA-256, ECDH"
pre_approved: true
- name: "swift-crypto"
purpose: "Argon2id key derivation"
pre_approved: true
declaration:
uses_non_exempt_encryption: false
exempt_under: "EAR 740.17(b)(1)"
documentation_complete: true
approvals:
- role: "Security Lead"
approved: false
date: null
- role: "Legal Counsel"
approved: false
date: null
18.3. Proceso de Validacion¶
flowchart TD
subgraph PreSubmit["PRE-SUBMISSION VALIDATION"]
A[Review Encryption Usage] --> B{Uses Non-Exempt?}
B --> |No| C[Set ITSAppUsesNonExemptEncryption = false]
B --> |Yes| D[Obtain ECCN Classification]
D --> E[Submit Annual Self-Classification]
C --> F[Document Exemption Justification]
end
subgraph Submit["APP STORE SUBMISSION"]
F --> G[Upload to App Store Connect]
E --> G
G --> H[Answer Export Compliance Questions]
H --> I[Submit for Review]
end
subgraph Annual["ANNUAL REQUIREMENTS"]
J[Jan 1-Feb 1: Self-Classification Report] --> K[BIS SNAP-R System]
K --> L[Document Submission]
end
I --> |If Exempt| Annual
18.4. Integracion con CI/CD¶
// Scripts/validate-export-compliance.swift
import Foundation
/// Validates export compliance settings before App Store submission
struct ExportComplianceValidator {
struct ValidationResult {
let isValid: Bool
let issues: [String]
let warnings: [String]
}
func validate() -> ValidationResult {
var issues: [String] = []
var warnings: [String] = []
// Check Info.plist
if let infoPlist = readInfoPlist() {
// Verify ITSAppUsesNonExemptEncryption
if let usesEncryption = infoPlist["ITSAppUsesNonExemptEncryption"] as? Bool {
if usesEncryption {
// Must have ECCN code
if infoPlist["ITSEncryptionExportComplianceCode"] == nil {
issues.append("ITSEncryptionExportComplianceCode required when ITSAppUsesNonExemptEncryption is true")
}
}
} else {
issues.append("Missing ITSAppUsesNonExemptEncryption in Info.plist")
}
} else {
issues.append("Could not read Info.plist")
}
// Check for encryption usage
let encryptionAPIs = findEncryptionUsage()
if encryptionAPIs.isEmpty {
warnings.append("No encryption APIs detected - verify if ITSAppUsesNonExemptEncryption should be true")
}
// Verify exemption documentation
if !FileManager.default.fileExists(atPath: "Docs/ExportCompliance.md") {
warnings.append("Missing ExportCompliance.md documentation")
}
return ValidationResult(
isValid: issues.isEmpty,
issues: issues,
warnings: warnings
)
}
private func findEncryptionUsage() -> [String] {
// Scan for CryptoKit usage
let patterns = [
"AES.GCM",
"ChaChaPoly",
"SecKeyCreateEncryptedData",
"CCCrypt",
"Argon2"
]
// Implementation to scan source files
return patterns
}
private func readInfoPlist() -> [String: Any]? {
// Read and parse Info.plist
return nil
}
}
// MARK: - Fastlane Integration
// Add to Fastfile:
//
// lane :validate_export do
// sh("swift Scripts/validate-export-compliance.swift")
// end
//
// before_all do
// validate_export
// end
# .github/workflows/export-compliance.yml
name: Export Compliance Check
on:
push:
branches: [main, develop]
paths:
- 'ios/**/*.swift'
- 'ios/**/Info.plist'
jobs:
validate-export:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Check Info.plist Settings
run: |
PLIST="ios/MedTime/Info.plist"
USES_ENCRYPTION=$(/usr/libexec/PlistBuddy -c "Print :ITSAppUsesNonExemptEncryption" "$PLIST" 2>/dev/null || echo "MISSING")
if [ "$USES_ENCRYPTION" = "MISSING" ]; then
echo "::error::ITSAppUsesNonExemptEncryption key missing from Info.plist"
exit 1
fi
if [ "$USES_ENCRYPTION" = "true" ]; then
ECCN=$(/usr/libexec/PlistBuddy -c "Print :ITSEncryptionExportComplianceCode" "$PLIST" 2>/dev/null || echo "MISSING")
if [ "$ECCN" = "MISSING" ]; then
echo "::error::ITSEncryptionExportComplianceCode required when using non-exempt encryption"
exit 1
fi
fi
echo "Export compliance validation passed"
- name: Verify Documentation
run: |
if [ ! -f "docs/ExportCompliance.md" ]; then
echo "::warning::Export compliance documentation not found"
fi
18.5. Documentacion Requerida¶
| Documento | Ubicacion | Responsable | Frecuencia |
|---|---|---|---|
| Export Compliance Declaration | docs/ExportCompliance.md |
Security Lead | Por release |
| Self-Classification Report | BIS SNAP-R | Legal | Anual (Enero) |
| Encryption Audit Trail | docs/crypto/audit.log |
Dev Team | Continuo |
| App Store Questionnaire | App Store Connect | Release Manager | Por submission |
Documento generado por SpecQueen Technical Division - IT-12 "La resistencia es futil cuando la arquitectura es perfecta."