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) -> 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" ) } }