Files
InvestmentTrackerApp/PortfolioJournalWatchWidget/WatchComplications.swift
T
alexandrev-tibco 464fbb2bad App de Apple Watch + 3 complicaciones (build 88) #48
App de watchOS de solo consulta: resumen de cartera (total, cambio del
mes y mayores posiciones), objetivos con su progreso y estado del
check-in con la racha. Toda la edición sigue en el iPhone.

Complicaciones (accessoryCircular / Rectangular / Inline / Corner):
valor total de la cartera, progreso del objetivo más cercano a cumplirse
y racha + estado del check-in.

Datos: los App Groups no se comparten entre iOS y watchOS, así que el
reloj no puede leer el store de CoreData que lee el widget de iOS. En su
lugar, WatchSyncService construye un WatchPortfolioSnapshot ligero y lo
envía como application context de WatchConnectivity, enganchado a
CoreDataStack.refreshWidgetData() — el mismo punto que refresca el widget
de iOS, así que cualquier repositorio que toque datos refresca el reloj.
WatchDataStore lo cachea en el App Group del watch (WatchSnapshotCache) y
recarga las timelines; la extensión de complicaciones solo lee ese caché.

Proyecto: dos targets nuevos (watchOS app + widget extension) con grupos
sincronizados de Xcode 16; Shared/ pertenece a la app de watch y se
comparte con la app de iOS y el widget mediante exception sets, el mismo
mecanismo que ya usaba el widget de iOS para el modelo de CoreData.
Scheme compartido PortfolioJournalWatch. Localización propia por target
en los 7 idiomas.

Verificado: compilan los tres targets, la app de watch queda embebida en
Watch/ con la extensión en PlugIns/, y en el par de simuladores
iPhone 17 Pro Max + Apple Watch Series 11 el reloj recibe y muestra el
snapshot enviado por el iPhone. 10 tests nuevos del payload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YcDn5ccuRFV83q7xWWokBT
2026-09-13 22:05:31 +02:00

296 lines
10 KiB
Swift

import WidgetKit
import SwiftUI
/// Complications for the Apple Watch face. They read the snapshot the watch app
/// cached from the iPhone (see `WatchSnapshotCache`) the extension never talks
/// to WatchConnectivity itself, the app reloads the timelines when new data
/// arrives.
struct WatchComplicationEntry: TimelineEntry {
let date: Date
let snapshot: WatchPortfolioSnapshot
}
struct WatchComplicationProvider: TimelineProvider {
func placeholder(in context: Context) -> WatchComplicationEntry {
WatchComplicationEntry(date: Date(), snapshot: .preview)
}
func getSnapshot(in context: Context, completion: @escaping (WatchComplicationEntry) -> Void) {
let snapshot = context.isPreview ? .preview : (WatchSnapshotCache.load() ?? .preview)
completion(WatchComplicationEntry(date: Date(), snapshot: snapshot))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<WatchComplicationEntry>) -> Void) {
let entry = WatchComplicationEntry(
date: Date(),
snapshot: WatchSnapshotCache.load() ?? .empty
)
// The watch app reloads timelines as soon as the phone sends new data;
// this cadence is just a safety net for a watch that has been away from
// its phone.
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date()
completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
}
}
// MARK: - Total value
struct WatchTotalValueComplication: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "WatchTotalValue", provider: WatchComplicationProvider()) { entry in
WatchTotalValueView(snapshot: entry.snapshot)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("complication_total_name")
.description("complication_total_description")
.supportedFamilies([
.accessoryCircular,
.accessoryRectangular,
.accessoryInline,
.accessoryCorner
])
}
}
struct WatchTotalValueView: View {
@Environment(\.widgetFamily) private var family
let snapshot: WatchPortfolioSnapshot
private var compactTotal: String {
WatchPortfolioSnapshot.format(
snapshot.totalValue,
currencyCode: snapshot.currencyCode,
compact: true
)
}
var body: some View {
switch family {
case .accessoryInline:
Text("\(compactTotal) \(snapshot.formattedMonthChangePercent)")
case .accessoryCorner:
Text(compactTotal)
.widgetCurvesContent()
.widgetLabel(snapshot.formattedMonthChangePercent)
case .accessoryRectangular:
VStack(alignment: .leading, spacing: 1) {
Text("complication_total_name")
.font(.caption2)
.foregroundStyle(.secondary)
Text(compactTotal)
.font(.title3.bold())
.minimumScaleFactor(0.6)
.lineLimit(1)
Text("\(snapshot.formattedMonthChange()) \(snapshot.formattedMonthChangePercent)")
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
default:
VStack(spacing: 0) {
Image(systemName: "chart.pie.fill")
.font(.caption2)
Text(compactTotal)
.font(.caption.bold())
.minimumScaleFactor(0.5)
.lineLimit(1)
}
}
}
}
// MARK: - Goal progress
struct WatchGoalProgressComplication: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "WatchGoalProgress", provider: WatchComplicationProvider()) { entry in
WatchGoalProgressView(snapshot: entry.snapshot)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("complication_goal_name")
.description("complication_goal_description")
.supportedFamilies([
.accessoryCircular,
.accessoryRectangular,
.accessoryInline,
.accessoryCorner
])
}
}
struct WatchGoalProgressView: View {
@Environment(\.widgetFamily) private var family
let snapshot: WatchPortfolioSnapshot
/// Most advanced goal still open the one the user is about to hit.
private var goal: WatchPortfolioSnapshot.Goal? { snapshot.featuredGoal }
private var percentText: String {
guard let goal else { return "" }
return "\(Int(goal.progress * 100))%"
}
var body: some View {
switch family {
case .accessoryInline:
if let goal {
Text("\(goal.name) \(percentText)")
} else {
Text("complication_goal_empty")
}
case .accessoryCorner:
Gauge(value: goal?.progress ?? 0) {
Text(percentText)
}
.gaugeStyle(.accessoryCircular)
.widgetLabel(goal?.name ?? String(localized: "complication_goal_empty"))
case .accessoryRectangular:
VStack(alignment: .leading, spacing: 2) {
Text(goal?.name ?? String(localized: "complication_goal_empty"))
.font(.caption.weight(.semibold))
.lineLimit(1)
ProgressView(value: goal?.progress ?? 0)
.tint(.green)
if let goal {
Text("\(WatchPortfolioSnapshot.format(goal.currentValue, currencyCode: snapshot.currencyCode, compact: true)) · \(percentText)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
default:
Gauge(value: goal?.progress ?? 0) {
Image(systemName: "target")
} currentValueLabel: {
Text(percentText)
.minimumScaleFactor(0.5)
}
.gaugeStyle(.accessoryCircularCapacity)
}
}
}
// MARK: - Streak / check-in
struct WatchStreakComplication: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "WatchStreak", provider: WatchComplicationProvider()) { entry in
WatchStreakView(snapshot: entry.snapshot)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("complication_streak_name")
.description("complication_streak_description")
.supportedFamilies([
.accessoryCircular,
.accessoryRectangular,
.accessoryInline,
.accessoryCorner
])
}
}
struct WatchStreakView: View {
@Environment(\.widgetFamily) private var family
let snapshot: WatchPortfolioSnapshot
private var statusSymbol: String {
snapshot.checkInDone ? "checkmark.circle.fill" : "exclamationmark.circle.fill"
}
private var streakText: String { "\(snapshot.currentStreak)x" }
var body: some View {
switch family {
case .accessoryInline:
Text("\(Image(systemName: "flame.fill")) \(streakText)")
case .accessoryCorner:
Text(streakText)
.widgetCurvesContent()
.widgetLabel(
snapshot.checkInDone
? String(localized: "complication_streak_done")
: String(localized: "complication_streak_pending")
)
case .accessoryRectangular:
VStack(alignment: .leading, spacing: 1) {
Label(streakText, systemImage: "flame.fill")
.font(.caption.weight(.semibold))
Text(snapshot.checkInDone
? "complication_streak_done"
: "complication_streak_pending")
.font(.caption2)
.foregroundStyle(.secondary)
if !snapshot.checkInMonthLabel.isEmpty {
Text(snapshot.checkInMonthLabel)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
default:
VStack(spacing: 0) {
Image(systemName: statusSymbol)
.font(.caption2)
.foregroundStyle(snapshot.checkInDone ? Color.green : Color.orange)
Text(streakText)
.font(.caption.bold())
}
}
}
}
// MARK: - Bundle
@main
struct PortfolioJournalWatchWidgetBundle: WidgetBundle {
var body: some Widget {
WatchTotalValueComplication()
WatchGoalProgressComplication()
WatchStreakComplication()
}
}
// MARK: - Previews
extension WatchPortfolioSnapshot {
/// Sample data for widget galleries and Xcode previews.
static var preview: WatchPortfolioSnapshot {
WatchPortfolioSnapshot(
generatedAt: Date(),
currencyCode: "EUR",
totalValue: 124_500,
monthChange: 2_340,
monthChangePercent: 0.019,
sources: [
Source(id: UUID(), name: "Indexa", categoryName: "ETFs", categoryColorHex: "#EC4899", value: 68_000),
Source(id: UUID(), name: "Cuenta ahorro", categoryName: "Cash", categoryColorHex: "#6B7280", value: 32_500)
],
goals: [
Goal(
id: UUID(),
name: "Cash buffer",
categoryName: "Cash",
categoryIcon: "banknote.fill",
categoryColorHex: "#6B7280",
currentValue: 32_500,
targetValue: 100_000
)
],
currentStreak: 7,
bestStreak: 15,
checkInDone: true,
checkInMonthLabel: "August 2026"
)
}
}