464fbb2bad
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
296 lines
10 KiB
Swift
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"
|
|
)
|
|
}
|
|
}
|