Files
FamilyMealPlanner/MealMood/Services/WatchWeekPayload.swift
T
alexandrev-tibco bc684a099c 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
2026-09-13 21:48:31 +02:00

219 lines
9.2 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
/// Snapshot of the week the iPhone pushes to the Watch (WatchConnectivity) and
/// the watch widget reads from the watch-side app group. Labels and day titles
/// arrive already localized the watch never formats, only renders.
/// Member of the iOS app, watch app and watch widget targets.
struct WatchWeekPayload: Codable {
struct Meal: Codable {
let type: String // MealType rawValue, for the icon
let label: String // localized ("Comida", "Cena")
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")
/// `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
/// 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]
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 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?
var emptyDayText: String?
var staleText: String?
/// "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"
func encoded() -> Data? { try? JSONEncoder().encode(self) }
static func decode(_ data: Data) -> WatchWeekPayload? {
try? JSONDecoder().decode(WatchWeekPayload.self, from: data)
}
/// Reads the last snapshot stored on this device's app group.
static func stored() -> WatchWeekPayload? {
guard let data = UserDefaults(suiteName: appGroupID)?.data(forKey: storageKey) else { return nil }
return decode(data)
}
func store() {
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`.
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 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 }
}
/// 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 {
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 {
switch mealType {
case "breakfast": return "cup.and.saucer.fill"
case "lunch": return "sun.max.fill"
case "snack": return "carrot.fill"
case "dinner": return "moon.stars.fill"
default: return "fork.knife"
}
}
}
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
}
}