1031 lines
37 KiB
Swift
1031 lines
37 KiB
Swift
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<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
|
|
|
|
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<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 { 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<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
|
|
}
|
|
}
|
|
|
|
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..<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)
|
|
.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..<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: - 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)
|
|
}
|
|
}
|