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:
alexandrev-tibco
2026-07-12 17:41:46 +02:00
parent c6c33d8143
commit a5c304e209
4 changed files with 93 additions and 3 deletions
+14 -1
View File
@@ -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
}
}
+7 -2
View File
@@ -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 {
@@ -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 }
}
}