Files
InvestmentTrackerApp/PortfolioJournal/Views/Settings/ImportDataView.swift
T
alexandrev-tibco a8a9ad64f3 Xcode 27: compila sin warnings y migra API deprecadas de SwiftUI
- Imports de CoreData que faltaban (MemberImportVisibility) en AccountsView,
  ImportDataView, ChartsContainerView y AddSourceView.
- Aislamiento MainActor: IsolatedDefaultValues en el target de la app,
  LinearGradient @MainActor, WatchSyncMessage nonisolated, closures de
  ReviewPromptService/MonthlyCheckInStore/AppIntents.
- Warnings de valores sin usar (SnapshotGapDetector, DashboardLayoutStore,
  OnboardingView, GoalEditorView, CoreDataStack, SettingsViewModel).
- foregroundColor→foregroundStyle, cornerRadius→clipShape,
  navigationBarLeading/Trailing→topBarLeading/Trailing,
  Task.sleep(nanoseconds:)→sleep(for:), NavigationLink(isActive:)→
  navigationDestination(isPresented:).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1u4K16xy7eQVtgsYNZ9Vn
2026-09-16 11:44:49 +02:00

623 lines
22 KiB
Swift

import SwiftUI
import CoreData
import UniformTypeIdentifiers
import UIKit
struct ImportDataView: View {
enum ImportContext {
case settings
case onboarding
}
enum AccountSelection: String, CaseIterable, Identifiable {
case existing
case new
var id: String { rawValue }
}
let importContext: ImportContext
@EnvironmentObject private var iapService: IAPService
@EnvironmentObject private var accountStore: AccountStore
@EnvironmentObject private var tabSelection: TabSelectionStore
@Environment(\.dismiss) private var dismiss
@State private var selectedFormat: ImportService.ImportFormat = .csv
@State private var showingImporter = false
@State private var resultMessage: String?
@State private var errorMessage: String?
@State private var isImporting = false
@State private var importProgress: Double = 0
@State private var importStatus = "Preparing import"
@State private var accountSelection: AccountSelection = .existing
@State private var selectedAccountId: UUID?
@State private var newAccountName = ""
@State private var accountErrorMessage: String?
// CSV column mapping flow
@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] {
accountStore.accounts.filter { $0.safeId != nil }
}
init(importContext: ImportContext = .settings) {
self.importContext = importContext
}
var body: some View {
NavigationStack {
List {
if shouldShowAccountSelection {
accountSection
}
Section {
Picker("Format", selection: $selectedFormat) {
Text("CSV").tag(ImportService.ImportFormat.csv)
Text("JSON").tag(ImportService.ImportFormat.json)
}
.pickerStyle(.segmented)
.disabled(isImporting)
Button {
showingImporter = true
} label: {
Label("Choose File", systemImage: "doc")
}
.disabled(isImporting || isPreviewing)
Button {
importFromClipboard()
} label: {
Label("Paste from Clipboard", systemImage: "doc.on.clipboard")
}
.disabled(isImporting || isPreviewing)
if isPreviewing {
HStack(spacing: 8) {
ProgressView()
Text("Reviewing import…")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.top, 8)
}
if isImporting {
VStack(alignment: .leading, spacing: 8) {
ProgressView(value: importProgress)
Text(importStatus)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.top, 8)
}
} header: {
Text("Import")
} footer: {
Text("Your data will be merged with existing categories and sources.")
}
Section {
if selectedFormat == .csv {
csvDocs
} else {
jsonDocs
}
} header: {
Text("Format Guide")
} footer: {
Text(importFooterText)
}
Section {
Button {
shareSampleFile()
} label: {
Label("Share Sample \(selectedFormat == .csv ? "CSV" : "JSON")", systemImage: "square.and.arrow.up")
}
} footer: {
Text("Use this sample file to email yourself a template.")
}
}
.navigationTitle("Import Data")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
.disabled(isImporting)
}
}
.fileImporter(
isPresented: $showingImporter,
allowedContentTypes: selectedFormat == .csv
? [.commaSeparatedText, .plainText, .text]
: [.json, .plainText, .text],
allowsMultipleSelection: false
) { result in
handleImport(result)
}
.sheet(isPresented: $showingCSVMapping) {
if let content = pendingCSVContent {
CSVMappingView(csvContent: content) { mapping in
performMappedCSVImport(content: content, mapping: mapping)
}
}
}
.alert(
"Import Complete",
isPresented: Binding(
get: { resultMessage != nil },
set: { if !$0 { resultMessage = nil } }
)
) {
Button("OK") { resultMessage = nil }
} message: {
Text(resultMessage ?? "")
}
.alert(
"Import Error",
isPresented: Binding(
get: { errorMessage != nil },
set: { if !$0 { errorMessage = nil } }
)
) {
Button("OK") { errorMessage = nil }
} 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 })
if selectedAccountId == nil || !validIds.contains(selectedAccountId!) {
selectedAccountId = accountStore.selectedAccount?.safeId ?? availableAccounts.first?.safeId
}
}
.onChange(of: availableAccounts) { _, newAccounts in
// Re-validate selectedAccountId when accounts change
let validIds = Set(newAccounts.compactMap { $0.safeId })
if let currentId = selectedAccountId, !validIds.contains(currentId) {
selectedAccountId = newAccounts.first?.safeId
}
}
.onChange(of: accountSelection) { _, _ in
accountErrorMessage = nil
}
.onChange(of: newAccountName) { _, _ in
guard accountSelection == .new else { return }
accountErrorMessage = validateNewAccountName()
}
}
.presentationDetents([.large])
}
private var csvDocs: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Headers")
.font(.headline)
Text("Account,Category,Source,Date,Value,Contribution,Notes")
.font(.caption.monospaced())
Text("Example")
.font(.headline)
Text("""
Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
,Crypto,BTC,01/15/2024 14:30,3200,,Cold storage
""")
.font(.caption.monospaced())
Text("Account, Contribution, and Notes are optional. Dates accept / or - and 24h/12h time.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var jsonDocs: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Top-level keys")
.font(.headline)
Text("version, currency, accounts")
.font(.caption.monospaced())
Text("Example")
.font(.headline)
Text("""
{
"version": 2,
"currency": "EUR",
"accounts": [{
"name": "Personal",
"inputMode": "simple",
"notificationFrequency": "monthly",
"categories": [{
"name": "Stocks",
"color": "#3B82F6",
"icon": "chart.line.uptrend.xyaxis",
"sources": [{
"name": "Index Fund",
"snapshots": [{
"date": "2024-01-01T00:00:00Z",
"value": 15000,
"contribution": 12000
}]
}]
}]
}]
}
""")
.font(.caption.monospaced())
}
}
private var shouldShowAccountSelection: Bool {
// Show account selection when there are multiple accounts (Premium) or during onboarding
iapService.isPremium && availableAccounts.count > 1 || importContext == .onboarding
}
private var importFooterText: String {
if shouldShowAccountSelection {
return iapService.isPremium
? "Import will be added to the selected account."
: "Free users import into the Default account."
}
return "Data will be imported into your Default account."
}
private var accountSection: some View {
Section {
if iapService.isPremium {
Picker("Account", selection: $accountSelection) {
Text("Existing").tag(AccountSelection.existing)
Text("New").tag(AccountSelection.new)
}
.pickerStyle(.segmented)
.disabled(isImporting)
if accountSelection == .existing {
Picker("Import into", selection: $selectedAccountId) {
ForEach(availableAccounts, id: \.objectID) { account in
Text(account.name).tag(Optional(account.safeId))
}
}
.disabled(isImporting)
} else {
TextField("New account name", text: $newAccountName)
.disabled(isImporting)
}
} else {
HStack {
Text("Account")
Spacer()
Text(selectedAccountName ?? "Personal")
.foregroundStyle(.secondary)
}
}
} header: {
Text("Account")
} footer: {
if let accountErrorMessage {
Text(accountErrorMessage)
.foregroundStyle(Color.negativeRed)
}
}
}
private var selectedAccountName: String? {
availableAccounts.first { $0.safeId == selectedAccountId }?.name
?? accountStore.selectedAccount?.name
?? availableAccounts.first?.name
}
private func validateNewAccountName() -> String? {
let trimmed = newAccountName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
return "Enter a name for the new account."
}
let normalized = trimmed.lowercased()
let exists = accountStore.accounts.contains {
$0.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == normalized
}
return exists ? "An account with this name already exists." : nil
}
private func handleImport(_ result: Result<[URL], Error>) {
do {
let urls = try result.get()
guard let url = urls.first else { return }
let content = try readFileContents(from: url)
handleImportContent(content)
} catch {
errorMessage = "Could not read the selected file. \(error.localizedDescription)"
}
}
private func readFileContents(from url: URL) throws -> String {
let accessing = url.startAccessingSecurityScopedResource()
defer {
if accessing {
url.stopAccessingSecurityScopedResource()
}
}
var coordinatorError: NSError?
var contentError: NSError?
var content = ""
let coordinator = NSFileCoordinator()
coordinator.coordinate(readingItemAt: url, options: [], error: &coordinatorError) { fileURL in
do {
if let isUbiquitous = try? fileURL.resourceValues(forKeys: [.isUbiquitousItemKey]).isUbiquitousItem,
isUbiquitous {
try? FileManager.default.startDownloadingUbiquitousItem(at: fileURL)
}
let data = try Data(contentsOf: fileURL)
if let decoded = String(data: data, encoding: .utf8)
?? String(data: data, encoding: .utf16)
?? String(data: data, encoding: .isoLatin1) {
content = decoded
} else {
throw NSError(
domain: "ImportDataView",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Unsupported text encoding."]
)
}
} catch {
contentError = error as NSError
}
}
if let coordinatorError {
throw coordinatorError
}
if let contentError {
throw contentError
}
return content
}
private func importFromClipboard() {
guard let content = UIPasteboard.general.string,
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
errorMessage = "Clipboard is empty."
return
}
handleImportContent(content)
}
private func resolvedAccountName() -> String {
let useAccountSelection = shouldShowAccountSelection && iapService.isPremium
var defaultAccountName = accountStore.accounts.first(where: { $0.isDefaultAccount })?.name
?? accountStore.selectedAccount?.name
?? accountStore.accounts.first?.name
?? Account.defaultAccountName
if useAccountSelection {
if accountSelection == .new {
let trimmed = newAccountName.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
let currency = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
let account = accountRepository.createAccount(
name: trimmed,
currency: currency,
inputMode: .simple,
notificationFrequency: .monthly,
customFrequencyMonths: 1
)
defaultAccountName = account.name
}
} else {
defaultAccountName = selectedAccountName ?? defaultAccountName
}
}
return defaultAccountName
}
private func handleImportContent(_ content: String) {
let useAccountSelection = shouldShowAccountSelection && iapService.isPremium
if useAccountSelection && accountSelection == .new {
accountErrorMessage = validateNewAccountName()
guard accountErrorMessage == nil else { return }
}
// CSV show column mapping sheet first
if selectedFormat == .csv {
pendingCSVContent = content
showingCSVMapping = true
return
}
// 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"
Task {
let importResult = await ImportService.shared.importCSVWithMappingAsync(
content: content,
mapping: mapping,
defaultAccountName: defaultAccountName
) { progress in
importProgress = progress.fraction
importStatus = progress.message
}
finishImport(importResult)
}
}
/// 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
importStatus = "Parsing file"
Task {
let importResult = await ImportService.shared.importDataAsync(
content: content,
format: format,
allowMultipleAccounts: false,
defaultAccountName: defaultAccountName
) { progress in
importProgress = progress.fraction
importStatus = progress.message
}
finishImport(importResult)
}
}
private func finishImport(_ importResult: ImportService.ImportResult) {
isImporting = false
if importResult.errors.isEmpty {
var message = "Imported \(importResult.sourcesCreated) sources and \(importResult.snapshotsCreated) snapshots."
if importResult.snapshotsUpdated > 0 {
message += " Updated \(importResult.snapshotsUpdated) existing snapshots."
}
resultMessage = message
tabSelection.selectedTab = 0
dismiss()
} else {
errorMessage = importResult.errors.joined(separator: "\n")
}
}
private func shareSampleFile() {
if selectedFormat == .csv {
ShareService.shared.shareTextFile(
content: ImportService.sampleCSV(),
fileName: "investment_tracker_sample.csv"
)
} else {
ShareService.shared.shareTextFile(
content: ImportService.sampleJSON(),
fileName: "investment_tracker_sample.json"
)
}
}
}
#Preview {
ImportDataView()
.environmentObject(IAPService())
.environmentObject(AccountStore(iapService: IAPService()))
}