watch: semanas navegables y los dias que no se planifican

Dos cosas que faltaban, las dos visibles en domingo con el fin de semana
desactivado:

El envio al reloj solo llevaba la semana en curso, asi que no habia forma de
ver las semanas ya planificadas por delante. Ahora viajan cuatro (la anterior,
la actual y las dos siguientes) y la vista de semana se desliza entre ellas,
arrancando en la actual.

Y un dia fuera del plan dejaba al reloj sin nada que enseñar: el domingo no
existe en el payload si no planificas fines de semana, asi que la complicacion
salia vacia y la app decia "abre MealMood en el iPhone", como si fuera un
problema de sincronizacion. Ahora se distingue: si hoy no se planifica se dice,
y se enseña el proximo dia que si tiene comidas — que es lo util en el reloj.

De paso, las semanas se comparan con el calendario alineado a lunes. Con el del
sistema, en las regiones donde la semana empieza en domingo, un domingo caia en
otra semana que su propio lunes y un envio recien hecho parecia caducado.

Refs #36, #37

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-13 21:48:24 +02:00
parent 4008eb7fa0
commit bc684a099c
13 changed files with 396 additions and 82 deletions
@@ -484,3 +484,5 @@
"watch_empty_day" = "Für heute ist nichts geplant"; "watch_empty_day" = "Für heute ist nichts geplant";
"watch_stale" = "Öffne MealMood auf dem iPhone, um diese Woche zu sehen"; "watch_stale" = "Öffne MealMood auf dem iPhone, um diese Woche zu sehen";
"watch_updated" = "Aktualisiert"; "watch_updated" = "Aktualisiert";
"watch_not_planned_today" = "Heute planst du keine Mahlzeiten";
"watch_next_up" = "Als Nächstes";
@@ -484,3 +484,5 @@
"watch_empty_day" = "Nothing planned for today"; "watch_empty_day" = "Nothing planned for today";
"watch_stale" = "Open MealMood on your iPhone to see this week"; "watch_stale" = "Open MealMood on your iPhone to see this week";
"watch_updated" = "Updated"; "watch_updated" = "Updated";
"watch_not_planned_today" = "You don't plan meals today";
"watch_next_up" = "Next up";
@@ -484,3 +484,5 @@
"watch_empty_day" = "Hoy no hay nada planificado"; "watch_empty_day" = "Hoy no hay nada planificado";
"watch_stale" = "Abre MealMood en el iPhone para ver esta semana"; "watch_stale" = "Abre MealMood en el iPhone para ver esta semana";
"watch_updated" = "Actualizado"; "watch_updated" = "Actualizado";
"watch_not_planned_today" = "Hoy no planificas comidas";
"watch_next_up" = "A continuación";
@@ -484,3 +484,5 @@
"watch_empty_day" = "Rien de prévu aujourd'hui"; "watch_empty_day" = "Rien de prévu aujourd'hui";
"watch_stale" = "Ouvrez MealMood sur l'iPhone pour voir cette semaine"; "watch_stale" = "Ouvrez MealMood sur l'iPhone pour voir cette semaine";
"watch_updated" = "Mis à jour"; "watch_updated" = "Mis à jour";
"watch_not_planned_today" = "Aujourd'hui vous ne planifiez pas";
"watch_next_up" = "À suivre";
@@ -484,3 +484,5 @@
"watch_empty_day" = "Oggi non c'è niente di pianificato"; "watch_empty_day" = "Oggi non c'è niente di pianificato";
"watch_stale" = "Apri MealMood sull'iPhone per vedere questa settimana"; "watch_stale" = "Apri MealMood sull'iPhone per vedere questa settimana";
"watch_updated" = "Aggiornato"; "watch_updated" = "Aggiornato";
"watch_not_planned_today" = "Oggi non pianifichi pasti";
"watch_next_up" = "Prossimamente";
@@ -484,3 +484,5 @@
"watch_empty_day" = "Nada planejado para hoje"; "watch_empty_day" = "Nada planejado para hoje";
"watch_stale" = "Abra o MealMood no iPhone para ver esta semana"; "watch_stale" = "Abra o MealMood no iPhone para ver esta semana";
"watch_updated" = "Atualizado"; "watch_updated" = "Atualizado";
"watch_not_planned_today" = "Hoje você não planeja refeições";
"watch_next_up" = "A seguir";
+73 -25
View File
@@ -42,20 +42,15 @@ final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable {
let context = ModelContext(modelContainer) let context = ModelContext(modelContainer)
guard let settings = try? context.fetch(FetchDescriptor<AppSettings>()).first else { return nil } guard let settings = try? context.fetch(FetchDescriptor<AppSettings>()).first else { return nil }
let weekStart = Date().startOfWeek() let plans = (try? context.fetch(FetchDescriptor<WeekPlan>())) ?? []
let descriptor = FetchDescriptor<WeekPlan>(
predicate: #Predicate<WeekPlan> { plan in plan.weekStartDate == weekStart }
)
// No plan for this week yet? Answer with the empty week anyway.
let plan = try? context.fetch(descriptor).first
let dishes = (try? context.fetch(FetchDescriptor<Dish>())) ?? [] let dishes = (try? context.fetch(FetchDescriptor<Dish>())) ?? []
return Self.makePayload(plan: plan, dishes: dishes, settings: settings)?.encoded() return Self.makePayload(plans: plans, dishes: dishes, settings: settings)?.encoded()
} }
func push(plan: WeekPlan?, dishes: [Dish], settings: AppSettings) { func push(plans: [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(plans: plans, dishes: dishes, settings: settings),
let data = payload.encoded() else { return } let data = payload.encoded() else { return }
send(data) send(data)
} }
@@ -73,19 +68,82 @@ final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable {
/// went on showing Wednesday's dinner on Saturday. An empty week travels /// went on showing Wednesday's dinner on Saturday. An empty week travels
/// too, and the watch says there's nothing planned. /// too, and the watch says there's nothing planned.
static func makePayload(plan: WeekPlan?, dishes: [Dish], settings: AppSettings) -> WatchWeekPayload? { static func makePayload(plan: WeekPlan?, dishes: [Dish], settings: AppSettings) -> WatchWeekPayload? {
let weekStart = plan?.weekStartDate ?? Date().startOfWeek() makePayload(plans: [plan].compactMap { $0 }, dishes: dishes, settings: settings)
let slots = plan?.slotList ?? [] }
/// Weeks sent to the watch: the previous one, the current one and the two
/// ahead. Sending only the current week is why the watch couldn't move
/// between weeks it simply had nothing else.
static let weekOffsets = [-1, 0, 1, 2]
static func makePayload(
plans: [WeekPlan],
dishes: [Dish],
settings: AppSettings,
now: Date = Date()
) -> WatchWeekPayload? {
let locale = Locale(identifier: settings.languageEnum.resolved().localeIdentifier) let locale = Locale(identifier: settings.languageEnum.resolved().localeIdentifier)
let dishById = Dictionary(dishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) let dishById = Dictionary(dishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
let dayFormatter = DateFormatter() let dayFormatter = DateFormatter()
dayFormatter.locale = locale dayFormatter.locale = locale
dayFormatter.setLocalizedDateFormatFromTemplate("EEE d") dayFormatter.setLocalizedDateFormatFromTemplate("EEE d")
let todayOffset = (Calendar.current.component(.weekday, from: Date()) + 5) % 7 let currentWeekStart = now.startOfWeek()
let isCurrentWeek = weekStart == Date().startOfWeek() let planByWeek = Dictionary(
let schedule = settings.schedule(for: plan) plans.map { ($0.weekStartDate, $0) },
uniquingKeysWith: { first, _ in first }
)
let days: [WatchWeekPayload.Day] = schedule.dayRange.map { day in let weeks: [WatchWeekPayload.Week] = weekOffsets.map { offset in
let weekStart = currentWeekStart.addingDays(offset * 7)
let plan = planByWeek[weekStart]
return WatchWeekPayload.Week(
weekStartDate: weekStart,
weekTitle: weekStart.formattedWeekRange(),
days: days(
weekStart: weekStart,
plan: plan,
settings: settings,
dishById: dishById,
dayFormatter: dayFormatter,
locale: locale,
now: now
)
)
}
guard let currentWeek = weeks.first(where: { $0.weekStartDate == currentWeekStart }) else { return nil }
return WatchWeekPayload(
weekTitle: currentWeek.weekTitle,
days: currentWeek.days,
updatedAt: now,
weekStartDate: currentWeekStart,
weeks: weeks,
emptyMealText: String(localized: "watch_empty_meal"),
emptyDayText: String(localized: "watch_empty_day"),
staleText: String(localized: "watch_stale"),
updatedLabel: String(localized: "watch_updated"),
notPlannedTodayText: String(localized: "watch_not_planned_today"),
nextUpLabel: String(localized: "watch_next_up")
)
}
private static func days(
weekStart: Date,
plan: WeekPlan?,
settings: AppSettings,
dishById: [UUID: Dish],
dayFormatter: DateFormatter,
locale: Locale,
now: Date
) -> [WatchWeekPayload.Day] {
let slots = plan?.slotList ?? []
let schedule = settings.schedule(for: plan)
let todayOffset = (Calendar.current.component(.weekday, from: now) + 5) % 7
let isCurrentWeek = weekStart == now.startOfWeek()
return schedule.dayRange.map { day in
let meals: [WatchWeekPayload.Meal] = schedule.mealTypes.map { meal in let meals: [WatchWeekPayload.Meal] = schedule.mealTypes.map { meal in
let slot = slots.first { $0.dayOfWeek == day && $0.mealType == meal.rawValue } let slot = slots.first { $0.dayOfWeek == day && $0.mealType == meal.rawValue }
let label = String(localized: String.LocalizationValue(meal.localizedKey)) let label = String(localized: String.LocalizationValue(meal.localizedKey))
@@ -115,16 +173,6 @@ final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable {
meals: meals meals: meals
) )
} }
return WatchWeekPayload(
weekTitle: weekStart.formattedWeekRange(),
days: days,
updatedAt: Date(),
emptyMealText: String(localized: "watch_empty_meal"),
emptyDayText: String(localized: "watch_empty_day"),
staleText: String(localized: "watch_stale"),
updatedLabel: String(localized: "watch_updated")
)
} }
// MARK: - WCSessionDelegate // MARK: - WCSessionDelegate
+102 -2
View File
@@ -11,6 +11,23 @@ struct WatchWeekPayload: Codable {
let name: String? // dish (+ secondary), "Comer fuera", or nil if empty let name: String? // dish (+ secondary), "Comer fuera", or nil if empty
} }
struct Week: Codable, Identifiable {
let weekStartDate: Date
let weekTitle: String
let days: [Day]
var id: Date { weekStartDate }
func isCurrentWeek(now: Date = Date(), calendar: Calendar = .current) -> Bool {
WatchWeekPayload.weekAligned(calendar).isDate(weekStartDate, equalTo: now, toGranularity: .weekOfYear)
}
/// Days with at least one meal planned what's worth showing.
var plannedDays: [Day] {
days.filter { !$0.isEmpty }
}
}
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")
@@ -51,6 +68,15 @@ struct WatchWeekPayload: Codable {
let days: [Day] let days: [Day]
let updatedAt: Date let updatedAt: Date
/// Monday of the week `days` belongs to. Optional: snapshots written before
/// 2.1.2 don't carry it.
var weekStartDate: Date?
/// Every week the phone sent (previous, current and the next ones), so the
/// watch can move between them. nil in older snapshots, which only ever
/// carried the current week in `days`.
var weeks: [Week]?
/// Localized texts the watch cannot build on its own its bundle carries no /// Localized texts the watch cannot build on its own its bundle carries no
/// strings. Optional so older snapshots still decode. /// strings. Optional so older snapshots still decode.
var emptyMealText: String? var emptyMealText: String?
@@ -59,6 +85,11 @@ struct WatchWeekPayload: Codable {
/// "Updated" shown next to `updatedAt` so it's visible at a glance when /// "Updated" shown next to `updatedAt` so it's visible at a glance when
/// the watch is holding an old snapshot. /// the watch is holding an old snapshot.
var updatedLabel: String? var updatedLabel: String?
/// "Today you don't plan meals" for the days left out of the plan
/// (weekends turned off), which are not the same as being out of date.
var notPlannedTodayText: String?
/// "Next" heading for the upcoming day shown instead.
var nextUpLabel: 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"
@@ -79,6 +110,16 @@ struct WatchWeekPayload: Codable {
UserDefaults(suiteName: Self.appGroupID)?.set(encoded(), forKey: Self.storageKey) UserDefaults(suiteName: Self.appGroupID)?.set(encoded(), forKey: Self.storageKey)
} }
/// The app defines a week as MondaySunday everywhere (`Date.startOfWeek`),
/// but `Calendar.current.firstWeekday` is Sunday in some regions. Comparing
/// weeks with the raw system calendar would put a Sunday in a different week
/// from its own Monday and make a perfectly current snapshot look stale.
static func weekAligned(_ calendar: Calendar) -> Calendar {
var aligned = calendar
aligned.firstWeekday = 2
return aligned
}
/// 0=Monday 6=Sunday, matching `MealSlot.dayOfWeek`. /// 0=Monday 6=Sunday, matching `MealSlot.dayOfWeek`.
static func weekdayOffset(for date: Date, calendar: Calendar = .current) -> Int { static func weekdayOffset(for date: Date, calendar: Calendar = .current) -> Int {
(calendar.component(.weekday, from: date) + 5) % 7 (calendar.component(.weekday, from: date) + 5) % 7
@@ -95,7 +136,7 @@ struct WatchWeekPayload: Codable {
// only handle and it's trustworthy only while the snapshot itself // only handle and it's trustworthy only while the snapshot itself
// belongs to the current week. Otherwise last week's Saturday would // belongs to the current week. Otherwise last week's Saturday would
// pass for today. // pass for today.
guard calendar.isDate(updatedAt, equalTo: now, toGranularity: .weekOfYear) else { return nil } guard Self.weekAligned(calendar).isDate(updatedAt, equalTo: now, toGranularity: .weekOfYear) else { return nil }
let offset = Self.weekdayOffset(for: now, calendar: calendar) let offset = Self.weekdayOffset(for: now, calendar: calendar)
return days.first { $0.dayOfWeek == offset } return days.first { $0.dayOfWeek == offset }
} }
@@ -103,7 +144,55 @@ struct WatchWeekPayload: Codable {
/// True when the snapshot no longer covers today, so showing any of its days /// True when the snapshot no longer covers today, so showing any of its days
/// would be showing the wrong meal. /// would be showing the wrong meal.
func isStale(now: Date = Date(), calendar: Calendar = .current) -> Bool { func isStale(now: Date = Date(), calendar: Calendar = .current) -> Bool {
currentDay(now: now, calendar: calendar) == nil if case .outOfDate = todayState(now: now, calendar: calendar) { return true }
return false
}
/// Whether the snapshot describes the week we're living in regardless of
/// whether today itself is one of the planned days.
func coversCurrentWeek(now: Date = Date(), calendar: Calendar = .current) -> Bool {
let aligned = Self.weekAligned(calendar)
if let weekStartDate {
return aligned.isDate(weekStartDate, equalTo: now, toGranularity: .weekOfYear)
}
if days.contains(where: { $0.date != nil }) {
return days.contains { aligned.isDate($0.date!, equalTo: now, toGranularity: .weekOfYear) }
}
return aligned.isDate(updatedAt, equalTo: now, toGranularity: .weekOfYear)
}
/// What the watch should be showing right now.
enum TodayState {
/// Today is planned here are its meals.
case planned(Day)
/// Today is deliberately not planned (weekends off). Shows the next day
/// that does have meals, when there is one.
case notPlannedToday(next: Day?)
/// The snapshot is from another week: anything in it would be wrong.
case outOfDate
}
func todayState(now: Date = Date(), calendar: Calendar = .current) -> TodayState {
if let today = currentDay(now: now, calendar: calendar) {
return .planned(today)
}
guard coversCurrentWeek(now: now, calendar: calendar) else { return .outOfDate }
return .notPlannedToday(next: upcomingDay(now: now, calendar: calendar))
}
/// The next day with something planned, looking across every week the phone
/// sent so a Sunday points at Monday's dinner instead of showing nothing.
func upcomingDay(now: Date = Date(), calendar: Calendar = .current) -> Day? {
let startOfToday = calendar.startOfDay(for: now)
let candidates = (weeks?.flatMap(\.days) ?? days)
.filter { !$0.isEmpty }
.compactMap { day -> (Date, Day)? in
guard let date = day.date else { return nil }
return (date, day)
}
.filter { $0.0 >= startOfToday }
.sorted { $0.0 < $1.0 }
return candidates.first?.1
} }
static func icon(for mealType: String) -> String { static func icon(for mealType: String) -> String {
@@ -116,3 +205,14 @@ struct WatchWeekPayload: Codable {
} }
} }
} }
extension Date {
/// Monday of this date's week. The watch targets don't compile the iOS
/// `Date+Helpers`, and this is the only piece of it they need.
func startOfWeekOnWatch(calendar: Calendar = .current) -> Date {
var cal = calendar
cal.firstWeekday = 2 // Monday
let components = cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: self)
return cal.date(from: components) ?? self
}
}
+3 -2
View File
@@ -1153,10 +1153,11 @@ struct HomeView: View {
WidgetDataStore.update(plan: todayPlan, dishes: dishes, settings: settings) WidgetDataStore.update(plan: todayPlan, dishes: dishes, settings: settings)
// Week snapshot: stored locally for the iOS week widget and pushed to // Week snapshot: stored locally for the iOS week widget and pushed to
// the watch app/complication via WatchConnectivity. // the watch app/complication via WatchConnectivity.
if let payload = WatchSyncService.makePayload(plan: todayPlan, dishes: dishes, settings: settings) { // The watch gets the neighbouring weeks too, so it can move between them.
if let payload = WatchSyncService.makePayload(plans: weekPlans, dishes: dishes, settings: settings) {
payload.store() payload.store()
} }
WatchSyncService.shared.push(plan: todayPlan, dishes: dishes, settings: settings) WatchSyncService.shared.push(plans: weekPlans, dishes: dishes, settings: settings)
} }
@ViewBuilder @ViewBuilder
+77
View File
@@ -183,6 +183,83 @@ final class WatchWeekPayloadTests: XCTestCase {
_ = container _ = container
} }
// MARK: - Days that are deliberately not planned
@MainActor
func testSundayWithWeekendsOffSaysSoAndPointsAtMonday() throws {
// The reported case: weekends off, so Sunday isn't in the payload at
// all. That is not "out of date" it's a day nobody plans.
let container = try ModelContainer(
for: AppSettings.self, Dish.self, Tag.self, WeekPlan.self, MealSlot.self, ShoppingItem.self,
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
)
let context = ModelContext(container)
let settings = AppSettings()
settings.activeMealTypes = [.dinner]
settings.includeWeekends = false
context.insert(settings)
let sunday = date(2026, 9, 13)
let thisMonday = sunday.startOfWeek()
let nextMonday = thisMonday.addingDays(7)
let dish = Dish(name: "Lentejas")
context.insert(dish)
let nextWeekPlan = WeekPlan(weekStartDate: nextMonday)
let slot = MealSlot(dayOfWeek: 0, mealType: MealType.dinner.rawValue)
slot.dishId = dish.id
slot.weekPlan = nextWeekPlan
nextWeekPlan.slotList.append(slot)
context.insert(nextWeekPlan)
let payload = try XCTUnwrap(
WatchSyncService.makePayload(plans: [nextWeekPlan], dishes: [dish], settings: settings, now: sunday)
)
XCTAssertTrue(payload.coversCurrentWeek(now: sunday, calendar: calendar))
XCTAssertFalse(payload.isStale(now: sunday, calendar: calendar),
"a Sunday you don't plan is not a stale snapshot")
switch payload.todayState(now: sunday, calendar: calendar) {
case .notPlannedToday(let next):
XCTAssertEqual(next?.meals.first?.name, "Lentejas",
"it should point at the next planned day, not show nothing")
default:
XCTFail("Sunday with weekends off must report as not planned")
}
_ = container
}
// MARK: - Moving between weeks
@MainActor
func testThePhoneSendsTheNeighbouringWeeks() throws {
let container = try ModelContainer(
for: AppSettings.self, Dish.self, Tag.self, WeekPlan.self, MealSlot.self, ShoppingItem.self,
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
)
let context = ModelContext(container)
let settings = AppSettings()
settings.activeMealTypes = [.lunch, .dinner]
settings.includeWeekends = true
context.insert(settings)
let now = date(2026, 9, 13)
let payload = try XCTUnwrap(
WatchSyncService.makePayload(plans: [], dishes: [], settings: settings, now: now)
)
let weeks = try XCTUnwrap(payload.weeks)
XCTAssertEqual(weeks.count, WatchSyncService.weekOffsets.count,
"without several weeks the watch has nothing to page through")
XCTAssertEqual(weeks.filter { $0.isCurrentWeek(now: now, calendar: calendar) }.count, 1)
XCTAssertEqual(payload.weekStartDate, now.startOfWeek())
// Weeks arrive in order, so paging forward moves forward in time.
XCTAssertEqual(weeks.map(\.weekStartDate), weeks.map(\.weekStartDate).sorted())
_ = container
}
func testWeekdayOffsetMapsMondayToZeroAndSundayToSix() { func testWeekdayOffsetMapsMondayToZeroAndSundayToSix() {
XCTAssertEqual(WatchWeekPayload.weekdayOffset(for: date(2026, 9, 7), calendar: calendar), 0) 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, 12), calendar: calendar), 5)
+67 -35
View File
@@ -5,52 +5,84 @@ 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? {
store.payload?.currentDay()
}
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
if let today { if let payload = store.payload {
HStack(alignment: .firstTextBaseline) { switch payload.todayState() {
Text(today.title) case .planned(let today):
.font(.system(.title3, design: .rounded, weight: .bold)) dayHeader(today.title)
.foregroundStyle(WatchTheme.coral) if today.isEmpty {
Spacer() EmptyDayView(text: payload.emptyDayText)
Image(systemName: "fork.knife") } else {
.font(.caption) ForEach(Array(today.meals.enumerated()), id: \.offset) { _, meal in
.foregroundStyle(.secondary) MealCard(meal: meal, emptyText: payload.emptyMealText)
} }
.padding(.horizontal, 2)
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)
} }
case .notPlannedToday(let next):
// A weekend with weekends turned off isn't a sync
// problem say so, and point at what does come next.
NotPlannedTodayView(text: payload.notPlannedTodayText)
if let next {
if let label = payload.nextUpLabel {
Text(label.uppercased())
.font(.system(size: 10, weight: .bold, design: .rounded))
.foregroundStyle(.secondary)
.padding(.top, 2)
}
dayHeader(next.title)
ForEach(Array(next.meals.enumerated()), id: \.offset) { _, meal in
MealCard(meal: meal, emptyText: payload.emptyMealText)
}
}
case .outOfDate:
StaleView(text: payload.staleText)
} }
} else if let payload = store.payload {
// There is a snapshot, but it doesn't cover today: better to // When the watch is holding an old snapshot, this is what
// say so than to show another day's meals as if they were // makes it obvious instead of guessing why meals look wrong.
// today's. LastUpdatedFooter(payload: payload)
StaleView(text: payload.staleText)
} else { } else {
EmptySyncView() EmptySyncView()
} }
// When the watch is holding an old snapshot, this is what makes
// it obvious instead of guessing why the meals look wrong.
if let payload = store.payload {
LastUpdatedFooter(payload: payload)
}
} }
} }
} }
private func dayHeader(_ title: String) -> some View {
HStack(alignment: .firstTextBaseline) {
Text(title)
.font(.system(.title3, design: .rounded, weight: .bold))
.foregroundStyle(WatchTheme.coral)
Spacer()
Image(systemName: "fork.knife")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 2)
}
}
/// Today is deliberately outside the plan (weekends off, typically).
struct NotPlannedTodayView: View {
var text: String?
var body: some View {
HStack(spacing: 6) {
Image(systemName: "calendar.badge.minus")
.font(.system(size: 13))
.foregroundStyle(WatchTheme.coral)
if let text {
Text(text)
.font(.system(size: 12, weight: .semibold, design: .rounded))
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 4)
}
} }
private struct LastUpdatedFooter: View { private struct LastUpdatedFooter: View {
+53 -15
View File
@@ -2,28 +2,66 @@ import SwiftUI
/// Week page Calendar-app style: day number in a circle on the left (today /// Week page Calendar-app style: day number in a circle on the left (today
/// filled in coral), meals with tinted icons on the right. /// filled in coral), meals with tinted icons on the right.
///
/// Swipes horizontally between every week the phone sent, so the weeks already
/// planned ahead are reachable from the wrist.
struct WeekView: View { struct WeekView: View {
@EnvironmentObject private var store: WatchWeekStore @EnvironmentObject private var store: WatchWeekStore
@State private var selection: Date?
/// Weeks from the snapshot, or the single week of a pre-2.1.2 payload.
private var weeks: [WatchWeekPayload.Week] {
if let weeks = store.payload?.weeks, !weeks.isEmpty { return weeks }
guard let payload = store.payload else { return [] }
return [WatchWeekPayload.Week(
weekStartDate: payload.weekStartDate ?? payload.updatedAt.startOfWeekOnWatch(),
weekTitle: payload.weekTitle,
days: payload.days
)]
}
var body: some View { var body: some View {
Group { Group {
if let payload = store.payload { if weeks.isEmpty {
ScrollView { EmptySyncView()
VStack(alignment: .leading, spacing: 6) { } else {
Text(payload.weekTitle) TabView(selection: $selection) {
.font(.system(.footnote, design: .rounded, weight: .bold)) ForEach(weeks) { week in
.foregroundStyle(WatchTheme.coral) weekPage(week)
.padding(.horizontal, 2) .tag(Optional(week.weekStartDate))
ForEach(payload.days, id: \.dayOfWeek) { day in
// Nothing is "today" in a snapshot that no longer
// covers today.
DayRow(day: day, isToday: payload.currentDay()?.dayOfWeek == day.dayOfWeek)
}
} }
} }
} else { .tabViewStyle(.verticalPage(transitionStyle: .blur))
EmptySyncView() .onAppear {
// Land on the current week, not on the oldest one.
if selection == nil {
selection = weeks.first(where: { $0.isCurrentWeek() })?.weekStartDate
?? weeks.first?.weekStartDate
}
}
}
}
}
private func weekPage(_ week: WatchWeekPayload.Week) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 4) {
Text(week.weekTitle)
.font(.system(.footnote, design: .rounded, weight: .bold))
.foregroundStyle(WatchTheme.coral)
if !week.isCurrentWeek() {
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 8, weight: .bold))
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 2)
ForEach(week.days, id: \.dayOfWeek) { day in
// Only the current week can contain today.
DayRow(day: day, isToday: week.isCurrentWeek() && day.isCurrentDay())
}
} }
} }
} }
@@ -33,10 +33,16 @@ 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 /// Today's meals, or on a day left out of the plan, like a weekend with
/// then shows its empty state instead of last week's dinner. /// weekends turned off the next day that does have meals. nil only when
/// the snapshot is from another week entirely.
private var currentDay: WatchWeekPayload.Day? { private var currentDay: WatchWeekPayload.Day? {
WatchWeekPayload.stored()?.currentDay() guard let payload = WatchWeekPayload.stored() else { return nil }
switch payload.todayState() {
case .planned(let day): return day
case .notPlannedToday(let next): return next
case .outOfDate: return nil
}
} }
private var sampleDay: WatchWeekPayload.Day { private var sampleDay: WatchWeekPayload.Day {