Files
FamilyMealPlanner/MealMood/Services/WatchSyncService.swift
T
alexandrev-tibco a049e4f74b watch: el dia de hoy se resuelve al pintar, no al generar el envio
El iPhone marcaba isToday al construir el snapshot y el reloj se creia esa
marca para siempre. Como el snapshot sobrevive en el app group, un sabado
seguia enseñando la cena del miercoles: el dia que era cuando se genero.

Ahora cada dia viaja con su fecha real y el reloj busca hoy cuando dibuja. Si
el snapshot es de otra semana no hay "hoy" que enseñar, asi que en vez de
colar la comida de otro dia lo dice y pide abrir el iPhone. Para snapshots
guardados por versiones anteriores, que no traen fecha, solo se confia en el
indice del dia mientras el propio snapshot sea de esta semana.

Ademas el reloj pide datos al abrirse y el iPhone, en vez de responder con lo
ultimo que cacheo, reconstruye la semana desde el store — que es la otra mitad
del "no se actualiza": el cache podia ser de hace dias.

Y cuando no hay nada planificado se dice, que antes era un guion indistinguible
de una comida marcada como no planificada: "Hoy no hay nada planificado" para
el dia entero y "Sin planificar" por comida, en los 6 idiomas (el bundle del
reloj no tiene cadenas propias, viajan en el envio).

Refs #34, #35

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013su1ttRiMeMYxkZJ1Y3246
2026-09-12 13:21:21 +02:00

157 lines
7.0 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?
/// 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<AppSettings>()).first else { return nil }
let weekStart = Date().startOfWeek()
let descriptor = FetchDescriptor<WeekPlan>(
predicate: #Predicate<WeekPlan> { plan in plan.weekStartDate == weekStart }
)
guard let plan = try? context.fetch(descriptor).first else { return nil }
let dishes = (try? context.fetch(FetchDescriptor<Dish>())) ?? []
return Self.makePayload(plan: plan, dishes: dishes, settings: settings)?.encoded()
}
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 = 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 = plan.weekStartDate.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
)
}
return WatchWeekPayload(
weekTitle: plan.weekStartDate.formattedWeekRange(),
days: days,
updatedAt: Date(),
emptyMealText: String(localized: "watch_empty_meal"),
emptyDayText: String(localized: "watch_empty_day"),
staleText: String(localized: "watch_stale")
)
}
// 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() }
}