Este documento describe la arquitectura interna de DucodeApp: capas, servicios, persistencia, integraciones y patrones de diseño.
┌──────────────────────────────────────────────────────────────────────────────┐
│ iOS App │
│ │
│ ┌──────────────────────────── Presentation ──────────────────────────────┐ │
│ │ │ │
│ │ IntroView ──▶ OnboardingCoordinator ──▶ ContentView (TabView) │ │
│ │ (3 páginas) (6 pasos: welcome → health → objetivo │ │
│ │ → preferences → inventario → generating) │ │
│ │ │ │
│ │ ┌──────────┬──────────┬──────────┬──────────┬──────────┐ │ │
│ │ ▼ ▼ ▼ ▼ ▼ ▼ │ │
│ │ Inicio Coach Historial Comidas Objetivo │ │
│ │ (Dash) (chat) (Whoop 7d) (prefs) │ │
│ │ │ │ │ │
│ │ ├─ Health360Card (HealthKit) │ │
│ │ ├─ PlanDelDia (regenerar comida individual) │ │
│ │ ├─ MetricsGrid (Recovery / Strain / HRV / Sueño) │ │
│ │ └─ Banner Conectar Apple Health │ │
│ └─────────────────────────────────────┬───────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────┴────── Service Layer ─────────────┐ │
│ │ │ │
│ │ WhoopService NutritionAIService CoachChatService │ │
│ │ • OAuth 2.0 • LanguageModelSession • LanguageModelSession│ │
│ │ • Whoop API v2 • @Generable PlanGenerado • streamResponse │ │
│ │ • Tokens en Keychain • generarPlanDiario(...) • System prompt din. │ │
│ │ • Métricas live • regenerarComida(at:) • CoachContext │ │
│ │ │ │
│ │ WhoopSyncService HealthKitService │ │
│ │ • Sync 7 días en paralelo • requestAuthorization │ │
│ │ • Persiste WhoopDailySnapshot • refreshAll (30+ tipos) │ │
│ │ • Calcula WhoopWeeklyAnalytics • HealthSummary + prompt ctx │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────── Data Layer ────────────────────────────────┐ │
│ │ │ │
│ │ SwiftData (ModelContainer) UserDefaults / @AppStorage │ │
│ │ • Alimento • hasSeenIntro │ │
│ │ • Objetivo • hasCompletedOnboarding │ │
│ │ • DiaRegistro • selectedTab │ │
│ │ • WhoopDailySnapshot • healthKitHasRequestedAuth │ │
│ │ • UserPreferences │ │
│ │ • ChatMessage Keychain │ │
│ │ • whoop_access_token │ │
│ │ AppConfig (Info.plist ← Secrets.xcconfig)│ whoop_refresh_token │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Whoop API v2 │ │ Foundation Models│ │ HealthKit │
│ api.prod.whoop │ │ on-device │ │ (system store) │
└────────────────┘ └──────────────────┘ └──────────────────┘
Toda la inteligencia (planes, chat, recomendaciones) corre on-device con Foundation Models. La app no tiene servidores propios y solo se comunica con la API pública de Whoop sobre HTTPS.
ContentView define cinco tabs persistidos en @AppStorage("selectedTab"). El estado se expone para que vistas hijas (por ejemplo, el botón "Inicio" del Coach) puedan navegar entre tabs sin ScrollView ni NavigationStack adicional.
| Tag | Tab | View | Función |
|---|---|---|---|
| 0 | Inicio | DashboardView |
Métricas Whoop, Salud 360, plan del día, regenerar comida |
| 1 | Coach | CoachView |
Chat conversacional con Apple Intelligence |
| 2 | Historial | WhoopHistoryView |
Tendencias y analytics 7 días desde WhoopDailySnapshot |
| 3 | Comidas | PreferenciasComidaView |
Cocinas, restricciones, alergias, notas extra, inventario |
| 4 | Objetivo | ObjetivoView |
TMB/TDEE, peso, altura, edad, nivel de actividad |
Sobre el TabView, ContentView dispara dos fullScreenCover secuenciales:
IntroViewsi!hasSeenIntro(3 páginas: bienvenida, creador, privacidad).OnboardingCoordinatorsihasSeenIntro && whoopService.isAuthenticated && !hasCompletedOnboarding.
Existe un backfill que marca hasCompletedOnboarding = true cuando el usuario ya tenía un Objetivo previo, evitando re-entrar al onboarding tras una actualización.
DucodeApp/Views/
├─ ContentView.swift TabView raíz + presentación de intro/onboarding
├─ DashboardView.swift Métricas Whoop, plan, fallback a snapshot
├─ Health360Card.swift Card expandible con HealthSummary
├─ PreferenciasComidaView.swift Auto-save prefs + inventario + regenerar plan
├─ ObjetivoView.swift Configuración TMB/TDEE
├─ WhoopHistoryView.swift Charts de la última semana
├─ Coach/
│ ├─ CoachView.swift Chat con suggestion chips, streaming, reset
│ ├─ ChatBubbleView.swift Bubbles + TypingIndicator
│ └─ MarkdownText.swift Parser markdown ligero
└─ Onboarding/
├─ IntroView.swift 3 páginas pre-Whoop (bienvenida/creador/privacidad)
├─ OnboardingCoordinator.swift Máquina de estados con progress bar y back button
├─ OnboardingComponents.swift SelectionChip, OnboardingPrimaryButton, etc.
├─ OnboardingWelcomeView.swift Post-Whoop celebration
├─ OnboardingHealthStep.swift Auth HealthKit + pre-fill peso/altura
├─ OnboardingObjetivoStep.swift Tipo objetivo + antropometría + actividad
├─ OnboardingPreferencesStep.swift Cocinas, restricciones, alergias, dislikes, notas
├─ OnboardingInventarioStep.swift Selección de alimentos comunes (skip-friendly)
└─ OnboardingGeneratingStep.swift Saving → Generating → Done (con fallback AI off)
Patrones aplicados:
- MVVM ligero: las views consumen
@Querydirectamente para datos persistidos y@EnvironmentObjectpara los servicios. - Inyección de dependencias:
DucodeAppinstancia los services como@StateObjecty los expone con.environmentObject(...). - State sharing entre tabs:
@AppStorage("selectedTab")permite a una vista mover al usuario a otro tab sin un coordinator dedicado. - fullScreenCover encadenados:
IntroViewyOnboardingCoordinatorse presentan sobre el TabView según flags persistidas.
Todos los servicios son @MainActor class ... ObservableObject y se instancian una sola vez en DucodeApp.
Responsabilidad: OAuth 2.0 con Whoop, llamadas a la API v2 y exposición de las métricas de hoy en vivo.
- Genera el
authorizationURLcon scopes (offline read:recovery read:cycles read:workout read:sleep read:profile read:body_measurement). handleOAuthCallback(url:)extrae elcodey lo intercambia por tokens.- Tokens guardados en Keychain (
whoop_access_token,whoop_refresh_token). makeAuthenticatedRequestadjuntaAuthorization: Bearer …, refresca el token al recibir 401 y reintenta una vez.- Endpoints consumidos:
/user/profile/basic,/recovery,/cycle,/activity/sleep,/activity/workout. - Expone
recoveryPercentage,strainScore,hrvValue,restingHeartRate,sleepHours,kilojoulesSpent,caloriesSpent, etc., como computed properties para el Dashboard. logout()borra tokens y limpia estado en memoria.
Responsabilidad: snapshot histórico de 7 días para alimentar Coach y NutritionAI.
configure(modelContext:whoopService:)se llama al inicio para inyectar dependencias.syncHistoricalData(days:)hace 4 fetches en paralelo (recoveries / cycles / sleeps / workouts) usandoasync let, deduplica por día y persiste comoWhoopDailySnapshot.- Comparación de fechas se hace con calendario UTC porque Whoop sirve en UTC.
loadCachedAnalytics()consulta los últimos 7 días de snapshots y construye unWhoopWeeklyAnalytics(struct, no persistido).onAppLaunch()decide si re-sincroniza basándose enlastSyncDate(>4h).- Background tasks están registradas pero como placeholder (sync en foreground por ahora).
Responsabilidad: generación de planes diarios y regeneración de comidas con Foundation Models.
availability: SystemLanguageModel.Availability— detecta.available,.unavailable(.deviceNotEligible),.unavailable(.appleIntelligenceNotEnabled)o.unavailable(.modelNotReady)y mapea a un mensaje en español.buildInstructions(preferences:)construye el system prompt con las cocinas, restricciones, alergias, dislikes y notas extra del usuario.generarPlanDiario(...)crea unaLanguageModelSession, llamarespond(to:generating: PlanGenerado.self)y mapea el resultado aPlanDiario(modelo de display).regenerarComida(at:)reemplaza una sola comida del plan actual sin tocar las demás. Usa eltipo, calorías y proteína actuales como restricciones y le pasa los otros platos del día como "no repetir".- Inyecta al prompt del usuario el
objetivo, losalimentosDisponibles, las métricas Whoop de hoy, elWhoopWeeklyAnalyticsy elHealthSummarysi están disponibles. regeneratingMealIndex: Int?permite a la UI mostrar un spinner sólo en la fila correspondiente.
Responsabilidad: chat conversacional multi-turn con todo el contexto del usuario.
- Mantiene una
LanguageModelSessionviva con instrucciones construidas a partir de unCoachContext(perfil, preferencias, Whoop hoy, weeklyAnalytics, HealthSummary, plan actual, últimas comidas). ensureSession(context:)re-crea la sesión solo cuando el contexto cambia (compara la cadena de instrucciones).send(_:context:)invocasession.streamResponse(to:)y publica el texto incremental enstreamingText. Si el stream falla, hace fallback arespond(to:).resetConversation(context:)crea una sesión nueva (ver Coach.md para más detalle).
Ver ./COACH.md para detalle de prompt, contexto y persistencia.
Responsabilidad: leer Apple Health en bloque y empaquetar todo en un HealthSummary listo para inyectar al LLM y para mostrar en Health360Card.
hasRequestedAuthorizationse persiste enUserDefaults(clavehealthKitHasRequestedAuthorization) para evitar re-pedir permiso en cada arranque.requestAuthorization()pide read-only sobre todos losreadTypesdefinidos.refreshAll()lanza ~30 queries en paralelo conasync lety construye un únicoHealthSummary.generatePromptContext()(extension deHealthSummary) produce un bloque de texto compacto que NutritionAIService y CoachChatService inyectan al prompt.
Categorías leídas:
| Categoría | Tipos |
|---|---|
| Body composition | bodyMass, bodyMassIndex, bodyFatPercentage, leanBodyMass, height |
| Activity | stepCount, distanceWalkingRunning, activeEnergyBurned, basalEnergyBurned, flightsClimbed, appleExerciseTime, appleStandTime, vo2Max |
| Vitals | heartRate, restingHeartRate, heartRateVariabilitySDNN, oxygenSaturation, respiratoryRate |
| Nutrition | dietaryEnergyConsumed, dietaryProtein, dietaryCarbohydrates, dietaryFatTotal, dietaryWater |
| Sleep | sleepAnalysis (in-bed, awake, asleepCore/Deep/REM) |
| Workouts | workoutType (últimos 7 días, sumando kcal y top types) |
| Mindfulness | mindfulSession (minutos hoy) |
Ver ./INTEGRATIONS.md para el flujo de autorización completo.
Un único ModelContainer se crea en DucodeApp.init() con todos los modelos:
modelContainer = try ModelContainer(
for: Alimento.self,
Objetivo.self,
DiaRegistro.self,
WhoopDailySnapshot.self,
UserPreferences.self,
ChatMessage.self
)El container se inyecta con .modelContainer(modelContainer) y el contexto principal se reutiliza para WhoopSyncService.configure(modelContext:...).
Detalle de cada modelo en ./DATA_MODELS.md.
El Dashboard implementa un patrón simple de fallback para que las métricas Whoop nunca aparezcan en cero entre arranques:
private var dashboardRecovery: Double {
whoopService.recoveryPercentage > 0
? whoopService.recoveryPercentage
: (latestSnapshot?.recoveryScore ?? 0)
}La live data del WhoopService tiene prioridad. Si está vacía (ej. la app acaba de abrir y aún no llamó fetchAllData), cae al WhoopDailySnapshot más reciente. Esto rinde una UX inmediata sin loaders innecesarios.
| Clave | Tipo | Uso |
|---|---|---|
hasSeenIntro |
Bool | Mostrar IntroView solo la primera vez |
hasCompletedOnboarding |
Bool | Mostrar OnboardingCoordinator solo si falta |
selectedTab |
Int | Tab actual (compartido entre vistas) |
healthKitHasRequestedAuthorization |
Bool | Saber si ya pedimos auth de HealthKit |
| Clave | Contenido |
|---|---|
whoop_access_token |
Bearer token de Whoop |
whoop_refresh_token |
Refresh token de Whoop |
Implementación en KeychainHelper (WhoopService.swift) usando kSecClassGenericPassword.
Toda la generación de contenido corre en LanguageModelSession:
let session = LanguageModelSession(instructions: systemPrompt)
let response = try await session.respond(to: userPrompt, generating: PlanGenerado.self)
// o para chat:
for try await partial in session.streamResponse(to: userMessage) {
streamingText = partial.content
}Patrones clave:
@Generablestructs garantizan estructura JSON-like (PlanGenerado,ComidaGenerada,IngredienteGenerado). Cada propiedad puede llevar un@Guide(description: "...")que el modelo respeta.- System instructions construidas dinámicamente (no hardcodeadas) — cada vez que cambian las preferencias o el contexto se rearman.
- Availability check antes de cada llamada —
SystemLanguageModel.default.availabilitypuede cambiar (modelo descargándose). - Streaming para chat — la sesión expone
streamResponse(to:)y publicapartial.content(acumulado, no incremental).
Ver ./INTEGRATIONS.md y ./COACH.md para más detalle.
- Entitlement
com.apple.developer.healthkitdeclarado enDucodeApp/DucodeApp.entitlements. Info.plistincluyeNSHealthShareUsageDescriptionyNSHealthUpdateUsageDescription.- La autorización se solicita una vez (banner en Dashboard u onboarding step) y se persiste el flag.
- Si el usuario deniega permisos, las queries devuelven
nilyHealthSummary.hasAnyData == false. El Dashboard ocultaHealth360Cardy NutritionAI / Coach simplemente omiten el bloque de Health en sus prompts.
| Servicio | Estrategia |
|---|---|
| WhoopService | WhoopError enum (notAuthenticated / authenticationFailed / invalidResponse / apiError(statusCode)). Auto-refresh con un retry en 401. |
| WhoopSyncService | Captura errores en cada fetch y los logea sin bloquear los demás |
| NutritionAIService | Si availability != .available muestra mensaje localizado y NO inicia sesión. Errores de generación se publican en error: String? y la UI los renderiza |
| CoachChatService | Si stream falla, hace fallback a session.respond(to:). Si no está disponible, lanza NSError con mensaje localizado |
| HealthKitService | Cualquier error en la auth o queries se captura y summary queda con campos nil. La UI lo trata como "sin datos" |
Convención visual de prefijos (no hay framework formal, son print(...) directos):
| Emoji | Significado |
|---|---|
| 🔵 | Info / OAuth (paso por paso) |
| 🟡 | En progreso / acción en curso |
| 🟢 | Éxito |
| 🔴 | Error |
| 🔄 | Sincronización |
| 🤖 | Generación con Foundation Models |
| 🚀 | App lifecycle |
| 📊 | Snapshot / dato calculado |
Cambios en código
│
▼
┌─────────────────────┐
│ xcodegen generate │ (auto en before_all)
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ fastlane beta │
│ • bump build # │
│ • build_app (gym) │
│ • upload_to_TF │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ App Store Connect │
│ (procesando) │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ TestFlight Internal │
│ (hasta 100 users) │
└─────────────────────┘
Ver ./DISTRIBUTION.md para el detalle completo.
- DATA_MODELS.md — todos los
@Modely structs auxiliares - INTEGRATIONS.md — Whoop, Foundation Models, HealthKit
- COACH.md — chat conversacional on-device
- USER_FLOWS.md — onboarding, daily flow, estados de error
- SETUP.md — pasos para correr el proyecto
- DISTRIBUTION.md — Fastlane y TestFlight
- PRIVACY_POLICY.md — datos que se procesan y dónde