2637 lines
96 KiB
Markdown
2637 lines
96 KiB
Markdown
# MealMood - Product Requirements Document (PRD)
|
||
|
||
## 📋 Índice
|
||
|
||
1. [Visión General](#visión-general)
|
||
2. [Problema y Solución](#problema-y-solución)
|
||
3. [Usuario Objetivo](#usuario-objetivo)
|
||
4. [Propuesta de Valor](#propuesta-de-valor)
|
||
5. [Arquitectura de Información](#arquitectura-de-información)
|
||
6. [Modelo de Datos](#modelo-de-datos)
|
||
7. [Funcionalidades MVP](#funcionalidades-mvp)
|
||
8. [Funcionalidades Freemium (Futuro)](#funcionalidades-freemium-futuro)
|
||
9. [Identidad Visual y Branding](#identidad-visual-y-branding)
|
||
10. [Especificaciones de Pantallas](#especificaciones-de-pantallas)
|
||
11. [Interacciones y Micro-animaciones](#interacciones-y-micro-animaciones)
|
||
12. [Integración con Calendario Nativo](#integración-con-calendario-nativo)
|
||
13. [Algoritmo de Autocompletado](#algoritmo-de-autocompletado)
|
||
14. [Sincronización y Persistencia](#sincronización-y-persistencia)
|
||
15. [Monetización](#monetización)
|
||
16. [Stack Técnico](#stack-técnico)
|
||
17. [Plan de Implementación (Sprints)](#plan-de-implementación-sprints)
|
||
18. [Anexos](#anexos)
|
||
|
||
---
|
||
|
||
## Visión General
|
||
|
||
### Nombre
|
||
**MealMood** 🍽️✨
|
||
|
||
### Tagline
|
||
- **Español**: "Planifica tu semana, disfruta tus comidas"
|
||
- **English**: "Plan your week, enjoy your meals"
|
||
|
||
### Descripción del Producto
|
||
MealMood es una aplicación iOS para planificar comidas y cenas semanales de manera rápida, visual y sin estrés. Orientada principalmente a familias (target: madres), permite organizar el menú semanal en 5 minutos usando drag & drop, respetando reglas nutricionales configurables y sincronizando opcionalmente con el calendario nativo de iOS.
|
||
|
||
**NO es un tracker de calorías ni macros**. El enfoque está en la simplicidad, variedad y disfrute de las comidas familiares.
|
||
|
||
---
|
||
|
||
## Problema y Solución
|
||
|
||
### Problema
|
||
- Planificar comidas semanales es una tarea recurrente que genera **estrés y fatiga decisional**.
|
||
- Las familias necesitan **variedad** sin repetir platos constantemente.
|
||
- Restricciones dietéticas (gluten, frecuencia de proteínas) complican la planificación.
|
||
- Apps existentes son **complejas** (trackers nutricionales) o requieren mucha configuración manual.
|
||
|
||
### Solución
|
||
MealMood ofrece:
|
||
1. **Planificación en 5 minutos**: interfaz visual con drag & drop.
|
||
2. **Reglas configurables**: limita frecuencia de ingredientes (carne, pescado, etc.) sin ser obsesivo.
|
||
3. **Autocompletado inteligente**: algoritmo rellena huecos respetando reglas.
|
||
4. **Sincronización con calendario**: eventos automáticos en el calendario nativo de iOS.
|
||
5. **Diseño acogedor**: tonos pastel, ilustraciones suaves, experiencia sin fricción.
|
||
|
||
---
|
||
|
||
## Usuario Objetivo
|
||
|
||
### Perfil Primario
|
||
- **Madres de familia** (25-45 años).
|
||
- Con hijos pequeños/adolescentes.
|
||
- Responsables de la planificación semanal de comidas.
|
||
- Buscan **eficiencia** sin perder variedad.
|
||
- No obsesionadas con calorías/macros, pero conscientes de dieta equilibrada.
|
||
|
||
### Perfil Secundario
|
||
- Padres que comparten la responsabilidad de cocinar.
|
||
- Personas organizadas que planifican con antelación.
|
||
- Familias con restricciones dietéticas leves (gluten, vegetarianos).
|
||
|
||
### Jobs to Be Done (JTBD)
|
||
*"Cuando llega el domingo por la tarde y necesito planificar la semana, quiero una forma rápida de decidir qué cocinar cada día sin repetir los mismos platos, para no estresarme y que mi familia tenga variedad."*
|
||
|
||
---
|
||
|
||
## Propuesta de Valor
|
||
|
||
### Diferenciadores clave
|
||
1. **Rapidez**: planificación en 5 minutos vs. 30+ en competencia.
|
||
2. **Visual**: interfaz calendario + drag & drop vs. listas aburridas.
|
||
3. **Reglas automáticas**: evita repeticiones sin pensar manualmente.
|
||
4. **No es un tracker nutricional**: enfoque en disfrute, no en restricción.
|
||
5. **Sincronización calendario**: eventos listos para compartir con la familia.
|
||
|
||
### Competencia
|
||
| App | Enfoque | Diferencia con MealMood |
|
||
|-----|---------|------------------------|
|
||
| **Paprika** | Recetas + listas de compra | Más complejo, orientado a recetas detalladas |
|
||
| **Mealime** | Planes nutricionales | Muy centrado en macros/calorías |
|
||
| **Plan to Eat** | Recetas + calendario | Requiere importar recetas, setup largo |
|
||
| **AnyList** | Listas de compra | No tiene planificación visual con reglas |
|
||
|
||
---
|
||
|
||
## Arquitectura de Información
|
||
|
||
### Flujo principal
|
||
|
||
```
|
||
Onboarding (primera vez)
|
||
↓
|
||
Configuración inicial
|
||
├── Ventanas de comida (cenas / comidas / ambas)
|
||
├── ¿Incluir fines de semana?
|
||
└── Sincronización calendario (opcional)
|
||
↓
|
||
Home (pantalla principal)
|
||
├── Calendario semanal (vista principal)
|
||
├── Drawer de platos disponibles
|
||
└── Botones: Completar / Resetear
|
||
↓
|
||
Gestión de platos
|
||
├── Lista de platos
|
||
├── Crear nuevo plato
|
||
└── Editar/Eliminar platos
|
||
↓
|
||
Gestión de etiquetas (Tags)
|
||
├── Lista de tags
|
||
├── Editar reglas de tags
|
||
└── Crear tags custom (Freemium)
|
||
↓
|
||
Settings
|
||
├── Ventanas de comida
|
||
├── Fines de semana
|
||
├── Calendario (sincronización)
|
||
├── Idioma
|
||
└── Premium
|
||
```
|
||
|
||
---
|
||
|
||
## Modelo de Datos
|
||
|
||
### Entidades principales
|
||
|
||
#### 1. Settings
|
||
```swift
|
||
struct Settings {
|
||
// General
|
||
var mealWindows: MealWindows // .dinnerOnly, .lunchOnly, .both
|
||
var includeWeekends: Bool
|
||
var language: Language // .spanish, .english
|
||
|
||
// Calendario
|
||
var calendarId: String? // ID del calendario nativo, nil si no sincroniza
|
||
var syncEnabled: Bool
|
||
var lunchTime: Date // hora del día (default: 14:00)
|
||
var dinnerTime: Date // hora del día (default: 21:00)
|
||
var eventDuration: Int // minutos (default: 60)
|
||
var eventPrefix: String // default: "🍽️"
|
||
var reminderMinutesBefore: Int? // nil = sin alerta, valores: 30, 60, 120
|
||
|
||
// Premium
|
||
var isPremium: Bool
|
||
}
|
||
|
||
enum MealWindows {
|
||
case dinnerOnly
|
||
case lunchOnly
|
||
case both
|
||
}
|
||
|
||
enum Language {
|
||
case spanish
|
||
case english
|
||
}
|
||
```
|
||
|
||
#### 2. Tag (Etiqueta)
|
||
```swift
|
||
struct Tag: Identifiable, Codable {
|
||
var id: UUID
|
||
var name: String // localizado según idioma
|
||
var color: String // hex (ej: "#E74C3C")
|
||
var rules: TagRules
|
||
}
|
||
|
||
struct TagRules: Codable {
|
||
var maxPerWeek: Int? // nil = sin límite
|
||
var noConsecutive: Bool // no puede estar en días consecutivos
|
||
var noDuplicateInDay: Bool // no puede estar en comida y cena el mismo día
|
||
var mealTypeRestriction: MealType? // .lunch, .dinner, nil
|
||
}
|
||
|
||
enum MealType: String, Codable {
|
||
case lunch
|
||
case dinner
|
||
}
|
||
```
|
||
|
||
#### 3. Dish (Plato)
|
||
```swift
|
||
struct Dish: Identifiable, Codable {
|
||
var id: UUID
|
||
var name: String
|
||
var description: String? // opcional
|
||
var tags: [UUID] // referencias a Tag IDs
|
||
var createdAt: Date
|
||
}
|
||
```
|
||
|
||
#### 4. MealSlot (Slot de comida)
|
||
```swift
|
||
struct MealSlot: Identifiable, Codable {
|
||
var id: UUID
|
||
var dayOfWeek: Int // 0=lunes, 6=domingo
|
||
var mealType: MealType // .lunch o .dinner
|
||
var dish: UUID? // nil si vacío
|
||
var calendarEventId: String? // ID del evento en EventKit, nil si no sincroniza
|
||
}
|
||
```
|
||
|
||
#### 5. WeekPlan (Planificación semanal)
|
||
```swift
|
||
struct WeekPlan: Identifiable, Codable {
|
||
var id: UUID
|
||
var weekStartDate: Date // lunes de esa semana
|
||
var slots: [MealSlot]
|
||
var createdAt: Date
|
||
var updatedAt: Date
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Funcionalidades MVP
|
||
|
||
### F1: Configuración Inicial (Onboarding)
|
||
**Descripción**: Primera vez que el usuario abre la app.
|
||
|
||
**Pasos**:
|
||
1. **Bienvenida**: logo + tagline + ilustración.
|
||
2. **Ventanas de comida**: selector (solo cenas / solo comidas / ambas).
|
||
3. **Fines de semana**: toggle (incluir o no).
|
||
4. **Calendario** (opcional): selector de calendario nativo + configuración de horas.
|
||
5. **Crear primeros platos**: formulario rápido para añadir 2-3 platos iniciales.
|
||
|
||
**Criterios de Aceptación**:
|
||
- Usuario puede completar onboarding en <2 minutos.
|
||
- Settings guardados en persistencia local (SwiftData/Core Data).
|
||
- Navegación fluida sin saltos bruscos.
|
||
|
||
---
|
||
|
||
### F2: Gestión de Platos
|
||
**Descripción**: Crear, editar y eliminar platos personales.
|
||
|
||
**Funcionalidad**:
|
||
- **Lista de platos**: muestra todos los platos del usuario.
|
||
- **Crear plato**:
|
||
- Campo: Nombre (obligatorio).
|
||
- Campo: Descripción (opcional).
|
||
- Selector: Etiquetas (multi-selección de tags disponibles).
|
||
- **Editar plato**: mismo formulario, pre-rellenado.
|
||
- **Eliminar plato**: swipe-to-delete + confirmación.
|
||
|
||
**Validaciones**:
|
||
- Nombre no puede estar vacío.
|
||
- No permitir eliminar plato si está asignado a un slot de la semana actual (mostrar alert).
|
||
|
||
**Criterios de Aceptación**:
|
||
- Usuario puede crear plato en <30 segundos.
|
||
- Cambios se reflejan inmediatamente en la lista de platos.
|
||
- Eliminación con confirmación para evitar errores.
|
||
|
||
---
|
||
|
||
### F3: Gestión de Etiquetas (Tags)
|
||
**Descripción**: Ver y editar reglas de etiquetas predefinidas.
|
||
|
||
**Funcionalidad**:
|
||
- **Lista de tags**: muestra tags por defecto (10 en MVP).
|
||
- **Editar reglas de tag**:
|
||
- Max por semana (slider 1-7 o "sin límite").
|
||
- Toggle: No consecutivo.
|
||
- Toggle: No duplicar en el mismo día.
|
||
- Selector: Restricción de tipo de comida (ninguna / solo comida / solo cena).
|
||
- **Ver color del tag**: visualización en lista con pill de color.
|
||
|
||
**Tags por defecto (MVP)**:
|
||
|
||
| Tag (ES) | Tag (EN) | Color | Reglas por defecto |
|
||
|----------|----------|-------|-------------------|
|
||
| Carne | Meat | #E74C3C | Max 3/semana, no consecutivo, no mismo día |
|
||
| Pescado | Fish | #3498DB | Max 2/semana, no consecutivo, no mismo día |
|
||
| Legumbres | Legumes | #95A5A6 | Max 2/semana, no mismo día |
|
||
| Verduras | Vegetables | #2ECC71 | Sin límite |
|
||
| Huevos | Eggs | #F1C40F | Max 2/semana, no mismo día |
|
||
| Pasta/Arroz | Pasta/Rice | #D4AC6E | Max 3/semana, no consecutivo |
|
||
| Solo Cena | Dinner Only | #9B59B6 | Restricción: solo cena |
|
||
| Solo Comida | Lunch Only | #E67E22 | Restricción: solo comida |
|
||
| Con Gluten | Contains Gluten | #8B4513 | Informativo (sin reglas) |
|
||
| Sin Gluten | Gluten Free | #1ABC9C | Informativo (sin reglas) |
|
||
|
||
**Criterios de Aceptación**:
|
||
- Usuario puede modificar reglas en <1 minuto.
|
||
- Cambios se aplican inmediatamente al algoritmo de validación.
|
||
- No permitir crear tags custom en MVP (solo Freemium).
|
||
|
||
---
|
||
|
||
### F4: Planificación Semanal (Pantalla Principal)
|
||
**Descripción**: Vista calendario con drag & drop para asignar platos a slots.
|
||
|
||
**Layout**:
|
||
```
|
||
┌─────────────────────────────────────────────┐
|
||
│ MealMood Semana 3-9 Feb [⚙️] │
|
||
│ │
|
||
│ ← Anterior Siguiente → │
|
||
├─────────────────────────────────────────────┤
|
||
│ │
|
||
│ Lun Mar Mié Jue Vie │
|
||
│ │
|
||
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
|
||
│ │🌙 │ │🌙 │ │🌙 │ │🌙 │ │🌙 │ │
|
||
│ │Pollo│ │Sal- │ │Torti│ │ │ │ │ │
|
||
│ │asado│ │món │ │lla │ │ │ │ │ │
|
||
│ │🔴 │ │🔵 │ │🟡 │ │ - - │ │ - - │ │
|
||
│ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ │
|
||
│ │
|
||
├─────────────────────────────────────────────┤
|
||
│ ┌──────────────┐ ┌──────────────┐ │
|
||
│ │ Completar │ │ Resetear │ │
|
||
│ │ ✨ │ │ 🔄 │ │
|
||
│ └──────────────┘ └──────────────┘ │
|
||
└─────────────────────────────────────────────┘
|
||
▲ Arrastra platos aquí ▲
|
||
┌─────────────────────────────────────────────┐
|
||
│ 📋 Mis platos [+] │
|
||
├─────────────────────────────────────────────┤
|
||
│ ┌─────────────────────────────────────┐ │
|
||
│ │ Pollo asado 🔴 │ │
|
||
│ │ Jugoso y al horno │ │
|
||
│ └─────────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌─────────────────────────────────────┐ │
|
||
│ │ Salmón a la plancha 🔵 │ │
|
||
│ │ Con limón y eneldo │ │
|
||
│ └─────────────────────────────────────┘ │
|
||
└─────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Funcionalidad**:
|
||
- **Calendario semanal**:
|
||
- Muestra días según configuración (lun-dom si incluye fines de semana, lun-vie si no).
|
||
- Muestra slots según configuración (solo cena, solo comida, o ambas).
|
||
- **Slots vacíos**: placeholder visual con icono (☀️ para comida, 🌙 para cena) + texto "Arrastra un plato".
|
||
- **Slots llenos**: muestra nombre del plato + descripción corta + pills de colores (tags).
|
||
|
||
- **Drawer de platos**:
|
||
- Lista scrolleable de todos los platos creados.
|
||
- Card por plato: nombre + descripción + pills de tags.
|
||
- Botón [+] para crear nuevo plato (abre modal/sheet).
|
||
|
||
- **Drag & Drop**:
|
||
- Long press (200ms) en plato → se "levanta" con sombra.
|
||
- Arrastrar sobre slot:
|
||
- **Válido** (respeta reglas): borde verde, haptic feedback suave.
|
||
- **Inválido** (viola reglas): borde rojo, haptic feedback "error".
|
||
- Drop:
|
||
- **Válido**: plato se asigna al slot, desaparece temporalmente del drawer, animación "plop".
|
||
- **Inválido**: plato vuelve al drawer con bounce animation.
|
||
|
||
- **Navegación semanal**:
|
||
- Flechas ← → para cambiar de semana.
|
||
- Muestra rango de fechas (ej: "Semana 3-9 Feb").
|
||
|
||
- **Botones de acción**:
|
||
- **Completar ✨**: ejecuta algoritmo de autocompletado (ver F5).
|
||
- **Resetear 🔄**: limpia todos los slots de la semana (con confirmación).
|
||
|
||
**Criterios de Aceptación**:
|
||
- Usuario puede planificar semana completa en <5 minutos.
|
||
- Drag & drop fluido (60fps), sin lag.
|
||
- Validación de reglas en tiempo real (antes de drop).
|
||
- Navegación entre semanas sin perder datos.
|
||
|
||
---
|
||
|
||
### F5: Autocompletado Inteligente
|
||
**Descripción**: Algoritmo que rellena slots vacíos respetando reglas de tags.
|
||
|
||
**Funcionamiento**:
|
||
1. Usuario presiona botón "Completar ✨".
|
||
2. Algoritmo analiza:
|
||
- Slots vacíos en la semana actual.
|
||
- Platos disponibles (todos los creados por el usuario).
|
||
- Reglas de cada tag asociado a cada plato.
|
||
3. Para cada slot vacío:
|
||
- Filtra platos válidos (que no violen reglas).
|
||
- Prioriza platos menos usados en la semana actual (para variedad).
|
||
- Asigna plato aleatoriamente entre válidos.
|
||
4. Si no encuentra plato válido para un slot → lo deja vacío.
|
||
5. Muestra resultado con animación secuencial (slots se rellenan uno por uno).
|
||
|
||
**Lógica de validación**:
|
||
```python
|
||
def violates_rules(dish, slot, current_plan, rules):
|
||
for tag_id in dish.tags:
|
||
tag_rule = rules[tag_id]
|
||
|
||
# Check max per week
|
||
if tag_rule.maxPerWeek:
|
||
count = current_plan.count_tag_usage(tag_id)
|
||
if count >= tag_rule.maxPerWeek:
|
||
return True
|
||
|
||
# Check no consecutive
|
||
if tag_rule.noConsecutive:
|
||
adjacent_slots = current_plan.get_adjacent_slots(slot)
|
||
for adj_slot in adjacent_slots:
|
||
if adj_slot.dish and tag_id in adj_slot.dish.tags:
|
||
return True
|
||
|
||
# Check no duplicate in same day
|
||
if tag_rule.noDuplicateInDay:
|
||
same_day_slots = current_plan.get_same_day_slots(slot)
|
||
for sd_slot in same_day_slots:
|
||
if sd_slot.dish and tag_id in sd_slot.dish.tags:
|
||
return True
|
||
|
||
# Check meal type restriction
|
||
if tag_rule.mealTypeRestriction:
|
||
if slot.mealType != tag_rule.mealTypeRestriction:
|
||
return True
|
||
|
||
return False
|
||
```
|
||
|
||
**Criterios de Aceptación**:
|
||
- Algoritmo completa semana en <2 segundos.
|
||
- Respeta todas las reglas configuradas.
|
||
- Si no puede completar todos los slots, informa al usuario ("No puedo completar con las reglas actuales. ¿Deseas relajar restricciones?").
|
||
- Usuario puede manualmente sobrescribir asignaciones del algoritmo.
|
||
|
||
---
|
||
|
||
### F6: Sincronización con Calendario Nativo
|
||
**Descripción**: Crear eventos en el calendario nativo de iOS para cada comida planificada.
|
||
|
||
**Configuración** (Settings → Calendario):
|
||
- Toggle: Sincronizar (ON/OFF).
|
||
- Selector: Calendario nativo (lista de calendarios del dispositivo).
|
||
- Time picker: Hora de comidas (default: 14:00).
|
||
- Time picker: Hora de cenas (default: 21:00).
|
||
- Slider: Duración del evento (30min, 1h, 1.5h, 2h) - default: 1h.
|
||
- Text field: Prefijo del título (default: "🍽️").
|
||
- Selector: Recordatorio (sin recordatorio / 30min antes / 1h antes / 2h antes).
|
||
|
||
**Funcionamiento**:
|
||
1. **Al asignar plato a slot**:
|
||
- Si `syncEnabled == true` y `calendarId != nil`:
|
||
- Crear evento en EventKit:
|
||
- **Título**: `{eventPrefix} {mealType}: {dish.name}` (ej: "🍽️ Cena: Pollo asado").
|
||
- **Fecha**: `weekStartDate + dayOfWeek`.
|
||
- **Hora inicio**: `lunchTime` (si es comida) o `dinnerTime` (si es cena).
|
||
- **Hora fin**: `startDate + eventDuration`.
|
||
- **Notas**: `dish.description` (si existe).
|
||
- **Alerta**: según `reminderMinutesBefore`.
|
||
- Guardar `eventId` en `slot.calendarEventId`.
|
||
|
||
2. **Al eliminar plato de slot**:
|
||
- Si `slot.calendarEventId != nil`:
|
||
- Eliminar evento de EventKit usando `eventId`.
|
||
- Limpiar `slot.calendarEventId`.
|
||
- Mostrar toast: "Evento eliminado del calendario".
|
||
|
||
3. **Al cambiar hora en Settings**:
|
||
- Preguntar: "¿Actualizar eventos existentes?".
|
||
- Si Sí:
|
||
- Para cada `slot` con `calendarEventId != nil` y fecha >= hoy:
|
||
- Actualizar evento en EventKit con nueva hora.
|
||
|
||
**Permisos**:
|
||
- Solicitar permiso de EventKit al activar sincronización.
|
||
- Si usuario deniega permiso → mostrar alert explicando que necesita acceso para sincronizar (con link a Settings del sistema).
|
||
|
||
**Casos edge**:
|
||
- Si calendario sincronizado es eliminado del dispositivo → detectar y mostrar warning: "El calendario sincronizado ya no existe. Selecciona otro."
|
||
- Si el usuario edita el evento directamente en la app Calendario nativa → **ignorar cambios** (no sincronización bidireccional en MVP).
|
||
|
||
**Criterios de Aceptación**:
|
||
- Eventos creados correctamente con todos los campos (título, fecha, hora, notas, alerta).
|
||
- Eliminación de eventos al quitar plato del slot.
|
||
- Usuario puede desactivar sincronización en cualquier momento sin perder datos en la app.
|
||
|
||
---
|
||
|
||
### F7: Settings (Ajustes)
|
||
**Descripción**: Pantalla de configuración general.
|
||
|
||
**Secciones**:
|
||
|
||
#### 🍽️ Planificación
|
||
- **Ventanas de comida**: selector (Solo cenas / Solo comidas / Comidas y cenas).
|
||
- **Incluir fines de semana**: toggle (ON/OFF).
|
||
|
||
#### 📅 Calendario
|
||
- **Sincronizar**: toggle (ON/OFF).
|
||
- **Calendario**: selector de calendarios nativos (solo visible si toggle ON).
|
||
- **Hora de comidas**: time picker (default: 14:00).
|
||
- **Hora de cenas**: time picker (default: 21:00).
|
||
- **Duración del evento**: selector (30min, 1h, 1.5h, 2h).
|
||
- **Prefijo del título**: text field (default: "🍽️").
|
||
- **Recordatorio**: selector (Sin recordatorio / 30min antes / 1h antes / 2h antes).
|
||
|
||
#### 🏷️ Etiquetas
|
||
- **Gestionar etiquetas**: navegar a pantalla de gestión de tags.
|
||
|
||
#### 🌍 Idioma
|
||
- **Idioma**: selector (Español / English).
|
||
|
||
#### ⭐ Premium
|
||
- **Quitar publicidad**: navegar a pantalla de compra (IAP).
|
||
- **Estado**: mostrar si el usuario es Premium o Free.
|
||
|
||
**Criterios de Aceptación**:
|
||
- Cambios se guardan inmediatamente (auto-save).
|
||
- Cambio de idioma actualiza toda la interfaz (incluyendo tags predefinidos).
|
||
- Navegación fluida entre Settings y otras pantallas.
|
||
|
||
---
|
||
|
||
### F8: Persistencia Local
|
||
**Descripción**: Guardar datos del usuario en el dispositivo.
|
||
|
||
**Tecnología**: SwiftData (iOS 17+) o Core Data (si necesitas compatibilidad con iOS 16).
|
||
|
||
**Entidades persistentes**:
|
||
- Settings
|
||
- Tag (10 tags por defecto)
|
||
- Dish (platos creados por el usuario)
|
||
- WeekPlan (planificaciones semanales, al menos la actual y las próximas 2-3)
|
||
|
||
**Comportamiento**:
|
||
- Datos se guardan automáticamente en cada cambio (no requiere botón "Guardar").
|
||
- Al abrir la app, carga la planificación de la semana actual.
|
||
- Si no existe planificación para la semana actual, crea una nueva vacía.
|
||
|
||
**Criterios de Aceptación**:
|
||
- Datos persisten entre sesiones de la app.
|
||
- No hay pérdida de datos al cerrar/abrir la app.
|
||
- Performance: carga inicial <1 segundo.
|
||
|
||
---
|
||
|
||
### F9: Multiidioma (Español/Inglés)
|
||
**Descripción**: Soporte para español e inglés desde día 1.
|
||
|
||
**Implementación**:
|
||
- Usar `Localizable.strings` para textos de UI.
|
||
- Tags predefinidos tienen nombre localizado (ej: "Carne" en ES, "Meat" en EN).
|
||
- Cambio de idioma en Settings → actualiza toda la interfaz sin reiniciar la app.
|
||
|
||
**Textos a traducir**:
|
||
- Onboarding (bienvenida, pasos).
|
||
- Pantalla principal (botones, placeholders).
|
||
- Gestión de platos (formularios, validaciones).
|
||
- Gestión de tags (nombres, reglas).
|
||
- Settings (todas las opciones).
|
||
- Alerts y toasts.
|
||
|
||
**Criterios de Aceptación**:
|
||
- Usuario puede cambiar idioma en Settings.
|
||
- Cambio se refleja inmediatamente en toda la app.
|
||
- Tags predefinidos cambian de nombre según idioma.
|
||
|
||
---
|
||
|
||
### F10: Navegación entre Semanas
|
||
**Descripción**: Usuario puede planificar múltiples semanas (pasadas y futuras).
|
||
|
||
**Funcionalidad**:
|
||
- Flechas ← → en la pantalla principal.
|
||
- Mostrar rango de fechas de la semana actual (ej: "Semana 3-9 Feb 2025").
|
||
- Al cambiar de semana:
|
||
- Cargar planificación de esa semana (si existe).
|
||
- Si no existe, crear planificación vacía.
|
||
- **Limitación MVP**: solo se pueden editar semana actual y futuras (no pasadas).
|
||
|
||
**Criterios de Aceptación**:
|
||
- Navegación fluida sin lag.
|
||
- Cada semana tiene su propia planificación independiente.
|
||
- Usuario puede ver hasta 4 semanas futuras.
|
||
|
||
---
|
||
|
||
## Funcionalidades Freemium (Futuro)
|
||
|
||
### Freemium 1: Tags Custom Ilimitados
|
||
- Usuario puede crear tags personalizados con nombre, color y reglas.
|
||
- En Free: solo 10 tags predefinidos, no se pueden crear nuevos.
|
||
|
||
### Freemium 2: Platos Ilimitados
|
||
- Free: máximo 20 platos.
|
||
- Premium: ilimitados.
|
||
|
||
### Freemium 3: Reglas Avanzadas
|
||
- **Separación mínima**: "Este plato no puede repetirse hasta X días después".
|
||
- **Día fijo**: "Este plato siempre los viernes" (tradiciones familiares).
|
||
- En Free: solo reglas básicas (max/semana, no consecutivo, no mismo día).
|
||
|
||
### Freemium 4: Múltiples Planificaciones Guardadas
|
||
- Guardar "plantillas" de semanas (ej: "Semana típica", "Semana de invitados").
|
||
- Aplicar plantilla a cualquier semana futura.
|
||
|
||
### Freemium 5: Exportar a Lista de Compra
|
||
- Generar lista de ingredientes basada en la planificación semanal.
|
||
- Exportar a Notes, Reminders o apps de terceros (AnyList).
|
||
|
||
### Freemium 6: Analytics/Histórico
|
||
- Dashboard con estadísticas:
|
||
- "¿Qué platos repito más?"
|
||
- "¿Cuántas veces comí carne esta semana?"
|
||
- Gráficos de variedad semanal.
|
||
|
||
### Freemium 7: Colaboración Familiar
|
||
- Múltiples usuarios pueden editar la misma planificación.
|
||
- Notificaciones push: "Papá cambió la cena del jueves".
|
||
|
||
---
|
||
|
||
## Identidad Visual y Branding
|
||
|
||
### Logo
|
||
**Descripción conceptual**:
|
||
- **Forma**: circular o redondeada (amigable).
|
||
- **Icono**: plato con tenedor/cuchara cruzados, estilo line art minimalista.
|
||
- **Colores**: gradiente suave coral → menta (#FFB4A2 → #B8E6D5).
|
||
- **Líneas**: grosor 2-3px, esquinas redondeadas.
|
||
- **Opcional**: pequeño "sparkle" (✨) en la esquina superior derecha.
|
||
|
||
**Placeholder en código**:
|
||
```swift
|
||
struct AppIconPlaceholder: View {
|
||
var body: some View {
|
||
ZStack {
|
||
Circle()
|
||
.fill(
|
||
LinearGradient(
|
||
colors: [Color(hex: "#FFB4A2"), Color(hex: "#B8E6D5")],
|
||
startPoint: .topLeading,
|
||
endPoint: .bottomTrailing
|
||
)
|
||
)
|
||
|
||
Image(systemName: "fork.knife.circle")
|
||
.font(.system(size: 60, weight: .light))
|
||
.foregroundColor(.white)
|
||
}
|
||
.frame(width: 120, height: 120)
|
||
.shadow(color: .black.opacity(0.1), radius: 10, y: 5)
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Paleta de Colores
|
||
|
||
```swift
|
||
// Colores principales
|
||
extension Color {
|
||
// Primary
|
||
static let mealMoodCoral = Color(hex: "#FFB4A2") // Coral pastel (acento cálido)
|
||
static let mealMoodMint = Color(hex: "#B8E6D5") // Menta claro (secundario)
|
||
|
||
// Background
|
||
static let mealMoodBackground = Color(hex: "#FFF9F5") // Crema muy claro
|
||
static let mealMoodSurface = Color.white // Blanco puro (cards)
|
||
|
||
// Text
|
||
static let mealMoodTextPrimary = Color(hex: "#2D2D2D") // Gris oscuro
|
||
static let mealMoodTextSecondary = Color(hex: "#6B6B6B") // Gris medio
|
||
|
||
// Feedback
|
||
static let mealMoodSuccess = Color(hex: "#A8D5BA") // Verde sage
|
||
static let mealMoodWarning = Color(hex: "#FFD6A5") // Melocotón
|
||
static let mealMoodError = Color(hex: "#FF9999") // Rojo suave
|
||
|
||
// Helpers
|
||
init(hex: String) {
|
||
let scanner = Scanner(string: hex)
|
||
scanner.currentIndex = hex.hasPrefix("#") ? hex.index(after: hex.startIndex) : hex.startIndex
|
||
var rgb: UInt64 = 0
|
||
scanner.scanHexInt64(&rgb)
|
||
let r = Double((rgb & 0xFF0000) >> 16) / 255.0
|
||
let g = Double((rgb & 0x00FF00) >> 8) / 255.0
|
||
let b = Double(rgb & 0x0000FF) / 255.0
|
||
self.init(red: r, green: g, blue: b)
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Tipografía
|
||
|
||
```swift
|
||
// Font system
|
||
extension Font {
|
||
// Headers
|
||
static let mealMoodH1 = Font.system(size: 28, weight: .bold, design: .rounded)
|
||
static let mealMoodH2 = Font.system(size: 20, weight: .semibold, design: .rounded)
|
||
static let mealMoodH3 = Font.system(size: 18, weight: .semibold, design: .rounded)
|
||
|
||
// Body
|
||
static let mealMoodBody = Font.system(size: 16, weight: .regular, design: .default)
|
||
static let mealMoodBodyBold = Font.system(size: 16, weight: .semibold, design: .default)
|
||
|
||
// Small
|
||
static let mealMoodCaption = Font.system(size: 12, weight: .medium, design: .default)
|
||
static let mealMoodSmall = Font.system(size: 14, weight: .regular, design: .default)
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Componentes UI Reutilizables
|
||
|
||
#### 1. Card Style (para slots y platos)
|
||
|
||
```swift
|
||
struct MealCardStyle: ViewModifier {
|
||
func body(content: Content) -> some View {
|
||
content
|
||
.background(Color.mealMoodSurface)
|
||
.cornerRadius(16)
|
||
.shadow(color: Color.black.opacity(0.05), radius: 8, y: 4)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 16)
|
||
.stroke(Color(hex: "#F0F0F0"), lineWidth: 1)
|
||
)
|
||
}
|
||
}
|
||
|
||
extension View {
|
||
func mealCardStyle() -> some View {
|
||
modifier(MealCardStyle())
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 2. Tag Pill (visual de etiquetas)
|
||
|
||
```swift
|
||
struct TagPill: View {
|
||
let tag: Tag
|
||
|
||
var body: some View {
|
||
Text(tag.name)
|
||
.font(.mealMoodCaption)
|
||
.foregroundColor(.white)
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 4)
|
||
.background(
|
||
Capsule()
|
||
.fill(Color(hex: tag.color))
|
||
)
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3. Empty Slot (placeholder visual)
|
||
|
||
```swift
|
||
struct EmptySlot: View {
|
||
let mealType: MealType
|
||
|
||
var body: some View {
|
||
VStack(spacing: 8) {
|
||
Image(systemName: mealType == .lunch ? "sun.max.fill" : "moon.stars.fill")
|
||
.font(.system(size: 24))
|
||
.foregroundColor(Color(hex: "#C4C4C4"))
|
||
|
||
Text(mealType == .lunch ? "Comida" : "Cena")
|
||
.font(.mealMoodSmall)
|
||
.foregroundColor(.mealMoodTextSecondary)
|
||
|
||
Text("Arrastra un plato")
|
||
.font(.mealMoodCaption)
|
||
.foregroundColor(Color(hex: "#A0A0A0"))
|
||
}
|
||
.frame(maxWidth: .infinity, minHeight: 100)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 12)
|
||
.fill(Color(hex: "#F9F9F9"))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 12)
|
||
.strokeBorder(
|
||
style: StrokeStyle(lineWidth: 2, dash: [5])
|
||
)
|
||
.foregroundColor(Color(hex: "#E0E0E0"))
|
||
)
|
||
)
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 4. Filled Slot (con plato asignado)
|
||
|
||
```swift
|
||
struct FilledSlot: View {
|
||
let dish: Dish
|
||
let tags: [Tag]
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Text(dish.name)
|
||
.font(.system(size: 16, weight: .semibold))
|
||
.foregroundColor(.mealMoodTextPrimary)
|
||
.lineLimit(2)
|
||
|
||
if let description = dish.description {
|
||
Text(description)
|
||
.font(.mealMoodCaption)
|
||
.foregroundColor(.mealMoodTextSecondary)
|
||
.lineLimit(1)
|
||
}
|
||
|
||
HStack(spacing: 4) {
|
||
ForEach(tags.prefix(3)) { tag in
|
||
TagPill(tag: tag)
|
||
}
|
||
if tags.count > 3 {
|
||
Text("+\(tags.count - 3)")
|
||
.font(.system(size: 10, weight: .medium))
|
||
.foregroundColor(.mealMoodTextSecondary)
|
||
}
|
||
}
|
||
}
|
||
.padding(12)
|
||
.frame(maxWidth: .infinity, minHeight: 100, alignment: .topLeading)
|
||
.mealCardStyle()
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 5. Primary Button
|
||
|
||
```swift
|
||
struct PrimaryButton: View {
|
||
let title: String
|
||
let icon: String?
|
||
let action: () -> Void
|
||
|
||
var body: some View {
|
||
Button(action: action) {
|
||
HStack(spacing: 8) {
|
||
Text(title)
|
||
.font(.mealMoodBodyBold)
|
||
if let icon = icon {
|
||
Text(icon)
|
||
}
|
||
}
|
||
.foregroundColor(.white)
|
||
.padding(.horizontal, 24)
|
||
.padding(.vertical, 12)
|
||
.background(
|
||
LinearGradient(
|
||
colors: [Color.mealMoodCoral, Color.mealMoodMint],
|
||
startPoint: .leading,
|
||
endPoint: .trailing
|
||
)
|
||
)
|
||
.cornerRadius(12)
|
||
.shadow(color: Color.mealMoodCoral.opacity(0.3), radius: 8, y: 4)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 6. Secondary Button
|
||
|
||
```swift
|
||
struct SecondaryButton: View {
|
||
let title: String
|
||
let icon: String?
|
||
let action: () -> Void
|
||
|
||
var body: some View {
|
||
Button(action: action) {
|
||
HStack(spacing: 8) {
|
||
Text(title)
|
||
.font(.mealMoodBodyBold)
|
||
if let icon = icon {
|
||
Text(icon)
|
||
}
|
||
}
|
||
.foregroundColor(.mealMoodTextPrimary)
|
||
.padding(.horizontal, 24)
|
||
.padding(.vertical, 12)
|
||
.background(Color.mealMoodSurface)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 12)
|
||
.stroke(Color.mealMoodTextSecondary.opacity(0.3), lineWidth: 1.5)
|
||
)
|
||
.cornerRadius(12)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Especificaciones de Pantallas
|
||
|
||
### Pantalla 1: Onboarding
|
||
|
||
#### 1.1: Bienvenida
|
||
|
||
```
|
||
┌─────────────────────────────────────────────┐
|
||
│ │
|
||
│ [Logo MealMood placeholder] │
|
||
│ │
|
||
│ ✨ Planifica tu semana en 5 minutos │
|
||
│ │
|
||
│ [Ilustración: familia cenando feliz] │
|
||
│ │
|
||
│ • Sin estrés │
|
||
│ • Sin contar calorías │
|
||
│ • Sincroniza con tu calendario │
|
||
│ │
|
||
│ │
|
||
│ [Comenzar] ──────────► │
|
||
│ │
|
||
└─────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Elementos**:
|
||
- Logo placeholder (usar componente AppIconPlaceholder).
|
||
- Título: H1, centrado, coral.
|
||
- Ilustración: placeholder con SF Symbols (ej: "figure.2.and.child.holdinghands").
|
||
- Lista de beneficios: bullets con iconos.
|
||
- Botón primario: "Comenzar".
|
||
|
||
**Navegación**: tap en "Comenzar" → Onboarding paso 1.
|
||
|
||
---
|
||
|
||
#### 1.2: Paso 1 - Ventanas de Comida
|
||
|
||
```
|
||
┌─────────────────────────────────────────────┐
|
||
│ ¿Qué quieres planificar? │
|
||
│ │
|
||
│ ┌─────────────────────────────────────┐ │
|
||
│ │ 🌙 Solo cenas │ │
|
||
│ └─────────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌─────────────────────────────────────┐ │
|
||
│ │ ☀️ Solo comidas │ │
|
||
│ └─────────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌─────────────────────────────────────┐ │
|
||
│ │ 🌞🌙 Comidas y cenas │ │
|
||
│ └─────────────────────────────────────┘ │
|
||
│ │
|
||
│ [Continuar] ────────► │
|
||
└─────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Elementos**:
|
||
- Título: H2, alineado a la izquierda.
|
||
- 3 cards seleccionables (radio button style).
|
||
- Botón primario: "Continuar" (solo habilitado si hay selección).
|
||
|
||
**Navegación**: tap en "Continuar" → Onboarding paso 2.
|
||
|
||
---
|
||
|
||
#### 1.3: Paso 2 - Fines de Semana
|
||
|
||
```
|
||
┌─────────────────────────────────────────────┐
|
||
│ ¿Incluir fines de semana? │
|
||
│ │
|
||
│ ┌─────────────────────────────────────┐ │
|
||
│ │ Planificar también sábados │ │
|
||
│ │ y domingos [ON] │ │
|
||
│ └─────────────────────────────────────┘ │
|
||
│ │
|
||
│ (Puedes cambiar esto después en ajustes) │
|
||
│ │
|
||
│ [Continuar] ────────► │
|
||
└─────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Elementos**:
|
||
- Título: H2.
|
||
- Toggle prominente.
|
||
- Nota aclaratoria (small text, gris).
|
||
- Botón primario: "Continuar".
|
||
|
||
**Navegación**: tap en "Continuar" → Onboarding paso 3.
|
||
|
||
---
|
||
|
||
#### 1.4: Paso 3 - Calendario (Opcional)
|
||
|
||
```
|
||
┌─────────────────────────────────────────────┐
|
||
│ ¿Sincronizar con tu calendario? │
|
||
│ │
|
||
│ ┌─────────────────────────────────────┐ │
|
||
│ │ Sincronizar [OFF] │ │
|
||
│ └─────────────────────────────────────┘ │
|
||
│ │
|
||
│ [Si activas sincronización:] │
|
||
│ │
|
||
│ ┌─────────────────────────────────────┐ │
|
||
│ │ Calendario │ │
|
||
│ │ Personal ▼ │ │
|
||
│ └─────────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌─────────────────────────────────────┐ │
|
||
│ │ Hora de comidas 14:00 ⏰ │ │
|
||
│ └─────────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌─────────────────────────────────────┐ │
|
||
│ │ Hora de cenas 21:00 ⏰ │ │
|
||
│ └─────────────────────────────────────┘ │
|
||
│ │
|
||
│ (Opcional, puedes activarlo después) │
|
||
│ │
|
||
│ [Omitir] [Continuar] ──────► │
|
||
└─────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Elementos**:
|
||
- Toggle: "Sincronizar".
|
||
- Si toggle ON → mostrar selector de calendario + time pickers.
|
||
- Botones: "Omitir" (secondary) y "Continuar" (primary).
|
||
|
||
**Navegación**:
|
||
- "Omitir" o "Continuar" → Onboarding paso 4.
|
||
- Si toggle ON, solicitar permiso de EventKit antes de continuar.
|
||
|
||
---
|
||
|
||
#### 1.5: Paso 4 - Crear Primeros Platos
|
||
|
||
```
|
||
┌─────────────────────────────────────────────┐
|
||
│ Añade tus primeros platos │
|
||
│ │
|
||
│ Añade al menos 2-3 platos para empezar │
|
||
│ │
|
||
│ ┌─────────────────────────────────────┐ │
|
||
│ │ Nombre del plato │ │
|
||
│ │ [Pollo asado ] │ │
|
||
│ │ │ │
|
||
│ │ Etiquetas │ │
|
||
│ │ 🔴 Carne [×] │ │
|
||
│ │ │ │
|
||
│ │ [+ Añadir plato] │ │
|
||
│ └─────────────────────────────────────┘ │
|
||
│ │
|
||
│ Platos añadidos: │
|
||
│ ✓ Pollo asado (🔴 Carne) │
|
||
│ │
|
||
│ [Finalizar] ────────► │
|
||
└─────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Elementos**:
|
||
- Formulario simple: nombre + selector de tags.
|
||
- Botón "Añadir plato" (añade a lista temporal).
|
||
- Lista de platos añadidos (con opción de eliminar).
|
||
- Botón "Finalizar" (habilitado cuando hay >= 2 platos).
|
||
|
||
**Navegación**: tap en "Finalizar" → Home (pantalla principal).
|
||
|
||
---
|
||
|
||
### Pantalla 2: Home (Pantalla Principal)
|
||
|
||
```
|
||
┌─────────────────────────────────────────────┐
|
||
│ MealMood Semana 3-9 Feb [⚙️] │
|
||
│ │
|
||
│ ← Anterior Siguiente → │
|
||
├─────────────────────────────────────────────┤
|
||
│ │
|
||
│ Lun Mar Mié Jue Vie │
|
||
│ │
|
||
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
|
||
│ │🌙 │ │🌙 │ │🌙 │ │🌙 │ │🌙 │ │
|
||
│ │Pollo│ │Sal- │ │Torti│ │ │ │ │ │
|
||
│ │asado│ │món │ │lla │ │ │ │ │ │
|
||
│ │🔴 │ │🔵 │ │🟡 │ │ - - │ │ - - │ │
|
||
│ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ │
|
||
│ │
|
||
│ [Si incluye comida y cena:] │
|
||
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
|
||
│ │☀️ │ │☀️ │ │☀️ │ │☀️ │ │☀️ │ │
|
||
│ │Lente│ │ │ │Pasta│ │ │ │ │ │
|
||
│ │jas │ │ │ │🟡 │ │ │ │ │ │
|
||
│ │🟢🟤 │ │ - - │ │ │ │ - - │ │ - - │ │
|
||
│ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ │
|
||
│ │
|
||
├─────────────────────────────────────────────┤
|
||
│ ┌──────────────┐ ┌──────────────┐ │
|
||
│ │ Completar │ │ Resetear │ │
|
||
│ │ ✨ │ │ 🔄 │ │
|
||
│ └──────────────┘ └──────────────┘ │
|
||
└─────────────────────────────────────────────┘
|
||
▲ Arrastra platos aquí ▲
|
||
┌─────────────────────────────────────────────┐
|
||
│ 📋 Mis platos [+] │
|
||
├─────────────────────────────────────────────┤
|
||
│ ┌─────────────────────────────────────┐ │
|
||
│ │ Pollo asado 🔴 │ │
|
||
│ │ Jugoso y al horno │ │
|
||
│ └─────────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌─────────────────────────────────────┐ │
|
||
│ │ Salmón a la plancha 🔵 │ │
|
||
│ │ Con limón y eneldo │ │
|
||
│ └─────────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌─────────────────────────────────────┐ │
|
||
│ │ Lentejas 🟢🟤 │ │
|
||
│ │ Estofadas con verduras │ │
|
||
│ └─────────────────────────────────────┘ │
|
||
│ │
|
||
│ ... (scroll si hay más platos) │
|
||
│ │
|
||
│ [Banner AdMob - 50px altura] │
|
||
└─────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Elementos**:
|
||
|
||
#### Header
|
||
- Título: "MealMood" (H2, bold).
|
||
- Rango de semana: "Semana 3-9 Feb" (body, gris).
|
||
- Botón Settings: icono ⚙️ (top-right).
|
||
|
||
#### Navegación Semanal
|
||
- Flechas: ← Anterior / Siguiente →.
|
||
- Centrado en el rango de fechas.
|
||
|
||
#### Calendario Semanal
|
||
- **Grid de slots**:
|
||
- Orientación: horizontal scroll si incluye fines de semana (7 columnas).
|
||
- Orientación: vertical scroll si incluye comida+cena (2 filas por día).
|
||
- **Slot vacío**: usar componente `EmptySlot`.
|
||
- **Slot lleno**: usar componente `FilledSlot`.
|
||
|
||
#### Botones de Acción
|
||
- **Completar ✨**: PrimaryButton.
|
||
- **Resetear 🔄**: SecondaryButton.
|
||
|
||
#### Drawer de Platos
|
||
- Lista scrolleable vertical.
|
||
- Card por plato (usar `mealCardStyle`).
|
||
- Botón [+]: circular, floating, color coral.
|
||
|
||
#### AdMob Banner (si Free)
|
||
- Altura: 50px.
|
||
- Background: mismo color que la app (#FFF9F5).
|
||
- Sticky al fondo del drawer.
|
||
|
||
**Interacciones**:
|
||
- **Long press en plato** → drag & drop.
|
||
- **Tap en slot lleno** → opción de eliminar plato.
|
||
- **Tap en [+]** → abrir sheet "Crear Plato".
|
||
- **Tap en ⚙️** → navegar a Settings.
|
||
- **Tap en "Completar"** → ejecutar algoritmo de autocompletado.
|
||
- **Tap en "Resetear"** → mostrar alert de confirmación.
|
||
|
||
---
|
||
|
||
### Pantalla 3: Crear/Editar Plato
|
||
|
||
```
|
||
┌─────────────────────────────────────────────┐
|
||
│ ← Nuevo plato [✓] │
|
||
├─────────────────────────────────────────────┤
|
||
│ │
|
||
│ Nombre del plato * │
|
||
│ ┌─────────────────────────────────────┐ │
|
||
│ │ Pollo asado │ │
|
||
│ └─────────────────────────────────────┘ │
|
||
│ │
|
||
│ Descripción (opcional) │
|
||
│ ┌─────────────────────────────────────┐ │
|
||
│ │ Jugoso y al horno con hierbas │ │
|
||
│ └─────────────────────────────────────┘ │
|
||
│ │
|
||
│ Etiquetas * │
|
||
│ ┌──────────────────────────────────┐ │
|
||
│ │ 🔴 Carne [×] │ │
|
||
│ │ 🟢 Verduras [×] │ │
|
||
│ └──────────────────────────────────┘ │
|
||
│ │
|
||
│ [+ Añadir etiqueta] │
|
||
│ │
|
||
│ │
|
||
│ │
|
||
│ [Eliminar plato] (solo en modo edición) │
|
||
└─────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Elementos**:
|
||
- **Header**:
|
||
- Botón back: ← (top-left).
|
||
- Título: "Nuevo plato" (crear) o "Editar plato" (editar).
|
||
- Botón guardar: ✓ (top-right, habilitado solo si campos válidos).
|
||
|
||
- **Formulario**:
|
||
- Campo: Nombre (obligatorio, TextField).
|
||
- Campo: Descripción (opcional, TextEditor, max 100 caracteres).
|
||
- Lista de tags seleccionados (pills con botón [×] para eliminar).
|
||
- Botón "Añadir etiqueta" → abre sheet selector de tags.
|
||
|
||
- **Botón Eliminar** (solo en modo edición):
|
||
- SecondaryButton, color rojo.
|
||
- Mostrar alert de confirmación.
|
||
|
||
**Validaciones**:
|
||
- Nombre no puede estar vacío.
|
||
- Debe tener al menos 1 etiqueta.
|
||
|
||
**Navegación**:
|
||
- Tap en [✓] → guardar y cerrar sheet.
|
||
- Tap en ← → descartar cambios (mostrar alert si hay cambios sin guardar).
|
||
|
||
---
|
||
|
||
#### 3.1: Sheet Selector de Tags
|
||
|
||
```
|
||
┌─────────────────────────────────────────────┐
|
||
│ Selecciona etiquetas [×] │
|
||
├─────────────────────────────────────────────┤
|
||
│ │
|
||
│ 🔴 Carne [✓] │
|
||
│ 🔵 Pescado [ ] │
|
||
│ 🟢 Verduras [✓] │
|
||
│ 🟡 Huevos [ ] │
|
||
│ 🟤 Legumbres [ ] │
|
||
│ 🟠 Pasta/Arroz [ ] │
|
||
│ 🟣 Solo Cena [ ] │
|
||
│ 🟠 Solo Comida [ ] │
|
||
│ 🟤 Con Gluten [ ] │
|
||
│ 🟢 Sin Gluten [ ] │
|
||
│ │
|
||
│ [Listo] │
|
||
└─────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Elementos**:
|
||
- Lista de todos los tags disponibles.
|
||
- Checkbox por tag (multi-selección).
|
||
- Color pill al lado del nombre.
|
||
- Botón "Listo" (primary).
|
||
|
||
**Navegación**: tap en "Listo" → cerrar sheet, actualizar lista de tags seleccionados.
|
||
|
||
---
|
||
|
||
### Pantalla 4: Gestión de Platos
|
||
|
||
```
|
||
┌─────────────────────────────────────────────┐
|
||
│ ← Mis platos [+] │
|
||
├─────────────────────────────────────────────┤
|
||
│ │
|
||
│ ┌─────────────────────────────────────┐ │
|
||
│ │ Pollo asado 🔴 [›] │ │
|
||
│ │ Jugoso y al horno │ │
|
||
│ └─────────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌─────────────────────────────────────┐ │
|
||
│ │ Salmón a la plancha 🔵 [›] │ │
|
||
│ │ Con limón y eneldo │ │
|
||
│ └─────────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌─────────────────────────────────────┐ │
|
||
│ │ Lentejas 🟢🟤 [›] │ │
|
||
│ │ Estofadas con verduras │ │
|
||
│ └─────────────────────────────────────┘ │
|
||
│ │
|
||
│ ... (scroll si hay más platos) │
|
||
│ │
|
||
└─────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Elementos**:
|
||
- **Header**:
|
||
- Botón back: ← (top-left).
|
||
- Título: "Mis platos".
|
||
- Botón [+]: crear nuevo plato (top-right).
|
||
|
||
- **Lista de platos**:
|
||
- Card por plato (mealCardStyle).
|
||
- Tap en card → abrir sheet "Editar Plato".
|
||
- Swipe left → botón "Eliminar" (rojo).
|
||
|
||
**Interacciones**:
|
||
- **Tap en plato** → abrir sheet edición.
|
||
- **Swipe left** → mostrar botón "Eliminar".
|
||
- **Tap en "Eliminar"** → mostrar alert de confirmación.
|
||
- **Tap en [+]** → abrir sheet "Crear Plato".
|
||
|
||
---
|
||
|
||
### Pantalla 5: Gestión de Etiquetas
|
||
|
||
```
|
||
┌─────────────────────────────────────────────┐
|
||
│ ← Etiquetas │
|
||
├─────────────────────────────────────────────┤
|
||
│ │
|
||
│ 🔴 Carne [›] │
|
||
│ Max 3/semana, no consecutivo │
|
||
│ │
|
||
│ 🔵 Pescado [›] │
|
||
│ Max 2/semana, no consecutivo │
|
||
│ │
|
||
│ 🟢 Verduras [›] │
|
||
│ Sin límite │
|
||
│ │
|
||
│ 🟡 Huevos [›] │
|
||
│ Max 2/semana, no mismo día │
|
||
│ │
|
||
│ ... (resto de tags) │
|
||
│ │
|
||
│ [+ Crear etiqueta] (solo Premium) │
|
||
└─────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Elementos**:
|
||
- Lista de tags con resumen de reglas.
|
||
- Tap en tag → abrir pantalla "Editar Reglas de Tag".
|
||
- Botón [+ Crear etiqueta] (disabled en Free, enabled en Premium).
|
||
|
||
**Navegación**: tap en tag → Pantalla 5.1.
|
||
|
||
---
|
||
|
||
#### 5.1: Editar Reglas de Tag
|
||
|
||
```
|
||
┌─────────────────────────────────────────────┐
|
||
│ ← Carne [✓] │
|
||
├─────────────────────────────────────────────┤
|
||
│ │
|
||
│ 🔴 Carne │
|
||
│ │
|
||
│ Máximo por semana │
|
||
│ ┌──────────────────────────────────┐ │
|
||
│ │ [1] [2] [3] [4] [5] [6] [7] [∞] │ │
|
||
│ └──────────────────────────────────┘ │
|
||
│ Seleccionado: 3 │
|
||
│ │
|
||
│ ┌──────────────────────────────────┐ │
|
||
│ │ No consecutivo [ON] │ │
|
||
│ └──────────────────────────────────┘ │
|
||
│ Evita que aparezca días seguidos │
|
||
│ │
|
||
│ ┌──────────────────────────────────┐ │
|
||
│ │ No repetir en el mismo día [ON] │ │
|
||
│ └──────────────────────────────────┘ │
|
||
│ No puede estar en comida y cena el mismo │
|
||
│ día │
|
||
│ │
|
||
│ Restricción de horario │
|
||
│ ┌──────────────────────────────────┐ │
|
||
│ │ Sin restricción ▼ │ │
|
||
│ └──────────────────────────────────┘ │
|
||
│ Opciones: Sin restricción / Solo comida / │
|
||
│ Solo cena │
|
||
│ │
|
||
└─────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Elementos**:
|
||
- Pill de color del tag (centrado).
|
||
- **Max por semana**: segmented control (1-7, ∞).
|
||
- **Toggles**: no consecutivo, no mismo día.
|
||
- **Restricción horario**: picker (sin restricción / solo comida / solo cena).
|
||
- Botón [✓] (top-right): guardar y volver.
|
||
|
||
**Navegación**: tap en [✓] → guardar y volver a lista de tags.
|
||
|
||
---
|
||
|
||
### Pantalla 6: Settings
|
||
|
||
```
|
||
┌─────────────────────────────────────────────┐
|
||
│ ← Ajustes │
|
||
├─────────────────────────────────────────────┤
|
||
│ │
|
||
│ 🍽️ PLANIFICACIÓN │
|
||
│ │
|
||
│ Ventanas de comida │
|
||
│ ┌──────────────────────────────────┐ │
|
||
│ │ Solo cenas ▼ │ │
|
||
│ └──────────────────────────────────┘ │
|
||
│ │
|
||
│ Incluir fines de semana │
|
||
│ ┌──────────────────────────────────┐ │
|
||
│ │ [ON] │ │
|
||
│ └──────────────────────────────────┘ │
|
||
│ │
|
||
│ ───────────────────────────────────── │
|
||
│ │
|
||
│ 📅 CALENDARIO │
|
||
│ │
|
||
│ Sincronizar │
|
||
│ ┌──────────────────────────────────┐ │
|
||
│ │ [OFF] │ │
|
||
│ └──────────────────────────────────┘ │
|
||
│ │
|
||
│ [Si ON, mostrar:] │
|
||
│ Calendario │
|
||
│ ┌──────────────────────────────────┐ │
|
||
│ │ Personal ▼ │ │
|
||
│ └──────────────────────────────────┘ │
|
||
│ │
|
||
│ Hora de comidas │
|
||
│ ┌──────────────────────────────────┐ │
|
||
│ │ 14:00 ⏰ │ │
|
||
│ └──────────────────────────────────┘ │
|
||
│ │
|
||
│ Hora de cenas │
|
||
│ ┌──────────────────────────────────┐ │
|
||
│ │ 21:00 ⏰ │ │
|
||
│ └──────────────────────────────────┘ │
|
||
│ │
|
||
│ Duración del evento │
|
||
│ ┌──────────────────────────────────┐ │
|
||
│ │ 1 hora ▼ │ │
|
||
│ └──────────────────────────────────┘ │
|
||
│ │
|
||
│ Prefijo del título │
|
||
│ ┌──────────────────────────────────┐ │
|
||
│ │ 🍽️ │ │
|
||
│ └──────────────────────────────────┘ │
|
||
│ │
|
||
│ Recordatorio │
|
||
│ ┌──────────────────────────────────┐ │
|
||
│ │ Sin recordatorio ▼ │ │
|
||
│ └──────────────────────────────────┘ │
|
||
│ │
|
||
│ ───────────────────────────────────── │
|
||
│ │
|
||
│ 🏷️ ETIQUETAS │
|
||
│ │
|
||
│ Gestionar etiquetas [›] │
|
||
│ │
|
||
│ ───────────────────────────────────── │
|
||
│ │
|
||
│ 🌍 IDIOMA │
|
||
│ │
|
||
│ Idioma │
|
||
│ ┌──────────────────────────────────┐ │
|
||
│ │ Español ▼ │ │
|
||
│ └──────────────────────────────────┘ │
|
||
│ │
|
||
│ ───────────────────────────────────── │
|
||
│ │
|
||
│ ⭐ PREMIUM │
|
||
│ │
|
||
│ Estado: Free │
|
||
│ Quitar publicidad [›] │
|
||
│ │
|
||
└─────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Elementos**:
|
||
- Secciones separadas por headers (emoji + título).
|
||
- Separadores visuales (línea gris claro).
|
||
- Controles nativos de iOS (Toggle, Picker, TextField).
|
||
- Navegación a sub-pantallas con [›].
|
||
|
||
**Navegación**:
|
||
- **Gestionar etiquetas** → Pantalla 5.
|
||
- **Quitar publicidad** → Pantalla de IAP (In-App Purchase).
|
||
|
||
---
|
||
|
||
### Pantalla 7: Premium / IAP
|
||
|
||
```
|
||
┌─────────────────────────────────────────────┐
|
||
│ ← Premium [×] │
|
||
├─────────────────────────────────────────────┤
|
||
│ │
|
||
│ ✨ MealMood Premium ✨ │
|
||
│ │
|
||
│ Disfruta de todas las funciones │
|
||
│ │
|
||
│ ✓ Sin publicidad │
|
||
│ ✓ Platos ilimitados │
|
||
│ ✓ Etiquetas personalizadas │
|
||
│ ✓ Reglas avanzadas (próximamente) │
|
||
│ │
|
||
│ ───────────────────────────────────── │
|
||
│ │
|
||
│ Planes disponibles: │
|
||
│ │
|
||
│ ┌─────────────────────────────────────┐ │
|
||
│ │ Mensual │ │
|
||
│ │ $2.99 / mes │ │
|
||
│ │ [Suscribirse] │ │
|
||
│ └─────────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌─────────────────────────────────────┐ │
|
||
│ │ Anual │ │
|
||
│ │ $19.99 / año (ahorra 44%) │ │
|
||
│ │ [Suscribirse] │ │
|
||
│ └─────────────────────────────────────┘ │
|
||
│ │
|
||
│ [Restaurar compras] │
|
||
│ │
|
||
└─────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Elementos**:
|
||
- Header con título y botón cerrar [×].
|
||
- Logo premium (placeholder con ✨).
|
||
- Lista de beneficios con checkmarks.
|
||
- Cards de planes (mensual / anual).
|
||
- Botones "Suscribirse" (StoreKit).
|
||
- Link "Restaurar compras".
|
||
|
||
**Navegación**:
|
||
- Tap en "Suscribirse" → StoreKit purchase flow.
|
||
- Tap en [×] → cerrar sheet.
|
||
|
||
---
|
||
|
||
## Interacciones y Micro-animaciones
|
||
|
||
### 1. Drag & Drop
|
||
|
||
**Gestos**:
|
||
1. **Long press** (200ms) en card de plato del drawer:
|
||
- Card se "levanta" con escala 1.05.
|
||
- Sombra aumenta (radius: 12, y: 8).
|
||
- Haptic feedback: `.light`.
|
||
|
||
2. **Drag** sobre slot:
|
||
- **Válido** (respeta reglas):
|
||
- Slot muestra borde verde (#A8D5BA, 2px).
|
||
- Haptic feedback: `.light` (suave, continuo cada 100ms).
|
||
- **Inválido** (viola reglas):
|
||
- Slot muestra borde rojo (#FF9999, 2px).
|
||
- Haptic feedback: `.rigid` (fuerte, único).
|
||
|
||
3. **Drop**:
|
||
- **Válido**:
|
||
- Animación "plop": slot hace scale 1.0 → 1.1 → 1.0 (duration: 0.3s).
|
||
- Plato desaparece del drawer con fade out.
|
||
- Haptic feedback: `.success`.
|
||
- **Inválido**:
|
||
- Plato vuelve al drawer con spring animation (bouncy).
|
||
- Haptic feedback: `.error`.
|
||
|
||
**Código ejemplo (SwiftUI)**:
|
||
```swift
|
||
.onLongPressGesture(minimumDuration: 0.2) {
|
||
// Iniciar drag
|
||
withAnimation(.spring()) {
|
||
draggedDish = dish
|
||
isLifted = true
|
||
}
|
||
HapticManager.shared.impact(style: .light)
|
||
}
|
||
.gesture(
|
||
DragGesture()
|
||
.onChanged { value in
|
||
dragOffset = value.translation
|
||
validateDrop(at: value.location)
|
||
}
|
||
.onEnded { value in
|
||
performDrop(at: value.location)
|
||
}
|
||
)
|
||
```
|
||
|
||
---
|
||
|
||
### 2. Completar Automáticamente
|
||
|
||
**Flujo**:
|
||
1. Usuario tap en botón "Completar ✨".
|
||
2. Botón muestra loading indicator (ProgressView, color coral).
|
||
3. Algoritmo ejecuta en background (no bloquea UI).
|
||
4. Animación secuencial:
|
||
- Slots vacíos se rellenan uno por uno (delay: 150ms entre cada uno).
|
||
- Cada slot hace fade in + scale animation (0.8 → 1.0).
|
||
5. Al terminar:
|
||
- Confetti suave (partículas pequeñas, colores pastel, duration: 2s).
|
||
- Haptic feedback: `.success`.
|
||
- Toast: "¡Listo! Tu semana está completa 🎉" (auto-dismiss en 3s).
|
||
|
||
**Código ejemplo (SwiftUI)**:
|
||
```swift
|
||
func autoComplete() {
|
||
isLoading = true
|
||
|
||
Task {
|
||
let filledSlots = await MealPlanner.shared.autoComplete(weekPlan: weekPlan)
|
||
|
||
for (index, slot) in filledSlots.enumerated() {
|
||
try? await Task.sleep(nanoseconds: 150_000_000) // 150ms
|
||
|
||
withAnimation(.spring()) {
|
||
weekPlan.slots[slot.id] = slot
|
||
}
|
||
HapticManager.shared.impact(style: .light)
|
||
}
|
||
|
||
isLoading = false
|
||
showConfetti = true
|
||
HapticManager.shared.notification(type: .success)
|
||
showToast = true
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 3. Resetear
|
||
|
||
**Flujo**:
|
||
1. Usuario tap en botón "Resetear 🔄".
|
||
2. Alert suave (no modal agresivo):
|
||
```
|
||
┌─────────────────────────────────────┐
|
||
│ ¿Limpiar la semana completa? │
|
||
│ │
|
||
│ Esto no se puede deshacer │
|
||
│ │
|
||
│ [Cancelar] [Limpiar] │
|
||
└─────────────────────────────────────┘
|
||
```
|
||
3. Si usuario confirma:
|
||
- Todos los slots vacíos hacen fade out (simultáneo, duration: 0.3s).
|
||
- Haptic feedback: `.warning`.
|
||
- Platos vuelven al drawer con fade in (staggered, delay: 50ms entre cada uno).
|
||
|
||
**Código ejemplo (SwiftUI)**:
|
||
```swift
|
||
func resetWeek() {
|
||
withAnimation(.easeOut(duration: 0.3)) {
|
||
for slot in weekPlan.slots {
|
||
slot.dish = nil
|
||
}
|
||
}
|
||
HapticManager.shared.notification(type: .warning)
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 4. Micro-interactions Adicionales
|
||
|
||
#### 4.1: Empty State (sin platos)
|
||
- Ilustración de familia con plato vacío.
|
||
- Texto: "Añade tu primer plato para empezar".
|
||
- Botón "Añadir plato" (primary, centrado).
|
||
|
||
#### 4.2: Pull to Refresh (en lista de platos)
|
||
- Arrastrar hacia abajo → loading indicator.
|
||
- Aunque no haya backend, simular recarga con delay 1s.
|
||
|
||
#### 4.3: Swipe to Delete (en lista de platos)
|
||
- Swipe left → botón "Eliminar" (rojo, icono 🗑️).
|
||
- Tap en eliminar → animación de desaparición (slide left + fade out).
|
||
|
||
#### 4.4: Toast Notifications
|
||
- Posición: top-center.
|
||
- Background: coral suave con opacity 0.95.
|
||
- Sombra suave.
|
||
- Auto-dismiss en 3s.
|
||
- Ejemplos:
|
||
- "Plato guardado ✓"
|
||
- "Evento eliminado del calendario"
|
||
- "No puedo completar con las reglas actuales"
|
||
|
||
---
|
||
|
||
## Integración con Calendario Nativo
|
||
|
||
### Permisos
|
||
|
||
**Solicitar permiso** al activar sincronización por primera vez:
|
||
```swift
|
||
import EventKit
|
||
|
||
let eventStore = EKEventStore()
|
||
|
||
func requestCalendarAccess() async -> Bool {
|
||
do {
|
||
return try await eventStore.requestFullAccessToEvents()
|
||
} catch {
|
||
print("Error requesting calendar access: \(error)")
|
||
return false
|
||
}
|
||
}
|
||
```
|
||
|
||
**Si usuario deniega**:
|
||
- Mostrar alert: "MealMood necesita acceso a tu calendario para sincronizar eventos. Ve a Ajustes > MealMood > Calendario."
|
||
- Link directo a Settings del sistema: `UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!)`
|
||
|
||
---
|
||
|
||
### Crear Evento
|
||
|
||
**Función**:
|
||
```swift
|
||
func createEvent(
|
||
slot: MealSlot,
|
||
dish: Dish,
|
||
weekStartDate: Date,
|
||
settings: Settings
|
||
) -> String? {
|
||
guard let calendarId = settings.calendarId,
|
||
let calendar = eventStore.calendar(withIdentifier: calendarId) else {
|
||
return nil
|
||
}
|
||
|
||
let event = EKEvent(eventStore: eventStore)
|
||
|
||
// Título
|
||
let prefix = settings.eventPrefix.isEmpty ? "" : "\(settings.eventPrefix) "
|
||
let mealName = slot.mealType == .lunch ? "Comida" : "Cena"
|
||
event.title = "\(prefix)\(mealName): \(dish.name)"
|
||
|
||
// Fecha y hora
|
||
let slotDate = weekStartDate.addingDays(slot.dayOfWeek)
|
||
let slotTime = slot.mealType == .lunch ? settings.lunchTime : settings.dinnerTime
|
||
event.startDate = combine(date: slotDate, time: slotTime)
|
||
event.endDate = event.startDate.addingMinutes(settings.eventDuration)
|
||
|
||
// Nota
|
||
event.notes = dish.description
|
||
|
||
// Calendario
|
||
event.calendar = calendar
|
||
|
||
// Alerta
|
||
if let reminderMinutes = settings.reminderMinutesBefore {
|
||
let alarm = EKAlarm(relativeOffset: -TimeInterval(reminderMinutes * 60))
|
||
event.addAlarm(alarm)
|
||
}
|
||
|
||
// Guardar
|
||
do {
|
||
try eventStore.save(event, span: .thisEvent)
|
||
return event.eventIdentifier
|
||
} catch {
|
||
print("Error saving event: \(error)")
|
||
return nil
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Eliminar Evento
|
||
|
||
**Función**:
|
||
```swift
|
||
func deleteEvent(eventId: String) {
|
||
guard let event = eventStore.event(withIdentifier: eventId) else {
|
||
return
|
||
}
|
||
|
||
do {
|
||
try eventStore.remove(event, span: .thisEvent)
|
||
} catch {
|
||
print("Error deleting event: \(error)")
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Actualizar Eventos (cambio de hora)
|
||
|
||
**Función**:
|
||
```swift
|
||
func updateEventsTime(weekPlan: WeekPlan, newLunchTime: Date, newDinnerTime: Date) {
|
||
for slot in weekPlan.slots {
|
||
guard let eventId = slot.calendarEventId,
|
||
let event = eventStore.event(withIdentifier: eventId),
|
||
event.startDate >= Date() else { // solo futuros
|
||
continue
|
||
}
|
||
|
||
// Actualizar hora
|
||
let newTime = slot.mealType == .lunch ? newLunchTime : newDinnerTime
|
||
let newStartDate = combine(date: event.startDate, time: newTime)
|
||
event.startDate = newStartDate
|
||
event.endDate = newStartDate.addingMinutes(settings.eventDuration)
|
||
|
||
// Guardar
|
||
do {
|
||
try eventStore.save(event, span: .thisEvent)
|
||
} catch {
|
||
print("Error updating event: \(error)")
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Helpers
|
||
|
||
```swift
|
||
extension Date {
|
||
func addingDays(_ days: Int) -> Date {
|
||
Calendar.current.date(byAdding: .day, value: days, to: self)!
|
||
}
|
||
|
||
func addingMinutes(_ minutes: Int) -> Date {
|
||
Calendar.current.date(byAdding: .minute, value: minutes, to: self)!
|
||
}
|
||
}
|
||
|
||
func combine(date: Date, time: Date) -> Date {
|
||
let calendar = Calendar.current
|
||
let dateComponents = calendar.dateComponents([.year, .month, .day], from: date)
|
||
let timeComponents = calendar.dateComponents([.hour, .minute], from: time)
|
||
|
||
var combined = DateComponents()
|
||
combined.year = dateComponents.year
|
||
combined.month = dateComponents.month
|
||
combined.day = dateComponents.day
|
||
combined.hour = timeComponents.hour
|
||
combined.minute = timeComponents.minute
|
||
|
||
return calendar.date(from: combined)!
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Algoritmo de Autocompletado
|
||
|
||
### Pseudocódigo
|
||
|
||
```python
|
||
def autocomplete(week_plan, available_dishes, tags_rules):
|
||
"""
|
||
Rellena slots vacíos respetando reglas de tags.
|
||
|
||
Args:
|
||
week_plan: WeekPlan actual
|
||
available_dishes: [Dish] todos los platos del usuario
|
||
tags_rules: {tag_id: TagRules} reglas de cada tag
|
||
|
||
Returns:
|
||
WeekPlan actualizado
|
||
"""
|
||
empty_slots = week_plan.get_empty_slots()
|
||
|
||
for slot in empty_slots:
|
||
# Filtrar platos válidos
|
||
candidates = filter_valid_dishes(
|
||
dishes=available_dishes,
|
||
slot=slot,
|
||
current_plan=week_plan,
|
||
rules=tags_rules
|
||
)
|
||
|
||
if len(candidates) == 0:
|
||
continue # dejar slot vacío
|
||
|
||
# Priorizar platos menos usados esta semana
|
||
dish = weighted_random_pick(candidates, week_plan)
|
||
week_plan.assign(slot, dish)
|
||
|
||
return week_plan
|
||
|
||
|
||
def filter_valid_dishes(dishes, slot, current_plan, rules):
|
||
"""Filtra platos que no violen reglas."""
|
||
valid = []
|
||
|
||
for dish in dishes:
|
||
if not violates_rules(dish, slot, current_plan, rules):
|
||
valid.append(dish)
|
||
|
||
return valid
|
||
|
||
|
||
def violates_rules(dish, slot, current_plan, rules):
|
||
"""
|
||
Verifica si asignar este plato al slot viola alguna regla.
|
||
|
||
Returns:
|
||
True si viola alguna regla, False si es válido
|
||
"""
|
||
for tag_id in dish.tags:
|
||
tag_rule = rules[tag_id]
|
||
|
||
# Regla 1: Max por semana
|
||
if tag_rule.maxPerWeek is not None:
|
||
count = current_plan.count_tag_usage(tag_id)
|
||
if count >= tag_rule.maxPerWeek:
|
||
return True
|
||
|
||
# Regla 2: No consecutivo
|
||
if tag_rule.noConsecutive:
|
||
adjacent_slots = current_plan.get_adjacent_slots(slot)
|
||
for adj_slot in adjacent_slots:
|
||
if adj_slot.dish and tag_id in adj_slot.dish.tags:
|
||
return True
|
||
|
||
# Regla 3: No duplicar en el mismo día
|
||
if tag_rule.noDuplicateInDay:
|
||
same_day_slots = current_plan.get_same_day_slots(slot)
|
||
for sd_slot in same_day_slots:
|
||
if sd_slot.dish and tag_id in sd_slot.dish.tags:
|
||
return True
|
||
|
||
# Regla 4: Restricción de tipo de comida
|
||
if tag_rule.mealTypeRestriction is not None:
|
||
if slot.mealType != tag_rule.mealTypeRestriction:
|
||
return True
|
||
|
||
return False
|
||
|
||
|
||
def weighted_random_pick(candidates, current_plan):
|
||
"""
|
||
Elige un plato aleatoriamente, priorizando los menos usados.
|
||
|
||
Lógica:
|
||
- Platos con 0 usos: peso 3
|
||
- Platos con 1 uso: peso 2
|
||
- Platos con 2+ usos: peso 1
|
||
"""
|
||
weights = []
|
||
for dish in candidates:
|
||
usage_count = current_plan.count_dish_usage(dish.id)
|
||
|
||
if usage_count == 0:
|
||
weight = 3
|
||
elif usage_count == 1:
|
||
weight = 2
|
||
else:
|
||
weight = 1
|
||
|
||
weights.append(weight)
|
||
|
||
# Random weighted choice
|
||
import random
|
||
return random.choices(candidates, weights=weights)[0]
|
||
```
|
||
|
||
---
|
||
|
||
### Helpers
|
||
|
||
```swift
|
||
extension WeekPlan {
|
||
func getEmptySlots() -> [MealSlot] {
|
||
slots.filter { $0.dish == nil }
|
||
}
|
||
|
||
func countTagUsage(_ tagId: UUID) -> Int {
|
||
var count = 0
|
||
for slot in slots {
|
||
if let dishId = slot.dish,
|
||
let dish = DishRepository.shared.getDish(by: dishId),
|
||
dish.tags.contains(tagId) {
|
||
count += 1
|
||
}
|
||
}
|
||
return count
|
||
}
|
||
|
||
func countDishUsage(_ dishId: UUID) -> Int {
|
||
slots.filter { $0.dish == dishId }.count
|
||
}
|
||
|
||
func getAdjacentSlots(_ slot: MealSlot) -> [MealSlot] {
|
||
var adjacent: [MealSlot] = []
|
||
|
||
// Día anterior, mismo tipo de comida
|
||
if slot.dayOfWeek > 0 {
|
||
if let prevSlot = slots.first(where: {
|
||
$0.dayOfWeek == slot.dayOfWeek - 1 && $0.mealType == slot.mealType
|
||
}) {
|
||
adjacent.append(prevSlot)
|
||
}
|
||
}
|
||
|
||
// Día siguiente, mismo tipo de comida
|
||
if slot.dayOfWeek < 6 {
|
||
if let nextSlot = slots.first(where: {
|
||
$0.dayOfWeek == slot.dayOfWeek + 1 && $0.mealType == slot.mealType
|
||
}) {
|
||
adjacent.append(nextSlot)
|
||
}
|
||
}
|
||
|
||
return adjacent
|
||
}
|
||
|
||
func getSameDaySlots(_ slot: MealSlot) -> [MealSlot] {
|
||
slots.filter {
|
||
$0.dayOfWeek == slot.dayOfWeek && $0.id != slot.id
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Sincronización y Persistencia
|
||
|
||
### SwiftData (iOS 17+)
|
||
|
||
#### Configuración
|
||
|
||
```swift
|
||
import SwiftData
|
||
|
||
@main
|
||
struct MealMoodApp: App {
|
||
var body: some Scene {
|
||
WindowGroup {
|
||
ContentView()
|
||
}
|
||
.modelContainer(for: [
|
||
Settings.self,
|
||
Tag.self,
|
||
Dish.self,
|
||
WeekPlan.self,
|
||
MealSlot.self
|
||
])
|
||
}
|
||
}
|
||
```
|
||
|
||
#### Modelo de Datos
|
||
|
||
```swift
|
||
@Model
|
||
final class Settings {
|
||
var mealWindows: String // "dinnerOnly", "lunchOnly", "both"
|
||
var includeWeekends: Bool
|
||
var language: String // "spanish", "english"
|
||
|
||
var calendarId: String?
|
||
var syncEnabled: Bool
|
||
var lunchTime: Date
|
||
var dinnerTime: Date
|
||
var eventDuration: Int
|
||
var eventPrefix: String
|
||
var reminderMinutesBefore: Int?
|
||
|
||
var isPremium: Bool
|
||
|
||
init() {
|
||
self.mealWindows = "dinnerOnly"
|
||
self.includeWeekends = true
|
||
self.language = "spanish"
|
||
self.calendarId = nil
|
||
self.syncEnabled = false
|
||
self.lunchTime = Date() // set to 14:00
|
||
self.dinnerTime = Date() // set to 21:00
|
||
self.eventDuration = 60
|
||
self.eventPrefix = "🍽️"
|
||
self.reminderMinutesBefore = nil
|
||
self.isPremium = false
|
||
}
|
||
}
|
||
|
||
@Model
|
||
final class Tag {
|
||
var id: UUID
|
||
var name: String
|
||
var color: String
|
||
var maxPerWeek: Int?
|
||
var noConsecutive: Bool
|
||
var noDuplicateInDay: Bool
|
||
var mealTypeRestriction: String? // "lunch", "dinner", nil
|
||
|
||
init(id: UUID = UUID(), name: String, color: String, maxPerWeek: Int? = nil, noConsecutive: Bool = false, noDuplicateInDay: Bool = false, mealTypeRestriction: String? = nil) {
|
||
self.id = id
|
||
self.name = name
|
||
self.color = color
|
||
self.maxPerWeek = maxPerWeek
|
||
self.noConsecutive = noConsecutive
|
||
self.noDuplicateInDay = noDuplicateInDay
|
||
self.mealTypeRestriction = mealTypeRestriction
|
||
}
|
||
}
|
||
|
||
@Model
|
||
final class Dish {
|
||
var id: UUID
|
||
var name: String
|
||
var descriptionText: String?
|
||
var tagIds: [UUID]
|
||
var createdAt: Date
|
||
|
||
init(id: UUID = UUID(), name: String, descriptionText: String? = nil, tagIds: [UUID] = [], createdAt: Date = Date()) {
|
||
self.id = id
|
||
self.name = name
|
||
self.descriptionText = descriptionText
|
||
self.tagIds = tagIds
|
||
self.createdAt = createdAt
|
||
}
|
||
}
|
||
|
||
@Model
|
||
final class WeekPlan {
|
||
var id: UUID
|
||
var weekStartDate: Date
|
||
var createdAt: Date
|
||
var updatedAt: Date
|
||
|
||
@Relationship(deleteRule: .cascade)
|
||
var slots: [MealSlot]
|
||
|
||
init(id: UUID = UUID(), weekStartDate: Date, slots: [MealSlot] = [], createdAt: Date = Date(), updatedAt: Date = Date()) {
|
||
self.id = id
|
||
self.weekStartDate = weekStartDate
|
||
self.slots = slots
|
||
self.createdAt = createdAt
|
||
self.updatedAt = updatedAt
|
||
}
|
||
}
|
||
|
||
@Model
|
||
final class MealSlot {
|
||
var id: UUID
|
||
var dayOfWeek: Int
|
||
var mealType: String // "lunch", "dinner"
|
||
var dishId: UUID?
|
||
var calendarEventId: String?
|
||
|
||
init(id: UUID = UUID(), dayOfWeek: Int, mealType: String, dishId: UUID? = nil, calendarEventId: String? = nil) {
|
||
self.id = id
|
||
self.dayOfWeek = dayOfWeek
|
||
self.mealType = mealType
|
||
self.dishId = dishId
|
||
self.calendarEventId = calendarEventId
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### CloudKit (Sincronización iCloud)
|
||
|
||
#### Configuración
|
||
|
||
1. Habilitar CloudKit en Xcode:
|
||
- Target → Signing & Capabilities → + Capability → iCloud.
|
||
- Activar "CloudKit".
|
||
- Crear container (ej: `iCloud.com.mealMood.app`).
|
||
|
||
2. Configurar SwiftData para usar CloudKit:
|
||
```swift
|
||
@main
|
||
struct MealMoodApp: App {
|
||
var body: some Scene {
|
||
WindowGroup {
|
||
ContentView()
|
||
}
|
||
.modelContainer(
|
||
for: [Settings.self, Tag.self, Dish.self, WeekPlan.self, MealSlot.self],
|
||
isStoredInMemoryOnly: false,
|
||
isAutosaveEnabled: true,
|
||
isUndoEnabled: false,
|
||
cloudKitContainerIdentifier: "iCloud.com.mealMood.app"
|
||
)
|
||
}
|
||
}
|
||
```
|
||
|
||
**Comportamiento**:
|
||
- Datos se sincronizan automáticamente entre dispositivos del mismo usuario.
|
||
- Sincronización en background (no requiere acción del usuario).
|
||
- Conflictos resueltos por "last write wins" (por defecto).
|
||
|
||
---
|
||
|
||
## Monetización
|
||
|
||
### Free Tier
|
||
|
||
**Limitaciones**:
|
||
- Máximo 20 platos.
|
||
- Solo 10 tags predefinidos (no se pueden crear custom).
|
||
- Solo reglas básicas (max/semana, no consecutivo, no mismo día).
|
||
- Banner AdMob en parte inferior de Home.
|
||
|
||
**AdMob Configuración**:
|
||
```swift
|
||
import GoogleMobileAds
|
||
|
||
struct AdBannerView: UIViewRepresentable {
|
||
func makeUIView(context: Context) -> GADBannerView {
|
||
let banner = GADBannerView(adSize: GADAdSizeBanner)
|
||
banner.adUnitID = "ca-app-pub-XXXXXXXXXXXXXXXX/XXXXXXXXXX" // reemplazar con tu ID
|
||
banner.rootViewController = UIApplication.shared.windows.first?.rootViewController
|
||
banner.load(GADRequest())
|
||
return banner
|
||
}
|
||
|
||
func updateUIView(_ uiView: GADBannerView, context: Context) {}
|
||
}
|
||
```
|
||
|
||
**Ubicación**:
|
||
- Home → parte inferior, sticky.
|
||
- Background: #FFF9F5 (mismo color que la app).
|
||
- Altura: 50px.
|
||
- NO en otras pantallas (Settings, gestión de platos, etc.).
|
||
|
||
---
|
||
|
||
### Premium Tier
|
||
|
||
**Precio**:
|
||
- Mensual: $2.99/mes.
|
||
- Anual: $19.99/año (ahorro 44%).
|
||
|
||
**Beneficios**:
|
||
- Sin publicidad.
|
||
- Platos ilimitados.
|
||
- Tags custom ilimitados.
|
||
- (Futuro) Reglas avanzadas.
|
||
- (Futuro) Múltiples planificaciones guardadas.
|
||
- (Futuro) Exportar a lista de compra.
|
||
- (Futuro) Analytics/histórico.
|
||
|
||
**IAP Configuración**:
|
||
```swift
|
||
import StoreKit
|
||
|
||
class StoreManager: ObservableObject {
|
||
@Published var products: [Product] = []
|
||
@Published var isPremium: Bool = false
|
||
|
||
private let productIds = [
|
||
"com.mealmood.premium.monthly",
|
||
"com.mealmood.premium.yearly"
|
||
]
|
||
|
||
init() {
|
||
Task {
|
||
await loadProducts()
|
||
await checkPremiumStatus()
|
||
}
|
||
}
|
||
|
||
func loadProducts() async {
|
||
do {
|
||
products = try await Product.products(for: productIds)
|
||
} catch {
|
||
print("Failed to load products: \(error)")
|
||
}
|
||
}
|
||
|
||
func purchase(_ product: Product) async {
|
||
do {
|
||
let result = try await product.purchase()
|
||
|
||
switch result {
|
||
case .success(let verification):
|
||
switch verification {
|
||
case .verified(let transaction):
|
||
await transaction.finish()
|
||
isPremium = true
|
||
case .unverified:
|
||
break
|
||
}
|
||
case .userCancelled, .pending:
|
||
break
|
||
@unknown default:
|
||
break
|
||
}
|
||
} catch {
|
||
print("Purchase failed: \(error)")
|
||
}
|
||
}
|
||
|
||
func restorePurchases() async {
|
||
do {
|
||
try await AppStore.sync()
|
||
await checkPremiumStatus()
|
||
} catch {
|
||
print("Restore failed: \(error)")
|
||
}
|
||
}
|
||
|
||
private func checkPremiumStatus() async {
|
||
for await result in Transaction.currentEntitlements {
|
||
if case .verified(let transaction) = result,
|
||
productIds.contains(transaction.productID) {
|
||
isPremium = true
|
||
return
|
||
}
|
||
}
|
||
isPremium = false
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Stack Técnico
|
||
|
||
### Plataforma
|
||
- **iOS**: mínimo iOS 17.0 (para SwiftData).
|
||
- **Dispositivos**: iPhone (optimizado para todos los tamaños).
|
||
- **Orientación**: Portrait only (landscape no necesario para MVP).
|
||
|
||
### Lenguajes y Frameworks
|
||
- **Swift**: 5.9+.
|
||
- **SwiftUI**: para toda la UI.
|
||
- **SwiftData**: persistencia local.
|
||
- **EventKit**: integración con calendario nativo.
|
||
- **CloudKit**: sincronización iCloud.
|
||
- **StoreKit 2**: IAP (In-App Purchases).
|
||
|
||
### Dependencias Externas
|
||
- **Google Mobile Ads SDK**: para AdMob.
|
||
- Instalación: Swift Package Manager (SPM) o CocoaPods.
|
||
- URL: `https://github.com/googleads/swift-package-manager-google-mobile-ads`
|
||
|
||
### Arquitectura
|
||
- **MVVM** (Model-View-ViewModel):
|
||
- **Models**: Settings, Tag, Dish, WeekPlan, MealSlot (SwiftData models).
|
||
- **ViewModels**: HomeViewModel, DishViewModel, SettingsViewModel, etc.
|
||
- **Views**: SwiftUI views.
|
||
|
||
### Testing
|
||
- **Unit tests**: lógica de algoritmo de autocompletado, validación de reglas.
|
||
- **UI tests**: flujos críticos (onboarding, drag & drop, crear plato).
|
||
|
||
---
|
||
|
||
## Plan de Implementación (Sprints)
|
||
|
||
### Sprint 1: Setup e Infraestructura (1 semana)
|
||
- Configurar proyecto Xcode.
|
||
- Configurar SwiftData models.
|
||
- Configurar CloudKit.
|
||
- Crear paleta de colores y componentes UI reutilizables.
|
||
- Setup Google AdMob.
|
||
|
||
**Entregables**:
|
||
- Proyecto compilable con estructura de carpetas.
|
||
- Modelos de datos persistentes.
|
||
- Componentes UI básicos (buttons, cards, pills).
|
||
|
||
---
|
||
|
||
### Sprint 2: Onboarding (1 semana)
|
||
- Pantalla de bienvenida.
|
||
- 4 pasos de configuración inicial.
|
||
- Navegación fluida entre pasos.
|
||
- Crear tags predefinidos en primera carga.
|
||
|
||
**Entregables**:
|
||
- Onboarding completo y funcional.
|
||
- Usuario puede completar setup inicial.
|
||
|
||
---
|
||
|
||
### Sprint 3: Gestión de Platos (1 semana)
|
||
- Pantalla de lista de platos.
|
||
- Crear/editar/eliminar platos.
|
||
- Selector de tags (modal).
|
||
- Validaciones de formulario.
|
||
|
||
**Entregables**:
|
||
- Usuario puede crear y gestionar platos.
|
||
- Lista de platos persistente.
|
||
|
||
---
|
||
|
||
### Sprint 4: Pantalla Principal (Calendario) (2 semanas)
|
||
- Layout de calendario semanal.
|
||
- Slots vacíos y llenos.
|
||
- Drawer de platos.
|
||
- Drag & drop básico (sin validaciones).
|
||
- Navegación entre semanas.
|
||
|
||
**Entregables**:
|
||
- Pantalla Home funcional.
|
||
- Usuario puede asignar platos manualmente.
|
||
|
||
---
|
||
|
||
### Sprint 5: Validación de Reglas y Drag & Drop Avanzado (1 semana)
|
||
- Implementar lógica de validación de reglas.
|
||
- Feedback visual en drag & drop (bordes verdes/rojos).
|
||
- Haptic feedback.
|
||
- Animaciones.
|
||
|
||
**Entregables**:
|
||
- Drag & drop valida reglas en tiempo real.
|
||
- Feedback visual y táctil funcional.
|
||
|
||
---
|
||
|
||
### Sprint 6: Autocompletado (1 semana)
|
||
- Implementar algoritmo de autocompletado.
|
||
- Animación secuencial al rellenar slots.
|
||
- Confetti y toast.
|
||
- Botón "Resetear" con confirmación.
|
||
|
||
**Entregables**:
|
||
- Botón "Completar" funcional.
|
||
- Usuario puede autocompletar semana respetando reglas.
|
||
|
||
---
|
||
|
||
### Sprint 7: Gestión de Tags (1 semana)
|
||
- Pantalla de lista de tags.
|
||
- Editar reglas de tags.
|
||
- Localización de tags predefinidos.
|
||
|
||
**Entregables**:
|
||
- Usuario puede modificar reglas de tags.
|
||
- Cambios se aplican al algoritmo.
|
||
|
||
---
|
||
|
||
### Sprint 8: Sincronización con Calendario (1 semana)
|
||
- Solicitar permiso de EventKit.
|
||
- Crear/eliminar eventos al asignar/quitar platos.
|
||
- Configuración en Settings (horarios, prefijo, etc.).
|
||
- Actualizar eventos al cambiar hora.
|
||
|
||
**Entregables**:
|
||
- Sincronización con calendario funcional.
|
||
- Eventos visibles en app Calendario nativa.
|
||
|
||
---
|
||
|
||
### Sprint 9: Settings y Multiidioma (1 semana)
|
||
- Pantalla de Settings completa.
|
||
- Cambio de idioma (español/inglés).
|
||
- Localización de toda la app.
|
||
- Persistencia de configuración.
|
||
|
||
**Entregables**:
|
||
- Settings funcional.
|
||
- App totalmente bilingüe.
|
||
|
||
---
|
||
|
||
### Sprint 10: IAP y Premium (1 semana)
|
||
- Configurar StoreKit.
|
||
- Pantalla de Premium con planes.
|
||
- Compra y restauración.
|
||
- Limitar features en Free.
|
||
|
||
**Entregables**:
|
||
- Usuario puede comprar Premium.
|
||
- Features Free/Premium funcionan correctamente.
|
||
|
||
---
|
||
|
||
### Sprint 11: Testing y QA (1 semana)
|
||
- Unit tests para algoritmo.
|
||
- UI tests para flujos críticos.
|
||
- Bug fixing.
|
||
- Performance optimization.
|
||
|
||
**Entregables**:
|
||
- App estable, sin bugs críticos.
|
||
- Cobertura de tests >70%.
|
||
|
||
---
|
||
|
||
### Sprint 12: Polish y Lanzamiento (1 semana)
|
||
- Ajustes finales de UI/UX.
|
||
- App Store assets (screenshots, descripción, keywords).
|
||
- Beta testing con TestFlight.
|
||
- Envío a App Store.
|
||
|
||
**Entregables**:
|
||
- App publicada en App Store.
|
||
|
||
---
|
||
|
||
## Anexos
|
||
|
||
### A. Glosario
|
||
|
||
- **Slot**: espacio en el calendario donde se asigna una comida (comida o cena de un día específico).
|
||
- **Tag**: etiqueta asociada a un plato (ej: Carne, Pescado, Sin Gluten).
|
||
- **Tag Rule**: regla asociada a un tag (ej: máximo 3 veces por semana).
|
||
- **Autocompletado**: algoritmo que rellena slots vacíos respetando reglas.
|
||
- **Drag & Drop**: interacción de arrastrar un plato y soltarlo en un slot.
|
||
- **EventKit**: framework de iOS para interactuar con el calendario nativo.
|
||
- **SwiftData**: framework de Apple para persistencia local (iOS 17+).
|
||
- **CloudKit**: servicio de Apple para sincronización en la nube.
|
||
- **IAP**: In-App Purchase (compra dentro de la app).
|
||
|
||
---
|
||
|
||
### B. Referencias Técnicas
|
||
|
||
#### SwiftUI Drag & Drop
|
||
- Documentación: https://developer.apple.com/documentation/swiftui/view/ondrag(_:)
|
||
- Tutorial: https://www.hackingwithswift.com/quick-start/swiftui/how-to-support-drag-and-drop-in-swiftui
|
||
|
||
#### EventKit
|
||
- Documentación: https://developer.apple.com/documentation/eventkit
|
||
- Tutorial: https://www.hackingwithswift.com/example-code/system/how-to-add-events-to-the-users-calendar-using-eventkit
|
||
|
||
#### SwiftData
|
||
- Documentación: https://developer.apple.com/documentation/swiftdata
|
||
- Tutorial: https://www.hackingwithswift.com/quick-start/swiftdata
|
||
|
||
#### StoreKit 2
|
||
- Documentación: https://developer.apple.com/documentation/storekit
|
||
- Tutorial: https://www.hackingwithswift.com/quick-start/storekit
|
||
|
||
#### Google Mobile Ads
|
||
- Documentación: https://developers.google.com/admob/ios/quick-start
|
||
- Setup: https://developers.google.com/admob/ios/quick-start
|
||
|
||
---
|
||
|
||
### C. Notas de Implementación
|
||
|
||
#### Consideraciones de Performance
|
||
- **SwiftData queries**: usar `@Query` con predicates para filtrar eficientemente.
|
||
- **Drag & Drop**: mantener 60fps, usar `LazyVStack` en lista de platos.
|
||
- **Algoritmo de autocompletado**: ejecutar en background thread (`Task`), no bloquear UI.
|
||
- **Sincronización CloudKit**: usar queue de operaciones para batch updates.
|
||
|
||
#### Consideraciones de UX
|
||
- **Feedback inmediato**: haptic feedback en cada interacción importante.
|
||
- **Animaciones suaves**: spring animations (0.3-0.5s duration).
|
||
- **Loading states**: mostrar ProgressView durante operaciones lentas.
|
||
- **Empty states**: ilustraciones amigables, no solo texto.
|
||
- **Error handling**: mostrar alerts/toasts amigables, no errores técnicos.
|
||
|
||
#### Consideraciones de Accesibilidad
|
||
- **VoiceOver**: etiquetar todos los elementos interactivos.
|
||
- **Dynamic Type**: usar `@ScaledMetric` para tamaños de fuente.
|
||
- **Color Contrast**: asegurar ratio mínimo 4.5:1 (WCAG AA).
|
||
- **Haptic Feedback**: opcional, respetar configuración del sistema.
|
||
|
||
---
|
||
|
||
### D. Checklist Pre-Lanzamiento
|
||
|
||
#### Funcionalidad
|
||
- [ ] Onboarding completo y sin bugs.
|
||
- [ ] Crear/editar/eliminar platos funcional.
|
||
- [ ] Drag & drop fluido (60fps).
|
||
- [ ] Validación de reglas correcta.
|
||
- [ ] Autocompletado respeta reglas.
|
||
- [ ] Sincronización con calendario funcional.
|
||
- [ ] Settings guarda cambios correctamente.
|
||
- [ ] Multiidioma (ES/EN) sin strings faltantes.
|
||
- [ ] IAP (compra y restauración) funcional.
|
||
- [ ] AdMob muestra ads correctamente.
|
||
|
||
#### Testing
|
||
- [ ] Unit tests para algoritmo (>80% cobertura).
|
||
- [ ] UI tests para flujos críticos.
|
||
- [ ] Testeo manual en iPhone SE, iPhone 15 Pro, iPhone 15 Pro Max.
|
||
- [ ] Beta testing con 10+ usuarios externos (TestFlight).
|
||
- [ ] Performance: app carga en <2s, drag & drop sin lag.
|
||
- [ ] Memoria: no memory leaks (Instruments).
|
||
|
||
#### App Store
|
||
- [ ] Screenshots en 3 tamaños (6.7", 6.5", 5.5").
|
||
- [ ] Video preview (opcional, recomendado).
|
||
- [ ] Descripción optimizada (keywords: meal planner, family meals, weekly menu).
|
||
- [ ] Privacy policy URL.
|
||
- [ ] Support URL.
|
||
- [ ] Categoría: Food & Drink.
|
||
- [ ] Keywords: meal planner, weekly menu, family meals, meal organizer, recipe planner.
|
||
|
||
#### Legal
|
||
- [ ] Privacy policy publicada (mencionar EventKit, CloudKit, AdMob).
|
||
- [ ] Terms of Service (opcional, recomendado).
|
||
- [ ] App Store Review Guidelines (revisar compliance).
|
||
|
||
---
|
||
|
||
## Fin del PRD
|
||
|
||
**Versión**: 1.0
|
||
**Fecha**: 2025-02-16
|
||
**Autor**: Alex + Claude
|
||
**Próxima revisión**: Post-Sprint 6 (evaluar ajustes de MVP)
|
||
|
||
---
|
||
|
||
## Notas para Claude Code
|
||
|
||
Este PRD está diseñado para ser consumido por Claude Code. Contiene:
|
||
|
||
1. **Especificaciones completas** de funcionalidades MVP y Freemium.
|
||
2. **Modelo de datos** con Swift structs/classes.
|
||
3. **Ejemplos de código** para componentes clave (drag & drop, EventKit, SwiftData).
|
||
4. **Wireframes textuales** detallados de cada pantalla.
|
||
5. **Paleta de colores** y componentes UI reutilizables.
|
||
6. **Plan de implementación** en sprints secuenciales.
|
||
|
||
**Instrucciones para Claude Code**:
|
||
- Implementar MVP siguiendo orden de sprints.
|
||
- Usar SwiftUI + SwiftData (iOS 17+).
|
||
- Priorizar UX fluida (animaciones, haptic feedback).
|
||
- Código limpio, comentado, siguiendo Swift style guide.
|
||
- Tests unitarios para lógica crítica (algoritmo, validaciones).
|
||
|
||
**Contacto**: Si hay dudas o ambigüedades, consultar con el product owner (Alex).
|
||
|
||
---
|
||
|
||
¡Buena suerte con la implementación! 🚀✨
|