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:
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -42,20 +42,15 @@ final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable {
|
||||
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 }
|
||||
)
|
||||
// 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<WeekPlan>())) ?? []
|
||||
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 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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -5,19 +5,55 @@ 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 {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func dayHeader(_ title: String) -> some View {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(today.title)
|
||||
Text(title)
|
||||
.font(.system(.title3, design: .rounded, weight: .bold))
|
||||
.foregroundStyle(WatchTheme.coral)
|
||||
Spacer()
|
||||
@@ -26,30 +62,26 @@ struct TodayView: View {
|
||||
.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)
|
||||
}
|
||||
}
|
||||
} 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()
|
||||
}
|
||||
/// Today is deliberately outside the plan (weekends off, typically).
|
||||
struct NotPlannedTodayView: View {
|
||||
var text: String?
|
||||
|
||||
// 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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,30 +2,68 @@ 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 {
|
||||
if weeks.isEmpty {
|
||||
EmptySyncView()
|
||||
} else {
|
||||
TabView(selection: $selection) {
|
||||
ForEach(weeks) { week in
|
||||
weekPage(week)
|
||||
.tag(Optional(week.weekStartDate))
|
||||
}
|
||||
}
|
||||
.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) {
|
||||
Text(payload.weekTitle)
|
||||
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(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)
|
||||
ForEach(week.days, id: \.dayOfWeek) { day in
|
||||
// Only the current week can contain today.
|
||||
DayRow(day: day, isToday: week.isCurrentWeek() && day.isCurrentDay())
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
EmptySyncView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user