2.1.0: app de Apple Watch, complicacion y widget semanal en iOS
- 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
This commit is contained in:
@@ -15,7 +15,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.0.5</string>
|
||||
<string>2.1.0</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import Foundation
|
||||
import WatchConnectivity
|
||||
import SwiftData
|
||||
|
||||
/// iPhone → Watch push of the week snapshot. Uses `updateApplicationContext`,
|
||||
/// which delivers the latest value even if the watch is unreachable right now.
|
||||
final class WatchSyncService: NSObject, WCSessionDelegate, @unchecked Sendable {
|
||||
// WCSession requires a stable delegate; state is confined to WCSession's
|
||||
// internal queue, hence the @unchecked Sendable.
|
||||
static let shared = WatchSyncService()
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
guard WCSession.isSupported() else { return }
|
||||
WCSession.default.delegate = self
|
||||
WCSession.default.activate()
|
||||
}
|
||||
|
||||
func activate() { /* init side effect */ }
|
||||
|
||||
/// Builds and pushes the current + visible week. Call wherever the iOS
|
||||
/// widget is refreshed so both stay in sync.
|
||||
func push(plan: WeekPlan?, dishes: [Dish], settings: AppSettings) {
|
||||
guard WCSession.isSupported(), WCSession.default.activationState == .activated else { return }
|
||||
guard let payload = Self.makePayload(plan: plan, dishes: dishes, settings: settings),
|
||||
let data = payload.encoded() else { return }
|
||||
try? WCSession.default.updateApplicationContext([WatchWeekPayload.storageKey: data])
|
||||
}
|
||||
|
||||
static func makePayload(plan: WeekPlan?, dishes: [Dish], settings: AppSettings) -> WatchWeekPayload? {
|
||||
guard let plan else { return nil }
|
||||
let locale = Locale(identifier: settings.languageEnum.resolved().localeIdentifier)
|
||||
let dishById = Dictionary(dishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
|
||||
let dayFormatter = DateFormatter()
|
||||
dayFormatter.locale = locale
|
||||
dayFormatter.setLocalizedDateFormatFromTemplate("EEE d")
|
||||
|
||||
let todayOffset = (Calendar.current.component(.weekday, from: Date()) + 5) % 7
|
||||
let isCurrentWeek = plan.weekStartDate == Date().startOfWeek()
|
||||
let range = settings.includeWeekends ? 0...6 : 0...4
|
||||
|
||||
let days: [WatchWeekPayload.Day] = range.map { day in
|
||||
let meals: [WatchWeekPayload.Meal] = settings.activeMealTypes.map { meal in
|
||||
let slot = plan.slotList.first { $0.dayOfWeek == day && $0.mealType == meal.rawValue }
|
||||
let label = String(localized: String.LocalizationValue(meal.localizedKey))
|
||||
var name: String?
|
||||
if let slot {
|
||||
if slot.isEatingOut {
|
||||
name = String(localized: "slot_eating_out")
|
||||
} else if slot.isSkipped {
|
||||
name = "—"
|
||||
} else if let dishId = slot.dishId, let dish = dishById[dishId] {
|
||||
name = dish.name
|
||||
if let secondaryId = slot.secondaryDishId, let secondary = dishById[secondaryId] {
|
||||
name! += " + \(secondary.name)"
|
||||
}
|
||||
}
|
||||
}
|
||||
return WatchWeekPayload.Meal(type: meal.rawValue, label: label, name: name)
|
||||
}
|
||||
let dayDate = plan.weekStartDate.addingDays(day)
|
||||
return WatchWeekPayload.Day(
|
||||
dayOfWeek: day,
|
||||
title: dayFormatter.string(from: dayDate).capitalized(with: locale),
|
||||
isToday: isCurrentWeek && day == todayOffset,
|
||||
meals: meals
|
||||
)
|
||||
}
|
||||
|
||||
return WatchWeekPayload(
|
||||
weekTitle: plan.weekStartDate.formattedWeekRange(),
|
||||
days: days,
|
||||
updatedAt: Date()
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - WCSessionDelegate
|
||||
|
||||
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {}
|
||||
func sessionDidBecomeInactive(_ session: WCSession) {}
|
||||
func sessionDidDeactivate(_ session: WCSession) { session.activate() }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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 Day: Codable {
|
||||
let dayOfWeek: Int // 0=Monday … 6=Sunday
|
||||
let title: String // localized short title ("Lun 8")
|
||||
let isToday: Bool
|
||||
let meals: [Meal]
|
||||
}
|
||||
|
||||
let weekTitle: String // localized week range ("8 – 14 sep")
|
||||
let days: [Day]
|
||||
let updatedAt: Date
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -999,6 +999,12 @@ struct HomeView: View {
|
||||
private func updateWidget(settings: AppSettings) {
|
||||
let todayPlan = fetchWeekPlan(for: Date().startOfWeek())
|
||||
WidgetDataStore.update(plan: todayPlan, dishes: dishes, settings: settings)
|
||||
// Week snapshot: stored locally for the iOS week widget and pushed to
|
||||
// the watch app/complication via WatchConnectivity.
|
||||
if let payload = WatchSyncService.makePayload(plan: todayPlan, dishes: dishes, settings: settings) {
|
||||
payload.store()
|
||||
}
|
||||
WatchSyncService.shared.push(plan: todayPlan, dishes: dishes, settings: settings)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
Reference in New Issue
Block a user