Skip to content

Commit

Permalink
Merge branch 'dev' into firestore-rules
Browse files Browse the repository at this point in the history
  • Loading branch information
mikaelacaron committed Oct 29, 2024
2 parents d9294ff + c560f22 commit bf2cc29
Show file tree
Hide file tree
Showing 15 changed files with 666 additions and 94 deletions.
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 @@ -32,6 +32,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
37 changes: 32 additions & 5 deletions Basic-Car-Maintenance/Shared/Dashboard/Views/DashboardView.swift
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 All @@ -30,20 +31,24 @@ struct DashboardView: View {
NavigationStack {
List {
ForEach(viewModel.searchedEvents) { event in
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 4) {
Text(event.title)
.font(.title3)
.fontWeight(.bold)

Text(event.date, formatter: self.eventDateFormat)
.foregroundStyle(.secondary)

let vehicleName = viewModel.vehicles.first { $0.id == event.vehicleID }?.name
if let vehicleName {
Text("\(vehicleName) on \(event.date, formatter: self.eventDateFormat)",
comment: "Maintenance list item for a vehicle on a date")
Text("For: \(vehicleName)", comment: "the vehcile name is filled in here")
.foregroundStyle(.secondary)
}

if !event.notes.isEmpty {
Text(event.notes)
.lineLimit(0)
Text("Notes:")
.foregroundStyle(.secondary)
Text(event.notes)
}
}
.accessibilityElement(children: .combine)
Expand Down Expand Up @@ -149,13 +154,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
Expand Up @@ -100,7 +100,7 @@ struct EditMaintenanceEventView: View {
notes: "Changed engine oil"
)

var viewModel = DashboardViewModel(userUID: "user123")
let viewModel = DashboardViewModel(userUID: "user123")

EditMaintenanceEventView(
selectedEvent: $selectedEvent,
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:")
.font(.headline)
.padding(.top, 20)

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])
}
}
}
}
}
Loading

0 comments on commit bf2cc29

Please sign in to comment.