Files
FamilyMealPlanner/MealMoodWatchWidget/MealMoodWatchWidget.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

243 lines
9.9 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)))
}
/// 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? {
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 {
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: " · ")
}
}