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