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:
alexandrev-tibco
2026-07-12 18:00:50 +02:00
parent 98230c732d
commit e15ff93465
20 changed files with 322 additions and 127 deletions
+76
View File
@@ -7,11 +7,17 @@ final class AppSettings {
var includeWeekends: Bool = true
var language: String // "spanish", "english"
/// Comma-separated MealType raw values, in chronological order (2.0).
/// nil = derive from the legacy `mealWindows` field (pre-2.0 installs).
var enabledMealTypesRaw: String?
var calendarId: String?
var syncEnabled: Bool = false
var syncMode: String? // "weekComplete", "manual"
var lunchTime: Date
var dinnerTime: Date
var breakfastTime: Date? // optional: pre-2.0 stores lack it
var snackTime: Date?
var eventDuration: Int
var eventPrefix: String
var reminderMinutesBefore: Int?
@@ -71,6 +77,57 @@ final class AppSettings {
get { WeekExportStyle(rawValue: weekExportStyle ?? "") ?? .defaultStyle }
set { weekExportStyle = newValue.rawValue }
}
/// The meal types the user plans, in chronological order. Single source of
/// truth for slot generation, calendar rows, exports and the widget.
/// Falls back to the legacy `mealWindows` for pre-2.0 installs.
var activeMealTypes: [MealType] {
get {
if let raw = enabledMealTypesRaw {
let types = raw.split(separator: ",").compactMap { MealType(rawValue: String($0)) }
if !types.isEmpty {
return MealType.allCases.filter { types.contains($0) }
}
}
switch mealWindowsEnum {
case .dinnerOnly: return [.dinner]
case .lunchOnly: return [.lunch]
case .both: return [.lunch, .dinner]
}
}
set {
let ordered = MealType.allCases.filter { newValue.contains($0) }
enabledMealTypesRaw = ordered.isEmpty
? MealType.dinner.rawValue
: ordered.map(\.rawValue).joined(separator: ",")
// Keep the legacy field roughly coherent for pre-2.0 code paths.
if ordered.contains(.lunch) && ordered.contains(.dinner) {
mealWindowsEnum = .both
} else if ordered.contains(.lunch) {
mealWindowsEnum = .lunchOnly
} else {
mealWindowsEnum = .dinnerOnly
}
}
}
/// Event start time for a meal type (calendar sync). Breakfast/snack get
/// sensible defaults on pre-2.0 stores that never set them.
func time(for mealType: MealType) -> Date {
switch mealType {
case .breakfast: return breakfastTime ?? Self.defaultTime(hour: 8)
case .lunch: return lunchTime
case .snack: return snackTime ?? Self.defaultTime(hour: 17)
case .dinner: return dinnerTime
}
}
static func defaultTime(hour: Int) -> Date {
var components = DateComponents()
components.hour = hour
components.minute = 0
return Calendar.current.date(from: components) ?? Date()
}
}
enum MealWindows: String, CaseIterable {
@@ -142,15 +199,34 @@ enum AppLanguage: String, CaseIterable {
}
enum MealType: String, Codable, CaseIterable {
// Declaration order is chronological allCases drives row/slot ordering.
case breakfast
case lunch
case snack
case dinner
var icon: String {
switch self {
case .breakfast: return "cup.and.saucer.fill"
case .lunch: return "sun.max.fill"
case .snack: return "carrot.fill"
case .dinner: return "moon.stars.fill"
}
}
var localizedKey: String {
switch self {
case .breakfast: return "breakfast"
case .lunch: return "lunch"
case .snack: return "snack"
case .dinner: return "dinner"
}
}
/// Chronological position, used to order slots within a day.
var order: Int {
MealType.allCases.firstIndex(of: self) ?? 0
}
}
enum CalendarSyncMode: String, CaseIterable {
@@ -406,3 +406,7 @@
"dish_ingredients_label" = "Zutaten";
"dish_ingredient_placeholder" = "z. B. 200 g Spaghetti";
"dish_ingredients_add" = "Zutat hinzufügen";
// Meal types (2.0)
"breakfast" = "Frühstück";
"snack" = "Snack";
@@ -406,3 +406,7 @@
"dish_ingredients_label" = "Ingredients";
"dish_ingredient_placeholder" = "e.g. 200 g spaghetti";
"dish_ingredients_add" = "Add ingredient";
// Meal types (2.0)
"breakfast" = "Breakfast";
"snack" = "Snack";
@@ -406,3 +406,7 @@
"dish_ingredients_label" = "Ingredientes";
"dish_ingredient_placeholder" = "p. ej. 200 g de espaguetis";
"dish_ingredients_add" = "Añadir ingrediente";
// Meal types (2.0)
"breakfast" = "Desayuno";
"snack" = "Merienda";
@@ -406,3 +406,7 @@
"dish_ingredients_label" = "Ingrédients";
"dish_ingredient_placeholder" = "ex. 200 g de spaghettis";
"dish_ingredients_add" = "Ajouter un ingrédient";
// Meal types (2.0)
"breakfast" = "Petit-déjeuner";
"snack" = "Goûter";
@@ -406,3 +406,7 @@
"dish_ingredients_label" = "Ingredienti";
"dish_ingredient_placeholder" = "es. 200 g di spaghetti";
"dish_ingredients_add" = "Aggiungi ingrediente";
// Meal types (2.0)
"breakfast" = "Colazione";
"snack" = "Merenda";
@@ -406,3 +406,7 @@
"dish_ingredients_label" = "Ingredientes";
"dish_ingredient_placeholder" = "ex.: 200 g de espaguete";
"dish_ingredients_add" = "Adicionar ingrediente";
// Meal types (2.0)
"breakfast" = "Café da manhã";
"snack" = "Lanche";
+5 -5
View File
@@ -37,12 +37,12 @@ final class CalendarService {
let event = EKEvent(eventStore: eventStore)
let prefix = settings.eventPrefix.isEmpty ? "" : "\(settings.eventPrefix) "
let mealNameKey = slot.mealType == "lunch" ? "lunch" : "dinner"
let mealNameKey = slot.mealTypeEnum.localizedKey
let mealName = localizedString(mealNameKey, language: settings.languageEnum.resolved())
event.title = "\(prefix)\(mealName): \(dishName)"
let slotDate = weekStartDate.addingDays(slot.dayOfWeek)
let slotTime = slot.mealType == "lunch" ? settings.lunchTime : settings.dinnerTime
let slotTime = settings.time(for: slot.mealTypeEnum)
event.startDate = combineDateAndTime(date: slotDate, time: slotTime)
event.endDate = event.startDate.addingMinutes(settings.eventDuration)
event.notes = dishDescription
@@ -82,12 +82,12 @@ final class CalendarService {
}
let prefix = settings.eventPrefix.isEmpty ? "" : "\(settings.eventPrefix) "
let mealNameKey = slot.mealType == "lunch" ? "lunch" : "dinner"
let mealNameKey = slot.mealTypeEnum.localizedKey
let mealName = localizedString(mealNameKey, language: settings.languageEnum.resolved())
event.title = "\(prefix)\(mealName): \(dishName)"
let slotDate = weekStartDate.addingDays(slot.dayOfWeek)
let slotTime = slot.mealType == "lunch" ? settings.lunchTime : settings.dinnerTime
let slotTime = settings.time(for: slot.mealTypeEnum)
event.startDate = combineDateAndTime(date: slotDate, time: slotTime)
event.endDate = event.startDate.addingMinutes(settings.eventDuration)
event.notes = dishDescription
@@ -124,7 +124,7 @@ final class CalendarService {
event.startDate >= Date() else { continue }
let slotDate = weekStartDate.addingDays(slot.dayOfWeek)
let newTime = slot.mealType == "lunch" ? settings.lunchTime : settings.dinnerTime
let newTime = settings.time(for: slot.mealTypeEnum)
event.startDate = combineDateAndTime(date: slotDate, time: newTime)
event.endDate = event.startDate.addingMinutes(settings.eventDuration)
+1 -7
View File
@@ -48,13 +48,7 @@ struct DefaultDataService {
context.insert(plan)
let maxDay = settings.includeWeekends ? 6 : 4
let mealTypes: [String] = {
switch settings.mealWindowsEnum {
case .dinnerOnly: return ["dinner"]
case .lunchOnly: return ["lunch"]
case .both: return ["lunch", "dinner"]
}
}()
let mealTypes: [String] = settings.activeMealTypes.map(\.rawValue)
for day in 0...maxDay {
for mealType in mealTypes {
@@ -59,6 +59,7 @@ final class ICloudSyncService {
settings: settings.map {
SettingsPayload(
mealWindows: $0.mealWindows,
enabledMealTypesRaw: $0.enabledMealTypesRaw,
includeWeekends: $0.includeWeekends,
language: $0.language,
calendarId: $0.calendarId,
@@ -141,6 +142,7 @@ final class ICloudSyncService {
if let settingsPayload = snapshot.settings {
let settings = AppSettings()
settings.mealWindows = settingsPayload.mealWindows
settings.enabledMealTypesRaw = settingsPayload.enabledMealTypesRaw
settings.includeWeekends = settingsPayload.includeWeekends
settings.language = settingsPayload.language
settings.calendarId = settingsPayload.calendarId
@@ -227,6 +229,8 @@ private struct SyncSnapshot: Codable {
private struct SettingsPayload: Codable {
let mealWindows: String
// Optional for backward-compatibility with pre-2.0 snapshots.
let enabledMealTypesRaw: String?
let includeWeekends: Bool
let language: String
let calendarId: String?
+21 -13
View File
@@ -1,11 +1,16 @@
import Foundation
import WidgetKit
/// Snapshot of today's meals for the widget. Generic over meal types (2.0):
/// one entry per active meal type, in chronological order.
struct TodayMealData: Codable {
let lunch: String?
let dinner: String?
let lunchLabel: String
let dinnerLabel: String
struct Meal: Codable {
let type: String // MealType raw value (drives icon/color in widget)
let label: String // localized meal name
let name: String? // dish name, nil when the slot is empty
}
let meals: [Meal]
let weekdayName: String
let updatedAt: Date
}
@@ -20,11 +25,17 @@ enum WidgetDataStore {
// weekday: 1=Sun,2=Mon..7=Sat appDayOfWeek: 0=Mon..6=Sun
let appDayOfWeek = (weekday + 5) % 7
let lunchId = plan?.slots.first { $0.dayOfWeek == appDayOfWeek && $0.mealType == MealType.lunch.rawValue }?.dishId
let dinnerId = plan?.slots.first { $0.dayOfWeek == appDayOfWeek && $0.mealType == MealType.dinner.rawValue }?.dishId
let lunch = lunchId.flatMap { id in dishes.first { $0.id == id }?.name }
let dinner = dinnerId.flatMap { id in dishes.first { $0.id == id }?.name }
let meals: [TodayMealData.Meal] = settings.activeMealTypes.map { mealType in
let dishId = plan?.slots.first {
$0.dayOfWeek == appDayOfWeek && $0.mealType == mealType.rawValue
}?.dishId
let name = dishId.flatMap { id in dishes.first { $0.id == id }?.name }
return TodayMealData.Meal(
type: mealType.rawValue,
label: String(localized: String.LocalizationValue(mealType.localizedKey)),
name: name
)
}
let locale = Locale(identifier: settings.languageEnum.resolved().localeIdentifier)
let formatter = DateFormatter()
@@ -33,10 +44,7 @@ enum WidgetDataStore {
let weekdayName = formatter.string(from: today).capitalized(with: locale)
let data = TodayMealData(
lunch: lunch,
dinner: dinner,
lunchLabel: String(localized: "lunch"),
dinnerLabel: String(localized: "dinner"),
meals: meals,
weekdayName: weekdayName,
updatedAt: today
)
+2 -8
View File
@@ -377,7 +377,7 @@ final class HomeViewModel: ObservableObject {
}
private func mealTypeOrder(_ raw: String) -> Int {
raw == MealType.lunch.rawValue ? 0 : 1
MealType(rawValue: raw)?.order ?? 0
}
private func applyCalendarSyncPolicy(plan: WeekPlan, settings: AppSettings, shouldNotify: Bool = true) {
@@ -495,13 +495,7 @@ final class HomeViewModel: ObservableObject {
private func syncSlotsIfNeeded(plan: WeekPlan, settings: AppSettings, context: ModelContext) {
let maxDay = settings.includeWeekends ? 6 : 4
let mealTypes: [String] = {
switch settings.mealWindowsEnum {
case .dinnerOnly: return ["dinner"]
case .lunchOnly: return ["lunch"]
case .both: return ["lunch", "dinner"]
}
}()
let mealTypes: [String] = settings.activeMealTypes.map(\.rawValue)
// Clean up any legacy duplicates for the same day+mealType key to keep UI mapping stable.
var groupedByKey: [SlotKey: [MealSlot]] = [:]
@@ -8,7 +8,7 @@ final class OnboardingViewModel: ObservableObject {
static let pendingAutoFillOnLaunchKey = "onboarding_pending_auto_fill_on_launch"
@Published var currentStep: Int = 0
@Published var selectedMealWindows: MealWindows = .dinnerOnly
@Published var selectedMealTypes: Set<MealType> = [.dinner]
@Published var includeWeekends: Bool = true
@Published var syncICloud: Bool = true
@Published var syncCalendar: Bool = false
@@ -130,7 +130,7 @@ final class OnboardingViewModel: ObservableObject {
return s
}()
settings.mealWindowsEnum = selectedMealWindows
settings.activeMealTypes = MealType.allCases.filter { selectedMealTypes.contains($0) }
settings.includeWeekends = includeWeekends
settings.iCloudSyncEnabled = syncICloud
settings.syncEnabled = syncCalendar
+5
View File
@@ -211,6 +211,11 @@ struct HomeView: View {
let plan = ensureCurrentWeekPlanExists(settings: settings) else { return }
viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context)
}
.onChange(of: settings?.enabledMealTypesRaw) { _, _ in
guard let settings = settings,
let plan = ensureCurrentWeekPlanExists(settings: settings) else { return }
viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context)
}
.onChange(of: settings?.mealWindows) { _, _ in
guard let settings = settings,
let plan = ensureCurrentWeekPlanExists(settings: settings) else { return }
+1 -5
View File
@@ -22,11 +22,7 @@ struct WeekCalendarView: View {
}
private var mealTypes: [MealType] {
switch settings.mealWindowsEnum {
case .dinnerOnly: return [.dinner]
case .lunchOnly: return [.lunch]
case .both: return [.lunch, .dinner]
}
settings.activeMealTypes
}
var body: some View {
+25 -11
View File
@@ -45,11 +45,7 @@ struct WeekPlanShareView: View {
}
private var mealTypes: [MealType] {
switch settings.mealWindowsEnum {
case .dinnerOnly: return [.dinner]
case .lunchOnly: return [.lunch]
case .both: return [.lunch, .dinner]
}
settings.activeMealTypes
}
private var exportStyle: WeekExportStyle {
@@ -389,11 +385,11 @@ struct WeekPlanShareView: View {
HStack(spacing: 14) {
Image(systemName: mealType.icon)
.font(.system(size: 42, weight: .medium))
.foregroundColor(mealType == .lunch ? Color(hex: "#C85E2A") : Color(hex: "#27604A"))
.foregroundColor(mealTypeAccent(mealType))
.frame(width: 56)
Text(mealTypeLabel(mealType).uppercased(with: locale))
.font(.system(size: 42, weight: .bold, design: .serif))
.foregroundColor(mealType == .lunch ? Color(hex: "#C85E2A") : Color(hex: "#27604A"))
.foregroundColor(mealTypeAccent(mealType))
}
Text(dishName(day: day, mealType: mealType))
.font(.system(size: 62, weight: .medium, design: .serif))
@@ -497,7 +493,17 @@ struct WeekPlanShareView: View {
}
private func mealTypeLabel(_ mealType: MealType) -> String {
mealType == .lunch ? String(localized: "lunch") : String(localized: "dinner")
String(localized: String.LocalizationValue(mealType.localizedKey))
}
/// Accent color per meal row in the vertical/school styles.
private func mealTypeAccent(_ mealType: MealType) -> Color {
switch mealType {
case .breakfast: return Color(hex: "#B07C2B")
case .lunch: return Color(hex: "#C85E2A")
case .snack: return Color(hex: "#7B5EA7")
case .dinner: return Color(hex: "#27604A")
}
}
private func dishName(day: Int, mealType: MealType) -> String {
@@ -526,9 +532,17 @@ struct WeekPlanShareView: View {
}
private func gridMealCell(_ title: String, icon: String, mealType: MealType) -> some View {
let background = mealType == .lunch ?
LinearGradient(colors: [Color(hex: "#FFE2C8"), Color(hex: "#FFD4B2")], startPoint: .topLeading, endPoint: .bottomTrailing) :
LinearGradient(colors: [Color(hex: "#D7EFE7"), Color(hex: "#C2E7DB")], startPoint: .topLeading, endPoint: .bottomTrailing)
let background: LinearGradient
switch mealType {
case .breakfast:
background = LinearGradient(colors: [Color(hex: "#FFEFC9"), Color(hex: "#FFE5A9")], startPoint: .topLeading, endPoint: .bottomTrailing)
case .lunch:
background = LinearGradient(colors: [Color(hex: "#FFE2C8"), Color(hex: "#FFD4B2")], startPoint: .topLeading, endPoint: .bottomTrailing)
case .snack:
background = LinearGradient(colors: [Color(hex: "#EBE0F5"), Color(hex: "#DFD0EF")], startPoint: .topLeading, endPoint: .bottomTrailing)
case .dinner:
background = LinearGradient(colors: [Color(hex: "#D7EFE7"), Color(hex: "#C2E7DB")], startPoint: .topLeading, endPoint: .bottomTrailing)
}
return HStack(spacing: 8) {
Image(systemName: icon)
Text(title)
@@ -1,7 +1,7 @@
import SwiftUI
struct MealWindowsStepView: View {
@Binding var selection: MealWindows
@Binding var selection: Set<MealType>
var onNext: () -> Void
var body: some View {
@@ -15,14 +15,21 @@ struct MealWindowsStepView: View {
.padding(.top, 12)
VStack(spacing: 12) {
ForEach(MealWindows.allCases, id: \.self) { option in
ForEach(MealType.allCases, id: \.self) { mealType in
SelectionCard(
icon: option.icon,
title: optionTitle(option),
isSelected: selection == option
icon: mealType.icon,
title: optionTitle(mealType),
isSelected: selection.contains(mealType)
) {
withAnimation(.spring(response: 0.3)) {
selection = option
if selection.contains(mealType) {
// Keep at least one meal selected.
if selection.count > 1 {
selection.remove(mealType)
}
} else {
selection.insert(mealType)
}
}
HapticManager.shared.selection()
}
@@ -38,12 +45,15 @@ struct MealWindowsStepView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
private func optionTitle(_ option: MealWindows) -> String {
switch option {
case .dinnerOnly: return "🌙 \(String(localized: "meal_windows_dinner_only"))"
case .lunchOnly: return "☀️ \(String(localized: "meal_windows_lunch_only"))"
case .both: return "🌞🌙 \(String(localized: "meal_windows_both"))"
private func optionTitle(_ mealType: MealType) -> String {
let emoji: String
switch mealType {
case .breakfast: emoji = "☕️"
case .lunch: emoji = "🌞"
case .snack: emoji = "🍎"
case .dinner: emoji = "🌙"
}
return "\(emoji) \(String(localized: String.LocalizationValue(mealType.localizedKey)))"
}
}
@@ -31,7 +31,7 @@ struct OnboardingView: View {
.tag(0)
MealWindowsStepView(
selection: $viewModel.selectedMealWindows,
selection: $viewModel.selectedMealTypes,
onNext: { viewModel.nextStep() }
)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
+20 -7
View File
@@ -61,13 +61,26 @@ struct SettingsView: View {
List {
// Planning section
Section {
Picker("settings_meal_windows", selection: Binding(
get: { settings.mealWindowsEnum },
set: { settings.mealWindowsEnum = $0 }
)) {
Text("meal_windows_dinner_only").tag(MealWindows.dinnerOnly)
Text("meal_windows_lunch_only").tag(MealWindows.lunchOnly)
Text("meal_windows_both").tag(MealWindows.both)
ForEach(MealType.allCases, id: \.self) { mealType in
Toggle(isOn: Binding(
get: { settings.activeMealTypes.contains(mealType) },
set: { enabled in
var types = settings.activeMealTypes
if enabled {
types.append(mealType)
} else if types.count > 1 {
// Keep at least one meal type active.
types.removeAll { $0 == mealType }
}
settings.activeMealTypes = types
}
)) {
Label(
String(localized: String.LocalizationValue(mealType.localizedKey)),
systemImage: mealType.icon
)
}
.tint(.mealMoodCoral)
}
Toggle("settings_include_weekends", isOn: Binding(
+114 -57
View File
@@ -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])
}
}