initial version
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
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()))
|
||||
}
|
||||
Reference in New Issue
Block a user