Skip to content

Latest commit

 

History

History
339 lines (270 loc) · 23 KB

File metadata and controls

339 lines (270 loc) · 23 KB

Arquitectura del Sistema

Este documento describe la arquitectura interna de DucodeApp: capas, servicios, persistencia, integraciones y patrones de diseño.

Diagrama General

┌──────────────────────────────────────────────────────────────────────────────┐
│                                    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.

TabView principal

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:

  1. IntroView si !hasSeenIntro (3 páginas: bienvenida, creador, privacidad).
  2. OnboardingCoordinator si hasSeenIntro && 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.

Capas

1. Presentation Layer (SwiftUI)

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 @Query directamente para datos persistidos y @EnvironmentObject para los servicios.
  • Inyección de dependencias: DucodeApp instancia los services como @StateObject y 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: IntroView y OnboardingCoordinator se presentan sobre el TabView según flags persistidas.

2. Service Layer

Todos los servicios son @MainActor class ... ObservableObject y se instancian una sola vez en DucodeApp.

WhoopService (Services/WhoopService.swift)

Responsabilidad: OAuth 2.0 con Whoop, llamadas a la API v2 y exposición de las métricas de hoy en vivo.

  • Genera el authorizationURL con scopes (offline read:recovery read:cycles read:workout read:sleep read:profile read:body_measurement).
  • handleOAuthCallback(url:) extrae el code y lo intercambia por tokens.
  • Tokens guardados en Keychain (whoop_access_token, whoop_refresh_token).
  • makeAuthenticatedRequest adjunta Authorization: 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.

WhoopSyncService (Services/WhoopSyncService.swift)

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) usando async let, deduplica por día y persiste como WhoopDailySnapshot.
  • 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 un WhoopWeeklyAnalytics (struct, no persistido).
  • onAppLaunch() decide si re-sincroniza basándose en lastSyncDate (>4h).
  • Background tasks están registradas pero como placeholder (sync en foreground por ahora).

NutritionAIService (Services/NutritionAIService.swift)

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 una LanguageModelSession, llama respond(to:generating: PlanGenerado.self) y mapea el resultado a PlanDiario (modelo de display).
  • regenerarComida(at:) reemplaza una sola comida del plan actual sin tocar las demás. Usa el tipo, 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, los alimentosDisponibles, las métricas Whoop de hoy, el WhoopWeeklyAnalytics y el HealthSummary si están disponibles.
  • regeneratingMealIndex: Int? permite a la UI mostrar un spinner sólo en la fila correspondiente.

CoachChatService (Services/CoachChatService.swift)

Responsabilidad: chat conversacional multi-turn con todo el contexto del usuario.

  • Mantiene una LanguageModelSession viva con instrucciones construidas a partir de un CoachContext (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:) invoca session.streamResponse(to:) y publica el texto incremental en streamingText. Si el stream falla, hace fallback a respond(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.

HealthKitService (Services/HealthKitService.swift)

Responsabilidad: leer Apple Health en bloque y empaquetar todo en un HealthSummary listo para inyectar al LLM y para mostrar en Health360Card.

  • hasRequestedAuthorization se persiste en UserDefaults (clave healthKitHasRequestedAuthorization) para evitar re-pedir permiso en cada arranque.
  • requestAuthorization() pide read-only sobre todos los readTypes definidos.
  • refreshAll() lanza ~30 queries en paralelo con async let y construye un único HealthSummary.
  • generatePromptContext() (extension de HealthSummary) 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.

3. Data Layer

SwiftData

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.

Persistencia de "última fuente conocida"

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.

UserDefaults / @AppStorage

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

Keychain

Clave Contenido
whoop_access_token Bearer token de Whoop
whoop_refresh_token Refresh token de Whoop

Implementación en KeychainHelper (WhoopService.swift) usando kSecClassGenericPassword.

Foundation Models (Apple Intelligence)

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:

  • @Generable structs 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 llamadaSystemLanguageModel.default.availability puede cambiar (modelo descargándose).
  • Streaming para chat — la sesión expone streamResponse(to:) y publica partial.content (acumulado, no incremental).

Ver ./INTEGRATIONS.md y ./COACH.md para más detalle.

HealthKit

  • Entitlement com.apple.developer.healthkit declarado en DucodeApp/DucodeApp.entitlements.
  • Info.plist incluye NSHealthShareUsageDescription y NSHealthUpdateUsageDescription.
  • 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 nil y HealthSummary.hasAnyData == false. El Dashboard oculta Health360Card y NutritionAI / Coach simplemente omiten el bloque de Health en sus prompts.

Manejo de errores

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"

Logging

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

Distribución

                       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.

Documentos relacionados