1.5.0: aviso de huecos + resumen mensual compartible + import con vista previa
Feature 1 — Aviso de huecos: SnapshotGapDetector detecta huecos internos por source (meses sin datos entre dos snapshots). Banner desechable en Dashboard + DataGapsSheet para rellenar (abre AddSnapshotView con el mes que falta). Complementa la interpolación de gráficas: la línea se ve suave pero el usuario sabe que faltan datos reales. Feature 2 — Resumen mensual compartible: MonthlySummaryShareView (tarjeta con marca: valor, variación desde el check-in, racha, mood/rating) renderizada con ImageRenderer y compartida vía ShareService. Botón en la fila "check-in done" del Dashboard. Feature 3 — Import con vista previa: ImportPreview + previewImportAsync (dry-run READ-ONLY, no muta nada) cuenta qué se creará/actualizará usando los mismos checks de existencia que applyImport. ImportDataView muestra la confirmación antes de importar. El path real de import (applyImport/importDataAsync) NO se toca. Localización en los 7 idiomas. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
This commit is contained in:
@@ -15,6 +15,10 @@ struct DashboardView: View {
|
||||
@State private var sectionConfigs = DashboardLayoutStore.load()
|
||||
@AppStorage("showForecast") private var showForecast = true
|
||||
@State private var showingQuickUpdate = false
|
||||
@State private var showingDataGaps = false
|
||||
/// Signature of the gap set last dismissed, so re-dismissal sticks until the
|
||||
/// gaps actually change (e.g. the user filled some).
|
||||
@AppStorage("dismissedDataGapsSignature") private var dismissedGapsSignature = ""
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
init() {
|
||||
@@ -39,9 +43,11 @@ struct DashboardView: View {
|
||||
VStack(spacing: 16) {
|
||||
ForEach(visibleSections) { config in
|
||||
sectionView(for: config)
|
||||
if config.id == DashboardSection.monthlyCheckIn.id,
|
||||
!viewModel.insights.isEmpty {
|
||||
InsightsRow(insights: viewModel.insights)
|
||||
if config.id == DashboardSection.monthlyCheckIn.id {
|
||||
gapsBanner
|
||||
if !viewModel.insights.isEmpty {
|
||||
InsightsRow(insights: viewModel.insights)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,6 +137,12 @@ struct DashboardView: View {
|
||||
.sheet(isPresented: $viewModel.showingPaywall) {
|
||||
PaywallView()
|
||||
}
|
||||
.sheet(isPresented: $showingDataGaps) {
|
||||
DataGapsSheet(
|
||||
gaps: viewModel.dataGaps,
|
||||
sourceProvider: { viewModel.source(withId: $0) }
|
||||
)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .openQuickUpdate)) { _ in
|
||||
showingQuickUpdate = true
|
||||
}
|
||||
@@ -180,6 +192,29 @@ struct DashboardView: View {
|
||||
sectionConfigs.filter { $0.isVisible }
|
||||
}
|
||||
|
||||
// MARK: - Data Gaps
|
||||
|
||||
/// A stable fingerprint of the current gap set — changes when gaps are
|
||||
/// added or filled, which re-shows a previously dismissed banner.
|
||||
private var currentGapsSignature: String {
|
||||
viewModel.dataGaps.map { $0.id }.sorted().joined(separator: "|")
|
||||
}
|
||||
|
||||
private var shouldShowGapsBanner: Bool {
|
||||
!viewModel.dataGaps.isEmpty && dismissedGapsSignature != currentGapsSignature
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var gapsBanner: some View {
|
||||
if shouldShowGapsBanner {
|
||||
DataGapsBanner(
|
||||
totalMissingMonths: viewModel.totalMissingMonths,
|
||||
onTap: { showingDataGaps = true },
|
||||
onDismiss: { dismissedGapsSignature = currentGapsSignature }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iPad Layout Helpers
|
||||
|
||||
/// Full-width "hero" zones that lead the dashboard, in order.
|
||||
@@ -224,8 +259,11 @@ struct DashboardView: View {
|
||||
VStack(spacing: 16) {
|
||||
ForEach(iPadLeadSections) { config in
|
||||
sectionView(for: config)
|
||||
if config.id == DashboardSection.monthlyCheckIn.id, !viewModel.insights.isEmpty {
|
||||
InsightsRow(insights: viewModel.insights)
|
||||
if config.id == DashboardSection.monthlyCheckIn.id {
|
||||
gapsBanner
|
||||
if !viewModel.insights.isEmpty {
|
||||
InsightsRow(insights: viewModel.insights)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,7 +323,10 @@ struct DashboardView: View {
|
||||
lastUpdated: viewModel.formattedLastUpdate,
|
||||
pendingCount: viewModel.sourcesNeedingUpdate.count,
|
||||
totalSources: viewModel.totalSourceCount,
|
||||
streak: viewModel.updateStreak
|
||||
streak: viewModel.updateStreak,
|
||||
onShareSummary: {
|
||||
ShareService.shared.shareMonthlySummary(viewModel.monthlySummaryShareData())
|
||||
}
|
||||
)
|
||||
}
|
||||
case .momentumStreaks:
|
||||
@@ -557,6 +598,7 @@ struct MonthlyCheckInCard: View {
|
||||
var pendingCount: Int = 0
|
||||
var totalSources: Int = 0
|
||||
var streak: Int = 0
|
||||
var onShareSummary: (() -> Void)? = nil
|
||||
@State private var startDestinationActive = false
|
||||
@State private var showingGuidedFlow = false
|
||||
|
||||
@@ -734,6 +776,15 @@ struct MonthlyCheckInCard: View {
|
||||
.background(Color.orange.opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
if let onShareSummary {
|
||||
Button(action: onShareSummary) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.appPrimary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("Share monthly summary"))
|
||||
}
|
||||
Button {
|
||||
startDestinationActive = true
|
||||
} label: {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Dismissible banner shown on the Dashboard when internal snapshot gaps exist.
|
||||
/// The evolution chart smooths these over, so without this the user would never
|
||||
/// know real data is missing. Tapping opens `DataGapsSheet` to fill them.
|
||||
struct DataGapsBanner: View {
|
||||
let totalMissingMonths: Int
|
||||
let onTap: () -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.title3)
|
||||
.foregroundColor(.orange)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(String(format: String(localized: "gaps_banner_title"), totalMissingMonths))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.primary)
|
||||
.multilineTextAlignment(.leading)
|
||||
Text("Fill in missing data")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(14)
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
.cornerRadius(AppConstants.UI.cornerRadius)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: AppConstants.UI.cornerRadius, style: .continuous)
|
||||
.stroke(Color.orange.opacity(0.35), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
Button(action: onDismiss) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("Dismiss"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists each detected gap and offers to fill it. Filling opens the standard
|
||||
/// add-snapshot flow for that source, prefilled to the missing month's date.
|
||||
struct DataGapsSheet: View {
|
||||
let gaps: [SnapshotGapDetector.Gap]
|
||||
/// Resolves a source id to a live InvestmentSource for the add-snapshot flow.
|
||||
let sourceProvider: (UUID) -> InvestmentSource?
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var fillTarget: FillTarget?
|
||||
|
||||
private struct FillTarget: Identifiable {
|
||||
let source: InvestmentSource
|
||||
let date: Date
|
||||
var id: String { "\(source.id.uuidString)-\(date.timeIntervalSince1970)" }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
ForEach(gaps) { gap in
|
||||
gapRow(gap)
|
||||
}
|
||||
} footer: {
|
||||
Text("The chart estimates these months. Add real snapshots to keep your history accurate.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Missing Data")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
.sheet(item: $fillTarget) { target in
|
||||
AddSnapshotView(source: target.source, initialDate: target.date)
|
||||
.environment(\.managedObjectContext, CoreDataStack.shared.viewContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func gapRow(_ gap: SnapshotGapDetector.Gap) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(gap.sourceName)
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Text(String(format: String(localized: "gaps_missing_count"), gap.missingMonths))
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.orange)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.orange.opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
Text(rangeLabel(gap))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if let source = sourceProvider(gap.sourceId),
|
||||
let firstMissing = gap.missingMonthDates.first {
|
||||
Button {
|
||||
fillTarget = FillTarget(source: source, date: firstMissing)
|
||||
} label: {
|
||||
Label("Fill Gap", systemImage: "plus.circle.fill")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.tint(.appPrimary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func rangeLabel(_ gap: SnapshotGapDetector.Gap) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM yyyy"
|
||||
let from = formatter.string(from: gap.fromMonth)
|
||||
let to = formatter.string(from: gap.toMonth)
|
||||
return String(format: String(localized: "gaps_range_between"), from, to)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A polished, branded card summarising the month — designed to be rendered to
|
||||
/// an image and shared (screenshot-worthy, marketing-facing). Pulls only from
|
||||
/// data the Dashboard already computes.
|
||||
struct MonthlySummaryShareView: View {
|
||||
let monthLabel: String
|
||||
let totalValue: String
|
||||
let changeAmount: String
|
||||
let changePercentage: String
|
||||
let isPositive: Bool
|
||||
let streak: Int
|
||||
let mood: MonthlyCheckInMood?
|
||||
let rating: Int?
|
||||
let appName: String
|
||||
var qrCodeImage: UIImage? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Header
|
||||
HStack {
|
||||
Text(monthLabel)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
.textCase(.uppercase)
|
||||
Spacer()
|
||||
if streak >= 2 {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "flame.fill")
|
||||
.font(.caption2)
|
||||
Text(String(format: String(localized: "share_summary_streak_badge"), streak))
|
||||
.font(.caption2.weight(.bold))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(Color.white.opacity(0.18))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
Text("Monthly Summary")
|
||||
.font(.title.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.top, 6)
|
||||
|
||||
// Hero value
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Portfolio Value")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
Text(totalValue)
|
||||
.font(.system(size: 40, weight: .bold, design: .rounded))
|
||||
.foregroundColor(.white)
|
||||
.minimumScaleFactor(0.6)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.top, 22)
|
||||
|
||||
// Change since last check-in
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: isPositive ? "arrow.up.right" : "arrow.down.right")
|
||||
.font(.subheadline.weight(.bold))
|
||||
Text("\(changeAmount) (\(changePercentage))")
|
||||
.font(.headline.weight(.bold))
|
||||
Spacer()
|
||||
Text("since last check-in")
|
||||
.font(.caption)
|
||||
.foregroundColor(.white.opacity(0.75))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.white.opacity(0.15))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.padding(.top, 18)
|
||||
|
||||
// Mood / rating
|
||||
if mood != nil || rating != nil {
|
||||
HStack(spacing: 14) {
|
||||
if let mood {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: mood.iconName)
|
||||
.font(.subheadline)
|
||||
Text(mood.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
if let rating {
|
||||
HStack(spacing: 2) {
|
||||
ForEach(1...5, id: \.self) { i in
|
||||
Image(systemName: i <= rating ? "star.fill" : "star")
|
||||
.font(.caption)
|
||||
.foregroundColor(.white.opacity(i <= rating ? 1 : 0.4))
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 16)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
// Footer / branding
|
||||
HStack(spacing: 12) {
|
||||
Image("BrandMark")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 40, height: 40)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Tracked with")
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
Text(appName)
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let qrCodeImage {
|
||||
Image(uiImage: qrCodeImage)
|
||||
.interpolation(.none)
|
||||
.resizable()
|
||||
.frame(width: 52, height: 52)
|
||||
.background(Color.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous))
|
||||
}
|
||||
}
|
||||
.padding(.top, 20)
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 340, height: 440)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color.appSecondary, Color.appPrimary],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 28, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,12 @@ struct ImportDataView: View {
|
||||
@State private var pendingCSVContent: String?
|
||||
@State private var showingCSVMapping = false
|
||||
|
||||
// Import preview (dry-run) confirmation flow
|
||||
@State private var previewSummary: ImportService.ImportPreview?
|
||||
@State private var isPreviewing = false
|
||||
/// Closure that runs the actual (unchanged) import once the user confirms.
|
||||
@State private var confirmedImportAction: (() -> Void)?
|
||||
|
||||
private let accountRepository = AccountRepository()
|
||||
|
||||
private var availableAccounts: [Account] {
|
||||
@@ -68,14 +74,24 @@ struct ImportDataView: View {
|
||||
} label: {
|
||||
Label("Choose File", systemImage: "doc")
|
||||
}
|
||||
.disabled(isImporting)
|
||||
.disabled(isImporting || isPreviewing)
|
||||
|
||||
Button {
|
||||
importFromClipboard()
|
||||
} label: {
|
||||
Label("Paste from Clipboard", systemImage: "doc.on.clipboard")
|
||||
}
|
||||
.disabled(isImporting)
|
||||
.disabled(isImporting || isPreviewing)
|
||||
|
||||
if isPreviewing {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("Reviewing import…")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
if isImporting {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@@ -160,6 +176,33 @@ struct ImportDataView: View {
|
||||
} message: {
|
||||
Text(errorMessage ?? "")
|
||||
}
|
||||
.alert(
|
||||
"Review Import",
|
||||
isPresented: Binding(
|
||||
get: { previewSummary != nil },
|
||||
set: { if !$0 { previewSummary = nil; confirmedImportAction = nil } }
|
||||
)
|
||||
) {
|
||||
if previewSummary?.hasAnything == true {
|
||||
Button("Import") {
|
||||
let action = confirmedImportAction
|
||||
previewSummary = nil
|
||||
confirmedImportAction = nil
|
||||
action?()
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
previewSummary = nil
|
||||
confirmedImportAction = nil
|
||||
}
|
||||
} else {
|
||||
Button("OK") {
|
||||
previewSummary = nil
|
||||
confirmedImportAction = nil
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text(previewSummary.map(previewMessage) ?? "")
|
||||
}
|
||||
.onAppear {
|
||||
// Ensure selectedAccountId is valid and exists in availableAccounts
|
||||
let validIds = Set(availableAccounts.compactMap { $0.safeId })
|
||||
@@ -420,12 +463,45 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
|
||||
return
|
||||
}
|
||||
|
||||
// JSON → import directly
|
||||
runImport(content: content, format: .json, defaultAccountName: resolvedAccountName())
|
||||
// JSON → dry-run preview first, then import on confirm.
|
||||
let accountName = resolvedAccountName()
|
||||
previewThenConfirm(
|
||||
dryRun: {
|
||||
await ImportService.shared.previewImportAsync(
|
||||
content: content,
|
||||
format: .json,
|
||||
allowMultipleAccounts: false,
|
||||
defaultAccountName: accountName
|
||||
)
|
||||
},
|
||||
onConfirm: {
|
||||
self.runImport(content: content, format: .json, defaultAccountName: accountName)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func performMappedCSVImport(content: String, mapping: ImportService.CSVMappingConfig) {
|
||||
let defaultAccountName = resolvedAccountName()
|
||||
// Dry-run preview first, then run the actual (unchanged) import on confirm.
|
||||
previewThenConfirm(
|
||||
dryRun: {
|
||||
await ImportService.shared.previewCSVWithMappingAsync(
|
||||
content: content,
|
||||
mapping: mapping,
|
||||
defaultAccountName: defaultAccountName
|
||||
)
|
||||
},
|
||||
onConfirm: {
|
||||
self.runMappedCSVImport(content: content, mapping: mapping, defaultAccountName: defaultAccountName)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func runMappedCSVImport(
|
||||
content: String,
|
||||
mapping: ImportService.CSVMappingConfig,
|
||||
defaultAccountName: String
|
||||
) {
|
||||
isImporting = true
|
||||
importProgress = 0
|
||||
importStatus = "Parsing file"
|
||||
@@ -443,6 +519,51 @@ Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a read-only dry-run, then presents a confirmation with the counts.
|
||||
/// Confirming triggers `onConfirm` (the existing, unchanged import path);
|
||||
/// cancelling does nothing.
|
||||
private func previewThenConfirm(
|
||||
dryRun: @escaping () async -> ImportService.ImportPreview,
|
||||
onConfirm: @escaping () -> Void
|
||||
) {
|
||||
isPreviewing = true
|
||||
Task {
|
||||
let preview = await dryRun()
|
||||
isPreviewing = false
|
||||
confirmedImportAction = onConfirm
|
||||
previewSummary = preview
|
||||
}
|
||||
}
|
||||
|
||||
private func previewMessage(_ preview: ImportService.ImportPreview) -> String {
|
||||
guard preview.hasAnything else {
|
||||
return String(localized: "import_preview_nothing")
|
||||
}
|
||||
var parts: [String] = []
|
||||
if preview.sourcesToCreate > 0 {
|
||||
parts.append(String(format: String(localized: "import_preview_sources"), preview.sourcesToCreate))
|
||||
}
|
||||
if preview.snapshotsToCreate > 0 {
|
||||
parts.append(String(format: String(localized: "import_preview_snapshots_new"), preview.snapshotsToCreate))
|
||||
}
|
||||
if preview.snapshotsToUpdate > 0 {
|
||||
parts.append(String(format: String(localized: "import_preview_snapshots_update"), preview.snapshotsToUpdate))
|
||||
}
|
||||
if preview.categoriesToCreate > 0 {
|
||||
parts.append(String(format: String(localized: "import_preview_categories"), preview.categoriesToCreate))
|
||||
}
|
||||
if preview.goalsToCreate > 0 {
|
||||
parts.append(String(format: String(localized: "import_preview_goals"), preview.goalsToCreate))
|
||||
}
|
||||
if preview.journalToCreate > 0 {
|
||||
parts.append(String(format: String(localized: "import_preview_journal_new"), preview.journalToCreate))
|
||||
}
|
||||
if preview.journalToUpdate > 0 {
|
||||
parts.append(String(format: String(localized: "import_preview_journal_update"), preview.journalToUpdate))
|
||||
}
|
||||
return parts.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private func runImport(content: String, format: ImportService.ImportFormat, defaultAccountName: String) {
|
||||
isImporting = true
|
||||
importProgress = 0
|
||||
|
||||
@@ -400,13 +400,13 @@ struct AddSnapshotView: View {
|
||||
@State private var showingPropagationDialog = false
|
||||
@State private var pendingContribution: Decimal?
|
||||
|
||||
init(source: InvestmentSource, snapshot: Snapshot? = nil) {
|
||||
init(source: InvestmentSource, snapshot: Snapshot? = nil, initialDate: Date? = nil) {
|
||||
self.source = source
|
||||
self.snapshot = snapshot
|
||||
if let snapshot = snapshot {
|
||||
_viewModel = StateObject(wrappedValue: SnapshotFormViewModel(source: source, mode: .edit(snapshot)))
|
||||
} else {
|
||||
_viewModel = StateObject(wrappedValue: SnapshotFormViewModel(source: source))
|
||||
_viewModel = StateObject(wrappedValue: SnapshotFormViewModel(source: source, initialDate: initialDate))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user