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:
@@ -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";
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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