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(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) -> 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(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(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..(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) .foregroundStyle(compact ? (entry.dayChange >= 0 ? .green : .red) : .secondary) } .foregroundStyle(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) .foregroundStyle(.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) .foregroundStyle(checkInIsUrgent(date) ? .orange : .secondary) Text(checkInText(for: date)) .font(.caption2.weight(.medium)) .foregroundStyle(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) .foregroundStyle(widgetSecondaryColor) Text(goal.name) .font(.caption.weight(.semibold)) .lineLimit(1) Spacer() Text("\(Int((pct * 100).rounded()))%") .font(.caption.weight(.bold)) .foregroundStyle(widgetSecondaryColor) } ProgressView(value: pct) .tint(widgetSecondaryColor) HStack { Text(entry.totalValue.compactCurrencyString(currencyCode: entry.currencyCode)) .font(.caption2) .foregroundStyle(.secondary) Spacer() Text(goal.targetAmount.compactCurrencyString(currencyCode: entry.currencyCode)) .font(.caption2) .foregroundStyle(.secondary) } } } else if entry.nextMilestone > 0 { VStack(alignment: .leading, spacing: 4) { HStack { Image(systemName: "flag.checkered") .font(.caption2) .foregroundStyle(widgetSecondaryColor) Text("Next milestone") .font(.caption.weight(.semibold)) Spacer() Text("\(Int((entry.milestoneProgress * 100).rounded()))%") .font(.caption.weight(.bold)) .foregroundStyle(widgetSecondaryColor) } ProgressView(value: entry.milestoneProgress) .tint(widgetSecondaryColor) HStack { Spacer() Text(entry.nextMilestone.compactCurrencyString(currencyCode: entry.currencyCode)) .font(.caption2) .foregroundStyle(.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) .foregroundStyle(.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) .foregroundStyle(.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) .foregroundStyle(widgetPrimaryColor) Text("No data yet") .font(.subheadline.weight(.semibold)) Text("Add a snapshot to start tracking.") .font(.caption2) .foregroundStyle(.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) .foregroundStyle(.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) .foregroundStyle(.secondary) Text("to see trend") .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) } } } else { VStack(alignment: .trailing, spacing: 4) { Text("Sparkline") .font(.caption2) .foregroundStyle(.secondary) Text("Premium") .font(.caption.weight(.semibold)) .foregroundStyle(.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) .foregroundStyle(.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) .foregroundStyle(.orange) VStack(alignment: .leading, spacing: 0) { Text(entry.insightTitle) .font(.caption2) .foregroundStyle(.secondary) Text(entry.insightValue) .font(.caption.weight(.semibold)) .foregroundStyle(.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) .foregroundStyle(.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) .foregroundStyle(.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) .foregroundStyle(.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) .foregroundStyle(.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) .foregroundStyle(.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) } .foregroundStyle(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) .foregroundStyle(.secondary) Spacer() Text(Decimal(minValue).shortCurrencyString(currencyCode: currencyCode)) .font(.caption2) .foregroundStyle(.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) .foregroundStyle(.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.. 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) .foregroundStyle(.secondary) Spacer() Text(Decimal(0).shortCurrencyString(currencyCode: currencyCode)) .font(.caption2) .foregroundStyle(.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.. 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) .foregroundStyle(.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)) .foregroundStyle(.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) } }