import WidgetKit import SwiftUI /// Watch complication: today's meals from the last snapshot the watch app /// stored in the app group. Refreshed via WidgetCenter whenever the watch app /// receives new data from the iPhone. @main struct MealMoodWatchWidgetBundle: WidgetBundle { var body: some Widget { MealMoodWatchWidget() MealMoodWatchDaysWidget() } } struct TodayEntry: TimelineEntry { let date: Date let day: WatchWeekPayload.Day? } struct TodayProvider: TimelineProvider { func placeholder(in context: Context) -> TodayEntry { TodayEntry(date: Date(), day: sampleDay) } func getSnapshot(in context: Context, completion: @escaping (TodayEntry) -> Void) { completion(TodayEntry(date: Date(), day: currentDay ?? sampleDay)) } func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { let entry = TodayEntry(date: Date(), day: currentDay) // Refresh after midnight so "today" rolls over even without new pushes. let nextMidnight = Calendar.current.startOfDay(for: Date()).addingTimeInterval(86_400 + 300) completion(Timeline(entries: [entry], policy: .after(nextMidnight))) } private var currentDay: WatchWeekPayload.Day? { guard let payload = WatchWeekPayload.stored() else { return nil } let todayOffset = (Calendar.current.component(.weekday, from: Date()) + 5) % 7 return payload.days.first { $0.dayOfWeek == todayOffset } ?? payload.days.first(where: \.isToday) } private var sampleDay: WatchWeekPayload.Day { WatchWeekPayload.Day( dayOfWeek: 0, title: "Lun 8", isToday: true, meals: [ .init(type: "lunch", label: "Comida", name: "Ensalada César"), .init(type: "dinner", label: "Cena", name: "Salmón al horno") ] ) } } struct MealMoodWatchWidget: Widget { var body: some WidgetConfiguration { StaticConfiguration(kind: "MealMoodWatchToday", provider: TodayProvider()) { entry in TodayComplicationView(entry: entry) .containerBackground(.clear, for: .widget) } .configurationDisplayName("MealMood") .supportedFamilies([.accessoryRectangular, .accessoryInline, .accessoryCircular]) } } struct TodayComplicationView: View { @Environment(\.widgetFamily) private var family let entry: TodayEntry var body: some View { switch family { case .accessoryInline: if let meal = nextMeal { Text("\(meal.label): \(meal.name ?? "—")") } else { Text("MealMood") } case .accessoryCircular: VStack(spacing: 0) { Image(systemName: WatchWeekPayload.icon(for: nextMeal?.type ?? "dinner")) .font(.system(size: 14, weight: .semibold)) Text(nextMeal?.name?.prefix(6) ?? "—") .font(.system(size: 9)) .lineLimit(1) } default: VStack(alignment: .leading, spacing: 2) { if let day = entry.day { // The rectangular slot fits ~3 lines: with fewer meals the // text scales up so the space isn't wasted. let meals = Array(day.meals.prefix(3)) let nameSize: CGFloat = meals.count <= 1 ? 17 : (meals.count == 2 ? 15 : 12) ForEach(Array(meals.enumerated()), id: \.offset) { _, meal in HStack(spacing: 4) { Image(systemName: WatchWeekPayload.icon(for: meal.type)) .font(.system(size: nameSize - 3, weight: .bold)) .foregroundStyle(accent(for: meal.type)) .widgetAccentable() .frame(width: nameSize) Text(meal.name ?? "—") .font(.system(size: nameSize, weight: .semibold, design: .rounded)) .lineLimit(meals.count <= 1 ? 3 : (meals.count == 2 ? 2 : 1)) .minimumScaleFactor(0.75) } } } else { Text(verbatim: "MealMood") .font(.system(size: 12, weight: .semibold, design: .rounded)) Image(systemName: "iphone.and.arrow.forward") .font(.system(size: 10)) .foregroundStyle(.secondary) } } .frame(maxWidth: .infinity, alignment: .leading) } } private func accent(for mealType: String) -> Color { Self.accentColor(for: mealType) } static func accentColor(for mealType: String) -> Color { switch mealType { case "breakfast": return Color(red: 0.95, green: 0.68, blue: 0.25) case "lunch": return Color(red: 0.98, green: 0.55, blue: 0.30) case "snack": return Color(red: 0.62, green: 0.50, blue: 0.83) case "dinner": return Color(red: 0.28, green: 0.62, blue: 0.55) default: return Color(red: 1.0, green: 0.45, blue: 0.35) } } /// Lunch until 16:00 local, dinner after — simple heuristic for the small families. private var nextMeal: WatchWeekPayload.Meal? { guard let meals = entry.day?.meals, !meals.isEmpty else { return nil } let hour = Calendar.current.component(.hour, from: entry.date) if hour >= 16, let dinner = meals.last(where: { $0.name != nil }) { return dinner } return meals.first(where: { $0.name != nil }) ?? meals.first } } // MARK: - Next days complication struct DaysEntry: TimelineEntry { let date: Date let days: [WatchWeekPayload.Day] } struct DaysProvider: TimelineProvider { func placeholder(in context: Context) -> DaysEntry { DaysEntry(date: Date(), days: sampleDays) } func getSnapshot(in context: Context, completion: @escaping (DaysEntry) -> Void) { let days = upcomingDays completion(DaysEntry(date: Date(), days: days.isEmpty ? sampleDays : days)) } func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { let entry = DaysEntry(date: Date(), days: upcomingDays) let nextMidnight = Calendar.current.startOfDay(for: Date()).addingTimeInterval(86_400 + 300) completion(Timeline(entries: [entry], policy: .after(nextMidnight))) } /// Today and the next two planned days (falls back to the week's first /// days when today is outside the planned range, e.g. weekends off). private var upcomingDays: [WatchWeekPayload.Day] { guard let payload = WatchWeekPayload.stored() else { return [] } let todayOffset = (Calendar.current.component(.weekday, from: Date()) + 5) % 7 let fromToday = payload.days.filter { $0.dayOfWeek >= todayOffset } let source = fromToday.isEmpty ? payload.days : fromToday return Array(source.prefix(3)) } private var sampleDays: [WatchWeekPayload.Day] { [ .init(dayOfWeek: 0, title: "Lun 8", isToday: true, meals: [ .init(type: "lunch", label: "Comida", name: "Ensalada"), .init(type: "dinner", label: "Cena", name: "Salmón")]), .init(dayOfWeek: 1, title: "Mar 9", isToday: false, meals: [ .init(type: "dinner", label: "Cena", name: "Lentejas")]), .init(dayOfWeek: 2, title: "Mié 10", isToday: false, meals: [ .init(type: "dinner", label: "Cena", name: "Pizza casera")]) ] } } struct MealMoodWatchDaysWidget: Widget { var body: some WidgetConfiguration { StaticConfiguration(kind: "MealMoodWatchDays", provider: DaysProvider()) { entry in DaysComplicationView(entry: entry) .containerBackground(.clear, for: .widget) } .configurationDisplayName("MealMood — 3 días") .supportedFamilies([.accessoryRectangular]) } } struct DaysComplicationView: View { let entry: DaysEntry var body: some View { VStack(alignment: .leading, spacing: 1) { if entry.days.isEmpty { Text(verbatim: "MealMood") .font(.system(size: 12, weight: .semibold, design: .rounded)) Image(systemName: "iphone.and.arrow.forward") .font(.system(size: 10)) .foregroundStyle(.secondary) } else { ForEach(entry.days, id: \.dayOfWeek) { day in HStack(alignment: .firstTextBaseline, spacing: 4) { Text(dayAbbrev(day)) .font(.system(size: 11, weight: .heavy, design: .rounded)) .foregroundStyle(day.isToday ? Color(red: 1.0, green: 0.45, blue: 0.35) : .secondary) .widgetAccentable() .frame(width: 28, alignment: .leading) Text(mealsLine(day)) .font(.system(size: 12, weight: .medium, design: .rounded)) .lineLimit(1) .minimumScaleFactor(0.7) } } } } .frame(maxWidth: .infinity, alignment: .leading) } private func dayAbbrev(_ day: WatchWeekPayload.Day) -> String { String(day.title.split(separator: " ").first ?? "").uppercased() } private func mealsLine(_ day: WatchWeekPayload.Day) -> String { let names = day.meals.compactMap(\.name) return names.isEmpty ? "—" : names.joined(separator: " · ") } }