Files
InvestmentTrackerApp/PortfolioJournal/Views/Settings/CSVMappingView.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

361 lines
14 KiB
Swift

import SwiftUI
struct CSVMappingView: View {
let csvContent: String
let onComplete: (ImportService.CSVMappingConfig) -> Void
@Environment(\.dismiss) private var dismiss
@State private var config = ImportService.CSVMappingConfig()
@State private var preview: ImportService.CSVPreview?
var body: some View {
NavigationStack {
Form {
// CSV Preview section
if let preview, let sample = preview.sampleRow(hasHeaderRow: config.hasHeaderRow) {
Section(String(localized: "csv_preview_section")) {
Toggle("Has Header Row", isOn: $config.hasHeaderRow)
.onChange(of: config.hasHeaderRow) { _, _ in resetMappings() }
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 16) {
ForEach(
Array(zip(
preview.headers(hasHeaderRow: config.hasHeaderRow),
sample
).enumerated()),
id: \.offset
) { _, pair in
VStack(alignment: .leading, spacing: 2) {
Text(pair.0)
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
Text(pair.1.isEmpty ? "" : pair.1)
.font(.caption)
.lineLimit(2)
}
.frame(minWidth: 60, alignment: .leading)
}
}
.padding(.vertical, 4)
}
}
}
// Required fields
Section(String(localized: "csv_required_section")) {
MappingRow(
label: String(localized: "csv_field_source"),
index: $config.sourceIndex,
constant: $config.sourceConstant,
headers: currentHeaders,
supportsConstant: true,
supportsUseToday: false,
canSkip: false
)
MappingRow(
label: String(localized: "csv_field_value"),
index: $config.valueIndex,
constant: .constant(""),
headers: currentHeaders,
supportsConstant: false,
supportsUseToday: false,
canSkip: false
)
}
// Date section
Section {
MappingRow(
label: String(localized: "csv_field_date"),
index: $config.dateIndex,
constant: .constant(""),
headers: currentHeaders,
supportsConstant: false,
supportsUseToday: true,
canSkip: false
)
} header: {
Text("Date")
} footer: {
if config.dateIndex == ImportService.CSVMappingConfig.useToday {
Text(String(localized: "csv_no_date_hint"))
}
}
// Optional fields
Section(String(localized: "csv_optional_section")) {
MappingRowWithConstant(
label: String(localized: "csv_field_category"),
index: $config.categoryIndex,
constant: $config.categoryConstant,
headers: currentHeaders
)
MappingRow(
label: String(localized: "csv_field_contribution"),
index: $config.contributionIndex,
constant: .constant(""),
headers: currentHeaders,
supportsConstant: false,
supportsUseToday: false,
canSkip: true
)
MappingRow(
label: String(localized: "csv_field_notes"),
index: $config.notesIndex,
constant: .constant(""),
headers: currentHeaders,
supportsConstant: false,
supportsUseToday: false,
canSkip: true
)
}
}
.navigationTitle("Map Columns")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .topBarTrailing) {
Button("Import") {
onComplete(config)
dismiss()
}
.fontWeight(.semibold)
.disabled(!config.isValid)
}
}
.onAppear {
let p = ImportService.shared.previewCSV(csvContent)
self.preview = p
autoDetectColumns(from: p)
}
}
.presentationDetents([.large])
}
private var currentHeaders: [String] {
preview?.headers(hasHeaderRow: config.hasHeaderRow) ?? []
}
private func resetMappings() {
guard let preview else { return }
autoDetectColumns(from: preview)
}
/// Try to auto-detect columns by matching common header names
private func autoDetectColumns(from preview: ImportService.CSVPreview) {
guard config.hasHeaderRow else { return }
let headers = preview.headers(hasHeaderRow: true)
let lower = headers.map { $0.lowercased().trimmingCharacters(in: .whitespaces) }
for (i, h) in lower.enumerated() {
if config.sourceIndex == ImportService.CSVMappingConfig.notMapped &&
(h == "source" || h == "name" || h == "fuente" || h == "nombre") {
config.sourceIndex = i
}
if config.valueIndex == ImportService.CSVMappingConfig.notMapped &&
(h.hasPrefix("value") || h == "amount" || h == "valor" || h == "importe") {
config.valueIndex = i
}
if config.dateIndex == ImportService.CSVMappingConfig.useToday &&
(h == "date" || h == "fecha" || h.hasPrefix("date")) {
config.dateIndex = i
}
if config.categoryIndex == ImportService.CSVMappingConfig.constant &&
(h == "category" || h == "categoría" || h == "categoria" || h == "type") {
config.categoryIndex = i
}
if config.contributionIndex == ImportService.CSVMappingConfig.notMapped &&
(h.hasPrefix("contribution") || h == "aportación" || h == "aportacion") {
config.contributionIndex = i
}
if config.notesIndex == ImportService.CSVMappingConfig.notMapped &&
(h == "notes" || h == "notas" || h == "note" || h == "comments") {
config.notesIndex = i
}
}
}
}
// MARK: - Mapping Row (column or constant/useToday/skip)
private struct MappingRow: View {
let label: String
@Binding var index: Int
@Binding var constant: String
let headers: [String]
let supportsConstant: Bool
let supportsUseToday: Bool
let canSkip: Bool
private var selectionLabel: String {
if index == ImportService.CSVMappingConfig.useToday {
return String(localized: "csv_use_today")
}
if index == ImportService.CSVMappingConfig.constant {
return constant.isEmpty ? String(localized: "csv_enter_value") : constant
}
if index == ImportService.CSVMappingConfig.notMapped {
return String(localized: "csv_no_column")
}
return headers[safe: index] ?? "Column \(index + 1)"
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(label)
.font(.subheadline)
Spacer()
Menu {
// Column options
if !headers.isEmpty {
ForEach(Array(headers.enumerated()), id: \.offset) { i, h in
Button { index = i } label: {
Label(h, systemImage: index == i ? "checkmark" : "")
}
}
Divider()
}
if supportsUseToday {
Button {
index = ImportService.CSVMappingConfig.useToday
} label: {
Label(
String(localized: "csv_use_today"),
systemImage: index == ImportService.CSVMappingConfig.useToday ? "checkmark" : ""
)
}
}
if supportsConstant {
Button {
index = ImportService.CSVMappingConfig.constant
} label: {
Label(
String(localized: "csv_enter_value"),
systemImage: index == ImportService.CSVMappingConfig.constant ? "checkmark" : ""
)
}
}
if canSkip {
Button {
index = ImportService.CSVMappingConfig.notMapped
} label: {
Label(
String(localized: "csv_no_column"),
systemImage: index == ImportService.CSVMappingConfig.notMapped ? "checkmark" : ""
)
}
}
} label: {
HStack(spacing: 4) {
Text(selectionLabel)
.font(.subheadline)
.foregroundStyle(index == ImportService.CSVMappingConfig.notMapped ? .secondary : Color.appPrimary)
Image(systemName: "chevron.up.chevron.down")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
if index == ImportService.CSVMappingConfig.constant && supportsConstant {
TextField(String(localized: "csv_enter_value"), text: $constant)
.textFieldStyle(.roundedBorder)
.font(.subheadline)
}
}
.padding(.vertical, 2)
}
}
// MARK: - Mapping Row With Constant (column, constant value, or skip)
private struct MappingRowWithConstant: View {
let label: String
@Binding var index: Int
@Binding var constant: String
let headers: [String]
private var selectionLabel: String {
if index == ImportService.CSVMappingConfig.constant {
return constant.isEmpty ? String(localized: "csv_enter_value") : constant
}
if index == ImportService.CSVMappingConfig.notMapped {
return String(localized: "csv_no_column")
}
return headers[safe: index] ?? "Column \(index + 1)"
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(label)
.font(.subheadline)
Spacer()
Menu {
ForEach(Array(headers.enumerated()), id: \.offset) { i, h in
Button { index = i } label: {
Label(h, systemImage: index == i ? "checkmark" : "")
}
}
Divider()
Button {
index = ImportService.CSVMappingConfig.constant
} label: {
Label(
String(localized: "csv_enter_value"),
systemImage: index == ImportService.CSVMappingConfig.constant ? "checkmark" : ""
)
}
Button {
index = ImportService.CSVMappingConfig.notMapped
} label: {
Label(
String(localized: "csv_no_column"),
systemImage: index == ImportService.CSVMappingConfig.notMapped ? "checkmark" : ""
)
}
} label: {
HStack(spacing: 4) {
Text(selectionLabel)
.font(.subheadline)
.foregroundStyle(index == ImportService.CSVMappingConfig.notMapped ? .secondary : Color.appPrimary)
Image(systemName: "chevron.up.chevron.down")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
if index == ImportService.CSVMappingConfig.constant {
TextField(String(localized: "csv_enter_value"), text: $constant)
.textFieldStyle(.roundedBorder)
.font(.subheadline)
}
}
.padding(.vertical, 2)
}
}
// MARK: - Array safe subscript
private extension Array {
subscript(safe index: Int) -> Element? {
guard index >= 0, index < count else { return nil }
return self[index]
}
}