a5c304e209
- 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
69 lines
2.9 KiB
Swift
69 lines
2.9 KiB
Swift
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 }
|
||
}
|
||
}
|