Files
InvestmentTrackerApp/PortfolioJournalWidget/InvestmentWidget.swift
T
alexandrev-tibco 326be6db04 1.6.0 #10 ocultar saldos + Face ID · #11 widget interactivo + Siri
#10 Ocultar saldos: BalancePrivacyManager (balancesHidden + requireBiometricToReveal),
modifier .hiddenBalance() (blur + máscara de bullets, ancho estable), botón ojo en Dashboard,
revelar con Face ID vía AppLockService, sección Privacidad en Settings. Aplicado a total
portfolio, forecast, sources, goals, monthly summary.

#11 Siri/App Intents: NetWorthIntent ('¿cuál es mi patrimonio?' → 'Tu patrimonio es X'),
StreakIntent, 5 AppShortcuts con frases. Widget interactivo iOS 17: botón Refresh
(Button(intent:) → reloadAllTimelines, in-process) en medium/large, gated #available(iOS 17).
Reader read-only del AppGroup. Dialogs Siri refactorizados a String(localized:).

Localización: strings de #10 y de Siri en los 7 idiomas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-25 17:52:09 +02:00

1505 lines
57 KiB
Swift

import WidgetKit
import SwiftUI
import CoreData
import AppIntents
private let appGroupIdentifier = "group.com.alexandrevazquez.portfoliojournal"
private let storeFileName = "PortfolioJournal.sqlite"
private let sharedPremiumKey = "premiumUnlocked"
private let widgetPrimaryColor = Color(hex: "#3B82F6") ?? .blue
private let widgetSecondaryColor = Color(hex: "#10B981") ?? .green
private func sharedStoreURL() -> URL? {
return FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)?
.appendingPathComponent(storeFileName)
}
private func createWidgetContainer() -> NSPersistentContainer? {
guard let modelURL = Bundle.main.url(forResource: "PortfolioJournal", withExtension: "momd"),
let model = NSManagedObjectModel(contentsOf: modelURL) else {
return nil
}
let container = NSPersistentContainer(name: "PortfolioJournal", managedObjectModel: model)
guard let storeURL = sharedStoreURL(),
FileManager.default.fileExists(atPath: storeURL.path) else {
return nil
}
let description = NSPersistentStoreDescription(url: storeURL)
description.isReadOnly = true
description.shouldMigrateStoreAutomatically = true
description.shouldInferMappingModelAutomatically = true
container.persistentStoreDescriptions = [description]
var loadError: Error?
container.loadPersistentStores { _, error in
loadError = error
}
if loadError != nil {
return nil
}
return container
}
private func fetchCurrencyCode(from container: NSPersistentContainer?) -> String {
guard let container = container else { return "EUR" }
let context = container.viewContext
let request = NSFetchRequest<NSManagedObject>(entityName: "AppSettings")
request.fetchLimit = 1
if let settings = try? context.fetch(request).first,
let code = settings.value(forKey: "currency") as? String,
!code.isEmpty {
return code
}
return "EUR"
}
// MARK: - Widget Entry
struct InvestmentWidgetEntry: TimelineEntry {
let date: Date
let isPremium: Bool
let totalValue: Decimal
let dayChange: Decimal
let dayChangePercentage: Double
let topSources: [(name: String, value: Decimal, color: String)]
let trendPoints: [Decimal]
let trendLabels: [String]
let categoryEvolution: [CategorySeries]
let categoryTotals: [(name: String, value: Decimal, color: String)]
let goals: [GoalSummary]
let currencyCode: String
let insightTitle: String
let insightValue: String
// Engagement + progress metrics (v1.6.0)
let streak: Int
let nextCheckInDate: Date?
/// Nearest active goal (soonest target date, else smallest target) if any.
let nearestGoal: GoalSummary?
/// Progress [0...1] of totalValue toward the nearest goal's target.
let nearestGoalProgress: Double
/// Fraction [0...1] toward the next net-worth milestone (fallback gauge when no goal).
let milestoneProgress: Double
/// The next milestone amount used for the fallback gauge (0 when none).
let nextMilestone: Decimal
/// True when the store has no usable portfolio data yet.
var hasData: Bool { totalValue != 0 || !trendPoints.isEmpty || !topSources.isEmpty }
/// A gauge value [0...1]: nearest-goal progress if a goal exists, else milestone progress.
var primaryGaugeProgress: Double {
nearestGoal != nil ? nearestGoalProgress : milestoneProgress
}
}
struct CategorySeries: Identifiable {
let id: String
let name: String
let color: String
let points: [Decimal]
let latestValue: Decimal
}
struct GoalSummary: Identifiable {
let id = UUID()
let name: String
let targetAmount: Decimal
let targetDate: Date?
}
// MARK: - Widget Provider
struct InvestmentWidgetProvider: TimelineProvider {
func placeholder(in context: Context) -> InvestmentWidgetEntry {
InvestmentWidgetEntry(
date: Date(),
isPremium: true,
totalValue: 50000,
dayChange: 250,
dayChangePercentage: 0.5,
topSources: [
("Stocks", 30000, "#10B981"),
("Bonds", 15000, "#3B82F6"),
("Real Estate", 5000, "#F59E0B")
],
trendPoints: [45000, 46000, 47000, 48000, 49000, 50000],
trendLabels: ["Aug", "Sep", "Oct", "Nov", "Dec", "Jan"],
categoryEvolution: [
CategorySeries(
id: "stocks",
name: "Stocks",
color: "#10B981",
points: [20000, 21000, 22000, 23000, 24000, 25000, 26000],
latestValue: 26000
),
CategorySeries(
id: "bonds",
name: "Bonds",
color: "#3B82F6",
points: [12000, 12000, 12500, 13000, 13500, 14000, 14500],
latestValue: 14500
),
CategorySeries(
id: "realestate",
name: "Real Estate",
color: "#F59E0B",
points: [6000, 6200, 6400, 6500, 6600, 6700, 6800],
latestValue: 6800
)
],
categoryTotals: [
("Stocks", 26000, "#10B981"),
("Bonds", 14500, "#3B82F6"),
("Real Estate", 6800, "#F59E0B")
],
goals: [
GoalSummary(name: "Target", targetAmount: 75000, targetDate: nil)
],
currencyCode: "EUR",
insightTitle: "Milestone ahead",
insightValue: "€5K from €50K",
streak: 4,
nextCheckInDate: Calendar.current.date(byAdding: .day, value: 6, to: Date()),
nearestGoal: GoalSummary(name: "Target", targetAmount: 75000, targetDate: nil),
nearestGoalProgress: 0.66,
milestoneProgress: 0.5,
nextMilestone: 100000
)
}
func getSnapshot(in context: Context, completion: @escaping (InvestmentWidgetEntry) -> Void) {
let entry = fetchData()
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<InvestmentWidgetEntry>) -> Void) {
let entry = fetchData()
// Refresh every hour
let nextUpdate = Calendar.current.date(byAdding: .hour, value: 1, to: Date()) ?? Date()
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
completion(timeline)
}
private func fetchData() -> InvestmentWidgetEntry {
let isPremium = UserDefaults(suiteName: appGroupIdentifier)?.bool(forKey: sharedPremiumKey) ?? false
let container = createWidgetContainer()
let currencyCode = fetchCurrencyCode(from: container)
guard let container = container else {
return InvestmentWidgetEntry(
date: Date(),
isPremium: isPremium,
totalValue: 0,
dayChange: 0,
dayChangePercentage: 0,
topSources: [],
trendPoints: [],
trendLabels: [],
categoryEvolution: [],
categoryTotals: [],
goals: [],
currencyCode: currencyCode,
insightTitle: "",
insightValue: "",
streak: 0,
nextCheckInDate: nil,
nearestGoal: nil,
nearestGoalProgress: 0,
milestoneProgress: 0,
nextMilestone: 0
)
}
let context = container.viewContext
func decimalValue(from object: NSManagedObject, key: String) -> Decimal {
if let number = object.value(forKey: key) as? NSDecimalNumber {
return number.decimalValue
}
if let dbl = object.value(forKey: key) as? Double {
return Decimal(dbl)
}
if let num = object.value(forKey: key) as? NSNumber {
return Decimal(num.doubleValue)
}
return .zero
}
// Build daily totals from snapshots for change/sparkline and category evolution.
let snapshotRequest = NSFetchRequest<NSManagedObject>(entityName: "Snapshot")
snapshotRequest.sortDescriptors = [NSSortDescriptor(key: "date", ascending: true)]
let snapshots = (try? context.fetch(snapshotRequest)) ?? []
var dailyTotals: [Date: Decimal] = [:]
var monthlyTotals: [Date: Decimal] = [:]
var latestBySource: [NSManagedObjectID: (date: Date, value: Decimal, source: NSManagedObject)] = [:]
var categoryMonthlyTotals: [String: [Date: Decimal]] = [:]
var categoryMeta: [String: (name: String, color: String)] = [:]
let calendar = Calendar.current
for snapshot in snapshots {
guard let rawDate = snapshot.value(forKey: "date") as? Date else { continue }
let day = calendar.startOfDay(for: rawDate)
let month = calendar.date(from: calendar.dateComponents([.year, .month], from: rawDate)) ?? day
let value = decimalValue(from: snapshot, key: "value")
dailyTotals[day, default: .zero] += value
monthlyTotals[month, default: .zero] += value
if let source = snapshot.value(forKey: "source") as? NSManagedObject {
let sourceId = source.objectID
if let existing = latestBySource[sourceId] {
if rawDate > existing.date {
latestBySource[sourceId] = (rawDate, value, source)
}
} else {
latestBySource[sourceId] = (rawDate, value, source)
}
var categoryId = "uncategorized"
var categoryName = "Uncategorized"
var categoryColor = "#94A3B8"
if let category = source.value(forKey: "category") as? NSManagedObject {
categoryId = category.objectID.uriRepresentation().absoluteString
categoryName = (category.value(forKey: "name") as? String) ?? categoryName
if let colorHex = category.value(forKey: "colorHex") as? String, !colorHex.isEmpty {
categoryColor = colorHex
}
}
categoryMeta[categoryId] = (categoryName, categoryColor)
var monthTotals = categoryMonthlyTotals[categoryId, default: [:]]
monthTotals[month, default: .zero] += value
categoryMonthlyTotals[categoryId] = monthTotals
}
}
let sortedTotals = dailyTotals
.map { ($0.key, $0.value) }
.sorted { $0.0 < $1.0 }
let sortedMonths = monthlyTotals
.map { ($0.key, $0.value) }
.sorted { $0.0 < $1.0 }
let months: [Date]
let trendPoints: [Decimal]
let trendLabels: [String]
if sortedMonths.isEmpty {
months = []
trendPoints = []
trendLabels = []
} else {
let latestMonth = sortedMonths.last?.0 ??
(calendar.date(from: calendar.dateComponents([.year, .month], from: Date())) ?? Date())
months = (0..<6).reversed().compactMap { offset in
calendar.date(byAdding: .month, value: -offset, to: latestMonth)
}
let monthFormatter = DateFormatter()
monthFormatter.dateFormat = "MMM"
trendPoints = months.map { month in
if let value = monthlyTotals[month] { return value }
// Forward-fill: use the most recent earlier month's value
let previous = sortedMonths.last { $0.0 < month }
return previous?.1 ?? .zero
}
trendLabels = months.map { monthFormatter.string(from: $0) }
}
let totalValue = latestBySource.values.reduce(Decimal.zero) { $0 + $1.value }
let mappedSources: [(name: String, value: Decimal, color: String)] = latestBySource.values.map { entry in
let source = entry.source
let name = (source.value(forKey: "name") as? String) ?? "Unknown"
var color = "#3B82F6"
if let category = source.value(forKey: "category") as? NSManagedObject,
let colorHex = category.value(forKey: "colorHex") as? String,
!colorHex.isEmpty {
color = colorHex
}
return (name: name, value: entry.value, color: color)
}
let topSources = mappedSources
.sorted { $0.value > $1.value }
.prefix(3)
var categoryTotalsMap: [String: Decimal] = [:]
for entry in latestBySource.values {
let source = entry.source
let categoryId: String = {
if let category = source.value(forKey: "category") as? NSManagedObject {
return category.objectID.uriRepresentation().absoluteString
}
return "uncategorized"
}()
categoryTotalsMap[categoryId, default: .zero] += entry.value
}
let categoryTotalsData = categoryTotalsMap
.map { key, value in
let meta = categoryMeta[key] ?? ("Uncategorized", "#94A3B8")
return (id: key, name: meta.0, value: value, color: meta.1)
}
.sorted { $0.value > $1.value }
let categoryEvolution: [CategorySeries] = categoryTotalsData.prefix(4).map { category in
let monthMap = categoryMonthlyTotals[category.id] ?? [:]
let sortedCategoryMonths = monthMap.map { ($0.key, $0.value) }.sorted { $0.0 < $1.0 }
let points = months.map { month -> Decimal in
if let value = monthMap[month] { return value }
// Forward-fill: use the most recent earlier month's value
let previous = sortedCategoryMonths.last { $0.0 < month }
return previous?.1 ?? .zero
}
return CategorySeries(
id: category.id,
name: category.name,
color: category.color,
points: points,
latestValue: category.value
)
}
var dayChange: Decimal = 0
var dayChangePercentage: Double = 0
if sortedTotals.count >= 2 {
let last = sortedTotals[sortedTotals.count - 1].1
let previous = sortedTotals[sortedTotals.count - 2].1
dayChange = last - previous
if previous != 0 {
dayChangePercentage = NSDecimalNumber(decimal: dayChange / previous).doubleValue * 100
}
}
let goalsRequest = NSFetchRequest<NSManagedObject>(entityName: "Goal")
goalsRequest.predicate = NSPredicate(format: "isActive == YES")
let goalObjects = (try? context.fetch(goalsRequest)) ?? []
let goalSummaries = goalObjects.compactMap { goal -> GoalSummary? in
let amount = decimalValue(from: goal, key: "targetAmount")
guard amount > 0 else { return nil }
let name = (goal.value(forKey: "name") as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let targetDate = goal.value(forKey: "targetDate") as? Date
return GoalSummary(name: (name?.isEmpty == false ? name! : "Goal"), targetAmount: amount, targetDate: targetDate)
}
let goals = goalSummaries.sorted { lhs, rhs in
switch (lhs.targetDate, rhs.targetDate) {
case let (lDate?, rDate?):
return lDate < rDate
case (_?, nil):
return true
case (nil, _?):
return false
default:
return lhs.targetAmount < rhs.targetAmount
}
}
// MARK: Streak consecutive months (year+month) that have snapshot data,
// counting back from the most recent. Mirrors DashboardViewModel.computeStreak.
let streak: Int = {
let monthDates = Set(sortedTotals.map { pair -> DateComponents in
let c = calendar.dateComponents([.year, .month], from: pair.0)
return DateComponents(year: c.year, month: c.month)
})
let sortedMonthsDesc = monthDates
.compactMap { calendar.date(from: $0) }
.sorted(by: >)
guard let mostRecent = sortedMonthsDesc.first else { return 0 }
var count = 1
var current = mostRecent
for i in 1..<max(sortedMonthsDesc.count, 1) {
guard i < sortedMonthsDesc.count,
let expected = calendar.date(byAdding: .month, value: -1, to: current) else { break }
let prev = sortedMonthsDesc[i]
let pc = calendar.dateComponents([.year, .month], from: prev)
let ec = calendar.dateComponents([.year, .month], from: expected)
if pc.year == ec.year && pc.month == ec.month {
count += 1
current = prev
} else {
break
}
}
return count
}()
// MARK: Next check-in date from the most recent completed JournalEntry.
// Mirrors DashboardView.nextCheckInDate (common case, without grace-period edge).
let nextCheckInDate: Date? = {
let journalRequest = NSFetchRequest<NSManagedObject>(entityName: "JournalEntry")
journalRequest.predicate = NSPredicate(format: "completionTime != nil")
journalRequest.sortDescriptors = [NSSortDescriptor(key: "completionTime", ascending: false)]
journalRequest.fetchLimit = 1
let lastCompletion = (try? context.fetch(journalRequest))?.first?
.value(forKey: "completionTime") as? Date
let base = lastCompletion ?? Date()
let startOfMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: base)) ?? base
// For a prior check-in, next is the end of the following month; otherwise
// the end of the current month.
let targetMonthStart: Date
if lastCompletion != nil {
targetMonthStart = calendar.date(byAdding: .month, value: 1, to: startOfMonth) ?? startOfMonth
} else {
targetMonthStart = startOfMonth
}
guard let nextMonthStart = calendar.date(byAdding: .month, value: 1, to: targetMonthStart) else {
return targetMonthStart
}
return calendar.date(byAdding: .day, value: -1, to: nextMonthStart)
}()
// MARK: Nearest goal + progress toward target from current total value.
let nearestGoal = goals.first
var nearestGoalProgress = 0.0
if let goal = nearestGoal {
let target = NSDecimalNumber(decimal: goal.targetAmount).doubleValue
if target > 0 {
nearestGoalProgress = min(max(NSDecimalNumber(decimal: totalValue).doubleValue / target, 0), 1)
}
}
// MARK: Milestone gauge (fallback when there's no goal).
let milestoneLadder: [Double] = [1000, 2500, 5000, 10000, 25000, 50000, 100000, 250000, 500000, 1_000_000, 2_500_000, 5_000_000]
let totalForMilestone = NSDecimalNumber(decimal: totalValue).doubleValue
var milestoneProgress = 0.0
var nextMilestone: Decimal = 0
if let nextMs = milestoneLadder.first(where: { $0 > totalForMilestone }) {
let prevMs = milestoneLadder.last(where: { $0 <= totalForMilestone }) ?? 0
let span = nextMs - prevMs
milestoneProgress = span > 0 ? min(max((totalForMilestone - prevMs) / span, 0), 1) : 0
nextMilestone = Decimal(nextMs)
} else if totalForMilestone > 0 {
milestoneProgress = 1
}
// Compute top insight
var insightTitle = ""
var insightValue = ""
// Milestone approaching
let milestones: [Double] = [1000, 2500, 5000, 10000, 25000, 50000, 100000, 250000, 500000, 1_000_000]
let totalDouble = NSDecimalNumber(decimal: totalValue).doubleValue
if let nextMs = milestones.first(where: { $0 > totalDouble }) {
let pct = totalDouble / nextMs
if pct >= 0.90 {
let gap = nextMs - totalDouble
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = currencyCode
formatter.maximumFractionDigits = 0
let msStr = formatter.string(from: NSNumber(value: nextMs)) ?? ""
let gapStr = formatter.string(from: NSNumber(value: gap)) ?? ""
insightTitle = "Milestone ahead"
insightValue = "\(gapStr) from \(msStr)"
}
}
// Year performance (only if no milestone insight)
if insightTitle.isEmpty && !trendPoints.isEmpty && trendPoints.count >= 2 {
let first = NSDecimalNumber(decimal: trendPoints.first!).doubleValue
let last = NSDecimalNumber(decimal: trendPoints.last!).doubleValue
if first > 0 {
let pct = (last - first) / first * 100
if abs(pct) >= 1 {
insightTitle = "Year to date"
insightValue = String(format: "%+.1f%%", pct)
}
}
}
return InvestmentWidgetEntry(
date: Date(),
isPremium: isPremium,
totalValue: totalValue,
dayChange: dayChange,
dayChangePercentage: dayChangePercentage,
topSources: Array(topSources),
trendPoints: trendPoints,
trendLabels: trendLabels,
categoryEvolution: categoryEvolution,
categoryTotals: categoryTotalsData.map { (name: $0.name, value: $0.value, color: $0.color) },
goals: goals,
currencyCode: currencyCode,
insightTitle: insightTitle,
insightValue: insightValue,
streak: streak,
nextCheckInDate: nextCheckInDate,
nearestGoal: nearestGoal,
nearestGoalProgress: nearestGoalProgress,
milestoneProgress: milestoneProgress,
nextMilestone: nextMilestone
)
}
}
// MARK: - Shared Formatting Helpers
private func checkInText(for date: Date?) -> String {
guard let date = date else { return "" }
let cal = Calendar.current
let days = cal.dateComponents([.day], from: Date().startOfWidgetDay, to: date.startOfWidgetDay).day ?? 0
if days < 0 { return "Overdue" }
if days == 0 { return "Due today" }
if days == 1 { return "Due tomorrow" }
if days <= 21 { return "In \(days)d" }
let formatter = DateFormatter()
formatter.dateFormat = "MMM d"
return formatter.string(from: date)
}
private func checkInIsUrgent(_ date: Date?) -> Bool {
guard let date = date else { return false }
let cal = Calendar.current
let days = cal.dateComponents([.day], from: Date().startOfWidgetDay, to: date.startOfWidgetDay).day ?? 0
return days <= 3
}
private extension Date {
var startOfWidgetDay: Date { Calendar.current.startOfDay(for: self) }
}
// MARK: - Change Line (shared)
struct ChangeLine: View {
let entry: InvestmentWidgetEntry
var compact: Bool = false
var body: some View {
HStack(spacing: 4) {
Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right")
.font(.caption2)
Text(entry.dayChange.compactCurrencyString(currencyCode: entry.currencyCode))
.font(.caption.weight(.medium))
Text("(\(String(format: "%.1f%%", entry.dayChangePercentage)))")
.font(.caption2)
.foregroundColor(compact ? (entry.dayChange >= 0 ? .green : .red) : .secondary)
}
.foregroundColor(entry.dayChange >= 0 ? .green : .red)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
}
// MARK: - Metric Pills (shared)
struct StreakPill: View {
let streak: Int
var body: some View {
HStack(spacing: 3) {
Image(systemName: "flame.fill")
.font(.caption2)
.foregroundColor(.orange)
Text("\(streak) mo")
.font(.caption2.weight(.semibold))
}
}
}
struct CheckInPill: View {
let date: Date?
var body: some View {
HStack(spacing: 3) {
Image(systemName: "calendar.badge.clock")
.font(.caption2)
.foregroundColor(checkInIsUrgent(date) ? .orange : .secondary)
Text(checkInText(for: date))
.font(.caption2.weight(.medium))
.foregroundColor(checkInIsUrgent(date) ? .orange : .secondary)
}
.lineLimit(1)
}
}
// MARK: - Goal Progress Row (shared)
struct GoalProgressRow: View {
let entry: InvestmentWidgetEntry
var body: some View {
if let goal = entry.nearestGoal {
let pct = entry.nearestGoalProgress
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "target")
.font(.caption2)
.foregroundColor(widgetSecondaryColor)
Text(goal.name)
.font(.caption.weight(.semibold))
.lineLimit(1)
Spacer()
Text("\(Int((pct * 100).rounded()))%")
.font(.caption.weight(.bold))
.foregroundColor(widgetSecondaryColor)
}
ProgressView(value: pct)
.tint(widgetSecondaryColor)
HStack {
Text(entry.totalValue.compactCurrencyString(currencyCode: entry.currencyCode))
.font(.caption2)
.foregroundColor(.secondary)
Spacer()
Text(goal.targetAmount.compactCurrencyString(currencyCode: entry.currencyCode))
.font(.caption2)
.foregroundColor(.secondary)
}
}
} else if entry.nextMilestone > 0 {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "flag.checkered")
.font(.caption2)
.foregroundColor(widgetSecondaryColor)
Text("Next milestone")
.font(.caption.weight(.semibold))
Spacer()
Text("\(Int((entry.milestoneProgress * 100).rounded()))%")
.font(.caption.weight(.bold))
.foregroundColor(widgetSecondaryColor)
}
ProgressView(value: entry.milestoneProgress)
.tint(widgetSecondaryColor)
HStack {
Spacer()
Text(entry.nextMilestone.compactCurrencyString(currencyCode: entry.currencyCode))
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
}
}
// MARK: - Small Widget View
struct SmallWidgetView: View {
let entry: InvestmentWidgetEntry
var body: some View {
Group {
if entry.hasData {
VStack(alignment: .leading, spacing: 6) {
Text("Total Value")
.font(.caption)
.foregroundColor(.secondary)
Text(entry.totalValue.compactCurrencyString(currencyCode: entry.currencyCode))
.font(.title2.weight(.bold))
.minimumScaleFactor(0.7)
.lineLimit(1)
ChangeLine(entry: entry, compact: true)
Text("since last check-in")
.font(.caption2)
.foregroundColor(.secondary)
Spacer(minLength: 0)
HStack {
if entry.streak >= 1 { StreakPill(streak: entry.streak) }
Spacer()
CheckInPill(date: entry.nextCheckInDate)
}
}
} else {
EmptyStateView()
}
}
.padding()
.containerBackground(.background, for: .widget)
}
}
// MARK: - Empty State
struct EmptyStateView: View {
var body: some View {
VStack(spacing: 8) {
Image(systemName: "chart.line.uptrend.xyaxis")
.font(.title)
.foregroundColor(widgetPrimaryColor)
Text("No data yet")
.font(.subheadline.weight(.semibold))
Text("Add a snapshot to start tracking.")
.font(.caption2)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - Medium Widget View
struct MediumWidgetView: View {
let entry: InvestmentWidgetEntry
var body: some View {
if !entry.hasData {
EmptyStateView()
.padding()
.containerBackground(.background, for: .widget)
} else {
content
}
}
private var content: some View {
HStack(spacing: 16) {
// Left side - Total value + engagement metrics
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
Text("Portfolio")
.font(.caption)
.foregroundColor(.secondary)
Spacer(minLength: 0)
WidgetRefreshButton()
}
Text(entry.totalValue.compactCurrencyString(currencyCode: entry.currencyCode))
.font(.title.weight(.bold))
.minimumScaleFactor(0.7)
.lineLimit(1)
ChangeLine(entry: entry)
Spacer(minLength: 4)
if entry.streak >= 1 { StreakPill(streak: entry.streak) }
CheckInPill(date: entry.nextCheckInDate)
}
Spacer()
VStack(alignment: .trailing, spacing: 8) {
if entry.isPremium {
if entry.trendPoints.count >= 2 {
TrendLineChartView(
points: entry.trendPoints,
labels: entry.trendLabels,
goal: entry.goals.first,
currencyCode: entry.currencyCode
)
.frame(height: 70)
} else {
VStack(alignment: .trailing, spacing: 4) {
Text("Add snapshots")
.font(.caption2)
.foregroundColor(.secondary)
Text("to see trend")
.font(.caption.weight(.semibold))
.foregroundColor(.secondary)
}
}
} else {
VStack(alignment: .trailing, spacing: 4) {
Text("Sparkline")
.font(.caption2)
.foregroundColor(.secondary)
Text("Premium")
.font(.caption.weight(.semibold))
.foregroundColor(.secondary)
}
}
// Top sources
VStack(alignment: .trailing, spacing: 6) {
ForEach(entry.topSources, id: \.name) { source in
HStack(spacing: 6) {
Circle()
.fill(Color(hex: source.color) ?? .gray)
.frame(width: 8, height: 8)
Text(source.name)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
Text(source.value.shortCurrencyString(currencyCode: entry.currencyCode))
.font(.caption.weight(.medium))
}
}
}
if !entry.insightTitle.isEmpty {
HStack(spacing: 4) {
Image(systemName: "lightbulb.fill")
.font(.caption2)
.foregroundColor(.orange)
VStack(alignment: .leading, spacing: 0) {
Text(entry.insightTitle)
.font(.caption2)
.foregroundColor(.secondary)
Text(entry.insightValue)
.font(.caption.weight(.semibold))
.foregroundColor(.primary)
.lineLimit(1)
}
}
}
}
}
.padding()
.containerBackground(.background, for: .widget)
}
}
// MARK: - Large Widget View
struct LargeWidgetView: View {
let entry: InvestmentWidgetEntry
private var hasCategoryTrend: Bool {
(entry.categoryEvolution.first?.points.count ?? 0) >= 2
}
var body: some View {
if !entry.hasData {
EmptyStateView()
.padding()
.containerBackground(.background, for: .widget)
} else {
content
}
}
private var content: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 6) {
Text("Portfolio")
.font(.caption)
.foregroundColor(.secondary)
Text(entry.totalValue.compactCurrencyString(currencyCode: entry.currencyCode))
.font(.title2.weight(.bold))
.minimumScaleFactor(0.7)
.lineLimit(1)
ChangeLine(entry: entry)
}
Spacer()
VStack(alignment: .trailing, spacing: 6) {
if entry.streak >= 1 { StreakPill(streak: entry.streak) }
CheckInPill(date: entry.nextCheckInDate)
WidgetRefreshButton()
}
}
GoalProgressRow(entry: entry)
if entry.isPremium {
if hasCategoryTrend {
CombinedCategoryChartView(
series: entry.categoryEvolution,
labels: entry.trendLabels,
goal: entry.goals.first,
currencyCode: entry.currencyCode
)
.frame(height: 84)
VStack(alignment: .leading, spacing: 6) {
ForEach(entry.categoryTotals.prefix(3), id: \.name) { category in
HStack {
RoundedRectangle(cornerRadius: 3)
.fill(Color(hex: category.color) ?? .gray)
.frame(width: 10, height: 10)
Text(category.name)
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Text(category.value.shortCurrencyString(currencyCode: entry.currencyCode))
.font(.caption.weight(.medium))
}
}
}
} else {
VStack(alignment: .leading, spacing: 6) {
Text("Add snapshots")
.font(.caption.weight(.semibold))
Text("Category evolution appears after updates.")
.font(.caption2)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
} else {
VStack(alignment: .leading, spacing: 6) {
Text("Unlock category trends")
.font(.caption.weight(.semibold))
Text("Premium shows evolution by category.")
.font(.caption2)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
}
.padding()
.containerBackground(.background, for: .widget)
}
}
// MARK: - Accessory Circular View (Lock Screen)
struct AccessoryCircularView: View {
let entry: InvestmentWidgetEntry
var body: some View {
Group {
if entry.hasData && (entry.nearestGoal != nil || entry.nextMilestone > 0) {
// Gauge toward nearest goal (or next milestone as fallback).
Gauge(value: entry.primaryGaugeProgress) {
Image(systemName: entry.nearestGoal != nil ? "target" : "flag.checkered")
} currentValueLabel: {
Text("\(Int((entry.primaryGaugeProgress * 100).rounded()))%")
.font(.system(size: 13, weight: .semibold))
}
.gaugeStyle(.accessoryCircularCapacity)
} else {
// Fallback: change since last check-in.
ZStack {
AccessoryWidgetBackground()
VStack(spacing: 2) {
Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right")
.font(.caption)
Text(String(format: "%.1f%%", entry.dayChangePercentage))
.font(.caption2.weight(.semibold))
}
}
}
}
.containerBackground(.background, for: .widget)
}
}
// MARK: - Accessory Inline View (Lock Screen)
struct AccessoryInlineView: View {
let entry: InvestmentWidgetEntry
var body: some View {
// e.g. "50K · +0.5%"
let arrow = entry.dayChange >= 0 ? "arrow.up" : "arrow.down"
Label {
Text("\(entry.totalValue.compactCurrencyString(currencyCode: entry.currencyCode)) · \(String(format: "%+.1f%%", entry.dayChangePercentage))")
} icon: {
Image(systemName: arrow)
}
}
}
// MARK: - Accessory Rectangular View (Lock Screen)
struct AccessoryRectangularView: View {
let entry: InvestmentWidgetEntry
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text("Portfolio")
.font(.caption2)
.foregroundColor(.secondary)
Text(entry.totalValue.compactCurrencyString(currencyCode: entry.currencyCode))
.font(.headline)
HStack(spacing: 4) {
Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right")
.font(.caption2)
Text(String(format: "%.1f%%", entry.dayChangePercentage))
.font(.caption2)
}
.foregroundColor(entry.dayChange >= 0 ? .green : .red)
}
.containerBackground(.background, for: .widget)
}
}
// MARK: - Trend Line Chart
struct TrendLineChartView: View {
let points: [Decimal]
let labels: [String]
let goal: GoalSummary?
var currencyCode: String = "EUR"
private var values: [Double] {
points.map { NSDecimalNumber(decimal: $0).doubleValue }
}
private var minValue: Double {
values.min() ?? 0
}
private var maxValue: Double {
values.max() ?? 0
}
private func normalized(_ value: Double) -> CGFloat {
let minV = minValue
let maxV = maxValue
if minV == maxV { return 0.5 }
return CGFloat((value - minV) / (maxV - minV))
}
var body: some View {
HStack(alignment: .center, spacing: 6) {
VStack(alignment: .leading, spacing: 2) {
Text(Decimal(maxValue).shortCurrencyString(currencyCode: currencyCode))
.font(.caption2)
.foregroundColor(.secondary)
Spacer()
Text(Decimal(minValue).shortCurrencyString(currencyCode: currencyCode))
.font(.caption2)
.foregroundColor(.secondary)
}
VStack(spacing: 4) {
GeometryReader { geo in
let width = geo.size.width
let height = geo.size.height
let step = points.count > 1 ? width / CGFloat(points.count - 1) : width
Path { path in
guard !values.isEmpty else { return }
for (index, value) in values.enumerated() {
let x = CGFloat(index) * step
let y = height - (normalized(value) * height)
if index == 0 {
path.move(to: CGPoint(x: x, y: y))
} else {
path.addLine(to: CGPoint(x: x, y: y))
}
}
}
.stroke(
LinearGradient(
colors: [
widgetPrimaryColor.opacity(0.9),
widgetPrimaryColor.opacity(0.6)
],
startPoint: .leading,
endPoint: .trailing
),
style: StrokeStyle(lineWidth: 2, lineJoin: .round)
)
if let goal, maxValue > 0 {
let goalValue = NSDecimalNumber(decimal: goal.targetAmount).doubleValue
let goalY = height - (normalized(goalValue) * height)
Path { path in
path.move(to: CGPoint(x: 0, y: goalY))
path.addLine(to: CGPoint(x: width, y: goalY))
}
.stroke(widgetSecondaryColor.opacity(0.6), style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
}
}
HStack(spacing: 4) {
ForEach(labels.indices, id: \.self) { index in
Text(labels[index])
.font(.caption2)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity)
}
}
}
}
}
}
// MARK: - Combined Category Chart
struct CombinedCategoryChartView: View {
let series: [CategorySeries]
let labels: [String]
let goal: GoalSummary?
var currencyCode: String = "EUR"
private var pointsCount: Int {
series.first?.points.count ?? 0
}
private var totals: [Decimal] {
guard pointsCount > 0 else { return [] }
return (0..<pointsCount).map { index in
series.reduce(Decimal.zero) { $0 + $1.points[index] }
}
}
private var maxValue: Double {
let seriesMax = totals.map { NSDecimalNumber(decimal: $0).doubleValue }.max() ?? 0
let goalValue = goal.map { NSDecimalNumber(decimal: $0.targetAmount).doubleValue } ?? 0
return max(seriesMax, goalValue)
}
private func normalized(_ value: Double) -> CGFloat {
let maxV = maxValue
if maxV == 0 { return 0 }
return CGFloat(value / maxV)
}
var body: some View {
HStack(alignment: .center, spacing: 6) {
VStack(alignment: .leading, spacing: 2) {
Text(Decimal(maxValue).shortCurrencyString(currencyCode: currencyCode))
.font(.caption2)
.foregroundColor(.secondary)
Spacer()
Text(Decimal(0).shortCurrencyString(currencyCode: currencyCode))
.font(.caption2)
.foregroundColor(.secondary)
}
VStack(spacing: 4) {
GeometryReader { geo in
let height = geo.size.height
let width = geo.size.width
let columnWidth = pointsCount > 0 ? (width / CGFloat(pointsCount)) : width
ZStack {
HStack(alignment: .bottom, spacing: 4) {
ForEach(0..<pointsCount, id: \.self) { index in
let total = totals.indices.contains(index) ? totals[index] : .zero
let totalValue = NSDecimalNumber(decimal: total).doubleValue
let columnHeight = height * normalized(totalValue)
VStack(spacing: 1) {
ForEach(series.indices, id: \.self) { catIndex in
let value = series[catIndex].points[index]
let valueDouble = NSDecimalNumber(decimal: value).doubleValue
let segmentHeight = totalValue == 0 ? 0 : height * CGFloat(valueDouble / maxValue)
RoundedRectangle(cornerRadius: 2)
.fill(Color(hex: series[catIndex].color) ?? .gray)
.frame(height: segmentHeight)
}
}
.frame(width: columnWidth, height: columnHeight, alignment: .bottom)
}
}
Path { path in
guard totals.count > 1 else { return }
let step = pointsCount > 1 ? width / CGFloat(pointsCount - 1) : width
for (index, total) in totals.enumerated() {
let value = NSDecimalNumber(decimal: total).doubleValue
let x = CGFloat(index) * step
let y = height - (normalized(value) * height)
if index == 0 {
path.move(to: CGPoint(x: x, y: y))
} else {
path.addLine(to: CGPoint(x: x, y: y))
}
}
}
.stroke(widgetPrimaryColor.opacity(0.85), style: StrokeStyle(lineWidth: 1.6))
if let goal, maxValue > 0 {
let goalValue = NSDecimalNumber(decimal: goal.targetAmount).doubleValue
let goalY = height - (normalized(goalValue) * height)
Path { path in
path.move(to: CGPoint(x: 0, y: goalY))
path.addLine(to: CGPoint(x: width, y: goalY))
}
.stroke(widgetSecondaryColor.opacity(0.6), style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
}
}
}
HStack(spacing: 4) {
ForEach(labels.indices, id: \.self) { index in
Text(labels[index])
.font(.caption2)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity)
}
}
}
}
}
}
// MARK: - Interactive Refresh Button (iOS 17+)
/// A small refresh control that reloads the widget timeline in-process via an
/// AppIntent. Only rendered on iOS 17+, where `Button(intent:)` is available;
/// on iOS 16 it collapses to nothing so the widget still builds and works with
/// the existing deep links.
struct WidgetRefreshButton: View {
var body: some View {
if #available(iOS 17.0, *) {
Button(intent: RefreshWidgetIntent()) {
Image(systemName: "arrow.clockwise")
.font(.caption2.weight(.semibold))
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
.invalidatableContent()
}
}
}
// MARK: - Widget Configuration
struct InvestmentWidget: Widget {
let kind: String = "InvestmentWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: InvestmentWidgetProvider()) { entry in
InvestmentWidgetEntryView(entry: entry)
}
.configurationDisplayName("Portfolio Value")
.description("View your total investment portfolio value.")
.supportedFamilies([
.systemSmall,
.systemMedium,
.systemLarge,
.accessoryCircular,
.accessoryRectangular,
.accessoryInline
])
}
}
// MARK: - Widget Entry View
struct InvestmentWidgetEntryView: View {
@Environment(\.widgetFamily) var family
let entry: InvestmentWidgetEntry
var body: some View {
switch family {
case .systemSmall:
SmallWidgetView(entry: entry)
.widgetURL(URL(string: "portfoliojournal://quickupdate")!)
case .systemMedium:
MediumWidgetView(entry: entry)
.widgetURL(URL(string: "portfoliojournal://quickupdate")!)
case .systemLarge:
LargeWidgetView(entry: entry)
.widgetURL(URL(string: "portfoliojournal://quickupdate")!)
case .accessoryCircular:
AccessoryCircularView(entry: entry)
.widgetURL(URL(string: "portfoliojournal://quickupdate")!)
case .accessoryRectangular:
AccessoryRectangularView(entry: entry)
.widgetURL(URL(string: "portfoliojournal://quickupdate")!)
case .accessoryInline:
AccessoryInlineView(entry: entry)
.widgetURL(URL(string: "portfoliojournal://quickupdate")!)
default:
SmallWidgetView(entry: entry)
.widgetURL(URL(string: "portfoliojournal://quickupdate")!)
}
}
}
// MARK: - Widget Bundle
@main
struct PortfolioJournalWidgetBundle: WidgetBundle {
var body: some Widget {
InvestmentWidget()
}
}
// MARK: - Previews
#Preview("Small", as: .systemSmall) {
InvestmentWidget()
} timeline: {
InvestmentWidgetEntry(
date: Date(),
isPremium: true,
totalValue: 50000,
dayChange: 250,
dayChangePercentage: 0.5,
topSources: [],
trendPoints: [45000, 46000, 47000, 48000, 49000, 50000],
trendLabels: ["Aug", "Sep", "Oct", "Nov", "Dec", "Jan"],
categoryEvolution: [],
categoryTotals: [],
goals: [],
currencyCode: "EUR",
insightTitle: "",
insightValue: "",
streak: 5,
nextCheckInDate: Calendar.current.date(byAdding: .day, value: 8, to: Date()),
nearestGoal: nil,
nearestGoalProgress: 0,
milestoneProgress: 0.5,
nextMilestone: 100000
)
}
#Preview("Medium", as: .systemMedium) {
InvestmentWidget()
} timeline: {
InvestmentWidgetEntry(
date: Date(),
isPremium: true,
totalValue: 50000,
dayChange: 250,
dayChangePercentage: 0.5,
topSources: [
("Stocks", 30000, "#10B981"),
("Bonds", 15000, "#3B82F6"),
("Real Estate", 5000, "#F59E0B")
],
trendPoints: [45000, 46000, 47000, 48000, 49000, 50000],
trendLabels: ["Aug", "Sep", "Oct", "Nov", "Dec", "Jan"],
categoryEvolution: [],
categoryTotals: [],
goals: [],
currencyCode: "EUR",
insightTitle: "Year to date",
insightValue: "+12.4%",
streak: 7,
nextCheckInDate: Calendar.current.date(byAdding: .day, value: 2, to: Date()),
nearestGoal: nil,
nearestGoalProgress: 0,
milestoneProgress: 0.5,
nextMilestone: 100000
)
}
#Preview("Large", as: .systemLarge) {
InvestmentWidget()
} timeline: {
InvestmentWidgetEntry(
date: Date(),
isPremium: true,
totalValue: 95000,
dayChange: 850,
dayChangePercentage: 0.9,
topSources: [
("Vanguard", 42000, "#10B981"),
("Bonds", 26000, "#3B82F6"),
("Real Estate", 18000, "#F59E0B")
],
trendPoints: [88000, 89000, 90000, 91500, 93000, 94000, 95000],
trendLabels: ["Jul", "Aug", "Sep", "Oct", "Nov", "Dec", "Jan"],
categoryEvolution: [
CategorySeries(
id: "stocks",
name: "Stocks",
color: "#10B981",
points: [30000, 31000, 32000, 33000, 34000, 35000, 36000],
latestValue: 36000
),
CategorySeries(
id: "bonds",
name: "Bonds",
color: "#3B82F6",
points: [22000, 22000, 23000, 24000, 25000, 25500, 26000],
latestValue: 26000
),
CategorySeries(
id: "realestate",
name: "Real Estate",
color: "#F59E0B",
points: [15000, 15500, 16000, 16500, 17000, 17500, 18000],
latestValue: 18000
)
],
categoryTotals: [
("Stocks", 36000, "#10B981"),
("Bonds", 26000, "#3B82F6"),
("Real Estate", 18000, "#F59E0B")
],
goals: [
GoalSummary(name: "House deposit", targetAmount: 120000, targetDate: nil)
],
currencyCode: "EUR",
insightTitle: "Milestone ahead",
insightValue: "€5K from €100K",
streak: 9,
nextCheckInDate: Calendar.current.date(byAdding: .day, value: 4, to: Date()),
nearestGoal: GoalSummary(name: "House deposit", targetAmount: 120000, targetDate: nil),
nearestGoalProgress: 0.79,
milestoneProgress: 0.6,
nextMilestone: 100000
)
}
extension Decimal {
func compactCurrencyString(currencyCode: String) -> String {
let absValue = (self as NSDecimalNumber).doubleValue.magnitude
let sign = (self as NSDecimalNumber).doubleValue < 0 ? -1.0 : 1.0
let (divisor, suffix): (Double, String)
switch absValue {
case 1_000_000_000_000...:
(divisor, suffix) = (1_000_000_000_000, "T")
case 1_000_000_000...:
(divisor, suffix) = (1_000_000_000, "B")
case 1_000_000...:
(divisor, suffix) = (1_000_000, "M")
case 1_000...:
(divisor, suffix) = (1_000, "K")
default:
(divisor, suffix) = (1, "")
}
let value = (self as NSDecimalNumber).doubleValue / divisor
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = currencyCode
formatter.maximumFractionDigits = value < 10 && suffix != "" ? 1 : 0
formatter.minimumFractionDigits = 0
let currencySymbol = formatter.currencySymbol ?? ""
let formattedNumber: String
do {
let nf = NumberFormatter()
nf.numberStyle = .decimal
nf.maximumFractionDigits = formatter.maximumFractionDigits
nf.minimumFractionDigits = formatter.minimumFractionDigits
formattedNumber = nf.string(from: NSNumber(value: value * sign)) ?? String(format: "%.0f", value * sign)
}
return "\(currencySymbol)\(formattedNumber)\(suffix)"
}
func shortCurrencyString(currencyCode: String) -> String {
return compactCurrencyString(currencyCode: currencyCode)
}
var compactCurrencyString: String {
return compactCurrencyString(currencyCode: "EUR")
}
var shortCurrencyString: String {
return shortCurrencyString(currencyCode: "EUR")
}
}
// MARK: - Color Helper
extension Color {
init?(hex: String) {
var hexString = hex
if hexString.hasPrefix("#") {
hexString.removeFirst()
}
guard hexString.count == 6,
let hexNumber = Int(hexString, radix: 16) else { return nil }
let red = Double((hexNumber >> 16) & 0xFF) / 255
let green = Double((hexNumber >> 8) & 0xFF) / 255
let blue = Double(hexNumber & 0xFF) / 255
self.init(red: red, green: green, blue: blue)
}
}