diff --git a/Gas Man.xcodeproj/project.pbxproj b/Gas Man.xcodeproj/project.pbxproj index d167c41..d459cf9 100644 --- a/Gas Man.xcodeproj/project.pbxproj +++ b/Gas Man.xcodeproj/project.pbxproj @@ -289,7 +289,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "Gas Man/Gas_Man.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 202503191240; + CURRENT_PROJECT_VERSION = 202503191418; DEVELOPMENT_ASSET_PATHS = "\"Gas Man/Preview Content\""; DEVELOPMENT_TEAM = Z734T5CD6B; ENABLE_HARDENED_RUNTIME = YES; @@ -332,7 +332,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "Gas Man/Gas_Man.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 202503191240; + CURRENT_PROJECT_VERSION = 202503191418; DEVELOPMENT_ASSET_PATHS = "\"Gas Man/Preview Content\""; DEVELOPMENT_TEAM = Z734T5CD6B; ENABLE_HARDENED_RUNTIME = YES; diff --git a/Gas Man/FuelLogs/FuelLogImporterView.swift b/Gas Man/FuelLogs/FuelLogImporterView.swift new file mode 100644 index 0000000..34c481a --- /dev/null +++ b/Gas Man/FuelLogs/FuelLogImporterView.swift @@ -0,0 +1,168 @@ +import SwiftUI +import UniformTypeIdentifiers +import CoreData + +struct FuelLogImporterView: View { + @Environment(\.managedObjectContext) private var viewContext + @Environment(\.dismiss) var dismiss + + // Pass in the selected vehicle from FuelLogListView. + var selectedVehicle: Vehicle? + + @State private var isFileImporterPresented = false + @State private var importError: String? = nil + + var body: some View { + VStack(spacing: 20) { + Text("Import Fuel Logs from CSV") + .font(.headline) + Button("Select CSV File") { + isFileImporterPresented = true + } + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(8) + + if let error = importError { + Text("Error: \(error)") + .foregroundColor(.red) + } + } + .fileImporter( + isPresented: $isFileImporterPresented, + allowedContentTypes: [UTType.commaSeparatedText], + allowsMultipleSelection: false + ) { result in + switch result { + case .success(let urls): + if let url = urls.first { + // Request access to the file URL. + guard url.startAccessingSecurityScopedResource() else { + importError = "Permission error: Unable to access the selected file." + return + } + defer { url.stopAccessingSecurityScopedResource() } + importCSV(from: url) + } + case .failure(let error): + importError = error.localizedDescription + } + } + .navigationTitle("Import Fuel Logs") + } + + private func importCSV(from url: URL) { + do { + let data = try Data(contentsOf: url) + guard let csvString = String(data: data, encoding: .utf8) else { + importError = "Could not decode file." + return + } + // Assume the first line is a header. + let lines = csvString.components(separatedBy: "\n").filter { !$0.isEmpty } + guard lines.count > 1 else { + importError = "CSV file appears empty." + return + } + // For this example, assume the CSV columns are: + // date,odometer,fuelVolume,cost,locationCoordinates,locationName,octane,pricePerGalon,fullTank + let header = lines[0].components(separatedBy: ",") + for line in lines.dropFirst() { + let fields = line.components(separatedBy: ",") + if fields.count < header.count { + print("Skipping row: \(line) – not enough fields (found \(fields.count), expected \(header.count))") + continue + } + + // Trim whitespace for each field + let dateStr = fields[0].trimmingCharacters(in: .whitespacesAndNewlines) + let odometerStr = fields[1].trimmingCharacters(in: .whitespacesAndNewlines) + let fuelVolumeStr = fields[2].trimmingCharacters(in: .whitespacesAndNewlines) + let costStr = fields[3].trimmingCharacters(in: .whitespacesAndNewlines) + let locationCoordinates = fields[4].trimmingCharacters(in: .whitespacesAndNewlines) + let locationName = fields[5].trimmingCharacters(in: .whitespacesAndNewlines) + let octaneStr = fields[6].trimmingCharacters(in: .whitespacesAndNewlines) + let pricePerGalonStr = fields[7].trimmingCharacters(in: .whitespacesAndNewlines) + let fullTankStr = fields[8].trimmingCharacters(in: .whitespacesAndNewlines) + + // Debug prints for each field + print("Row: \(line)") + print("Date: \(dateStr), Odometer: \(odometerStr), Fuel Volume: \(fuelVolumeStr), Cost: \(costStr), Octane: \(octaneStr), Price/Gal: \(pricePerGalonStr), FullTank: \(fullTankStr)") + + // Setup date formatter – adjust format as needed. + var parsedDate: Date? + let formats = ["M/d/yyyy", "yyyy-MM-dd HH:mm"] + for format in formats { + let formatter = DateFormatter() + formatter.dateFormat = format + formatter.locale = Locale(identifier: "en_US_POSIX") + if let date = formatter.date(from: dateStr) { + parsedDate = date + break + } + } + guard let date = parsedDate else { + print("Skipping row – invalid date: \(dateStr)") + continue + } + + guard let odometer = Double(odometerStr) else { + print("Skipping row – invalid odometer: \(odometerStr)") + continue + } + + guard let fuelVolume = Double(fuelVolumeStr) else { + print("Skipping row – invalid fuel volume: \(fuelVolumeStr)") + continue + } + + guard let cost = Double(costStr) else { + print("Skipping row – invalid cost: \(costStr)") + continue + } + + guard let octane = Int16(octaneStr) else { + print("Skipping row – invalid octane: \(octaneStr)") + continue + } + + guard let pricePerGalon = Double(pricePerGalonStr) else { + print("Skipping row – invalid price per gallon: \(pricePerGalonStr)") + continue + } + + // If all conversions succeed, create the fuel log. + let fullTank = (fullTankStr.lowercased() == "true" || fullTankStr == "1") + + let newLog = FuelLog(context: viewContext) + newLog.id = UUID() + newLog.date = date + newLog.odometer = odometer + newLog.fuelVolume = fuelVolume + newLog.cost = cost + newLog.locationCoordinates = locationCoordinates + newLog.locationName = locationName + newLog.octane = octane + newLog.pricePerGalon = pricePerGalon + newLog.fullTank = fullTank + + // Optionally assign vehicle if needed. + print("Assigning vehicle: \(selectedVehicle?.make ?? "none")") + newLog.vehicle = selectedVehicle + + print("Imported row successfully.") + } + try viewContext.save() + dismiss() + } catch { + importError = error.localizedDescription + } + } +} + +struct FuelLogImporterView_Previews: PreviewProvider { + static var previews: some View { + FuelLogImporterView(selectedVehicle: nil) + } +} diff --git a/Gas Man/FuelLogs/FuelLogListView.swift b/Gas Man/FuelLogs/FuelLogListView.swift index 212c7c2..d907c70 100644 --- a/Gas Man/FuelLogs/FuelLogListView.swift +++ b/Gas Man/FuelLogs/FuelLogListView.swift @@ -24,6 +24,7 @@ struct FuelLogListView: View { @State private var previousOdometer: Double? = nil @State private var showOdometerAlert = false @State private var isUpdatingCalculation = false + @State private var showingImporter = false // Computed property for formatted previous odometer value private var previousOdometerString: String { @@ -154,10 +155,28 @@ struct FuelLogListView: View { ToolbarItem(placement: .navigationBarLeading) { EditButton() } + ToolbarItem(placement: .navigationBarLeading) { + Button("Import CSV") { + showingImporter = true + } + } } .sheet(isPresented: $showingAddFuelLog) { AddFuelLogView().environment(\.managedObjectContext, viewContext) } + .sheet(isPresented: $showingImporter) { + // Retrieve the selected vehicle from the vehicles fetched results. + if let selectedVehicle = vehicles.first(where: { $0.id == selectedVehicleID }) { + FuelLogImporterView(selectedVehicle: selectedVehicle) + .environment(\.managedObjectContext, viewContext) + } else { + // Optionally, provide a fallback, e.g. use the first vehicle. + FuelLogImporterView(selectedVehicle: vehicles.first) + .environment(\.managedObjectContext, viewContext) + } +// FuelLogImporterView() +// .environment(\.managedObjectContext, PersistenceController.shared.container.viewContext) + } .onAppear { print("Total fuel logs: \(fuelLogs.count)") setDefaultVehicle() diff --git a/Gas Man/FuelLogs/FuelLogSummaryView.swift b/Gas Man/FuelLogs/FuelLogSummaryView.swift index c1d2acc..441fe82 100644 --- a/Gas Man/FuelLogs/FuelLogSummaryView.swift +++ b/Gas Man/FuelLogs/FuelLogSummaryView.swift @@ -21,7 +21,7 @@ struct FuelLogSummaryView: View { var distanceSincePrevious: Double? var body: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 2) { // Row 1: Date and MPG (if available) HStack { Text(log.date ?? Date(), formatter: dateFormatter) @@ -31,13 +31,18 @@ struct FuelLogSummaryView: View { if let mpg = mpg { Text("MPG: \(mpg, specifier: "%.3f")") .font(.subheadline) - .foregroundColor(.secondary) + .foregroundColor(.primary) + .bold() } } + .padding(2) + .background(Color.blue) + .cornerRadius(10) + Divider() // Row 2: Distance (instead of odometer) and Fuel Volume HStack { - VStack(alignment: .leading) { + HStack() { Text("Distance") .font(.caption) .foregroundColor(.secondary) @@ -50,17 +55,17 @@ struct FuelLogSummaryView: View { } } Spacer() - VStack(alignment: .leading) { - Text("Fuel") + HStack() { + Text("Fuel (Gal)") .font(.caption) .foregroundColor(.secondary) - Text("\(log.fuelVolume, specifier: "%.3f") gal") + Text("\(log.fuelVolume, specifier: "%.3f")") .bold() } } // Row 3: Cost and Price per Gallon HStack { - VStack(alignment: .leading) { + HStack() { Text("Cost") .font(.caption) .foregroundColor(.secondary) @@ -68,7 +73,7 @@ struct FuelLogSummaryView: View { .bold() } Spacer() - VStack(alignment: .leading) { + HStack() { Text("Price/Gal") .font(.caption) .foregroundColor(.secondary) diff --git a/Gas Man/Gas_Man.entitlements b/Gas Man/Gas_Man.entitlements index ba12f8a..2665a5d 100644 --- a/Gas Man/Gas_Man.entitlements +++ b/Gas Man/Gas_Man.entitlements @@ -11,9 +11,14 @@ com.apple.developer.icloud-services CloudKit + CloudDocuments + com.apple.developer.ubiquity-container-identifiers + com.apple.security.app-sandbox + com.apple.security.assets.pictures.read-only + com.apple.security.files.user-selected.read-write com.apple.security.personal-information.location diff --git a/Gas Man/Stats/DistanceBetweenFillupsTrendChartView.swift b/Gas Man/Stats/DistanceBetweenFillupsTrendChartView.swift index cb960b4..08bda83 100644 --- a/Gas Man/Stats/DistanceBetweenFillupsTrendChartView.swift +++ b/Gas Man/Stats/DistanceBetweenFillupsTrendChartView.swift @@ -48,6 +48,12 @@ struct DistanceBetweenFillupsTrendChartView: View { x: .value("Date", point.date), y: .value("Price", point.distance) ) + .annotation(position: .top) { + // This annotation displays the data point number and the MPG value. + Text("\(point.distance, specifier: "%.0f")") + .font(.caption2) + .foregroundColor(.blue) + } } } .chartXAxis { diff --git a/Gas Man/Stats/GalPerFuelUpTrendChartView.swift b/Gas Man/Stats/GalPerFuelUpTrendChartView.swift index 0e52248..32c1c19 100644 --- a/Gas Man/Stats/GalPerFuelUpTrendChartView.swift +++ b/Gas Man/Stats/GalPerFuelUpTrendChartView.swift @@ -42,6 +42,12 @@ struct GalPerFuelUpTrendChartView: View { x: .value("Date", point.date), y: .value("Price", point.gal) ) + .annotation(position: .top) { + // This annotation displays the data point number and the MPG value. + Text("\(point.gal, specifier: "%.1f")") + .font(.caption2) + .foregroundColor(.blue) + } } } .chartXAxis { @@ -50,6 +56,7 @@ struct GalPerFuelUpTrendChartView: View { .chartYAxis { AxisMarks(values: .automatic(desiredCount: 5)) } + } } .padding() diff --git a/Gas Man/Stats/MPGTrendChartView.swift b/Gas Man/Stats/MPGTrendChartView.swift index b03d2ad..56c2f59 100644 --- a/Gas Man/Stats/MPGTrendChartView.swift +++ b/Gas Man/Stats/MPGTrendChartView.swift @@ -1,10 +1,3 @@ -// -// MPGTrendChartView.swift -// Gas Man -// -// Created by Kameron Kenny on 3/19/25. -// - import SwiftUI import Charts import CoreData @@ -38,7 +31,8 @@ struct MPGTrendChartView: View { .foregroundColor(.secondary) } else { Chart { - ForEach(trendData, id: \.date) { point in + // Enumerate the trend data to get an index for each data point. + ForEach(Array(trendData.enumerated()), id: \.element.date) { index, point in LineMark( x: .value("Date", point.date), y: .value("MPG", point.mpg) @@ -47,13 +41,19 @@ struct MPGTrendChartView: View { x: .value("Date", point.date), y: .value("MPG", point.mpg) ) + .annotation(position: .top) { + // This annotation displays the data point number and the MPG value. + Text("\(point.mpg, specifier: "%.1f")") + .font(.caption2) + .foregroundColor(.blue) + } } } .chartXAxis { - AxisMarks(values: .automatic(desiredCount: 4)) + AxisMarks(values: .automatic(desiredCount: 6)) } .chartYAxis { - AxisMarks(values: .automatic(desiredCount: 5)) + AxisMarks(values: .automatic(desiredCount: 8)) } } } diff --git a/Gas Man/Stats/PricePerGallonTrendChartView.swift b/Gas Man/Stats/PricePerGallonTrendChartView.swift index cb0155e..68db458 100644 --- a/Gas Man/Stats/PricePerGallonTrendChartView.swift +++ b/Gas Man/Stats/PricePerGallonTrendChartView.swift @@ -42,6 +42,12 @@ struct PricePerGallonTrendChartView: View { x: .value("Date", point.date), y: .value("Price", point.price) ) + .annotation(position: .top) { + // This annotation displays the data point number and the MPG value. + Text("\(point.price, specifier: "%.3f")") + .font(.caption2) + .foregroundColor(.blue) + } } } .chartXAxis {