import WidgetKit import SwiftUI import CoreData private let appGroupIdentifier = "group.com.alexandrevazquez.portfoliojournal" private let storeFileName = "PortfolioJournal.sqlite" private let sharedPremiumKey = "premiumUnlocked" 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 } // 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 sparklineData: [Decimal] let categoryEvolution: [CategorySeries] let categoryTotals: [(name: String, value: Decimal, color: String)] } struct CategorySeries: Identifiable { let id: String let name: String let color: String let points: [Decimal] let latestValue: Decimal } // 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") ], sparklineData: [45000, 46000, 47000, 48000, 49000, 50000], 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") ] ) } 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 guard let container = createWidgetContainer() else { return InvestmentWidgetEntry( date: Date(), isPremium: isPremium, totalValue: 0, dayChange: 0, dayChangePercentage: 0, topSources: [], sparklineData: [], categoryEvolution: [], categoryTotals: [] ) } 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 latestBySource: [NSManagedObjectID: (date: Date, value: Decimal, source: NSManagedObject)] = [:] var categoryDailyTotals: [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 value = decimalValue(from: snapshot, key: "value") dailyTotals[day, 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 dayTotals = categoryDailyTotals[categoryId, default: [:]] dayTotals[day, default: .zero] += value categoryDailyTotals[categoryId] = dayTotals } } let sortedTotals = dailyTotals .map { ($0.key, $0.value) } .sorted { $0.0 < $1.0 } let sparklineData = Array(sortedTotals.suffix(7).map { $0.1 }) 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 evolutionDays = Array(sortedTotals.suffix(7).map { $0.0 }) let categoryEvolution: [CategorySeries] = categoryTotalsData.prefix(4).map { category in let dayMap = categoryDailyTotals[category.id] ?? [:] let points = evolutionDays.map { dayMap[$0] ?? .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 } } return InvestmentWidgetEntry( date: Date(), isPremium: isPremium, totalValue: totalValue, dayChange: dayChange, dayChangePercentage: dayChangePercentage, topSources: Array(topSources), sparklineData: sparklineData, categoryEvolution: categoryEvolution, categoryTotals: categoryTotalsData.map { (name: $0.name, value: $0.value, color: $0.color) } ) } } // MARK: - Small Widget View struct SmallWidgetView: View { let entry: InvestmentWidgetEntry var body: some View { VStack(alignment: .leading, spacing: 8) { Text("Total Value") .font(.caption) .foregroundColor(.secondary) Text(entry.totalValue.compactCurrencyString) .font(.title2.weight(.bold)) .minimumScaleFactor(0.7) .lineLimit(1) VStack(alignment: .leading, spacing: 4) { Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right") .font(.caption2) Text(entry.dayChange.compactCurrencyString) .font(.caption.weight(.medium)) Text(String(format: "%.1f%% since last", entry.dayChangePercentage)) .font(.caption2) .foregroundColor(.secondary) } .foregroundColor(entry.dayChange >= 0 ? .green : .red) Spacer() } .padding() .containerBackground(.background, for: .widget) } } // MARK: - Medium Widget View struct MediumWidgetView: View { let entry: InvestmentWidgetEntry var body: some View { HStack(spacing: 16) { // Left side - Total value VStack(alignment: .leading, spacing: 8) { Text("Portfolio") .font(.caption) .foregroundColor(.secondary) Text(entry.totalValue.compactCurrencyString) .font(.title.weight(.bold)) .minimumScaleFactor(0.7) .lineLimit(1) HStack(spacing: 4) { Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right") .font(.caption2) Text(entry.dayChange.compactCurrencyString) .font(.caption.weight(.medium)) Text("(\(String(format: "%.1f%%", entry.dayChangePercentage)))") .font(.caption) .foregroundColor(.secondary) } .foregroundColor(entry.dayChange >= 0 ? .green : .red) } Spacer() VStack(alignment: .trailing, spacing: 8) { if entry.isPremium { if entry.sparklineData.count >= 2 { SparklineView(data: entry.sparklineData, isPositive: entry.dayChange >= 0) .frame(height: 48) } 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) .font(.caption.weight(.medium)) } } } } } .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 { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 6) { Text("Portfolio") .font(.caption) .foregroundColor(.secondary) Text(entry.totalValue.compactCurrencyString) .font(.title2.weight(.bold)) .minimumScaleFactor(0.7) .lineLimit(1) HStack(spacing: 4) { Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right") .font(.caption2) Text(entry.dayChange.compactCurrencyString) .font(.caption.weight(.medium)) Text("(\(String(format: "%.1f%%", entry.dayChangePercentage)))") .font(.caption2) .foregroundColor(.secondary) } .foregroundColor(entry.dayChange >= 0 ? .green : .red) } Spacer() Text("Category Evolution") .font(.caption.weight(.semibold)) .foregroundColor(.secondary) } if entry.isPremium { if hasCategoryTrend { CategoryEvolutionView(series: entry.categoryEvolution) .frame(height: 86) VStack(alignment: .leading, spacing: 8) { ForEach(entry.categoryTotals.prefix(4), 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) .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 { 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 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) .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: - Sparkline struct SparklineView: View { let data: [Decimal] let isPositive: Bool private var points: [CGFloat] { let doubles = data.map { NSDecimalNumber(decimal: $0).doubleValue } guard let minV = doubles.min(), let maxV = doubles.max(), minV != maxV else { return doubles.map { _ in 0.5 } } return doubles.map { CGFloat(($0 - minV) / (maxV - minV)) } } var body: some View { GeometryReader { geo in let width = geo.size.width let height = geo.size.height let step = data.count > 1 ? width / CGFloat(data.count - 1) : width Path { path in guard !points.isEmpty else { return } for (index, value) in points.enumerated() { let x = CGFloat(index) * step let y = height - (CGFloat(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: [ (isPositive ? Color.green : Color.red).opacity(0.9), (isPositive ? Color.green : Color.red).opacity(0.6) ], startPoint: .leading, endPoint: .trailing ), style: StrokeStyle(lineWidth: 2, lineJoin: .round) ) .shadow(color: Color.black.opacity(0.08), radius: 2, y: 1) } } } // MARK: - Category Evolution Chart struct CategoryEvolutionView: View { let series: [CategorySeries] private var pointsCount: Int { series.first?.points.count ?? 0 } var body: some View { GeometryReader { geo in let height = geo.size.height let width = geo.size.width let columnWidth = pointsCount > 0 ? (width / CGFloat(pointsCount)) : width HStack(alignment: .bottom, spacing: 4) { ForEach(0..> 16) & 0xFF) / 255 let green = Double((hexNumber >> 8) & 0xFF) / 255 let blue = Double(hexNumber & 0xFF) / 255 self.init(red: red, green: green, blue: blue) } }