Skip to content

Latest commit

 

History

History
385 lines (297 loc) · 16.1 KB

File metadata and controls

385 lines (297 loc) · 16.1 KB

Flujos de Usuario

Mapa de los flujos principales de la app, desde el primer launch hasta los estados de error.

Índice


1. First launch

                    Abrir app por primera vez
                              │
                              ▼
                    ┌─────────────────────┐
                    │     IntroView       │  hasSeenIntro = false
                    │  (3 pages, swipe)   │
                    └──────────┬──────────┘
                               │ user tap "Comenzar"
                               ▼
                    hasSeenIntro = true
                               │
                               ▼
                    ┌─────────────────────┐
                    │   Dashboard vacío   │  whoopService.isAuthenticated = false
                    │  "Conectar Whoop"   │
                    └──────────┬──────────┘
                               │ user tap "Conectar Whoop"
                               ▼
                       OAuth Whoop (Safari)
                               │
                               ▼
                    handleOAuthCallback
                    isAuthenticated = true
                               │
                               ▼
                    Trigger: hasSeenIntro && isAuthenticated && !hasCompletedOnboarding
                               │
                               ▼
                    ┌─────────────────────┐
                    │ OnboardingCoordinator│
                    │  (6 pasos)          │
                    └─────────────────────┘

Tres flags @AppStorage controlan la presentación:

Flag Efecto
hasSeenIntro Mostrar IntroView (3 páginas: bienvenida, creador, privacidad)
whoopService.isAuthenticated Whoop conectado
hasCompletedOnboarding Onboarding completado (insertó Objetivo, Preferences, Alimentos)

Backfill: si el usuario ya tenía un Objetivo antes de esta versión, se marca hasCompletedOnboarding = true automáticamente al abrir la app, evitando re-entrar al onboarding.

IntroView (3 páginas)

Página Contenido
Bienvenida Logo "sparkles" con gradient orange/pink, anillos pulsantes, "Bienvenido a Ducode"
Creador Avatar "AD" gradient purple/blue + emoji 👋, "App personal de Alejandro Duque"
Privacidad Shield gradient green/mint, 3 bullets: procesamiento local, cero servidores, sin terceros

Botón "Comenzar" en la última página (gradient orange/pink). En las dos primeras hay un botón "Saltar" además de "Siguiente".


2. Onboarding paso a paso

Una vez completado IntroView y conectado Whoop, se presenta OnboardingCoordinator con 6 pasos:

welcome ──▶ health ──▶ objetivo ──▶ preferences ──▶ inventario ──▶ generating

Una progress bar con label "Paso N de 6 · " se muestra en todos los pasos excepto welcome y generating. Cada paso (excepto welcome y generating) tiene un botón back en la esquina superior izquierda.

Paso 1: Welcome (post-Whoop)

  • Hero: ícono checkmark.circle.fill con anillos
  • Saludo personalizado: "¡Hola, !"
  • Subtítulo: "Tu Whoop está conectado"
  • Resumen rápido de las primeras métricas Whoop si están disponibles
  • Botón "Empezar configuración"

Paso 2: Health (Apple Health)

  • Hero: heart.text.square.fill con pulso
  • Lista de beneficios: composición corporal, pasos y kcal, sueño y vitales, entrenamientos
  • Botón principal: "Conectar Apple Health" → HealthKitService.requestAuthorization()refreshAll()
  • Si HealthKit retorna peso/altura: pre-fill state.pesoActual, pesoMeta y altura para el siguiente paso
  • Botón secundario: "Saltar este paso"

Paso 3: Objetivo

  • Selector de tipo: Pérdida / Mantenimiento / Ganancia
  • Antropometría con steppers: peso actual, peso meta, altura, edad
  • Selector de nivel de actividad (5 opciones, sedentario → muy activo)
  • Card "Resumen calculado" muestra calorías y proteína en tiempo real (Mifflin-St Jeor)
  • Botón "Siguiente"

Paso 4: Preferencias

  • Cocinas (chips multi-select con emojis)
  • Restricciones dietéticas (chips con icons SF Symbols)
  • Alergias (text field)
  • Dislikes (text field)
  • Notas extra (text field grande): "prefiero comidas rápidas en la noche, me gusta el picante…" → va directo al prompt
  • Botón "Siguiente"

Paso 5: Inventario

  • Lista de alimentos comunes (Alimento.ejemplos) con toggle on/off
  • Banner: "Si saltas este paso, generamos un plan con comida casera común"
  • Botón "Siguiente" (skip-friendly)

Paso 6: Generating

Tres fases con animación de anillos pulsantes:

phase = .saving → "Guardando tus datos…"
        │
        ▼ (~600ms)
phase = .generating → "Generando tu primer plan…"
        │
        │ runFlow() → ejecuta:
        │   1. saveObjetivo() / savePreferences() / saveAlimentos()
        │   2. healthKit.refreshAll() si autorizado
        │   3. nutritionAI.generarPlanDiario(...)
        ▼
phase = .done → "¡Tu plan está listo!" + botón "Ver mi plan"
        │
        ▼
hasCompletedOnboarding = true → Dashboard

Fallback si Apple Intelligence no está disponible: en lugar de phase = .done, muestra el mensaje de availability y un botón "Continuar al inicio" (el plan se podrá generar cuando AI esté listo).


3. Daily flow

                       Abrir app (post-onboarding)
                                │
                                ▼
                    ┌─────────────────────────┐
                    │      DashboardView      │
                    │  (TabView tag = 0)      │
                    └────────────┬────────────┘
                                 │
                ┌────────────────┼────────────────┐
                │                │                │
                ▼                ▼                ▼
        Métricas Whoop    Salud 360 Card    Plan del día
        (live + fallback  (HealthKit         (con regenerar
         a snapshot)       expandible)        por comida)

Dashboard al abrir

Al entrar al tab Inicio se ejecutan en paralelo:

  1. nutritionAI.refreshAvailability() — re-chequear Apple Intelligence
  2. whoopService.fetchAllData() (vía botón refresh manual o cuando aplica)
  3. healthKit.refreshAll() si hasRequestedAuthorization

Métricas Whoop

Grid 2x2: Recovery, Strain, HRV, Sueño. Cada métrica usa el patrón "live first, snapshot fallback":

private var dashboardRecovery: Double {
    whoopService.recoveryPercentage > 0
        ? whoopService.recoveryPercentage
        : (latestSnapshot?.recoveryScore ?? 0)
}

Si Whoop aún no respondió pero hay un snapshot reciente, se muestra el snapshot. Cuando llega la live data, se actualiza la UI.

Salud 360 Card

  • Aparece solo si healthKit.summary.hasAnyData.
  • Resumen rápido: pasos, kcal activas, sueño, peso (top 4).
  • Tap en la flecha expande secciones: composición corporal, actividad hoy, vitales, workouts 7 días, nutrición consumida, mindfulness.

Si HealthKit no se ha autorizado y isAvailable: aparece un banner "Conectar Apple Health" con tap-to-authorize.

Plan del día

Una card con resumen, calorías totales y comidas. Cada comida muestra:

  • Tipo (Desayuno / Almuerzo / Snack / Cena)
  • Botón regenerar individual (arrow.triangle.2.circlepath) → nutritionAI.regenerarComida(at:)
  • Calorías y macros (P/C/G chips)
  • "Ver ingredientes" expandible

Si no hay plan: aparece el card "Genera tu plan del día" con botón verde. Si Apple Intelligence no está disponible, en su lugar aparece la card "Apple Intelligence no disponible" con botón Reintentar.

Regenerar UNA comida

User tap arrow.triangle.2.circlepath en una comida
    │
    ▼
nutritionAI.regenerarComida(at: index, ...)
    │ regeneratingMealIndex = index → spinner local en esa fila
    │
    ▼
LanguageModelSession.respond(
    to: prompt construido con la comida actual + otros platos del día,
    generating: ComidaGenerada.self
)
    │
    ▼
replaceMeal(at: index, with: nueva)
    │ recalcula totales del plan
    │ regeneratingMealIndex = nil
    ▼
UI actualizada (transición opacity + move)

Solo se regenera UNA fila a la vez (botón disabled si regeneratingMealIndex != nil).


4. Coach chat

Tab "Coach" (tag = 1)
    │
    ▼
┌───────────────────────────────────────────┐
│  CoachView                                 │
│                                            │
│  if !coach.isAvailable: unavailableState   │
│  else if messages.isEmpty: emptyState      │
│  else: chatContent (ScrollView + bubbles)  │
└─────────────┬─────────────────────────────┘
              │ user types o tap suggestion
              ▼
        sendMessage(text)
              │
              ▼
   ChatMessage(role: .user) → SwiftData
              │
              ▼
   coach.send(text, context: buildContext())
              │
              ▼
   LanguageModelSession.streamResponse
              │
              │ for try await partial in stream:
              │   streamingText = partial.content
              ▼
   ChatMessage(role: .assistant) → SwiftData
   streamingText = ""

Empty state

  • Hero "sparkles" con pulso y gradient orange/pink
  • Texto: "Hola, soy tu Coach"
  • Sub: "Conozco tu Whoop, tu Apple Health, tu objetivo y tu plan…"
  • Suggestion chips dinámicos:
    • "¿Cómo estoy hoy?"
    • "¿Qué debería comer ahora?"
    • "Tengo recovery bajo, ¿qué hago?" (si recovery < 50) o "Hazme un workout para hoy" (si recovery >= 50)
    • "Resumen de mi semana"

Tap en un chip → envía como mensaje del usuario.

Chat con streaming

Cada bubble se renderiza con MarkdownText. Mientras llega el stream, hay una bubble extra id = "streaming" que muestra el texto incremental con un TypingIndicator cuando aún no llegó nada.

scrollDismissesKeyboard(.interactively) permite arrastrar para cerrar el teclado.

Toolbar

  • TopBarLeading: botón "Inicio" → selectedTab = 0 (vuelve al Dashboard sin perder el chat)
  • TopBarTrailing: botón reset (arrow.counterclockwise) → alert de confirmación → borra todos los ChatMessage y coach.resetConversation(...)

5. Modificar preferencias y regenerar plan

Tab "Comidas" (tag = 3)
    │
    ▼
┌──────────────────────────────────────────┐
│ PreferenciasComidaView                    │
│                                           │
│  Header card                              │
│  Cocinas (chips)         ← auto-save      │
│  Restricciones (chips)   ← auto-save      │
│  Alergias (text)         ← auto-save 500ms│
│  Dislikes (text)         ← auto-save 500ms│
│  Notas extra (text)      ← auto-save 500ms│
│  Inventario:                              │
│   • Alimentos activos (toggle/eliminar)   │
│   • Sugerencias rápidas (chips)           │
│   • Botón "Agregar" (sheet custom food)   │
│                                           │
│  ┌─────────────────────────────────────┐ │
│  │ Sticky bottom: "Regenerar plan"     │ │
│  └─────────────────────────────────────┘ │
└──────────────────────────────────────────┘

Auto-save: cualquier cambio en preferencias se persiste en UserPreferences (debounce de 500ms para los text fields). El usuario no necesita pulsar "Guardar".

El botón "Regenerar plan" llama a nutritionAI.generarPlanDiario(...) con las nuevas preferencias y refresca el plan en Dashboard. Al terminar muestra un alert "Plan regenerado".


6. Estados de error

Whoop sin conectar

  • Dashboard muestra el card "Conecta tu Whoop" con botón naranja.
  • Las métricas no aparecen.
  • El plan se puede generar igual (sin contexto de Whoop).
  • El Coach funciona pero su prompt no incluye el bloque de Whoop.

Apple Intelligence no disponible

Mensajes específicos según el estado:

Estado Mensaje en pantalla
.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."

Dónde se muestra:

  • Dashboard: card "Apple Intelligence no disponible" con botón Reintentar (en lugar del botón "Generar plan").
  • CoachView: pantalla completa "Sparkles slash" con el mensaje y botón Reintentar.
  • OnboardingGeneratingStep: muestra el mensaje y un botón "Continuar al inicio" (el plan se podrá generar después).

En todos los casos, "Reintentar" llama a refreshAvailability() que vuelve a consultar SystemLanguageModel.default.availability.

HealthKit denegado

  • El banner "Conectar Apple Health" no aparece (porque hasRequestedAuthorization == true).
  • Health360Card no se muestra (summary.hasAnyData == false).
  • NutritionAI y Coach simplemente no inyectan el bloque de Health al prompt.
  • El usuario puede revocar/reactivar permisos en Ajustes → Privacidad y seguridad → Salud → Ducode.

Whoop expira el token (401)

  • makeAuthenticatedRequest detecta el 401 e intenta refreshAccessToken() con el refresh token guardado.
  • Si el refresh funciona, reintenta la request original automáticamente.
  • Si el refresh falla, lanza WhoopError.notAuthenticated. La UI mantiene los datos previos (snapshot fallback) y el botón refresh muestra el error.
  • Para recuperarse: tap en "Logout" (Dashboard top bar leading) y reconectar.

Error generando plan

  • nutritionAI.error: String? se publica.
  • La UI puede mostrarlo (actualmente no hay un toast formal; el plan simplemente no aparece y el log marca 🔴 [NutritionAI]).
  • Reintentar pulsando "Generar Plan" o desde la pestaña Comidas → "Regenerar plan".

Error en chat

  • Si streamResponse lanza, hay fallback a respond(to:).
  • Si ese también falla, se inserta un ChatMessage(role: .assistant) con prefijo "⚠️" y el mensaje de error.

Documentos relacionados