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