Skip to content

Commit

Permalink
Implement CSV export of maintenance events (#371)
Browse files Browse the repository at this point in the history
* 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
mitaligondaliya and mikaelacaron authored Dec 12, 2024
1 parent b8055a9 commit 1d68c23
Show file tree
Hide file tree
Showing 4 changed files with 357 additions and 9 deletions.
195 changes: 195 additions & 0 deletions Basic-Car-Maintenance/Shared/Dashboard/CSVEncoder.swift
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 Basic-Car-Maintenance/Shared/Dashboard/Views/CSVGeneratorView.swift
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: ""
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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]]

Expand All @@ -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) {
Expand All @@ -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",
Expand Down
Loading

0 comments on commit 1d68c23

Please sign in to comment.