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 includeWeekends: Bool = true
|
||||||
var language: String // "spanish", "english"
|
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 calendarId: String?
|
||||||
var syncEnabled: Bool = false
|
var syncEnabled: Bool = false
|
||||||
var syncMode: String? // "weekComplete", "manual"
|
var syncMode: String? // "weekComplete", "manual"
|
||||||
var lunchTime: Date
|
var lunchTime: Date
|
||||||
var dinnerTime: Date
|
var dinnerTime: Date
|
||||||
|
var breakfastTime: Date? // optional: pre-2.0 stores lack it
|
||||||
|
var snackTime: Date?
|
||||||
var eventDuration: Int
|
var eventDuration: Int
|
||||||
var eventPrefix: String
|
var eventPrefix: String
|
||||||
var reminderMinutesBefore: Int?
|
var reminderMinutesBefore: Int?
|
||||||
@@ -71,6 +77,57 @@ final class AppSettings {
|
|||||||
get { WeekExportStyle(rawValue: weekExportStyle ?? "") ?? .defaultStyle }
|
get { WeekExportStyle(rawValue: weekExportStyle ?? "") ?? .defaultStyle }
|
||||||
set { weekExportStyle = newValue.rawValue }
|
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 {
|
enum MealWindows: String, CaseIterable {
|
||||||
@@ -142,15 +199,34 @@ enum AppLanguage: String, CaseIterable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum MealType: String, Codable, CaseIterable {
|
enum MealType: String, Codable, CaseIterable {
|
||||||
|
// Declaration order is chronological — allCases drives row/slot ordering.
|
||||||
|
case breakfast
|
||||||
case lunch
|
case lunch
|
||||||
|
case snack
|
||||||
case dinner
|
case dinner
|
||||||
|
|
||||||
var icon: String {
|
var icon: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
case .breakfast: return "cup.and.saucer.fill"
|
||||||
case .lunch: return "sun.max.fill"
|
case .lunch: return "sun.max.fill"
|
||||||
|
case .snack: return "carrot.fill"
|
||||||
case .dinner: return "moon.stars.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 {
|
enum CalendarSyncMode: String, CaseIterable {
|
||||||
|
|||||||
@@ -406,3 +406,7 @@
|
|||||||
"dish_ingredients_label" = "Zutaten";
|
"dish_ingredients_label" = "Zutaten";
|
||||||
"dish_ingredient_placeholder" = "z. B. 200 g Spaghetti";
|
"dish_ingredient_placeholder" = "z. B. 200 g Spaghetti";
|
||||||
"dish_ingredients_add" = "Zutat hinzufügen";
|
"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_ingredients_label" = "Ingredients";
|
||||||
"dish_ingredient_placeholder" = "e.g. 200 g spaghetti";
|
"dish_ingredient_placeholder" = "e.g. 200 g spaghetti";
|
||||||
"dish_ingredients_add" = "Add ingredient";
|
"dish_ingredients_add" = "Add ingredient";
|
||||||
|
|
||||||
|
// Meal types (2.0)
|
||||||
|
"breakfast" = "Breakfast";
|
||||||
|
"snack" = "Snack";
|
||||||
|
|||||||
@@ -406,3 +406,7 @@
|
|||||||
"dish_ingredients_label" = "Ingredientes";
|
"dish_ingredients_label" = "Ingredientes";
|
||||||
"dish_ingredient_placeholder" = "p. ej. 200 g de espaguetis";
|
"dish_ingredient_placeholder" = "p. ej. 200 g de espaguetis";
|
||||||
"dish_ingredients_add" = "Añadir ingrediente";
|
"dish_ingredients_add" = "Añadir ingrediente";
|
||||||
|
|
||||||
|
// Meal types (2.0)
|
||||||
|
"breakfast" = "Desayuno";
|
||||||
|
"snack" = "Merienda";
|
||||||
|
|||||||
@@ -406,3 +406,7 @@
|
|||||||
"dish_ingredients_label" = "Ingrédients";
|
"dish_ingredients_label" = "Ingrédients";
|
||||||
"dish_ingredient_placeholder" = "ex. 200 g de spaghettis";
|
"dish_ingredient_placeholder" = "ex. 200 g de spaghettis";
|
||||||
"dish_ingredients_add" = "Ajouter un ingrédient";
|
"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_ingredients_label" = "Ingredienti";
|
||||||
"dish_ingredient_placeholder" = "es. 200 g di spaghetti";
|
"dish_ingredient_placeholder" = "es. 200 g di spaghetti";
|
||||||
"dish_ingredients_add" = "Aggiungi ingrediente";
|
"dish_ingredients_add" = "Aggiungi ingrediente";
|
||||||
|
|
||||||
|
// Meal types (2.0)
|
||||||
|
"breakfast" = "Colazione";
|
||||||
|
"snack" = "Merenda";
|
||||||
|
|||||||
@@ -406,3 +406,7 @@
|
|||||||
"dish_ingredients_label" = "Ingredientes";
|
"dish_ingredients_label" = "Ingredientes";
|
||||||
"dish_ingredient_placeholder" = "ex.: 200 g de espaguete";
|
"dish_ingredient_placeholder" = "ex.: 200 g de espaguete";
|
||||||
"dish_ingredients_add" = "Adicionar ingrediente";
|
"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 event = EKEvent(eventStore: eventStore)
|
||||||
|
|
||||||
let prefix = settings.eventPrefix.isEmpty ? "" : "\(settings.eventPrefix) "
|
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())
|
let mealName = localizedString(mealNameKey, language: settings.languageEnum.resolved())
|
||||||
event.title = "\(prefix)\(mealName): \(dishName)"
|
event.title = "\(prefix)\(mealName): \(dishName)"
|
||||||
|
|
||||||
let slotDate = weekStartDate.addingDays(slot.dayOfWeek)
|
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.startDate = combineDateAndTime(date: slotDate, time: slotTime)
|
||||||
event.endDate = event.startDate.addingMinutes(settings.eventDuration)
|
event.endDate = event.startDate.addingMinutes(settings.eventDuration)
|
||||||
event.notes = dishDescription
|
event.notes = dishDescription
|
||||||
@@ -82,12 +82,12 @@ final class CalendarService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let prefix = settings.eventPrefix.isEmpty ? "" : "\(settings.eventPrefix) "
|
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())
|
let mealName = localizedString(mealNameKey, language: settings.languageEnum.resolved())
|
||||||
event.title = "\(prefix)\(mealName): \(dishName)"
|
event.title = "\(prefix)\(mealName): \(dishName)"
|
||||||
|
|
||||||
let slotDate = weekStartDate.addingDays(slot.dayOfWeek)
|
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.startDate = combineDateAndTime(date: slotDate, time: slotTime)
|
||||||
event.endDate = event.startDate.addingMinutes(settings.eventDuration)
|
event.endDate = event.startDate.addingMinutes(settings.eventDuration)
|
||||||
event.notes = dishDescription
|
event.notes = dishDescription
|
||||||
@@ -124,7 +124,7 @@ final class CalendarService {
|
|||||||
event.startDate >= Date() else { continue }
|
event.startDate >= Date() else { continue }
|
||||||
|
|
||||||
let slotDate = weekStartDate.addingDays(slot.dayOfWeek)
|
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.startDate = combineDateAndTime(date: slotDate, time: newTime)
|
||||||
event.endDate = event.startDate.addingMinutes(settings.eventDuration)
|
event.endDate = event.startDate.addingMinutes(settings.eventDuration)
|
||||||
|
|
||||||
|
|||||||
@@ -48,13 +48,7 @@ struct DefaultDataService {
|
|||||||
context.insert(plan)
|
context.insert(plan)
|
||||||
|
|
||||||
let maxDay = settings.includeWeekends ? 6 : 4
|
let maxDay = settings.includeWeekends ? 6 : 4
|
||||||
let mealTypes: [String] = {
|
let mealTypes: [String] = settings.activeMealTypes.map(\.rawValue)
|
||||||
switch settings.mealWindowsEnum {
|
|
||||||
case .dinnerOnly: return ["dinner"]
|
|
||||||
case .lunchOnly: return ["lunch"]
|
|
||||||
case .both: return ["lunch", "dinner"]
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
for day in 0...maxDay {
|
for day in 0...maxDay {
|
||||||
for mealType in mealTypes {
|
for mealType in mealTypes {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ final class ICloudSyncService {
|
|||||||
settings: settings.map {
|
settings: settings.map {
|
||||||
SettingsPayload(
|
SettingsPayload(
|
||||||
mealWindows: $0.mealWindows,
|
mealWindows: $0.mealWindows,
|
||||||
|
enabledMealTypesRaw: $0.enabledMealTypesRaw,
|
||||||
includeWeekends: $0.includeWeekends,
|
includeWeekends: $0.includeWeekends,
|
||||||
language: $0.language,
|
language: $0.language,
|
||||||
calendarId: $0.calendarId,
|
calendarId: $0.calendarId,
|
||||||
@@ -141,6 +142,7 @@ final class ICloudSyncService {
|
|||||||
if let settingsPayload = snapshot.settings {
|
if let settingsPayload = snapshot.settings {
|
||||||
let settings = AppSettings()
|
let settings = AppSettings()
|
||||||
settings.mealWindows = settingsPayload.mealWindows
|
settings.mealWindows = settingsPayload.mealWindows
|
||||||
|
settings.enabledMealTypesRaw = settingsPayload.enabledMealTypesRaw
|
||||||
settings.includeWeekends = settingsPayload.includeWeekends
|
settings.includeWeekends = settingsPayload.includeWeekends
|
||||||
settings.language = settingsPayload.language
|
settings.language = settingsPayload.language
|
||||||
settings.calendarId = settingsPayload.calendarId
|
settings.calendarId = settingsPayload.calendarId
|
||||||
@@ -227,6 +229,8 @@ private struct SyncSnapshot: Codable {
|
|||||||
|
|
||||||
private struct SettingsPayload: Codable {
|
private struct SettingsPayload: Codable {
|
||||||
let mealWindows: String
|
let mealWindows: String
|
||||||
|
// Optional for backward-compatibility with pre-2.0 snapshots.
|
||||||
|
let enabledMealTypesRaw: String?
|
||||||
let includeWeekends: Bool
|
let includeWeekends: Bool
|
||||||
let language: String
|
let language: String
|
||||||
let calendarId: String?
|
let calendarId: String?
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import WidgetKit
|
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 {
|
struct TodayMealData: Codable {
|
||||||
let lunch: String?
|
struct Meal: Codable {
|
||||||
let dinner: String?
|
let type: String // MealType raw value (drives icon/color in widget)
|
||||||
let lunchLabel: String
|
let label: String // localized meal name
|
||||||
let dinnerLabel: String
|
let name: String? // dish name, nil when the slot is empty
|
||||||
|
}
|
||||||
|
|
||||||
|
let meals: [Meal]
|
||||||
let weekdayName: String
|
let weekdayName: String
|
||||||
let updatedAt: Date
|
let updatedAt: Date
|
||||||
}
|
}
|
||||||
@@ -20,11 +25,17 @@ enum WidgetDataStore {
|
|||||||
// weekday: 1=Sun,2=Mon..7=Sat → appDayOfWeek: 0=Mon..6=Sun
|
// weekday: 1=Sun,2=Mon..7=Sat → appDayOfWeek: 0=Mon..6=Sun
|
||||||
let appDayOfWeek = (weekday + 5) % 7
|
let appDayOfWeek = (weekday + 5) % 7
|
||||||
|
|
||||||
let lunchId = plan?.slots.first { $0.dayOfWeek == appDayOfWeek && $0.mealType == MealType.lunch.rawValue }?.dishId
|
let meals: [TodayMealData.Meal] = settings.activeMealTypes.map { mealType in
|
||||||
let dinnerId = plan?.slots.first { $0.dayOfWeek == appDayOfWeek && $0.mealType == MealType.dinner.rawValue }?.dishId
|
let dishId = plan?.slots.first {
|
||||||
|
$0.dayOfWeek == appDayOfWeek && $0.mealType == mealType.rawValue
|
||||||
let lunch = lunchId.flatMap { id in dishes.first { $0.id == id }?.name }
|
}?.dishId
|
||||||
let dinner = dinnerId.flatMap { id in dishes.first { $0.id == id }?.name }
|
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 locale = Locale(identifier: settings.languageEnum.resolved().localeIdentifier)
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
@@ -33,10 +44,7 @@ enum WidgetDataStore {
|
|||||||
let weekdayName = formatter.string(from: today).capitalized(with: locale)
|
let weekdayName = formatter.string(from: today).capitalized(with: locale)
|
||||||
|
|
||||||
let data = TodayMealData(
|
let data = TodayMealData(
|
||||||
lunch: lunch,
|
meals: meals,
|
||||||
dinner: dinner,
|
|
||||||
lunchLabel: String(localized: "lunch"),
|
|
||||||
dinnerLabel: String(localized: "dinner"),
|
|
||||||
weekdayName: weekdayName,
|
weekdayName: weekdayName,
|
||||||
updatedAt: today
|
updatedAt: today
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -377,7 +377,7 @@ final class HomeViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func mealTypeOrder(_ raw: String) -> Int {
|
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) {
|
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) {
|
private func syncSlotsIfNeeded(plan: WeekPlan, settings: AppSettings, context: ModelContext) {
|
||||||
let maxDay = settings.includeWeekends ? 6 : 4
|
let maxDay = settings.includeWeekends ? 6 : 4
|
||||||
let mealTypes: [String] = {
|
let mealTypes: [String] = settings.activeMealTypes.map(\.rawValue)
|
||||||
switch settings.mealWindowsEnum {
|
|
||||||
case .dinnerOnly: return ["dinner"]
|
|
||||||
case .lunchOnly: return ["lunch"]
|
|
||||||
case .both: return ["lunch", "dinner"]
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Clean up any legacy duplicates for the same day+mealType key to keep UI mapping stable.
|
// Clean up any legacy duplicates for the same day+mealType key to keep UI mapping stable.
|
||||||
var groupedByKey: [SlotKey: [MealSlot]] = [:]
|
var groupedByKey: [SlotKey: [MealSlot]] = [:]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ final class OnboardingViewModel: ObservableObject {
|
|||||||
static let pendingAutoFillOnLaunchKey = "onboarding_pending_auto_fill_on_launch"
|
static let pendingAutoFillOnLaunchKey = "onboarding_pending_auto_fill_on_launch"
|
||||||
|
|
||||||
@Published var currentStep: Int = 0
|
@Published var currentStep: Int = 0
|
||||||
@Published var selectedMealWindows: MealWindows = .dinnerOnly
|
@Published var selectedMealTypes: Set<MealType> = [.dinner]
|
||||||
@Published var includeWeekends: Bool = true
|
@Published var includeWeekends: Bool = true
|
||||||
@Published var syncICloud: Bool = true
|
@Published var syncICloud: Bool = true
|
||||||
@Published var syncCalendar: Bool = false
|
@Published var syncCalendar: Bool = false
|
||||||
@@ -130,7 +130,7 @@ final class OnboardingViewModel: ObservableObject {
|
|||||||
return s
|
return s
|
||||||
}()
|
}()
|
||||||
|
|
||||||
settings.mealWindowsEnum = selectedMealWindows
|
settings.activeMealTypes = MealType.allCases.filter { selectedMealTypes.contains($0) }
|
||||||
settings.includeWeekends = includeWeekends
|
settings.includeWeekends = includeWeekends
|
||||||
settings.iCloudSyncEnabled = syncICloud
|
settings.iCloudSyncEnabled = syncICloud
|
||||||
settings.syncEnabled = syncCalendar
|
settings.syncEnabled = syncCalendar
|
||||||
|
|||||||
@@ -211,6 +211,11 @@ struct HomeView: View {
|
|||||||
let plan = ensureCurrentWeekPlanExists(settings: settings) else { return }
|
let plan = ensureCurrentWeekPlanExists(settings: settings) else { return }
|
||||||
viewModel.reconcileSlotsIfNeeded(plan: plan, settings: settings, context: context)
|
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
|
.onChange(of: settings?.mealWindows) { _, _ in
|
||||||
guard let settings = settings,
|
guard let settings = settings,
|
||||||
let plan = ensureCurrentWeekPlanExists(settings: settings) else { return }
|
let plan = ensureCurrentWeekPlanExists(settings: settings) else { return }
|
||||||
|
|||||||
@@ -22,11 +22,7 @@ struct WeekCalendarView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var mealTypes: [MealType] {
|
private var mealTypes: [MealType] {
|
||||||
switch settings.mealWindowsEnum {
|
settings.activeMealTypes
|
||||||
case .dinnerOnly: return [.dinner]
|
|
||||||
case .lunchOnly: return [.lunch]
|
|
||||||
case .both: return [.lunch, .dinner]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|||||||
@@ -45,11 +45,7 @@ struct WeekPlanShareView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var mealTypes: [MealType] {
|
private var mealTypes: [MealType] {
|
||||||
switch settings.mealWindowsEnum {
|
settings.activeMealTypes
|
||||||
case .dinnerOnly: return [.dinner]
|
|
||||||
case .lunchOnly: return [.lunch]
|
|
||||||
case .both: return [.lunch, .dinner]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var exportStyle: WeekExportStyle {
|
private var exportStyle: WeekExportStyle {
|
||||||
@@ -389,11 +385,11 @@ struct WeekPlanShareView: View {
|
|||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
Image(systemName: mealType.icon)
|
Image(systemName: mealType.icon)
|
||||||
.font(.system(size: 42, weight: .medium))
|
.font(.system(size: 42, weight: .medium))
|
||||||
.foregroundColor(mealType == .lunch ? Color(hex: "#C85E2A") : Color(hex: "#27604A"))
|
.foregroundColor(mealTypeAccent(mealType))
|
||||||
.frame(width: 56)
|
.frame(width: 56)
|
||||||
Text(mealTypeLabel(mealType).uppercased(with: locale))
|
Text(mealTypeLabel(mealType).uppercased(with: locale))
|
||||||
.font(.system(size: 42, weight: .bold, design: .serif))
|
.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))
|
Text(dishName(day: day, mealType: mealType))
|
||||||
.font(.system(size: 62, weight: .medium, design: .serif))
|
.font(.system(size: 62, weight: .medium, design: .serif))
|
||||||
@@ -497,7 +493,17 @@ struct WeekPlanShareView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func mealTypeLabel(_ mealType: MealType) -> String {
|
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 {
|
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 {
|
private func gridMealCell(_ title: String, icon: String, mealType: MealType) -> some View {
|
||||||
let background = mealType == .lunch ?
|
let background: LinearGradient
|
||||||
LinearGradient(colors: [Color(hex: "#FFE2C8"), Color(hex: "#FFD4B2")], startPoint: .topLeading, endPoint: .bottomTrailing) :
|
switch mealType {
|
||||||
LinearGradient(colors: [Color(hex: "#D7EFE7"), Color(hex: "#C2E7DB")], startPoint: .topLeading, endPoint: .bottomTrailing)
|
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) {
|
return HStack(spacing: 8) {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
Text(title)
|
Text(title)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct MealWindowsStepView: View {
|
struct MealWindowsStepView: View {
|
||||||
@Binding var selection: MealWindows
|
@Binding var selection: Set<MealType>
|
||||||
var onNext: () -> Void
|
var onNext: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -15,14 +15,21 @@ struct MealWindowsStepView: View {
|
|||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
|
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
ForEach(MealWindows.allCases, id: \.self) { option in
|
ForEach(MealType.allCases, id: \.self) { mealType in
|
||||||
SelectionCard(
|
SelectionCard(
|
||||||
icon: option.icon,
|
icon: mealType.icon,
|
||||||
title: optionTitle(option),
|
title: optionTitle(mealType),
|
||||||
isSelected: selection == option
|
isSelected: selection.contains(mealType)
|
||||||
) {
|
) {
|
||||||
withAnimation(.spring(response: 0.3)) {
|
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()
|
HapticManager.shared.selection()
|
||||||
}
|
}
|
||||||
@@ -38,12 +45,15 @@ struct MealWindowsStepView: View {
|
|||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func optionTitle(_ option: MealWindows) -> String {
|
private func optionTitle(_ mealType: MealType) -> String {
|
||||||
switch option {
|
let emoji: String
|
||||||
case .dinnerOnly: return "🌙 \(String(localized: "meal_windows_dinner_only"))"
|
switch mealType {
|
||||||
case .lunchOnly: return "☀️ \(String(localized: "meal_windows_lunch_only"))"
|
case .breakfast: emoji = "☕️"
|
||||||
case .both: return "🌞🌙 \(String(localized: "meal_windows_both"))"
|
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)
|
.tag(0)
|
||||||
|
|
||||||
MealWindowsStepView(
|
MealWindowsStepView(
|
||||||
selection: $viewModel.selectedMealWindows,
|
selection: $viewModel.selectedMealTypes,
|
||||||
onNext: { viewModel.nextStep() }
|
onNext: { viewModel.nextStep() }
|
||||||
)
|
)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
|
|||||||
@@ -61,13 +61,26 @@ struct SettingsView: View {
|
|||||||
List {
|
List {
|
||||||
// Planning section
|
// Planning section
|
||||||
Section {
|
Section {
|
||||||
Picker("settings_meal_windows", selection: Binding(
|
ForEach(MealType.allCases, id: \.self) { mealType in
|
||||||
get: { settings.mealWindowsEnum },
|
Toggle(isOn: Binding(
|
||||||
set: { settings.mealWindowsEnum = $0 }
|
get: { settings.activeMealTypes.contains(mealType) },
|
||||||
)) {
|
set: { enabled in
|
||||||
Text("meal_windows_dinner_only").tag(MealWindows.dinnerOnly)
|
var types = settings.activeMealTypes
|
||||||
Text("meal_windows_lunch_only").tag(MealWindows.lunchOnly)
|
if enabled {
|
||||||
Text("meal_windows_both").tag(MealWindows.both)
|
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(
|
Toggle("settings_include_weekends", isOn: Binding(
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import Foundation
|
|||||||
// MARK: - Shared data model (mirrors WidgetDataStore in main app)
|
// MARK: - Shared data model (mirrors WidgetDataStore in main app)
|
||||||
|
|
||||||
private struct TodayMealData: Codable {
|
private struct TodayMealData: Codable {
|
||||||
let lunch: String?
|
struct Meal: Codable {
|
||||||
let dinner: String?
|
let type: String
|
||||||
let lunchLabel: String
|
let label: String
|
||||||
let dinnerLabel: String
|
let name: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
let meals: [Meal]
|
||||||
let weekdayName: String
|
let weekdayName: String
|
||||||
let updatedAt: Date
|
let updatedAt: Date
|
||||||
}
|
}
|
||||||
@@ -26,39 +29,70 @@ private enum WidgetStore {
|
|||||||
|
|
||||||
// MARK: - Timeline
|
// 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 {
|
struct TodayMealEntry: TimelineEntry {
|
||||||
let date: Date
|
let date: Date
|
||||||
let lunch: String?
|
let meals: [MealItem]
|
||||||
let dinner: String?
|
|
||||||
let lunchLabel: String
|
|
||||||
let dinnerLabel: String
|
|
||||||
let weekdayName: String
|
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 {
|
struct Provider: TimelineProvider {
|
||||||
func placeholder(in context: Context) -> TodayMealEntry {
|
func placeholder(in context: Context) -> TodayMealEntry {
|
||||||
TodayMealEntry(date: Date(), lunch: "Pasta al pesto", dinner: "Salmón al horno",
|
TodayMealEntry(
|
||||||
lunchLabel: "Comida", dinnerLabel: "Cena", weekdayName: "Lunes")
|
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) {
|
func getSnapshot(in context: Context, completion: @escaping (TodayMealEntry) -> Void) {
|
||||||
let data = WidgetStore.read()
|
completion(entry(from: WidgetStore.read()))
|
||||||
completion(entry(from: data))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTimeline(in context: Context, completion: @escaping (Timeline<TodayMealEntry>) -> Void) {
|
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())!)
|
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 {
|
private func entry(from data: TodayMealData?) -> TodayMealEntry {
|
||||||
TodayMealEntry(
|
TodayMealEntry(
|
||||||
date: Date(),
|
date: Date(),
|
||||||
lunch: data?.lunch,
|
meals: (data?.meals ?? []).map {
|
||||||
dinner: data?.dinner,
|
MealItem(type: $0.type, label: $0.label, name: $0.name)
|
||||||
lunchLabel: data?.lunchLabel ?? "Lunch",
|
},
|
||||||
dinnerLabel: data?.dinnerLabel ?? "Dinner",
|
|
||||||
weekdayName: data?.weekdayName ?? ""
|
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 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 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 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
|
// MARK: - Small widget
|
||||||
@@ -102,12 +141,16 @@ private struct SmallWidgetView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
mealRow(icon: "sun.max.fill", label: entry.lunchLabel,
|
let rows = entry.compactMeals
|
||||||
name: entry.lunch, color: .mmLunchOrange)
|
ForEach(Array(rows.enumerated()), id: \.element) { index, meal in
|
||||||
.padding(.bottom, 8)
|
mealRow(meal)
|
||||||
|
.padding(.bottom, index < rows.count - 1 ? 8 : 0)
|
||||||
mealRow(icon: "moon.stars.fill", label: entry.dinnerLabel,
|
}
|
||||||
name: entry.dinner, color: .mmDinnerBlue)
|
if rows.isEmpty {
|
||||||
|
Text("—")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundColor(.mmSubtext)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
.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) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: icon)
|
Image(systemName: meal.icon)
|
||||||
.font(.system(size: 13))
|
.font(.system(size: 13))
|
||||||
.foregroundColor(color)
|
.foregroundColor(meal.color)
|
||||||
.frame(width: 18)
|
.frame(width: 18)
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
Text(label.uppercased())
|
Text(meal.label.uppercased())
|
||||||
.font(.system(size: 8, weight: .semibold))
|
.font(.system(size: 8, weight: .semibold))
|
||||||
.foregroundColor(.mmSubtext)
|
.foregroundColor(.mmSubtext)
|
||||||
Text(name ?? "—")
|
Text(meal.name ?? "—")
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.system(size: 13, weight: .semibold))
|
||||||
.foregroundColor(.mmText)
|
.foregroundColor(.mmText)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
@@ -167,12 +210,19 @@ private struct MediumWidgetView: View {
|
|||||||
.fill(Color.mmCoral.opacity(0.2))
|
.fill(Color.mmCoral.opacity(0.2))
|
||||||
.frame(width: 1)
|
.frame(width: 1)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
let rows = entry.meals.isEmpty ? entry.compactMeals : entry.meals
|
||||||
mediumRow(icon: "sun.max.fill", label: entry.lunchLabel,
|
VStack(alignment: .leading, spacing: rows.count > 2 ? 8 : 14) {
|
||||||
name: entry.lunch, color: .mmLunchOrange)
|
ForEach(Array(rows.enumerated()), id: \.element) { index, meal in
|
||||||
Divider()
|
mediumRow(meal, compact: rows.count > 2)
|
||||||
mediumRow(icon: "moon.stars.fill", label: entry.dinnerLabel,
|
if index < rows.count - 1 {
|
||||||
name: entry.dinner, color: .mmDinnerBlue)
|
Divider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rows.isEmpty {
|
||||||
|
Text("—")
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundColor(.mmSubtext)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(14)
|
.padding(14)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
.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) {
|
HStack(spacing: 10) {
|
||||||
Image(systemName: icon)
|
Image(systemName: meal.icon)
|
||||||
.font(.system(size: 16))
|
.font(.system(size: compact ? 13 : 16))
|
||||||
.foregroundColor(color)
|
.foregroundColor(meal.color)
|
||||||
.frame(width: 22)
|
.frame(width: 22)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
if compact {
|
||||||
Text(label.uppercased())
|
Text(meal.label.uppercased())
|
||||||
.font(.system(size: 9, weight: .semibold))
|
.font(.system(size: 9, weight: .semibold))
|
||||||
.foregroundColor(.mmSubtext)
|
.foregroundColor(.mmSubtext)
|
||||||
Text(name ?? "—")
|
.frame(width: 70, alignment: .leading)
|
||||||
.font(.system(size: 15, weight: .semibold))
|
Text(meal.name ?? "—")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
.foregroundColor(.mmText)
|
.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()
|
.widgetAccentable()
|
||||||
|
|
||||||
if let lunch = entry.lunch {
|
let rows = entry.compactMeals.filter { $0.name != nil }
|
||||||
Text("\(Image(systemName: "sun.max.fill")) \(lunch)")
|
ForEach(rows.prefix(2), id: \.self) { meal in
|
||||||
|
Text("\(Image(systemName: meal.icon)) \(meal.name ?? "")")
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
if let dinner = entry.dinner {
|
if rows.isEmpty {
|
||||||
Text("\(Image(systemName: "moon.stars.fill")) \(dinner)")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
if entry.lunch == nil && entry.dinner == nil {
|
|
||||||
Text("—")
|
Text("—")
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
}
|
}
|
||||||
@@ -238,8 +295,8 @@ private struct AccessoryInlineView: View {
|
|||||||
let entry: TodayMealEntry
|
let entry: TodayMealEntry
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
// Inline is a single line: show the next relevant meal.
|
// Inline is a single line: prefer the last main meal with a dish.
|
||||||
let name = entry.dinner ?? entry.lunch
|
let name = entry.compactMeals.reversed().compactMap(\.name).first
|
||||||
Label(name ?? "MealMood", systemImage: "fork.knife")
|
Label(name ?? "MealMood", systemImage: "fork.knife")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -283,7 +340,7 @@ struct MealMoodWidget: Widget {
|
|||||||
MealMoodWidgetView(entry: entry)
|
MealMoodWidgetView(entry: entry)
|
||||||
}
|
}
|
||||||
.configurationDisplayName("MealMood")
|
.configurationDisplayName("MealMood")
|
||||||
.description("Today's lunch and dinner at a glance.")
|
.description("Today's meals at a glance.")
|
||||||
.supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular, .accessoryInline])
|
.supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular, .accessoryInline])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user