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
+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]) {
+62 -5
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)
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) {
+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)
)
}