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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
This commit is contained in:
alexandrev-tibco
2026-06-30 16:51:03 +02:00
parent 54dfd8a8e3
commit 7a18dd8360
48 changed files with 2854 additions and 608 deletions
@@ -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
}