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"