This guide explains how to extend Caluru with new screens, models, services, and features.
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/
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)
}
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()
}
}
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()
}
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
}
Edit CaluruApp.swift:
let schema = Schema([
UserProfile.self,
FoodItem.self,
Recipe.self,
DailyLog.self,
WeeklyPlan.self,
ShoppingList.self,
WeightEntry.self,
YourModel.self, // Add here
])
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),
]
}
Delete app from simulator and run again to trigger fresh seed.
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
}
@Observable
class SomeViewModel {
func doCalculation() {
let result = YourService.calculate(from: myData)
// Use result
}
func doAsyncWork() {
Task {
let results = await YourService.fetchSomething()
// Update state
}
}
}
Goal: Let users mark foods as favorites and view them in a list.
Edit Models/FoodItem.swift:
@Model
final class FoodItem {
// ... existing fields
var isFavorite: Bool = false // Add field
}
// 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)) ?? []
}
}
// 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)
}
}
// 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)
}
}
}
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)
}
Add to tab bar or as menu option in existing screen.
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)
}
YourComponent(
title: "Mi opciΓ³n",
subtitle: "DescripciΓ³n opcional",
icon: "heart.fill"
) {
// Handle tap
}
PascalCase + View suffix (e.g., HomeView)PascalCase + ViewModel suffixPascalCase + Service suffixPascalCase (no suffix for SwiftData models)PascalCase (descriptive name)// 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 { ... }
@State for local view state@Observable for ViewModelsModelContext via @Environment// In ViewModel (already on MainActor)
@MainActor @Observable
class MyViewModel {
func doAsync() {
Task {
let result = await SomeService.fetch()
self.data = result // Safe on MainActor
}
}
}
#Preview {
RecipeCard(recipe: Recipe(
title: "Test Recipe",
mealType: .comida,
calories: 500,
protein: 30,
carbs: 40,
fat: 20
)) {}
.padding()
.background(AppColors.background)
}
#Preview {
ContentView()
.modelContainer(for: [
UserProfile.self,
Recipe.self
], inMemory: true)
}
Symptom: Runtime crash on model access
Fix: Add to schema in CaluruApp.swift
Symptom: βModifying state during view updateβ warning
Fix: Use .task, .onAppear, or button actions
Symptom: Swift 6 concurrency errors
Fix: Mark ViewModels with @MainActor
Symptom: Nested data not persisting
Fix: Ensure all nested types are Codable
SUPABASE_URL and SUPABASE_ANON_KEY (Info.plist or xcconfig). Deploy Edge Function openai-proxy with OPENAI_API_KEY; see Backend.cloudKitDatabase: .private("iCloud.com.agarcia.caluru"); ensure capability is enabled.supabase/functions/<name>/index.ts; deploy with supabase functions deploy <name> --verify-jwt if auth required.