From bd8090c845d1bbfe5e633e87f33a28b173fc5aa9 Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Tue, 8 Sep 2026 23:32:55 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG --- MealMood/ContentView.swift | 3 + MealMood/Services/WatchSyncService.swift | 51 ++++++++++-- MealMoodWatch/MealMoodWatchApp.swift | 36 ++++++++- MealMoodWatch/TodayView.swift | 77 +++++++++++++----- MealMoodWatch/WeekView.swift | 79 ++++++++++++++----- MealMoodWatchWidget/Info.plist | 2 +- MealMoodWatchWidget/MealMoodWatchWidget.swift | 21 ++++- MealMoodWidget/Info.plist | 2 +- 8 files changed, 216 insertions(+), 55 deletions(-) diff --git a/MealMood/ContentView.swift b/MealMood/ContentView.swift index ee50fd4..2295434 100644 --- a/MealMood/ContentView.swift +++ b/MealMood/ContentView.swift @@ -30,6 +30,9 @@ struct ContentView: View { .environment(\.locale, Locale(identifier: settings?.languageEnum.localeIdentifier ?? Locale.current.identifier)) // MARK: Transaction.updates listener (StoreKit 2 recommended pattern) // Fires for new purchases, renewals and revocations while the app is running. + // Activate WatchConnectivity as early as possible so the first widget + // refresh doesn't race the session activation. + .onAppear { WatchSyncService.shared.activate() } // This is the most reliable way to catch a purchase even if the app was // interrupted during the payment flow. .task { diff --git a/MealMood/Services/WatchSyncService.swift b/MealMood/Services/WatchSyncService.swift index 8752406..11c01af 100644 --- a/MealMood/Services/WatchSyncService.swift +++ b/MealMood/Services/WatchSyncService.swift @@ -2,13 +2,21 @@ import Foundation import WatchConnectivity import SwiftData -/// iPhone → Watch push of the week snapshot. Uses `updateApplicationContext`, -/// which delivers the latest value even if the watch is unreachable right now. +/// 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 { - // WCSession requires a stable delegate; state is confined to WCSession's - // internal queue, hence the @unchecked Sendable. static let shared = WatchSyncService() + private var pendingData: Data? + private override init() { super.init() guard WCSession.isSupported() else { return } @@ -16,14 +24,21 @@ final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable { WCSession.default.activate() } + /// Touch from app launch so activation happens before the first push. func activate() { /* init side effect */ } - /// Builds and pushes the current + visible week. Call wherever the iOS - /// widget is refreshed so both stay in sync. func push(plan: WeekPlan?, dishes: [Dish], settings: AppSettings) { - guard WCSession.isSupported(), WCSession.default.activationState == .activated else { return } + 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]) } @@ -76,7 +91,27 @@ final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable { // MARK: - WCSessionDelegate - func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {} + 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() } } diff --git a/MealMoodWatch/MealMoodWatchApp.swift b/MealMoodWatch/MealMoodWatchApp.swift index bbcfb28..ff8934e 100644 --- a/MealMoodWatch/MealMoodWatchApp.swift +++ b/MealMoodWatch/MealMoodWatchApp.swift @@ -18,8 +18,25 @@ struct MealMoodWatchApp: App { } } +/// Shared palette for the watch UI — same family as the iOS app. +enum WatchTheme { + static let coral = Color(red: 1.0, green: 0.45, blue: 0.35) + + static func accent(for mealType: String) -> Color { + switch mealType { + case "breakfast": return Color(red: 0.95, green: 0.68, blue: 0.25) + case "lunch": return Color(red: 0.98, green: 0.55, blue: 0.30) + case "snack": return Color(red: 0.62, green: 0.50, blue: 0.83) + case "dinner": return Color(red: 0.28, green: 0.62, blue: 0.55) + default: return coral + } + } +} + /// Receives the week snapshot from the iPhone and persists it in the watch's -/// app group so the complication can read it too. +/// app group so the complication can read it too. On launch it also *asks* +/// the iPhone (which wakes in background) so a fresh install shows data +/// immediately instead of waiting for the next push. final class WatchWeekStore: NSObject, ObservableObject, WCSessionDelegate { static let shared = WatchWeekStore() @@ -42,11 +59,26 @@ final class WatchWeekStore: NSObject, ObservableObject, WCSessionDelegate { } } + private func requestFromPhone(_ session: WCSession) { + guard session.isReachable else { return } + session.sendMessage(["request": "week"], replyHandler: { [weak self] reply in + if let data = reply[WatchWeekPayload.storageKey] as? Data { + self?.apply(data) + } + }, errorHandler: nil) + } + func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { - // Pick up a context delivered while the app wasn't running. + // Pick up a context delivered while the app wasn't running… if let data = session.receivedApplicationContext[WatchWeekPayload.storageKey] as? Data { apply(data) } + // …and pull fresh data from the phone if it's around. + requestFromPhone(session) + } + + func sessionReachabilityDidChange(_ session: WCSession) { + if payload == nil { requestFromPhone(session) } } func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { diff --git a/MealMoodWatch/TodayView.swift b/MealMoodWatch/TodayView.swift index c4ce732..dc4f765 100644 --- a/MealMoodWatch/TodayView.swift +++ b/MealMoodWatch/TodayView.swift @@ -1,54 +1,91 @@ import SwiftUI +/// Today page — Weather-app style: a bold colored header with the day, then +/// one vivid gradient card per meal. struct TodayView: View { @EnvironmentObject private var store: WatchWeekStore private var today: WatchWeekPayload.Day? { - store.payload?.days.first(where: \.isToday) + store.payload?.days.first(where: \.isToday) ?? store.payload?.days.first } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 8) { if let today { - Text(today.title) - .font(.headline) - .foregroundStyle(Color(red: 1.0, green: 0.45, blue: 0.35)) + HStack(alignment: .firstTextBaseline) { + Text(today.title) + .font(.system(.title3, design: .rounded, weight: .bold)) + .foregroundStyle(WatchTheme.coral) + Spacer() + Image(systemName: "fork.knife") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 2) ForEach(Array(today.meals.enumerated()), id: \.offset) { _, meal in - VStack(alignment: .leading, spacing: 2) { - Label(meal.label, systemImage: WatchWeekPayload.icon(for: meal.type)) - .font(.caption2) - .foregroundStyle(.secondary) - Text(meal.name ?? "—") - .font(.body.weight(.medium)) - .lineLimit(2) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(8) - .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8)) + MealCard(meal: meal) } } else { EmptySyncView() } } } - .navigationTitle("MealMood") + } +} + +struct MealCard: View { + let meal: WatchWeekPayload.Meal + + private var accent: Color { WatchTheme.accent(for: meal.type) } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 5) { + Image(systemName: WatchWeekPayload.icon(for: meal.type)) + .font(.system(size: 11, weight: .bold)) + Text(meal.label.uppercased()) + .font(.system(size: 11, weight: .bold, design: .rounded)) + .tracking(0.5) + } + .foregroundStyle(.white.opacity(0.85)) + + Text(meal.name ?? "—") + .font(.system(.body, design: .rounded, weight: .semibold)) + .foregroundStyle(.white) + .lineLimit(2) + .minimumScaleFactor(0.8) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 9) + .background( + LinearGradient( + colors: [accent, accent.opacity(0.65)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + in: RoundedRectangle(cornerRadius: 12) + ) } } struct EmptySyncView: View { var body: some View { VStack(spacing: 8) { - Image(systemName: "iphone.and.arrow.forward") + Image(systemName: "applewatch.radiowaves.left.and.right") .font(.title3) - .foregroundStyle(.secondary) - // The payload arrives fully localized from the iPhone; the watch + .foregroundStyle(WatchTheme.coral) + // Payload arrives fully localized from the iPhone; the watch // bundle carries no strings of its own, so this stays neutral. Text(verbatim: "MealMood") + .font(.system(.caption2, design: .rounded, weight: .semibold)) + Image(systemName: "iphone.gen3") .font(.caption2) .foregroundStyle(.secondary) } - .padding(.top, 20) + .frame(maxWidth: .infinity) + .padding(.top, 24) } } diff --git a/MealMoodWatch/WeekView.swift b/MealMoodWatch/WeekView.swift index 6ef672f..0b13d3d 100644 --- a/MealMoodWatch/WeekView.swift +++ b/MealMoodWatch/WeekView.swift @@ -1,31 +1,22 @@ import SwiftUI +/// Week page — Calendar-app style: day number in a circle on the left (today +/// filled in coral), meals with tinted icons on the right. struct WeekView: View { @EnvironmentObject private var store: WatchWeekStore var body: some View { Group { if let payload = store.payload { - List { - Section(payload.weekTitle) { + ScrollView { + VStack(alignment: .leading, spacing: 6) { + Text(payload.weekTitle) + .font(.system(.footnote, design: .rounded, weight: .bold)) + .foregroundStyle(WatchTheme.coral) + .padding(.horizontal, 2) + ForEach(payload.days, id: \.dayOfWeek) { day in - VStack(alignment: .leading, spacing: 3) { - Text(day.title) - .font(.caption.weight(.semibold)) - .foregroundStyle(day.isToday - ? Color(red: 1.0, green: 0.45, blue: 0.35) - : .primary) - ForEach(Array(day.meals.enumerated()), id: \.offset) { _, meal in - HStack(spacing: 4) { - Image(systemName: WatchWeekPayload.icon(for: meal.type)) - .font(.system(size: 10)) - .foregroundStyle(.secondary) - Text(meal.name ?? "—") - .font(.caption2) - .lineLimit(1) - } - } - } + DayRow(day: day) } } } @@ -35,3 +26,53 @@ struct WeekView: View { } } } + +private struct DayRow: View { + let day: WatchWeekPayload.Day + + /// "Lun 8" → ("Lun", "8") + private var parts: (name: String, number: String) { + let pieces = day.title.split(separator: " ", maxSplits: 1) + guard pieces.count == 2 else { return (day.title, "") } + return (String(pieces[0]), String(pieces[1])) + } + + var body: some View { + HStack(alignment: .top, spacing: 8) { + VStack(spacing: 0) { + Text(parts.name.uppercased()) + .font(.system(size: 9, weight: .bold, design: .rounded)) + .foregroundStyle(day.isToday ? WatchTheme.coral : .secondary) + Text(parts.number) + .font(.system(size: 15, weight: .bold, design: .rounded)) + .foregroundStyle(day.isToday ? .white : .primary) + .frame(width: 26, height: 26) + .background( + Circle().fill(day.isToday ? WatchTheme.coral : .clear) + ) + } + .frame(width: 32) + + VStack(alignment: .leading, spacing: 3) { + ForEach(Array(day.meals.enumerated()), id: \.offset) { _, meal in + HStack(spacing: 4) { + Image(systemName: WatchWeekPayload.icon(for: meal.type)) + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(WatchTheme.accent(for: meal.type)) + .frame(width: 12) + Text(meal.name ?? "—") + .font(.system(size: 13, design: .rounded)) + .lineLimit(1) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 5) + .padding(.horizontal, 7) + .background( + day.isToday ? WatchTheme.coral.opacity(0.16) : Color.white.opacity(0.07), + in: RoundedRectangle(cornerRadius: 9) + ) + } + } +} diff --git a/MealMoodWatchWidget/Info.plist b/MealMoodWatchWidget/Info.plist index 4fbaab6..cdf3c3e 100644 --- a/MealMoodWatchWidget/Info.plist +++ b/MealMoodWatchWidget/Info.plist @@ -17,7 +17,7 @@ CFBundleShortVersionString 2.1.0 CFBundleVersion - 80 + 81 NSExtension NSExtensionPointIdentifier diff --git a/MealMoodWatchWidget/MealMoodWatchWidget.swift b/MealMoodWatchWidget/MealMoodWatchWidget.swift index 259b42e..6e27d86 100644 --- a/MealMoodWatchWidget/MealMoodWatchWidget.swift +++ b/MealMoodWatchWidget/MealMoodWatchWidget.swift @@ -84,17 +84,20 @@ struct TodayComplicationView: View { VStack(alignment: .leading, spacing: 2) { if let day = entry.day { ForEach(Array(day.meals.prefix(3).enumerated()), id: \.offset) { _, meal in - HStack(spacing: 3) { + HStack(spacing: 4) { Image(systemName: WatchWeekPayload.icon(for: meal.type)) - .font(.system(size: 9)) + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(accent(for: meal.type)) + .widgetAccentable() + .frame(width: 11) Text(meal.name ?? "—") - .font(.system(size: 12, weight: .medium)) + .font(.system(size: 12, weight: .semibold, design: .rounded)) .lineLimit(1) } } } else { Text(verbatim: "MealMood") - .font(.system(size: 12, weight: .semibold)) + .font(.system(size: 12, weight: .semibold, design: .rounded)) Image(systemName: "iphone.and.arrow.forward") .font(.system(size: 10)) .foregroundStyle(.secondary) @@ -104,6 +107,16 @@ struct TodayComplicationView: View { } } + private func accent(for mealType: String) -> Color { + switch mealType { + case "breakfast": return Color(red: 0.95, green: 0.68, blue: 0.25) + case "lunch": return Color(red: 0.98, green: 0.55, blue: 0.30) + case "snack": return Color(red: 0.62, green: 0.50, blue: 0.83) + case "dinner": return Color(red: 0.28, green: 0.62, blue: 0.55) + default: return Color(red: 1.0, green: 0.45, blue: 0.35) + } + } + /// Lunch until 16:00 local, dinner after — simple heuristic for the small families. private var nextMeal: WatchWeekPayload.Meal? { guard let meals = entry.day?.meals, !meals.isEmpty else { return nil } diff --git a/MealMoodWidget/Info.plist b/MealMoodWidget/Info.plist index 4fbaab6..cdf3c3e 100644 --- a/MealMoodWidget/Info.plist +++ b/MealMoodWidget/Info.plist @@ -17,7 +17,7 @@ CFBundleShortVersionString 2.1.0 CFBundleVersion - 80 + 81 NSExtension NSExtensionPointIdentifier