1.6.0: auto-rellenar huecos (snapshots estimados) + widgets/lock screen enriquecidos
Auto-relleno (#5): SnapshotRepository.autoFillGap crea snapshots interpolados linealmente entre los dos snapshots que bordean el hueco, marcados isEstimated. deleteEstimatedSnapshots para borrado en bloque. Botón 'Autocompletar N meses' (wand.and.stars) en DataGapsSheet + 'Añadir manualmente'. Idempotente. Strings 7 idiomas. Widgets/Lock Screen (#8, vía agente): familias enriquecidas — systemSmall/Medium/Large con net worth + cambio desde check-in + racha + próximo check-in + progreso de objetivo; lock screen accessoryCircular (gauge objetivo/milestone), accessoryRectangular, accessoryInline. Deep link quickupdate preservado. Solo InvestmentWidget.swift. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
This commit is contained in:
@@ -76,6 +76,26 @@ struct InvestmentWidgetEntry: TimelineEntry {
|
||||
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 {
|
||||
@@ -143,7 +163,13 @@ struct InvestmentWidgetProvider: TimelineProvider {
|
||||
],
|
||||
currencyCode: "EUR",
|
||||
insightTitle: "Milestone ahead",
|
||||
insightValue: "€5K from €50K"
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -182,7 +208,13 @@ struct InvestmentWidgetProvider: TimelineProvider {
|
||||
goals: [],
|
||||
currencyCode: currencyCode,
|
||||
insightTitle: "",
|
||||
insightValue: ""
|
||||
insightValue: "",
|
||||
streak: 0,
|
||||
nextCheckInDate: nil,
|
||||
nearestGoal: nil,
|
||||
nearestGoalProgress: 0,
|
||||
milestoneProgress: 0,
|
||||
nextMilestone: 0
|
||||
)
|
||||
}
|
||||
|
||||
@@ -368,6 +400,84 @@ struct InvestmentWidgetProvider: TimelineProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// 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..<max(sortedMonthsDesc.count, 1) {
|
||||
guard i < sortedMonthsDesc.count,
|
||||
let expected = calendar.date(byAdding: .month, value: -1, to: current) else { break }
|
||||
let prev = sortedMonthsDesc[i]
|
||||
let pc = calendar.dateComponents([.year, .month], from: prev)
|
||||
let ec = calendar.dateComponents([.year, .month], from: expected)
|
||||
if pc.year == ec.year && pc.month == ec.month {
|
||||
count += 1
|
||||
current = prev
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return count
|
||||
}()
|
||||
|
||||
// MARK: Next check-in date — from the most recent completed JournalEntry.
|
||||
// Mirrors DashboardView.nextCheckInDate (common case, without grace-period edge).
|
||||
let nextCheckInDate: Date? = {
|
||||
let journalRequest = NSFetchRequest<NSManagedObject>(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 = ""
|
||||
@@ -417,55 +527,231 @@ struct InvestmentWidgetProvider: TimelineProvider {
|
||||
goals: goals,
|
||||
currencyCode: currencyCode,
|
||||
insightTitle: insightTitle,
|
||||
insightValue: insightValue
|
||||
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)
|
||||
.foregroundColor(compact ? (entry.dayChange >= 0 ? .green : .red) : .secondary)
|
||||
}
|
||||
.foregroundColor(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)
|
||||
.foregroundColor(.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)
|
||||
.foregroundColor(checkInIsUrgent(date) ? .orange : .secondary)
|
||||
Text(checkInText(for: date))
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundColor(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)
|
||||
.foregroundColor(widgetSecondaryColor)
|
||||
Text(goal.name)
|
||||
.font(.caption.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Text("\(Int((pct * 100).rounded()))%")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundColor(widgetSecondaryColor)
|
||||
}
|
||||
ProgressView(value: pct)
|
||||
.tint(widgetSecondaryColor)
|
||||
HStack {
|
||||
Text(entry.totalValue.compactCurrencyString(currencyCode: entry.currencyCode))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Text(goal.targetAmount.compactCurrencyString(currencyCode: entry.currencyCode))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
} else if entry.nextMilestone > 0 {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Image(systemName: "flag.checkered")
|
||||
.font(.caption2)
|
||||
.foregroundColor(widgetSecondaryColor)
|
||||
Text("Next milestone")
|
||||
.font(.caption.weight(.semibold))
|
||||
Spacer()
|
||||
Text("\(Int((entry.milestoneProgress * 100).rounded()))%")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundColor(widgetSecondaryColor)
|
||||
}
|
||||
ProgressView(value: entry.milestoneProgress)
|
||||
.tint(widgetSecondaryColor)
|
||||
HStack {
|
||||
Spacer()
|
||||
Text(entry.nextMilestone.compactCurrencyString(currencyCode: entry.currencyCode))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
Group {
|
||||
if entry.hasData {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Total Value")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(entry.totalValue.compactCurrencyString(currencyCode: entry.currencyCode))
|
||||
.font(.title2.weight(.bold))
|
||||
.minimumScaleFactor(0.7)
|
||||
.lineLimit(1)
|
||||
Text(entry.totalValue.compactCurrencyString(currencyCode: entry.currencyCode))
|
||||
.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)
|
||||
ChangeLine(entry: entry, compact: true)
|
||||
Text("since last check-in")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(entry.dayChange.compactCurrencyString(currencyCode: entry.currencyCode))
|
||||
.font(.caption.weight(.medium))
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Text(String(format: "%.1f%% since last", entry.dayChangePercentage))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
HStack {
|
||||
if entry.streak >= 1 { StreakPill(streak: entry.streak) }
|
||||
Spacer()
|
||||
CheckInPill(date: entry.nextCheckInDate)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
EmptyStateView()
|
||||
}
|
||||
.foregroundColor(entry.dayChange >= 0 ? .green : .red)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.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)
|
||||
.foregroundColor(widgetPrimaryColor)
|
||||
Text("No data yet")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Add a snapshot to start tracking.")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.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
|
||||
// Left side - Total value + engagement metrics
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Portfolio")
|
||||
.font(.caption)
|
||||
@@ -476,18 +762,12 @@ struct MediumWidgetView: View {
|
||||
.minimumScaleFactor(0.7)
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: entry.dayChange >= 0 ? "arrow.up.right" : "arrow.down.right")
|
||||
.font(.caption2)
|
||||
ChangeLine(entry: entry)
|
||||
|
||||
Text(entry.dayChange.compactCurrencyString(currencyCode: entry.currencyCode))
|
||||
.font(.caption.weight(.medium))
|
||||
Spacer(minLength: 4)
|
||||
|
||||
Text("(\(String(format: "%.1f%%", entry.dayChangePercentage)))")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.foregroundColor(entry.dayChange >= 0 ? .green : .red)
|
||||
if entry.streak >= 1 { StreakPill(streak: entry.streak) }
|
||||
CheckInPill(date: entry.nextCheckInDate)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -574,7 +854,17 @@ struct LargeWidgetView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
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")
|
||||
@@ -586,27 +876,19 @@ struct LargeWidgetView: View {
|
||||
.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(currencyCode: entry.currencyCode))
|
||||
.font(.caption.weight(.medium))
|
||||
|
||||
Text("(\(String(format: "%.1f%%", entry.dayChangePercentage)))")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.foregroundColor(entry.dayChange >= 0 ? .green : .red)
|
||||
ChangeLine(entry: entry)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("Category Evolution")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.secondary)
|
||||
VStack(alignment: .trailing, spacing: 6) {
|
||||
if entry.streak >= 1 { StreakPill(streak: entry.streak) }
|
||||
CheckInPill(date: entry.nextCheckInDate)
|
||||
}
|
||||
}
|
||||
|
||||
GoalProgressRow(entry: entry)
|
||||
|
||||
if entry.isPremium {
|
||||
if hasCategoryTrend {
|
||||
CombinedCategoryChartView(
|
||||
@@ -615,10 +897,10 @@ struct LargeWidgetView: View {
|
||||
goal: entry.goals.first,
|
||||
currencyCode: entry.currencyCode
|
||||
)
|
||||
.frame(height: 98)
|
||||
.frame(height: 84)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(entry.categoryTotals.prefix(4), id: \.name) { category in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(entry.categoryTotals.prefix(3), id: \.name) { category in
|
||||
HStack {
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(Color(hex: category.color) ?? .gray)
|
||||
@@ -667,21 +949,49 @@ 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))
|
||||
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 {
|
||||
@@ -929,7 +1239,8 @@ struct InvestmentWidget: Widget {
|
||||
.systemMedium,
|
||||
.systemLarge,
|
||||
.accessoryCircular,
|
||||
.accessoryRectangular
|
||||
.accessoryRectangular,
|
||||
.accessoryInline
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -957,6 +1268,9 @@ struct InvestmentWidgetEntryView: View {
|
||||
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")!)
|
||||
@@ -992,7 +1306,13 @@ struct PortfolioJournalWidgetBundle: WidgetBundle {
|
||||
goals: [],
|
||||
currencyCode: "EUR",
|
||||
insightTitle: "",
|
||||
insightValue: ""
|
||||
insightValue: "",
|
||||
streak: 5,
|
||||
nextCheckInDate: Calendar.current.date(byAdding: .day, value: 8, to: Date()),
|
||||
nearestGoal: nil,
|
||||
nearestGoalProgress: 0,
|
||||
milestoneProgress: 0.5,
|
||||
nextMilestone: 100000
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1017,7 +1337,13 @@ struct PortfolioJournalWidgetBundle: WidgetBundle {
|
||||
goals: [],
|
||||
currencyCode: "EUR",
|
||||
insightTitle: "Year to date",
|
||||
insightValue: "+12.4%"
|
||||
insightValue: "+12.4%",
|
||||
streak: 7,
|
||||
nextCheckInDate: Calendar.current.date(byAdding: .day, value: 2, to: Date()),
|
||||
nearestGoal: nil,
|
||||
nearestGoalProgress: 0,
|
||||
milestoneProgress: 0.5,
|
||||
nextMilestone: 100000
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1066,11 +1392,17 @@ struct PortfolioJournalWidgetBundle: WidgetBundle {
|
||||
("Real Estate", 18000, "#F59E0B")
|
||||
],
|
||||
goals: [
|
||||
GoalSummary(name: "Target", targetAmount: 120000, targetDate: nil)
|
||||
GoalSummary(name: "House deposit", targetAmount: 120000, targetDate: nil)
|
||||
],
|
||||
currencyCode: "EUR",
|
||||
insightTitle: "Milestone ahead",
|
||||
insightValue: "€5K from €100K"
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user