-
-
Notifications
You must be signed in to change notification settings - Fork 149
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Implement CSV export of maintenance events (#371)
* Implemented csv export maintenance events * Implement csv export of maintenance events * refactor code and added close button * make code related to the CSVEncoder internal * PR suggestions * PR suggestion, move functions below body * PR Suggestions --------- Co-authored-by: Mikaela Caron <mikaelacaron@gmail.com>
- Loading branch information
1 parent
b8055a9
commit 1d68c23
Showing
4 changed files
with
357 additions
and
9 deletions.
There are no files selected for viewing
195 changes: 195 additions & 0 deletions
195
Basic-Car-Maintenance/Shared/Dashboard/CSVEncoder.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<Record> { | ||
/// 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<T: CSVEncodable> (_ header: String, _ keyPath: KeyPath<Record, T>) { | ||
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<Record> { | ||
/// A description of all the columns of the CSV file, order from left to right. | ||
private(set) var columns: [CSVColumn<Record>] | ||
|
||
/// 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<Record>], | ||
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<Record>) -> String { | ||
([headers] + allRows(rows: rows)).newlineDelimited | ||
} | ||
|
||
private func allRows(rows: any Sequence<Record>) -> [String] { | ||
rows.map { row in | ||
columns.map { $0.attribute(row).escapedOutput(configuration: configuration) }.commaDelimited | ||
} | ||
} | ||
} |
115 changes: 115 additions & 0 deletions
115
Basic-Car-Maintenance/Shared/Dashboard/Views/CSVGeneratorView.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<MaintenanceEvent>( | ||
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: "" | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.