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
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user