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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user