This document describes the business logic services, their responsibilities, and public APIs.
| Service | File | Purpose |
|---|---|---|
NutritionCalculator |
NutritionCalculator.swift |
Macro/calorie calculations |
SeedDataService |
SeedDataService.swift |
First-launch data seeding |
ShoppingListBuilder |
ShoppingListBuilder.swift |
Generate shopping list from plan |
| AI / Backend | ||
AIAssistantService |
AssistantResponseService.swift |
GPT-4o-mini chat via Supabase openai-proxy; parses suggested actions |
OpenAIProxyService |
OpenAIProxyService.swift |
HTTP client for Supabase Edge Function openai-proxy (JWT required) |
OpenFoodFactsService |
OpenFoodFactsService.swift |
Barcode lookup (public API) |
FoodRecognitionService |
FoodRecognitionService.swift |
Photo → GPT-4o Vision via openai-proxy |
MockFoodRecognitionService |
MockFoodRecognitionService.swift |
Fallback/offline barcode/photo mock |
| Auth / Supabase | ||
SupabaseManager |
SupabaseClient.swift |
Shared Supabase client (url + anon key) |
AuthService |
AuthService.swift |
Sign in with Apple, session restore, signOut |
| Onboarding / Profile | ||
TargetCalculator |
TargetCalculator.swift |
Mifflin-St Jeor; CalculatedTargets from OnboardingState |
MealPlanGenerator |
MealPlanGenerator.swift |
Auto-generate weekly meal plan from preferences |
ProgressiveProfilingService |
ProgressiveProfilingService.swift |
Post-onboarding profile cards (meal timing, dislikes, etc.) |
OnboardingAnalytics |
OnboardingAnalytics.swift |
Onboarding analytics (if used) |
| Other | ||
NotificationService |
NotificationService.swift |
Meal, goal, streak, weight reminders |
AccountResetService |
AccountResetService.swift |
Reset data / re-onboard |
RecipeService |
RecipeService.swift |
Recipe CRUD / cloud (if used) |
File: Services/NutritionCalculator.swift
Pure calculation service for nutrition aggregation, progress tracking, and recipe suggestions.
struct NutritionSummary {
let calories: Int
let protein: Double
let carbs: Double
let fat: Double
static let zero: NutritionSummary
static func + (lhs: NutritionSummary, rhs: NutritionSummary) -> NutritionSummary
}
struct NutritionProgress {
let consumed: NutritionSummary
let target: NutritionSummary
var calorieProgress: Double // 0.0 - 1.0+
var proteinProgress: Double
var carbsProgress: Double
var fatProgress: Double
var remainingCalories: Int
var remainingProtein: Double
// etc.
}
| Method | Parameters | Returns | Description |
|---|---|---|---|
calculate(from:) |
DailyLog |
NutritionSummary |
Sum all logged entries |
calculate(from:servings:) |
Recipe, Double |
NutritionSummary |
Scale recipe macros |
calculate(from:servings:) |
FoodItem, Double |
NutritionSummary |
Scale food macros |
calculateProgress(consumed:profile:) |
NutritionSummary, UserProfile |
NutritionProgress |
Compare consumed vs target |
calculateWeeklyTotals(from:) |
WeeklyPlan |
NutritionSummary |
Sum all planned meals |
suggestRecipe(from:remainingCalories:remainingProtein:) |
[Recipe], Int, Double |
Recipe? |
Find best fit for remaining budget |
// In HomeViewModel
let summary = NutritionCalculator.calculate(from: dailyLog)
let progress = NutritionCalculator.calculateProgress(consumed: summary, profile: userProfile)
print("Calorie progress: \(progress.calorieProgress * 100)%")
print("Remaining: \(progress.remainingCalories) kcal")
DailyLog, Recipe, FoodItem, UserProfile, WeeklyPlanFile: Services/SeedDataService.swift
Populate initial data on first app launch. Creates user profile, recipes, food items, and empty containers.
| Method | Parameters | Returns | Description |
|---|---|---|---|
seedIfNeeded(context:) |
ModelContext |
Void |
Check if empty, seed if needed |
static func seedIfNeeded(context: ModelContext) {
// 1. Check for existing UserProfile
let existingProfiles = try? context.fetch(FetchDescriptor<UserProfile>())
guard existingProfiles?.isEmpty == true else {
print("Seed data already exists, skipping...")
return
}
// 2. Create profile
context.insert(createUserProfile())
// 3. Create recipes (20+)
for recipe in createRecipes() {
context.insert(recipe)
}
// 4. Create food items (45+)
for food in createFoodItems() {
context.insert(food)
}
// 5. Create empty containers
context.insert(DailyLog(date: Date()))
context.insert(WeeklyPlan())
context.insert(ShoppingList())
try? context.save()
}
UserProfile(
calorieTarget: 3500,
proteinTarget: 190,
carbsTarget: 250,
fatTarget: 85,
language: "es"
)
createUserProfile() -> UserProfilecreateRecipes() -> [Recipe]createFoodItems() -> [FoodItem]ModelContextFile: Services/ShoppingListBuilder.swift
Generate consolidated shopping list from weekly meal plan ingredients.
| Method | Parameters | Returns | Description |
|---|---|---|---|
build(from:) |
WeeklyPlan |
[ShoppingListItem] |
Extract & aggregate ingredients |
groupByCategory(_:) |
[ShoppingListItem] |
[FoodCategory: [ShoppingListItem]] |
Group by food category |
categoryOrder |
— | [FoodCategory] |
Display order for categories |
updateShoppingList(_:from:) |
ShoppingList, WeeklyPlan |
Void |
Replace list items from plan |
static func build(from plan: WeeklyPlan) -> [ShoppingListItem] {
var ingredientMap: [String: (quantity: Double, unit: String, category: FoodCategory)] = [:]
for day in plan.days {
// Process breakfast, lunch, dinner recipes
if let recipe = day.breakfastRecipe {
aggregateIngredients(recipe.ingredients, into: &ingredientMap)
}
// ... lunch, dinner
}
// Convert map to items, sorted alphabetically
return ingredientMap.map { key, value in
ShoppingListItem(name: key, quantity: value.quantity, ...)
}.sorted { $0.name < $1.name }
}
Ingredients with same name + unit are combined:
let key = "\(ingredient.name.lowercased())_\(ingredient.unit.lowercased())"
// "huevos_unidades" → quantities are summed
// In ShoppingListViewModel
func regenerateFromPlan(context: ModelContext) {
guard let plan = weeklyPlan, let list = shoppingList else { return }
ShoppingListBuilder.updateShoppingList(list, from: plan)
try? context.save()
}
WeeklyPlan, PlannedDay, Ingredient, ShoppingListItem, FoodCategoryFile: Services/MockFoodRecognitionService.swift
Fallback/offline mock for barcode and food photo recognition. Production scan uses: barcode → OpenFoodFactsService; photo → FoodRecognitionService (GPT-4o Vision via proxy).
struct RecognizedFood {
let name: String
let confidence: Double // 0.0 - 1.0
let calories: Int
let protein: Double
let carbs: Double
let fat: Double
let servingSize: String
}
| Method | Parameters | Returns | Description |
|---|---|---|---|
recognizeBarcode(_:) |
String |
async RecognizedFood? |
Lookup barcode in mock DB |
recognizeImage() |
— | async [RecognizedFood] |
Return random mock foods |
toFoodItem(_:) |
RecognizedFood |
FoodItem |
Convert to saveable FoodItem |
let database: [String: RecognizedFood] = [
"1234567890123": RecognizedFood(name: "Proteína en polvo", ...),
"0987654321098": RecognizedFood(name: "Yogur griego natural", ...),
// Unknown barcodes return generic product
]
static func recognizeImage() async -> [RecognizedFood] {
// Simulate 1.5s processing delay
try? await Task.sleep(nanoseconds: 1_500_000_000)
// Return 1-3 random foods from preset list
let possibleFoods = [
RecognizedFood(name: "Pollo a la plancha", confidence: 0.92, ...),
RecognizedFood(name: "Ensalada mixta", confidence: 0.88, ...),
// ... more options
]
return Array(possibleFoods.shuffled().prefix(Int.random(in: 1...3)))
}
// In ScanViewModel
func handleBarcodeDetected(_ barcode: String) {
scanState = .processing
Task {
if let food = await MockFoodRecognitionService.recognizeBarcode(barcode) {
self.recognizedFood = food
self.scanState = .found(food)
} else {
self.scanState = .error("No se encontró el producto")
}
}
}
FoodItem, FoodCategoryFile: Services/AssistantResponseService.swift (class name: AIAssistantService)
Generate AI chat responses via OpenAI GPT-4o-mini through Supabase Edge Function openai-proxy. Builds user_context from UserProfile and DailyLog; sends conversation history + user message; server injects locale-aware system prompt with safety/refusal rules and runs OpenAI Moderation on input/output. Parses response and ActionParser.parseActions(from: content) for suggested actions (logMeal, createRecipe, generateMealPlan, adjustTarget, etc.).
struct ChatMessage: Identifiable {
let id: UUID
let content: String
let isUser: Bool
let timestamp: Date
}
| Method | Parameters | Returns | Description |
|---|---|---|---|
generateResponse(for:conversationHistory:profile:dailyLog:recipes:) |
message, history, profile, dailyLog, recipes | async throws AIResponse |
Calls OpenAI via OpenAIProxyService; returns message + suggestedActions |
| (Initial greeting is set in AssistantViewModel) | — | — | — |
AIResponse: message: String, suggestedActions: [AssistantAction]
<<<CALURU_ACTIONS … CALURU_ACTIONS>>> JSON block (required fields per action type). No keyword/asterisk triggers.[AssistantAction]. Incomplete or keyword-only replies produce no chips.// In AssistantViewModel
let aiResponse = try await AIAssistantService.shared.generateResponse(
for: trimmedText,
conversationHistory: currentMessages,
profile: profile,
dailyLog: log,
recipes: currentRecipes
)
assistantMessage.content = aiResponse.message
assistantMessage.suggestedActions = aiResponse.suggestedActions
DailyLog, UserProfile, RecipeFile: Services/OpenAIProxyService.swift
Sends POST requests to Supabase Edge Function openai-proxy with the user’s JWT. The proxy forwards to OpenAI Chat Completions with the server’s OPENAI_API_KEY. Used by AIAssistantService (chat) and FoodRecognitionService (GPT-4o Vision).
request(body: [String: Any]) async throws -> Data – Requires valid Supabase session; sets Authorization: Bearer <accessToken>, apikey, Content-Type; returns response data or throws ProxyError (invalidConfiguration, invalidResponse, apiError).File: Services/AuthService.swift
Sign in with Apple and Supabase session management. @Observable singleton; exposes isSignedIn, userEmail, isLoading.
restoreSession() async – Restores session from Supabase; updates isSignedIn, userEmail.generateNonce(), sha256(_:) – For Apple Sign In.handleAppleSignIn(result:nonce:) async – Handles ASAuthorization result; sends idToken + nonce to Supabase signInWithIdToken(provider: .apple, ...).signOut() async – Signs out from Supabase and clears local state.| Service | Called From | Trigger |
|---|---|---|
NutritionCalculator |
HomeViewModel, AI/Assistant flows |
Load data, calculate progress |
SeedDataService |
MainTabView.task |
App launch |
ShoppingListBuilder |
ShoppingListViewModel |
“Actualizar” button |
OpenFoodFactsService |
ScanViewModel |
Barcode detected |
FoodRecognitionService |
ScanViewModel |
Photo capture (GPT-4o Vision) |
AIAssistantService |
AssistantViewModel |
Send message |
OpenAIProxyService |
AIAssistantService, FoodRecognitionService |
All OpenAI API calls |
AuthService |
CaluruApp, Profile, SignInView |
Restore session; Sign in with Apple; signOut |
TargetCalculator |
OnboardingViewModel |
Targets from onboarding state |
ProgressiveProfilingService |
MainTabView |
Check triggers; show/dismiss cards |
NotificationService |
CaluruApp, NotificationsSettingsView |
Schedule/cancel reminders |
AccountResetService |
Profile | Reset data / re-onboard |
openai-proxy (OpenAI API key server-side only). See Backend.