Files
FamilyMealPlanner/MealMoodWidget/MealMoodWidget.swift
T
alexandrev-tibco e15ff93465 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
2026-07-12 18:00:50 +02:00

347 lines
12 KiB
Swift

import WidgetKit
import SwiftUI
import Foundation
// MARK: - Shared data model (mirrors WidgetDataStore in main app)
private struct TodayMealData: Codable {
struct Meal: Codable {
let type: String
let label: String
let name: String?
}
let meals: [Meal]
let weekdayName: String
let updatedAt: Date
}
private enum WidgetStore {
static let appGroupID = "group.com.alexandrevazquez.mealmood"
static let key = "mealmood.today_meals"
static func read() -> TodayMealData? {
guard let defaults = UserDefaults(suiteName: appGroupID),
let data = defaults.data(forKey: key) else { return nil }
return try? JSONDecoder().decode(TodayMealData.self, from: data)
}
}
// 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 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(),
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) {
completion(entry(from: WidgetStore.read()))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<TodayMealEntry>) -> Void) {
let nextMidnight = Calendar.current.startOfDay(for: Calendar.current.date(byAdding: .day, value: 1, to: Date())!)
completion(Timeline(entries: [entry(from: WidgetStore.read())], policy: .after(nextMidnight)))
}
private func entry(from data: TodayMealData?) -> TodayMealEntry {
TodayMealEntry(
date: Date(),
meals: (data?.meals ?? []).map {
MealItem(type: $0.type, label: $0.label, name: $0.name)
},
weekdayName: data?.weekdayName ?? ""
)
}
}
// MARK: - Colors
private extension Color {
static let mmCoral = Color(red: 0.93, green: 0.42, blue: 0.31)
static let mmBgWarm = Color(red: 1.00, green: 0.94, blue: 0.91)
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)
}
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
private struct SmallWidgetView: View {
let entry: TodayMealEntry
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 4) {
Image(systemName: "fork.knife")
.font(.system(size: 10, weight: .bold))
.foregroundColor(.mmCoral)
Text("MealMood")
.font(.system(size: 11, weight: .bold))
.foregroundColor(.mmCoral)
Spacer()
}
Text(entry.weekdayName)
.font(.system(size: 10, weight: .medium))
.foregroundColor(.mmSubtext)
.lineLimit(1)
.padding(.top, 2)
.padding(.bottom, 10)
Spacer()
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)
.background(
LinearGradient(colors: [.mmBgWarm, .mmBgMint],
startPoint: .topLeading, endPoint: .bottomTrailing)
)
}
private func mealRow(_ meal: MealItem) -> some View {
HStack(spacing: 8) {
Image(systemName: meal.icon)
.font(.system(size: 13))
.foregroundColor(meal.color)
.frame(width: 18)
VStack(alignment: .leading, spacing: 1) {
Text(meal.label.uppercased())
.font(.system(size: 8, weight: .semibold))
.foregroundColor(.mmSubtext)
Text(meal.name ?? "")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.mmText)
.lineLimit(2)
.minimumScaleFactor(0.75)
}
}
}
}
// MARK: - Medium widget
private struct MediumWidgetView: View {
let entry: TodayMealEntry
var body: some View {
HStack(spacing: 0) {
VStack(alignment: .leading, spacing: 6) {
Image(systemName: "fork.knife")
.font(.system(size: 18, weight: .bold))
.foregroundColor(.mmCoral)
Spacer()
Text(entry.weekdayName)
.font(.system(size: 16, weight: .bold))
.foregroundColor(.mmText)
.lineLimit(1)
.minimumScaleFactor(0.7)
Text(Date(), style: .date)
.font(.system(size: 11, weight: .medium))
.foregroundColor(.mmSubtext)
}
.padding(14)
.frame(width: 112)
.frame(maxHeight: .infinity, alignment: .leading)
.background(Color.mmBgWarm)
Rectangle()
.fill(Color.mmCoral.opacity(0.2))
.frame(width: 1)
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)
.background(Color.white)
}
}
private func mediumRow(_ meal: MealItem, compact: Bool) -> some View {
HStack(spacing: 10) {
Image(systemName: meal.icon)
.font(.system(size: compact ? 13 : 16))
.foregroundColor(meal.color)
.frame(width: 22)
if compact {
Text(meal.label.uppercased())
.font(.system(size: 9, weight: .semibold))
.foregroundColor(.mmSubtext)
.frame(width: 70, alignment: .leading)
Text(meal.name ?? "")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.mmText)
.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)
}
}
}
}
}
// MARK: - Lock Screen widgets
private struct AccessoryRectangularView: View {
let entry: TodayMealEntry
var body: some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 3) {
Image(systemName: "fork.knife")
.font(.system(size: 10, weight: .bold))
Text(entry.weekdayName.isEmpty ? "MealMood" : entry.weekdayName)
.font(.system(size: 12, weight: .bold))
.lineLimit(1)
}
.widgetAccentable()
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 rows.isEmpty {
Text("")
.font(.system(size: 12))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private struct AccessoryInlineView: View {
let entry: TodayMealEntry
var body: some View {
// 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")
}
}
// MARK: - Widget entry view
struct MealMoodWidgetView: View {
var entry: Provider.Entry
@Environment(\.widgetFamily) var family
var body: some View {
Group {
switch family {
case .systemMedium:
MediumWidgetView(entry: entry)
.containerBackground(for: .widget) { Color.white }
case .accessoryRectangular:
AccessoryRectangularView(entry: entry)
.containerBackground(for: .widget) { Color.clear }
case .accessoryInline:
AccessoryInlineView(entry: entry)
.containerBackground(for: .widget) { Color.clear }
default:
SmallWidgetView(entry: entry)
.containerBackground(for: .widget) { Color.white }
}
}
// Deep-link into the app's current week ("today") when tapped.
.widgetURL(URL(string: "mealmood://today"))
}
}
// MARK: - Widget definition
@main
struct MealMoodWidget: Widget {
let kind = "MealMoodWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
MealMoodWidgetView(entry: entry)
}
.configurationDisplayName("MealMood")
.description("Today's meals at a glance.")
.supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular, .accessoryInline])
}
}