bd8090c845
- El primer push se descartaba: updateApplicationContext se llamaba antes de que WCSession terminara de activarse (activacion asincrona). Ahora la sesion se activa al arrancar la app (ContentView), el payload pendiente se envia al completarse la activacion, y ademas el reloj PIDE los datos al abrirse (sendMessage despierta al iPhone en segundo plano y responde con el snapshot del app group) - Rediseno: Hoy con cabecera del dia en coral y tarjetas degradadas por comida (estilo Weather); Semana con circulo de dia a la izquierda y hoy relleno en coral (estilo Calendar); complicacion con iconos tintados por comida y widgetAccentable - Verificado con capturas en simulador de Watch sembrando el app group Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
118 lines
5.1 KiB
Swift
118 lines
5.1 KiB
Swift
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 range = settings.includeWeekends ? 0...6 : 0...4
|
|
|
|
let days: [WatchWeekPayload.Day] = range.map { day in
|
|
let meals: [WatchWeekPayload.Meal] = settings.activeMealTypes.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() }
|
|
}
|