From a5c304e209b010600ec72ec8bb23cf19e953ade5 Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Sun, 12 Jul 2026 17:41:46 +0200 Subject: [PATCH] 2.0: Dish gains optional ingredients + photo; on-device ingredient generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dish: ingredients [String] (default [], never required) and photoData (externalStorage). Additive SwiftData migration — existing stores unaffected. - ICloudSyncService: sync ingredients (optional in payload so pre-2.0 snapshots still decode); photos deliberately excluded from KV sync (1MB limit). - IngredientGenerator: Foundation Models (iOS 26+) generates localized ingredient lines from the dish name, fully on-device. isAvailable gates the UI affordance on older OS/devices; failures record to Crashlytics and return []. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BkanrydYtrme8wipTzWssG --- MealMood.xcodeproj/project.pbxproj | 4 ++ MealMood/Models/Dish.swift | 15 ++++- MealMood/Services/ICloudSyncService.swift | 9 ++- MealMood/Services/IngredientGenerator.swift | 68 +++++++++++++++++++++ 4 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 MealMood/Services/IngredientGenerator.swift diff --git a/MealMood.xcodeproj/project.pbxproj b/MealMood.xcodeproj/project.pbxproj index 3e2af11..866e91e 100644 --- a/MealMood.xcodeproj/project.pbxproj +++ b/MealMood.xcodeproj/project.pbxproj @@ -74,6 +74,7 @@ CD174669421FE1FD4315F6E0 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BB0D740B79B9E129A3C2F382 /* WidgetKit.framework */; }; CD2ADCDADFEC2D562E23D8E6 /* AutocompleteEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7AB2ECCA7D330E50AC549E /* AutocompleteEngine.swift */; }; D2840F9D92D16026338CE83C /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7C00EED19123F00AE87FF24 /* AppSettings.swift */; }; + D39BED40CA59849F181D80B5 /* IngredientGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71F0ADA350289AE410D572A7 /* IngredientGenerator.swift */; }; D8149D55E74F7BBFC0402F46 /* DishFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63CA2A8666E1FBBAD65F141 /* DishFormView.swift */; }; D89413095E37C3D677856437 /* PremiumAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0200C0C11C912711C724558 /* PremiumAccess.swift */; }; DE43F08AAC939C083BF511C1 /* FirstDishesStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10594EA55F6BEFF4241D364C /* FirstDishesStepView.swift */; }; @@ -152,6 +153,7 @@ 6D718B2E4F7E6D22E502F0A6 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; 6EA1948B5DAEEEA09B78C8A9 /* SecondaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryButton.swift; sourceTree = ""; }; 70D140A6E835CA6227C2D4D9 /* WelcomeStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeStepView.swift; sourceTree = ""; }; + 71F0ADA350289AE410D572A7 /* IngredientGenerator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IngredientGenerator.swift; sourceTree = ""; }; 7603187713D390BD4D35770C /* AutocompleteEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteEngineTests.swift; sourceTree = ""; }; 76719058DD3A4177B2B4163E /* CalendarStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarStepView.swift; sourceTree = ""; }; 797A41D292142240FBDF9F2A /* MealSlot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealSlot.swift; sourceTree = ""; }; @@ -499,6 +501,7 @@ A6FC2153490D53B80DBDC034 /* WidgetDataStore.swift */, 928E6D4F6D78AD1840245FB4 /* PremiumSyncService.swift */, A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.swift */, + 71F0ADA350289AE410D572A7 /* IngredientGenerator.swift */, ); path = Services; sourceTree = ""; @@ -737,6 +740,7 @@ 443CC3997A9A4818B51066FB /* StatsView.swift in Sources */, B4D6F8012C3E5F7A91B3D5F7 /* FeedbackStore.swift in Sources */, 2124FD777418A4EEDF64BDC3 /* WeekReadyStepView.swift in Sources */, + D39BED40CA59849F181D80B5 /* IngredientGenerator.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/MealMood/Models/Dish.swift b/MealMood/Models/Dish.swift index f7b136b..a8f36e5 100644 --- a/MealMood/Models/Dish.swift +++ b/MealMood/Models/Dish.swift @@ -10,13 +10,24 @@ final class Dish { var createdAt: Date var isPriority: Bool = false + /// Optional ingredient lines (one per entry, e.g. "200g spaghetti"). + /// Populated lazily — manually or via on-device generation — and only for + /// the dishes a user actually shops for. Never required to create a dish. + var ingredients: [String] = [] + + /// Optional dish photo. Stored outside the main store to keep it light and + /// CloudKit-friendly. + @Attribute(.externalStorage) var photoData: Data? + init( id: UUID = UUID(), name: String, descriptionText: String? = nil, tagIds: [UUID] = [], createdAt: Date = Date(), - isPriority: Bool = false + isPriority: Bool = false, + ingredients: [String] = [], + photoData: Data? = nil ) { self.id = id self.name = name @@ -24,6 +35,8 @@ final class Dish { self.tagIds = tagIds self.createdAt = createdAt self.isPriority = isPriority + self.ingredients = ingredients + self.photoData = photoData } } diff --git a/MealMood/Services/ICloudSyncService.swift b/MealMood/Services/ICloudSyncService.swift index 5511c78..138cac6 100644 --- a/MealMood/Services/ICloudSyncService.swift +++ b/MealMood/Services/ICloudSyncService.swift @@ -94,7 +94,8 @@ final class ICloudSyncService { name: $0.name, descriptionText: $0.descriptionText, tagIds: $0.tagIds, - createdAt: $0.createdAt + createdAt: $0.createdAt, + ingredients: $0.ingredients ) }, weekPlans: plans.map { plan in @@ -181,7 +182,8 @@ final class ICloudSyncService { name: payload.name, descriptionText: payload.descriptionText, tagIds: payload.tagIds, - createdAt: payload.createdAt + createdAt: payload.createdAt, + ingredients: payload.ingredients ?? [] ) context.insert(dish) } @@ -261,6 +263,9 @@ private struct DishPayload: Codable { let descriptionText: String? let tagIds: [UUID] let createdAt: Date + // Optional for backward-compatibility with pre-2.0 snapshots. Photos are not + // synced through the KV store (size limits) — they ride CloudKit instead. + let ingredients: [String]? } private struct WeekPlanPayload: Codable { diff --git a/MealMood/Services/IngredientGenerator.swift b/MealMood/Services/IngredientGenerator.swift new file mode 100644 index 0000000..e476b58 --- /dev/null +++ b/MealMood/Services/IngredientGenerator.swift @@ -0,0 +1,68 @@ +import Foundation +#if canImport(FoundationModels) +import FoundationModels +#endif + +/// On-device ingredient generation from a dish name using Apple's Foundation +/// Models (iOS 26+, Apple Intelligence). Fully local — no network, no API key, +/// private. On older OS versions or unsupported devices it simply reports +/// `isAvailable == false` and callers hide the "Generate" affordance. +enum IngredientGenerator { + + /// Whether on-device generation can run right now on this device. + static var isAvailable: Bool { + #if canImport(FoundationModels) + if #available(iOS 26.0, *) { + if case .available = SystemLanguageModel.default.availability { return true } + } + #endif + return false + } + + /// Generates ingredient lines for a dish, localized to `language`. + /// Returns an empty array when unavailable or on failure (never throws). + static func generate(dishName: String, language: AppLanguage) async -> [String] { + let trimmed = dishName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + + #if canImport(FoundationModels) + if #available(iOS 26.0, *), case .available = SystemLanguageModel.default.availability { + let langName = language.resolved().displayName + let instructions = """ + You are a helpful cooking assistant. Given a dish name, list the \ + typical grocery ingredients needed to cook it for a family of four. \ + Output one ingredient per line with a rough quantity (e.g. \ + "500 g minced beef"). No numbering, no bullet symbols, no headings, \ + no extra commentary. Write the ingredients in \(langName). + """ + do { + let session = LanguageModelSession(instructions: instructions) + let response = try await session.respond(to: "Dish: \(trimmed)") + return parseLines(response.content) + } catch { + CrashlyticsService.record(error, context: "ingredient_generation") + return [] + } + } + #endif + return [] + } + + /// Normalizes raw model output into clean ingredient lines, stripping any + /// stray bullets or numbering the model may still emit. + static func parseLines(_ text: String) -> [String] { + text + .split(whereSeparator: \.isNewline) + .map { line -> String in + var s = line.trimmingCharacters(in: .whitespaces) + for bullet in ["- ", "• ", "* ", "– ", "— "] where s.hasPrefix(bullet) { + s.removeFirst(bullet.count) + } + if let range = s.range(of: #"^\d+[\.\)]\s+"#, options: .regularExpression) { + s.removeSubrange(range) + } + return s.trimmingCharacters(in: .whitespaces) + } + .filter { !$0.isEmpty } + } +}