From bc684a099c1118af80197e945389fe3560f537c8 Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Sun, 13 Sep 2026 21:48:24 +0200 Subject: [PATCH] watch: semanas navegables y los dias que no se planifican MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_013su1ttRiMeMYxkZJ1Y3246 --- .../Resources/de.lproj/Localizable.strings | 2 + .../Resources/en.lproj/Localizable.strings | 2 + .../Resources/es.lproj/Localizable.strings | 2 + .../Resources/fr.lproj/Localizable.strings | 2 + .../Resources/it.lproj/Localizable.strings | 2 + .../Resources/pt-BR.lproj/Localizable.strings | 2 + MealMood/Services/WatchSyncService.swift | 98 ++++++++++++----- MealMood/Services/WatchWeekPayload.swift | 104 +++++++++++++++++- MealMood/Views/Home/HomeView.swift | 5 +- MealMoodTests/WatchWeekPayloadTests.swift | 77 +++++++++++++ MealMoodWatch/TodayView.swift | 102 +++++++++++------ MealMoodWatch/WeekView.swift | 68 +++++++++--- MealMoodWatchWidget/MealMoodWatchWidget.swift | 12 +- 13 files changed, 396 insertions(+), 82 deletions(-) diff --git a/MealMood/Resources/de.lproj/Localizable.strings b/MealMood/Resources/de.lproj/Localizable.strings index 6fdcddc..513fc5a 100644 --- a/MealMood/Resources/de.lproj/Localizable.strings +++ b/MealMood/Resources/de.lproj/Localizable.strings @@ -484,3 +484,5 @@ "watch_empty_day" = "Für heute ist nichts geplant"; "watch_stale" = "Öffne MealMood auf dem iPhone, um diese Woche zu sehen"; "watch_updated" = "Aktualisiert"; +"watch_not_planned_today" = "Heute planst du keine Mahlzeiten"; +"watch_next_up" = "Als Nächstes"; diff --git a/MealMood/Resources/en.lproj/Localizable.strings b/MealMood/Resources/en.lproj/Localizable.strings index 1e8b2c8..2e5b316 100644 --- a/MealMood/Resources/en.lproj/Localizable.strings +++ b/MealMood/Resources/en.lproj/Localizable.strings @@ -484,3 +484,5 @@ "watch_empty_day" = "Nothing planned for today"; "watch_stale" = "Open MealMood on your iPhone to see this week"; "watch_updated" = "Updated"; +"watch_not_planned_today" = "You don't plan meals today"; +"watch_next_up" = "Next up"; diff --git a/MealMood/Resources/es.lproj/Localizable.strings b/MealMood/Resources/es.lproj/Localizable.strings index 93c28dd..351add1 100644 --- a/MealMood/Resources/es.lproj/Localizable.strings +++ b/MealMood/Resources/es.lproj/Localizable.strings @@ -484,3 +484,5 @@ "watch_empty_day" = "Hoy no hay nada planificado"; "watch_stale" = "Abre MealMood en el iPhone para ver esta semana"; "watch_updated" = "Actualizado"; +"watch_not_planned_today" = "Hoy no planificas comidas"; +"watch_next_up" = "A continuación"; diff --git a/MealMood/Resources/fr.lproj/Localizable.strings b/MealMood/Resources/fr.lproj/Localizable.strings index ee4b6d1..86c5fb1 100644 --- a/MealMood/Resources/fr.lproj/Localizable.strings +++ b/MealMood/Resources/fr.lproj/Localizable.strings @@ -484,3 +484,5 @@ "watch_empty_day" = "Rien de prévu aujourd'hui"; "watch_stale" = "Ouvrez MealMood sur l'iPhone pour voir cette semaine"; "watch_updated" = "Mis à jour"; +"watch_not_planned_today" = "Aujourd'hui vous ne planifiez pas"; +"watch_next_up" = "À suivre"; diff --git a/MealMood/Resources/it.lproj/Localizable.strings b/MealMood/Resources/it.lproj/Localizable.strings index 8b3f1aa..b94af94 100644 --- a/MealMood/Resources/it.lproj/Localizable.strings +++ b/MealMood/Resources/it.lproj/Localizable.strings @@ -484,3 +484,5 @@ "watch_empty_day" = "Oggi non c'è niente di pianificato"; "watch_stale" = "Apri MealMood sull'iPhone per vedere questa settimana"; "watch_updated" = "Aggiornato"; +"watch_not_planned_today" = "Oggi non pianifichi pasti"; +"watch_next_up" = "Prossimamente"; diff --git a/MealMood/Resources/pt-BR.lproj/Localizable.strings b/MealMood/Resources/pt-BR.lproj/Localizable.strings index 16f5d99..f118271 100644 --- a/MealMood/Resources/pt-BR.lproj/Localizable.strings +++ b/MealMood/Resources/pt-BR.lproj/Localizable.strings @@ -484,3 +484,5 @@ "watch_empty_day" = "Nada planejado para hoje"; "watch_stale" = "Abra o MealMood no iPhone para ver esta semana"; "watch_updated" = "Atualizado"; +"watch_not_planned_today" = "Hoje você não planeja refeições"; +"watch_next_up" = "A seguir"; diff --git a/MealMood/Services/WatchSyncService.swift b/MealMood/Services/WatchSyncService.swift index 3feb1f1..57d6797 100644 --- a/MealMood/Services/WatchSyncService.swift +++ b/MealMood/Services/WatchSyncService.swift @@ -42,20 +42,15 @@ final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable { let context = ModelContext(modelContainer) guard let settings = try? context.fetch(FetchDescriptor()).first else { return nil } - let weekStart = Date().startOfWeek() - let descriptor = FetchDescriptor( - predicate: #Predicate { plan in plan.weekStartDate == weekStart } - ) - // No plan for this week yet? Answer with the empty week anyway. - let plan = try? context.fetch(descriptor).first + let plans = (try? context.fetch(FetchDescriptor())) ?? [] let dishes = (try? context.fetch(FetchDescriptor())) ?? [] - 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 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 } 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 /// too, and the watch says there's nothing planned. static func makePayload(plan: WeekPlan?, dishes: [Dish], settings: AppSettings) -> WatchWeekPayload? { - let weekStart = plan?.weekStartDate ?? Date().startOfWeek() - let slots = plan?.slotList ?? [] + makePayload(plans: [plan].compactMap { $0 }, dishes: dishes, settings: settings) + } + + /// 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 dishById = Dictionary(dishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) let dayFormatter = DateFormatter() dayFormatter.locale = locale dayFormatter.setLocalizedDateFormatFromTemplate("EEE d") - let todayOffset = (Calendar.current.component(.weekday, from: Date()) + 5) % 7 - let isCurrentWeek = weekStart == Date().startOfWeek() - let schedule = settings.schedule(for: plan) + let currentWeekStart = now.startOfWeek() + let planByWeek = Dictionary( + 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 slot = slots.first { $0.dayOfWeek == day && $0.mealType == meal.rawValue } let label = String(localized: String.LocalizationValue(meal.localizedKey)) @@ -115,16 +173,6 @@ final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable { 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 diff --git a/MealMood/Services/WatchWeekPayload.swift b/MealMood/Services/WatchWeekPayload.swift index 8c4d634..a131d05 100644 --- a/MealMood/Services/WatchWeekPayload.swift +++ b/MealMood/Services/WatchWeekPayload.swift @@ -11,6 +11,23 @@ struct WatchWeekPayload: Codable { 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 { let dayOfWeek: Int // 0=Monday … 6=Sunday let title: String // localized short title ("Lun 8") @@ -51,6 +68,15 @@ struct WatchWeekPayload: Codable { let days: [Day] 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 /// strings. Optional so older snapshots still decode. var emptyMealText: String? @@ -59,6 +85,11 @@ struct WatchWeekPayload: Codable { /// "Updated" — shown next to `updatedAt` so it's visible at a glance when /// the watch is holding an old snapshot. 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 storageKey = "watch_week_payload_v1" @@ -79,6 +110,16 @@ struct WatchWeekPayload: Codable { UserDefaults(suiteName: Self.appGroupID)?.set(encoded(), forKey: Self.storageKey) } + /// The app defines a week as Monday→Sunday 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`. static func weekdayOffset(for date: Date, calendar: Calendar = .current) -> Int { (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 // 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 } + guard Self.weekAligned(calendar).isDate(updatedAt, equalTo: now, toGranularity: .weekOfYear) else { return nil } let offset = Self.weekdayOffset(for: now, calendar: calendar) 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 /// would be showing the wrong meal. 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 { @@ -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 + } +} diff --git a/MealMood/Views/Home/HomeView.swift b/MealMood/Views/Home/HomeView.swift index f60c959..27d9dac 100644 --- a/MealMood/Views/Home/HomeView.swift +++ b/MealMood/Views/Home/HomeView.swift @@ -1153,10 +1153,11 @@ struct HomeView: View { WidgetDataStore.update(plan: todayPlan, dishes: dishes, settings: settings) // Week snapshot: stored locally for the iOS week widget and pushed to // 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() } - WatchSyncService.shared.push(plan: todayPlan, dishes: dishes, settings: settings) + WatchSyncService.shared.push(plans: weekPlans, dishes: dishes, settings: settings) } @ViewBuilder diff --git a/MealMoodTests/WatchWeekPayloadTests.swift b/MealMoodTests/WatchWeekPayloadTests.swift index 8279024..27bb1df 100644 --- a/MealMoodTests/WatchWeekPayloadTests.swift +++ b/MealMoodTests/WatchWeekPayloadTests.swift @@ -183,6 +183,83 @@ final class WatchWeekPayloadTests: XCTestCase { _ = 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() { XCTAssertEqual(WatchWeekPayload.weekdayOffset(for: date(2026, 9, 7), calendar: calendar), 0) XCTAssertEqual(WatchWeekPayload.weekdayOffset(for: date(2026, 9, 12), calendar: calendar), 5) diff --git a/MealMoodWatch/TodayView.swift b/MealMoodWatch/TodayView.swift index 83980a4..3c01b91 100644 --- a/MealMoodWatch/TodayView.swift +++ b/MealMoodWatch/TodayView.swift @@ -5,52 +5,84 @@ 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?.currentDay() - } - var body: some View { ScrollView { VStack(alignment: .leading, spacing: 8) { - if let today { - HStack(alignment: .firstTextBaseline) { - Text(today.title) - .font(.system(.title3, design: .rounded, weight: .bold)) - .foregroundStyle(WatchTheme.coral) - Spacer() - Image(systemName: "fork.knife") - .font(.caption) - .foregroundStyle(.secondary) - } - .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) + if let payload = store.payload { + switch payload.todayState() { + case .planned(let today): + dayHeader(today.title) + if today.isEmpty { + EmptyDayView(text: payload.emptyDayText) + } else { + ForEach(Array(today.meals.enumerated()), id: \.offset) { _, meal in + MealCard(meal: meal, emptyText: 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 - // say so than to show another day's meals as if they were - // today's. - StaleView(text: payload.staleText) + + // When the watch is holding an old snapshot, this is what + // makes it obvious instead of guessing why meals look wrong. + LastUpdatedFooter(payload: payload) } else { 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 { diff --git a/MealMoodWatch/WeekView.swift b/MealMoodWatch/WeekView.swift index 48c74ce..f7d1f0d 100644 --- a/MealMoodWatch/WeekView.swift +++ b/MealMoodWatch/WeekView.swift @@ -2,28 +2,66 @@ import SwiftUI /// Week page — Calendar-app style: day number in a circle on the left (today /// 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 { @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 { Group { - if let payload = store.payload { - ScrollView { - VStack(alignment: .leading, spacing: 6) { - Text(payload.weekTitle) - .font(.system(.footnote, design: .rounded, weight: .bold)) - .foregroundStyle(WatchTheme.coral) - .padding(.horizontal, 2) - - 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) - } + if weeks.isEmpty { + EmptySyncView() + } else { + TabView(selection: $selection) { + ForEach(weeks) { week in + weekPage(week) + .tag(Optional(week.weekStartDate)) } } - } else { - EmptySyncView() + .tabViewStyle(.verticalPage(transitionStyle: .blur)) + .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()) + } } } } diff --git a/MealMoodWatchWidget/MealMoodWatchWidget.swift b/MealMoodWatchWidget/MealMoodWatchWidget.swift index b9fe940..df55659 100644 --- a/MealMoodWatchWidget/MealMoodWatchWidget.swift +++ b/MealMoodWatchWidget/MealMoodWatchWidget.swift @@ -33,10 +33,16 @@ 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. + /// Today's meals, or — on a day left out of the plan, like a weekend with + /// 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? { - 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 {