b48a47ce10
- 1A: Notificación mensual de resumen de portfolio (día 5 de cada mes) - 1B: Badge de racha mensual en Dashboard (streak counter) - 1C: Empty states mejorados en Goals y Journal con CTAs claros - 2A: Quick Update sheet — actualiza todas las fuentes desde una pantalla - 2B: Notificación batch update redirige al Quick Update sheet - 2C: Widget deep link portfoliojournal://quickupdate abre Quick Update - 3A: App Store version check banner en Settings - 3C: What's New sheet en primer launch de versión nueva - Localización completa en 7 idiomas para todas las nuevas strings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
501 lines
18 KiB
Swift
501 lines
18 KiB
Swift
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?
|
|
|
|
// CSV column mapping flow
|
|
@State private var pendingCSVContent: String?
|
|
@State private var showingCSVMapping = false
|
|
|
|
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)
|
|
|
|
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)
|
|
}
|
|
.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 ?? "")
|
|
}
|
|
.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)
|
|
.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 {
|
|
// 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")
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Account")
|
|
} footer: {
|
|
if let accountErrorMessage {
|
|
Text(accountErrorMessage)
|
|
.foregroundColor(.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 → import directly
|
|
runImport(content: content, format: .json, defaultAccountName: resolvedAccountName())
|
|
}
|
|
|
|
private func performMappedCSVImport(content: String, mapping: ImportService.CSVMappingConfig) {
|
|
let defaultAccountName = resolvedAccountName()
|
|
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)
|
|
}
|
|
}
|
|
|
|
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()))
|
|
}
|