From ea78cb68feaec2102bd3d3c12c9afc48069037b6 Mon Sep 17 00:00:00 2001 From: Mitali Date: Thu, 21 Nov 2024 17:37:23 +0530 Subject: [PATCH 1/7] Implemented csv export maintenance events --- Basic-Car-Maintenance/Shared/Dashboard/Views/CSVEncoder.swift | 0 .../Shared/Dashboard/Views/CSVGeneratorView.swift | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 Basic-Car-Maintenance/Shared/Dashboard/Views/CSVEncoder.swift create mode 100644 Basic-Car-Maintenance/Shared/Dashboard/Views/CSVGeneratorView.swift diff --git a/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVEncoder.swift b/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVEncoder.swift new file mode 100644 index 00000000..e69de29b 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..e69de29b From 07ad16946767df9f04c8fbeb061fbff40f52b3ad Mon Sep 17 00:00:00 2001 From: Mitali Date: Thu, 21 Nov 2024 17:38:32 +0530 Subject: [PATCH 2/7] Implement csv export of maintenance events --- .../Shared/Dashboard/Views/CSVEncoder.swift | 259 ++++++++++++++++++ .../Dashboard/Views/CSVGeneratorView.swift | 74 +++++ .../Dashboard/Views/ExportOptionsView.swift | 56 +++- 3 files changed, 380 insertions(+), 9 deletions(-) diff --git a/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVEncoder.swift b/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVEncoder.swift index e69de29b..f5f93afb 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVEncoder.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVEncoder.swift @@ -0,0 +1,259 @@ +// +// CSVEncoder.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import Foundation +import SwiftUI + +struct CSVFile { + var events: [MaintenanceEvent] + + func csvData() -> String { + let table = CSVTable( + columns: [ + CSVColumn("Date") { $0.date.formatted() }, + CSVColumn("Vehicle Name", \.title), + CSVColumn("Notes", \.notes) + ], + configuration: CSVEncoderConfiguration(dateEncodingStrategy: .iso8601) + ) + return table.export(rows: events) + } + + func generateCSVFile(vehicle: String) -> URL? { + // Get the path to the Documents Directory + let fileManager = FileManager.default + guard let documentsDirectory = fileManager.urls( + for: .documentDirectory, in: .userDomainMask).first else { + print("Failed to locate the Documents Directory.") + return nil + } + + // Create the file URL + let fileName = "\(vehicle)-MaintenanceReport" + let fileURL = documentsDirectory.appendingPathComponent(fileName).appendingPathExtension("csv") + + do { + // Save the CSV content to the file + 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 + } + } +} + +extension CSVFile: Transferable { + static var transferRepresentation: some TransferRepresentation { + DataRepresentation(exportedContentType: .commaSeparatedText) { file in + Data(file.csvData().utf8) + } + } +} + +internal extension BidirectionalCollection where Element == String { + var commaDelimited: String { joined(separator: ",") } + var newlineDelimited: String { joined(separator: "\r\n") } +} + +public struct CSVColumn { + /// The header name to use for the column in the CSV file's first row. + public private(set) var header: String + + public private(set) var attribute: (Record) -> CSVEncodable + + public init( + _ header: String, + attribute: @escaping (Record) -> CSVEncodable + ) { + self.header = header + self.attribute = attribute + } +} + +extension CSVColumn { + public init ( + _ header: String, + _ keyPath: KeyPath + ) { + self.init( + header, + attribute: { $0[keyPath: keyPath] } + ) + } +} + +public protocol CSVEncodable { + /// Derive the string representation to be used in the exported CSV. + func encode(configuration: CSVEncoderConfiguration) -> String +} + +extension String: CSVEncodable { + public func encode(configuration: CSVEncoderConfiguration) -> String { + self + } +} + +extension Date: CSVEncodable { + public 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 { + public func encode(configuration: CSVEncoderConfiguration) -> String { + uuidString + } +} + +extension Int: CSVEncodable { + public func encode(configuration: CSVEncoderConfiguration) -> String { + String(self) + } +} + +extension Double: CSVEncodable { + public func encode(configuration: CSVEncoderConfiguration) -> String { + String(self) + } +} + +extension Bool: CSVEncodable { + public func encode(configuration: CSVEncoderConfiguration) -> String { + let (trueValue, falseValue) = configuration.boolEncodingStrategy.encodingValues + + return self == true ? trueValue : falseValue + } +} + +extension Optional: CSVEncodable where Wrapped: CSVEncodable { + public func encode(configuration: CSVEncoderConfiguration) -> String { + switch self { + case .none: + "" + case .some(let wrapped): + wrapped.encode(configuration: configuration) + } + } +} + +extension CSVEncodable { + internal 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 + } + } +} + +public struct CSVEncoderConfiguration { + /// The strategy to use when encoding dates. + public private(set) var dateEncodingStrategy: DateEncodingStrategy = .iso8601 + + /// The strategy to use when encoding Boolean values. + public private(set) var boolEncodingStrategy: BoolEncodingStrategy = .trueFalse + + public init( + dateEncodingStrategy: DateEncodingStrategy = .iso8601, + boolEncodingStrategy: BoolEncodingStrategy = .trueFalse + ) { + self.dateEncodingStrategy = dateEncodingStrategy + self.boolEncodingStrategy = boolEncodingStrategy + } + + /// The strategy to use when encoding `Date` objects for CSV output. + public 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. + public enum BoolEncodingStrategy { + case trueFalse + case trueFalseUppercase + case yesNo + case yesNoUppercase + case integer + case custom(true: String, false: String) + } + public static var `default`: CSVEncoderConfiguration = CSVEncoderConfiguration() +} + +internal extension CSVEncoderConfiguration.BoolEncodingStrategy { + var encodingValues: (String, String) { + switch self { + 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) + } + } +} + +public struct CSVTable { + /// A description of all the columns of the CSV file, order from left to right. + public private(set) var columns: [CSVColumn] + /// The set of configuration parameters to use while encoding attributes and the whole file. + public private(set) var configuration: CSVEncoderConfiguration + + /// Create a CSV table definition. + public init( + columns: [CSVColumn], + configuration: CSVEncoderConfiguration = .default + ) { + self.columns = columns + self.configuration = configuration + } + + /// Constructs a CSV text file structure from the given rows of data. + public func export( + rows: any Sequence + ) -> String { + ([headers] + allRows(rows: rows)).newlineDelimited + } + + // MARK: - + + private var headers: String { + columns.map { $0.header.escapedOutput(configuration: configuration) }.commaDelimited + } + + 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 index e69de29b..d124532c 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVGeneratorView.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVGeneratorView.swift @@ -0,0 +1,74 @@ +// +// CSVGeneratorView.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import Foundation +import SwiftUI + +struct CSVGeneratorView: View { + let events: [MaintenanceEvent] + @Binding var csvFileURL: URL? + + var body: some View { + VStack { + List { + Grid { + GridRow { + Text("Date") + Text("Vehicle Name") + Text("Notes") + } + .bold() + .frame(height: 40) + Divider() + ForEach(events) { event in + GridRow { + Text(event.date.formatted()) + .frame(maxWidth: 100, maxHeight: .infinity) + Text(event.title) + Text(event.notes) + } + if event != events.last { + Divider() + } + } + } + } + VStack { + if let fileURL = csvFileURL { + ShareLink(item: fileURL) { + Label("Share", systemImage: SFSymbol.share) + } + } else { + Text("Error: Failed to save CSV file.") + .foregroundColor(.red) + .font(.subheadline) + } + } + .safeAreaPadding(.bottom) + } + } +} + +#Preview { + CSVGeneratorView( + events: [ + .init( + vehicleID: "1", + title: "Creta", + date: .now, + notes: "Service" + ), + .init( + vehicleID: "1", + title: "Creta", + date: .now, + notes: "Service") + ], + csvFileURL: .constant(nil) + ) +} diff --git a/Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift b/Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift index 6b59bb5e..ecd6fd85 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift @@ -9,12 +9,22 @@ 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 showingExporter = false + @State private var csvFile: CSVFile? + @State private var csvFileURL: URL? private let dataSource: [Vehicle: [MaintenanceEvent]] @@ -41,22 +51,41 @@ struct ExportOptionsView: View { .padding(.horizontal) .toolbar { ToolbarItem(placement: .topBarTrailing) { - Button("Export") { + Menu(content: { + Picker("Export", selection: $selectedOption) { + ForEach(ExportOption.allCases, id: \.self) { 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 + showingExporter = true + let csv: CSVFile = CSVFile(events: events) + csvFileURL = csv.generateCSVFile(vehicle: selectedVehicle.name) + case .none: + print("No option selected") + } } else { showingErrorAlert = true } } - } + }) } } .sheet(isPresented: $isShowingThumbnail) { @@ -80,6 +109,15 @@ struct ExportOptionsView: View { .presentationDetents([.medium]) } } + .sheet(isPresented: $showingExporter) { + if let selectedVehicle, + let events = self.dataSource[selectedVehicle] { + CSVGeneratorView( + events: events, + csvFileURL: $csvFileURL + ).presentationDetents([.medium]) + } + } .alert( Text( "Failed to Export Events", From 3b7bdbdbe2e85584f620737b797db9a0b61525d9 Mon Sep 17 00:00:00 2001 From: Mitali Date: Wed, 27 Nov 2024 23:57:49 +0530 Subject: [PATCH 3/7] refactor code and added close button --- .../Shared/Dashboard/Views/CSVEncoder.swift | 49 ------- .../Dashboard/Views/CSVGeneratorView.swift | 120 ++++++++++++------ .../Dashboard/Views/ExportOptionsView.swift | 6 +- 3 files changed, 83 insertions(+), 92 deletions(-) diff --git a/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVEncoder.swift b/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVEncoder.swift index f5f93afb..b6be0671 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVEncoder.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVEncoder.swift @@ -7,55 +7,6 @@ // import Foundation -import SwiftUI - -struct CSVFile { - var events: [MaintenanceEvent] - - func csvData() -> String { - let table = CSVTable( - columns: [ - CSVColumn("Date") { $0.date.formatted() }, - CSVColumn("Vehicle Name", \.title), - CSVColumn("Notes", \.notes) - ], - configuration: CSVEncoderConfiguration(dateEncodingStrategy: .iso8601) - ) - return table.export(rows: events) - } - - func generateCSVFile(vehicle: String) -> URL? { - // Get the path to the Documents Directory - let fileManager = FileManager.default - guard let documentsDirectory = fileManager.urls( - for: .documentDirectory, in: .userDomainMask).first else { - print("Failed to locate the Documents Directory.") - return nil - } - - // Create the file URL - let fileName = "\(vehicle)-MaintenanceReport" - let fileURL = documentsDirectory.appendingPathComponent(fileName).appendingPathExtension("csv") - - do { - // Save the CSV content to the file - 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 - } - } -} - -extension CSVFile: Transferable { - static var transferRepresentation: some TransferRepresentation { - DataRepresentation(exportedContentType: .commaSeparatedText) { file in - Data(file.csvData().utf8) - } - } -} internal extension BidirectionalCollection where Element == String { var commaDelimited: String { joined(separator: ",") } diff --git a/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVGeneratorView.swift b/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVGeneratorView.swift index d124532c..1b3c9ab4 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVGeneratorView.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVGeneratorView.swift @@ -10,46 +10,96 @@ import Foundation import SwiftUI struct CSVGeneratorView: View { + @Environment(\.dismiss) var dismiss + let events: [MaintenanceEvent] - @Binding var csvFileURL: URL? + let vehicleName: String + + func csvData() -> String { + let table = CSVTable( + columns: [ + CSVColumn("Date") { $0.date.formatted() }, + CSVColumn("Vehicle Name", \.title), + CSVColumn("Notes", \.notes) + ], + configuration: CSVEncoderConfiguration(dateEncodingStrategy: .iso8601) + ) + return table.export(rows: events) + } + + func generateCSVFile(vehicle: String) -> URL? { + // Get the path to the Documents Directory + let fileManager = FileManager.default + guard let documentsDirectory = fileManager.urls( + for: .documentDirectory, in: .userDomainMask).first else { + print("Failed to locate the Documents Directory.") + return nil + } + + // Create the file URL + let fileName = "\(vehicle)-MaintenanceReport" + let fileURL = documentsDirectory.appendingPathComponent(fileName).appendingPathExtension("csv") + + do { + // Save the CSV content to the file + 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 + } + } var body: some View { - VStack { - List { - Grid { - GridRow { - Text("Date") - Text("Vehicle Name") - Text("Notes") - } - .bold() - .frame(height: 40) - Divider() - ForEach(events) { event in + NavigationStack { + VStack(spacing: 0) { + List { + Grid(alignment: .leading, verticalSpacing: 5) { GridRow { - Text(event.date.formatted()) - .frame(maxWidth: 100, maxHeight: .infinity) - Text(event.title) - Text(event.notes) + 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() + } } - 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) } - VStack { - if let fileURL = csvFileURL { - ShareLink(item: fileURL) { - Label("Share", systemImage: SFSymbol.share) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Close") { + dismiss() } - } else { - Text("Error: Failed to save CSV file.") - .foregroundColor(.red) - .font(.subheadline) } } - .safeAreaPadding(.bottom) } } } @@ -59,16 +109,10 @@ struct CSVGeneratorView: View { events: [ .init( vehicleID: "1", - title: "Creta", + title: "1st service", date: .now, - notes: "Service" - ), - .init( - vehicleID: "1", - title: "Creta", - date: .now, - notes: "Service") - ], - csvFileURL: .constant(nil) + 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 ecd6fd85..843e1dd3 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift @@ -23,8 +23,6 @@ struct ExportOptionsView: View { @State private var showingErrorAlert = false @State private var selectedOption: ExportOption? @State private var showingExporter = false - @State private var csvFile: CSVFile? - @State private var csvFileURL: URL? private let dataSource: [Vehicle: [MaintenanceEvent]] @@ -76,8 +74,6 @@ struct ExportOptionsView: View { case .csv: selectedOption = nil showingExporter = true - let csv: CSVFile = CSVFile(events: events) - csvFileURL = csv.generateCSVFile(vehicle: selectedVehicle.name) case .none: print("No option selected") } @@ -114,7 +110,7 @@ struct ExportOptionsView: View { let events = self.dataSource[selectedVehicle] { CSVGeneratorView( events: events, - csvFileURL: $csvFileURL + vehicleName: selectedVehicle.name ).presentationDetents([.medium]) } } From b7788a60bc0a28c551ab86c1b5d23d1628cd7b67 Mon Sep 17 00:00:00 2001 From: Mikaela Caron Date: Thu, 12 Dec 2024 02:14:34 -0500 Subject: [PATCH 4/7] make code related to the CSVEncoder internal --- .../Shared/Dashboard/Views/CSVEncoder.swift | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVEncoder.swift b/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVEncoder.swift index b6be0671..48c6f66e 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVEncoder.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVEncoder.swift @@ -8,18 +8,18 @@ import Foundation -internal extension BidirectionalCollection where Element == String { +extension BidirectionalCollection where Element == String { var commaDelimited: String { joined(separator: ",") } var newlineDelimited: String { joined(separator: "\r\n") } } -public struct CSVColumn { +struct CSVColumn { /// The header name to use for the column in the CSV file's first row. - public private(set) var header: String + private(set) var header: String - public private(set) var attribute: (Record) -> CSVEncodable + private(set) var attribute: (Record) -> CSVEncodable - public init( + init( _ header: String, attribute: @escaping (Record) -> CSVEncodable ) { @@ -29,7 +29,7 @@ public struct CSVColumn { } extension CSVColumn { - public init ( + init ( _ header: String, _ keyPath: KeyPath ) { @@ -40,13 +40,13 @@ extension CSVColumn { } } -public protocol CSVEncodable { +protocol CSVEncodable { /// Derive the string representation to be used in the exported CSV. func encode(configuration: CSVEncoderConfiguration) -> String } extension String: CSVEncodable { - public func encode(configuration: CSVEncoderConfiguration) -> String { + func encode(configuration: CSVEncoderConfiguration) -> String { self } } @@ -93,7 +93,7 @@ extension Bool: CSVEncodable { } extension Optional: CSVEncodable where Wrapped: CSVEncodable { - public func encode(configuration: CSVEncoderConfiguration) -> String { + func encode(configuration: CSVEncoderConfiguration) -> String { switch self { case .none: "" @@ -104,7 +104,7 @@ extension Optional: CSVEncodable where Wrapped: CSVEncodable { } extension CSVEncodable { - internal func escapedOutput(configuration: CSVEncoderConfiguration) -> String { + func escapedOutput(configuration: CSVEncoderConfiguration) -> String { let output = self.encode(configuration: configuration) if output.contains(",") || output.contains("\"") || output.contains(#"\n"#) || output.hasPrefix(" ") || output.hasSuffix(" ") { @@ -174,14 +174,14 @@ internal extension CSVEncoderConfiguration.BoolEncodingStrategy { } } -public struct CSVTable { +struct CSVTable { /// A description of all the columns of the CSV file, order from left to right. - public private(set) var columns: [CSVColumn] + private(set) var columns: [CSVColumn] /// The set of configuration parameters to use while encoding attributes and the whole file. - public private(set) var configuration: CSVEncoderConfiguration + private(set) var configuration: CSVEncoderConfiguration /// Create a CSV table definition. - public init( + init( columns: [CSVColumn], configuration: CSVEncoderConfiguration = .default ) { @@ -190,7 +190,7 @@ public struct CSVTable { } /// Constructs a CSV text file structure from the given rows of data. - public func export( + func export( rows: any Sequence ) -> String { ([headers] + allRows(rows: rows)).newlineDelimited From ac6cf5933be8fffa3e2102e0f45d2d0d72bfba74 Mon Sep 17 00:00:00 2001 From: Mikaela Caron Date: Thu, 12 Dec 2024 02:15:17 -0500 Subject: [PATCH 5/7] PR suggestions --- .../Shared/Dashboard/Views/ExportOptionsView.swift | 10 +++++----- Basic-Car-Maintenance/Shared/Localizable.xcstrings | 6 ++++++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift b/Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift index 843e1dd3..986cb0b8 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift @@ -49,15 +49,15 @@ struct ExportOptionsView: View { .padding(.horizontal) .toolbar { ToolbarItem(placement: .topBarTrailing) { - Menu(content: { + Menu { Picker("Export", selection: $selectedOption) { - ForEach(ExportOption.allCases, id: \.self) { option in + ForEach(ExportOption.allCases) { option in Text(option.rawValue).tag(option) } } - }, label: { + } label: { Text("Export") - }) + } .onChange(of: selectedOption, { _, _ in if let selectedVehicle, let events = self.dataSource[selectedVehicle] { @@ -75,7 +75,7 @@ struct ExportOptionsView: View { selectedOption = nil showingExporter = true case .none: - print("No option selected") + print("No option selected, do nothing") } } else { showingErrorAlert = true 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" : { From 2f0811036a2386f8f8667e620577b0111096812e Mon Sep 17 00:00:00 2001 From: Mikaela Caron Date: Thu, 12 Dec 2024 02:50:33 -0500 Subject: [PATCH 6/7] PR suggestion, move functions below body --- .../Dashboard/Views/CSVGeneratorView.swift | 69 +++++++++---------- 1 file changed, 33 insertions(+), 36 deletions(-) diff --git a/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVGeneratorView.swift b/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVGeneratorView.swift index 1b3c9ab4..b5deaa0e 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVGeneratorView.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVGeneratorView.swift @@ -15,42 +15,6 @@ struct CSVGeneratorView: View { let events: [MaintenanceEvent] let vehicleName: String - func csvData() -> String { - let table = CSVTable( - columns: [ - CSVColumn("Date") { $0.date.formatted() }, - CSVColumn("Vehicle Name", \.title), - CSVColumn("Notes", \.notes) - ], - configuration: CSVEncoderConfiguration(dateEncodingStrategy: .iso8601) - ) - return table.export(rows: events) - } - - func generateCSVFile(vehicle: String) -> URL? { - // Get the path to the Documents Directory - let fileManager = FileManager.default - guard let documentsDirectory = fileManager.urls( - for: .documentDirectory, in: .userDomainMask).first else { - print("Failed to locate the Documents Directory.") - return nil - } - - // Create the file URL - let fileName = "\(vehicle)-MaintenanceReport" - let fileURL = documentsDirectory.appendingPathComponent(fileName).appendingPathExtension("csv") - - do { - // Save the CSV content to the file - 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 - } - } - var body: some View { NavigationStack { VStack(spacing: 0) { @@ -102,6 +66,39 @@ struct CSVGeneratorView: View { } } } + + private func csvData() -> String { + let table = CSVTable( + columns: [ + CSVColumn("Date") { $0.date.formatted() }, + CSVColumn("Vehicle Name", \.title), + CSVColumn("Notes", \.notes) + ], + configuration: CSVEncoderConfiguration(dateEncodingStrategy: .iso8601) + ) + 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 { From 72dd5a61f598d23235935d3a1ab060f7a2adb043 Mon Sep 17 00:00:00 2001 From: Mikaela Caron Date: Thu, 12 Dec 2024 03:03:14 -0500 Subject: [PATCH 7/7] PR Suggestions --- .../Dashboard/{Views => }/CSVEncoder.swift | 67 +++++++------------ .../Dashboard/Views/CSVGeneratorView.swift | 2 +- .../Dashboard/Views/ExportOptionsView.swift | 12 ++-- 3 files changed, 32 insertions(+), 49 deletions(-) rename Basic-Car-Maintenance/Shared/Dashboard/{Views => }/CSVEncoder.swift (78%) diff --git a/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVEncoder.swift b/Basic-Car-Maintenance/Shared/Dashboard/CSVEncoder.swift similarity index 78% rename from Basic-Car-Maintenance/Shared/Dashboard/Views/CSVEncoder.swift rename to Basic-Car-Maintenance/Shared/Dashboard/CSVEncoder.swift index 48c6f66e..435ecb6a 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVEncoder.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/CSVEncoder.swift @@ -19,24 +19,13 @@ struct CSVColumn { private(set) var attribute: (Record) -> CSVEncodable - init( - _ header: String, - attribute: @escaping (Record) -> CSVEncodable - ) { + init( _ header: String, attribute: @escaping (Record) -> CSVEncodable) { self.header = header self.attribute = attribute } -} - -extension CSVColumn { - init ( - _ header: String, - _ keyPath: KeyPath - ) { - self.init( - header, - attribute: { $0[keyPath: keyPath] } - ) + + init (_ header: String, _ keyPath: KeyPath) { + self.init(header, attribute: { $0[keyPath: keyPath] }) } } @@ -52,7 +41,7 @@ extension String: CSVEncodable { } extension Date: CSVEncodable { - public func encode(configuration: CSVEncoderConfiguration) -> String { + func encode(configuration: CSVEncoderConfiguration) -> String { switch configuration.dateEncodingStrategy { case .deferredToDate: String(self.timeIntervalSinceReferenceDate) @@ -67,26 +56,26 @@ extension Date: CSVEncodable { } extension UUID: CSVEncodable { - public func encode(configuration: CSVEncoderConfiguration) -> String { + func encode(configuration: CSVEncoderConfiguration) -> String { uuidString } } extension Int: CSVEncodable { - public func encode(configuration: CSVEncoderConfiguration) -> String { + func encode(configuration: CSVEncoderConfiguration) -> String { String(self) } } extension Double: CSVEncodable { - public func encode(configuration: CSVEncoderConfiguration) -> String { + func encode(configuration: CSVEncoderConfiguration) -> String { String(self) } } extension Bool: CSVEncodable { - public func encode(configuration: CSVEncoderConfiguration) -> String { - let (trueValue, falseValue) = configuration.boolEncodingStrategy.encodingValues + func encode(configuration: CSVEncoderConfiguration) -> String { + let (trueValue, falseValue) = configuration.encodingValues return self == true ? trueValue : falseValue } @@ -120,14 +109,14 @@ extension CSVEncodable { } } -public struct CSVEncoderConfiguration { +struct CSVEncoderConfiguration { /// The strategy to use when encoding dates. - public private(set) var dateEncodingStrategy: DateEncodingStrategy = .iso8601 + private(set) var dateEncodingStrategy: DateEncodingStrategy = .iso8601 /// The strategy to use when encoding Boolean values. - public private(set) var boolEncodingStrategy: BoolEncodingStrategy = .trueFalse + private(set) var boolEncodingStrategy: BoolEncodingStrategy = .trueFalse - public init( + init( dateEncodingStrategy: DateEncodingStrategy = .iso8601, boolEncodingStrategy: BoolEncodingStrategy = .trueFalse ) { @@ -136,7 +125,7 @@ public struct CSVEncoderConfiguration { } /// The strategy to use when encoding `Date` objects for CSV output. - public enum DateEncodingStrategy { + enum DateEncodingStrategy { case deferredToDate case iso8601 case formatted(DateFormatter) @@ -144,7 +133,7 @@ public struct CSVEncoderConfiguration { } /// The strategy to use when encoding `Bool` objects for CSV output. - public enum BoolEncodingStrategy { + enum BoolEncodingStrategy { case trueFalse case trueFalseUppercase case yesNo @@ -152,12 +141,9 @@ public struct CSVEncoderConfiguration { case integer case custom(true: String, false: String) } - public static var `default`: CSVEncoderConfiguration = CSVEncoderConfiguration() -} - -internal extension CSVEncoderConfiguration.BoolEncodingStrategy { + var encodingValues: (String, String) { - switch self { + switch boolEncodingStrategy { case .trueFalse: return ("true", "false") case .trueFalseUppercase: @@ -172,14 +158,21 @@ internal extension CSVEncoderConfiguration.BoolEncodingStrategy { 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], @@ -190,18 +183,10 @@ struct CSVTable { } /// Constructs a CSV text file structure from the given rows of data. - func export( - rows: any Sequence - ) -> String { + func export(rows: any Sequence) -> String { ([headers] + allRows(rows: rows)).newlineDelimited } - // MARK: - - - private var headers: String { - columns.map { $0.header.escapedOutput(configuration: configuration) }.commaDelimited - } - 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 index b5deaa0e..3efa75e8 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVGeneratorView.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVGeneratorView.swift @@ -74,7 +74,7 @@ struct CSVGeneratorView: View { CSVColumn("Vehicle Name", \.title), CSVColumn("Notes", \.notes) ], - configuration: CSVEncoderConfiguration(dateEncodingStrategy: .iso8601) + configuration: CSVEncoderConfiguration() ) return table.export(rows: events) } diff --git a/Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift b/Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift index 986cb0b8..f2092a36 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift @@ -22,7 +22,7 @@ struct ExportOptionsView: View { @State private var pdfDoc: PDFDocument? @State private var showingErrorAlert = false @State private var selectedOption: ExportOption? - @State private var showingExporter = false + @State private var showingCSVExporter = false private let dataSource: [Vehicle: [MaintenanceEvent]] @@ -73,7 +73,7 @@ struct ExportOptionsView: View { isShowingThumbnail = true case .csv: selectedOption = nil - showingExporter = true + showingCSVExporter = true case .none: print("No option selected, do nothing") } @@ -105,13 +105,11 @@ struct ExportOptionsView: View { .presentationDetents([.medium]) } } - .sheet(isPresented: $showingExporter) { + .sheet(isPresented: $showingCSVExporter) { if let selectedVehicle, let events = self.dataSource[selectedVehicle] { - CSVGeneratorView( - events: events, - vehicleName: selectedVehicle.name - ).presentationDetents([.medium]) + CSVGeneratorView(events: events, vehicleName: selectedVehicle.name) + .presentationDetents([.medium]) } } .alert(