watch: el dia de hoy se resuelve al pintar, no al generar el envio

El iPhone marcaba isToday al construir el snapshot y el reloj se creia esa
marca para siempre. Como el snapshot sobrevive en el app group, un sabado
seguia enseñando la cena del miercoles: el dia que era cuando se genero.

Ahora cada dia viaja con su fecha real y el reloj busca hoy cuando dibuja. Si
el snapshot es de otra semana no hay "hoy" que enseñar, asi que en vez de
colar la comida de otro dia lo dice y pide abrir el iPhone. Para snapshots
guardados por versiones anteriores, que no traen fecha, solo se confia en el
indice del dia mientras el propio snapshot sea de esta semana.

Ademas el reloj pide datos al abrirse y el iPhone, en vez de responder con lo
ultimo que cacheo, reconstruye la semana desde el store — que es la otra mitad
del "no se actualiza": el cache podia ser de hace dias.

Y cuando no hay nada planificado se dice, que antes era un guion indistinguible
de una comida marcada como no planificada: "Hoy no hay nada planificado" para
el dia entero y "Sin planificar" por comida, en los 6 idiomas (el bundle del
reloj no tiene cadenas propias, viajan en el envio).

Refs #34, #35

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013su1ttRiMeMYxkZJ1Y3246
This commit is contained in:
alexandrev-tibco
2026-09-12 13:21:21 +02:00
parent d8345a5b20
commit a049e4f74b
16 changed files with 404 additions and 19 deletions
+4
View File
@@ -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 = "<group>"; };
623EDDC4CD134883FC9D242D /* Dish.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dish.swift; sourceTree = "<group>"; };
62E2F6B2694CEC97AE3C1B13 /* PremiumSyncServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PremiumSyncServiceTests.swift; sourceTree = "<group>"; };
673BD35BD6F1EC366F9E9FA8 /* WatchWeekPayloadTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchWeekPayloadTests.swift; sourceTree = "<group>"; };
67CBDF2D2972139881AA59B1 /* WatchWeekPayload.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchWeekPayload.swift; sourceTree = "<group>"; };
683D03A3F2E26A2BA627B353 /* WeekView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WeekView.swift; sourceTree = "<group>"; };
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 = "<group>";
@@ -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;
};
+3
View File
@@ -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)
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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";
+43 -4
View File
@@ -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<AppSettings>()).first else { return nil }
let weekStart = Date().startOfWeek()
let descriptor = FetchDescriptor<WeekPlan>(
predicate: #Predicate<WeekPlan> { plan in plan.weekStartDate == weekStart }
)
guard let plan = try? context.fetch(descriptor).first else { return nil }
let dishes = (try? context.fetch(FetchDescriptor<Dish>())) ?? []
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([:])
+62
View File
@@ -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"
+7
View File
@@ -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 {
+161
View File
@@ -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)
}
}
+18 -1
View File
@@ -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]) {
+61 -4
View File
@@ -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)
if today.isEmpty {
EmptyDayView(text: store.payload?.emptyDayText)
} else {
ForEach(Array(today.meals.enumerated()), id: \.offset) { _, meal in
MealCard(meal: meal)
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) {
+10 -5
View File
@@ -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)
)
}
@@ -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()