1.4.1 (build 42): contribución por snapshot, fixes Period vs Period y navegación iPad

- 151: contribución editable por snapshot en cualquier modo (quitado gate detailed)
  con diálogo de propagación al editar: solo este / adelante / atrás / todos
  (SnapshotRepository.propagateContribution, SnapshotFormViewModel.contributionChanged)
- 148: Period vs Period mostraba mal el último mes del period B — la agrupación usaba
  chartMonth(for:) que aplicaba el grace-period del check-in a snapshots históricos;
  ahora agrupa por mes calendario crudo
- 152: iPad Sources no saltaba entre fuentes — añadido .id() al SourceDetailView para
  recrear el StateObject al cambiar de selección
- 146/147: Year vs Year con forecast del año en curso (asterisco de estimado) y KPIs
  arriba / detalle debajo
- 145: card de contribución mensual en SourceDetailView
- Nuevas claves snapshot_contribution_propagate_* en los 7 idiomas

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
This commit is contained in:
alexandrev-tibco
2026-06-30 16:51:03 +02:00
parent 54dfd8a8e3
commit 7a18dd8360
48 changed files with 2854 additions and 608 deletions
@@ -0,0 +1,139 @@
import SwiftUI
// MARK: - Stat definition
struct ChartStat {
let label: String
let value: String
let color: Color
}
// MARK: - Stats summary row (horizontal scroll of chips)
struct ChartStatsRow: View {
let stats: [ChartStat]
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(stats.indices, id: \.self) { i in
VStack(alignment: .center, spacing: 3) {
Text(stats[i].label)
.font(.caption2)
.foregroundColor(.secondary)
Text(stats[i].value)
.font(.subheadline.weight(.semibold))
.foregroundColor(stats[i].color)
}
.frame(minWidth: 60)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(.systemGray6))
.cornerRadius(8)
}
}
}
}
}
// MARK: - Data table row model
struct ChartDataTableRow: Identifiable {
let id = UUID()
let label: String
let value: String
let deltaPrev: String?
let deltaFirst: String?
let isPrevPositive: Bool
let isFirstPositive: Bool
}
// MARK: - Expandable data table
struct ChartDataTable: View {
let rows: [ChartDataTableRow]
let valueHeader: String
let deltaPrevHeader: String?
let deltaFirstHeader: String?
@State private var isExpanded = false
private let previewCount = 5
var body: some View {
VStack(spacing: 0) {
tableHeader
Divider()
let displayed = isExpanded ? rows : Array(rows.suffix(previewCount))
ForEach(displayed) { row in
tableDataRow(row)
if row.id != displayed.last?.id {
Divider().opacity(0.35)
}
}
if rows.count > previewCount {
Divider().opacity(0.35)
Button {
withAnimation(.easeInOut(duration: 0.15)) { isExpanded.toggle() }
} label: {
HStack(spacing: 4) {
Text(isExpanded ? "Show less" : "Show all \(rows.count)")
.font(.caption)
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption2)
}
.foregroundColor(.appPrimary)
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
}
}
.background(Color(.systemGray6))
.cornerRadius(8)
}
private var tableHeader: some View {
HStack(spacing: 0) {
Text("Date").font(.caption2.weight(.semibold)).foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
Text(valueHeader).font(.caption2.weight(.semibold)).foregroundColor(.secondary)
.frame(width: 72, alignment: .trailing)
if let h = deltaPrevHeader {
Text(h).font(.caption2.weight(.semibold)).foregroundColor(.secondary)
.frame(width: 62, alignment: .trailing)
}
if let h = deltaFirstHeader {
Text(h).font(.caption2.weight(.semibold)).foregroundColor(.secondary)
.frame(width: 62, alignment: .trailing)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
}
private func tableDataRow(_ row: ChartDataTableRow) -> some View {
HStack(spacing: 0) {
Text(row.label).font(.caption2).foregroundColor(.primary)
.frame(maxWidth: .infinity, alignment: .leading)
Text(row.value).font(.caption2.weight(.medium))
.frame(width: 72, alignment: .trailing)
if let dp = row.deltaPrev, deltaPrevHeader != nil {
Text(dp).font(.caption2.weight(.medium))
.foregroundColor(row.isPrevPositive ? .positiveGreen : .negativeRed)
.frame(width: 62, alignment: .trailing)
}
if let df = row.deltaFirst, deltaFirstHeader != nil {
Text(df).font(.caption2.weight(.medium))
.foregroundColor(row.isFirstPositive ? .positiveGreen : .negativeRed)
.frame(width: 62, alignment: .trailing)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 5)
}
}
// MARK: - Compact date formatter
private let tableMonthFormatter: DateFormatter = {
let f = DateFormatter()
f.setLocalizedDateFormatFromTemplate("MMMy")
return f
}()
func chartTableDateLabel(_ date: Date) -> String {
tableMonthFormatter.string(from: date)
}
@@ -26,6 +26,7 @@ struct ChartsContainerView: View {
.sheet(isPresented: $viewModel.showingPaywall) { PaywallView() }
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) { accountFilterMenu }
ToolbarItem(placement: .navigationBarLeading) { focusModeButton }
}
.onAppear { syncState() }
.onChange(of: accountStore.selectedAccount) { _, newAccount in
@@ -46,6 +47,26 @@ struct ChartsContainerView: View {
}
}
// MARK: - Focus Mode Button
private var focusModeButton: some View {
Button {
calmModeEnabled.toggle()
} label: {
HStack(spacing: 4) {
Image(systemName: calmModeEnabled ? "moon.stars.fill" : "moon.stars")
Text(calmModeEnabled ? "Focus" : "Full")
.font(.caption.weight(.semibold))
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(calmModeEnabled ? Color.appPrimary.opacity(0.12) : Color.gray.opacity(0.1))
.foregroundColor(calmModeEnabled ? .appPrimary : .secondary)
.cornerRadius(14)
}
.buttonStyle(.plain)
}
// MARK: - iPad Layout (sidebar + full-width chart)
private var iPadChartsLayout: some View {
@@ -66,6 +87,35 @@ struct ChartsContainerView: View {
iPadChartTypeRow(chartType)
}
Divider().padding(.vertical, 8)
// Focus Mode row in sidebar (Button avoids Toggle gesture conflicts in ScrollView)
Button {
calmModeEnabled.toggle()
} label: {
HStack(spacing: 10) {
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(calmModeEnabled ? Color.appPrimary : Color(.systemGray5))
.frame(width: 32, height: 32)
Image(systemName: calmModeEnabled ? "moon.stars.fill" : "moon.stars")
.font(.system(size: 14))
.foregroundColor(calmModeEnabled ? .white : .primary)
}
Text("Focus Mode")
.font(.subheadline)
.foregroundColor(.primary)
Spacer()
Image(systemName: calmModeEnabled ? "checkmark.circle.fill" : "circle")
.foregroundColor(calmModeEnabled ? .appPrimary : .secondary)
.font(.system(size: 16))
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(calmModeEnabled ? Color.appPrimary.opacity(0.06) : Color.clear)
}
.buttonStyle(.plain)
if hasAnyFilter {
Divider().padding(.vertical, 12)
filtersSection
@@ -209,13 +259,28 @@ struct ChartsContainerView: View {
VStack(alignment: .leading, spacing: 10) {
let chartType = viewModel.selectedChartType
// Time Range
if chartType != .allocation && chartType != .riskReturn {
// Time Range (not for performance uses slider)
if chartType != .allocation && chartType != .riskReturn && chartType != .performance {
filterRow(icon: "calendar", label: "Period") {
timeRangeSelector
}
}
// Performance: custom period slider
if chartType == .performance {
filterRow(icon: "calendar.badge.clock", label: "Period: \(periodLabel(viewModel.performancePeriodMonths))") {
Slider(
value: Binding(
get: { Double(viewModel.performancePeriodMonths) },
set: { viewModel.performancePeriodMonths = Int($0) }
),
in: 1...60,
step: 1
)
.tint(.appPrimary)
}
}
// Breakdown (category vs source)
if chartType == .allocation || chartType == .performance {
filterRow(icon: "square.grid.2x2", label: "Group") {
@@ -449,6 +514,16 @@ struct ChartsContainerView: View {
}
}
private func periodLabel(_ months: Int) -> String {
if months < 12 {
return "\(months)M"
} else if months % 12 == 0 {
return "\(months / 12)Y"
} else {
return "\(months / 12)Y \(months % 12)M"
}
}
// MARK: - Chart Content
@ViewBuilder
@@ -501,7 +576,7 @@ struct ChartsContainerView: View {
historicalData: viewModel.evolutionData
)
case .yearOverYear:
YearOverYearChartView(series: viewModel.yearOverYearData)
YearOverYearChartView(viewModel: viewModel)
case .comparison:
ComparisonChartView(viewModel: viewModel)
case .simulator:
@@ -696,6 +771,9 @@ struct EvolutionChartView: View {
headerView
modePicker
chartSection
if !data.isEmpty && chartMode == .total {
ChartStatsRow(stats: evolutionStats)
}
}
.padding()
.background(Color(.systemBackground))
@@ -703,6 +781,23 @@ struct EvolutionChartView: View {
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private var evolutionStats: [ChartStat] {
guard !data.isEmpty else { return [] }
let values = data.map { NSDecimalNumber(decimal: $0.value).doubleValue }
let latest = values.last ?? 0
let first = values.first ?? 0
let change = latest - first
let changePct = first > 0 ? (change / first) * 100 : 0
let maxVal = values.max() ?? 0
let minVal = values.min() ?? 0
return [
ChartStat(label: "Latest", value: Decimal(latest).compactCurrencyString, color: .appPrimary),
ChartStat(label: "Change", value: String(format: "%+.1f%%", changePct), color: changePct >= 0 ? .positiveGreen : .negativeRed),
ChartStat(label: "Max", value: Decimal(maxVal).compactCurrencyString, color: .positiveGreen),
ChartStat(label: "Min", value: Decimal(minVal).compactCurrencyString, color: .negativeRed),
]
}
private var headerView: some View {
HStack {
Text("Portfolio Evolution")
@@ -937,6 +1032,7 @@ struct ContributionsChartView: View {
}
}
.frame(height: 260)
ChartStatsRow(stats: contributionStats).padding(.top, 4)
}
}
.padding()
@@ -944,6 +1040,21 @@ struct ContributionsChartView: View {
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private var contributionStats: [ChartStat] {
guard !data.isEmpty else { return [] }
let amounts = data.map { NSDecimalNumber(decimal: $0.amount).doubleValue }
let total = amounts.reduce(0, +)
let avg = total / Double(amounts.count)
let highest = amounts.max() ?? 0
let last = amounts.last ?? 0
return [
ChartStat(label: "Total", value: Decimal(total).compactCurrencyString, color: .primary),
ChartStat(label: "Monthly avg", value: Decimal(avg).compactCurrencyString, color: .secondary),
ChartStat(label: "Highest", value: Decimal(highest).compactCurrencyString, color: .positiveGreen),
ChartStat(label: "Last", value: Decimal(last).compactCurrencyString, color: .appPrimary),
]
}
}
// MARK: - Rolling 12-Month Return
@@ -994,6 +1105,13 @@ struct RollingReturnChartView: View {
}
}
.frame(height: 260)
ChartStatsRow(stats: rollingStats).padding(.top, 4)
ChartDataTable(
rows: rollingTableRows,
valueHeader: "Return",
deltaPrevHeader: "Δ prev",
deltaFirstHeader: "Δ first"
)
}
}
.padding()
@@ -1001,6 +1119,41 @@ struct RollingReturnChartView: View {
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private var rollingStats: [ChartStat] {
guard !data.isEmpty else { return [] }
let values = data.map { $0.value }
let latest = values.last ?? 0
let best = values.max() ?? 0
let worst = values.min() ?? 0
let avg = values.reduce(0, +) / Double(values.count)
let change = (values.last ?? 0) - (values.first ?? 0)
return [
ChartStat(label: "Latest", value: String(format: "%.1f%%", latest), color: latest >= 0 ? .positiveGreen : .negativeRed),
ChartStat(label: "Best", value: String(format: "%.1f%%", best), color: .positiveGreen),
ChartStat(label: "Worst", value: String(format: "%.1f%%", worst), color: .negativeRed),
ChartStat(label: "Average", value: String(format: "%.1f%%", avg), color: .secondary),
ChartStat(label: "Change", value: String(format: "%+.1f%%", change), color: change >= 0 ? .positiveGreen : .negativeRed),
]
}
private var rollingTableRows: [ChartDataTableRow] {
guard !data.isEmpty else { return [] }
let firstVal = data.first?.value ?? 0
return data.enumerated().map { idx, point in
let prevVal = idx > 0 ? data[idx - 1].value : point.value
let deltaPrev = point.value - prevVal
let deltaFirst = point.value - firstVal
return ChartDataTableRow(
label: chartTableDateLabel(point.date),
value: String(format: "%.1f%%", point.value),
deltaPrev: idx > 0 ? String(format: "%+.1f%%", deltaPrev) : nil,
deltaFirst: idx > 0 ? String(format: "%+.1f%%", deltaFirst) : nil,
isPrevPositive: deltaPrev >= 0,
isFirstPositive: deltaFirst >= 0
)
}
}
}
// MARK: - Risk vs Return
@@ -1115,6 +1268,7 @@ struct CashflowStackedChartView: View {
}
}
.frame(height: 260)
ChartStatsRow(stats: cashflowStats).padding(.top, 4)
}
}
.padding()
@@ -1122,6 +1276,18 @@ struct CashflowStackedChartView: View {
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private var cashflowStats: [ChartStat] {
guard !data.isEmpty else { return [] }
let totalContrib = data.reduce(Decimal.zero) { $0 + $1.contributions }
let totalPerf = data.reduce(Decimal.zero) { $0 + $1.netPerformance }
let net = totalContrib + totalPerf
return [
ChartStat(label: "Contributions", value: totalContrib.compactCurrencyString, color: .appSecondary),
ChartStat(label: "Market returns", value: totalPerf.compactCurrencyString, color: totalPerf >= 0 ? .positiveGreen : .negativeRed),
ChartStat(label: "Net total", value: net.compactCurrencyString, color: net >= 0 ? .positiveGreen : .negativeRed),
]
}
}
// MARK: - Allocation Evolution Chart
@@ -4,7 +4,6 @@ import Charts
struct ComparisonChartView: View {
@ObservedObject var viewModel: ChartsViewModel
private let palette: [Color] = [.blue, .orange, .green, .purple, .red, .teal]
var body: some View {
VStack(alignment: .leading, spacing: 16) {
@@ -35,7 +34,7 @@ struct ComparisonChartView: View {
HStack(spacing: 8) {
ForEach(Array(viewModel.comparisonAvailableSources.enumerated()), id: \.element.id) { index, source in
let isSelected = viewModel.comparisonSelectedSourceIds.contains(source.id)
let chipColor = palette[index % palette.count]
let chipColor = Color(hex: ChartsViewModel.sourceColorHexesPublic[index % ChartsViewModel.sourceColorHexesPublic.count]) ?? .appPrimary
Button {
if isSelected {
viewModel.comparisonSelectedSourceIds.remove(source.id)
@@ -106,8 +105,8 @@ struct ComparisonChartView: View {
private var lineChart: some View {
VStack(spacing: 12) {
Chart {
ForEach(Array(viewModel.comparisonData.enumerated()), id: \.element.id) { index, series in
let seriesColor = palette[index % palette.count]
ForEach(viewModel.comparisonData, id: \.id) { series in
let seriesColor = Color(hex: series.colorHex) ?? .appPrimary
ForEach(series.points, id: \.date) { point in
LineMark(
x: .value("Date", point.date),
@@ -149,10 +148,10 @@ struct ComparisonChartView: View {
private var legend: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(Array(viewModel.comparisonData.enumerated()), id: \.element.id) { index, series in
ForEach(viewModel.comparisonData, id: \.id) { series in
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 2)
.fill(palette[index % palette.count])
.fill(Color(hex: series.colorHex) ?? .appPrimary)
.frame(width: 20, height: 3)
Text(series.name)
.font(.caption)
@@ -183,6 +182,8 @@ struct ComparisonChartView: View {
return String(format: "%.1f%%", value)
case .absolute:
return Decimal(value).shortCurrencyString
case .monthlyReturn:
return String(format: "%.1f%%", value)
}
}
}
@@ -96,6 +96,13 @@ struct DrawdownChart: View {
)
}
.padding(.top, 8)
ChartDataTable(
rows: drawdownTableRows,
valueHeader: "Drawdown",
deltaPrevHeader: "Δ prev",
deltaFirstHeader: "Δ first"
)
.padding(.top, 4)
} else {
Text("Not enough data for drawdown analysis")
.foregroundColor(.secondary)
@@ -114,6 +121,24 @@ struct DrawdownChart: View {
let sum = data.reduce(0.0) { $0 + abs($1.drawdown) }
return sum / Double(data.count)
}
private var drawdownTableRows: [ChartDataTableRow] {
data.enumerated().map { idx, point in
let val = abs(point.drawdown)
let prevVal = idx > 0 ? abs(data[idx - 1].drawdown) : val
let firstVal = abs(data.first?.drawdown ?? val)
let deltaPrev = -(val - prevVal) // negative delta = improvement (less drawdown)
let deltaFirst = -(val - firstVal)
return ChartDataTableRow(
label: chartTableDateLabel(point.date),
value: String(format: "%.1f%%", val),
deltaPrev: idx > 0 ? String(format: "%+.1f%%", deltaPrev) : nil,
deltaFirst: idx > 0 ? String(format: "%+.1f%%", deltaFirst) : nil,
isPrevPositive: deltaPrev >= 0,
isFirstPositive: deltaFirst >= 0
)
}
}
}
struct DrawdownStatView: View {
@@ -156,15 +181,6 @@ struct VolatilityChartView: View {
.font(.headline)
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("Current")
.font(.caption)
.foregroundColor(.secondary)
Text(String(format: "%.1f%%", currentVolatility))
.font(.subheadline.weight(.semibold))
.foregroundColor(volatilityColor(currentVolatility))
}
}
Text("Measures price variability over time")
@@ -221,13 +237,19 @@ struct VolatilityChartView: View {
}
.frame(height: 250)
// Volatility interpretation
HStack(spacing: 16) {
VolatilityLevelView(level: "Low", range: "0-10%", color: .positiveGreen)
VolatilityLevelView(level: "Medium", range: "10-20%", color: .appWarning)
VolatilityLevelView(level: "High", range: "20%+", color: .negativeRed)
}
ChartStatsRow(stats: [
ChartStat(label: "Current", value: String(format: "%.1f%%", currentVolatility), color: volatilityColor(currentVolatility)),
ChartStat(label: "Max", value: String(format: "%.1f%%", data.map { $0.volatility }.max() ?? 0), color: .negativeRed),
ChartStat(label: "Min", value: String(format: "%.1f%%", data.map { $0.volatility }.min() ?? 0), color: .positiveGreen),
ChartStat(label: "Average", value: String(format: "%.1f%%", averageVolatility), color: .secondary),
])
.padding(.top, 8)
ChartDataTable(
rows: volatilityTableRows,
valueHeader: "Volatility",
deltaPrevHeader: "Δ prev",
deltaFirstHeader: nil
)
} else {
Text("Not enough data for volatility analysis")
.foregroundColor(.secondary)
@@ -251,6 +273,21 @@ struct VolatilityChartView: View {
return .negativeRed
}
}
private var volatilityTableRows: [ChartDataTableRow] {
data.enumerated().map { idx, point in
let prevVal = idx > 0 ? data[idx - 1].volatility : point.volatility
let delta = point.volatility - prevVal
return ChartDataTableRow(
label: chartTableDateLabel(point.date),
value: String(format: "%.1f%%", point.volatility),
deltaPrev: idx > 0 ? String(format: "%+.1f%%", delta) : nil,
deltaFirst: nil,
isPrevPositive: delta <= 0,
isFirstPositive: false
)
}
}
}
struct VolatilityLevelView: View {
@@ -14,6 +14,10 @@ struct PerformanceBarChart: View {
.font(.caption)
.foregroundColor(.secondary)
if !data.isEmpty {
ChartStatsRow(stats: perfStats)
}
if !data.isEmpty {
Chart(data, id: \.category) { item in
BarMark(
@@ -84,6 +88,19 @@ struct PerformanceBarChart: View {
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private var perfStats: [ChartStat] {
guard !data.isEmpty else { return [] }
let values = data.map { $0.cagr }
let best = values.max() ?? 0
let worst = values.min() ?? 0
let avg = values.reduce(0, +) / Double(values.count)
return [
ChartStat(label: "Best", value: String(format: "%.1f%%", best), color: .positiveGreen),
ChartStat(label: "Worst", value: String(format: "%.1f%%", worst), color: .negativeRed),
ChartStat(label: "Average", value: String(format: "%.1f%%", avg), color: .secondary),
]
}
}
// MARK: - Horizontal Bar Version
@@ -4,6 +4,9 @@ import Charts
struct PeriodComparisonChartView: View {
@ObservedObject var viewModel: ChartsViewModel
private let colorA = Color(hex: "#3478F6") ?? .blue
private let colorB = Color(hex: "#FF9500") ?? .orange
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Period vs Period")
@@ -21,39 +24,37 @@ struct PeriodComparisonChartView: View {
// MARK: - Period Pickers
private var periodPickersSection: some View {
VStack(spacing: 10) {
periodRow(
label: "Period A",
color: Color(hex: "#3478F6") ?? .blue,
start: $viewModel.periodAStart,
end: $viewModel.periodAEnd
)
periodRow(
label: "Period B",
color: Color(hex: "#FF9500") ?? .orange,
start: $viewModel.periodBStart,
end: $viewModel.periodBEnd
)
VStack(spacing: 8) {
periodRow(label: "Period A", color: colorA, start: $viewModel.periodAStart, end: $viewModel.periodAEnd)
Divider()
.background(Color.secondary.opacity(0.15))
periodRow(label: "Period B", color: colorB, start: $viewModel.periodBStart, end: $viewModel.periodBEnd)
}
.padding(12)
.background(Color(.systemGray6))
.cornerRadius(10)
.background(
LinearGradient(
colors: [colorA.opacity(0.06), colorB.opacity(0.06)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.cornerRadius(12)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.secondary.opacity(0.1), lineWidth: 1)
)
}
private func periodRow(
label: String,
color: Color,
start: Binding<Date>,
end: Binding<Date>
) -> some View {
HStack(spacing: 8) {
Circle()
private func periodRow(label: String, color: Color, start: Binding<Date>, end: Binding<Date>) -> some View {
HStack(spacing: 10) {
RoundedRectangle(cornerRadius: 2)
.fill(color)
.frame(width: 8, height: 8)
.frame(width: 3, height: 30)
Text(label)
.font(.caption.weight(.semibold))
.font(.caption.weight(.bold))
.foregroundColor(color)
.frame(width: 58, alignment: .leading)
.frame(width: 60, alignment: .leading)
DatePicker(
"",
@@ -67,7 +68,7 @@ struct PeriodComparisonChartView: View {
.labelsHidden()
.frame(maxWidth: .infinity)
Text("")
Text("")
.font(.caption)
.foregroundColor(.secondary)
@@ -92,13 +93,73 @@ struct PeriodComparisonChartView: View {
if viewModel.periodComparisonData.isEmpty {
emptyView
} else {
VStack(spacing: 12) {
VStack(spacing: 16) {
numericalSummary
periodChart
legend
}
}
}
private var numericalSummary: some View {
HStack(spacing: 12) {
ForEach(viewModel.periodComparisonData) { series in
let color = Color(hex: series.colorHex) ?? .gray
let finalReturn = series.points.last?.returnPct ?? 0
let maxReturn = series.points.map { $0.returnPct }.max() ?? 0
let minReturn = series.points.map { $0.returnPct }.min() ?? 0
HStack(spacing: 0) {
Rectangle()
.fill(color)
.frame(width: 4)
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 4) {
Circle()
.fill(color)
.frame(width: 6, height: 6)
Text(series.label)
.font(.caption2.weight(.medium))
.foregroundColor(.secondary)
.lineLimit(1)
}
Text(String(format: "%+.2f%%", finalReturn))
.font(.title2.weight(.bold))
.foregroundColor(finalReturn >= 0 ? .positiveGreen : .negativeRed)
HStack(spacing: 10) {
HStack(spacing: 3) {
Image(systemName: "arrow.up")
.font(.caption2.weight(.semibold))
.foregroundColor(.positiveGreen)
Text(String(format: "%.1f%%", maxReturn))
.font(.caption2)
.foregroundColor(.positiveGreen)
}
HStack(spacing: 3) {
Image(systemName: "arrow.down")
.font(.caption2.weight(.semibold))
.foregroundColor(.negativeRed)
Text(String(format: "%.1f%%", minReturn))
.font(.caption2)
.foregroundColor(.negativeRed)
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
}
.background(color.opacity(0.07))
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(color.opacity(0.15), lineWidth: 1)
)
}
}
}
private var emptyView: some View {
VStack(spacing: 12) {
Image(systemName: "calendar.badge.clock")
@@ -148,21 +209,28 @@ struct PeriodComparisonChartView: View {
Color(hex: $0.colorHex) ?? .gray
}
return Chart(points) { point in
LineMark(
x: .value("Month", point.monthOffset),
y: .value("Return", point.returnPct),
series: .value("Period", point.seriesLabel)
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Period", point.seriesLabel))
return Chart {
ForEach(points) { point in
LineMark(
x: .value("Month", point.monthOffset),
y: .value("Return", point.returnPct),
series: .value("Period", point.seriesLabel)
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Period", point.seriesLabel))
.lineStyle(StrokeStyle(lineWidth: 2.5))
PointMark(
x: .value("Month", point.monthOffset),
y: .value("Return", point.returnPct)
)
.foregroundStyle(by: .value("Period", point.seriesLabel))
.symbolSize(20)
PointMark(
x: .value("Month", point.monthOffset),
y: .value("Return", point.returnPct)
)
.foregroundStyle(by: .value("Period", point.seriesLabel))
.symbolSize(30)
}
RuleMark(y: .value("Zero", 0))
.foregroundStyle(Color.secondary.opacity(0.4))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 4]))
}
.chartForegroundStyleScale(
domain: viewModel.periodComparisonData.map { $0.label },
@@ -194,23 +262,22 @@ struct PeriodComparisonChartView: View {
}
.frame(height: 260)
.drawingGroup()
.onChange(of: seriesIds) { _, _ in } // silence unused warning
.onChange(of: seriesIds) { _, _ in }
}
private var legend: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(viewModel.periodComparisonData) { series in
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 2)
.fill(Color(hex: series.colorHex) ?? .gray)
.frame(width: 20, height: 3)
Text(series.label)
.font(.caption)
.foregroundColor(.secondary)
}
HStack(spacing: 16) {
ForEach(viewModel.periodComparisonData) { series in
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 2)
.fill(Color(hex: series.colorHex) ?? .gray)
.frame(width: 20, height: 3)
Text(series.label)
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
}
}
@@ -77,6 +77,28 @@ struct PredictionChartView: View {
.symbolSize(24)
}
// Bull scenario line (upper confidence interval)
ForEach(predictions) { prediction in
LineMark(
x: .value("Date", prediction.date),
y: .value("Bull", NSDecimalNumber(decimal: prediction.confidenceInterval.upper).doubleValue),
series: .value("Scenario", "bull")
)
.foregroundStyle(Color.positiveGreen.opacity(0.7))
.lineStyle(StrokeStyle(lineWidth: 1.5, dash: [4, 3]))
}
// Bear scenario line (lower confidence interval)
ForEach(predictions) { prediction in
LineMark(
x: .value("Date", prediction.date),
y: .value("Bear", NSDecimalNumber(decimal: prediction.confidenceInterval.lower).doubleValue),
series: .value("Scenario", "bear")
)
.foregroundStyle(Color.negativeRed.opacity(0.7))
.lineStyle(StrokeStyle(lineWidth: 1.5, dash: [4, 3]))
}
// Connect historical to prediction
if let lastHistorical = historicalData.last,
let firstPrediction = predictions.first {
@@ -114,6 +136,17 @@ struct PredictionChartView: View {
}
.frame(height: 280)
// Stats row
if let currentValue = historicalData.last?.value,
let lastPred = predictions.last {
ChartStatsRow(stats: [
ChartStat(label: "Current", value: currentValue.compactCurrencyString, color: .appPrimary),
ChartStat(label: "Base (\(predictions.count)M)", value: lastPred.predictedValue.compactCurrencyString, color: .appSecondary),
ChartStat(label: "Bull case", value: lastPred.confidenceInterval.upper.compactCurrencyString, color: .positiveGreen),
ChartStat(label: "Bear case", value: lastPred.confidenceInterval.lower.compactCurrencyString, color: .negativeRed),
])
}
// Legend
HStack(spacing: 20) {
HStack(spacing: 6) {
@@ -150,6 +183,24 @@ struct PredictionChartView: View {
.font(.caption)
.foregroundColor(.secondary)
}
HStack(spacing: 6) {
Rectangle()
.fill(Color.positiveGreen.opacity(0.7))
.frame(width: 20, height: 2)
Text("Bull")
.font(.caption)
.foregroundColor(.secondary)
}
HStack(spacing: 6) {
Rectangle()
.fill(Color.negativeRed.opacity(0.7))
.frame(width: 20, height: 2)
Text("Bear")
.font(.caption)
.foregroundColor(.secondary)
}
}
// Prediction details
@@ -195,6 +246,14 @@ struct PredictionChartView: View {
}
}
}
Divider().padding(.vertical, 4)
ChartDataTable(
rows: predictionTableRows,
valueHeader: "Base",
deltaPrevHeader: "Bull",
deltaFirstHeader: "Bear"
)
}
} else {
VStack(spacing: 12) {
@@ -220,6 +279,19 @@ struct PredictionChartView: View {
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private var predictionTableRows: [ChartDataTableRow] {
predictions.map { pred in
ChartDataTableRow(
label: chartTableDateLabel(pred.date),
value: pred.predictedValue.compactCurrencyString,
deltaPrev: pred.confidenceInterval.upper.compactCurrencyString,
deltaFirst: pred.confidenceInterval.lower.compactCurrencyString,
isPrevPositive: true,
isFirstPositive: false
)
}
}
}
#Preview {
@@ -0,0 +1,278 @@
import SwiftUI
import Charts
struct YearOverYearChartView: View {
@ObservedObject var viewModel: ChartsViewModel
private let monthAbbreviations = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
private let lineColors: [Color] = [
.appPrimary, .appSecondary, .orange, .purple, .pink, .teal
]
private var allYears: [ChartsViewModel.YearSeries] { viewModel.yearOverYearData }
private var selectedSeries: [ChartsViewModel.YearSeries] {
allYears.filter { viewModel.yoySelectedYears.contains($0.year) }
.sorted { $0.year < $1.year }
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text(String(localized: "chart_yoy_title"))
.font(.headline)
if allYears.isEmpty {
Text(String(localized: "chart_yoy_empty"))
.font(.subheadline)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.frame(height: 200)
} else {
yearSelector
if !selectedSeries.isEmpty {
// KPIs arriba del todo (#147)
yearEndStatsRow
if selectedSeries.count == 2 {
comparisonStatsHeader
}
if hasEstimate {
Text(String(localized: "chart_yoy_estimated_note"))
.font(.caption2)
.foregroundColor(.secondary)
}
// Detalle debajo (#147)
chartBody
legend
if selectedSeries.count == 2 {
comparisonTable
}
}
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
// MARK: - Year Selector
private var yearSelector: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(allYears) { ys in
let idx = allYears.firstIndex(where: { $0.id == ys.id }) ?? 0
let color = lineColors[idx % lineColors.count]
let isSelected = viewModel.yoySelectedYears.contains(ys.year)
Button {
if isSelected {
viewModel.yoySelectedYears.remove(ys.year)
} else {
viewModel.yoySelectedYears.insert(ys.year)
}
} label: {
Text(String(ys.year))
.font(.caption.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(isSelected ? color.opacity(0.15) : Color.gray.opacity(0.1))
.foregroundColor(isSelected ? color : .secondary)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(isSelected ? color : Color.clear, lineWidth: 1.5)
)
.cornerRadius(16)
}
.buttonStyle(.plain)
}
}
}
}
// MARK: - Chart
@ViewBuilder
private var chartBody: some View {
if #available(iOS 16.0, *) {
Chart {
ForEach(selectedSeries) { yearSeries in
let idx = allYears.firstIndex(where: { $0.id == yearSeries.id }) ?? 0
let color = lineColors[idx % lineColors.count]
ForEach(0..<12, id: \.self) { monthIdx in
let value = yearSeries.values[monthIdx]
if !value.isNaN {
LineMark(
x: .value("Month", monthIdx),
y: .value("Return", value),
series: .value("Year", String(yearSeries.year))
)
.foregroundStyle(color)
.interpolationMethod(.catmullRom)
}
}
}
}
.chartXAxis {
AxisMarks(values: Array(0..<12)) { value in
AxisValueLabel {
if let idx = value.as(Int.self) {
Text(monthAbbreviations[idx])
.font(.caption2)
}
}
}
}
.chartYAxis {
AxisMarks { value in
AxisValueLabel {
if let d = value.as(Double.self) {
Text(String(format: "%+.1f%%", d))
.font(.caption2)
}
}
AxisGridLine()
}
}
.frame(height: 220)
} else {
Text("iOS 16+ required for chart")
.foregroundColor(.secondary)
.frame(height: 220)
}
}
// MARK: - Legend
private var legend: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(selectedSeries) { yearSeries in
let idx = allYears.firstIndex(where: { $0.id == yearSeries.id }) ?? 0
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 2)
.fill(lineColors[idx % lineColors.count])
.frame(width: 20, height: 3)
Text(String(yearSeries.year))
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
}
// MARK: - Year-end stats chips
private var yearEndStatsRow: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(selectedSeries) { yearSeries in
let idx = allYears.firstIndex(where: { $0.id == yearSeries.id }) ?? 0
let color = lineColors[idx % lineColors.count]
let end = endValue(yearSeries)
VStack(alignment: .center, spacing: 3) {
Text(String(yearSeries.year))
.font(.caption2)
.foregroundColor(.secondary)
Text(String(format: "%+.1f%%", end.value) + (end.estimated ? "*" : ""))
.font(.subheadline.weight(.semibold))
.foregroundColor(end.value >= 0 ? color : .negativeRed)
}
.frame(minWidth: 60)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(.systemGray6))
.cornerRadius(8)
}
}
}
}
// MARK: - 2-year comparison: KPIs (top) and table (bottom)
@ViewBuilder
private var comparisonStatsHeader: some View {
let yearA = selectedSeries[0]
let yearB = selectedSeries[1]
let diffs = monthDiffs(yearA: yearA, yearB: yearB)
let validDiffs = diffs.compactMap { $0 }
let avgDiff = validDiffs.isEmpty ? 0 : validDiffs.reduce(0, +) / Double(validDiffs.count)
let endAInfo = endValue(yearA)
let endBInfo = endValue(yearB)
let endDiff = endBInfo.value - endAInfo.value
let endEstimated = endAInfo.estimated || endBInfo.estimated
let bestDiff = validDiffs.max() ?? 0
let worstDiff = validDiffs.min() ?? 0
VStack(alignment: .leading, spacing: 8) {
Text("\(yearA.year) vs \(yearB.year)")
.font(.subheadline.weight(.semibold))
ChartStatsRow(stats: [
ChartStat(label: "Avg diff", value: String(format: "%+.1f%%", avgDiff),
color: avgDiff >= 0 ? .positiveGreen : .negativeRed),
ChartStat(label: "End diff", value: String(format: "%+.1f%%", endDiff) + (endEstimated ? "*" : ""),
color: endDiff >= 0 ? .positiveGreen : .negativeRed),
ChartStat(label: "Best month", value: String(format: "%+.1f%%", bestDiff),
color: .positiveGreen),
ChartStat(label: "Worst month", value: String(format: "%+.1f%%", worstDiff),
color: .negativeRed),
])
}
}
@ViewBuilder
private var comparisonTable: some View {
let yearA = selectedSeries[0]
let yearB = selectedSeries[1]
ChartDataTable(
rows: comparisonTableRows(yearA: yearA, yearB: yearB),
valueHeader: String(yearA.year),
deltaPrevHeader: String(yearB.year),
deltaFirstHeader: "Diff"
)
}
// MARK: - Helpers
/// Effective year-end value: the forecast (estimated) for the current incomplete year,
/// otherwise the last month with real data.
private func endValue(_ s: ChartsViewModel.YearSeries) -> (value: Double, estimated: Bool) {
if let f = s.forecastEndValue { return (f, true) }
return (s.values.last(where: { !$0.isNaN }) ?? 0, false)
}
private var hasEstimate: Bool {
selectedSeries.contains { $0.forecastEndValue != nil }
}
private func monthDiffs(yearA: ChartsViewModel.YearSeries, yearB: ChartsViewModel.YearSeries) -> [Double?] {
(0..<12).map { idx in
let a = yearA.values[idx]
let b = yearB.values[idx]
guard !a.isNaN, !b.isNaN else { return nil }
return b - a
}
}
private func comparisonTableRows(
yearA: ChartsViewModel.YearSeries,
yearB: ChartsViewModel.YearSeries
) -> [ChartDataTableRow] {
(0..<12).compactMap { idx in
let valA = yearA.values[idx]
let valB = yearB.values[idx]
guard !valA.isNaN || !valB.isNaN else { return nil }
let diff = (!valA.isNaN && !valB.isNaN) ? valB - valA : 0
return ChartDataTableRow(
label: monthAbbreviations[idx],
value: valA.isNaN ? "" : String(format: "%+.1f%%", valA),
deltaPrev: valB.isNaN ? "" : String(format: "%+.1f%%", valB),
deltaFirst: (!valA.isNaN && !valB.isNaN) ? String(format: "%+.1f%%", diff) : "",
isPrevPositive: valB >= 0,
isFirstPositive: diff >= 0
)
}
}
}
@@ -47,6 +47,10 @@ struct DashboardView: View {
.transition(.move(edge: .top).combined(with: .opacity))
}
if !viewModel.insights.isEmpty {
InsightsRow(insights: viewModel.insights)
}
if horizontalSizeClass == .regular {
iPadDashboardLayout
} else {
@@ -99,6 +103,9 @@ struct DashboardView: View {
Image(systemName: "slider.horizontal.3")
}
}
ToolbarItem(placement: .navigationBarLeading) {
dashboardFocusModeButton
}
}
.onAppear {
viewModel.selectedAccount = accountStore.selectedAccount
@@ -143,6 +150,24 @@ struct DashboardView: View {
}
}
private var dashboardFocusModeButton: some View {
Button {
calmModeEnabled.toggle()
} label: {
HStack(spacing: 4) {
Image(systemName: calmModeEnabled ? "moon.stars.fill" : "moon.stars")
Text(calmModeEnabled ? "Focus" : "Full")
.font(.caption.weight(.semibold))
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(calmModeEnabled ? Color.appPrimary.opacity(0.12) : Color.gray.opacity(0.1))
.foregroundColor(calmModeEnabled ? .appPrimary : .secondary)
.cornerRadius(14)
}
.buttonStyle(.plain)
}
private var streakBadge: some View {
HStack(spacing: 6) {
Image(systemName: "flame.fill")
@@ -0,0 +1,49 @@
import SwiftUI
struct InsightsRow: View {
let insights: [PortfolioInsight]
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text(String(localized: "insights_section_title"))
.font(.headline)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(insights) { insight in
insightChip(insight)
}
}
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
}
private func insightChip(_ insight: PortfolioInsight) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 5) {
Image(systemName: insight.systemImage)
.font(.caption.weight(.semibold))
.foregroundColor(insight.accentColor)
Text(insight.title)
.font(.caption2)
.foregroundColor(.secondary)
}
Text(insight.value)
.font(.subheadline.weight(.semibold))
.foregroundColor(.primary)
.lineLimit(1)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(insight.accentColor.opacity(0.08))
.cornerRadius(12)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(insight.accentColor.opacity(0.2), lineWidth: 1)
)
}
}
@@ -257,6 +257,7 @@ struct MonthlyCheckInView: View {
: min(referenceDate.endOfMonth, now)
MonthlyCheckInStore.setCompletionDate(completionDate, for: referenceDate)
ReviewPromptService.shared.recordMonthlyCheckInCompleted()
NotificationService.shared.scheduleMonthlyCheckIn()
viewModel.refresh()
let newlyUnlockedAchievementKeys = unlockedAchievementKeys().subtracting(previousUnlockedAchievementKeys)
if ReviewPromptService.shared.shouldAskForAchievementSatisfaction(
@@ -1079,6 +1080,7 @@ struct BatchUpdateView: View {
value: parsed,
contribution: parsedContribution
)
NotificationService.shared.scheduleReminder(for: source)
count += 1
}
@@ -11,6 +11,7 @@ struct QuickUpdateView: View {
) private var sources: FetchedResults<InvestmentSource>
@State private var values: [NSManagedObjectID: String] = [:]
@State private var contributions: [NSManagedObjectID: String] = [:]
@State private var isSaving = false
@State private var saveError: String?
@@ -62,30 +63,51 @@ struct QuickUpdateView: View {
} message: {
Text(saveError ?? "")
}
.onAppear { prefillContributions() }
}
}
@ViewBuilder
private func sourceRow(_ source: InvestmentSource) -> some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(source.name)
.font(.subheadline.weight(.medium))
if source.latestValue != .zero {
Text(source.latestValue.currencyString)
VStack(spacing: 6) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(source.name)
.font(.subheadline.weight(.medium))
if source.latestValue != .zero {
Text(source.latestValue.currencyString)
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
TextField(
String(localized: "quick_update_placeholder"),
text: valueBinding(for: source)
)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(width: 120)
.font(.subheadline)
}
if contributions[source.objectID] != nil {
HStack {
Text(String(localized: "quick_update_contribution_label"))
.font(.caption)
.foregroundColor(.secondary)
Spacer()
TextField(
String(localized: "quick_update_contribution_placeholder"),
text: contributionBinding(for: source)
)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(width: 120)
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
TextField(
String(localized: "quick_update_placeholder"),
text: binding(for: source)
)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(width: 120)
.font(.subheadline)
}
}
@@ -93,13 +115,28 @@ struct QuickUpdateView: View {
values.filter { !$0.value.trimmingCharacters(in: .whitespaces).isEmpty }
}
private func binding(for source: InvestmentSource) -> Binding<String> {
private func valueBinding(for source: InvestmentSource) -> Binding<String> {
Binding(
get: { values[source.objectID] ?? "" },
set: { values[source.objectID] = $0 }
)
}
private func contributionBinding(for source: InvestmentSource) -> Binding<String> {
Binding(
get: { contributions[source.objectID] ?? "" },
set: { contributions[source.objectID] = $0 }
)
}
private func prefillContributions() {
for source in sources {
if let amount = MonthlyContributionStore.contribution(for: source.id) {
contributions[source.objectID] = String(format: "%.2f", NSDecimalNumber(decimal: amount).doubleValue)
}
}
}
private func saveAll() {
isSaving = true
let now = Date()
@@ -114,10 +151,20 @@ struct QuickUpdateView: View {
snapshot.value = NSDecimalNumber(decimal: value)
snapshot.date = now
snapshot.source = source
if let contribRaw = contributions[source.objectID],
!contribRaw.trimmingCharacters(in: .whitespaces).isEmpty,
let contrib = CurrencyFormatter.parseUserInput(contribRaw) {
snapshot.contribution = NSDecimalNumber(decimal: contrib)
}
}
do {
try context.save()
// Reschedule source reminders so they reflect the new snapshots
for source in sources where values[source.objectID].map({ !$0.isEmpty }) == true {
NotificationService.shared.scheduleReminder(for: source)
}
dismiss()
} catch {
saveError = error.localizedDescription
@@ -281,10 +281,15 @@ struct OnboardingQuickStartView: View {
.background(Color.gray.opacity(0.1))
.cornerRadius(12)
Toggle("Enable Calm Mode (recommended)", isOn: $calmModeEnabled)
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(12)
VStack(alignment: .leading, spacing: 6) {
Toggle("Focus Mode (recommended)", isOn: $calmModeEnabled)
Text("Shows returns since your last check-in instead of daily noise. You can change this anytime in Settings.")
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(12)
Toggle("Sync with iCloud (optional)", isOn: $cloudSyncEnabled)
.padding()
@@ -0,0 +1,360 @@
import SwiftUI
struct CSVMappingView: View {
let csvContent: String
let onComplete: (ImportService.CSVMappingConfig) -> Void
@Environment(\.dismiss) private var dismiss
@State private var config = ImportService.CSVMappingConfig()
@State private var preview: ImportService.CSVPreview?
var body: some View {
NavigationStack {
Form {
// CSV Preview section
if let preview, let sample = preview.sampleRow(hasHeaderRow: config.hasHeaderRow) {
Section(String(localized: "csv_preview_section")) {
Toggle("Has Header Row", isOn: $config.hasHeaderRow)
.onChange(of: config.hasHeaderRow) { _, _ in resetMappings() }
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 16) {
ForEach(
Array(zip(
preview.headers(hasHeaderRow: config.hasHeaderRow),
sample
).enumerated()),
id: \.offset
) { _, pair in
VStack(alignment: .leading, spacing: 2) {
Text(pair.0)
.font(.caption2.weight(.semibold))
.foregroundColor(.secondary)
Text(pair.1.isEmpty ? "" : pair.1)
.font(.caption)
.lineLimit(2)
}
.frame(minWidth: 60, alignment: .leading)
}
}
.padding(.vertical, 4)
}
}
}
// Required fields
Section(String(localized: "csv_required_section")) {
MappingRow(
label: String(localized: "csv_field_source"),
index: $config.sourceIndex,
constant: $config.sourceConstant,
headers: currentHeaders,
supportsConstant: true,
supportsUseToday: false,
canSkip: false
)
MappingRow(
label: String(localized: "csv_field_value"),
index: $config.valueIndex,
constant: .constant(""),
headers: currentHeaders,
supportsConstant: false,
supportsUseToday: false,
canSkip: false
)
}
// Date section
Section {
MappingRow(
label: String(localized: "csv_field_date"),
index: $config.dateIndex,
constant: .constant(""),
headers: currentHeaders,
supportsConstant: false,
supportsUseToday: true,
canSkip: false
)
} header: {
Text("Date")
} footer: {
if config.dateIndex == ImportService.CSVMappingConfig.useToday {
Text(String(localized: "csv_no_date_hint"))
}
}
// Optional fields
Section(String(localized: "csv_optional_section")) {
MappingRowWithConstant(
label: String(localized: "csv_field_category"),
index: $config.categoryIndex,
constant: $config.categoryConstant,
headers: currentHeaders
)
MappingRow(
label: String(localized: "csv_field_contribution"),
index: $config.contributionIndex,
constant: .constant(""),
headers: currentHeaders,
supportsConstant: false,
supportsUseToday: false,
canSkip: true
)
MappingRow(
label: String(localized: "csv_field_notes"),
index: $config.notesIndex,
constant: .constant(""),
headers: currentHeaders,
supportsConstant: false,
supportsUseToday: false,
canSkip: true
)
}
}
.navigationTitle("Map Columns")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Import") {
onComplete(config)
dismiss()
}
.fontWeight(.semibold)
.disabled(!config.isValid)
}
}
.onAppear {
let p = ImportService.shared.previewCSV(csvContent)
self.preview = p
autoDetectColumns(from: p)
}
}
.presentationDetents([.large])
}
private var currentHeaders: [String] {
preview?.headers(hasHeaderRow: config.hasHeaderRow) ?? []
}
private func resetMappings() {
guard let preview else { return }
autoDetectColumns(from: preview)
}
/// Try to auto-detect columns by matching common header names
private func autoDetectColumns(from preview: ImportService.CSVPreview) {
guard config.hasHeaderRow else { return }
let headers = preview.headers(hasHeaderRow: true)
let lower = headers.map { $0.lowercased().trimmingCharacters(in: .whitespaces) }
for (i, h) in lower.enumerated() {
if config.sourceIndex == ImportService.CSVMappingConfig.notMapped &&
(h == "source" || h == "name" || h == "fuente" || h == "nombre") {
config.sourceIndex = i
}
if config.valueIndex == ImportService.CSVMappingConfig.notMapped &&
(h.hasPrefix("value") || h == "amount" || h == "valor" || h == "importe") {
config.valueIndex = i
}
if config.dateIndex == ImportService.CSVMappingConfig.useToday &&
(h == "date" || h == "fecha" || h.hasPrefix("date")) {
config.dateIndex = i
}
if config.categoryIndex == ImportService.CSVMappingConfig.constant &&
(h == "category" || h == "categoría" || h == "categoria" || h == "type") {
config.categoryIndex = i
}
if config.contributionIndex == ImportService.CSVMappingConfig.notMapped &&
(h.hasPrefix("contribution") || h == "aportación" || h == "aportacion") {
config.contributionIndex = i
}
if config.notesIndex == ImportService.CSVMappingConfig.notMapped &&
(h == "notes" || h == "notas" || h == "note" || h == "comments") {
config.notesIndex = i
}
}
}
}
// MARK: - Mapping Row (column or constant/useToday/skip)
private struct MappingRow: View {
let label: String
@Binding var index: Int
@Binding var constant: String
let headers: [String]
let supportsConstant: Bool
let supportsUseToday: Bool
let canSkip: Bool
private var selectionLabel: String {
if index == ImportService.CSVMappingConfig.useToday {
return String(localized: "csv_use_today")
}
if index == ImportService.CSVMappingConfig.constant {
return constant.isEmpty ? String(localized: "csv_enter_value") : constant
}
if index == ImportService.CSVMappingConfig.notMapped {
return String(localized: "csv_no_column")
}
return headers[safe: index] ?? "Column \(index + 1)"
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(label)
.font(.subheadline)
Spacer()
Menu {
// Column options
if !headers.isEmpty {
ForEach(Array(headers.enumerated()), id: \.offset) { i, h in
Button { index = i } label: {
Label(h, systemImage: index == i ? "checkmark" : "")
}
}
Divider()
}
if supportsUseToday {
Button {
index = ImportService.CSVMappingConfig.useToday
} label: {
Label(
String(localized: "csv_use_today"),
systemImage: index == ImportService.CSVMappingConfig.useToday ? "checkmark" : ""
)
}
}
if supportsConstant {
Button {
index = ImportService.CSVMappingConfig.constant
} label: {
Label(
String(localized: "csv_enter_value"),
systemImage: index == ImportService.CSVMappingConfig.constant ? "checkmark" : ""
)
}
}
if canSkip {
Button {
index = ImportService.CSVMappingConfig.notMapped
} label: {
Label(
String(localized: "csv_no_column"),
systemImage: index == ImportService.CSVMappingConfig.notMapped ? "checkmark" : ""
)
}
}
} label: {
HStack(spacing: 4) {
Text(selectionLabel)
.font(.subheadline)
.foregroundColor(index == ImportService.CSVMappingConfig.notMapped ? .secondary : .appPrimary)
Image(systemName: "chevron.up.chevron.down")
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
if index == ImportService.CSVMappingConfig.constant && supportsConstant {
TextField(String(localized: "csv_enter_value"), text: $constant)
.textFieldStyle(.roundedBorder)
.font(.subheadline)
}
}
.padding(.vertical, 2)
}
}
// MARK: - Mapping Row With Constant (column, constant value, or skip)
private struct MappingRowWithConstant: View {
let label: String
@Binding var index: Int
@Binding var constant: String
let headers: [String]
private var selectionLabel: String {
if index == ImportService.CSVMappingConfig.constant {
return constant.isEmpty ? String(localized: "csv_enter_value") : constant
}
if index == ImportService.CSVMappingConfig.notMapped {
return String(localized: "csv_no_column")
}
return headers[safe: index] ?? "Column \(index + 1)"
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(label)
.font(.subheadline)
Spacer()
Menu {
ForEach(Array(headers.enumerated()), id: \.offset) { i, h in
Button { index = i } label: {
Label(h, systemImage: index == i ? "checkmark" : "")
}
}
Divider()
Button {
index = ImportService.CSVMappingConfig.constant
} label: {
Label(
String(localized: "csv_enter_value"),
systemImage: index == ImportService.CSVMappingConfig.constant ? "checkmark" : ""
)
}
Button {
index = ImportService.CSVMappingConfig.notMapped
} label: {
Label(
String(localized: "csv_no_column"),
systemImage: index == ImportService.CSVMappingConfig.notMapped ? "checkmark" : ""
)
}
} label: {
HStack(spacing: 4) {
Text(selectionLabel)
.font(.subheadline)
.foregroundColor(index == ImportService.CSVMappingConfig.notMapped ? .secondary : .appPrimary)
Image(systemName: "chevron.up.chevron.down")
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
if index == ImportService.CSVMappingConfig.constant {
TextField(String(localized: "csv_enter_value"), text: $constant)
.textFieldStyle(.roundedBorder)
.font(.subheadline)
}
}
.padding(.vertical, 2)
}
}
// MARK: - Array safe subscript
private extension Array {
subscript(safe index: Int) -> Element? {
guard index >= 0, index < count else { return nil }
return self[index]
}
}
@@ -0,0 +1,271 @@
import SwiftUI
import CoreData
// MARK: - Categories List View
struct CategoriesView: View {
@StateObject private var categoryRepository = CategoryRepository()
@State private var showingAddCategory = false
@State private var editingCategory: Category?
@State private var showingDeleteError = false
var body: some View {
List {
if categoryRepository.categories.isEmpty {
Text(String(localized: "categories_empty"))
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 32)
} else {
ForEach(categoryRepository.categories) { category in
CategoryRow(category: category)
.swipeActions(edge: .leading, allowsFullSwipe: false) {
Button {
editingCategory = category
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.appSecondary)
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
if category.sourceCount == 0 {
Button(role: .destructive) {
categoryRepository.deleteCategory(category)
} label: {
Label("Delete", systemImage: "trash")
}
} else {
Button {
showingDeleteError = true
} label: {
Label("Delete", systemImage: "trash")
}
.tint(.gray)
}
}
}
.onMove { from, to in
var reordered = categoryRepository.categories
reordered.move(fromOffsets: from, toOffset: to)
categoryRepository.updateSortOrder(reordered)
}
}
}
.navigationTitle("Categories")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
showingAddCategory = true
} label: {
Image(systemName: "plus")
}
}
ToolbarItem(placement: .navigationBarLeading) {
EditButton()
}
}
.sheet(isPresented: $showingAddCategory) {
CategoryEditorView()
}
.sheet(item: $editingCategory) { category in
CategoryEditorView(category: category)
}
.alert(
"Cannot Delete",
isPresented: $showingDeleteError
) {
Button("OK", role: .cancel) { showingDeleteError = false }
} message: {
Text(String(localized: "category_has_sources_warning"))
}
}
}
// MARK: - Category Row
private struct CategoryRow: View {
let category: Category
var body: some View {
Group {
if category.isDeleted || category.isFault {
EmptyView()
} else {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(category.color.opacity(0.2))
.frame(width: 40, height: 40)
Image(systemName: category.icon)
.font(.system(size: 16))
.foregroundColor(category.color)
}
VStack(alignment: .leading, spacing: 2) {
Text(category.name)
.font(.body)
Text(category.sourceCount == 1 ? "1 source" : "\(category.sourceCount) sources")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
.padding(.vertical, 2)
}
}
}
}
// MARK: - Category Editor View
struct CategoryEditorView: View {
let category: Category?
@Environment(\.dismiss) private var dismiss
@StateObject private var categoryRepository = CategoryRepository()
@State private var name = ""
@State private var selectedColor = "#10B981"
@State private var selectedIcon = "chart.pie.fill"
@State private var didLoad = false
init(category: Category? = nil) {
self.category = category
}
private let colors: [String] = [
"#10B981", "#3B82F6", "#8B5CF6", "#F59E0B", "#EF4444", "#EC4899",
"#14B8A6", "#6B7280", "#F97316", "#06B6D4", "#84CC16", "#64748B"
]
private let icons: [String] = [
"chart.line.uptrend.xyaxis", "chart.bar.fill", "chart.pie.fill",
"banknote.fill", "building.columns.fill", "house.fill",
"bitcoinsign.circle.fill", "person.fill", "briefcase.fill",
"dollarsign.circle.fill", "creditcard.fill", "globe",
"cart.fill", "star.fill", "heart.fill", "ellipsis.circle.fill",
"folder.fill", "lock.fill", "leaf.fill", "flame.fill"
]
var body: some View {
NavigationStack {
Form {
Section("Category Name") {
TextField(String(localized: "category_name_placeholder"), text: $name)
.autocorrectionDisabled()
}
Section("Color") {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 6), spacing: 8) {
ForEach(colors, id: \.self) { hex in
ColorSwatch(hex: hex, isSelected: selectedColor == hex)
.onTapGesture { selectedColor = hex }
}
}
.padding(.vertical, 4)
}
Section("Icon") {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 5), spacing: 12) {
ForEach(icons, id: \.self) { icon in
IconSwatch(icon: icon, colorHex: selectedColor, isSelected: selectedIcon == icon)
.onTapGesture { selectedIcon = icon }
}
}
.padding(.vertical, 4)
}
// Preview
Section("Preview") {
HStack(spacing: 12) {
ZStack {
Circle()
.fill((Color(hex: selectedColor) ?? .blue).opacity(0.2))
.frame(width: 44, height: 44)
Image(systemName: selectedIcon)
.font(.system(size: 18))
.foregroundColor(Color(hex: selectedColor) ?? .blue)
}
Text(name.isEmpty ? String(localized: "category_name_placeholder") : name)
.foregroundColor(name.isEmpty ? .secondary : .primary)
}
}
}
.navigationTitle(category == nil ? "Add Category" : "Edit Category")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") { save() }
.fontWeight(.semibold)
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.onAppear {
guard let category, !didLoad else { return }
name = category.name
selectedColor = category.colorHex
selectedIcon = category.icon
didLoad = true
}
}
.presentationDetents([.large])
}
private func save() {
let trimmed = name.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return }
if let category {
categoryRepository.updateCategory(category, name: trimmed, colorHex: selectedColor, icon: selectedIcon)
} else {
categoryRepository.createCategory(name: trimmed, colorHex: selectedColor, icon: selectedIcon)
}
dismiss()
}
}
// MARK: - Color Swatch
private struct ColorSwatch: View {
let hex: String
let isSelected: Bool
var body: some View {
let color = Color(hex: hex) ?? .blue
Circle()
.fill(color)
.frame(width: 36, height: 36)
.overlay(Circle().strokeBorder(isSelected ? Color.primary : Color.clear, lineWidth: 2))
}
}
// MARK: - Icon Swatch
private struct IconSwatch: View {
let icon: String
let colorHex: String
let isSelected: Bool
var body: some View {
let color = Color(hex: colorHex) ?? .blue
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(isSelected ? color.opacity(0.2) : Color(.systemGray6))
.frame(width: 44, height: 44)
Image(systemName: icon)
.font(.system(size: 18))
.foregroundColor(isSelected ? color : .secondary)
}
}
}
#Preview {
NavigationStack {
CategoriesView()
}
}
@@ -706,7 +706,7 @@ struct SettingsView: View {
private var longTermSection: some View {
Section {
Toggle("Calm Mode", isOn: $calmModeEnabled)
Toggle("Focus Mode", isOn: $calmModeEnabled)
Toggle("Show Forecast", isOn: $showForecast)
@@ -723,7 +723,7 @@ struct SettingsView: View {
} header: {
Text("Long-Term Focus")
} footer: {
Text("Calm Mode hides short-term noise and advanced charts. Show Forecast controls whether prediction data appears in portfolio and charts.")
Text("Focus Mode shows returns since your last check-in instead of daily fluctuations, and hides advanced technical charts. Turn it off for full access to all metrics and chart types.")
}
}
@@ -178,6 +178,12 @@ struct AddSourceView: View {
// Schedule notification
NotificationService.shared.scheduleReminder(for: source)
// Check milestone after snapshot is saved
let sourceRepo = InvestmentSourceRepository()
let allSources = sourceRepo.fetchActiveSources()
let total = allSources.reduce(Decimal.zero) { $0 + $1.latestValue }
NotificationService.shared.checkAndScheduleMilestoneNotification(portfolioValue: total)
// Log analytics
FirebaseService.shared.logSourceAdded(
categoryName: category.name,
@@ -391,6 +397,8 @@ struct AddSnapshotView: View {
@StateObject private var viewModel: SnapshotFormViewModel
@State private var showingDuplicateAlert = false
@State private var showingPropagationDialog = false
@State private var pendingContribution: Decimal?
init(source: InvestmentSource, snapshot: Snapshot? = nil) {
self.source = source
@@ -464,24 +472,22 @@ struct AddSnapshotView: View {
}
}
if viewModel.inputMode == .detailed {
Section {
Toggle("Include Contribution", isOn: $viewModel.includeContribution)
Section {
Toggle("Include Contribution", isOn: $viewModel.includeContribution)
if viewModel.includeContribution {
HStack {
Text(viewModel.currencySymbol)
.foregroundColor(.secondary)
if viewModel.includeContribution {
HStack {
Text(viewModel.currencySymbol)
.foregroundColor(.secondary)
TextField("New capital added", text: $viewModel.contributionString)
.keyboardType(.decimalPad)
}
TextField("New capital added", text: $viewModel.contributionString)
.keyboardType(.decimalPad)
}
} header: {
Text("Contribution (Optional)")
} footer: {
Text("Track new capital added to separate it from investment growth.")
}
} header: {
Text("Contribution (Optional)")
} footer: {
Text("Track new capital added to separate it from investment growth.")
}
Section {
@@ -530,6 +536,20 @@ struct AddSnapshotView: View {
} message: {
Text(String(localized: "snapshot_duplicate_message"))
}
.confirmationDialog(
String(localized: "snapshot_contribution_propagate_title"),
isPresented: $showingPropagationDialog,
titleVisibility: .visible
) {
Button(String(localized: "snapshot_contribution_propagate_forward")) { propagate(.forward) }
Button(String(localized: "snapshot_contribution_propagate_backward")) { propagate(.backward) }
Button(String(localized: "snapshot_contribution_propagate_all")) { propagate(.all) }
Button(String(localized: "snapshot_contribution_propagate_this"), role: .cancel) { dismiss() }
} message: {
if let amount = pendingContribution {
Text(String(format: String(localized: "snapshot_contribution_propagate_message"), amount.currencyString))
}
}
}
}
@@ -560,6 +580,7 @@ struct AddSnapshotView: View {
guard let value = viewModel.value else { return }
let repository = SnapshotRepository()
let isEdit = snapshot != nil
if let snapshot = snapshot {
let contributionTextIsEmpty = viewModel.contributionString
.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -597,6 +618,20 @@ struct AddSnapshotView: View {
// Log analytics
FirebaseService.shared.logSnapshotAdded(sourceName: source.name, value: value)
// When editing, offer to propagate a changed contribution to other snapshots.
if isEdit, let contribution = viewModel.contribution, viewModel.contributionChanged {
pendingContribution = contribution
showingPropagationDialog = true
return
}
dismiss()
}
private func propagate(_ direction: SnapshotRepository.ContributionPropagation) {
if let snapshot = snapshot, let amount = pendingContribution {
SnapshotRepository().propagateContribution(amount, from: snapshot, direction: direction)
}
dismiss()
}
@@ -10,6 +10,10 @@ struct SourceDetailView: View {
@State private var showingDeleteConfirmation = false
@Environment(\.managedObjectContext) private var viewContext
@State private var editingSnapshotSelection: SnapshotSelection?
@State private var editingContribution = false
@State private var contributionInput = ""
@State private var showingContributionApplyDialog = false
@State private var pendingContributionAmount: Decimal? = nil
init(source: InvestmentSource, iapService: IAPService) {
_viewModel = StateObject(wrappedValue: SourceDetailViewModel(
@@ -50,6 +54,9 @@ struct SourceDetailView: View {
// Quick Actions
quickActions
// Monthly Contribution
monthlyContributionCard
// Chart
if !viewModel.chartData.isEmpty {
chartSection
@@ -185,6 +192,85 @@ struct SourceDetailView: View {
}
}
// MARK: - Monthly Contribution Card
private var monthlyContributionCard: some View {
let sourceId = viewModel.source.id
let current = MonthlyContributionStore.contribution(for: sourceId)
return VStack(alignment: .leading, spacing: 10) {
HStack {
Label(String(localized: "source_monthly_contribution_title"), systemImage: "arrow.down.circle.fill")
.font(.subheadline.weight(.semibold))
.foregroundColor(.primary)
Spacer()
Button {
if editingContribution {
if let val = CurrencyFormatter.parseUserInput(contributionInput), val > 0 {
MonthlyContributionStore.setContribution(val, for: sourceId)
pendingContributionAmount = val
showingContributionApplyDialog = true
} else if contributionInput.trimmingCharacters(in: .whitespaces).isEmpty {
MonthlyContributionStore.setContribution(nil, for: sourceId)
}
} else {
contributionInput = current.map { String(format: "%.2f", NSDecimalNumber(decimal: $0).doubleValue) } ?? ""
}
editingContribution.toggle()
} label: {
Text(editingContribution ? String(localized: "done") : String(localized: "edit"))
.font(.caption.weight(.semibold))
.foregroundColor(.appPrimary)
}
}
if editingContribution {
TextField(String(localized: "source_monthly_contribution_placeholder"), text: $contributionInput)
.keyboardType(.decimalPad)
.padding(8)
.background(Color(.systemGray6))
.cornerRadius(8)
} else {
Text(current.map { $0.currencyString } ?? String(localized: "source_monthly_contribution_not_set"))
.font(.subheadline)
.foregroundColor(current != nil ? .primary : .secondary)
}
Text(String(localized: "source_monthly_contribution_hint"))
.font(.caption2)
.foregroundColor(.secondary)
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(AppConstants.UI.cornerRadius)
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
.confirmationDialog(
String(localized: "source_monthly_contribution_apply_title"),
isPresented: $showingContributionApplyDialog,
titleVisibility: .visible
) {
Button(String(localized: "source_monthly_contribution_apply_retroactive")) {
if let amount = pendingContributionAmount {
applyContributionRetroactively(amount: amount)
}
}
Button(String(localized: "source_monthly_contribution_apply_forward"), role: .cancel) {}
} message: {
if let amount = pendingContributionAmount {
Text(String(format: String(localized: "source_monthly_contribution_apply_message"), amount.currencyString))
}
}
}
private func applyContributionRetroactively(amount: Decimal) {
let request = Snapshot.fetchRequest()
request.predicate = NSPredicate(format: "source == %@ AND (contribution == nil OR contribution == 0)", viewModel.source)
guard let snapshots = try? viewContext.fetch(request) else { return }
for snapshot in snapshots {
snapshot.contribution = NSDecimalNumber(decimal: amount)
}
try? viewContext.save()
}
// MARK: - Chart Section
private var chartSection: some View {
@@ -59,6 +59,7 @@ struct SourceListView: View {
let source = try? context.existingObject(with: id) as? InvestmentSource,
!source.isDeleted {
SourceDetailView(source: source, iapService: iapService)
.id(id)
} else {
noSourceSelectedView
}