Update version
This commit is contained in:
@@ -5,6 +5,8 @@ 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
|
||||
@@ -53,9 +55,11 @@ struct InvestmentWidgetEntry: TimelineEntry {
|
||||
let dayChange: Decimal
|
||||
let dayChangePercentage: Double
|
||||
let topSources: [(name: String, value: Decimal, color: String)]
|
||||
let sparklineData: [Decimal]
|
||||
let trendPoints: [Decimal]
|
||||
let trendLabels: [String]
|
||||
let categoryEvolution: [CategorySeries]
|
||||
let categoryTotals: [(name: String, value: Decimal, color: String)]
|
||||
let goals: [GoalSummary]
|
||||
}
|
||||
|
||||
struct CategorySeries: Identifiable {
|
||||
@@ -66,6 +70,13 @@ struct CategorySeries: Identifiable {
|
||||
let latestValue: Decimal
|
||||
}
|
||||
|
||||
struct GoalSummary: Identifiable {
|
||||
let id = UUID()
|
||||
let name: String
|
||||
let targetAmount: Decimal
|
||||
let targetDate: Date?
|
||||
}
|
||||
|
||||
// MARK: - Widget Provider
|
||||
|
||||
struct InvestmentWidgetProvider: TimelineProvider {
|
||||
@@ -81,7 +92,8 @@ struct InvestmentWidgetProvider: TimelineProvider {
|
||||
("Bonds", 15000, "#3B82F6"),
|
||||
("Real Estate", 5000, "#F59E0B")
|
||||
],
|
||||
sparklineData: [45000, 46000, 47000, 48000, 49000, 50000],
|
||||
trendPoints: [45000, 46000, 47000, 48000, 49000, 50000],
|
||||
trendLabels: ["Aug", "Sep", "Oct", "Nov", "Dec", "Jan"],
|
||||
categoryEvolution: [
|
||||
CategorySeries(
|
||||
id: "stocks",
|
||||
@@ -109,6 +121,9 @@ struct InvestmentWidgetProvider: TimelineProvider {
|
||||
("Stocks", 26000, "#10B981"),
|
||||
("Bonds", 14500, "#3B82F6"),
|
||||
("Real Estate", 6800, "#F59E0B")
|
||||
],
|
||||
goals: [
|
||||
GoalSummary(name: "Target", targetAmount: 75000, targetDate: nil)
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -139,9 +154,11 @@ struct InvestmentWidgetProvider: TimelineProvider {
|
||||
dayChange: 0,
|
||||
dayChangePercentage: 0,
|
||||
topSources: [],
|
||||
sparklineData: [],
|
||||
trendPoints: [],
|
||||
trendLabels: [],
|
||||
categoryEvolution: [],
|
||||
categoryTotals: []
|
||||
categoryTotals: [],
|
||||
goals: []
|
||||
)
|
||||
}
|
||||
|
||||
@@ -166,15 +183,18 @@ struct InvestmentWidgetProvider: TimelineProvider {
|
||||
let snapshots = (try? context.fetch(snapshotRequest)) ?? []
|
||||
|
||||
var dailyTotals: [Date: Decimal] = [:]
|
||||
var monthlyTotals: [Date: Decimal] = [:]
|
||||
var latestBySource: [NSManagedObjectID: (date: Date, value: Decimal, source: NSManagedObject)] = [:]
|
||||
var categoryDailyTotals: [String: [Date: Decimal]] = [:]
|
||||
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
|
||||
@@ -198,9 +218,9 @@ struct InvestmentWidgetProvider: TimelineProvider {
|
||||
}
|
||||
|
||||
categoryMeta[categoryId] = (categoryName, categoryColor)
|
||||
var dayTotals = categoryDailyTotals[categoryId, default: [:]]
|
||||
dayTotals[day, default: .zero] += value
|
||||
categoryDailyTotals[categoryId] = dayTotals
|
||||
var monthTotals = categoryMonthlyTotals[categoryId, default: [:]]
|
||||
monthTotals[month, default: .zero] += value
|
||||
categoryMonthlyTotals[categoryId] = monthTotals
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +228,27 @@ struct InvestmentWidgetProvider: TimelineProvider {
|
||||
.map { ($0.key, $0.value) }
|
||||
.sorted { $0.0 < $1.0 }
|
||||
|
||||
let sparklineData = Array(sortedTotals.suffix(7).map { $0.1 })
|
||||
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 }
|
||||
|
||||
@@ -247,10 +287,9 @@ struct InvestmentWidgetProvider: TimelineProvider {
|
||||
}
|
||||
.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 }
|
||||
let monthMap = categoryMonthlyTotals[category.id] ?? [:]
|
||||
let points = months.map { monthMap[$0] ?? .zero }
|
||||
return CategorySeries(
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
@@ -271,6 +310,29 @@ struct InvestmentWidgetProvider: TimelineProvider {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -278,9 +340,11 @@ struct InvestmentWidgetProvider: TimelineProvider {
|
||||
dayChange: dayChange,
|
||||
dayChangePercentage: dayChangePercentage,
|
||||
topSources: Array(topSources),
|
||||
sparklineData: sparklineData,
|
||||
trendPoints: trendPoints,
|
||||
trendLabels: trendLabels,
|
||||
categoryEvolution: categoryEvolution,
|
||||
categoryTotals: categoryTotalsData.map { (name: $0.name, value: $0.value, color: $0.color) }
|
||||
categoryTotals: categoryTotalsData.map { (name: $0.name, value: $0.value, color: $0.color) },
|
||||
goals: goals
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -357,9 +421,13 @@ struct MediumWidgetView: View {
|
||||
|
||||
VStack(alignment: .trailing, spacing: 8) {
|
||||
if entry.isPremium {
|
||||
if entry.sparklineData.count >= 2 {
|
||||
SparklineView(data: entry.sparklineData, isPositive: entry.dayChange >= 0)
|
||||
.frame(height: 48)
|
||||
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")
|
||||
@@ -450,8 +518,12 @@ struct LargeWidgetView: View {
|
||||
|
||||
if entry.isPremium {
|
||||
if hasCategoryTrend {
|
||||
CategoryEvolutionView(series: entry.categoryEvolution)
|
||||
.frame(height: 86)
|
||||
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
|
||||
@@ -545,83 +617,202 @@ struct AccessoryRectangularView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sparkline
|
||||
// MARK: - Trend Line Chart
|
||||
|
||||
struct SparklineView: View {
|
||||
let data: [Decimal]
|
||||
let isPositive: Bool
|
||||
struct TrendLineChartView: View {
|
||||
let points: [Decimal]
|
||||
let labels: [String]
|
||||
let goal: GoalSummary?
|
||||
|
||||
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)) }
|
||||
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 {
|
||||
GeometryReader { geo in
|
||||
let width = geo.size.width
|
||||
let height = geo.size.height
|
||||
let step = data.count > 1 ? width / CGFloat(data.count - 1) : width
|
||||
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)
|
||||
}
|
||||
|
||||
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))
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
.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
|
||||
// MARK: - Combined Category Chart
|
||||
|
||||
struct CategoryEvolutionView: View {
|
||||
struct CombinedCategoryChartView: View {
|
||||
let series: [CategorySeries]
|
||||
let labels: [String]
|
||||
let goal: GoalSummary?
|
||||
|
||||
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
|
||||
private var totals: [Decimal] {
|
||||
guard pointsCount > 0 else { return [] }
|
||||
return (0..<pointsCount).map { index in
|
||||
series.reduce(Decimal.zero) { $0 + $1.points[index] }
|
||||
}
|
||||
}
|
||||
|
||||
HStack(alignment: .bottom, spacing: 4) {
|
||||
ForEach(0..<pointsCount, id: \.self) { index in
|
||||
let dayTotal = series.reduce(Decimal.zero) { $0 + $1.points[index] }
|
||||
VStack(spacing: 1) {
|
||||
ForEach(series.indices, id: \.self) { catIndex in
|
||||
let value = series[catIndex].points[index]
|
||||
let ratio = dayTotal == 0 ? 0 : NSDecimalNumber(decimal: value / dayTotal).doubleValue
|
||||
let segmentHeight = value == 0 ? 0 : max(1, height * CGFloat(ratio))
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color(hex: series[catIndex].color) ?? .gray)
|
||||
.frame(height: segmentHeight)
|
||||
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]))
|
||||
}
|
||||
}
|
||||
.frame(width: columnWidth, height: height, alignment: .bottom)
|
||||
}
|
||||
|
||||
HStack(spacing: 4) {
|
||||
ForEach(labels.indices, id: \.self) { index in
|
||||
Text(labels[index])
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -687,59 +878,64 @@ struct PortfolioJournalWidgetBundle: WidgetBundle {
|
||||
#Preview("Small", as: .systemSmall) {
|
||||
InvestmentWidget()
|
||||
} timeline: {
|
||||
InvestmentWidgetEntry(
|
||||
date: Date(),
|
||||
isPremium: true,
|
||||
totalValue: 50000,
|
||||
dayChange: 250,
|
||||
dayChangePercentage: 0.5,
|
||||
topSources: [],
|
||||
sparklineData: [],
|
||||
categoryEvolution: [],
|
||||
categoryTotals: []
|
||||
)
|
||||
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")
|
||||
],
|
||||
sparklineData: [],
|
||||
categoryEvolution: [],
|
||||
categoryTotals: []
|
||||
)
|
||||
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")
|
||||
],
|
||||
sparklineData: [88000, 89000, 90000, 91500, 93000, 94000, 95000],
|
||||
categoryEvolution: [
|
||||
CategorySeries(
|
||||
id: "stocks",
|
||||
name: "Stocks",
|
||||
color: "#10B981",
|
||||
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
|
||||
),
|
||||
@@ -757,13 +953,16 @@ struct PortfolioJournalWidgetBundle: WidgetBundle {
|
||||
points: [15000, 15500, 16000, 16500, 17000, 17500, 18000],
|
||||
latestValue: 18000
|
||||
)
|
||||
],
|
||||
categoryTotals: [
|
||||
("Stocks", 36000, "#10B981"),
|
||||
("Bonds", 26000, "#3B82F6"),
|
||||
("Real Estate", 18000, "#F59E0B")
|
||||
]
|
||||
)
|
||||
],
|
||||
categoryTotals: [
|
||||
("Stocks", 36000, "#10B981"),
|
||||
("Bonds", 26000, "#3B82F6"),
|
||||
("Real Estate", 18000, "#F59E0B")
|
||||
],
|
||||
goals: [
|
||||
GoalSummary(name: "Target", targetAmount: 120000, targetDate: nil)
|
||||
]
|
||||
)
|
||||
}
|
||||
extension Decimal {
|
||||
var compactCurrencyString: String {
|
||||
|
||||
Reference in New Issue
Block a user