diff --git a/Basic-Car-Maintenance/Shared/Dashboard/CarMaintenancePDFGenerator.swift b/Basic-Car-Maintenance/Shared/Dashboard/CarMaintenancePDFGenerator.swift new file mode 100644 index 00000000..d860567d --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Dashboard/CarMaintenancePDFGenerator.swift @@ -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? { + 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 + } +} diff --git a/Basic-Car-Maintenance/Shared/Dashboard/ViewModels/DashboardViewModel.swift b/Basic-Car-Maintenance/Shared/Dashboard/ViewModels/DashboardViewModel.swift index c49ba813..8febbc65 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/ViewModels/DashboardViewModel.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/ViewModels/DashboardViewModel.swift @@ -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 diff --git a/Basic-Car-Maintenance/Shared/Dashboard/Views/DashboardView.swift b/Basic-Car-Maintenance/Shared/Dashboard/Views/DashboardView.swift index a1ca004c..fc9060e4 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/Views/DashboardView.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/DashboardView.swift @@ -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?) { @@ -149,6 +150,24 @@ 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() @@ -156,6 +175,10 @@ struct DashboardView: View { .sheet(isPresented: $isShowingAddView) { makeAddMaintenanceView() } + .sheet(isPresented: $isShowingExportOptionsView) { + ExportOptionsView(dataSource: viewModel.vehiclesWithSortedEventsDict) + .presentationDetents([.medium]) + } } .onChange(of: scenePhase) { _, newScenePhase in guard case .active = newScenePhase else { return } diff --git a/Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift b/Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift new file mode 100644 index 00000000..6c4b905f --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift @@ -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]) + } + } + } + } +} diff --git a/Basic-Car-Maintenance/Shared/Localizable.xcstrings b/Basic-Car-Maintenance/Shared/Localizable.xcstrings index f98f7f53..0a0df49c 100644 --- a/Basic-Car-Maintenance/Shared/Localizable.xcstrings +++ b/Basic-Car-Maintenance/Shared/Localizable.xcstrings @@ -2016,6 +2016,12 @@ } } }, + "Export" : { + + }, + "Export Event" : { + "comment" : "Label for exporting maintenance events" + }, "Failed To Add Vehicle" : { "localizations" : { "be" : { @@ -4405,6 +4411,12 @@ } } } + }, + "Select a Vehicle" : { + + }, + "Select the vehicle you want to export the maintenance events for:" : { + }, "Settings" : { "comment" : "Label to display settings.", @@ -4495,6 +4507,9 @@ } } }, + "Share" : { + "comment" : "Share the exported file in the share sheet" + }, "Sign Out" : { "localizations" : { "be" : { diff --git a/Basic-Car-Maintenance/Shared/Utilities/Constants.swift b/Basic-Car-Maintenance/Shared/Utilities/Constants.swift index b57a8069..bdafb9a4 100644 --- a/Basic-Car-Maintenance/Shared/Utilities/Constants.swift +++ b/Basic-Car-Maintenance/Shared/Utilities/Constants.swift @@ -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" diff --git a/Basic-Car-Maintenance/Shared/Utilities/PageDimension.swift b/Basic-Car-Maintenance/Shared/Utilities/PageDimension.swift new file mode 100644 index 00000000..a831b647 --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Utilities/PageDimension.swift @@ -0,0 +1,32 @@ +// +// PageDimension.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import Foundation + +enum PageDimension { + case A4 // swiftlint:disable:this identifier_name + + var pageWidth: CGFloat { + switch self { + case .A4: + return 595.2 + } + } + + var pageHeight: CGFloat { + switch self { + case .A4: + return 841.8 + } + } + + var size: CGSize { + CGSize(width: pageWidth, height: pageHeight) + } + +}