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
259 lines
9.4 KiB
Swift
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)
|
|
}
|
|
}
|