diff --git a/Basic-Car-Maintenance/Shared/Dashboard/CSVEncoder.swift b/Basic-Car-Maintenance/Shared/Dashboard/CSVEncoder.swift new file mode 100644 index 00000000..435ecb6a --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Dashboard/CSVEncoder.swift @@ -0,0 +1,195 @@ +// +// CSVEncoder.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import Foundation + +extension BidirectionalCollection where Element == String { + var commaDelimited: String { joined(separator: ",") } + var newlineDelimited: String { joined(separator: "\r\n") } +} + +struct CSVColumn { + /// The header name to use for the column in the CSV file's first row. + private(set) var header: String + + private(set) var attribute: (Record) -> CSVEncodable + + init( _ header: String, attribute: @escaping (Record) -> CSVEncodable) { + self.header = header + self.attribute = attribute + } + + init (_ header: String, _ keyPath: KeyPath) { + self.init(header, attribute: { $0[keyPath: keyPath] }) + } +} + +protocol CSVEncodable { + /// Derive the string representation to be used in the exported CSV. + func encode(configuration: CSVEncoderConfiguration) -> String +} + +extension String: CSVEncodable { + func encode(configuration: CSVEncoderConfiguration) -> String { + self + } +} + +extension Date: CSVEncodable { + func encode(configuration: CSVEncoderConfiguration) -> String { + switch configuration.dateEncodingStrategy { + case .deferredToDate: + String(self.timeIntervalSinceReferenceDate) + case .iso8601: + ISO8601DateFormatter().string(from: self) + case .formatted(let dateFormatter): + dateFormatter.string(from: self) + case .custom(let customFunc): + customFunc(self) + } + } +} + +extension UUID: CSVEncodable { + func encode(configuration: CSVEncoderConfiguration) -> String { + uuidString + } +} + +extension Int: CSVEncodable { + func encode(configuration: CSVEncoderConfiguration) -> String { + String(self) + } +} + +extension Double: CSVEncodable { + func encode(configuration: CSVEncoderConfiguration) -> String { + String(self) + } +} + +extension Bool: CSVEncodable { + func encode(configuration: CSVEncoderConfiguration) -> String { + let (trueValue, falseValue) = configuration.encodingValues + + return self == true ? trueValue : falseValue + } +} + +extension Optional: CSVEncodable where Wrapped: CSVEncodable { + func encode(configuration: CSVEncoderConfiguration) -> String { + switch self { + case .none: + "" + case .some(let wrapped): + wrapped.encode(configuration: configuration) + } + } +} + +extension CSVEncodable { + func escapedOutput(configuration: CSVEncoderConfiguration) -> String { + let output = self.encode(configuration: configuration) + if output.contains(",") || output.contains("\"") || output.contains(#"\n"#) + || output.hasPrefix(" ") || output.hasSuffix(" ") { + // Escape existing double quotes by doubling them + let escapedQuotes = output.replacingOccurrences(of: "\"", with: "\"\"") + + // Wrap the string in double quotes + return "\"\(escapedQuotes)\"" + } else { + // No special characters, return as is + return output + } + } +} + +struct CSVEncoderConfiguration { + /// The strategy to use when encoding dates. + private(set) var dateEncodingStrategy: DateEncodingStrategy = .iso8601 + + /// The strategy to use when encoding Boolean values. + private(set) var boolEncodingStrategy: BoolEncodingStrategy = .trueFalse + + init( + dateEncodingStrategy: DateEncodingStrategy = .iso8601, + boolEncodingStrategy: BoolEncodingStrategy = .trueFalse + ) { + self.dateEncodingStrategy = dateEncodingStrategy + self.boolEncodingStrategy = boolEncodingStrategy + } + + /// The strategy to use when encoding `Date` objects for CSV output. + enum DateEncodingStrategy { + case deferredToDate + case iso8601 + case formatted(DateFormatter) + case custom(@Sendable (Date) -> String) + } + + /// The strategy to use when encoding `Bool` objects for CSV output. + enum BoolEncodingStrategy { + case trueFalse + case trueFalseUppercase + case yesNo + case yesNoUppercase + case integer + case custom(true: String, false: String) + } + + var encodingValues: (String, String) { + switch boolEncodingStrategy { + case .trueFalse: + return ("true", "false") + case .trueFalseUppercase: + return ("TRUE", "FALSE") + case .yesNo: + return ("yes", "no") + case .yesNoUppercase: + return ("YES", "NO") + case .integer: + return ("1", "0") + case .custom(let trueValue, let falseValue): + return (trueValue, falseValue) + } + } + + static var `default`: CSVEncoderConfiguration = CSVEncoderConfiguration() +} + +struct CSVTable { + /// A description of all the columns of the CSV file, order from left to right. + private(set) var columns: [CSVColumn] + + /// The set of configuration parameters to use while encoding attributes and the whole file. + private(set) var configuration: CSVEncoderConfiguration + + private var headers: String { + columns.map { $0.header.escapedOutput(configuration: configuration) }.commaDelimited + } + + /// Create a CSV table definition. + init( + columns: [CSVColumn], + configuration: CSVEncoderConfiguration = .default + ) { + self.columns = columns + self.configuration = configuration + } + + /// Constructs a CSV text file structure from the given rows of data. + func export(rows: any Sequence) -> String { + ([headers] + allRows(rows: rows)).newlineDelimited + } + + private func allRows(rows: any Sequence) -> [String] { + rows.map { row in + columns.map { $0.attribute(row).escapedOutput(configuration: configuration) }.commaDelimited + } + } +} diff --git a/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVGeneratorView.swift b/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVGeneratorView.swift new file mode 100644 index 00000000..3efa75e8 --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVGeneratorView.swift @@ -0,0 +1,115 @@ +// +// CSVGeneratorView.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import Foundation +import SwiftUI + +struct CSVGeneratorView: View { + @Environment(\.dismiss) var dismiss + + let events: [MaintenanceEvent] + let vehicleName: String + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + List { + Grid(alignment: .leading, verticalSpacing: 5) { + GridRow { + Text("Date") + Text("Vehicle Name") + Text("Notes") + } + .font(.headline) + .frame(height: 50) + + Divider() + + ForEach(events) { event in + GridRow(alignment: .firstTextBaseline) { + Text(event.date.formatted()) + .frame(maxWidth: 100, maxHeight: .infinity) + Text(event.title) + Text(event.notes) + } + .font(.subheadline) + if event != events.last { + Divider() + } + } + } + } + VStack { + if let fileURL = generateCSVFile(vehicle: vehicleName) { + ShareLink(item: fileURL) { + Label("Share", systemImage: SFSymbol.share) + } + } else { + Text("Error: Failed to save CSV file.") + .foregroundColor(.red) + .font(.subheadline) + } + } + .safeAreaPadding(.bottom) + } + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Close") { + dismiss() + } + } + } + } + } + + private func csvData() -> String { + let table = CSVTable( + columns: [ + CSVColumn("Date") { $0.date.formatted() }, + CSVColumn("Vehicle Name", \.title), + CSVColumn("Notes", \.notes) + ], + configuration: CSVEncoderConfiguration() + ) + return table.export(rows: events) + } + + private func generateCSVFile(vehicle: String) -> URL? { + let fileManager = FileManager.default + guard let documentsDirectory = fileManager.urls( + for: .documentDirectory, in: .userDomainMask).first else { + print("Failed to locate the Documents Directory.") + return nil + } + + let fileName = "\(vehicle)-MaintenanceReport" + let fileURL = documentsDirectory.appendingPathComponent(fileName).appendingPathExtension("csv") + + do { + try csvData().write(to: fileURL, atomically: true, encoding: .utf8) + print("File saved to \(fileURL)") + return fileURL + } catch { + print("Failed to save CSV file: \(error.localizedDescription)") + return nil + } + } +} + +#Preview { + CSVGeneratorView( + events: [ + .init( + vehicleID: "1", + title: "1st service", + date: .now, + notes: "Maintenance and service" + )], + vehicleName: "" + ) +} diff --git a/Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift b/Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift index 6b59bb5e..f2092a36 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift @@ -9,12 +9,20 @@ import SwiftUI import PDFKit +enum ExportOption: String, Identifiable, CaseIterable { + case pdf = "PDF" + case csv = "CSV" + var id: Self { self } +} + struct ExportOptionsView: View { @Environment(\.dismiss) var dismiss @State private var selectedVehicle: Vehicle? @State private var isShowingThumbnail = false @State private var pdfDoc: PDFDocument? @State private var showingErrorAlert = false + @State private var selectedOption: ExportOption? + @State private var showingCSVExporter = false private let dataSource: [Vehicle: [MaintenanceEvent]] @@ -41,22 +49,39 @@ struct ExportOptionsView: View { .padding(.horizontal) .toolbar { ToolbarItem(placement: .topBarTrailing) { - Button("Export") { + Menu { + Picker("Export", selection: $selectedOption) { + ForEach(ExportOption.allCases) { option in + Text(option.rawValue).tag(option) + } + } + } label: { + Text("Export") + } + .onChange(of: selectedOption, { _, _ in if let selectedVehicle, let events = self.dataSource[selectedVehicle] { - if !events.isEmpty { - let pdfGenerator = CarMaintenancePDFGenerator( - vehicleName: selectedVehicle.name, - events: events - ) - self.pdfDoc = pdfGenerator.generatePDF() - isShowingThumbnail = true + switch selectedOption { + case .pdf: + selectedOption = nil + let pdfGenerator = CarMaintenancePDFGenerator( + vehicleName: selectedVehicle.name, + events: events + ) + self.pdfDoc = pdfGenerator.generatePDF() + isShowingThumbnail = true + case .csv: + selectedOption = nil + showingCSVExporter = true + case .none: + print("No option selected, do nothing") + } } else { showingErrorAlert = true } } - } + }) } } .sheet(isPresented: $isShowingThumbnail) { @@ -80,6 +105,13 @@ struct ExportOptionsView: View { .presentationDetents([.medium]) } } + .sheet(isPresented: $showingCSVExporter) { + if let selectedVehicle, + let events = self.dataSource[selectedVehicle] { + CSVGeneratorView(events: events, vehicleName: selectedVehicle.name) + .presentationDetents([.medium]) + } + } .alert( Text( "Failed to Export Events", diff --git a/Basic-Car-Maintenance/Shared/Localizable.xcstrings b/Basic-Car-Maintenance/Shared/Localizable.xcstrings index 6f0b19f4..63b36af8 100644 --- a/Basic-Car-Maintenance/Shared/Localizable.xcstrings +++ b/Basic-Car-Maintenance/Shared/Localizable.xcstrings @@ -1211,6 +1211,9 @@ } } } + }, + "Close" : { + }, "Color" : { "localizations" : { @@ -2051,6 +2054,9 @@ } } } + }, + "Error: Failed to save CSV file." : { + }, "Export" : { "localizations" : {