Files
FamilyMealPlanner/MealMood/ViewModels/DishViewModel.swift
T
alexandrev-tibco 4ffa15b06a 2.0: CloudKit private-database sync replaces KV snapshot sync
Foundation for 2.1 family sharing (CKShare needs these models + container).
SwiftData cannot share across Apple IDs today, so 2.0 ships true multi-device
sync for the same account instead:

- Models made CloudKit-compatible: dropped @Attribute(.unique) on all 5,
  inline defaults on every attribute, WeekPlan.slots stored as optional
  relationship (name preserved → lightweight migration) with non-optional
  slotList facade; ~73 call sites renamed.
- Container: cloudKitDatabase .automatic, falling back to the local-only
  store when CloudKit is unavailable; failures recorded to Crashlytics.
- Entitlements: iCloud CloudKit service (container already existed);
  remote-notification background mode for push-driven sync.
- Legacy KV snapshot sync (ICloudSyncService) stays inert when CloudKit is
  active — kept only for 1.x devices.
- DeduplicationService collapses cross-device duplicates deterministically on
  launch (settings singleton, default tags with tagId remapping, same-week
  plans).
- Note: AppSettings.isPremium now syncs across same-Apple-ID devices; StoreKit
  (PremiumSyncService + Transaction.updates) remains the source of truth and
  reconciles on every launch/foreground.

All 21 unit tests pass, including ICloudSyncPremiumIsolationTests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkanrydYtrme8wipTzWssG
2026-07-12 18:13:45 +02:00

130 lines
4.3 KiB
Swift

import SwiftUI
import SwiftData
@MainActor
final class DishViewModel: ObservableObject {
@Published var name: String = ""
@Published var descriptionText: String = ""
@Published var selectedTagIds: Set<UUID> = []
@Published var isPriority: Bool = false
@Published var ingredients: [String] = []
@Published var photoData: Data?
@Published var isGeneratingIngredients: Bool = false
@Published var showTagSelector: Bool = false
@Published var showDeleteAlert: Bool = false
@Published var showAssignedDeleteAlert: Bool = false
var editingDish: Dish?
var isEditing: Bool { editingDish != nil }
var isValid: Bool {
!name.trimmingCharacters(in: .whitespaces).isEmpty
}
func loadDish(_ dish: Dish) {
editingDish = dish
name = dish.name
descriptionText = dish.descriptionText ?? ""
selectedTagIds = Set(dish.tagIds)
isPriority = dish.isPriority
ingredients = dish.ingredients
photoData = dish.photoData
}
func reset() {
editingDish = nil
name = ""
descriptionText = ""
selectedTagIds = []
isPriority = false
ingredients = []
photoData = nil
}
/// Generates ingredient lines on-device from the dish name and replaces
/// the current draft list. No-op when unavailable or the name is empty.
func generateIngredients(language: AppLanguage) {
guard !isGeneratingIngredients, isValid else { return }
isGeneratingIngredients = true
let dishName = name
Task {
let lines = await IngredientGenerator.generate(dishName: dishName, language: language)
await MainActor.run {
self.isGeneratingIngredients = false
guard !lines.isEmpty else { return }
self.ingredients = lines
PremiumAccess.recordIngredientGeneration()
AnalyticsService.logIngredientsGenerated(lineCount: lines.count, source: "dish_editor")
}
}
}
func save(context: ModelContext) {
let trimmedName = name.trimmingCharacters(in: .whitespaces)
guard !trimmedName.isEmpty else { return }
let cleanIngredients = ingredients
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
if let dish = editingDish {
dish.name = trimmedName
dish.descriptionText = descriptionText.isEmpty ? nil : descriptionText
dish.tagIds = Array(selectedTagIds)
dish.isPriority = isPriority
dish.ingredients = cleanIngredients
dish.photoData = photoData
} else {
let dish = Dish(
name: trimmedName,
descriptionText: descriptionText.isEmpty ? nil : descriptionText,
tagIds: Array(selectedTagIds),
isPriority: isPriority,
ingredients: cleanIngredients,
photoData: photoData
)
context.insert(dish)
AnalyticsService.logDishAdded(tagCount: selectedTagIds.count)
}
try? context.save()
reset()
}
func deleteDish(context: ModelContext) -> Bool {
guard let dish = editingDish else { return false }
if isDishAssignedInCurrentWeek(dishId: dish.id, context: context) {
showAssignedDeleteAlert = true
return false
}
context.delete(dish)
try? context.save()
AnalyticsService.logDishDeleted()
reset()
return true
}
func canDelete(currentPlan: WeekPlan?) -> Bool {
guard let dish = editingDish, let plan = currentPlan else { return true }
return !plan.slotList.contains { $0.dishId == dish.id }
}
private func isDishAssignedInCurrentWeek(dishId: UUID, context: ModelContext) -> Bool {
let currentWeekStart = Date().startOfWeek()
let descriptor = FetchDescriptor<WeekPlan>(
predicate: #Predicate<WeekPlan> { plan in
plan.weekStartDate == currentWeekStart
}
)
guard let currentPlan = try? context.fetch(descriptor).first else {
return false
}
return currentPlan.slotList.contains { $0.dishId == dishId }
}
}