import Foundation import WatchConnectivity import SwiftData /// iPhone → Watch push of the week snapshot. /// /// Two delivery paths, both needed: /// - `updateApplicationContext`: latest value delivered when the watch app /// next runs, even if unreachable now. Activation is asynchronous, so a /// payload pushed before the session activates is kept and flushed from /// `activationDidCompleteWith`. /// - `didReceiveMessage` reply: the watch asks for data on launch (wakes this /// app in background); we answer with the snapshot the iPhone keeps in its /// own app group for the iOS week widget. final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable { static let shared = WatchSyncService() private var pendingData: Data? /// Set at launch so a request from the watch can rebuild the snapshot from /// the store instead of replying with whatever was cached days ago. private var modelContainer: ModelContainer? private override init() { super.init() guard WCSession.isSupported() else { return } WCSession.default.delegate = self WCSession.default.activate() } /// Touch from app launch so activation happens before the first push. func activate() { /* init side effect */ } func configure(container: ModelContainer) { modelContainer = container } /// Rebuilds the snapshot for the current week straight from the store. /// Used when the watch asks: the cached one may be from another day — or /// another week — and would answer with the wrong meals. private func freshPayloadData() -> Data? { guard let modelContainer else { return nil } let context = ModelContext(modelContainer) guard let settings = try? context.fetch(FetchDescriptor()).first else { return nil } let plans = (try? context.fetch(FetchDescriptor())) ?? [] let dishes = (try? context.fetch(FetchDescriptor())) ?? [] return Self.makePayload(plans: plans, dishes: dishes, settings: settings)?.encoded() } func push(plans: [WeekPlan], dishes: [Dish], settings: AppSettings) { guard WCSession.isSupported() else { return } guard let payload = Self.makePayload(plans: plans, dishes: dishes, settings: settings), let data = payload.encoded() else { return } send(data) } private func send(_ data: Data) { guard WCSession.default.activationState == .activated else { pendingData = data return } try? WCSession.default.updateApplicationContext([WatchWeekPayload.storageKey: data]) } /// Builds the snapshot for a week. A missing plan is NOT a reason to stay /// quiet: without this the watch keeps whatever it had — which is how it /// went on showing Wednesday's dinner on Saturday. An empty week travels /// too, and the watch says there's nothing planned. static func makePayload(plan: WeekPlan?, dishes: [Dish], settings: AppSettings) -> WatchWeekPayload? { makePayload(plans: [plan].compactMap { $0 }, dishes: dishes, settings: settings) } /// Weeks sent to the watch: the previous one, the current one and the two /// ahead. Sending only the current week is why the watch couldn't move /// between weeks — it simply had nothing else. static let weekOffsets = [-1, 0, 1, 2] static func makePayload( plans: [WeekPlan], dishes: [Dish], settings: AppSettings, now: Date = Date() ) -> WatchWeekPayload? { let locale = Locale(identifier: settings.languageEnum.resolved().localeIdentifier) let dishById = Dictionary(dishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) let dayFormatter = DateFormatter() dayFormatter.locale = locale dayFormatter.setLocalizedDateFormatFromTemplate("EEE d") let currentWeekStart = now.startOfWeek() let planByWeek = Dictionary( plans.map { ($0.weekStartDate, $0) }, uniquingKeysWith: { first, _ in first } ) let weeks: [WatchWeekPayload.Week] = weekOffsets.map { offset in let weekStart = currentWeekStart.addingDays(offset * 7) let plan = planByWeek[weekStart] return WatchWeekPayload.Week( weekStartDate: weekStart, weekTitle: weekStart.formattedWeekRange(), days: days( weekStart: weekStart, plan: plan, settings: settings, dishById: dishById, dayFormatter: dayFormatter, locale: locale, now: now ) ) } guard let currentWeek = weeks.first(where: { $0.weekStartDate == currentWeekStart }) else { return nil } return WatchWeekPayload( weekTitle: currentWeek.weekTitle, days: currentWeek.days, updatedAt: now, weekStartDate: currentWeekStart, weeks: weeks, emptyMealText: String(localized: "watch_empty_meal"), emptyDayText: String(localized: "watch_empty_day"), staleText: String(localized: "watch_stale"), updatedLabel: String(localized: "watch_updated"), notPlannedTodayText: String(localized: "watch_not_planned_today"), nextUpLabel: String(localized: "watch_next_up") ) } private static func days( weekStart: Date, plan: WeekPlan?, settings: AppSettings, dishById: [UUID: Dish], dayFormatter: DateFormatter, locale: Locale, now: Date ) -> [WatchWeekPayload.Day] { let slots = plan?.slotList ?? [] let schedule = settings.schedule(for: plan) let todayOffset = (Calendar.current.component(.weekday, from: now) + 5) % 7 let isCurrentWeek = weekStart == now.startOfWeek() return schedule.dayRange.map { day in let meals: [WatchWeekPayload.Meal] = schedule.mealTypes.map { meal in let slot = slots.first { $0.dayOfWeek == day && $0.mealType == meal.rawValue } let label = String(localized: String.LocalizationValue(meal.localizedKey)) var name: String? if let slot { if slot.isEatingOut { name = String(localized: "slot_eating_out") } else if slot.isSkipped { name = String(localized: "slot_skipped") } else if let dishId = slot.dishId, let dish = dishById[dishId] { name = dish.name if let secondaryId = slot.secondaryDishId, let secondary = dishById[secondaryId] { name! += " + \(secondary.name)" } } } return WatchWeekPayload.Meal(type: meal.rawValue, label: label, name: name) } let dayDate = weekStart.addingDays(day) return WatchWeekPayload.Day( dayOfWeek: day, title: dayFormatter.string(from: dayDate).capitalized(with: locale), isToday: isCurrentWeek && day == todayOffset, // The real date travels so the watch can work out "today" when // it renders — `isToday` freezes the day the snapshot was made. date: dayDate, meals: meals ) } } // MARK: - WCSessionDelegate func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { guard activationState == .activated else { return } if let data = pendingData { pendingData = nil try? session.updateApplicationContext([WatchWeekPayload.storageKey: data]) } else if let fresh = freshPayloadData() { try? session.updateApplicationContext([WatchWeekPayload.storageKey: fresh]) } else if let stored = WatchWeekPayload.stored()?.encoded() { // Nothing pending this run, but ship the last known snapshot so a // freshly-paired watch has data without waiting for an edit. try? session.updateApplicationContext([WatchWeekPayload.storageKey: stored]) } } /// The watch asks on launch (which wakes this app in background). Answer /// with a freshly built snapshot, falling back to the stored one only if the /// store isn't reachable from here. func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { guard message["request"] as? String == "week" else { replyHandler([:]) return } if let data = freshPayloadData() ?? WatchWeekPayload.stored()?.encoded() { replyHandler([WatchWeekPayload.storageKey: data]) } else { replyHandler([:]) } } func sessionDidBecomeInactive(_ session: WCSession) {} func sessionDidDeactivate(_ session: WCSession) { session.activate() } }