c0b485387d
- Target MealMoodWatch (watchOS 10, SwiftUI): vista de hoy y de la semana en TabView vertical; recibe el snapshot del iPhone por WatchConnectivity (updateApplicationContext) y lo guarda en su app group para la complicacion - Target MealMoodWatchWidget: complicacion accessoryRectangular/ Circular/Inline con las comidas de hoy (heuristica comida/cena por hora); icono del watch generado desde el logo - WatchWeekPayload compartido entre iOS app, widget iOS, watch app y complicacion — llega ya localizado desde el iPhone - Widget iOS nuevo "Semana" (systemMedium/Large) con los 7 dias; el widget de hoy pasa a WidgetBundle - Targets creados via gema xcodeproj (sin abrir Xcode); embed del watch antes del script de Crashlytics para evitar ciclo de build Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
432 lines
15 KiB
Swift
432 lines
15 KiB
Swift
import WidgetKit
|
|
import SwiftUI
|
|
import Foundation
|
|
|
|
// MARK: - Shared data model (mirrors WidgetDataStore in main app)
|
|
|
|
private struct TodayMealData: Codable {
|
|
struct Meal: Codable {
|
|
let type: String
|
|
let label: String
|
|
let name: String?
|
|
}
|
|
|
|
let meals: [Meal]
|
|
let weekdayName: String
|
|
let updatedAt: Date
|
|
}
|
|
|
|
private enum WidgetStore {
|
|
static let appGroupID = "group.com.alexandrevazquez.mealmood"
|
|
static let key = "mealmood.today_meals"
|
|
|
|
static func read() -> TodayMealData? {
|
|
guard let defaults = UserDefaults(suiteName: appGroupID),
|
|
let data = defaults.data(forKey: key) else { return nil }
|
|
return try? JSONDecoder().decode(TodayMealData.self, from: data)
|
|
}
|
|
}
|
|
|
|
// MARK: - Timeline
|
|
|
|
struct MealItem: Hashable {
|
|
let type: String
|
|
let label: String
|
|
let name: String?
|
|
|
|
var icon: String {
|
|
switch type {
|
|
case "breakfast": return "cup.and.saucer.fill"
|
|
case "lunch": return "sun.max.fill"
|
|
case "snack": return "carrot.fill"
|
|
default: return "moon.stars.fill"
|
|
}
|
|
}
|
|
|
|
var color: Color {
|
|
switch type {
|
|
case "breakfast": return .mmBreakfastAmber
|
|
case "lunch": return .mmLunchOrange
|
|
case "snack": return .mmSnackPurple
|
|
default: return .mmDinnerBlue
|
|
}
|
|
}
|
|
}
|
|
|
|
struct TodayMealEntry: TimelineEntry {
|
|
let date: Date
|
|
let meals: [MealItem]
|
|
let weekdayName: String
|
|
|
|
/// Compact families show the "main" meals when the list is long.
|
|
var compactMeals: [MealItem] {
|
|
guard meals.count > 2 else { return meals }
|
|
let main = meals.filter { $0.type == "lunch" || $0.type == "dinner" }
|
|
return main.isEmpty ? Array(meals.prefix(2)) : Array(main.prefix(2))
|
|
}
|
|
}
|
|
|
|
struct Provider: TimelineProvider {
|
|
func placeholder(in context: Context) -> TodayMealEntry {
|
|
TodayMealEntry(
|
|
date: Date(),
|
|
meals: [
|
|
MealItem(type: "lunch", label: "Comida", name: "Pasta al pesto"),
|
|
MealItem(type: "dinner", label: "Cena", name: "Salmón al horno")
|
|
],
|
|
weekdayName: "Lunes"
|
|
)
|
|
}
|
|
|
|
func getSnapshot(in context: Context, completion: @escaping (TodayMealEntry) -> Void) {
|
|
completion(entry(from: WidgetStore.read()))
|
|
}
|
|
|
|
func getTimeline(in context: Context, completion: @escaping (Timeline<TodayMealEntry>) -> Void) {
|
|
let nextMidnight = Calendar.current.startOfDay(for: Calendar.current.date(byAdding: .day, value: 1, to: Date())!)
|
|
completion(Timeline(entries: [entry(from: WidgetStore.read())], policy: .after(nextMidnight)))
|
|
}
|
|
|
|
private func entry(from data: TodayMealData?) -> TodayMealEntry {
|
|
TodayMealEntry(
|
|
date: Date(),
|
|
meals: (data?.meals ?? []).map {
|
|
MealItem(type: $0.type, label: $0.label, name: $0.name)
|
|
},
|
|
weekdayName: data?.weekdayName ?? ""
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Colors
|
|
|
|
private extension Color {
|
|
static let mmCoral = Color(red: 0.93, green: 0.42, blue: 0.31)
|
|
static let mmBgWarm = Color(red: 1.00, green: 0.94, blue: 0.91)
|
|
static let mmBgMint = Color(red: 0.93, green: 0.98, blue: 0.95)
|
|
static let mmText = Color(red: 0.13, green: 0.13, blue: 0.13)
|
|
static let mmSubtext = Color(red: 0.55, green: 0.55, blue: 0.55)
|
|
}
|
|
|
|
extension Color {
|
|
static let mmBreakfastAmber = Color(red: 0.76, green: 0.55, blue: 0.18)
|
|
static let mmLunchOrange = Color(red: 0.90, green: 0.55, blue: 0.18)
|
|
static let mmSnackPurple = Color(red: 0.51, green: 0.39, blue: 0.69)
|
|
static let mmDinnerBlue = Color(red: 0.36, green: 0.48, blue: 0.78)
|
|
}
|
|
|
|
// MARK: - Small widget
|
|
|
|
private struct SmallWidgetView: View {
|
|
let entry: TodayMealEntry
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "fork.knife")
|
|
.font(.system(size: 10, weight: .bold))
|
|
.foregroundColor(.mmCoral)
|
|
Text("MealMood")
|
|
.font(.system(size: 11, weight: .bold))
|
|
.foregroundColor(.mmCoral)
|
|
Spacer()
|
|
}
|
|
|
|
Text(entry.weekdayName)
|
|
.font(.system(size: 10, weight: .medium))
|
|
.foregroundColor(.mmSubtext)
|
|
.lineLimit(1)
|
|
.padding(.top, 2)
|
|
.padding(.bottom, 10)
|
|
|
|
Spacer()
|
|
|
|
let rows = entry.compactMeals
|
|
ForEach(Array(rows.enumerated()), id: \.element) { index, meal in
|
|
mealRow(meal)
|
|
.padding(.bottom, index < rows.count - 1 ? 8 : 0)
|
|
}
|
|
if rows.isEmpty {
|
|
Text("—")
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundColor(.mmSubtext)
|
|
}
|
|
}
|
|
.padding(12)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
|
.background(
|
|
LinearGradient(colors: [.mmBgWarm, .mmBgMint],
|
|
startPoint: .topLeading, endPoint: .bottomTrailing)
|
|
)
|
|
}
|
|
|
|
private func mealRow(_ meal: MealItem) -> some View {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: meal.icon)
|
|
.font(.system(size: 13))
|
|
.foregroundColor(meal.color)
|
|
.frame(width: 18)
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(meal.label.uppercased())
|
|
.font(.system(size: 8, weight: .semibold))
|
|
.foregroundColor(.mmSubtext)
|
|
Text(meal.name ?? "—")
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundColor(.mmText)
|
|
.lineLimit(2)
|
|
.minimumScaleFactor(0.75)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Medium widget
|
|
|
|
private struct MediumWidgetView: View {
|
|
let entry: TodayMealEntry
|
|
|
|
var body: some View {
|
|
HStack(spacing: 0) {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Image(systemName: "fork.knife")
|
|
.font(.system(size: 18, weight: .bold))
|
|
.foregroundColor(.mmCoral)
|
|
Spacer()
|
|
Text(entry.weekdayName)
|
|
.font(.system(size: 16, weight: .bold))
|
|
.foregroundColor(.mmText)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.7)
|
|
Text(Date(), style: .date)
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundColor(.mmSubtext)
|
|
}
|
|
.padding(14)
|
|
.frame(width: 112)
|
|
.frame(maxHeight: .infinity, alignment: .leading)
|
|
.background(Color.mmBgWarm)
|
|
|
|
Rectangle()
|
|
.fill(Color.mmCoral.opacity(0.2))
|
|
.frame(width: 1)
|
|
|
|
let rows = entry.meals.isEmpty ? entry.compactMeals : entry.meals
|
|
VStack(alignment: .leading, spacing: rows.count > 2 ? 8 : 14) {
|
|
ForEach(Array(rows.enumerated()), id: \.element) { index, meal in
|
|
mediumRow(meal, compact: rows.count > 2)
|
|
if index < rows.count - 1 {
|
|
Divider()
|
|
}
|
|
}
|
|
if rows.isEmpty {
|
|
Text("—")
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundColor(.mmSubtext)
|
|
}
|
|
}
|
|
.padding(14)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
|
.background(Color.white)
|
|
}
|
|
}
|
|
|
|
private func mediumRow(_ meal: MealItem, compact: Bool) -> some View {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: meal.icon)
|
|
.font(.system(size: compact ? 13 : 16))
|
|
.foregroundColor(meal.color)
|
|
.frame(width: 22)
|
|
if compact {
|
|
Text(meal.label.uppercased())
|
|
.font(.system(size: 9, weight: .semibold))
|
|
.foregroundColor(.mmSubtext)
|
|
.frame(width: 70, alignment: .leading)
|
|
Text(meal.name ?? "—")
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundColor(.mmText)
|
|
.lineLimit(1)
|
|
} else {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(meal.label.uppercased())
|
|
.font(.system(size: 9, weight: .semibold))
|
|
.foregroundColor(.mmSubtext)
|
|
Text(meal.name ?? "—")
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundColor(.mmText)
|
|
.lineLimit(2)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Lock Screen widgets
|
|
|
|
private struct AccessoryRectangularView: View {
|
|
let entry: TodayMealEntry
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack(spacing: 3) {
|
|
Image(systemName: "fork.knife")
|
|
.font(.system(size: 10, weight: .bold))
|
|
Text(entry.weekdayName.isEmpty ? "MealMood" : entry.weekdayName)
|
|
.font(.system(size: 12, weight: .bold))
|
|
.lineLimit(1)
|
|
}
|
|
.widgetAccentable()
|
|
|
|
let rows = entry.compactMeals.filter { $0.name != nil }
|
|
ForEach(rows.prefix(2), id: \.self) { meal in
|
|
Text("\(Image(systemName: meal.icon)) \(meal.name ?? "")")
|
|
.font(.system(size: 12))
|
|
.lineLimit(1)
|
|
}
|
|
if rows.isEmpty {
|
|
Text("—")
|
|
.font(.system(size: 12))
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
private struct AccessoryInlineView: View {
|
|
let entry: TodayMealEntry
|
|
|
|
var body: some View {
|
|
// Inline is a single line: prefer the last main meal with a dish.
|
|
let name = entry.compactMeals.reversed().compactMap(\.name).first
|
|
Label(name ?? "MealMood", systemImage: "fork.knife")
|
|
}
|
|
}
|
|
|
|
// MARK: - Widget entry view
|
|
|
|
struct MealMoodWidgetView: View {
|
|
var entry: Provider.Entry
|
|
@Environment(\.widgetFamily) var family
|
|
|
|
var body: some View {
|
|
Group {
|
|
switch family {
|
|
case .systemMedium:
|
|
MediumWidgetView(entry: entry)
|
|
.containerBackground(for: .widget) { Color.white }
|
|
case .accessoryRectangular:
|
|
AccessoryRectangularView(entry: entry)
|
|
.containerBackground(for: .widget) { Color.clear }
|
|
case .accessoryInline:
|
|
AccessoryInlineView(entry: entry)
|
|
.containerBackground(for: .widget) { Color.clear }
|
|
default:
|
|
SmallWidgetView(entry: entry)
|
|
.containerBackground(for: .widget) { Color.white }
|
|
}
|
|
}
|
|
// Deep-link into the app's current week ("today") when tapped.
|
|
.widgetURL(URL(string: "mealmood://today"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Widget definition
|
|
|
|
@main
|
|
struct MealMoodWidgetBundle: WidgetBundle {
|
|
var body: some Widget {
|
|
MealMoodWidget()
|
|
MealMoodWeekWidget()
|
|
}
|
|
}
|
|
|
|
struct MealMoodWidget: Widget {
|
|
let kind = "MealMoodWidget"
|
|
|
|
var body: some WidgetConfiguration {
|
|
StaticConfiguration(kind: kind, provider: Provider()) { entry in
|
|
MealMoodWidgetView(entry: entry)
|
|
}
|
|
.configurationDisplayName("MealMood")
|
|
.description("Today's meals at a glance.")
|
|
.supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular, .accessoryInline])
|
|
}
|
|
}
|
|
|
|
// MARK: - Week widget (2.1.0)
|
|
|
|
struct WeekEntry: TimelineEntry {
|
|
let date: Date
|
|
let payload: WatchWeekPayload?
|
|
}
|
|
|
|
struct WeekProvider: TimelineProvider {
|
|
func placeholder(in context: Context) -> WeekEntry {
|
|
WeekEntry(date: Date(), payload: WatchWeekPayload.stored())
|
|
}
|
|
|
|
func getSnapshot(in context: Context, completion: @escaping (WeekEntry) -> Void) {
|
|
completion(WeekEntry(date: Date(), payload: WatchWeekPayload.stored()))
|
|
}
|
|
|
|
func getTimeline(in context: Context, completion: @escaping (Timeline<WeekEntry>) -> Void) {
|
|
let entry = WeekEntry(date: Date(), payload: WatchWeekPayload.stored())
|
|
let nextMidnight = Calendar.current.startOfDay(for: Date()).addingTimeInterval(86_400 + 300)
|
|
completion(Timeline(entries: [entry], policy: .after(nextMidnight)))
|
|
}
|
|
}
|
|
|
|
struct MealMoodWeekWidget: Widget {
|
|
let kind = "MealMoodWeekWidget"
|
|
|
|
var body: some WidgetConfiguration {
|
|
StaticConfiguration(kind: kind, provider: WeekProvider()) { entry in
|
|
WeekWidgetView(entry: entry)
|
|
.containerBackground(Color(red: 1.0, green: 0.97, blue: 0.94), for: .widget)
|
|
}
|
|
.configurationDisplayName("MealMood — Semana")
|
|
.description("Your whole week at a glance.")
|
|
.supportedFamilies([.systemMedium, .systemLarge])
|
|
}
|
|
}
|
|
|
|
struct WeekWidgetView: View {
|
|
@Environment(\.widgetFamily) private var family
|
|
let entry: WeekEntry
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let payload = entry.payload, !payload.days.isEmpty {
|
|
VStack(alignment: .leading, spacing: family == .systemLarge ? 6 : 3) {
|
|
ForEach(payload.days, id: \.dayOfWeek) { day in
|
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
|
Text(day.title)
|
|
.font(.system(size: family == .systemLarge ? 13 : 10, weight: .bold, design: .rounded))
|
|
.foregroundStyle(day.isToday ? Color(red: 1.0, green: 0.45, blue: 0.35) : .primary)
|
|
.frame(width: family == .systemLarge ? 62 : 48, alignment: .leading)
|
|
Text(mealsLine(day))
|
|
.font(.system(size: family == .systemLarge ? 12 : 10, design: .rounded))
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(family == .systemLarge ? 2 : 1)
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
} else {
|
|
VStack(spacing: 4) {
|
|
Image(systemName: "calendar")
|
|
.foregroundStyle(.secondary)
|
|
Text(verbatim: "MealMood")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
.widgetURL(URL(string: "mealmood://today"))
|
|
}
|
|
|
|
private func mealsLine(_ day: WatchWeekPayload.Day) -> String {
|
|
day.meals.compactMap(\.name).joined(separator: " · ")
|
|
}
|
|
}
|