import Foundation /// Snapshot of the week the iPhone pushes to the Watch (WatchConnectivity) and /// the watch widget reads from the watch-side app group. Labels and day titles /// arrive already localized — the watch never formats, only renders. /// Member of the iOS app, watch app and watch widget targets. struct WatchWeekPayload: Codable { struct Meal: Codable { let type: String // MealType rawValue, for the icon let label: String // localized ("Comida", "Cena"…) let name: String? // dish (+ secondary), "Comer fuera", or nil if empty } struct Day: Codable { let dayOfWeek: Int // 0=Monday … 6=Sunday let title: String // localized short title ("Lun 8") /// `isToday` as of the moment the iPhone built the snapshot. Kept for /// payloads written by older versions; `date` is what the watch should /// use, because a stored snapshot outlives the day it was made in. let isToday: Bool /// The actual calendar date of this day. Optional: snapshots stored by /// versions before 2.1.1 don't carry it. var date: Date? let meals: [Meal] init(dayOfWeek: Int, title: String, isToday: Bool, date: Date? = nil, meals: [Meal]) { self.dayOfWeek = dayOfWeek self.title = title self.isToday = isToday self.date = date self.meals = meals } /// Whether this day is today *now*, asked at render time. func isCurrentDay(now: Date = Date(), calendar: Calendar = .current) -> Bool { if let date { return calendar.isDate(date, inSameDayAs: now) } // Pre-2.1.1 snapshot: fall back to the weekday offset, which is only // right while the snapshot belongs to the current week. return dayOfWeek == WatchWeekPayload.weekdayOffset(for: now, calendar: calendar) } /// Nothing planned at all for this day. var isEmpty: Bool { meals.allSatisfy { $0.name == nil } } } let weekTitle: String // localized week range ("8 – 14 sep") let days: [Day] let updatedAt: Date /// Localized texts the watch cannot build on its own — its bundle carries no /// strings. Optional so older snapshots still decode. var emptyMealText: String? var emptyDayText: String? var staleText: String? /// "Updated" — shown next to `updatedAt` so it's visible at a glance when /// the watch is holding an old snapshot. var updatedLabel: String? static let appGroupID = "group.com.alexandrevazquez.mealmood" static let storageKey = "watch_week_payload_v1" func encoded() -> Data? { try? JSONEncoder().encode(self) } static func decode(_ data: Data) -> WatchWeekPayload? { try? JSONDecoder().decode(WatchWeekPayload.self, from: data) } /// Reads the last snapshot stored on this device's app group. static func stored() -> WatchWeekPayload? { guard let data = UserDefaults(suiteName: appGroupID)?.data(forKey: storageKey) else { return nil } return decode(data) } func store() { UserDefaults(suiteName: Self.appGroupID)?.set(encoded(), forKey: Self.storageKey) } /// 0=Monday … 6=Sunday, matching `MealSlot.dayOfWeek`. static func weekdayOffset(for date: Date, calendar: Calendar = .current) -> Int { (calendar.component(.weekday, from: date) + 5) % 7 } /// The day to show as "today", resolved when the view renders rather than /// when the iPhone built the snapshot. Returns nil when the snapshot is from /// another week and simply doesn't contain today. func currentDay(now: Date = Date(), calendar: Calendar = .current) -> Day? { if days.contains(where: { $0.date != nil }) { return days.first { $0.isCurrentDay(now: now, calendar: calendar) } } // Snapshot written before 2.1.1: no dates, so the weekday offset is the // only handle — and it's trustworthy only while the snapshot itself // belongs to the current week. Otherwise last week's Saturday would // pass for today. guard calendar.isDate(updatedAt, equalTo: now, toGranularity: .weekOfYear) else { return nil } let offset = Self.weekdayOffset(for: now, calendar: calendar) return days.first { $0.dayOfWeek == offset } } /// True when the snapshot no longer covers today, so showing any of its days /// would be showing the wrong meal. func isStale(now: Date = Date(), calendar: Calendar = .current) -> Bool { currentDay(now: now, calendar: calendar) == nil } static func icon(for mealType: String) -> String { switch mealType { case "breakfast": return "cup.and.saucer.fill" case "lunch": return "sun.max.fill" case "snack": return "carrot.fill" case "dinner": return "moon.stars.fill" default: return "fork.knife" } } }