DucodeApp se integra con tres sistemas externos: la API pública de Whoop, los Foundation Models de Apple Intelligence (on-device) y HealthKit. No hay backend propio, ni servicios de terceros tipo Firebase / Sentry / analytics.
Whoop es un wearable de fitness que mide recovery, strain, sueño, HRV y workouts. La app consume la API v2 con OAuth 2.0.
Base URL: https://api.prod.whoop.com/developer/v2
Documentación oficial: developer.whoop.com
| Métrica | Descripción | Uso en la app |
|---|---|---|
| Recovery Score | Recuperación 0-100% | Ajustar intensidad y calorías del día |
| Strain | Esfuerzo acumulado 0-21 | Estimar gasto adicional |
| HRV (RMSSD ms) | Variabilidad cardíaca | Indicador de estado físico |
| Resting Heart Rate | Frecuencia cardíaca en reposo | Indicador de recuperación |
| SpO2 / Skin Temp | Saturación O2 / temperatura corporal | Contexto adicional |
| Sleep stages | Inbed / awake / light / deep / REM / debt | Ajustar plan, alertar deuda de sueño |
| Sleep performance | Performance / efficiency / consistency | Contexto para AI |
| Workouts | Sport, strain, kilojoule, HR, distancia | Tipos top semanales y kcal quemadas |
| Profile | Nombre y email | Saludo personalizado |
┌─────────────┐ ┌─────────────┐
│ iOS App │ │ Whoop │
└──────┬──────┘ └──────┬──────┘
│ │
│ 1. User taps "Conectar Whoop" │
│ → UIApplication.shared.open(authorizationURL) │
│ │
│ 2. GET /oauth/oauth2/auth │
│ ?client_id=…&redirect_uri=ducode://oauth/… │
│ &response_type=code │
│ &scope=offline read:recovery read:cycles … │
│ │
│ 3. User logs in & authorizes │
│ ◄──────────────────────────────────────────────┐ │
│ 302 ducode://oauth/callback?code=AUTH_CODE │
│ │
│ 4. .onOpenURL → handleOAuthCallback(url:) │
│ │
│ 5. POST /oauth/oauth2/token │
│ grant_type=authorization_code │
│ code=AUTH_CODE & client_id & client_secret │
│ ────────────────────────────────────────────────►│
│ │
│ 6. { access_token, refresh_token, expires_in } │
│ ◄────────────────────────────────────────────────│
│ Tokens guardados en Keychain. │
│ isAuthenticated = true. │
│ fetchAllData() │
Scopes solicitados (definidos en AppConfig.whoopScopes):
offline → necesario para refresh_token
read:recovery
read:cycles
read:workout
read:sleep
read:profile
read:body_measurement
offline es obligatorio si quieres mantener la sesión más allá de 1 hora.
makeAuthenticatedRequest adjunta Authorization: Bearer <token>. Si recibe 401 Unauthorized, intenta refreshAccessToken() con el refresh token guardado y reintenta la request una vez. Si el refresh falla, lanza WhoopError.notAuthenticated y la UI invita al usuario a reconectarse.
| Endpoint | Método | Scope | Uso |
|---|---|---|---|
/user/profile/basic |
GET | read:profile |
Nombre y email |
/recovery |
GET | read:recovery |
Score más reciente / paginado |
/cycle |
GET | read:cycles |
Strain del ciclo actual / paginado |
/activity/sleep |
GET | read:sleep |
Sesiones de sueño / paginado |
/activity/workout |
GET | read:workout |
Workouts / paginado |
Todos soportan paginación con next_token. WhoopSyncService.fetchPaginated(...) itera hasta agotar la última página (con un safety limit de 10 páginas).
WhoopSyncService.syncHistoricalData(days:) (default 7) lanza las 4 queries en paralelo con async let, parsea las fechas en UTC y persiste un WhoopDailySnapshot por día. Si ya existe el snapshot del día, lo actualiza en lugar de duplicar.
onAppLaunch() decide si re-sincroniza basándose en lastSyncDate: si han pasado más de 4 horas, sincroniza; sino, usa los snapshots cacheados.
// WhoopService.swift
private var accessToken: String? {
get { KeychainHelper.get(key: "whoop_access_token") }
set { /* save / delete según value */ }
}Implementación de KeychainHelper con kSecClassGenericPassword. Tokens nunca se persisten a disco fuera del Keychain.
Secrets.xcconfig define dos variables:
WHOOP_CLIENT_ID = ...
WHOOP_CLIENT_SECRET = ...
project.yml las pasa al Info.plist y AppConfig las lee desde Bundle.main.infoDictionary. Ver ./SETUP.md.
La generación de planes y el chat con el Coach usan los modelos on-device de Apple Intelligence. No hay API key, no hay costo por token y no hay datos saliendo del dispositivo.
Frameworks: FoundationModels (importado en NutritionAIService.swift y CoachChatService.swift).
| Requisito | Valor |
|---|---|
| Hardware | iPhone 15 Pro / Pro Max o iPhone 16 (todos) |
| iOS | 26.0 o superior |
| Apple Intelligence | Activado en Ajustes → Apple Intelligence |
| Modelo descargado | Sí (la primera activación tarda minutos) |
Antes de cada llamada se consulta:
availability = SystemLanguageModel.default.availabilityEstados manejados:
| Estado | Mensaje al usuario |
|---|---|
.available |
(vacío, todo OK) |
.unavailable(.deviceNotEligible) |
"Tu dispositivo no soporta Apple Intelligence. Se requiere iPhone 15 Pro+" |
.unavailable(.appleIntelligenceNotEnabled) |
"Activa Apple Intelligence en Ajustes → Apple Intelligence y Siri." |
.unavailable(.modelNotReady) |
"El modelo se está descargando. Intenta de nuevo en unos minutos." |
.unavailable(otro) |
"Apple Intelligence no está disponible en este momento." |
Tanto NutritionAIService como CoachChatService exponen isAvailable: Bool y availabilityMessage: String. El Dashboard muestra una card "Apple Intelligence no disponible" con botón Reintentar cuando aplica. El OnboardingGeneratingStep ofrece un fallback "Continuar al inicio" si AI no está listo durante el primer plan.
Cada operación crea o reutiliza una sesión:
let session = LanguageModelSession(instructions: systemPrompt)
// Generación estructurada (plan completo)
let response = try await session.respond(to: userPrompt, generating: PlanGenerado.self)
let plan: PlanGenerado = response.content
// Generación de UNA sola comida
let response = try await session.respond(to: userPrompt, generating: ComidaGenerada.self)
// Chat con streaming
for try await partial in session.streamResponse(to: userMessage) {
streamingText = partial.content
}Las structs marcadas con @Generable garantizan que el modelo respete la estructura. @Guide(description:) orienta semánticamente cada campo.
@Generable
private struct PlanGenerado {
@Guide(description: "Análisis personalizado en 1-2 frases")
let resumen: String
@Guide(description: "Comidas en orden cronológico")
let comidas: [ComidaGenerada]
// …
}Definiciones completas en ./DATA_MODELS.md.
CoachChatService.send(_:context:) consume streamResponse(to:). Cada partial.content es el texto acumulado hasta ese punto, no un delta. La UI lo asigna directamente a streamingText y ChatBubbleView lo renderiza con MarkdownText. Cuando el stream termina, el último partial.content es la respuesta final que se persiste como ChatMessage.
Si el stream lanza un error, se hace fallback a session.respond(to:) (no streaming).
Tanto NutritionAI como Coach construyen las instrucciones desde código a partir del estado actual del usuario (objetivo, preferencias, Whoop, HealthSummary, plan, etc). No hay system prompt hardcodeado.
Ver detalle del Coach en ./COACH.md.
Integración nativa con Apple Health para complementar los datos de Whoop. La app es read-only por ahora.
DucodeApp/DucodeApp.entitlements:
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.access</key>
<array/>Info.plist (vía project.yml):
NSHealthShareUsageDescription:
"Ducode usa tus datos de salud (peso, pasos, sueño y entrenamientos) para
personalizar tus planes de comida según tu estado físico real."
NSHealthUpdateUsageDescription:
"Ducode guarda las comidas que registres en Apple Health para que tu
información nutricional esté centralizada."
Organizados por categoría (todos los HKQuantityType y HKCategoryType se declaran en HealthKitService.readTypes).
| 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 (incluye inBed, awake, asleepCore/Deep/REM) |
| Workouts | workoutType() (últimos 7 días, agrega kcal y top 3 tipos) |
| Mindfulness | mindfulSession |
First launch
│
▼
OnboardingHealthStep / Dashboard banner
│ user taps "Conectar Apple Health"
▼
HealthKitService.requestAuthorization()
│ store.requestAuthorization(toShare: [], read: readTypes)
▼
iOS muestra sheet nativa por categoría (el usuario elige qué compartir)
│
▼
hasRequestedAuthorization = true (persistido en UserDefaults)
│
▼
HealthKitService.refreshAll()
│ ~30 queries en paralelo con async let
▼
HealthSummary publicado en @Published var summary
│
▼
Health360Card visible en Dashboard
HealthSummary inyectado a NutritionAI y Coach
hasRequestedAuthorization se guarda en UserDefaults con la clave healthKitHasRequestedAuthorization. Esto evita re-pedir permiso en cada arranque y permite a la UI saber si debe mostrar el banner "Conectar Apple Health" o no.
Convierte el snapshot a un bloque de texto compacto que se inyecta directamente al prompt del LLM (NutritionAI y Coach). Incluye solo los campos con datos.
Ejemplo de output:
DATOS DE APPLE HEALTH:
- Peso actual: 76.4kg, grasa 18.2%, masa magra 62.5kg
- Hoy: 8421 pasos, 412 kcal activas, 6.1km, 32 min ejercicio
- Vitales: RHR 54bpm, HRV 68ms, VO2max 48.1
- Sueño anoche: 7.4h (88% eficiencia)
- Workouts 7 días: 5 sesiones, 2840 kcal (Strength, Running, Cycling)
- Ya consumido hoy: 1240 kcal, P92g, C140g, G34g
- Agua hoy: 1800ml
- Las queries devuelven
nilysummary.hasAnyData == false. Health360Cardse oculta en el Dashboard.- El banner "Conectar Apple Health" no se muestra (porque
hasRequestedAuthorization == true). - NutritionAI y Coach simplemente no inyectan el bloque de Apple Health al prompt.
El usuario puede revocar permisos en cualquier momento desde Ajustes → Privacidad y seguridad → Salud → Ducode.
Aplicación específica de Foundation Models para el chat. Se documenta brevemente aquí; ver ./COACH.md para el detalle completo.
┌─────────────────────────────────────────────────────────────┐
│ CoachView │
│ • @Query messages: [ChatMessage] │
│ • streaming bubble en vivo │
└──────────┬──────────────────────────────────────────────────┘
│ buildContext() → CoachContext
▼
┌─────────────────────────────────────────────────────────────┐
│ CoachChatService │
│ • ensureSession(context:) → recrea si cambió el system prompt│
│ • send(text, context) → streamResponse │
│ • resetConversation(context:) │
└──────────┬──────────────────────────────────────────────────┘
│ system instructions = perfil + prefs + Whoop +
│ weekly + HealthSummary +
│ plan + últimas comidas
▼
┌─────────────────────────────────────────────────────────────┐
│ LanguageModelSession (on-device) │
└─────────────────────────────────────────────────────────────┘
La LanguageModelSession mantiene el historial conversacional internamente. La app solo recrea la sesión cuando cambian las instrucciones (es decir, cuando cambia el contexto de fondo del usuario), preservando el chat mientras el contexto sea estable.
Cada mensaje se guarda como ChatMessage(role:content:timestamp:) en SwiftData. Al borrar la conversación se borran todos los mensajes y se crea una sesión nueva.
- SETUP.md — cómo configurar Whoop y Apple Intelligence
- DATA_MODELS.md —
@Generabley structs de prompt - COACH.md — detalle del chat conversacional
- PRIVACY_POLICY.md — qué se procesa y dónde