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