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)) .foregroundColor(.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: .navigationBarLeading) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .navigationBarTrailing) { 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) .foregroundColor(index == ImportService.CSVMappingConfig.notMapped ? .secondary : .appPrimary) Image(systemName: "chevron.up.chevron.down") .font(.caption2) .foregroundColor(.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) .foregroundColor(index == ImportService.CSVMappingConfig.notMapped ? .secondary : .appPrimary) Image(systemName: "chevron.up.chevron.down") .font(.caption2) .foregroundColor(.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] } }