import SwiftUI import UniformTypeIdentifiers import UIKit struct ImportDataView: View { @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" var body: some View { NavigationStack { List { 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(iapService.isPremium ? "Accounts are imported as provided." : "Free users import into the Personal account.") } 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 ?? "") } } .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 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 allowMultipleAccounts = iapService.isPremium let defaultAccountName = accountStore.selectedAccount?.name ?? accountStore.accounts.first?.name 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())) }