a049e4f74b
El iPhone marcaba isToday al construir el snapshot y el reloj se creia esa marca para siempre. Como el snapshot sobrevive en el app group, un sabado seguia enseñando la cena del miercoles: el dia que era cuando se genero. Ahora cada dia viaja con su fecha real y el reloj busca hoy cuando dibuja. Si el snapshot es de otra semana no hay "hoy" que enseñar, asi que en vez de colar la comida de otro dia lo dice y pide abrir el iPhone. Para snapshots guardados por versiones anteriores, que no traen fecha, solo se confia en el indice del dia mientras el propio snapshot sea de esta semana. Ademas el reloj pide datos al abrirse y el iPhone, en vez de responder con lo ultimo que cacheo, reconstruye la semana desde el store — que es la otra mitad del "no se actualiza": el cache podia ser de hace dias. Y cuando no hay nada planificado se dice, que antes era un guion indistinguible de una comida marcada como no planificada: "Hoy no hay nada planificado" para el dia entero y "Sin planificar" por comida, en los 6 idiomas (el bundle del reloj no tiene cadenas propias, viajan en el envio). Refs #34, #35 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013su1ttRiMeMYxkZJ1Y3246
237 lines
9.6 KiB
Swift
237 lines
9.6 KiB
Swift
import WidgetKit
|
|
import SwiftUI
|
|
|
|
/// Watch complication: today's meals from the last snapshot the watch app
|
|
/// stored in the app group. Refreshed via WidgetCenter whenever the watch app
|
|
/// receives new data from the iPhone.
|
|
@main
|
|
struct MealMoodWatchWidgetBundle: WidgetBundle {
|
|
var body: some Widget {
|
|
MealMoodWatchWidget()
|
|
MealMoodWatchDaysWidget()
|
|
}
|
|
}
|
|
|
|
struct TodayEntry: TimelineEntry {
|
|
let date: Date
|
|
let day: WatchWeekPayload.Day?
|
|
}
|
|
|
|
struct TodayProvider: TimelineProvider {
|
|
func placeholder(in context: Context) -> TodayEntry {
|
|
TodayEntry(date: Date(), day: sampleDay)
|
|
}
|
|
|
|
func getSnapshot(in context: Context, completion: @escaping (TodayEntry) -> Void) {
|
|
completion(TodayEntry(date: Date(), day: currentDay ?? sampleDay))
|
|
}
|
|
|
|
func getTimeline(in context: Context, completion: @escaping (Timeline<TodayEntry>) -> Void) {
|
|
let entry = TodayEntry(date: Date(), day: currentDay)
|
|
// Refresh after midnight so "today" rolls over even without new pushes.
|
|
let nextMidnight = Calendar.current.startOfDay(for: Date()).addingTimeInterval(86_400 + 300)
|
|
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.
|
|
private var currentDay: WatchWeekPayload.Day? {
|
|
WatchWeekPayload.stored()?.currentDay()
|
|
}
|
|
|
|
private var sampleDay: WatchWeekPayload.Day {
|
|
WatchWeekPayload.Day(
|
|
dayOfWeek: 0, title: "Lun 8", isToday: true,
|
|
meals: [
|
|
.init(type: "lunch", label: "Comida", name: "Ensalada César"),
|
|
.init(type: "dinner", label: "Cena", name: "Salmón al horno")
|
|
]
|
|
)
|
|
}
|
|
}
|
|
|
|
struct MealMoodWatchWidget: Widget {
|
|
var body: some WidgetConfiguration {
|
|
StaticConfiguration(kind: "MealMoodWatchToday", provider: TodayProvider()) { entry in
|
|
TodayComplicationView(entry: entry)
|
|
.containerBackground(.clear, for: .widget)
|
|
}
|
|
.configurationDisplayName("MealMood")
|
|
.supportedFamilies([.accessoryRectangular, .accessoryInline, .accessoryCircular])
|
|
}
|
|
}
|
|
|
|
struct TodayComplicationView: View {
|
|
@Environment(\.widgetFamily) private var family
|
|
let entry: TodayEntry
|
|
|
|
var body: some View {
|
|
switch family {
|
|
case .accessoryInline:
|
|
if let meal = nextMeal {
|
|
Text("\(meal.label): \(meal.name ?? "—")")
|
|
} else {
|
|
Text("MealMood")
|
|
}
|
|
case .accessoryCircular:
|
|
VStack(spacing: 0) {
|
|
Image(systemName: WatchWeekPayload.icon(for: nextMeal?.type ?? "dinner"))
|
|
.font(.system(size: 14, weight: .semibold))
|
|
Text(nextMeal?.name?.prefix(6) ?? "—")
|
|
.font(.system(size: 9))
|
|
.lineLimit(1)
|
|
}
|
|
default:
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
if let day = entry.day {
|
|
// The rectangular slot fits ~3 lines: with fewer meals the
|
|
// text scales up so the space isn't wasted.
|
|
let meals = Array(day.meals.prefix(3))
|
|
let nameSize: CGFloat = meals.count <= 1 ? 17 : (meals.count == 2 ? 15 : 12)
|
|
ForEach(Array(meals.enumerated()), id: \.offset) { _, meal in
|
|
HStack(spacing: 4) {
|
|
Image(systemName: WatchWeekPayload.icon(for: meal.type))
|
|
.font(.system(size: nameSize - 3, weight: .bold))
|
|
.foregroundStyle(accent(for: meal.type))
|
|
.widgetAccentable()
|
|
.frame(width: nameSize)
|
|
Text(meal.name ?? "—")
|
|
.font(.system(size: nameSize, weight: .semibold, design: .rounded))
|
|
.lineLimit(meals.count <= 1 ? 3 : (meals.count == 2 ? 2 : 1))
|
|
.minimumScaleFactor(0.75)
|
|
}
|
|
}
|
|
} else {
|
|
Text(verbatim: "MealMood")
|
|
.font(.system(size: 12, weight: .semibold, design: .rounded))
|
|
Image(systemName: "iphone.and.arrow.forward")
|
|
.font(.system(size: 10))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
private func accent(for mealType: String) -> Color {
|
|
Self.accentColor(for: mealType)
|
|
}
|
|
|
|
static func accentColor(for mealType: String) -> Color {
|
|
switch mealType {
|
|
case "breakfast": return Color(red: 0.95, green: 0.68, blue: 0.25)
|
|
case "lunch": return Color(red: 0.98, green: 0.55, blue: 0.30)
|
|
case "snack": return Color(red: 0.62, green: 0.50, blue: 0.83)
|
|
case "dinner": return Color(red: 0.28, green: 0.62, blue: 0.55)
|
|
default: return Color(red: 1.0, green: 0.45, blue: 0.35)
|
|
}
|
|
}
|
|
|
|
/// Lunch until 16:00 local, dinner after — simple heuristic for the small families.
|
|
private var nextMeal: WatchWeekPayload.Meal? {
|
|
guard let meals = entry.day?.meals, !meals.isEmpty else { return nil }
|
|
let hour = Calendar.current.component(.hour, from: entry.date)
|
|
if hour >= 16, let dinner = meals.last(where: { $0.name != nil }) { return dinner }
|
|
return meals.first(where: { $0.name != nil }) ?? meals.first
|
|
}
|
|
}
|
|
|
|
// MARK: - Next days complication
|
|
|
|
struct DaysEntry: TimelineEntry {
|
|
let date: Date
|
|
let days: [WatchWeekPayload.Day]
|
|
}
|
|
|
|
struct DaysProvider: TimelineProvider {
|
|
func placeholder(in context: Context) -> DaysEntry {
|
|
DaysEntry(date: Date(), days: sampleDays)
|
|
}
|
|
|
|
func getSnapshot(in context: Context, completion: @escaping (DaysEntry) -> Void) {
|
|
let days = upcomingDays
|
|
completion(DaysEntry(date: Date(), days: days.isEmpty ? sampleDays : days))
|
|
}
|
|
|
|
func getTimeline(in context: Context, completion: @escaping (Timeline<DaysEntry>) -> Void) {
|
|
let entry = DaysEntry(date: Date(), days: upcomingDays)
|
|
let nextMidnight = Calendar.current.startOfDay(for: Date()).addingTimeInterval(86_400 + 300)
|
|
completion(Timeline(entries: [entry], policy: .after(nextMidnight)))
|
|
}
|
|
|
|
/// Today and the next two planned days (falls back to the week's first
|
|
/// days when today is outside the planned range, e.g. weekends off).
|
|
private var upcomingDays: [WatchWeekPayload.Day] {
|
|
guard let payload = WatchWeekPayload.stored() else { return [] }
|
|
let todayOffset = (Calendar.current.component(.weekday, from: Date()) + 5) % 7
|
|
let fromToday = payload.days.filter { $0.dayOfWeek >= todayOffset }
|
|
let source = fromToday.isEmpty ? payload.days : fromToday
|
|
return Array(source.prefix(3))
|
|
}
|
|
|
|
private var sampleDays: [WatchWeekPayload.Day] {
|
|
[
|
|
.init(dayOfWeek: 0, title: "Lun 8", isToday: true, meals: [
|
|
.init(type: "lunch", label: "Comida", name: "Ensalada"),
|
|
.init(type: "dinner", label: "Cena", name: "Salmón")]),
|
|
.init(dayOfWeek: 1, title: "Mar 9", isToday: false, meals: [
|
|
.init(type: "dinner", label: "Cena", name: "Lentejas")]),
|
|
.init(dayOfWeek: 2, title: "Mié 10", isToday: false, meals: [
|
|
.init(type: "dinner", label: "Cena", name: "Pizza casera")])
|
|
]
|
|
}
|
|
}
|
|
|
|
struct MealMoodWatchDaysWidget: Widget {
|
|
var body: some WidgetConfiguration {
|
|
StaticConfiguration(kind: "MealMoodWatchDays", provider: DaysProvider()) { entry in
|
|
DaysComplicationView(entry: entry)
|
|
.containerBackground(.clear, for: .widget)
|
|
}
|
|
.configurationDisplayName("MealMood — 3 días")
|
|
.supportedFamilies([.accessoryRectangular])
|
|
}
|
|
}
|
|
|
|
struct DaysComplicationView: View {
|
|
let entry: DaysEntry
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
if entry.days.isEmpty {
|
|
Text(verbatim: "MealMood")
|
|
.font(.system(size: 12, weight: .semibold, design: .rounded))
|
|
Image(systemName: "iphone.and.arrow.forward")
|
|
.font(.system(size: 10))
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
ForEach(entry.days, id: \.dayOfWeek) { day in
|
|
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
|
Text(dayAbbrev(day))
|
|
.font(.system(size: 11, weight: .heavy, design: .rounded))
|
|
.foregroundStyle(day.isCurrentDay()
|
|
? Color(red: 1.0, green: 0.45, blue: 0.35)
|
|
: .secondary)
|
|
.widgetAccentable()
|
|
.frame(width: 28, alignment: .leading)
|
|
Text(mealsLine(day))
|
|
.font(.system(size: 12, weight: .medium, design: .rounded))
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.7)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
private func dayAbbrev(_ day: WatchWeekPayload.Day) -> String {
|
|
String(day.title.split(separator: " ").first ?? "").uppercased()
|
|
}
|
|
|
|
private func mealsLine(_ day: WatchWeekPayload.Day) -> String {
|
|
let names = day.meals.compactMap(\.name)
|
|
return names.isEmpty ? "—" : names.joined(separator: " · ")
|
|
}
|
|
}
|