watch: fix del sync que nunca llegaba y rediseno estilo Calendar/Weather

- El primer push se descartaba: updateApplicationContext se llamaba antes
  de que WCSession terminara de activarse (activacion asincrona). Ahora
  la sesion se activa al arrancar la app (ContentView), el payload
  pendiente se envia al completarse la activacion, y ademas el reloj
  PIDE los datos al abrirse (sendMessage despierta al iPhone en segundo
  plano y responde con el snapshot del app group)
- Rediseno: Hoy con cabecera del dia en coral y tarjetas degradadas por
  comida (estilo Weather); Semana con circulo de dia a la izquierda y
  hoy relleno en coral (estilo Calendar); complicacion con iconos
  tintados por comida y widgetAccentable
- Verificado con capturas en simulador de Watch sembrando el app group

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
This commit is contained in:
alexandrev-tibco
2026-09-08 23:32:55 +02:00
parent db36054df0
commit bd8090c845
8 changed files with 216 additions and 55 deletions
+34 -2
View File
@@ -18,8 +18,25 @@ struct MealMoodWatchApp: App {
}
}
/// Shared palette for the watch UI same family as the iOS app.
enum WatchTheme {
static let coral = Color(red: 1.0, green: 0.45, blue: 0.35)
static func accent(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 coral
}
}
}
/// Receives the week snapshot from the iPhone and persists it in the watch's
/// app group so the complication can read it too.
/// app group so the complication can read it too. On launch it also *asks*
/// the iPhone (which wakes in background) so a fresh install shows data
/// immediately instead of waiting for the next push.
final class WatchWeekStore: NSObject, ObservableObject, WCSessionDelegate {
static let shared = WatchWeekStore()
@@ -42,11 +59,26 @@ final class WatchWeekStore: NSObject, ObservableObject, WCSessionDelegate {
}
}
private func requestFromPhone(_ session: WCSession) {
guard session.isReachable else { return }
session.sendMessage(["request": "week"], replyHandler: { [weak self] reply in
if let data = reply[WatchWeekPayload.storageKey] as? Data {
self?.apply(data)
}
}, errorHandler: nil)
}
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
// Pick up a context delivered while the app wasn't running.
// Pick up a context delivered while the app wasn't running
if let data = session.receivedApplicationContext[WatchWeekPayload.storageKey] as? Data {
apply(data)
}
// and pull fresh data from the phone if it's around.
requestFromPhone(session)
}
func sessionReachabilityDidChange(_ session: WCSession) {
if payload == nil { requestFromPhone(session) }
}
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
+57 -20
View File
@@ -1,54 +1,91 @@
import SwiftUI
/// Today page Weather-app style: a bold colored header with the day, then
/// one vivid gradient card per meal.
struct TodayView: View {
@EnvironmentObject private var store: WatchWeekStore
private var today: WatchWeekPayload.Day? {
store.payload?.days.first(where: \.isToday)
store.payload?.days.first(where: \.isToday) ?? store.payload?.days.first
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 8) {
if let today {
Text(today.title)
.font(.headline)
.foregroundStyle(Color(red: 1.0, green: 0.45, blue: 0.35))
HStack(alignment: .firstTextBaseline) {
Text(today.title)
.font(.system(.title3, design: .rounded, weight: .bold))
.foregroundStyle(WatchTheme.coral)
Spacer()
Image(systemName: "fork.knife")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 2)
ForEach(Array(today.meals.enumerated()), id: \.offset) { _, meal in
VStack(alignment: .leading, spacing: 2) {
Label(meal.label, systemImage: WatchWeekPayload.icon(for: meal.type))
.font(.caption2)
.foregroundStyle(.secondary)
Text(meal.name ?? "")
.font(.body.weight(.medium))
.lineLimit(2)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
MealCard(meal: meal)
}
} else {
EmptySyncView()
}
}
}
.navigationTitle("MealMood")
}
}
struct MealCard: View {
let meal: WatchWeekPayload.Meal
private var accent: Color { WatchTheme.accent(for: meal.type) }
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 5) {
Image(systemName: WatchWeekPayload.icon(for: meal.type))
.font(.system(size: 11, weight: .bold))
Text(meal.label.uppercased())
.font(.system(size: 11, weight: .bold, design: .rounded))
.tracking(0.5)
}
.foregroundStyle(.white.opacity(0.85))
Text(meal.name ?? "")
.font(.system(.body, design: .rounded, weight: .semibold))
.foregroundStyle(.white)
.lineLimit(2)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 10)
.padding(.vertical, 9)
.background(
LinearGradient(
colors: [accent, accent.opacity(0.65)],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
in: RoundedRectangle(cornerRadius: 12)
)
}
}
struct EmptySyncView: View {
var body: some View {
VStack(spacing: 8) {
Image(systemName: "iphone.and.arrow.forward")
Image(systemName: "applewatch.radiowaves.left.and.right")
.font(.title3)
.foregroundStyle(.secondary)
// The payload arrives fully localized from the iPhone; the watch
.foregroundStyle(WatchTheme.coral)
// Payload arrives fully localized from the iPhone; the watch
// bundle carries no strings of its own, so this stays neutral.
Text(verbatim: "MealMood")
.font(.system(.caption2, design: .rounded, weight: .semibold))
Image(systemName: "iphone.gen3")
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(.top, 20)
.frame(maxWidth: .infinity)
.padding(.top, 24)
}
}
+60 -19
View File
@@ -1,31 +1,22 @@
import SwiftUI
/// Week page Calendar-app style: day number in a circle on the left (today
/// filled in coral), meals with tinted icons on the right.
struct WeekView: View {
@EnvironmentObject private var store: WatchWeekStore
var body: some View {
Group {
if let payload = store.payload {
List {
Section(payload.weekTitle) {
ScrollView {
VStack(alignment: .leading, spacing: 6) {
Text(payload.weekTitle)
.font(.system(.footnote, design: .rounded, weight: .bold))
.foregroundStyle(WatchTheme.coral)
.padding(.horizontal, 2)
ForEach(payload.days, id: \.dayOfWeek) { day in
VStack(alignment: .leading, spacing: 3) {
Text(day.title)
.font(.caption.weight(.semibold))
.foregroundStyle(day.isToday
? Color(red: 1.0, green: 0.45, blue: 0.35)
: .primary)
ForEach(Array(day.meals.enumerated()), id: \.offset) { _, meal in
HStack(spacing: 4) {
Image(systemName: WatchWeekPayload.icon(for: meal.type))
.font(.system(size: 10))
.foregroundStyle(.secondary)
Text(meal.name ?? "")
.font(.caption2)
.lineLimit(1)
}
}
}
DayRow(day: day)
}
}
}
@@ -35,3 +26,53 @@ struct WeekView: View {
}
}
}
private struct DayRow: View {
let day: WatchWeekPayload.Day
/// "Lun 8" ("Lun", "8")
private var parts: (name: String, number: String) {
let pieces = day.title.split(separator: " ", maxSplits: 1)
guard pieces.count == 2 else { return (day.title, "") }
return (String(pieces[0]), String(pieces[1]))
}
var body: some View {
HStack(alignment: .top, spacing: 8) {
VStack(spacing: 0) {
Text(parts.name.uppercased())
.font(.system(size: 9, weight: .bold, design: .rounded))
.foregroundStyle(day.isToday ? WatchTheme.coral : .secondary)
Text(parts.number)
.font(.system(size: 15, weight: .bold, design: .rounded))
.foregroundStyle(day.isToday ? .white : .primary)
.frame(width: 26, height: 26)
.background(
Circle().fill(day.isToday ? WatchTheme.coral : .clear)
)
}
.frame(width: 32)
VStack(alignment: .leading, spacing: 3) {
ForEach(Array(day.meals.enumerated()), id: \.offset) { _, meal in
HStack(spacing: 4) {
Image(systemName: WatchWeekPayload.icon(for: meal.type))
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(WatchTheme.accent(for: meal.type))
.frame(width: 12)
Text(meal.name ?? "")
.font(.system(size: 13, design: .rounded))
.lineLimit(1)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 5)
.padding(.horizontal, 7)
.background(
day.isToday ? WatchTheme.coral.opacity(0.16) : Color.white.opacity(0.07),
in: RoundedRectangle(cornerRadius: 9)
)
}
}
}