Files
FamilyMealPlanner/MealMood/Services/WatchSyncService.swift
T
alexandrev-tibco bd8090c845 watch: fix del sync que nunca llegaba y rediseno estilo Calendar/Weather
- 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
2026-09-08 23:32:55 +02:00

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() }
}