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