diff --git a/MealMood.xcodeproj/project.pbxproj b/MealMood.xcodeproj/project.pbxproj index e49a874..cc0aa2c 100644 --- a/MealMood.xcodeproj/project.pbxproj +++ b/MealMood.xcodeproj/project.pbxproj @@ -59,6 +59,7 @@ 87EB371FD3B6C241AC721C55 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 8E15828872FAEA71A7384099 /* Localizable.strings */; }; 8B12FAE3CC07AFB284094C61 /* WeekSchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27337A07289450A1D494AC02 /* WeekSchedule.swift */; }; 8B4672C76B05BC1DFB5C979D /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5018BA4B3DC94CA7443CA9D1 /* TodayView.swift */; }; + 8C41C5C4D2676772E8351374 /* WatchWeekPayloadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 673BD35BD6F1EC366F9E9FA8 /* WatchWeekPayloadTests.swift */; }; 8CEAC8993D33BFBBEB5319A8 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EE10285FEE9BE0684CC66A3 /* NotificationService.swift */; }; 8DB624B469178CFD8CD87672 /* OnboardingFlowUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CBD59AC26D5C6D138D67E35 /* OnboardingFlowUITests.swift */; }; 8F739A4496501009B8D829B3 /* Color+MealMood.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB771E85924AE086647A5113 /* Color+MealMood.swift */; }; @@ -228,6 +229,7 @@ 60F0A5C2FF1B797A4BB69B39 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 623EDDC4CD134883FC9D242D /* Dish.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dish.swift; sourceTree = ""; }; 62E2F6B2694CEC97AE3C1B13 /* PremiumSyncServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PremiumSyncServiceTests.swift; sourceTree = ""; }; + 673BD35BD6F1EC366F9E9FA8 /* WatchWeekPayloadTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchWeekPayloadTests.swift; sourceTree = ""; }; 67CBDF2D2972139881AA59B1 /* WatchWeekPayload.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchWeekPayload.swift; sourceTree = ""; }; 683D03A3F2E26A2BA627B353 /* WeekView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WeekView.swift; sourceTree = ""; }; 6971B29A22117CC7621DB938 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS26.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; @@ -507,6 +509,7 @@ 62E2F6B2694CEC97AE3C1B13 /* PremiumSyncServiceTests.swift */, 69C0BDFEBA8B692530CB6B5E /* SpeechDictationServiceTests.swift */, 91C2BCF9D7A1458E045DA053 /* WeekScheduleTests.swift */, + 673BD35BD6F1EC366F9E9FA8 /* WatchWeekPayloadTests.swift */, ); path = MealMoodTests; sourceTree = ""; @@ -973,6 +976,7 @@ 2A58E7C96AF7C14EBFE1C65C /* PremiumSyncServiceTests.swift in Sources */, 64CC61B20F0AF602A6CC1114 /* SpeechDictationServiceTests.swift in Sources */, A569F5FED382E8EABC471416 /* WeekScheduleTests.swift in Sources */, + 8C41C5C4D2676772E8351374 /* WatchWeekPayloadTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/MealMood/MealMoodApp.swift b/MealMood/MealMoodApp.swift index 52918ea..1c45c87 100644 --- a/MealMood/MealMoodApp.swift +++ b/MealMood/MealMoodApp.swift @@ -81,6 +81,9 @@ struct MealMoodApp: App { init() { FirebaseApp.configure() + // The watch can ask for the week while the app is in the background, and + // answering that needs the store. + WatchSyncService.shared.configure(container: modelContainer) AnalyticsService.applyCollectionPolicy() #if canImport(GoogleMobileAds) GADMobileAds.sharedInstance().start(completionHandler: nil) diff --git a/MealMood/Resources/de.lproj/Localizable.strings b/MealMood/Resources/de.lproj/Localizable.strings index 84bc7ff..c92f13c 100644 --- a/MealMood/Resources/de.lproj/Localizable.strings +++ b/MealMood/Resources/de.lproj/Localizable.strings @@ -478,3 +478,8 @@ "week_schedule_warning_confirm" = "Trotzdem entfernen"; "toast_week_schedule_updated" = "Woche aktualisiert"; "toast_week_schedule_reset" = "Woche folgt wieder deinen Einstellungen"; + +/* Apple Watch */ +"watch_empty_meal" = "Nicht geplant"; +"watch_empty_day" = "Für heute ist nichts geplant"; +"watch_stale" = "Öffne MealMood auf dem iPhone, um diese Woche zu sehen"; diff --git a/MealMood/Resources/en.lproj/Localizable.strings b/MealMood/Resources/en.lproj/Localizable.strings index 2c18cbf..6281188 100644 --- a/MealMood/Resources/en.lproj/Localizable.strings +++ b/MealMood/Resources/en.lproj/Localizable.strings @@ -478,3 +478,8 @@ "week_schedule_warning_confirm" = "Remove anyway"; "toast_week_schedule_updated" = "Week updated"; "toast_week_schedule_reset" = "Week back to your general settings"; + +/* Apple Watch */ +"watch_empty_meal" = "Not planned"; +"watch_empty_day" = "Nothing planned for today"; +"watch_stale" = "Open MealMood on your iPhone to see this week"; diff --git a/MealMood/Resources/es.lproj/Localizable.strings b/MealMood/Resources/es.lproj/Localizable.strings index 1542b1e..67001f6 100644 --- a/MealMood/Resources/es.lproj/Localizable.strings +++ b/MealMood/Resources/es.lproj/Localizable.strings @@ -478,3 +478,8 @@ "week_schedule_warning_confirm" = "Quitar igualmente"; "toast_week_schedule_updated" = "Semana actualizada"; "toast_week_schedule_reset" = "Semana con tu configuración general"; + +/* Apple Watch */ +"watch_empty_meal" = "Sin planificar"; +"watch_empty_day" = "Hoy no hay nada planificado"; +"watch_stale" = "Abre MealMood en el iPhone para ver esta semana"; diff --git a/MealMood/Resources/fr.lproj/Localizable.strings b/MealMood/Resources/fr.lproj/Localizable.strings index 4c1662d..f1f05ad 100644 --- a/MealMood/Resources/fr.lproj/Localizable.strings +++ b/MealMood/Resources/fr.lproj/Localizable.strings @@ -478,3 +478,8 @@ "week_schedule_warning_confirm" = "Retirer quand même"; "toast_week_schedule_updated" = "Semaine mise à jour"; "toast_week_schedule_reset" = "Semaine revenue à vos réglages généraux"; + +/* Apple Watch */ +"watch_empty_meal" = "Non planifié"; +"watch_empty_day" = "Rien de prévu aujourd'hui"; +"watch_stale" = "Ouvrez MealMood sur l'iPhone pour voir cette semaine"; diff --git a/MealMood/Resources/it.lproj/Localizable.strings b/MealMood/Resources/it.lproj/Localizable.strings index e3da5e0..a310108 100644 --- a/MealMood/Resources/it.lproj/Localizable.strings +++ b/MealMood/Resources/it.lproj/Localizable.strings @@ -478,3 +478,8 @@ "week_schedule_warning_confirm" = "Rimuovi comunque"; "toast_week_schedule_updated" = "Settimana aggiornata"; "toast_week_schedule_reset" = "Settimana tornata alle impostazioni generali"; + +/* Apple Watch */ +"watch_empty_meal" = "Non pianificato"; +"watch_empty_day" = "Oggi non c'è niente di pianificato"; +"watch_stale" = "Apri MealMood sull'iPhone per vedere questa settimana"; diff --git a/MealMood/Resources/pt-BR.lproj/Localizable.strings b/MealMood/Resources/pt-BR.lproj/Localizable.strings index eb568a2..b7c067d 100644 --- a/MealMood/Resources/pt-BR.lproj/Localizable.strings +++ b/MealMood/Resources/pt-BR.lproj/Localizable.strings @@ -478,3 +478,8 @@ "week_schedule_warning_confirm" = "Remover mesmo assim"; "toast_week_schedule_updated" = "Semana atualizada"; "toast_week_schedule_reset" = "Semana de volta às configurações gerais"; + +/* Apple Watch */ +"watch_empty_meal" = "Sem planejamento"; +"watch_empty_day" = "Nada planejado para hoje"; +"watch_stale" = "Abra o MealMood no iPhone para ver esta semana"; diff --git a/MealMood/Services/WatchSyncService.swift b/MealMood/Services/WatchSyncService.swift index a97ca54..319acc1 100644 --- a/MealMood/Services/WatchSyncService.swift +++ b/MealMood/Services/WatchSyncService.swift @@ -16,6 +16,9 @@ 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() @@ -27,6 +30,28 @@ final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable { /// 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()).first else { return nil } + + let weekStart = Date().startOfWeek() + let descriptor = FetchDescriptor( + predicate: #Predicate { plan in plan.weekStartDate == weekStart } + ) + guard let plan = try? context.fetch(descriptor).first else { return nil } + let dishes = (try? context.fetch(FetchDescriptor())) ?? [] + + 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), @@ -63,7 +88,7 @@ final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable { if slot.isEatingOut { name = String(localized: "slot_eating_out") } else if slot.isSkipped { - name = "—" + 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] { @@ -78,6 +103,9 @@ final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable { 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 ) } @@ -85,7 +113,10 @@ final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable { return WatchWeekPayload( weekTitle: plan.weekStartDate.formattedWeekRange(), days: days, - updatedAt: Date() + updatedAt: Date(), + emptyMealText: String(localized: "watch_empty_meal"), + emptyDayText: String(localized: "watch_empty_day"), + staleText: String(localized: "watch_stale") ) } @@ -96,6 +127,8 @@ final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable { 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. @@ -103,9 +136,15 @@ final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable { } } - /// The watch asks on launch; reply with the snapshot from the app group. + /// 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) { - if message["request"] as? String == "week", let data = WatchWeekPayload.stored()?.encoded() { + guard message["request"] as? String == "week" else { + replyHandler([:]) + return + } + if let data = freshPayloadData() ?? WatchWeekPayload.stored()?.encoded() { replyHandler([WatchWeekPayload.storageKey: data]) } else { replyHandler([:]) diff --git a/MealMood/Services/WatchWeekPayload.swift b/MealMood/Services/WatchWeekPayload.swift index ae6b0ef..c5cd78b 100644 --- a/MealMood/Services/WatchWeekPayload.swift +++ b/MealMood/Services/WatchWeekPayload.swift @@ -14,14 +14,49 @@ struct WatchWeekPayload: Codable { struct Day: Codable { let dayOfWeek: Int // 0=Monday … 6=Sunday let title: String // localized short title ("Lun 8") + /// `isToday` as of the moment the iPhone built the snapshot. Kept for + /// payloads written by older versions; `date` is what the watch should + /// use, because a stored snapshot outlives the day it was made in. let isToday: Bool + /// The actual calendar date of this day. Optional: snapshots stored by + /// versions before 2.1.1 don't carry it. + var date: Date? let meals: [Meal] + + init(dayOfWeek: Int, title: String, isToday: Bool, date: Date? = nil, meals: [Meal]) { + self.dayOfWeek = dayOfWeek + self.title = title + self.isToday = isToday + self.date = date + self.meals = meals + } + + /// Whether this day is today *now*, asked at render time. + func isCurrentDay(now: Date = Date(), calendar: Calendar = .current) -> Bool { + if let date { + return calendar.isDate(date, inSameDayAs: now) + } + // Pre-2.1.1 snapshot: fall back to the weekday offset, which is only + // right while the snapshot belongs to the current week. + return dayOfWeek == WatchWeekPayload.weekdayOffset(for: now, calendar: calendar) + } + + /// Nothing planned at all for this day. + var isEmpty: Bool { + meals.allSatisfy { $0.name == nil } + } } let weekTitle: String // localized week range ("8 – 14 sep") let days: [Day] let updatedAt: Date + /// Localized texts the watch cannot build on its own — its bundle carries no + /// strings. Optional so older snapshots still decode. + var emptyMealText: String? + var emptyDayText: String? + var staleText: String? + static let appGroupID = "group.com.alexandrevazquez.mealmood" static let storageKey = "watch_week_payload_v1" @@ -41,6 +76,33 @@ struct WatchWeekPayload: Codable { UserDefaults(suiteName: Self.appGroupID)?.set(encoded(), forKey: Self.storageKey) } + /// 0=Monday … 6=Sunday, matching `MealSlot.dayOfWeek`. + static func weekdayOffset(for date: Date, calendar: Calendar = .current) -> Int { + (calendar.component(.weekday, from: date) + 5) % 7 + } + + /// The day to show as "today", resolved when the view renders rather than + /// when the iPhone built the snapshot. Returns nil when the snapshot is from + /// another week and simply doesn't contain today. + func currentDay(now: Date = Date(), calendar: Calendar = .current) -> Day? { + if days.contains(where: { $0.date != nil }) { + return days.first { $0.isCurrentDay(now: now, calendar: calendar) } + } + // Snapshot written before 2.1.1: no dates, so the weekday offset is the + // only handle — and it's trustworthy only while the snapshot itself + // belongs to the current week. Otherwise last week's Saturday would + // pass for today. + guard calendar.isDate(updatedAt, equalTo: now, toGranularity: .weekOfYear) else { return nil } + let offset = Self.weekdayOffset(for: now, calendar: calendar) + return days.first { $0.dayOfWeek == offset } + } + + /// True when the snapshot no longer covers today, so showing any of its days + /// would be showing the wrong meal. + func isStale(now: Date = Date(), calendar: Calendar = .current) -> Bool { + currentDay(now: now, calendar: calendar) == nil + } + static func icon(for mealType: String) -> String { switch mealType { case "breakfast": return "cup.and.saucer.fill" diff --git a/MealMood/Views/Home/HomeView.swift b/MealMood/Views/Home/HomeView.swift index 9aef02a..f60c959 100644 --- a/MealMood/Views/Home/HomeView.swift +++ b/MealMood/Views/Home/HomeView.swift @@ -5,6 +5,7 @@ import UIKit struct HomeView: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.modelContext) private var context + @Environment(\.scenePhase) private var scenePhase @Environment(\.openURL) private var openURL @Query(sort: \Dish.createdAt, order: .reverse) private var dishes: [Dish] @Query private var tags: [Tag] @@ -806,6 +807,12 @@ struct HomeView: View { guard url.scheme == "mealmood", url.host == "today" else { return } viewModel.jumpToWeek(startDate: Date().startOfWeek()) } + // Coming back to the app is the moment the watch is most likely to be + // holding a stale snapshot (a day or a week old). + .onChange(of: scenePhase) { _, phase in + guard phase == .active else { return } + updateWidget(settings: settings) + } .onChange(of: plan.updatedAt) { _, _ in let nowComplete = isWeekComplete(plan: plan) if nowComplete && !wasWeekComplete { diff --git a/MealMoodTests/WatchWeekPayloadTests.swift b/MealMoodTests/WatchWeekPayloadTests.swift new file mode 100644 index 0000000..fc28f72 --- /dev/null +++ b/MealMoodTests/WatchWeekPayloadTests.swift @@ -0,0 +1,161 @@ +import XCTest +@testable import MealMood + +/// Regression suite for "the watch showed Wednesday's meal on Saturday". +/// +/// The snapshot the iPhone sends carries an `isToday` flag, which is true only +/// at the moment it was built. The watch outlives that moment — it keeps the +/// last snapshot in its app group — so "today" has to be resolved when the view +/// renders, never read from the payload. +final class WatchWeekPayloadTests: XCTestCase { + + private let calendar = Calendar(identifier: .gregorian) + + private func date(_ year: Int, _ month: Int, _ day: Int) -> Date { + calendar.date(from: DateComponents(year: year, month: month, day: day, hour: 12))! + } + + private func makePayload(weekStart: Date, todayFlagOn flaggedDay: Int?) -> WatchWeekPayload { + let days = (0...6).map { offset in + WatchWeekPayload.Day( + dayOfWeek: offset, + title: "Day \(offset)", + isToday: offset == flaggedDay, + date: calendar.date(byAdding: .day, value: offset, to: weekStart)!, + meals: [ + WatchWeekPayload.Meal(type: "dinner", label: "Dinner", name: "Dish \(offset)") + ] + ) + } + return WatchWeekPayload( + weekTitle: "Week", + days: days, + updatedAt: weekStart, + emptyMealText: "Not planned", + emptyDayText: "Nothing planned for today", + staleText: "Open MealMood on your iPhone" + ) + } + + // MARK: - The reported bug + + func testSaturdayShowsSaturdayEvenIfTheSnapshotWasBuiltOnWednesday() { + let monday = date(2026, 9, 7) + // Snapshot built on Wednesday: that's the day it flagged as "today". + let payload = makePayload(weekStart: monday, todayFlagOn: 2) + let saturday = date(2026, 9, 12) + + let today = payload.currentDay(now: saturday, calendar: calendar) + + XCTAssertEqual(today?.dayOfWeek, 5, "Saturday must win over the frozen Wednesday flag") + XCTAssertEqual(today?.meals.first?.name, "Dish 5") + XCTAssertFalse(payload.isStale(now: saturday, calendar: calendar)) + } + + func testSnapshotFromAnotherWeekHasNoToday() { + let lastMonday = date(2026, 9, 7) + let payload = makePayload(weekStart: lastMonday, todayFlagOn: 2) + let nextSaturday = date(2026, 9, 19) + + XCTAssertNil(payload.currentDay(now: nextSaturday, calendar: calendar)) + XCTAssertTrue(payload.isStale(now: nextSaturday, calendar: calendar), + "showing last week's meals as today's is exactly what must not happen") + } + + func testEachDayKnowsWhetherItIsToday() { + let monday = date(2026, 9, 7) + let payload = makePayload(weekStart: monday, todayFlagOn: 2) + let thursday = date(2026, 9, 10) + + XCTAssertTrue(payload.days[3].isCurrentDay(now: thursday, calendar: calendar)) + XCTAssertFalse(payload.days[2].isCurrentDay(now: thursday, calendar: calendar), + "the day flagged when the snapshot was built is not today any more") + } + + // MARK: - Empty states + + func testDayWithNoDishesIsEmpty() { + let empty = WatchWeekPayload.Day( + dayOfWeek: 1, title: "Tue", isToday: true, date: date(2026, 9, 8), + meals: [ + WatchWeekPayload.Meal(type: "lunch", label: "Lunch", name: nil), + WatchWeekPayload.Meal(type: "dinner", label: "Dinner", name: nil) + ] + ) + XCTAssertTrue(empty.isEmpty) + + let planned = WatchWeekPayload.Day( + dayOfWeek: 1, title: "Tue", isToday: true, date: date(2026, 9, 8), + meals: [ + WatchWeekPayload.Meal(type: "lunch", label: "Lunch", name: nil), + WatchWeekPayload.Meal(type: "dinner", label: "Dinner", name: "Tortilla") + ] + ) + XCTAssertFalse(planned.isEmpty, "one planned meal is enough for the day not to be empty") + } + + func testPayloadCarriesTheLocalizedEmptyTexts() { + // The watch bundle has no strings of its own, so these have to travel. + let payload = makePayload(weekStart: date(2026, 9, 7), todayFlagOn: 0) + + XCTAssertEqual(payload.emptyMealText, "Not planned") + XCTAssertEqual(payload.emptyDayText, "Nothing planned for today") + XCTAssertEqual(payload.staleText, "Open MealMood on your iPhone") + } + + // MARK: - Compatibility with snapshots already on people's watches + + func testSnapshotWrittenBeforeTheFixStillDecodes() throws { + // Shape written by 2.1.0: no `date`, no texts. Stamped inside the + // current week, which is when its weekday offsets still mean something. + let wednesday = date(2026, 9, 9) + let json = """ + { + "weekTitle": "7 – 13 sep", + "updatedAt": \(wednesday.timeIntervalSinceReferenceDate), + "days": [ + {"dayOfWeek": 0, "title": "Mon 7", "isToday": false, + "meals": [{"type": "dinner", "label": "Dinner", "name": "Pizza"}]}, + {"dayOfWeek": 2, "title": "Wed 9", "isToday": true, + "meals": [{"type": "dinner", "label": "Dinner", "name": "Salmon"}]} + ] + } + """ + let payload = try XCTUnwrap(WatchWeekPayload.decode(Data(json.utf8))) + + XCTAssertEqual(payload.days.count, 2) + XCTAssertNil(payload.days[0].date) + XCTAssertNil(payload.emptyMealText, "old snapshots have no texts; the UI falls back") + + // Without dates it can only fall back to the weekday offset. + XCTAssertEqual(payload.currentDay(now: wednesday, calendar: calendar)?.dayOfWeek, 2) + } + + func testDatelessSnapshotFromAnotherWeekIsStale() throws { + // The watch that reported the bug had one of these: no dates, built in a + // previous week. Trusting its weekday offsets is what showed the wrong + // day, so it now counts as stale instead. + let lastWednesday = date(2026, 9, 2) + let json = """ + { + "weekTitle": "31 ago – 6 sep", + "updatedAt": \(lastWednesday.timeIntervalSinceReferenceDate), + "days": [ + {"dayOfWeek": 5, "title": "Sat 5", "isToday": false, + "meals": [{"type": "dinner", "label": "Dinner", "name": "Old dinner"}]} + ] + } + """ + let payload = try XCTUnwrap(WatchWeekPayload.decode(Data(json.utf8))) + let saturday = date(2026, 9, 12) + + XCTAssertNil(payload.currentDay(now: saturday, calendar: calendar)) + XCTAssertTrue(payload.isStale(now: saturday, calendar: calendar)) + } + + func testWeekdayOffsetMapsMondayToZeroAndSundayToSix() { + XCTAssertEqual(WatchWeekPayload.weekdayOffset(for: date(2026, 9, 7), calendar: calendar), 0) + XCTAssertEqual(WatchWeekPayload.weekdayOffset(for: date(2026, 9, 12), calendar: calendar), 5) + XCTAssertEqual(WatchWeekPayload.weekdayOffset(for: date(2026, 9, 13), calendar: calendar), 6) + } +} diff --git a/MealMoodWatch/MealMoodWatchApp.swift b/MealMoodWatch/MealMoodWatchApp.swift index ff8934e..2bde6f0 100644 --- a/MealMoodWatch/MealMoodWatchApp.swift +++ b/MealMoodWatch/MealMoodWatchApp.swift @@ -5,6 +5,7 @@ import WidgetKit @main struct MealMoodWatchApp: App { @StateObject private var store = WatchWeekStore.shared + @Environment(\.scenePhase) private var scenePhase var body: some Scene { WindowGroup { @@ -14,6 +15,11 @@ struct MealMoodWatchApp: App { } .tabViewStyle(.verticalPage) .environmentObject(store) + // Opening the app is exactly when a snapshot from another day would + // be on screen, so ask the phone for a fresh one. + .onChange(of: scenePhase) { _, phase in + if phase == .active { store.refresh() } + } } } } @@ -59,6 +65,13 @@ final class WatchWeekStore: NSObject, ObservableObject, WCSessionDelegate { } } + /// Asks the phone for an up-to-date snapshot. The phone rebuilds it from its + /// store, so this also fixes a snapshot left over from a previous week. + func refresh() { + guard WCSession.isSupported() else { return } + requestFromPhone(WCSession.default) + } + private func requestFromPhone(_ session: WCSession) { guard session.isReachable else { return } session.sendMessage(["request": "week"], replyHandler: { [weak self] reply in @@ -78,7 +91,11 @@ final class WatchWeekStore: NSObject, ObservableObject, WCSessionDelegate { } func sessionReachabilityDidChange(_ session: WCSession) { - if payload == nil { requestFromPhone(session) } + // Refresh when there's nothing yet, and also when what we have no longer + // covers today — otherwise the watch keeps showing an old day. + if payload == nil || payload?.isStale() == true { + requestFromPhone(session) + } } func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { diff --git a/MealMoodWatch/TodayView.swift b/MealMoodWatch/TodayView.swift index dc4f765..b73efd8 100644 --- a/MealMoodWatch/TodayView.swift +++ b/MealMoodWatch/TodayView.swift @@ -5,8 +5,11 @@ import SwiftUI struct TodayView: View { @EnvironmentObject private var store: WatchWeekStore + /// Resolved as the view renders. The snapshot's own `isToday` is frozen at + /// the moment the iPhone built it, which is how a Wednesday plan ended up + /// showing on a Saturday. private var today: WatchWeekPayload.Day? { - store.payload?.days.first(where: \.isToday) ?? store.payload?.days.first + store.payload?.currentDay() } var body: some View { @@ -24,9 +27,18 @@ struct TodayView: View { } .padding(.horizontal, 2) - ForEach(Array(today.meals.enumerated()), id: \.offset) { _, meal in - MealCard(meal: meal) + if today.isEmpty { + EmptyDayView(text: store.payload?.emptyDayText) + } else { + ForEach(Array(today.meals.enumerated()), id: \.offset) { _, meal in + MealCard(meal: meal, emptyText: store.payload?.emptyMealText) + } } + } else if let payload = store.payload { + // There is a snapshot, but it doesn't cover today: better to + // say so than to show another day's meals as if they were + // today's. + StaleView(text: payload.staleText) } else { EmptySyncView() } @@ -37,6 +49,9 @@ struct TodayView: View { struct MealCard: View { let meal: WatchWeekPayload.Meal + /// Localized "nothing planned" coming from the iPhone — the watch bundle + /// has no strings of its own. + var emptyText: String? private var accent: Color { WatchTheme.accent(for: meal.type) } @@ -51,9 +66,9 @@ struct MealCard: View { } .foregroundStyle(.white.opacity(0.85)) - Text(meal.name ?? "—") + Text(meal.name ?? emptyText ?? "—") .font(.system(.body, design: .rounded, weight: .semibold)) - .foregroundStyle(.white) + .foregroundStyle(.white.opacity(meal.name == nil ? 0.7 : 1)) .lineLimit(2) .minimumScaleFactor(0.8) } @@ -71,6 +86,48 @@ struct MealCard: View { } } +/// The day exists in the snapshot but has no meals planned. +struct EmptyDayView: View { + var text: String? + + var body: some View { + VStack(spacing: 6) { + Image(systemName: "moon.zzz.fill") + .font(.title3) + .foregroundStyle(WatchTheme.coral) + if let text { + Text(text) + .font(.system(.caption2, design: .rounded, weight: .semibold)) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity) + .padding(.top, 18) + } +} + +/// A snapshot from another week: the watch has data, but not for today. +struct StaleView: View { + var text: String? + + var body: some View { + VStack(spacing: 6) { + Image(systemName: "iphone.gen3.radiowaves.left.and.right") + .font(.title3) + .foregroundStyle(WatchTheme.coral) + if let text { + Text(text) + .font(.system(.caption2, design: .rounded, weight: .semibold)) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity) + .padding(.top, 18) + } +} + struct EmptySyncView: View { var body: some View { VStack(spacing: 8) { diff --git a/MealMoodWatch/WeekView.swift b/MealMoodWatch/WeekView.swift index 0b13d3d..48c74ce 100644 --- a/MealMoodWatch/WeekView.swift +++ b/MealMoodWatch/WeekView.swift @@ -16,7 +16,9 @@ struct WeekView: View { .padding(.horizontal, 2) ForEach(payload.days, id: \.dayOfWeek) { day in - DayRow(day: day) + // Nothing is "today" in a snapshot that no longer + // covers today. + DayRow(day: day, isToday: payload.currentDay()?.dayOfWeek == day.dayOfWeek) } } } @@ -29,6 +31,9 @@ struct WeekView: View { private struct DayRow: View { let day: WatchWeekPayload.Day + /// Resolved by the payload when the view renders, not by the snapshot's own + /// frozen `isToday`. + let isToday: Bool /// "Lun 8" → ("Lun", "8") private var parts: (name: String, number: String) { @@ -42,13 +47,13 @@ private struct DayRow: View { VStack(spacing: 0) { Text(parts.name.uppercased()) .font(.system(size: 9, weight: .bold, design: .rounded)) - .foregroundStyle(day.isToday ? WatchTheme.coral : .secondary) + .foregroundStyle(isToday ? WatchTheme.coral : .secondary) Text(parts.number) .font(.system(size: 15, weight: .bold, design: .rounded)) - .foregroundStyle(day.isToday ? .white : .primary) + .foregroundStyle(isToday ? .white : .primary) .frame(width: 26, height: 26) .background( - Circle().fill(day.isToday ? WatchTheme.coral : .clear) + Circle().fill(isToday ? WatchTheme.coral : .clear) ) } .frame(width: 32) @@ -70,7 +75,7 @@ private struct DayRow: View { .padding(.vertical, 5) .padding(.horizontal, 7) .background( - day.isToday ? WatchTheme.coral.opacity(0.16) : Color.white.opacity(0.07), + isToday ? WatchTheme.coral.opacity(0.16) : Color.white.opacity(0.07), in: RoundedRectangle(cornerRadius: 9) ) } diff --git a/MealMoodWatchWidget/MealMoodWatchWidget.swift b/MealMoodWatchWidget/MealMoodWatchWidget.swift index 4fd58b8..b9fe940 100644 --- a/MealMoodWatchWidget/MealMoodWatchWidget.swift +++ b/MealMoodWatchWidget/MealMoodWatchWidget.swift @@ -33,10 +33,10 @@ struct TodayProvider: TimelineProvider { completion(Timeline(entries: [entry], policy: .after(nextMidnight))) } + /// nil when the stored snapshot is from another week — the complication + /// then shows its empty state instead of last week's dinner. private var currentDay: WatchWeekPayload.Day? { - guard let payload = WatchWeekPayload.stored() else { return nil } - let todayOffset = (Calendar.current.component(.weekday, from: Date()) + 5) % 7 - return payload.days.first { $0.dayOfWeek == todayOffset } ?? payload.days.first(where: \.isToday) + WatchWeekPayload.stored()?.currentDay() } private var sampleDay: WatchWeekPayload.Day { @@ -209,7 +209,7 @@ struct DaysComplicationView: View { HStack(alignment: .firstTextBaseline, spacing: 4) { Text(dayAbbrev(day)) .font(.system(size: 11, weight: .heavy, design: .rounded)) - .foregroundStyle(day.isToday + .foregroundStyle(day.isCurrentDay() ? Color(red: 1.0, green: 0.45, blue: 0.35) : .secondary) .widgetAccentable()