import WidgetKit import SwiftUI import CoreData 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 } // 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] } 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) ] ) } 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: [], trendPoints: [], trendLabels: [], categoryEvolution: [], categoryTotals: [], goals: [] ) } 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 { monthlyTotals[$0] ?? .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 points = months.map { monthMap[$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 } } 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 } } 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 ) } } // 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.trendPoints.count >= 2 { TrendLineChartView( points: entry.trendPoints, labels: entry.trendLabels, goal: entry.goals.first ) .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) .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 { CombinedCategoryChartView( series: entry.categoryEvolution, labels: entry.trendLabels, goal: entry.goals.first ) .frame(height: 98) 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: - Trend Line Chart struct TrendLineChartView: View { let points: [Decimal] let labels: [String] let goal: GoalSummary? 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) .font(.caption2) .foregroundColor(.secondary) Spacer() Text(Decimal(minValue).shortCurrencyString) .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? 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) .font(.caption2) .foregroundColor(.secondary) Spacer() Text(Decimal(0).shortCurrencyString) .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.. 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: - 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 ]) } } // 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) case .systemMedium: MediumWidgetView(entry: entry) case .systemLarge: LargeWidgetView(entry: entry) case .accessoryCircular: AccessoryCircularView(entry: entry) case .accessoryRectangular: AccessoryRectangularView(entry: entry) default: SmallWidgetView(entry: entry) } } } // 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: [] ) } #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: [] ) } #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: "Target", targetAmount: 120000, targetDate: nil) ] ) } extension Decimal { var compactCurrencyString: 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.maximumFractionDigits = value < 10 && suffix != "" ? 1 : 0 formatter.minimumFractionDigits = 0 // Use current locale currency symbol let currencySymbol = formatter.currencySymbol ?? "€" let formattedNumber: String do { // Use a plain decimal formatter to better control digits 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)" } var shortCurrencyString: String { return compactCurrencyString } } // 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) } }