Skip to content

Commit ba71d02

Browse files
OmarHegazy93Omar Hegazymikaelacaron
authored
Implement export maintenance events (#345)
* Implement export maintenance events * address comments * PR suggestions --------- Co-authored-by: Omar Hegazy <Omar.Hegazy@integrant.com> Co-authored-by: Mikaela Caron <mikaelacaron@gmail.com>
1 parent 52737ab commit ba71d02

File tree

7 files changed

+343
-0
lines changed

7 files changed

+343
-0
lines changed
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
//
2+
// CarMaintenancePDFGenerator.swift
3+
// Basic-Car-Maintenance
4+
//
5+
// https://github.com/mikaelacaron/Basic-Car-Maintenance
6+
// See LICENSE for license information.
7+
//
8+
9+
import UIKit
10+
import PDFKit
11+
12+
final class CarMaintenancePDFGenerator {
13+
private let vehicleName: String
14+
private let events: [MaintenanceEvent]
15+
16+
// Define page margins
17+
private let topMargin: CGFloat = 50
18+
private let bottomMargin: CGFloat = 50
19+
private let leftMargin: CGFloat = 20
20+
private let rightMargin: CGFloat = 20
21+
private let columnWidth: CGFloat
22+
private let documentsDirectory = FileManager
23+
.default
24+
.urls(for: .documentDirectory, in: .userDomainMask)
25+
.first
26+
27+
init(vehicleName: String, events: [MaintenanceEvent]) {
28+
self.vehicleName = vehicleName
29+
self.events = events
30+
self.columnWidth = (PageDimension.A4.pageWidth - leftMargin - rightMargin) / 3
31+
}
32+
33+
func generatePDF() -> PDFDocument? {
34+
guard !events.isEmpty else { return nil }
35+
let pdfRenderer = UIGraphicsPDFRenderer(bounds: CGRect(origin: .zero, size: PageDimension.A4.size))
36+
let pdfData = pdfRenderer.pdfData { context in
37+
var yPosition: CGFloat = topMargin
38+
39+
beginNewPage(
40+
context: context,
41+
yPosition: &yPosition,
42+
isFirstPage: true
43+
)
44+
45+
let tableRowAttributes: [NSAttributedString.Key: Any] = [
46+
.font: UIFont.systemFont(ofSize: 14),
47+
.foregroundColor: UIColor.black
48+
]
49+
50+
for (index, event) in events.enumerated() {
51+
if yPosition + 60 > PageDimension.A4.pageHeight - bottomMargin {
52+
beginNewPage(
53+
context: context,
54+
yPosition: &yPosition,
55+
isFirstPage: index == 0
56+
)
57+
}
58+
59+
event.date
60+
.formatted()
61+
.draw(at: CGPoint(x: leftMargin, y: yPosition), withAttributes: tableRowAttributes)
62+
63+
vehicleName.draw(
64+
at: CGPoint(x: leftMargin + columnWidth, y: yPosition),
65+
withAttributes: tableRowAttributes
66+
)
67+
68+
let notesRect = CGRect(
69+
x: leftMargin + 2 * columnWidth,
70+
y: yPosition,
71+
width: columnWidth - 20,
72+
height: 50
73+
)
74+
event.notes.draw(in: notesRect, withAttributes: tableRowAttributes)
75+
76+
yPosition += 60
77+
}
78+
}
79+
80+
do {
81+
guard let fileURL = documentsDirectory?
82+
.appendingPathComponent("\(vehicleName)-MaintenanceReport.pdf")
83+
else { return nil }
84+
85+
if FileManager.default.fileExists(atPath: fileURL.absoluteString) {
86+
try FileManager.default.removeItem(at: fileURL)
87+
}
88+
89+
try pdfData.write(to: fileURL)
90+
print("PDF saved to: \(fileURL.path)")
91+
return PDFDocument(url: fileURL)
92+
} catch {
93+
print("Could not save the PDF: \(error)")
94+
return nil
95+
}
96+
}
97+
98+
/// Draw the center header and header columns
99+
private func drawHeader(context: UIGraphicsPDFRendererContext, yPosition: inout CGFloat) {
100+
let titleAttributes: [NSAttributedString.Key: Any] = [
101+
.font: UIFont.boldSystemFont(ofSize: 20),
102+
.foregroundColor: UIColor.black
103+
]
104+
let subtitleAttributes: [NSAttributedString.Key: Any] = [
105+
.font: UIFont.systemFont(ofSize: 16),
106+
.foregroundColor: UIColor.black
107+
]
108+
109+
let titleString = "Basic Car Maintenance"
110+
let eventTitleString = "Maintenance Events"
111+
guard
112+
let startDate = events.first?.date.formatted(date: .numeric, time: .omitted),
113+
let endDate = events.last?.date.formatted(date: .numeric, time: .omitted)
114+
else { return }
115+
let dateRangeString = "From \(startDate) to \(endDate)"
116+
117+
let titleSize = titleString.size(withAttributes: titleAttributes)
118+
let vehicleSize = vehicleName.size(withAttributes: subtitleAttributes)
119+
let eventTitleSize = eventTitleString.size(withAttributes: subtitleAttributes)
120+
let dateRangeSize = dateRangeString.size(withAttributes: subtitleAttributes)
121+
122+
let titleX = (PageDimension.A4.pageWidth - titleSize.width) / 2
123+
let vehicleX = (PageDimension.A4.pageWidth - vehicleSize.width) / 2
124+
let eventTitleX = (PageDimension.A4.pageWidth - eventTitleSize.width) / 2
125+
let dateRangeX = (PageDimension.A4.pageWidth - dateRangeSize.width) / 2
126+
127+
titleString.draw(at: CGPoint(x: titleX, y: yPosition), withAttributes: titleAttributes)
128+
yPosition += 30
129+
130+
vehicleName.draw(at: CGPoint(x: vehicleX, y: yPosition), withAttributes: subtitleAttributes)
131+
yPosition += 30
132+
133+
eventTitleString.draw(at: CGPoint(x: eventTitleX, y: yPosition), withAttributes: subtitleAttributes)
134+
yPosition += 30
135+
136+
dateRangeString.draw(at: CGPoint(x: dateRangeX, y: yPosition), withAttributes: subtitleAttributes)
137+
yPosition += 50
138+
139+
drawColumnsHeaders(yPosition: &yPosition)
140+
}
141+
142+
private func beginNewPage(
143+
context: UIGraphicsPDFRendererContext,
144+
yPosition: inout CGFloat,
145+
isFirstPage: Bool
146+
) {
147+
context.beginPage()
148+
yPosition = topMargin
149+
150+
if isFirstPage {
151+
drawHeader(context: context, yPosition: &yPosition)
152+
}
153+
}
154+
155+
private func drawColumnsHeaders(yPosition: inout CGFloat) {
156+
let dateColumnHeader = "Date"
157+
let vehicleColumnHeader = "Vehicle Name"
158+
let noteColumnHeader = "Notes"
159+
160+
let subtitleAttributes: [NSAttributedString.Key: Any] = [
161+
.font: UIFont.boldSystemFont(ofSize: 16),
162+
.foregroundColor: UIColor.black
163+
]
164+
165+
dateColumnHeader.draw(
166+
at: CGPoint(x: leftMargin, y: yPosition),
167+
withAttributes: subtitleAttributes
168+
)
169+
170+
vehicleColumnHeader.draw(
171+
at: CGPoint(x: leftMargin + columnWidth, y: yPosition),
172+
withAttributes: subtitleAttributes
173+
)
174+
175+
let notesRect = CGRect(
176+
x: leftMargin + 2 * columnWidth,
177+
y: yPosition,
178+
width: columnWidth - 20,
179+
height: 50
180+
)
181+
noteColumnHeader.draw(in: notesRect, withAttributes: subtitleAttributes)
182+
183+
yPosition += 30
184+
}
185+
}

Basic-Car-Maintenance/Shared/Dashboard/ViewModels/DashboardViewModel.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ class DashboardViewModel {
3333
}
3434
}
3535

36+
var vehiclesWithSortedEventsDict: [Vehicle: [MaintenanceEvent]] {
37+
vehicles.reduce(into: [Vehicle: [MaintenanceEvent]]()) { result, currentVehicle in
38+
result[currentVehicle] = events
39+
.filter { $0.vehicleID == currentVehicle.id }
40+
.sorted(by: { $0.date < $1.date })
41+
}
42+
}
43+
3644
var searchedEvents: [MaintenanceEvent] {
3745
if searchText.isEmpty {
3846
sortedEvents

Basic-Car-Maintenance/Shared/Dashboard/Views/DashboardView.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ struct DashboardView: View {
1414
@State private var isShowingAddView = false
1515
@State private var viewModel: DashboardViewModel
1616
@State private var isShowingEditView = false
17+
@State private var isShowingExportOptionsView = false
1718
@State private var selectedMaintenanceEvent: MaintenanceEvent?
1819

1920
init(userUID: String?) {
@@ -149,13 +150,35 @@ struct DashboardView: View {
149150
}
150151
}
151152
}
153+
.toolbar {
154+
if !viewModel.events.isEmpty {
155+
ToolbarItem(placement: .topBarLeading) {
156+
Button {
157+
isShowingExportOptionsView = true
158+
} label: {
159+
Image(systemName: SFSymbol.share)
160+
}
161+
.accessibilityShowsLargeContentViewer {
162+
Label {
163+
Text("Export Event", comment: "Label for exporting maintenance events")
164+
} icon: {
165+
Image(systemName: SFSymbol.share)
166+
}
167+
}
168+
}
169+
}
170+
}
152171
.task {
153172
await viewModel.getMaintenanceEvents()
154173
await viewModel.getVehicles()
155174
}
156175
.sheet(isPresented: $isShowingAddView) {
157176
makeAddMaintenanceView()
158177
}
178+
.sheet(isPresented: $isShowingExportOptionsView) {
179+
ExportOptionsView(dataSource: viewModel.vehiclesWithSortedEventsDict)
180+
.presentationDetents([.medium])
181+
}
159182
}
160183
.onChange(of: scenePhase) { _, newScenePhase in
161184
guard case .active = newScenePhase else { return }
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
//
2+
// ExportOptionsView.swift
3+
// Basic-Car-Maintenance
4+
//
5+
// https://github.com/mikaelacaron/Basic-Car-Maintenance
6+
// See LICENSE for license information.
7+
//
8+
9+
import SwiftUI
10+
import PDFKit
11+
12+
struct ExportOptionsView: View {
13+
@Environment(\.dismiss) var dismiss
14+
@State private var selectedVehicle: Vehicle?
15+
@State private var isShowingThumbnail = false
16+
@State private var pdfDoc: PDFDocument?
17+
18+
private let dataSource: [Vehicle: [MaintenanceEvent]]
19+
20+
init(dataSource: [Vehicle: [MaintenanceEvent]]) {
21+
self.dataSource = dataSource
22+
self._selectedVehicle = State(initialValue: dataSource.first?.key)
23+
}
24+
25+
var body: some View {
26+
NavigationView {
27+
VStack(alignment: .leading, spacing: 16) {
28+
Text("Select the vehicle you want to export the maintenance events for:")
29+
.font(.headline)
30+
.padding(.top, 20)
31+
32+
Picker("Select a Vehicle", selection: $selectedVehicle) {
33+
ForEach(dataSource.map(\.key)) { vehicle in
34+
Text(vehicle.name)
35+
.tag(vehicle)
36+
}
37+
}
38+
.pickerStyle(.wheel)
39+
}
40+
.padding(.horizontal)
41+
.toolbar {
42+
ToolbarItem(placement: .topBarTrailing) {
43+
Button("Export") {
44+
if let selectedVehicle,
45+
let events = self.dataSource[selectedVehicle] {
46+
let pdfGenerator = CarMaintenancePDFGenerator(
47+
vehicleName: selectedVehicle.name,
48+
events: events
49+
)
50+
self.pdfDoc = pdfGenerator.generatePDF()
51+
isShowingThumbnail = true
52+
}
53+
}
54+
}
55+
}
56+
.sheet(isPresented: $isShowingThumbnail) {
57+
if let pdfDoc,
58+
let url = pdfDoc.documentURL,
59+
let thumbnail = pdfDoc
60+
.page(at: .zero)?
61+
.thumbnail(
62+
of: CGSize(
63+
width: UIScreen.main.bounds.width,
64+
height: UIScreen.main.bounds.height / 2),
65+
for: .mediaBox
66+
) {
67+
ShareLink(item: url) {
68+
VStack {
69+
Image(uiImage: thumbnail)
70+
Label("Share", systemImage: SFSymbol.share)
71+
}
72+
.safeAreaPadding(.bottom)
73+
}
74+
.presentationDetents([.medium])
75+
}
76+
}
77+
}
78+
}
79+
}

Basic-Car-Maintenance/Shared/Localizable.xcstrings

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2017,6 +2017,12 @@
20172017
}
20182018
}
20192019
},
2020+
"Export" : {
2021+
2022+
},
2023+
"Export Event" : {
2024+
"comment" : "Label for exporting maintenance events"
2025+
},
20202026
"Failed To Add Vehicle" : {
20212027
"localizations" : {
20222028
"be" : {
@@ -4354,6 +4360,12 @@
43544360
}
43554361
}
43564362
}
4363+
},
4364+
"Select a Vehicle" : {
4365+
4366+
},
4367+
"Select the vehicle you want to export the maintenance events for:" : {
4368+
43574369
},
43584370
"Settings" : {
43594371
"comment" : "Label to display settings.",
@@ -4444,6 +4456,9 @@
44444456
}
44454457
}
44464458
},
4459+
"Share" : {
4460+
"comment" : "Share the exported file in the share sheet"
4461+
},
44474462
"Sign Out" : {
44484463
"localizations" : {
44494464
"be" : {

Basic-Car-Maintenance/Shared/Utilities/Constants.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ enum SFSymbol {
6464
// Navigation Items
6565
static let filter = "line.3.horizontal.decrease.circle"
6666
static let plus = "plus"
67+
static let share = "square.and.arrow.up"
6768

6869
// Dashboard
6970
static let trash = "trash"

0 commit comments

Comments
 (0)