Files
InvestmentTrackerApp/PortfolioJournalWatch/WatchRootView.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

259 lines
9.4 KiB
Swift

import SwiftUI
/// Three vertical pages: portfolio summary, goals, and check-in status. The
/// watch app is read-only every edit still happens on the iPhone.
struct WatchRootView: View {
@EnvironmentObject private var store: WatchDataStore
var body: some View {
TabView {
WatchSummaryView(snapshot: store.snapshot)
WatchGoalsView(snapshot: store.snapshot)
WatchCheckInView(snapshot: store.snapshot)
}
.tabViewStyle(.verticalPage)
}
}
// MARK: - Summary
struct WatchSummaryView: View {
let snapshot: WatchPortfolioSnapshot
@EnvironmentObject private var store: WatchDataStore
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 10) {
if !snapshot.hasData {
WatchEmptyStateView()
} else {
VStack(alignment: .leading, spacing: 2) {
Text("watch_total_value")
.font(.caption2)
.foregroundStyle(.secondary)
Text(snapshot.formattedTotal())
.font(.title3.bold())
.minimumScaleFactor(0.6)
.lineLimit(1)
}
HStack(spacing: 6) {
Image(systemName: snapshot.monthChange >= 0
? "arrow.up.right"
: "arrow.down.right")
Text(snapshot.formattedMonthChange())
Text(snapshot.formattedMonthChangePercent)
.foregroundStyle(.secondary)
}
.font(.caption)
.foregroundStyle(snapshot.monthChange >= 0 ? Color.green : Color.red)
if !snapshot.sources.isEmpty {
Divider()
ForEach(snapshot.sources) { source in
HStack(spacing: 6) {
Circle()
.fill(Color(watchHex: source.categoryColorHex) ?? .gray)
.frame(width: 6, height: 6)
Text(source.name)
.font(.caption2)
.lineLimit(1)
Spacer(minLength: 4)
Text(WatchPortfolioSnapshot.format(
source.value,
currencyCode: snapshot.currencyCode,
compact: true
))
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
}
}
}
WatchFooterView(snapshot: snapshot)
}
}
.padding(.horizontal, 2)
}
.navigationTitle("watch_tab_summary")
}
}
// MARK: - Goals
struct WatchGoalsView: View {
let snapshot: WatchPortfolioSnapshot
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Text("watch_goals_title")
.font(.headline)
if snapshot.goals.isEmpty {
Text("watch_no_goals")
.font(.caption2)
.foregroundStyle(.secondary)
} else {
ForEach(snapshot.goals) { goal in
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
if let icon = goal.categoryIcon {
Image(systemName: icon)
.font(.caption2)
.foregroundStyle(Color(watchHex: goal.categoryColorHex) ?? .accentColor)
}
Text(goal.name)
.font(.caption.weight(.semibold))
.lineLimit(1)
}
ProgressView(value: goal.progress)
.tint(goal.isAchieved
? .green
: (Color(watchHex: goal.categoryColorHex) ?? .accentColor))
HStack {
Text(WatchPortfolioSnapshot.format(
goal.currentValue,
currencyCode: snapshot.currencyCode,
compact: true
))
Text(String(
format: String(localized: "watch_of_target"),
WatchPortfolioSnapshot.format(
goal.targetValue,
currencyCode: snapshot.currencyCode,
compact: true
)
))
.foregroundStyle(.secondary)
Spacer(minLength: 2)
Text("\(Int(goal.progress * 100))%")
.foregroundStyle(.secondary)
}
.font(.caption2)
}
}
}
}
.padding(.horizontal, 2)
}
}
}
// MARK: - Check-in
struct WatchCheckInView: View {
let snapshot: WatchPortfolioSnapshot
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Text("watch_checkin_title")
.font(.headline)
HStack(spacing: 8) {
Image(systemName: snapshot.checkInDone
? "checkmark.circle.fill"
: "exclamationmark.circle.fill")
.foregroundStyle(snapshot.checkInDone ? Color.green : Color.orange)
VStack(alignment: .leading, spacing: 2) {
Text(snapshot.checkInDone ? "watch_checkin_done" : "watch_checkin_pending")
.font(.caption.weight(.semibold))
if !snapshot.checkInMonthLabel.isEmpty {
Text(snapshot.checkInMonthLabel)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
HStack(spacing: 12) {
WatchStatTile(
title: String(localized: "watch_streak"),
value: "\(snapshot.currentStreak)x"
)
WatchStatTile(
title: String(localized: "watch_best_streak"),
value: "\(snapshot.bestStreak)x"
)
}
}
.padding(.horizontal, 2)
}
}
}
// MARK: - Shared pieces
struct WatchStatTile: View {
let title: String
let value: String
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.caption2)
.foregroundStyle(.secondary)
Text(value)
.font(.title3.bold())
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 10))
}
}
struct WatchEmptyStateView: View {
var body: some View {
VStack(spacing: 8) {
Image(systemName: "iphone.and.arrow.forward")
.font(.title2)
.foregroundStyle(.secondary)
Text("watch_no_data_title")
.font(.caption.weight(.semibold))
Text("watch_no_data_body")
.font(.caption2)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
}
/// Freshness line plus a manual pull, for when the user wants to be sure the
/// number on the wrist is the one on the phone.
struct WatchFooterView: View {
let snapshot: WatchPortfolioSnapshot
@EnvironmentObject private var store: WatchDataStore
var body: some View {
VStack(spacing: 6) {
Text(String(
format: String(localized: "watch_updated"),
snapshot.generatedAt.formatted(date: .omitted, time: .shortened)
))
.font(.caption2)
.foregroundStyle(.secondary)
if store.isPhoneReachable {
Button {
store.refresh()
} label: {
if store.isRefreshing {
ProgressView()
} else {
Label("watch_refresh", systemImage: "arrow.clockwise")
.font(.caption2)
}
}
.buttonStyle(.bordered)
}
}
.frame(maxWidth: .infinity)
.padding(.top, 4)
}
}