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:
@@ -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