Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement export maintenance events #345

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
//
// CarMaintenancePDFGenerator.swift
// Basic-Car-Maintenance
//
// https://github.com/mikaelacaron/Basic-Car-Maintenance
// See LICENSE for license information.
//

import UIKit
import PDFKit

final class CarMaintenancePDFGenerator {
private let vehicleName: String
private let events: [MaintenanceEvent]

// Define page margins
private let topMargin: CGFloat = 50
private let bottomMargin: CGFloat = 50
private let leftMargin: CGFloat = 20
private let rightMargin: CGFloat = 20
private let columnWidth: CGFloat
private let documentsDirectory = FileManager
.default
.urls(for: .documentDirectory, in: .userDomainMask)
.first

init(vehicleName: String, events: [MaintenanceEvent]) {
self.vehicleName = vehicleName
self.events = events
self.columnWidth = (PageDimension.A4.pageWidth - leftMargin - rightMargin) / 3
}

func generatePDF() -> PDFDocument? {

Check warning on line 33 in Basic-Car-Maintenance/Shared/Dashboard/CarMaintenancePDFGenerator.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Function body should span 50 lines or less excluding comments and whitespace: currently spans 52 lines (function_body_length)
guard !events.isEmpty else { return nil }
let pdfRenderer = UIGraphicsPDFRenderer(bounds: CGRect(origin: .zero, size: PageDimension.A4.size))
let pdfData = pdfRenderer.pdfData { context in
var yPosition: CGFloat = topMargin

beginNewPage(
context: context,
yPosition: &yPosition,
isFirstPage: true
)

let tableRowAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 14),
.foregroundColor: UIColor.black
]

for (index, event) in events.enumerated() {
if yPosition + 60 > PageDimension.A4.pageHeight - bottomMargin {
beginNewPage(
context: context,
yPosition: &yPosition,
isFirstPage: index == 0
)
}

event.date
.formatted()
.draw(at: CGPoint(x: leftMargin, y: yPosition), withAttributes: tableRowAttributes)

vehicleName.draw(
at: CGPoint(x: leftMargin + columnWidth, y: yPosition),
withAttributes: tableRowAttributes
)

let notesRect = CGRect(
x: leftMargin + 2 * columnWidth,
y: yPosition,
width: columnWidth - 20,
height: 50
)
event.notes.draw(in: notesRect, withAttributes: tableRowAttributes)

yPosition += 60
}
}

do {
guard let fileURL = documentsDirectory?
.appendingPathComponent("\(vehicleName)-MaintenanceReport.pdf")
else { return nil }

if FileManager.default.fileExists(atPath: fileURL.absoluteString) {
try FileManager.default.removeItem(at: fileURL)
}

try pdfData.write(to: fileURL)
print("PDF saved to: \(fileURL.path)")
return PDFDocument(url: fileURL)
} catch {
print("Could not save the PDF: \(error)")
return nil
}
}

/// Draw the center header and header columns
private func drawHeader(context: UIGraphicsPDFRendererContext, yPosition: inout CGFloat) {
let titleAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.boldSystemFont(ofSize: 20),
.foregroundColor: UIColor.black
]
let subtitleAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 16),
.foregroundColor: UIColor.black
]

let titleString = "Basic Car Maintenance"
let eventTitleString = "Maintenance Events"
guard
let startDate = events.first?.date.formatted(date: .numeric, time: .omitted),
let endDate = events.last?.date.formatted(date: .numeric, time: .omitted)
else { return }
let dateRangeString = "From \(startDate) to \(endDate)"

let titleSize = titleString.size(withAttributes: titleAttributes)
let vehicleSize = vehicleName.size(withAttributes: subtitleAttributes)
let eventTitleSize = eventTitleString.size(withAttributes: subtitleAttributes)
let dateRangeSize = dateRangeString.size(withAttributes: subtitleAttributes)

let titleX = (PageDimension.A4.pageWidth - titleSize.width) / 2
let vehicleX = (PageDimension.A4.pageWidth - vehicleSize.width) / 2
let eventTitleX = (PageDimension.A4.pageWidth - eventTitleSize.width) / 2
let dateRangeX = (PageDimension.A4.pageWidth - dateRangeSize.width) / 2

titleString.draw(at: CGPoint(x: titleX, y: yPosition), withAttributes: titleAttributes)
yPosition += 30

vehicleName.draw(at: CGPoint(x: vehicleX, y: yPosition), withAttributes: subtitleAttributes)
yPosition += 30

eventTitleString.draw(at: CGPoint(x: eventTitleX, y: yPosition), withAttributes: subtitleAttributes)
yPosition += 30

dateRangeString.draw(at: CGPoint(x: dateRangeX, y: yPosition), withAttributes: subtitleAttributes)
yPosition += 50

drawColumnsHeaders(yPosition: &yPosition)
}

private func beginNewPage(
context: UIGraphicsPDFRendererContext,
yPosition: inout CGFloat,
isFirstPage: Bool
) {
context.beginPage()
yPosition = topMargin

if isFirstPage {
drawHeader(context: context, yPosition: &yPosition)
}
}

private func drawColumnsHeaders(yPosition: inout CGFloat) {
let dateColumnHeader = "Date"
let vehicleColumnHeader = "Vehicle Name"
let noteColumnHeader = "Notes"

let subtitleAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.boldSystemFont(ofSize: 16),
.foregroundColor: UIColor.black
]

dateColumnHeader.draw(
at: CGPoint(x: leftMargin, y: yPosition),
withAttributes: subtitleAttributes
)

vehicleColumnHeader.draw(
at: CGPoint(x: leftMargin + columnWidth, y: yPosition),
withAttributes: subtitleAttributes
)

let notesRect = CGRect(
x: leftMargin + 2 * columnWidth,
y: yPosition,
width: columnWidth - 20,
height: 50
)
noteColumnHeader.draw(in: notesRect, withAttributes: subtitleAttributes)

yPosition += 30
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ class DashboardViewModel {
}
}

var vehiclesWithSortedEventsDict: [Vehicle: [MaintenanceEvent]] {
vehicles.reduce(into: [Vehicle: [MaintenanceEvent]]()) { result, currentVehicle in
result[currentVehicle] = events
.filter { $0.vehicleID == currentVehicle.id }
.sorted(by: { $0.date < $1.date })
}
}

var searchedEvents: [MaintenanceEvent] {
if searchText.isEmpty {
sortedEvents
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ struct DashboardView: View {
@State private var isShowingAddView = false
@State private var viewModel: DashboardViewModel
@State private var isShowingEditView = false
@State private var isShowingExportOptionsView = false
@State private var selectedMaintenanceEvent: MaintenanceEvent?

init(userUID: String?) {
Expand Down Expand Up @@ -149,13 +150,35 @@ struct DashboardView: View {
}
}
}
.toolbar {
if !viewModel.events.isEmpty {
ToolbarItem(placement: .topBarLeading) {
Button {
isShowingExportOptionsView = true
} label: {
Image(systemName: SFSymbol.share)
}
.accessibilityShowsLargeContentViewer {
Label {
Text("Export Event", comment: "Label for exporting maintenance events")
} icon: {
Image(systemName: SFSymbol.share)
}
}
}
}
}
.task {
await viewModel.getMaintenanceEvents()
await viewModel.getVehicles()
}
.sheet(isPresented: $isShowingAddView) {
makeAddMaintenanceView()
}
.sheet(isPresented: $isShowingExportOptionsView) {
ExportOptionsView(dataSource: viewModel.vehiclesWithSortedEventsDict)
.presentationDetents([.medium])
}
}
.onChange(of: scenePhase) { _, newScenePhase in
guard case .active = newScenePhase else { return }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
//
// ExportOptionsView.swift
// Basic-Car-Maintenance
//
// https://github.com/mikaelacaron/Basic-Car-Maintenance
// See LICENSE for license information.
//

import SwiftUI
import PDFKit

struct ExportOptionsView: View {
@Environment(\.dismiss) var dismiss
@State private var selectedVehicle: Vehicle?
@State private var isShowingThumbnail = false
@State private var pdfDoc: PDFDocument?

private let dataSource: [Vehicle: [MaintenanceEvent]]

init(dataSource: [Vehicle: [MaintenanceEvent]]) {
self.dataSource = dataSource
self._selectedVehicle = State(initialValue: dataSource.first?.key)
}

var body: some View {
NavigationView {
VStack(alignment: .leading, spacing: 16) {
Text("Select the vehicle you want to export the maintenance events for:")
Comment on lines +26 to +28
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this could be a scroll view to be able to accommodate dynamic type sizes

.font(.headline)
.padding(.top, 20)
Comment on lines +26 to +30
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this could be the title of the NavigationView as opposed to just text in a VStack, because this won't work when the dynamic type size is really big

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

adding the title to the NavigationView makes the title truncated as shown below, also there is no way to style the Text passed to the navigation title to make it smaller as I'm getting the warning

Only unstyled text can be used with navigationTitle(_:)

image


Picker("Select a Vehicle", selection: $selectedVehicle) {
ForEach(dataSource.map(\.key)) { vehicle in
Text(vehicle.name)
.tag(vehicle)
}
}
.pickerStyle(.wheel)
}
.padding(.horizontal)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Export") {
if let selectedVehicle,
let events = self.dataSource[selectedVehicle] {
let pdfGenerator = CarMaintenancePDFGenerator(
vehicleName: selectedVehicle.name,
events: events
)
self.pdfDoc = pdfGenerator.generatePDF()
isShowingThumbnail = true
}
}
}
}
.sheet(isPresented: $isShowingThumbnail) {
if let pdfDoc,
let url = pdfDoc.documentURL,
let thumbnail = pdfDoc
.page(at: .zero)?
.thumbnail(
of: CGSize(
width: UIScreen.main.bounds.width,
height: UIScreen.main.bounds.height / 2),
for: .mediaBox
) {
ShareLink(item: url) {
VStack {
Image(uiImage: thumbnail)
Label("Share", systemImage: SFSymbol.share)
}
.safeAreaPadding(.bottom)
}
.presentationDetents([.medium])
}
}
}
}
}
15 changes: 15 additions & 0 deletions Basic-Car-Maintenance/Shared/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -2016,6 +2016,12 @@
}
}
},
"Export" : {

},
"Export Event" : {
"comment" : "Label for exporting maintenance events"
},
"Failed To Add Vehicle" : {
"localizations" : {
"be" : {
Expand Down Expand Up @@ -4405,6 +4411,12 @@
}
}
}
},
"Select a Vehicle" : {

},
"Select the vehicle you want to export the maintenance events for:" : {

},
"Settings" : {
"comment" : "Label to display settings.",
Expand Down Expand Up @@ -4495,6 +4507,9 @@
}
}
},
"Share" : {
"comment" : "Share the exported file in the share sheet"
},
"Sign Out" : {
"localizations" : {
"be" : {
Expand Down
1 change: 1 addition & 0 deletions Basic-Car-Maintenance/Shared/Utilities/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ enum SFSymbol {
// Navigation Items
static let filter = "line.3.horizontal.decrease.circle"
static let plus = "plus"
static let share = "square.and.arrow.up"

// Dashboard
static let trash = "trash"
Expand Down
Loading
Loading