8e4d0c2d89
- Dictado por voz (Speech/SFSpeechRecognizer, on-device) en el nombre de plato y en ingredientes, con transcripción en vivo. - Un solo texto dictado se separa en ingredientes individuales vía Apple Foundation Models on-device (IngredientParser), con fallback heurístico. - Permisos de micrófono y reconocimiento de voz en Info.plist. - Cadenas de dictado en los 6 idiomas. - iPad/Mac: el calendario reclama ~50% de la altura disponible en lugar de quedar fijo a ~240px; iPhone (compact) sin cambios. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A3HaWmmtTQ1vdTERtSYU6p
62 lines
2.8 KiB
Swift
62 lines
2.8 KiB
Swift
import Foundation
|
|
#if canImport(FoundationModels)
|
|
import FoundationModels
|
|
#endif
|
|
|
|
/// Splits a single free-form phrase (typically dictated, e.g. "two onions, half
|
|
/// a litre of milk and some olive oil") into individual, normalized ingredient
|
|
/// lines. Uses Apple's on-device Foundation Models when available for smart
|
|
/// parsing of quantities and natural-language separators; otherwise falls back
|
|
/// to a lightweight heuristic splitter. Fully local and never throws.
|
|
enum IngredientParser {
|
|
|
|
/// Parses `text` into clean ingredient lines, kept in `language`.
|
|
static func parse(_ text: String, language: AppLanguage) async -> [String] {
|
|
let trimmed = text.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 split a spoken grocery list into individual ingredients. The user \
|
|
dictates several ingredients in one sentence, separated by commas, the \
|
|
word "and", or natural pauses. Output ONE ingredient per line, keeping \
|
|
any quantity the user actually said (e.g. "2 onions", "500 g flour"). \
|
|
Do not invent ingredients, do not add quantities that were not said, \
|
|
do not number or bullet the lines, and add no commentary. Keep the \
|
|
ingredients written in \(langName).
|
|
"""
|
|
do {
|
|
let session = LanguageModelSession(instructions: instructions)
|
|
let response = try await session.respond(to: trimmed)
|
|
let lines = IngredientGenerator.parseLines(response.content)
|
|
if !lines.isEmpty { return lines }
|
|
} catch {
|
|
CrashlyticsService.record(error, context: "ingredient_parsing")
|
|
}
|
|
}
|
|
#endif
|
|
|
|
return heuristicSplit(trimmed)
|
|
}
|
|
|
|
/// Splits on newlines, commas, semicolons and standalone conjunctions
|
|
/// ("and"/"y"/"e"/"et"/"und") when the on-device model is unavailable.
|
|
static func heuristicSplit(_ text: String) -> [String] {
|
|
let separators = CharacterSet(charactersIn: ",;\n•")
|
|
let conjunctions = [" y ", " e ", " and ", " et ", " und "]
|
|
return text
|
|
.components(separatedBy: separators)
|
|
.flatMap { piece -> [String] in
|
|
var parts = [piece]
|
|
for conjunction in conjunctions {
|
|
parts = parts.flatMap { $0.components(separatedBy: conjunction) }
|
|
}
|
|
return parts
|
|
}
|
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
.filter { !$0.isEmpty }
|
|
}
|
|
}
|