2.0: Dish gains optional ingredients + photo; on-device ingredient generation
- 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BkanrydYtrme8wipTzWssG
This commit is contained in:
@@ -74,6 +74,7 @@
|
|||||||
CD174669421FE1FD4315F6E0 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BB0D740B79B9E129A3C2F382 /* WidgetKit.framework */; };
|
CD174669421FE1FD4315F6E0 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BB0D740B79B9E129A3C2F382 /* WidgetKit.framework */; };
|
||||||
CD2ADCDADFEC2D562E23D8E6 /* AutocompleteEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7AB2ECCA7D330E50AC549E /* AutocompleteEngine.swift */; };
|
CD2ADCDADFEC2D562E23D8E6 /* AutocompleteEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7AB2ECCA7D330E50AC549E /* AutocompleteEngine.swift */; };
|
||||||
D2840F9D92D16026338CE83C /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7C00EED19123F00AE87FF24 /* AppSettings.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 */; };
|
D8149D55E74F7BBFC0402F46 /* DishFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63CA2A8666E1FBBAD65F141 /* DishFormView.swift */; };
|
||||||
D89413095E37C3D677856437 /* PremiumAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0200C0C11C912711C724558 /* PremiumAccess.swift */; };
|
D89413095E37C3D677856437 /* PremiumAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0200C0C11C912711C724558 /* PremiumAccess.swift */; };
|
||||||
DE43F08AAC939C083BF511C1 /* FirstDishesStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10594EA55F6BEFF4241D364C /* FirstDishesStepView.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 = "<group>"; };
|
6D718B2E4F7E6D22E502F0A6 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
||||||
6EA1948B5DAEEEA09B78C8A9 /* SecondaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryButton.swift; sourceTree = "<group>"; };
|
6EA1948B5DAEEEA09B78C8A9 /* SecondaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryButton.swift; sourceTree = "<group>"; };
|
||||||
70D140A6E835CA6227C2D4D9 /* WelcomeStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeStepView.swift; sourceTree = "<group>"; };
|
70D140A6E835CA6227C2D4D9 /* WelcomeStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeStepView.swift; sourceTree = "<group>"; };
|
||||||
|
71F0ADA350289AE410D572A7 /* IngredientGenerator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IngredientGenerator.swift; sourceTree = "<group>"; };
|
||||||
7603187713D390BD4D35770C /* AutocompleteEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteEngineTests.swift; sourceTree = "<group>"; };
|
7603187713D390BD4D35770C /* AutocompleteEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteEngineTests.swift; sourceTree = "<group>"; };
|
||||||
76719058DD3A4177B2B4163E /* CalendarStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarStepView.swift; sourceTree = "<group>"; };
|
76719058DD3A4177B2B4163E /* CalendarStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarStepView.swift; sourceTree = "<group>"; };
|
||||||
797A41D292142240FBDF9F2A /* MealSlot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealSlot.swift; sourceTree = "<group>"; };
|
797A41D292142240FBDF9F2A /* MealSlot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealSlot.swift; sourceTree = "<group>"; };
|
||||||
@@ -499,6 +501,7 @@
|
|||||||
A6FC2153490D53B80DBDC034 /* WidgetDataStore.swift */,
|
A6FC2153490D53B80DBDC034 /* WidgetDataStore.swift */,
|
||||||
928E6D4F6D78AD1840245FB4 /* PremiumSyncService.swift */,
|
928E6D4F6D78AD1840245FB4 /* PremiumSyncService.swift */,
|
||||||
A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.swift */,
|
A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.swift */,
|
||||||
|
71F0ADA350289AE410D572A7 /* IngredientGenerator.swift */,
|
||||||
);
|
);
|
||||||
path = Services;
|
path = Services;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -737,6 +740,7 @@
|
|||||||
443CC3997A9A4818B51066FB /* StatsView.swift in Sources */,
|
443CC3997A9A4818B51066FB /* StatsView.swift in Sources */,
|
||||||
B4D6F8012C3E5F7A91B3D5F7 /* FeedbackStore.swift in Sources */,
|
B4D6F8012C3E5F7A91B3D5F7 /* FeedbackStore.swift in Sources */,
|
||||||
2124FD777418A4EEDF64BDC3 /* WeekReadyStepView.swift in Sources */,
|
2124FD777418A4EEDF64BDC3 /* WeekReadyStepView.swift in Sources */,
|
||||||
|
D39BED40CA59849F181D80B5 /* IngredientGenerator.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,13 +10,24 @@ final class Dish {
|
|||||||
var createdAt: Date
|
var createdAt: Date
|
||||||
var isPriority: Bool = false
|
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(
|
init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
name: String,
|
name: String,
|
||||||
descriptionText: String? = nil,
|
descriptionText: String? = nil,
|
||||||
tagIds: [UUID] = [],
|
tagIds: [UUID] = [],
|
||||||
createdAt: Date = Date(),
|
createdAt: Date = Date(),
|
||||||
isPriority: Bool = false
|
isPriority: Bool = false,
|
||||||
|
ingredients: [String] = [],
|
||||||
|
photoData: Data? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
@@ -24,6 +35,8 @@ final class Dish {
|
|||||||
self.tagIds = tagIds
|
self.tagIds = tagIds
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
self.isPriority = isPriority
|
self.isPriority = isPriority
|
||||||
|
self.ingredients = ingredients
|
||||||
|
self.photoData = photoData
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,8 @@ final class ICloudSyncService {
|
|||||||
name: $0.name,
|
name: $0.name,
|
||||||
descriptionText: $0.descriptionText,
|
descriptionText: $0.descriptionText,
|
||||||
tagIds: $0.tagIds,
|
tagIds: $0.tagIds,
|
||||||
createdAt: $0.createdAt
|
createdAt: $0.createdAt,
|
||||||
|
ingredients: $0.ingredients
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
weekPlans: plans.map { plan in
|
weekPlans: plans.map { plan in
|
||||||
@@ -181,7 +182,8 @@ final class ICloudSyncService {
|
|||||||
name: payload.name,
|
name: payload.name,
|
||||||
descriptionText: payload.descriptionText,
|
descriptionText: payload.descriptionText,
|
||||||
tagIds: payload.tagIds,
|
tagIds: payload.tagIds,
|
||||||
createdAt: payload.createdAt
|
createdAt: payload.createdAt,
|
||||||
|
ingredients: payload.ingredients ?? []
|
||||||
)
|
)
|
||||||
context.insert(dish)
|
context.insert(dish)
|
||||||
}
|
}
|
||||||
@@ -261,6 +263,9 @@ private struct DishPayload: Codable {
|
|||||||
let descriptionText: String?
|
let descriptionText: String?
|
||||||
let tagIds: [UUID]
|
let tagIds: [UUID]
|
||||||
let createdAt: Date
|
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 {
|
private struct WeekPlanPayload: Codable {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user