bc684a099c
Dos cosas que faltaban, las dos visibles en domingo con el fin de semana desactivado: El envio al reloj solo llevaba la semana en curso, asi que no habia forma de ver las semanas ya planificadas por delante. Ahora viajan cuatro (la anterior, la actual y las dos siguientes) y la vista de semana se desliza entre ellas, arrancando en la actual. Y un dia fuera del plan dejaba al reloj sin nada que enseñar: el domingo no existe en el payload si no planificas fines de semana, asi que la complicacion salia vacia y la app decia "abre MealMood en el iPhone", como si fuera un problema de sincronizacion. Ahora se distingue: si hoy no se planifica se dice, y se enseña el proximo dia que si tiene comidas — que es lo util en el reloj. De paso, las semanas se comparan con el calendario alineado a lunes. Con el del sistema, en las regiones donde la semana empieza en domingo, un domingo caia en otra semana que su propio lunes y un envio recien hecho parecia caducado. Refs #36, #37 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013su1ttRiMeMYxkZJ1Y3246
212 lines
9.0 KiB
Swift
212 lines
9.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 plans = (try? context.fetch(FetchDescriptor<WeekPlan>())) ?? []
|
|
let dishes = (try? context.fetch(FetchDescriptor<Dish>())) ?? []
|
|
|
|
return Self.makePayload(plans: plans, dishes: dishes, settings: settings)?.encoded()
|
|
}
|
|
|
|
func push(plans: [WeekPlan], dishes: [Dish], settings: AppSettings) {
|
|
guard WCSession.isSupported() else { return }
|
|
guard let payload = Self.makePayload(plans: plans, 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? {
|
|
makePayload(plans: [plan].compactMap { $0 }, dishes: dishes, settings: settings)
|
|
}
|
|
|
|
/// Weeks sent to the watch: the previous one, the current one and the two
|
|
/// ahead. Sending only the current week is why the watch couldn't move
|
|
/// between weeks — it simply had nothing else.
|
|
static let weekOffsets = [-1, 0, 1, 2]
|
|
|
|
static func makePayload(
|
|
plans: [WeekPlan],
|
|
dishes: [Dish],
|
|
settings: AppSettings,
|
|
now: Date = Date()
|
|
) -> WatchWeekPayload? {
|
|
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 currentWeekStart = now.startOfWeek()
|
|
let planByWeek = Dictionary(
|
|
plans.map { ($0.weekStartDate, $0) },
|
|
uniquingKeysWith: { first, _ in first }
|
|
)
|
|
|
|
let weeks: [WatchWeekPayload.Week] = weekOffsets.map { offset in
|
|
let weekStart = currentWeekStart.addingDays(offset * 7)
|
|
let plan = planByWeek[weekStart]
|
|
return WatchWeekPayload.Week(
|
|
weekStartDate: weekStart,
|
|
weekTitle: weekStart.formattedWeekRange(),
|
|
days: days(
|
|
weekStart: weekStart,
|
|
plan: plan,
|
|
settings: settings,
|
|
dishById: dishById,
|
|
dayFormatter: dayFormatter,
|
|
locale: locale,
|
|
now: now
|
|
)
|
|
)
|
|
}
|
|
|
|
guard let currentWeek = weeks.first(where: { $0.weekStartDate == currentWeekStart }) else { return nil }
|
|
|
|
return WatchWeekPayload(
|
|
weekTitle: currentWeek.weekTitle,
|
|
days: currentWeek.days,
|
|
updatedAt: now,
|
|
weekStartDate: currentWeekStart,
|
|
weeks: weeks,
|
|
emptyMealText: String(localized: "watch_empty_meal"),
|
|
emptyDayText: String(localized: "watch_empty_day"),
|
|
staleText: String(localized: "watch_stale"),
|
|
updatedLabel: String(localized: "watch_updated"),
|
|
notPlannedTodayText: String(localized: "watch_not_planned_today"),
|
|
nextUpLabel: String(localized: "watch_next_up")
|
|
)
|
|
}
|
|
|
|
private static func days(
|
|
weekStart: Date,
|
|
plan: WeekPlan?,
|
|
settings: AppSettings,
|
|
dishById: [UUID: Dish],
|
|
dayFormatter: DateFormatter,
|
|
locale: Locale,
|
|
now: Date
|
|
) -> [WatchWeekPayload.Day] {
|
|
let slots = plan?.slotList ?? []
|
|
let schedule = settings.schedule(for: plan)
|
|
let todayOffset = (Calendar.current.component(.weekday, from: now) + 5) % 7
|
|
let isCurrentWeek = weekStart == now.startOfWeek()
|
|
|
|
return 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
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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() }
|
|
}
|