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) -> 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 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]) } }