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:
@@ -59,6 +59,7 @@
|
|||||||
87EB371FD3B6C241AC721C55 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 8E15828872FAEA71A7384099 /* Localizable.strings */; };
|
87EB371FD3B6C241AC721C55 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 8E15828872FAEA71A7384099 /* Localizable.strings */; };
|
||||||
8B12FAE3CC07AFB284094C61 /* WeekSchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27337A07289450A1D494AC02 /* WeekSchedule.swift */; };
|
8B12FAE3CC07AFB284094C61 /* WeekSchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27337A07289450A1D494AC02 /* WeekSchedule.swift */; };
|
||||||
8B4672C76B05BC1DFB5C979D /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5018BA4B3DC94CA7443CA9D1 /* TodayView.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 */; };
|
8CEAC8993D33BFBBEB5319A8 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EE10285FEE9BE0684CC66A3 /* NotificationService.swift */; };
|
||||||
8DB624B469178CFD8CD87672 /* OnboardingFlowUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CBD59AC26D5C6D138D67E35 /* OnboardingFlowUITests.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 */; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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 */,
|
62E2F6B2694CEC97AE3C1B13 /* PremiumSyncServiceTests.swift */,
|
||||||
69C0BDFEBA8B692530CB6B5E /* SpeechDictationServiceTests.swift */,
|
69C0BDFEBA8B692530CB6B5E /* SpeechDictationServiceTests.swift */,
|
||||||
91C2BCF9D7A1458E045DA053 /* WeekScheduleTests.swift */,
|
91C2BCF9D7A1458E045DA053 /* WeekScheduleTests.swift */,
|
||||||
|
673BD35BD6F1EC366F9E9FA8 /* WatchWeekPayloadTests.swift */,
|
||||||
);
|
);
|
||||||
path = MealMoodTests;
|
path = MealMoodTests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -973,6 +976,7 @@
|
|||||||
2A58E7C96AF7C14EBFE1C65C /* PremiumSyncServiceTests.swift in Sources */,
|
2A58E7C96AF7C14EBFE1C65C /* PremiumSyncServiceTests.swift in Sources */,
|
||||||
64CC61B20F0AF602A6CC1114 /* SpeechDictationServiceTests.swift in Sources */,
|
64CC61B20F0AF602A6CC1114 /* SpeechDictationServiceTests.swift in Sources */,
|
||||||
A569F5FED382E8EABC471416 /* WeekScheduleTests.swift in Sources */,
|
A569F5FED382E8EABC471416 /* WeekScheduleTests.swift in Sources */,
|
||||||
|
8C41C5C4D2676772E8351374 /* WatchWeekPayloadTests.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ struct MealMoodApp: App {
|
|||||||
|
|
||||||
init() {
|
init() {
|
||||||
FirebaseApp.configure()
|
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()
|
AnalyticsService.applyCollectionPolicy()
|
||||||
#if canImport(GoogleMobileAds)
|
#if canImport(GoogleMobileAds)
|
||||||
GADMobileAds.sharedInstance().start(completionHandler: nil)
|
GADMobileAds.sharedInstance().start(completionHandler: nil)
|
||||||
|
|||||||
@@ -478,3 +478,8 @@
|
|||||||
"week_schedule_warning_confirm" = "Trotzdem entfernen";
|
"week_schedule_warning_confirm" = "Trotzdem entfernen";
|
||||||
"toast_week_schedule_updated" = "Woche aktualisiert";
|
"toast_week_schedule_updated" = "Woche aktualisiert";
|
||||||
"toast_week_schedule_reset" = "Woche folgt wieder deinen Einstellungen";
|
"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";
|
"week_schedule_warning_confirm" = "Remove anyway";
|
||||||
"toast_week_schedule_updated" = "Week updated";
|
"toast_week_schedule_updated" = "Week updated";
|
||||||
"toast_week_schedule_reset" = "Week back to your general settings";
|
"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";
|
"week_schedule_warning_confirm" = "Quitar igualmente";
|
||||||
"toast_week_schedule_updated" = "Semana actualizada";
|
"toast_week_schedule_updated" = "Semana actualizada";
|
||||||
"toast_week_schedule_reset" = "Semana con tu configuración general";
|
"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";
|
"week_schedule_warning_confirm" = "Retirer quand même";
|
||||||
"toast_week_schedule_updated" = "Semaine mise à jour";
|
"toast_week_schedule_updated" = "Semaine mise à jour";
|
||||||
"toast_week_schedule_reset" = "Semaine revenue à vos réglages généraux";
|
"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";
|
"week_schedule_warning_confirm" = "Rimuovi comunque";
|
||||||
"toast_week_schedule_updated" = "Settimana aggiornata";
|
"toast_week_schedule_updated" = "Settimana aggiornata";
|
||||||
"toast_week_schedule_reset" = "Settimana tornata alle impostazioni generali";
|
"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";
|
"week_schedule_warning_confirm" = "Remover mesmo assim";
|
||||||
"toast_week_schedule_updated" = "Semana atualizada";
|
"toast_week_schedule_updated" = "Semana atualizada";
|
||||||
"toast_week_schedule_reset" = "Semana de volta às configurações gerais";
|
"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";
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable {
|
|||||||
static let shared = WatchSyncService()
|
static let shared = WatchSyncService()
|
||||||
|
|
||||||
private var pendingData: Data?
|
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() {
|
private override init() {
|
||||||
super.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.
|
/// Touch from app launch so activation happens before the first push.
|
||||||
func activate() { /* init side effect */ }
|
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) {
|
func push(plan: WeekPlan?, dishes: [Dish], settings: AppSettings) {
|
||||||
guard WCSession.isSupported() else { return }
|
guard WCSession.isSupported() else { return }
|
||||||
guard let payload = Self.makePayload(plan: plan, dishes: dishes, settings: settings),
|
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 {
|
if slot.isEatingOut {
|
||||||
name = String(localized: "slot_eating_out")
|
name = String(localized: "slot_eating_out")
|
||||||
} else if slot.isSkipped {
|
} else if slot.isSkipped {
|
||||||
name = "—"
|
name = String(localized: "slot_skipped")
|
||||||
} else if let dishId = slot.dishId, let dish = dishById[dishId] {
|
} else if let dishId = slot.dishId, let dish = dishById[dishId] {
|
||||||
name = dish.name
|
name = dish.name
|
||||||
if let secondaryId = slot.secondaryDishId, let secondary = dishById[secondaryId] {
|
if let secondaryId = slot.secondaryDishId, let secondary = dishById[secondaryId] {
|
||||||
@@ -78,6 +103,9 @@ final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable {
|
|||||||
dayOfWeek: day,
|
dayOfWeek: day,
|
||||||
title: dayFormatter.string(from: dayDate).capitalized(with: locale),
|
title: dayFormatter.string(from: dayDate).capitalized(with: locale),
|
||||||
isToday: isCurrentWeek && day == todayOffset,
|
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
|
meals: meals
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -85,7 +113,10 @@ final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable {
|
|||||||
return WatchWeekPayload(
|
return WatchWeekPayload(
|
||||||
weekTitle: plan.weekStartDate.formattedWeekRange(),
|
weekTitle: plan.weekStartDate.formattedWeekRange(),
|
||||||
days: days,
|
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 {
|
if let data = pendingData {
|
||||||
pendingData = nil
|
pendingData = nil
|
||||||
try? session.updateApplicationContext([WatchWeekPayload.storageKey: data])
|
try? session.updateApplicationContext([WatchWeekPayload.storageKey: data])
|
||||||
|
} else if let fresh = freshPayloadData() {
|
||||||
|
try? session.updateApplicationContext([WatchWeekPayload.storageKey: fresh])
|
||||||
} else if let stored = WatchWeekPayload.stored()?.encoded() {
|
} else if let stored = WatchWeekPayload.stored()?.encoded() {
|
||||||
// Nothing pending this run, but ship the last known snapshot so a
|
// Nothing pending this run, but ship the last known snapshot so a
|
||||||
// freshly-paired watch has data without waiting for an edit.
|
// 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) {
|
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])
|
replyHandler([WatchWeekPayload.storageKey: data])
|
||||||
} else {
|
} else {
|
||||||
replyHandler([:])
|
replyHandler([:])
|
||||||
|
|||||||
@@ -14,14 +14,49 @@ struct WatchWeekPayload: Codable {
|
|||||||
struct Day: Codable {
|
struct Day: Codable {
|
||||||
let dayOfWeek: Int // 0=Monday … 6=Sunday
|
let dayOfWeek: Int // 0=Monday … 6=Sunday
|
||||||
let title: String // localized short title ("Lun 8")
|
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
|
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]
|
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 weekTitle: String // localized week range ("8 – 14 sep")
|
||||||
let days: [Day]
|
let days: [Day]
|
||||||
let updatedAt: Date
|
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 appGroupID = "group.com.alexandrevazquez.mealmood"
|
||||||
static let storageKey = "watch_week_payload_v1"
|
static let storageKey = "watch_week_payload_v1"
|
||||||
|
|
||||||
@@ -41,6 +76,33 @@ struct WatchWeekPayload: Codable {
|
|||||||
UserDefaults(suiteName: Self.appGroupID)?.set(encoded(), forKey: Self.storageKey)
|
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 {
|
static func icon(for mealType: String) -> String {
|
||||||
switch mealType {
|
switch mealType {
|
||||||
case "breakfast": return "cup.and.saucer.fill"
|
case "breakfast": return "cup.and.saucer.fill"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import UIKit
|
|||||||
struct HomeView: View {
|
struct HomeView: View {
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
@Environment(\.modelContext) private var context
|
@Environment(\.modelContext) private var context
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@Environment(\.openURL) private var openURL
|
@Environment(\.openURL) private var openURL
|
||||||
@Query(sort: \Dish.createdAt, order: .reverse) private var dishes: [Dish]
|
@Query(sort: \Dish.createdAt, order: .reverse) private var dishes: [Dish]
|
||||||
@Query private var tags: [Tag]
|
@Query private var tags: [Tag]
|
||||||
@@ -806,6 +807,12 @@ struct HomeView: View {
|
|||||||
guard url.scheme == "mealmood", url.host == "today" else { return }
|
guard url.scheme == "mealmood", url.host == "today" else { return }
|
||||||
viewModel.jumpToWeek(startDate: Date().startOfWeek())
|
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
|
.onChange(of: plan.updatedAt) { _, _ in
|
||||||
let nowComplete = isWeekComplete(plan: plan)
|
let nowComplete = isWeekComplete(plan: plan)
|
||||||
if nowComplete && !wasWeekComplete {
|
if nowComplete && !wasWeekComplete {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import WidgetKit
|
|||||||
@main
|
@main
|
||||||
struct MealMoodWatchApp: App {
|
struct MealMoodWatchApp: App {
|
||||||
@StateObject private var store = WatchWeekStore.shared
|
@StateObject private var store = WatchWeekStore.shared
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
@@ -14,6 +15,11 @@ struct MealMoodWatchApp: App {
|
|||||||
}
|
}
|
||||||
.tabViewStyle(.verticalPage)
|
.tabViewStyle(.verticalPage)
|
||||||
.environmentObject(store)
|
.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) {
|
private func requestFromPhone(_ session: WCSession) {
|
||||||
guard session.isReachable else { return }
|
guard session.isReachable else { return }
|
||||||
session.sendMessage(["request": "week"], replyHandler: { [weak self] reply in
|
session.sendMessage(["request": "week"], replyHandler: { [weak self] reply in
|
||||||
@@ -78,7 +91,11 @@ final class WatchWeekStore: NSObject, ObservableObject, WCSessionDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sessionReachabilityDidChange(_ session: WCSession) {
|
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]) {
|
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import SwiftUI
|
|||||||
struct TodayView: View {
|
struct TodayView: View {
|
||||||
@EnvironmentObject private var store: WatchWeekStore
|
@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? {
|
private var today: WatchWeekPayload.Day? {
|
||||||
store.payload?.days.first(where: \.isToday) ?? store.payload?.days.first
|
store.payload?.currentDay()
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -24,9 +27,18 @@ struct TodayView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 2)
|
.padding(.horizontal, 2)
|
||||||
|
|
||||||
ForEach(Array(today.meals.enumerated()), id: \.offset) { _, meal in
|
if today.isEmpty {
|
||||||
MealCard(meal: meal)
|
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 {
|
} else {
|
||||||
EmptySyncView()
|
EmptySyncView()
|
||||||
}
|
}
|
||||||
@@ -37,6 +49,9 @@ struct TodayView: View {
|
|||||||
|
|
||||||
struct MealCard: View {
|
struct MealCard: View {
|
||||||
let meal: WatchWeekPayload.Meal
|
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) }
|
private var accent: Color { WatchTheme.accent(for: meal.type) }
|
||||||
|
|
||||||
@@ -51,9 +66,9 @@ struct MealCard: View {
|
|||||||
}
|
}
|
||||||
.foregroundStyle(.white.opacity(0.85))
|
.foregroundStyle(.white.opacity(0.85))
|
||||||
|
|
||||||
Text(meal.name ?? "—")
|
Text(meal.name ?? emptyText ?? "—")
|
||||||
.font(.system(.body, design: .rounded, weight: .semibold))
|
.font(.system(.body, design: .rounded, weight: .semibold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white.opacity(meal.name == nil ? 0.7 : 1))
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
.minimumScaleFactor(0.8)
|
.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 {
|
struct EmptySyncView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ struct WeekView: View {
|
|||||||
.padding(.horizontal, 2)
|
.padding(.horizontal, 2)
|
||||||
|
|
||||||
ForEach(payload.days, id: \.dayOfWeek) { day in
|
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 {
|
private struct DayRow: View {
|
||||||
let day: WatchWeekPayload.Day
|
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")
|
/// "Lun 8" → ("Lun", "8")
|
||||||
private var parts: (name: String, number: String) {
|
private var parts: (name: String, number: String) {
|
||||||
@@ -42,13 +47,13 @@ private struct DayRow: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Text(parts.name.uppercased())
|
Text(parts.name.uppercased())
|
||||||
.font(.system(size: 9, weight: .bold, design: .rounded))
|
.font(.system(size: 9, weight: .bold, design: .rounded))
|
||||||
.foregroundStyle(day.isToday ? WatchTheme.coral : .secondary)
|
.foregroundStyle(isToday ? WatchTheme.coral : .secondary)
|
||||||
Text(parts.number)
|
Text(parts.number)
|
||||||
.font(.system(size: 15, weight: .bold, design: .rounded))
|
.font(.system(size: 15, weight: .bold, design: .rounded))
|
||||||
.foregroundStyle(day.isToday ? .white : .primary)
|
.foregroundStyle(isToday ? .white : .primary)
|
||||||
.frame(width: 26, height: 26)
|
.frame(width: 26, height: 26)
|
||||||
.background(
|
.background(
|
||||||
Circle().fill(day.isToday ? WatchTheme.coral : .clear)
|
Circle().fill(isToday ? WatchTheme.coral : .clear)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.frame(width: 32)
|
.frame(width: 32)
|
||||||
@@ -70,7 +75,7 @@ private struct DayRow: View {
|
|||||||
.padding(.vertical, 5)
|
.padding(.vertical, 5)
|
||||||
.padding(.horizontal, 7)
|
.padding(.horizontal, 7)
|
||||||
.background(
|
.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)
|
in: RoundedRectangle(cornerRadius: 9)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,10 +33,10 @@ struct TodayProvider: TimelineProvider {
|
|||||||
completion(Timeline(entries: [entry], policy: .after(nextMidnight)))
|
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? {
|
private var currentDay: WatchWeekPayload.Day? {
|
||||||
guard let payload = WatchWeekPayload.stored() else { return nil }
|
WatchWeekPayload.stored()?.currentDay()
|
||||||
let todayOffset = (Calendar.current.component(.weekday, from: Date()) + 5) % 7
|
|
||||||
return payload.days.first { $0.dayOfWeek == todayOffset } ?? payload.days.first(where: \.isToday)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var sampleDay: WatchWeekPayload.Day {
|
private var sampleDay: WatchWeekPayload.Day {
|
||||||
@@ -209,7 +209,7 @@ struct DaysComplicationView: View {
|
|||||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||||
Text(dayAbbrev(day))
|
Text(dayAbbrev(day))
|
||||||
.font(.system(size: 11, weight: .heavy, design: .rounded))
|
.font(.system(size: 11, weight: .heavy, design: .rounded))
|
||||||
.foregroundStyle(day.isToday
|
.foregroundStyle(day.isCurrentDay()
|
||||||
? Color(red: 1.0, green: 0.45, blue: 0.35)
|
? Color(red: 1.0, green: 0.45, blue: 0.35)
|
||||||
: .secondary)
|
: .secondary)
|
||||||
.widgetAccentable()
|
.widgetAccentable()
|
||||||
|
|||||||
Reference in New Issue
Block a user