bc684a099c
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
219 lines
9.2 KiB
Swift
219 lines
9.2 KiB
Swift
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 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
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
}
|