From d244023cb368b9b68bb1c35472a92bc5e93fe2d3 Mon Sep 17 00:00:00 2001 From: Omar Hegazy Date: Sun, 20 Oct 2024 00:16:44 +0300 Subject: [PATCH 1/3] Implement export maintenance events --- .../ViewModels/DashboardViewModel.swift | 8 +- .../Views/CarMaintenancePDFGenerator.swift | 230 ++++++++++++++++++ .../Dashboard/Views/DashboardView.swift | 41 ++++ .../Dashboard/Views/ExportOptionsView.swift | 70 ++++++ .../Dashboard/Views/PDFShareController.swift | 49 ++++ .../Shared/Localizable.xcstrings | 12 + .../Shared/Utilities/Constants.swift | 1 + 7 files changed, 410 insertions(+), 1 deletion(-) create mode 100644 Basic-Car-Maintenance/Shared/Dashboard/Views/CarMaintenancePDFGenerator.swift create mode 100644 Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift create mode 100644 Basic-Car-Maintenance/Shared/Dashboard/Views/PDFShareController.swift diff --git a/Basic-Car-Maintenance/Shared/Dashboard/ViewModels/DashboardViewModel.swift b/Basic-Car-Maintenance/Shared/Dashboard/ViewModels/DashboardViewModel.swift index c49ba813..52a75da5 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/ViewModels/DashboardViewModel.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/ViewModels/DashboardViewModel.swift @@ -11,7 +11,7 @@ import FirebaseFirestoreSwift import Foundation @Observable -class DashboardViewModel { +class DashboardViewModel: MaintenanceEventsFetcher { let userUID: String? @@ -166,6 +166,12 @@ class DashboardViewModel { } } } + + func fetchEvents(for vehicle: Vehicle) -> [MaintenanceEvent] { + events + .filter { $0.vehicleID == vehicle.id } + .sorted(by: { $0.date < $1.date }) + } } // MARK: - Sort Option diff --git a/Basic-Car-Maintenance/Shared/Dashboard/Views/CarMaintenancePDFGenerator.swift b/Basic-Car-Maintenance/Shared/Dashboard/Views/CarMaintenancePDFGenerator.swift new file mode 100644 index 00000000..25ead617 --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/CarMaintenancePDFGenerator.swift @@ -0,0 +1,230 @@ +// +// CarMaintenancePDFGenerator.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import UIKit +import SwiftUI + +protocol PDFGeneratable { + func generatePDF() -> URL? +} + +final class CarMaintenancePDFGenerator: PDFGeneratable { + private let title = "Basic Car Maintenance" + private let vehicleName: String + private let events: [MaintenanceEvent] + // Define the PDF page size (A4 size in points) + private let pageWidth: CGFloat = 595.2 + private let pageHeight: CGFloat = 841.8 + private var pageSize: CGSize + + // 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.pageSize = CGSize(width: pageWidth, height: pageHeight) + self.columnWidth = (pageWidth - leftMargin - rightMargin) / 3 + } + + func generatePDF() -> URL? { + guard !events.isEmpty else { return nil } + let fileName = "\(vehicleName)MaintenanceReport.pdf" + + // Get the path to the documents directory + let fileURL = documentsDirectory?.appendingPathComponent(fileName) + + // Create a PDF renderer + let pdfRenderer = UIGraphicsPDFRenderer(bounds: CGRect(origin: .zero, size: pageSize)) + + // Render content to the PDF file + let pdfData = pdfRenderer.pdfData { context in + + // Initialize the y-position (start after the headers) + var yPosition: CGFloat = topMargin + + // Function to begin a new page + func beginNewPage(isFirstPage: Bool) { + context.beginPage() + + // Reset yPosition to start after the headers + yPosition = topMargin + + // Draw headers for the first page + if isFirstPage { + drawHeader( + context: context, + yPosition: &yPosition + ) + } + } + + // Start the first page + beginNewPage(isFirstPage: true) + + // Draw the content of the PDF + let tableRowAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 14), + .foregroundColor: UIColor.black + ] + + for (index, event) in events.enumerated() { + // Check if yPosition is close to the bottom of the page and create a new page if necessary + if yPosition + 60 > pageHeight - bottomMargin { // 60 is the estimated height of one row + beginNewPage(isFirstPage: index == 0) + } + + // Draw Date and Vehicle Name as single-line text + event.date + .formatted() + .draw( + at: CGPoint( + x: leftMargin, + y: yPosition + ), + withAttributes: tableRowAttributes + ) + + vehicleName + .draw( + at: CGPoint( + x: leftMargin + columnWidth, + y: yPosition + ), + withAttributes: tableRowAttributes + ) + + // Draw Notes with text wrapping within a bounding rectangle + let notesRect = CGRect( + x: leftMargin + 2 * columnWidth, + y: yPosition, + width: columnWidth - 20, + height: 50 + ) + event.notes.draw(in: notesRect, withAttributes: tableRowAttributes) + + // Adjust the spacing for the next row + yPosition += 60 + } + } + + // Save the PDF data to the file URL + do { + try pdfData.write(to: fileURL!) + print("PDF saved to: \(fileURL!.path)") + return fileURL + } catch { + print("Could not save the PDF: \(error)") + return nil + } + } + + // Helper function to draw header + 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) + + // Center the headers + let titleX = (pageWidth - titleSize.width) / 2 + let vehicleX = (pageWidth - vehicleSize.width) / 2 + let eventTitleX = (pageWidth - eventTitleSize.width) / 2 + let dateRangeX = (pageWidth - dateRangeSize.width) / 2 + + // Draw the main title centered + titleString.draw(at: CGPoint(x: titleX, y: yPosition), withAttributes: titleAttributes) + yPosition += 30 + + // Draw the vehicle name centered + vehicleName.draw(at: CGPoint(x: vehicleX, y: yPosition), withAttributes: subtitleAttributes) + yPosition += 30 + + // Draw the maintenance event title centered + eventTitleString.draw(at: CGPoint(x: eventTitleX, y: yPosition), withAttributes: subtitleAttributes) + yPosition += 30 + + // Draw date range centered + dateRangeString.draw(at: CGPoint(x: dateRangeX, y: yPosition), withAttributes: subtitleAttributes) + yPosition += 50 // Add more space after the headers + + drawColumnsHeaders(yPosition: &yPosition) + } + + private func drawColumnsHeaders( + yPosition: inout CGFloat + ) { + // Draw the headers of each column + 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 + ) + + // Draw Notes with text wrapping within a bounding rectangle + let notesRect = CGRect( + x: leftMargin + 2 * columnWidth, + y: yPosition, + width: columnWidth - 20, + height: 50 + ) + noteColumnHeader.draw(in: notesRect, withAttributes: subtitleAttributes) + + // Adjust the spacing for the next row + yPosition += 30 + } +} diff --git a/Basic-Car-Maintenance/Shared/Dashboard/Views/DashboardView.swift b/Basic-Car-Maintenance/Shared/Dashboard/Views/DashboardView.swift index a1ca004c..31d163dd 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/Views/DashboardView.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/DashboardView.swift @@ -14,6 +14,9 @@ struct DashboardView: View { @State private var isShowingAddView = false @State private var viewModel: DashboardViewModel @State private var isShowingEditView = false + @State private var isShowingShareView = false + @State private var didGetURL = false + @State private var pdfURL: URL? @State private var selectedMaintenanceEvent: MaintenanceEvent? init(userUID: String?) { @@ -149,6 +152,24 @@ struct DashboardView: View { } } } + .toolbar { + if !viewModel.events.isEmpty { + ToolbarItem(placement: .topBarLeading) { + Button { + isShowingShareView = true + } label: { + Image(systemName: SFSymbol.share) + } + .accessibilityShowsLargeContentViewer { + Label { + Text("ExportEvent", comment: "Label for exporting maintenance events") + } icon: { + Image(systemName: SFSymbol.share) + } + } + } + } + } .task { await viewModel.getMaintenanceEvents() await viewModel.getVehicles() @@ -156,6 +177,26 @@ struct DashboardView: View { .sheet(isPresented: $isShowingAddView) { makeAddMaintenanceView() } + .sheet(isPresented: $isShowingShareView) { + ExportOptionsView( + vehicles: viewModel.vehicles, + eventsFetcher: viewModel + ) { url in + pdfURL = url + } + .presentationDetents([.fraction(0.35)]) + .presentationCornerRadius(10) + } + .onChange(of: pdfURL) { _, newURL in + if newURL != nil { + didGetURL = true + } + } + .sheet(isPresented: $didGetURL) { + if let pdfURL { + PDFShareController(url: pdfURL) + } + } } .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..bb274860 --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift @@ -0,0 +1,70 @@ +// +// ExportOptionsView.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import SwiftUI + +protocol MaintenanceEventsFetcher { + func fetchEvents(for vehicle: Vehicle) -> [MaintenanceEvent] +} + +struct ExportOptionsView: View { + @Environment(\.dismiss) var dismiss + @State private var selectedVehicle: Vehicle? + @State private var isShowingShareSheet = false + private let eventsFetcher: MaintenanceEventsFetcher + private let onExport: (URL) -> Void + private let vehicles: [Vehicle] + + init( + vehicles: [Vehicle], + eventsFetcher: MaintenanceEventsFetcher, + onExport: @escaping (URL) -> Void + ) { + self.vehicles = vehicles + self.eventsFetcher = eventsFetcher + self.onExport = onExport + self._selectedVehicle = State(initialValue: vehicles.first) + } + + 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(vehicles, id: \.id) { vehicle in + Text(vehicle.name) + .tag(vehicle as Vehicle?) + } + } + .pickerStyle(InlinePickerStyle()) + } + .padding(.horizontal) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Export") { + if let selectedVehicle { + let events = eventsFetcher.fetchEvents(for: selectedVehicle) + let pdfGenerator = CarMaintenancePDFGenerator( + vehicleName: selectedVehicle.name, + events: events + ) + if let pdfURL = pdfGenerator.generatePDF() { + onExport(pdfURL) + dismiss() + } + } + } + .disabled(selectedVehicle == nil) + } + } + } + } +} diff --git a/Basic-Car-Maintenance/Shared/Dashboard/Views/PDFShareController.swift b/Basic-Car-Maintenance/Shared/Dashboard/Views/PDFShareController.swift new file mode 100644 index 00000000..d4faec13 --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/PDFShareController.swift @@ -0,0 +1,49 @@ +// +// PDFShareController.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import UIKit +import SwiftUI + +struct PDFShareController: UIViewControllerRepresentable { + var url: URL + + func makeUIViewController(context: Context) -> UIActivityViewController { + let activityViewController = UIActivityViewController( + activityItems: [url], + applicationActivities: nil + ) + // For iPads, you need to specify the source of the activity view controller + if let popoverController = activityViewController.popoverPresentationController { + popoverController.sourceView = context.coordinator.sourceView + popoverController.sourceRect = CGRect( + x: UIScreen.main.bounds.midX, + y: UIScreen.main.bounds.midY, + width: 0, + height: 0 + ) + popoverController.permittedArrowDirections = [] + } + + return activityViewController + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) { + // No update needed + } + + class Coordinator: NSObject { + var sourceView: UIView + init(sourceView: UIView) { + self.sourceView = sourceView + } + } + + func makeCoordinator() -> Coordinator { + return Coordinator(sourceView: UIView()) + } +} diff --git a/Basic-Car-Maintenance/Shared/Localizable.xcstrings b/Basic-Car-Maintenance/Shared/Localizable.xcstrings index f98f7f53..65965da8 100644 --- a/Basic-Car-Maintenance/Shared/Localizable.xcstrings +++ b/Basic-Car-Maintenance/Shared/Localizable.xcstrings @@ -2016,6 +2016,12 @@ } } }, + "Export" : { + + }, + "ExportEvent" : { + "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.", 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" From 587b813f6a22884179c20a91e75fc451a33465c4 Mon Sep 17 00:00:00 2001 From: Omar Hegazy Date: Wed, 23 Oct 2024 02:13:25 +0300 Subject: [PATCH 2/3] address comments --- .../ViewModels/DashboardViewModel.swift | 17 +- .../Views/CarMaintenancePDFGenerator.swift | 162 +++++++----------- .../Dashboard/Views/DashboardView.swift | 27 +-- .../Dashboard/Views/ExportOptionsView.swift | 61 ++++--- .../Dashboard/Views/PDFShareController.swift | 49 ------ .../Shared/Localizable.xcstrings | 5 +- .../Shared/Utilities/PageDimension.swift | 32 ++++ 7 files changed, 145 insertions(+), 208 deletions(-) delete mode 100644 Basic-Car-Maintenance/Shared/Dashboard/Views/PDFShareController.swift create mode 100644 Basic-Car-Maintenance/Shared/Utilities/PageDimension.swift diff --git a/Basic-Car-Maintenance/Shared/Dashboard/ViewModels/DashboardViewModel.swift b/Basic-Car-Maintenance/Shared/Dashboard/ViewModels/DashboardViewModel.swift index 52a75da5..d1c95b97 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/ViewModels/DashboardViewModel.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/ViewModels/DashboardViewModel.swift @@ -11,7 +11,7 @@ import FirebaseFirestoreSwift import Foundation @Observable -class DashboardViewModel: MaintenanceEventsFetcher { +class DashboardViewModel { let userUID: String? @@ -33,6 +33,15 @@ class DashboardViewModel: MaintenanceEventsFetcher { } } + 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 @@ -166,12 +175,6 @@ class DashboardViewModel: MaintenanceEventsFetcher { } } } - - func fetchEvents(for vehicle: Vehicle) -> [MaintenanceEvent] { - events - .filter { $0.vehicleID == vehicle.id } - .sorted(by: { $0.date < $1.date }) - } } // MARK: - Sort Option diff --git a/Basic-Car-Maintenance/Shared/Dashboard/Views/CarMaintenancePDFGenerator.swift b/Basic-Car-Maintenance/Shared/Dashboard/Views/CarMaintenancePDFGenerator.swift index 25ead617..baae195c 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/Views/CarMaintenancePDFGenerator.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/CarMaintenancePDFGenerator.swift @@ -7,20 +7,11 @@ // import UIKit -import SwiftUI +import PDFKit -protocol PDFGeneratable { - func generatePDF() -> URL? -} - -final class CarMaintenancePDFGenerator: PDFGeneratable { - private let title = "Basic Car Maintenance" +final class CarMaintenancePDFGenerator { private let vehicleName: String private let events: [MaintenanceEvent] - // Define the PDF page size (A4 size in points) - private let pageWidth: CGFloat = 595.2 - private let pageHeight: CGFloat = 841.8 - private var pageSize: CGSize // Define page margins private let topMargin: CGFloat = 50 @@ -36,78 +27,47 @@ final class CarMaintenancePDFGenerator: PDFGeneratable { init(vehicleName: String, events: [MaintenanceEvent]) { self.vehicleName = vehicleName self.events = events - self.pageSize = CGSize(width: pageWidth, height: pageHeight) - self.columnWidth = (pageWidth - leftMargin - rightMargin) / 3 + self.columnWidth = (PageDimension.A4.pageWidth - leftMargin - rightMargin) / 3 } - func generatePDF() -> URL? { + func generatePDF() -> PDFDocument? { guard !events.isEmpty else { return nil } - let fileName = "\(vehicleName)MaintenanceReport.pdf" - - // Get the path to the documents directory - let fileURL = documentsDirectory?.appendingPathComponent(fileName) - - // Create a PDF renderer - let pdfRenderer = UIGraphicsPDFRenderer(bounds: CGRect(origin: .zero, size: pageSize)) - - // Render content to the PDF file - let pdfData = pdfRenderer.pdfData { context in - - // Initialize the y-position (start after the headers) + let pdfRenderer = UIGraphicsPDFRenderer(bounds: CGRect(origin: .zero, size: PageDimension.A4.size)) + let pdfData = pdfRenderer.pdfData { context in var yPosition: CGFloat = topMargin - // Function to begin a new page - func beginNewPage(isFirstPage: Bool) { - context.beginPage() - - // Reset yPosition to start after the headers - yPosition = topMargin - - // Draw headers for the first page - if isFirstPage { - drawHeader( - context: context, - yPosition: &yPosition - ) - } - } - - // Start the first page - beginNewPage(isFirstPage: true) + beginNewPage( + context: context, + yPosition: &yPosition, + isFirstPage: true + ) - // Draw the content of the PDF let tableRowAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 14), .foregroundColor: UIColor.black ] for (index, event) in events.enumerated() { - // Check if yPosition is close to the bottom of the page and create a new page if necessary - if yPosition + 60 > pageHeight - bottomMargin { // 60 is the estimated height of one row - beginNewPage(isFirstPage: index == 0) + if yPosition + 60 > PageDimension.A4.pageHeight - bottomMargin { + beginNewPage( + context: context, + yPosition: &yPosition, + isFirstPage: index == 0 + ) } - // Draw Date and Vehicle Name as single-line text event.date .formatted() .draw( - at: CGPoint( - x: leftMargin, - y: yPosition - ), + at: CGPoint(x: leftMargin, y: yPosition), withAttributes: tableRowAttributes ) - vehicleName - .draw( - at: CGPoint( - x: leftMargin + columnWidth, - y: yPosition - ), - withAttributes: tableRowAttributes - ) + vehicleName.draw( + at: CGPoint(x: leftMargin + columnWidth, y: yPosition), + withAttributes: tableRowAttributes + ) - // Draw Notes with text wrapping within a bounding rectangle let notesRect = CGRect( x: leftMargin + 2 * columnWidth, y: yPosition, @@ -116,27 +76,28 @@ final class CarMaintenancePDFGenerator: PDFGeneratable { ) event.notes.draw(in: notesRect, withAttributes: tableRowAttributes) - // Adjust the spacing for the next row yPosition += 60 } } - // Save the PDF data to the file URL do { - try pdfData.write(to: fileURL!) - print("PDF saved to: \(fileURL!.path)") - return fileURL + 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 } } - // Helper function to draw header - private func drawHeader( - context: UIGraphicsPDFRendererContext, - yPosition: inout CGFloat - ) { + // 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 @@ -159,35 +120,40 @@ final class CarMaintenancePDFGenerator: PDFGeneratable { let eventTitleSize = eventTitleString.size(withAttributes: subtitleAttributes) let dateRangeSize = dateRangeString.size(withAttributes: subtitleAttributes) - // Center the headers - let titleX = (pageWidth - titleSize.width) / 2 - let vehicleX = (pageWidth - vehicleSize.width) / 2 - let eventTitleX = (pageWidth - eventTitleSize.width) / 2 - let dateRangeX = (pageWidth - dateRangeSize.width) / 2 + 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 - // Draw the main title centered titleString.draw(at: CGPoint(x: titleX, y: yPosition), withAttributes: titleAttributes) yPosition += 30 - // Draw the vehicle name centered vehicleName.draw(at: CGPoint(x: vehicleX, y: yPosition), withAttributes: subtitleAttributes) yPosition += 30 - // Draw the maintenance event title centered eventTitleString.draw(at: CGPoint(x: eventTitleX, y: yPosition), withAttributes: subtitleAttributes) yPosition += 30 - // Draw date range centered dateRangeString.draw(at: CGPoint(x: dateRangeX, y: yPosition), withAttributes: subtitleAttributes) - yPosition += 50 // Add more space after the headers + yPosition += 50 drawColumnsHeaders(yPosition: &yPosition) } - private func drawColumnsHeaders( - yPosition: inout CGFloat + private func beginNewPage( + context: UIGraphicsPDFRendererContext, + yPosition: inout CGFloat, + isFirstPage: Bool ) { - // Draw the headers of each column + 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" @@ -197,25 +163,16 @@ final class CarMaintenancePDFGenerator: PDFGeneratable { .foregroundColor: UIColor.black ] - dateColumnHeader - .draw( - at: CGPoint( - x: leftMargin, - y: yPosition - ), - withAttributes: subtitleAttributes - ) + dateColumnHeader.draw( + at: CGPoint(x: leftMargin, y: yPosition), + withAttributes: subtitleAttributes + ) - vehicleColumnHeader - .draw( - at: CGPoint( - x: leftMargin + columnWidth, - y: yPosition - ), - withAttributes: subtitleAttributes - ) + vehicleColumnHeader.draw( + at: CGPoint(x: leftMargin + columnWidth, y: yPosition), + withAttributes: subtitleAttributes + ) - // Draw Notes with text wrapping within a bounding rectangle let notesRect = CGRect( x: leftMargin + 2 * columnWidth, y: yPosition, @@ -224,7 +181,6 @@ final class CarMaintenancePDFGenerator: PDFGeneratable { ) noteColumnHeader.draw(in: notesRect, withAttributes: subtitleAttributes) - // Adjust the spacing for the next row yPosition += 30 } } diff --git a/Basic-Car-Maintenance/Shared/Dashboard/Views/DashboardView.swift b/Basic-Car-Maintenance/Shared/Dashboard/Views/DashboardView.swift index 31d163dd..af82ce1b 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/Views/DashboardView.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/DashboardView.swift @@ -14,9 +14,7 @@ struct DashboardView: View { @State private var isShowingAddView = false @State private var viewModel: DashboardViewModel @State private var isShowingEditView = false - @State private var isShowingShareView = false - @State private var didGetURL = false - @State private var pdfURL: URL? + @State private var isShowingExportOptionsView = false @State private var selectedMaintenanceEvent: MaintenanceEvent? init(userUID: String?) { @@ -156,13 +154,13 @@ struct DashboardView: View { if !viewModel.events.isEmpty { ToolbarItem(placement: .topBarLeading) { Button { - isShowingShareView = true + isShowingExportOptionsView = true } label: { Image(systemName: SFSymbol.share) } .accessibilityShowsLargeContentViewer { Label { - Text("ExportEvent", comment: "Label for exporting maintenance events") + Text("Export Event", comment: "Label for exporting maintenance events") } icon: { Image(systemName: SFSymbol.share) } @@ -177,26 +175,11 @@ struct DashboardView: View { .sheet(isPresented: $isShowingAddView) { makeAddMaintenanceView() } - .sheet(isPresented: $isShowingShareView) { - ExportOptionsView( - vehicles: viewModel.vehicles, - eventsFetcher: viewModel - ) { url in - pdfURL = url - } + .sheet(isPresented: $isShowingExportOptionsView) { + ExportOptionsView(dataSource: viewModel.vehiclesWithSortedEventsDict) .presentationDetents([.fraction(0.35)]) .presentationCornerRadius(10) } - .onChange(of: pdfURL) { _, newURL in - if newURL != nil { - didGetURL = true - } - } - .sheet(isPresented: $didGetURL) { - if let pdfURL { - PDFShareController(url: pdfURL) - } - } } .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 index bb274860..4233c6d6 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift @@ -7,28 +7,19 @@ // import SwiftUI - -protocol MaintenanceEventsFetcher { - func fetchEvents(for vehicle: Vehicle) -> [MaintenanceEvent] -} +import PDFKit struct ExportOptionsView: View { @Environment(\.dismiss) var dismiss @State private var selectedVehicle: Vehicle? @State private var isShowingShareSheet = false - private let eventsFetcher: MaintenanceEventsFetcher - private let onExport: (URL) -> Void - private let vehicles: [Vehicle] + @State private var pdfDoc: PDFDocument? - init( - vehicles: [Vehicle], - eventsFetcher: MaintenanceEventsFetcher, - onExport: @escaping (URL) -> Void - ) { - self.vehicles = vehicles - self.eventsFetcher = eventsFetcher - self.onExport = onExport - self._selectedVehicle = State(initialValue: vehicles.first) + private let dataSource: [Vehicle: [MaintenanceEvent]] + + init(dataSource: [Vehicle: [MaintenanceEvent]]) { + self.dataSource = dataSource + self._selectedVehicle = State(initialValue: dataSource.first?.key) } var body: some View { @@ -39,30 +30,48 @@ struct ExportOptionsView: View { .padding(.top, 20) Picker("Select a Vehicle", selection: $selectedVehicle) { - ForEach(vehicles, id: \.id) { vehicle in + ForEach(dataSource.map(\.key)) { vehicle in Text(vehicle.name) - .tag(vehicle as Vehicle?) + .tag(vehicle) } } - .pickerStyle(InlinePickerStyle()) + .pickerStyle(.inline) } .padding(.horizontal) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Export") { - if let selectedVehicle { - let events = eventsFetcher.fetchEvents(for: selectedVehicle) + if let selectedVehicle, + let events = self.dataSource[selectedVehicle] { let pdfGenerator = CarMaintenancePDFGenerator( vehicleName: selectedVehicle.name, events: events ) - if let pdfURL = pdfGenerator.generatePDF() { - onExport(pdfURL) - dismiss() - } + self.pdfDoc = pdfGenerator.generatePDF() + isShowingShareSheet = true + } + } + } + } + .sheet(isPresented: $isShowingShareSheet) { + 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", image: SFSymbol.share) } + .safeAreaPadding(.bottom) } - .disabled(selectedVehicle == nil) + .presentationDetents([.medium]) } } } diff --git a/Basic-Car-Maintenance/Shared/Dashboard/Views/PDFShareController.swift b/Basic-Car-Maintenance/Shared/Dashboard/Views/PDFShareController.swift deleted file mode 100644 index d4faec13..00000000 --- a/Basic-Car-Maintenance/Shared/Dashboard/Views/PDFShareController.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// PDFShareController.swift -// Basic-Car-Maintenance -// -// https://github.com/mikaelacaron/Basic-Car-Maintenance -// See LICENSE for license information. -// - -import UIKit -import SwiftUI - -struct PDFShareController: UIViewControllerRepresentable { - var url: URL - - func makeUIViewController(context: Context) -> UIActivityViewController { - let activityViewController = UIActivityViewController( - activityItems: [url], - applicationActivities: nil - ) - // For iPads, you need to specify the source of the activity view controller - if let popoverController = activityViewController.popoverPresentationController { - popoverController.sourceView = context.coordinator.sourceView - popoverController.sourceRect = CGRect( - x: UIScreen.main.bounds.midX, - y: UIScreen.main.bounds.midY, - width: 0, - height: 0 - ) - popoverController.permittedArrowDirections = [] - } - - return activityViewController - } - - func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) { - // No update needed - } - - class Coordinator: NSObject { - var sourceView: UIView - init(sourceView: UIView) { - self.sourceView = sourceView - } - } - - func makeCoordinator() -> Coordinator { - return Coordinator(sourceView: UIView()) - } -} diff --git a/Basic-Car-Maintenance/Shared/Localizable.xcstrings b/Basic-Car-Maintenance/Shared/Localizable.xcstrings index 65965da8..6c941978 100644 --- a/Basic-Car-Maintenance/Shared/Localizable.xcstrings +++ b/Basic-Car-Maintenance/Shared/Localizable.xcstrings @@ -2019,7 +2019,7 @@ "Export" : { }, - "ExportEvent" : { + "Export Event" : { "comment" : "Label for exporting maintenance events" }, "Failed To Add Vehicle" : { @@ -4506,6 +4506,9 @@ } } } + }, + "Share" : { + }, "Sign Out" : { "localizations" : { 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) + } + +} From 1d7fa9a155efd87ea231b93c8f3f74ae3f38f02b Mon Sep 17 00:00:00 2001 From: Mikaela Caron Date: Fri, 25 Oct 2024 10:19:45 -0400 Subject: [PATCH 3/3] PR suggestions --- .../{Views => }/CarMaintenancePDFGenerator.swift | 11 +++++------ .../Dashboard/ViewModels/DashboardViewModel.swift | 1 - .../Shared/Dashboard/Views/DashboardView.swift | 3 +-- .../Shared/Dashboard/Views/ExportOptionsView.swift | 10 +++++----- Basic-Car-Maintenance/Shared/Localizable.xcstrings | 2 +- 5 files changed, 12 insertions(+), 15 deletions(-) rename Basic-Car-Maintenance/Shared/Dashboard/{Views => }/CarMaintenancePDFGenerator.swift (96%) diff --git a/Basic-Car-Maintenance/Shared/Dashboard/Views/CarMaintenancePDFGenerator.swift b/Basic-Car-Maintenance/Shared/Dashboard/CarMaintenancePDFGenerator.swift similarity index 96% rename from Basic-Car-Maintenance/Shared/Dashboard/Views/CarMaintenancePDFGenerator.swift rename to Basic-Car-Maintenance/Shared/Dashboard/CarMaintenancePDFGenerator.swift index baae195c..d860567d 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/Views/CarMaintenancePDFGenerator.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/CarMaintenancePDFGenerator.swift @@ -27,7 +27,7 @@ final class CarMaintenancePDFGenerator { init(vehicleName: String, events: [MaintenanceEvent]) { self.vehicleName = vehicleName self.events = events - self.columnWidth = (PageDimension.A4.pageWidth - leftMargin - rightMargin) / 3 + self.columnWidth = (PageDimension.A4.pageWidth - leftMargin - rightMargin) / 3 } func generatePDF() -> PDFDocument? { @@ -58,10 +58,7 @@ final class CarMaintenancePDFGenerator { event.date .formatted() - .draw( - at: CGPoint(x: leftMargin, y: yPosition), - withAttributes: tableRowAttributes - ) + .draw(at: CGPoint(x: leftMargin, y: yPosition), withAttributes: tableRowAttributes) vehicleName.draw( at: CGPoint(x: leftMargin + columnWidth, y: yPosition), @@ -84,9 +81,11 @@ final class CarMaintenancePDFGenerator { 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) @@ -96,7 +95,7 @@ final class CarMaintenancePDFGenerator { } } - // Draw the center header and header columns + /// 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), diff --git a/Basic-Car-Maintenance/Shared/Dashboard/ViewModels/DashboardViewModel.swift b/Basic-Car-Maintenance/Shared/Dashboard/ViewModels/DashboardViewModel.swift index d1c95b97..8febbc65 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/ViewModels/DashboardViewModel.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/ViewModels/DashboardViewModel.swift @@ -38,7 +38,6 @@ class DashboardViewModel { result[currentVehicle] = events .filter { $0.vehicleID == currentVehicle.id } .sorted(by: { $0.date < $1.date }) - } } diff --git a/Basic-Car-Maintenance/Shared/Dashboard/Views/DashboardView.swift b/Basic-Car-Maintenance/Shared/Dashboard/Views/DashboardView.swift index af82ce1b..fc9060e4 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/Views/DashboardView.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/DashboardView.swift @@ -177,8 +177,7 @@ struct DashboardView: View { } .sheet(isPresented: $isShowingExportOptionsView) { ExportOptionsView(dataSource: viewModel.vehiclesWithSortedEventsDict) - .presentationDetents([.fraction(0.35)]) - .presentationCornerRadius(10) + .presentationDetents([.medium]) } } .onChange(of: scenePhase) { _, newScenePhase in diff --git a/Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift b/Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift index 4233c6d6..6c4b905f 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift @@ -12,7 +12,7 @@ import PDFKit struct ExportOptionsView: View { @Environment(\.dismiss) var dismiss @State private var selectedVehicle: Vehicle? - @State private var isShowingShareSheet = false + @State private var isShowingThumbnail = false @State private var pdfDoc: PDFDocument? private let dataSource: [Vehicle: [MaintenanceEvent]] @@ -35,7 +35,7 @@ struct ExportOptionsView: View { .tag(vehicle) } } - .pickerStyle(.inline) + .pickerStyle(.wheel) } .padding(.horizontal) .toolbar { @@ -48,12 +48,12 @@ struct ExportOptionsView: View { events: events ) self.pdfDoc = pdfGenerator.generatePDF() - isShowingShareSheet = true + isShowingThumbnail = true } } } } - .sheet(isPresented: $isShowingShareSheet) { + .sheet(isPresented: $isShowingThumbnail) { if let pdfDoc, let url = pdfDoc.documentURL, let thumbnail = pdfDoc @@ -67,7 +67,7 @@ struct ExportOptionsView: View { ShareLink(item: url) { VStack { Image(uiImage: thumbnail) - Label("Share", image: SFSymbol.share) + Label("Share", systemImage: SFSymbol.share) } .safeAreaPadding(.bottom) } diff --git a/Basic-Car-Maintenance/Shared/Localizable.xcstrings b/Basic-Car-Maintenance/Shared/Localizable.xcstrings index 6c941978..0a0df49c 100644 --- a/Basic-Car-Maintenance/Shared/Localizable.xcstrings +++ b/Basic-Car-Maintenance/Shared/Localizable.xcstrings @@ -4508,7 +4508,7 @@ } }, "Share" : { - + "comment" : "Share the exported file in the share sheet" }, "Sign Out" : { "localizations" : {