Caluru

Caluru - Development Guide

This guide explains how to extend Caluru with new screens, models, services, and features.


πŸ“ File Organization Conventions

Caluru/
β”œβ”€β”€ Models/           # SwiftData @Model classes + Codable structs
β”œβ”€β”€ Views/            # SwiftUI views, organized by feature
β”‚   └── FeatureName/
β”‚       β”œβ”€β”€ FeatureView.swift
β”‚       └── FeatureSubView.swift
β”œβ”€β”€ ViewModels/       # @Observable classes (one per major screen)
β”œβ”€β”€ Services/         # Business logic (static methods)
β”œβ”€β”€ DesignSystem/     # Colors, typography, reusable components
β”‚   └── Components/

πŸ–₯ How to Add a New Screen

Step 1: Create the View

Create Views/YourFeature/YourFeatureView.swift:

import SwiftUI
import SwiftData

struct YourFeatureView: View {
    @Environment(\.modelContext) private var modelContext
    @State private var viewModel = YourFeatureViewModel()
    
    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: AppSpacing.lg) {
                    // Your UI components
                }
                .padding(.vertical, AppSpacing.md)
                .padding(.bottom, AppSpacing.tabBarHeight)
            }
            .background(AppColors.background)
            .navigationTitle("")
            .toolbar {
                ToolbarItem(placement: .topBarLeading) {
                    Text("Tu Pantalla")
                        .font(AppTypography.title1)
                        .foregroundColor(AppColors.textPrimary)
                }
            }
        }
        .task {
            viewModel.loadData(context: modelContext)
        }
    }
}

#Preview {
    YourFeatureView()
        .modelContainer(for: [/* models */], inMemory: true)
}

Step 2: Create the ViewModel

Create ViewModels/YourFeatureViewModel.swift:

import Foundation
import SwiftData
import SwiftUI

@Observable
class YourFeatureViewModel {
    // State
    var items: [YourModel] = []
    var isLoading: Bool = false
    var showSheet: Bool = false
    
    // MARK: - Data Loading
    
    func loadData(context: ModelContext) {
        let descriptor = FetchDescriptor<YourModel>(
            sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
        )
        items = (try? context.fetch(descriptor)) ?? []
    }
    
    // MARK: - Actions
    
    func doSomething(context: ModelContext) {
        // Modify data
        try? context.save()
    }
}

Step 3: Add to Navigation

For tab navigation, edit MainTabView.swift:

enum Tab {
    case home, plan, scan, recipes, list, yourFeature  // Add case
}

TabView(selection: $selectedTab) {
    // ... existing tabs
    
    YourFeatureView()
        .tag(Tab.yourFeature)
}

// Add TabBarButton in CustomTabBar
TabBarButton(
    icon: "star.fill",
    label: "Nuevo",
    isSelected: selectedTab == .yourFeature
) {
    selectedTab = .yourFeature
}

For push navigation, use NavigationStack:

@State private var showYourFeature = false

NavigationLink(destination: YourFeatureView()) {
    Text("Go to Feature")
}

// Or programmatically
.navigationDestination(isPresented: $showYourFeature) {
    YourFeatureView()
}

For modal sheets:

.sheet(isPresented: $showYourFeature) {
    YourFeatureView()
}

πŸ“¦ How to Add a New Model

Step 1: Create the Model File

Create Models/YourModel.swift:

import Foundation
import SwiftData

@Model
final class YourModel {
    var id: UUID
    var name: String
    var value: Int
    var createdAt: Date
    
    // For nested Codable data
    var detailsData: Data?
    
    var details: [YourDetail] {
        get {
            guard let data = detailsData else { return [] }
            return (try? JSONDecoder().decode([YourDetail].self, from: data)) ?? []
        }
        set {
            detailsData = try? JSONEncoder().encode(newValue)
        }
    }
    
    init(
        id: UUID = UUID(),
        name: String,
        value: Int = 0,
        details: [YourDetail] = [],
        createdAt: Date = Date()
    ) {
        self.id = id
        self.name = name
        self.value = value
        self.detailsData = try? JSONEncoder().encode(details)
        self.createdAt = createdAt
    }
}

// Nested Codable struct
struct YourDetail: Codable, Identifiable {
    var id: UUID = UUID()
    var label: String
    var amount: Double
}

Step 2: Register in ModelContainer

Edit CaluruApp.swift:

let schema = Schema([
    UserProfile.self,
    FoodItem.self,
    Recipe.self,
    DailyLog.self,
    WeeklyPlan.self,
    ShoppingList.self,
    WeightEntry.self,
    YourModel.self,  // Add here
])

Step 3: Add Seed Data (if needed)

Edit Services/SeedDataService.swift:

static func seedIfNeeded(context: ModelContext) {
    // ... existing code
    
    // Create your model instances
    let yourItems = createYourModels()
    for item in yourItems {
        context.insert(item)
    }
    
    try? context.save()
}

private static func createYourModels() -> [YourModel] {
    [
        YourModel(name: "Example 1", value: 100),
        YourModel(name: "Example 2", value: 200),
    ]
}

Step 4: Reset Data to Test

Delete app from simulator and run again to trigger fresh seed.


βš™οΈ How to Add a New Service

Step 1: Create the Service File

Create Services/YourService.swift:

import Foundation

class YourService {
    
    // MARK: - Public API
    
    /// Brief description of what this does
    static func calculate(from data: SomeModel) -> SomeResult {
        // Implementation
    }
    
    /// Async method example
    static func fetchSomething() async -> [SomeResult] {
        // Simulate delay for async operations
        try? await Task.sleep(nanoseconds: 500_000_000)
        return []
    }
    
    // MARK: - Private Helpers
    
    private static func helperMethod() { }
}

// Supporting types (if any)
struct SomeResult {
    let value: Int
}

Step 2: Use in ViewModel

@Observable
class SomeViewModel {
    func doCalculation() {
        let result = YourService.calculate(from: myData)
        // Use result
    }
    
    func doAsyncWork() {
        Task {
            let results = await YourService.fetchSomething()
            // Update state
        }
    }
}

πŸš€ How to Add a Feature End-to-End

Example: β€œFavorites” Feature

Goal: Let users mark foods as favorites and view them in a list.

Step 1: Update Model

Edit Models/FoodItem.swift:

@Model
final class FoodItem {
    // ... existing fields
    var isFavorite: Bool = false  // Add field
}

Step 2: Create Service (if complex logic needed)

// Services/FavoritesService.swift
class FavoritesService {
    static func getFavorites(from context: ModelContext) -> [FoodItem] {
        let descriptor = FetchDescriptor<FoodItem>(
            predicate: #Predicate { $0.isFavorite == true }
        )
        return (try? context.fetch(descriptor)) ?? []
    }
}

Step 3: Create ViewModel

// ViewModels/FavoritesViewModel.swift
@Observable
class FavoritesViewModel {
    var favorites: [FoodItem] = []
    
    func loadFavorites(context: ModelContext) {
        favorites = FavoritesService.getFavorites(from: context)
    }
    
    func toggleFavorite(_ item: FoodItem, context: ModelContext) {
        item.isFavorite.toggle()
        try? context.save()
        loadFavorites(context: context)
    }
}

Step 4: Create View

// Views/Favorites/FavoritesView.swift
struct FavoritesView: View {
    @Environment(\.modelContext) private var modelContext
    @State private var viewModel = FavoritesViewModel()
    
    var body: some View {
        NavigationStack {
            List(viewModel.favorites) { food in
                HStack {
                    Text(food.name)
                    Spacer()
                    Button {
                        viewModel.toggleFavorite(food, context: modelContext)
                    } label: {
                        Image(systemName: "heart.fill")
                    }
                }
            }
            .navigationTitle("Favoritos")
        }
        .task {
            viewModel.loadFavorites(context: modelContext)
        }
    }
}

Step 5: Add Toggle in Existing UI

Edit AddFoodSheet.swift to show favorite button:

// In FoodItemRow
Button {
    food.isFavorite.toggle()
    try? modelContext.save()
} label: {
    Image(systemName: food.isFavorite ? "heart.fill" : "heart")
        .foregroundColor(.red)
}

Step 6: Add Navigation

Add to tab bar or as menu option in existing screen.


🎨 How to Add a UI Component

Step 1: Create Component

Create DesignSystem/Components/YourComponent.swift:

import SwiftUI

struct YourComponent: View {
    // Configuration
    let title: String
    var subtitle: String? = nil
    var icon: String? = nil
    let action: () -> Void
    
    var body: some View {
        Button(action: action) {
            HStack(spacing: AppSpacing.sm) {
                if let icon = icon {
                    Image(systemName: icon)
                        .font(.system(size: AppSpacing.iconMedium))
                        .foregroundColor(AppColors.accentGreen)
                }
                
                VStack(alignment: .leading, spacing: 2) {
                    Text(title)
                        .font(AppTypography.bodyMedium)
                        .foregroundColor(AppColors.textPrimary)
                    
                    if let subtitle = subtitle {
                        Text(subtitle)
                            .font(AppTypography.caption)
                            .foregroundColor(AppColors.textSecondary)
                    }
                }
                
                Spacer()
                
                Image(systemName: "chevron.right")
                    .foregroundColor(AppColors.textTertiary)
            }
            .padding(AppSpacing.cardPadding)
            .background(AppColors.cardBackground)
            .clipShape(RoundedRectangle(cornerRadius: AppSpacing.radiusCard))
            .cardShadow()
        }
        .buttonStyle(.plain)
    }
}

#Preview {
    VStack {
        YourComponent(title: "Example", subtitle: "Description", icon: "star") {}
        YourComponent(title: "Simple") {}
    }
    .padding()
    .background(AppColors.background)
}

Step 2: Use in Views

YourComponent(
    title: "Mi opciΓ³n",
    subtitle: "DescripciΓ³n opcional",
    icon: "heart.fill"
) {
    // Handle tap
}

πŸ“ Code Style Conventions

Naming

File Organization

// 1. Imports
import SwiftUI
import SwiftData

// 2. Main type
struct MyView: View {
    // 2a. Environment
    @Environment(\.modelContext) private var modelContext
    
    // 2b. State
    @State private var viewModel = MyViewModel()
    
    // 2c. Props
    let data: SomeData
    
    // 2d. Body
    var body: some View { ... }
    
    // 2e. Private methods
    private func helperMethod() { ... }
}

// 3. Supporting types
struct SubComponent: View { ... }

// 4. Preview
#Preview { ... }

SwiftUI Best Practices

Async Patterns

// In ViewModel (already on MainActor)
@MainActor @Observable
class MyViewModel {
    func doAsync() {
        Task {
            let result = await SomeService.fetch()
            self.data = result  // Safe on MainActor
        }
    }
}

πŸ§ͺ Testing Patterns

Preview with Mock Data

#Preview {
    RecipeCard(recipe: Recipe(
        title: "Test Recipe",
        mealType: .comida,
        calories: 500,
        protein: 30,
        carbs: 40,
        fat: 20
    )) {}
    .padding()
    .background(AppColors.background)
}

In-Memory ModelContainer

#Preview {
    ContentView()
        .modelContainer(for: [
            UserProfile.self,
            Recipe.self
        ], inMemory: true)
}

⚠️ Common Pitfalls

1. Forgetting to Register Model

Symptom: Runtime crash on model access Fix: Add to schema in CaluruApp.swift

2. Mutating State in Body

Symptom: β€œModifying state during view update” warning Fix: Use .task, .onAppear, or button actions

3. Missing @MainActor

Symptom: Swift 6 concurrency errors Fix: Mark ViewModels with @MainActor

4. JSON Encoding Failures

Symptom: Nested data not persisting Fix: Ensure all nested types are Codable


πŸ”Œ Backend / External