Files
FamilyMealPlanner/MealMood/Services/WatchSyncService.swift
T
alexandrev-tibco 4579b9bb69 watch: enviar la semana aunque todavia no este planificada
Faltaba la otra mitad del "no se actualiza": makePayload devolvia nil cuando la
semana en curso no tenia plan creado, asi que el iPhone no mandaba nada — ni
respondia cuando el reloj pedia datos — y el reloj se quedaba con el ultimo
snapshot, el del miercoles. Ahora la semana viaja igualmente, vacia, y el reloj
dice que no hay nada planificado en vez de enseñar lo de hace dias.

Ademas el reloj muestra cuando se actualizo por ultima vez. Sin eso, un
snapshot viejo y uno recien llegado se ven igual, y no hay forma de saber si
el problema es el envio o lo que se pinta.

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 16:32:34 +02:00

164 lines
7.5 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 }
)
// No plan for this week yet? Answer with the empty week anyway.
let plan = try? context.fetch(descriptor).first
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])
}
/// 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? {
let weekStart = plan?.weekStartDate ?? Date().startOfWeek()
let slots = plan?.slotList ?? []
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 = weekStart == 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 = 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
)
}
return WatchWeekPayload(
weekTitle: weekStart.formattedWeekRange(),
days: days,
updatedAt: Date(),
emptyMealText: String(localized: "watch_empty_meal"),
emptyDayText: String(localized: "watch_empty_day"),
staleText: String(localized: "watch_stale"),
updatedLabel: String(localized: "watch_updated")
)
}
// 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() }
}