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? 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 push(plan: WeekPlan?, dishes: [Dish], settings: AppSettings) { guard WCSession.isSupported() else { return } guard let payload = Self.makePayload(plan: plan, 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]) } static func makePayload(plan: WeekPlan?, dishes: [Dish], settings: AppSettings) -> WatchWeekPayload? { guard let plan else { return nil } 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 todayOffset = (Calendar.current.component(.weekday, from: Date()) + 5) % 7 let isCurrentWeek = plan.weekStartDate == Date().startOfWeek() let schedule = settings.schedule(for: plan) let days: [WatchWeekPayload.Day] = schedule.dayRange.map { day in let meals: [WatchWeekPayload.Meal] = schedule.mealTypes.map { meal in let slot = plan.slotList.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 = "—" } 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 = plan.weekStartDate.addingDays(day) return WatchWeekPayload.Day( dayOfWeek: day, title: dayFormatter.string(from: dayDate).capitalized(with: locale), isToday: isCurrentWeek && day == todayOffset, meals: meals ) } return WatchWeekPayload( weekTitle: plan.weekStartDate.formattedWeekRange(), days: days, updatedAt: Date() ) } // 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 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; reply with the snapshot from the app group. func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { if message["request"] as? String == "week", let data = WatchWeekPayload.stored()?.encoded() { replyHandler([WatchWeekPayload.storageKey: data]) } else { replyHandler([:]) } } func sessionDidBecomeInactive(_ session: WCSession) {} func sessionDidDeactivate(_ session: WCSession) { session.activate() } }