2.0: breakfast and snack meal types across the app
- MealType gains breakfast/snack (chronological order drives slot/row order). - AppSettings.activeMealTypes: single source of truth for enabled meals, backed by new optional enabledMealTypesRaw with legacy mealWindows fallback — additive migration, keeps pre-2.0 installs and iCloud snapshots working. Legacy field kept coherent by the setter. time(for:) resolves per-meal event times (breakfast 8:00 / snack 17:00 defaults on old stores). - Replaced the 5 duplicated mealWindows→[MealType] derivations (slot sync, default plan creation, week calendar, export view) with activeMealTypes. - CalendarService: per-type event names and times. - Export styles: per-type accent colors and gradients. - Widget: generic meals list (N rows) instead of hardcoded lunch/dinner; compact families fall back to main meals when >2 are active. - Settings: 4 meal toggles (min 1) replace the 3-option picker. - Onboarding: meal step is now multi-select over the 4 types. - iCloud sync: enabledMealTypesRaw synced (optional, pre-2.0 compatible). - Localized breakfast/snack in all 6 languages. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BkanrydYtrme8wipTzWssG
This commit is contained in:
@@ -5,10 +5,13 @@ import Foundation
|
||||
// MARK: - Shared data model (mirrors WidgetDataStore in main app)
|
||||
|
||||
private struct TodayMealData: Codable {
|
||||
let lunch: String?
|
||||
let dinner: String?
|
||||
let lunchLabel: String
|
||||
let dinnerLabel: String
|
||||
struct Meal: Codable {
|
||||
let type: String
|
||||
let label: String
|
||||
let name: String?
|
||||
}
|
||||
|
||||
let meals: [Meal]
|
||||
let weekdayName: String
|
||||
let updatedAt: Date
|
||||
}
|
||||
@@ -26,39 +29,70 @@ private enum WidgetStore {
|
||||
|
||||
// MARK: - Timeline
|
||||
|
||||
struct MealItem: Hashable {
|
||||
let type: String
|
||||
let label: String
|
||||
let name: String?
|
||||
|
||||
var icon: String {
|
||||
switch type {
|
||||
case "breakfast": return "cup.and.saucer.fill"
|
||||
case "lunch": return "sun.max.fill"
|
||||
case "snack": return "carrot.fill"
|
||||
default: return "moon.stars.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch type {
|
||||
case "breakfast": return .mmBreakfastAmber
|
||||
case "lunch": return .mmLunchOrange
|
||||
case "snack": return .mmSnackPurple
|
||||
default: return .mmDinnerBlue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TodayMealEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let lunch: String?
|
||||
let dinner: String?
|
||||
let lunchLabel: String
|
||||
let dinnerLabel: String
|
||||
let meals: [MealItem]
|
||||
let weekdayName: String
|
||||
|
||||
/// Compact families show the "main" meals when the list is long.
|
||||
var compactMeals: [MealItem] {
|
||||
guard meals.count > 2 else { return meals }
|
||||
let main = meals.filter { $0.type == "lunch" || $0.type == "dinner" }
|
||||
return main.isEmpty ? Array(meals.prefix(2)) : Array(main.prefix(2))
|
||||
}
|
||||
}
|
||||
|
||||
struct Provider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> TodayMealEntry {
|
||||
TodayMealEntry(date: Date(), lunch: "Pasta al pesto", dinner: "Salmón al horno",
|
||||
lunchLabel: "Comida", dinnerLabel: "Cena", weekdayName: "Lunes")
|
||||
TodayMealEntry(
|
||||
date: Date(),
|
||||
meals: [
|
||||
MealItem(type: "lunch", label: "Comida", name: "Pasta al pesto"),
|
||||
MealItem(type: "dinner", label: "Cena", name: "Salmón al horno")
|
||||
],
|
||||
weekdayName: "Lunes"
|
||||
)
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (TodayMealEntry) -> Void) {
|
||||
let data = WidgetStore.read()
|
||||
completion(entry(from: data))
|
||||
completion(entry(from: WidgetStore.read()))
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<TodayMealEntry>) -> Void) {
|
||||
let data = WidgetStore.read()
|
||||
let nextMidnight = Calendar.current.startOfDay(for: Calendar.current.date(byAdding: .day, value: 1, to: Date())!)
|
||||
completion(Timeline(entries: [entry(from: data)], policy: .after(nextMidnight)))
|
||||
completion(Timeline(entries: [entry(from: WidgetStore.read())], policy: .after(nextMidnight)))
|
||||
}
|
||||
|
||||
private func entry(from data: TodayMealData?) -> TodayMealEntry {
|
||||
TodayMealEntry(
|
||||
date: Date(),
|
||||
lunch: data?.lunch,
|
||||
dinner: data?.dinner,
|
||||
lunchLabel: data?.lunchLabel ?? "Lunch",
|
||||
dinnerLabel: data?.dinnerLabel ?? "Dinner",
|
||||
meals: (data?.meals ?? []).map {
|
||||
MealItem(type: $0.type, label: $0.label, name: $0.name)
|
||||
},
|
||||
weekdayName: data?.weekdayName ?? ""
|
||||
)
|
||||
}
|
||||
@@ -72,8 +106,13 @@ private extension Color {
|
||||
static let mmBgMint = Color(red: 0.93, green: 0.98, blue: 0.95)
|
||||
static let mmText = Color(red: 0.13, green: 0.13, blue: 0.13)
|
||||
static let mmSubtext = Color(red: 0.55, green: 0.55, blue: 0.55)
|
||||
static let mmLunchOrange = Color(red: 0.90, green: 0.55, blue: 0.18)
|
||||
static let mmDinnerBlue = Color(red: 0.36, green: 0.48, blue: 0.78)
|
||||
}
|
||||
|
||||
extension Color {
|
||||
static let mmBreakfastAmber = Color(red: 0.76, green: 0.55, blue: 0.18)
|
||||
static let mmLunchOrange = Color(red: 0.90, green: 0.55, blue: 0.18)
|
||||
static let mmSnackPurple = Color(red: 0.51, green: 0.39, blue: 0.69)
|
||||
static let mmDinnerBlue = Color(red: 0.36, green: 0.48, blue: 0.78)
|
||||
}
|
||||
|
||||
// MARK: - Small widget
|
||||
@@ -102,12 +141,16 @@ private struct SmallWidgetView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
mealRow(icon: "sun.max.fill", label: entry.lunchLabel,
|
||||
name: entry.lunch, color: .mmLunchOrange)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
mealRow(icon: "moon.stars.fill", label: entry.dinnerLabel,
|
||||
name: entry.dinner, color: .mmDinnerBlue)
|
||||
let rows = entry.compactMeals
|
||||
ForEach(Array(rows.enumerated()), id: \.element) { index, meal in
|
||||
mealRow(meal)
|
||||
.padding(.bottom, index < rows.count - 1 ? 8 : 0)
|
||||
}
|
||||
if rows.isEmpty {
|
||||
Text("—")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundColor(.mmSubtext)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
@@ -117,17 +160,17 @@ private struct SmallWidgetView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private func mealRow(icon: String, label: String, name: String?, color: Color) -> some View {
|
||||
private func mealRow(_ meal: MealItem) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
Image(systemName: meal.icon)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(color)
|
||||
.foregroundColor(meal.color)
|
||||
.frame(width: 18)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(label.uppercased())
|
||||
Text(meal.label.uppercased())
|
||||
.font(.system(size: 8, weight: .semibold))
|
||||
.foregroundColor(.mmSubtext)
|
||||
Text(name ?? "—")
|
||||
Text(meal.name ?? "—")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundColor(.mmText)
|
||||
.lineLimit(2)
|
||||
@@ -167,12 +210,19 @@ private struct MediumWidgetView: View {
|
||||
.fill(Color.mmCoral.opacity(0.2))
|
||||
.frame(width: 1)
|
||||
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
mediumRow(icon: "sun.max.fill", label: entry.lunchLabel,
|
||||
name: entry.lunch, color: .mmLunchOrange)
|
||||
Divider()
|
||||
mediumRow(icon: "moon.stars.fill", label: entry.dinnerLabel,
|
||||
name: entry.dinner, color: .mmDinnerBlue)
|
||||
let rows = entry.meals.isEmpty ? entry.compactMeals : entry.meals
|
||||
VStack(alignment: .leading, spacing: rows.count > 2 ? 8 : 14) {
|
||||
ForEach(Array(rows.enumerated()), id: \.element) { index, meal in
|
||||
mediumRow(meal, compact: rows.count > 2)
|
||||
if index < rows.count - 1 {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
if rows.isEmpty {
|
||||
Text("—")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundColor(.mmSubtext)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
@@ -180,20 +230,31 @@ private struct MediumWidgetView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func mediumRow(icon: String, label: String, name: String?, color: Color) -> some View {
|
||||
private func mediumRow(_ meal: MealItem, compact: Bool) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(color)
|
||||
Image(systemName: meal.icon)
|
||||
.font(.system(size: compact ? 13 : 16))
|
||||
.foregroundColor(meal.color)
|
||||
.frame(width: 22)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label.uppercased())
|
||||
if compact {
|
||||
Text(meal.label.uppercased())
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundColor(.mmSubtext)
|
||||
Text(name ?? "—")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.frame(width: 70, alignment: .leading)
|
||||
Text(meal.name ?? "—")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundColor(.mmText)
|
||||
.lineLimit(2)
|
||||
.lineLimit(1)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(meal.label.uppercased())
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundColor(.mmSubtext)
|
||||
Text(meal.name ?? "—")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundColor(.mmText)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,17 +276,13 @@ private struct AccessoryRectangularView: View {
|
||||
}
|
||||
.widgetAccentable()
|
||||
|
||||
if let lunch = entry.lunch {
|
||||
Text("\(Image(systemName: "sun.max.fill")) \(lunch)")
|
||||
let rows = entry.compactMeals.filter { $0.name != nil }
|
||||
ForEach(rows.prefix(2), id: \.self) { meal in
|
||||
Text("\(Image(systemName: meal.icon)) \(meal.name ?? "")")
|
||||
.font(.system(size: 12))
|
||||
.lineLimit(1)
|
||||
}
|
||||
if let dinner = entry.dinner {
|
||||
Text("\(Image(systemName: "moon.stars.fill")) \(dinner)")
|
||||
.font(.system(size: 12))
|
||||
.lineLimit(1)
|
||||
}
|
||||
if entry.lunch == nil && entry.dinner == nil {
|
||||
if rows.isEmpty {
|
||||
Text("—")
|
||||
.font(.system(size: 12))
|
||||
}
|
||||
@@ -238,8 +295,8 @@ private struct AccessoryInlineView: View {
|
||||
let entry: TodayMealEntry
|
||||
|
||||
var body: some View {
|
||||
// Inline is a single line: show the next relevant meal.
|
||||
let name = entry.dinner ?? entry.lunch
|
||||
// Inline is a single line: prefer the last main meal with a dish.
|
||||
let name = entry.compactMeals.reversed().compactMap(\.name).first
|
||||
Label(name ?? "MealMood", systemImage: "fork.knife")
|
||||
}
|
||||
}
|
||||
@@ -283,7 +340,7 @@ struct MealMoodWidget: Widget {
|
||||
MealMoodWidgetView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("MealMood")
|
||||
.description("Today's lunch and dinner at a glance.")
|
||||
.description("Today's meals at a glance.")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular, .accessoryInline])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user