import SwiftUI 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? private let accountRepository = AccountRepository() 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) Button { importFromClipboard() } label: { Label("Paste from Clipboard", systemImage: "doc.on.clipboard") } .disabled(isImporting) if isImporting { VStack(alignment: .leading, spacing: 8) { ProgressView(value: importProgress) Text(importStatus) .font(.caption) .foregroundColor(.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: .navigationBarTrailing) { Button("Done") { dismiss() } .disabled(isImporting) } } .fileImporter( isPresented: $showingImporter, allowedContentTypes: selectedFormat == .csv ? [.commaSeparatedText, .plainText, .text] : [.json, .plainText, .text], allowsMultipleSelection: false ) { result in handleImport(result) } .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 ?? "") } .onAppear { if selectedAccountId == nil { selectedAccountId = accountStore.selectedAccount?.id ?? accountStore.accounts.first?.id } } .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) .foregroundColor(.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 { importContext == .onboarding } private var importFooterText: String { if importContext == .onboarding { return iapService.isPremium ? "Import will be added to the selected account." : "Free users import into the existing account." } return iapService.isPremium ? "Accounts are imported as provided." : "Free users import into the selected 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(accountStore.accounts) { account in Text(account.name).tag(Optional(account.id)) } } .disabled(isImporting) } else { TextField("New account name", text: $newAccountName) .disabled(isImporting) } } else { HStack { Text("Account") Spacer() Text(selectedAccountName ?? "Personal") .foregroundColor(.secondary) } } } header: { Text("Account") } footer: { if let accountErrorMessage { Text(accountErrorMessage) .foregroundColor(.negativeRed) } } } private var selectedAccountName: String? { accountStore.accounts.first { $0.id == selectedAccountId }?.name ?? accountStore.selectedAccount?.name ?? accountStore.accounts.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 handleImportContent(_ content: String) { let shouldForceSingleAccount = importContext == .onboarding let allowMultipleAccounts = iapService.isPremium && !shouldForceSingleAccount var defaultAccountName = accountStore.selectedAccount?.name ?? accountStore.accounts.first?.name if shouldForceSingleAccount && iapService.isPremium { if accountSelection == .new { accountErrorMessage = validateNewAccountName() guard accountErrorMessage == nil else { return } let trimmed = newAccountName.trimmingCharacters(in: .whitespacesAndNewlines) 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 } } isImporting = true importProgress = 0 importStatus = "Parsing file" Task { let importResult = await ImportService.shared.importDataAsync( content: content, format: selectedFormat, allowMultipleAccounts: allowMultipleAccounts, defaultAccountName: defaultAccountName ) { progress in importProgress = progress.fraction importStatus = progress.message } isImporting = false if importResult.errors.isEmpty { resultMessage = "Imported \(importResult.sourcesCreated) sources and \(importResult.snapshotsCreated) snapshots." 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())) }