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 Week: Codable, Identifiable { let weekStartDate: Date let weekTitle: String let days: [Day] var id: Date { weekStartDate } func isCurrentWeek(now: Date = Date(), calendar: Calendar = .current) -> Bool { WatchWeekPayload.weekAligned(calendar).isDate(weekStartDate, equalTo: now, toGranularity: .weekOfYear) } /// Days with at least one meal planned — what's worth showing. var plannedDays: [Day] { days.filter { !$0.isEmpty } } } 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 /// Monday of the week `days` belongs to. Optional: snapshots written before /// 2.1.2 don't carry it. var weekStartDate: Date? /// Every week the phone sent (previous, current and the next ones), so the /// watch can move between them. nil in older snapshots, which only ever /// carried the current week in `days`. var weeks: [Week]? /// 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? /// "Today you don't plan meals" — for the days left out of the plan /// (weekends turned off), which are not the same as being out of date. var notPlannedTodayText: String? /// "Next" — heading for the upcoming day shown instead. var nextUpLabel: 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) } /// The app defines a week as Monday→Sunday everywhere (`Date.startOfWeek`), /// but `Calendar.current.firstWeekday` is Sunday in some regions. Comparing /// weeks with the raw system calendar would put a Sunday in a different week /// from its own Monday — and make a perfectly current snapshot look stale. static func weekAligned(_ calendar: Calendar) -> Calendar { var aligned = calendar aligned.firstWeekday = 2 return aligned } /// 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 Self.weekAligned(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 { if case .outOfDate = todayState(now: now, calendar: calendar) { return true } return false } /// Whether the snapshot describes the week we're living in — regardless of /// whether today itself is one of the planned days. func coversCurrentWeek(now: Date = Date(), calendar: Calendar = .current) -> Bool { let aligned = Self.weekAligned(calendar) if let weekStartDate { return aligned.isDate(weekStartDate, equalTo: now, toGranularity: .weekOfYear) } if days.contains(where: { $0.date != nil }) { return days.contains { aligned.isDate($0.date!, equalTo: now, toGranularity: .weekOfYear) } } return aligned.isDate(updatedAt, equalTo: now, toGranularity: .weekOfYear) } /// What the watch should be showing right now. enum TodayState { /// Today is planned — here are its meals. case planned(Day) /// Today is deliberately not planned (weekends off). Shows the next day /// that does have meals, when there is one. case notPlannedToday(next: Day?) /// The snapshot is from another week: anything in it would be wrong. case outOfDate } func todayState(now: Date = Date(), calendar: Calendar = .current) -> TodayState { if let today = currentDay(now: now, calendar: calendar) { return .planned(today) } guard coversCurrentWeek(now: now, calendar: calendar) else { return .outOfDate } return .notPlannedToday(next: upcomingDay(now: now, calendar: calendar)) } /// The next day with something planned, looking across every week the phone /// sent — so a Sunday points at Monday's dinner instead of showing nothing. func upcomingDay(now: Date = Date(), calendar: Calendar = .current) -> Day? { let startOfToday = calendar.startOfDay(for: now) let candidates = (weeks?.flatMap(\.days) ?? days) .filter { !$0.isEmpty } .compactMap { day -> (Date, Day)? in guard let date = day.date else { return nil } return (date, day) } .filter { $0.0 >= startOfToday } .sorted { $0.0 < $1.0 } return candidates.first?.1 } 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" } } } extension Date { /// Monday of this date's week. The watch targets don't compile the iOS /// `Date+Helpers`, and this is the only piece of it they need. func startOfWeekOnWatch(calendar: Calendar = .current) -> Date { var cal = calendar cal.firstWeekday = 2 // Monday let components = cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: self) return cal.date(from: components) ?? self } }