diff --git a/Gas Man.xcodeproj/project.pbxproj b/Gas Man.xcodeproj/project.pbxproj index 6df2ad5..71c59f3 100644 --- a/Gas Man.xcodeproj/project.pbxproj +++ b/Gas Man.xcodeproj/project.pbxproj @@ -296,12 +296,14 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Gas Man/Info.plist"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.travel"; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; + INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; @@ -309,15 +311,16 @@ IPHONEOS_DEPLOYMENT_TARGET = 18.1; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 14.7; + MACOSX_DEPLOYMENT_TARGET = 14.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "pro.thelinux.Gas-Man"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; + TARGETED_DEVICE_FAMILY = "1,2"; XROS_DEPLOYMENT_TARGET = 2.1; }; name = Debug; @@ -336,12 +339,14 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Gas Man/Info.plist"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.travel"; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; + INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; @@ -349,15 +354,16 @@ IPHONEOS_DEPLOYMENT_TARGET = 18.1; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 14.7; + MACOSX_DEPLOYMENT_TARGET = 14.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "pro.thelinux.Gas-Man"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; + TARGETED_DEVICE_FAMILY = "1,2"; XROS_DEPLOYMENT_TARGET = 2.1; }; name = Release; diff --git a/Gas Man.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/Gas Man.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/Gas Man.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,5 @@ + + + + + diff --git a/Gas Man.xcodeproj/project.xcworkspace/xcuserdata/kkenny.xcuserdatad/WorkspaceSettings.xcsettings b/Gas Man.xcodeproj/project.xcworkspace/xcuserdata/kkenny.xcuserdatad/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..bbfef02 --- /dev/null +++ b/Gas Man.xcodeproj/project.xcworkspace/xcuserdata/kkenny.xcuserdatad/WorkspaceSettings.xcsettings @@ -0,0 +1,14 @@ + + + + + BuildLocationStyle + UseAppPreferences + CustomBuildLocationType + RelativeToDerivedData + DerivedDataLocationStyle + Default + ShowSharedSchemesAutomaticallyEnabled + + + diff --git a/Gas Man.xcodeproj/xcshareddata/xcschemes/Gas Man.xcscheme b/Gas Man.xcodeproj/xcshareddata/xcschemes/Gas Man.xcscheme new file mode 100644 index 0000000..26fe07a --- /dev/null +++ b/Gas Man.xcodeproj/xcshareddata/xcschemes/Gas Man.xcscheme @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Gas Man.xcodeproj/xcuserdata/kkenny.xcuserdatad/xcschemes/xcschememanagement.plist b/Gas Man.xcodeproj/xcuserdata/kkenny.xcuserdatad/xcschemes/xcschememanagement.plist index 20c817c..503f867 100644 --- a/Gas Man.xcodeproj/xcuserdata/kkenny.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Gas Man.xcodeproj/xcuserdata/kkenny.xcuserdatad/xcschemes/xcschememanagement.plist @@ -10,5 +10,23 @@ 0 + SuppressBuildableAutocreation + + 3D34444F2D889F7D00AA3172 + + primary + + + 3D3444662D889F7F00AA3172 + + primary + + + 3D3444702D889F8000AA3172 + + primary + + + diff --git a/Gas Man/AddFuelLogView.swift b/Gas Man/AddFuelLogView.swift new file mode 100644 index 0000000..e5bbcf6 --- /dev/null +++ b/Gas Man/AddFuelLogView.swift @@ -0,0 +1,249 @@ +// +// AddFuelLogView.swift +// Gas Man +// +// Created by Kameron Kenny on 3/17/25. +// + +import SwiftUI +import CoreData + +struct AddFuelLogView: View { + @Environment(\.managedObjectContext) private var viewContext + @Environment(\.dismiss) var dismiss + + // Form fields + @State private var date = Date() + @State private var odometer = "" + @State private var fuelVolume = "" + @State private var cost = "" + @State private var locationCoordinates = "" + @State private var locationName = "" + @State private var selectedOctane: Int = 87 // Default or from previous record + @State private var pricePerGalon = "" + + // Allowed octane options + let octaneOptions = [87, 89, 91, 92, 93, 95] + + // Location manager for automatic location updates + @StateObject private var locationManager = LocationManager() + + // For tracking the previous odometer reading + @State private var previousOdometer: Double? = nil + @State private var showOdometerAlert = false + + // Flag to avoid update loops in our calculation logic + @State private var isUpdatingCalculation = false + + // Computed property for formatted previous odometer value + private var previousOdometerString: String { + previousOdometer.map { String(format: "%.0f", $0) } ?? "N/A" + } + + // Computed property for the odometer alert + var odometerAlert: Alert { + let messageString = "Odometer reading must be greater than the previous record (\(previousOdometerString))." + return Alert( + title: Text("Odometer Reading Error"), + message: Text(messageString), + dismissButton: .default(Text("OK")) + ) + } + + var body: some View { + NavigationView { + Form { + fuelLogDetailsSection + } + .navigationTitle("Add Fuel Log") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { saveFuelLog() } + } + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { dismiss() } + } + } + .onChange(of: locationManager.location) { newLocation in + if let loc = newLocation { + locationCoordinates = "\(loc.coordinate.latitude), \(loc.coordinate.longitude)" + } + } + .onChange(of: locationManager.placemark) { newPlacemark in + if let placemark = newPlacemark { + locationName = placemark.name ?? "" + } + } + .onAppear(perform: loadPreviousData) + .alert(isPresented: $showOdometerAlert) { odometerAlert } + } + } + + // MARK: - Subviews + + private var fuelLogDetailsSection: some View { + Section(header: Text("Fuel Log Details")) { + DatePicker("Date", selection: $date, displayedComponents: [.date, .hourAndMinute]) + + // Odometer field + HStack(spacing: 4) { + Text("Odometer: ") + .font(.caption) + .foregroundColor(.secondary) + TextField("", text: $odometer) + .keyboardType(.decimalPad) + .textFieldStyle(RoundedBorderTextFieldStyle()) + } + + // Fuel Volume field + HStack(spacing: 4) { + Text("Gallons: ") + .font(.caption) + .foregroundColor(.secondary) + TextField("", text: $fuelVolume) + .keyboardType(.decimalPad) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .onChange(of: fuelVolume) { _ in updateCalculatedValues() } + } + + // Price per Gallon field + HStack(spacing: 4) { + Text("Price/Gal: $") + .font(.caption) + .foregroundColor(.secondary) + TextField("", text: $pricePerGalon) + .keyboardType(.decimalPad) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .onChange(of: pricePerGalon) { _ in updateCalculatedValues() } + } + + // Cost field + HStack(spacing: 4) { + Text("Cost: $") + .font(.caption) + .foregroundColor(.secondary) + TextField("", text: $cost) + .keyboardType(.decimalPad) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .onChange(of: cost) { _ in updateCalculatedValues() } + } + + Button("Get Current Location") { + locationManager.requestLocation() + } + + if !locationCoordinates.isEmpty { + Text("Coordinates: \(locationCoordinates)") + .font(.caption) + .foregroundColor(.secondary) + } + + TextField("Location Coordinates", text: $locationCoordinates) + + HStack(spacing: 4) { + Text("Location Name:") + .font(.caption) + .foregroundColor(.secondary) + TextField("", text: $locationName) + } + + Picker("Octane", selection: $selectedOctane) { + ForEach(octaneOptions, id: \.self) { option in + Text("\(option)").tag(option) + } + } + .pickerStyle(MenuPickerStyle()) + } + } + + // MARK: - Helper Methods + + private func loadPreviousData() { + let fetchRequest: NSFetchRequest = FuelLog.fetchRequest() + fetchRequest.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)] + fetchRequest.fetchLimit = 1 + if let lastFuelLog = try? viewContext.fetch(fetchRequest).first { + selectedOctane = Int(lastFuelLog.octane) + previousOdometer = lastFuelLog.odometer + odometer = String(format: "%.0f", lastFuelLog.odometer) + } else { + selectedOctane = 87 + odometer = "" + } + } + + private func roundToThree(_ value: Double) -> Double { + return (value * 1000).rounded() / 1000 + } + + private func roundToTwo(_ value: Double) -> Double { + return (value * 100).rounded() / 100 + } + + private func updateCalculatedValues() { + // Prevent recursive updates. + guard !isUpdatingCalculation else { return } + isUpdatingCalculation = true + + let fuel = Double(fuelVolume) + let costVal = Double(cost) + let price = Double(pricePerGalon) + + // 1. If fuelVolume and pricePerGalon are provided, calculate cost. + if let f = fuel, let p = price, f > 0 { + let computedCost = roundToTwo(f * p) + let computedCostStr = String(format: "%.2f", computedCost) + if cost != computedCostStr { + cost = computedCostStr + } + } + // 2. If fuelVolume and cost are provided, and pricePerGalon is empty, calculate pricePerGalon. + else if let f = fuel, let c = costVal, f > 0, pricePerGalon.trimmingCharacters(in: .whitespaces).isEmpty { + let computedPrice = roundToThree(c / f) + let computedPriceStr = String(format: "%.3f", computedPrice) + if pricePerGalon != computedPriceStr { + pricePerGalon = computedPriceStr + } + } + // 3. If pricePerGalon and cost are provided, and fuelVolume is empty, calculate fuelVolume. + else if let p = price, let c = costVal, p > 0, fuelVolume.trimmingCharacters(in: .whitespaces).isEmpty { + let computedFuel = roundToThree(c / p) + let computedFuelStr = String(format: "%.3f", computedFuel) + if fuelVolume != computedFuelStr { + fuelVolume = computedFuelStr + } + } + + isUpdatingCalculation = false + } + + private func saveFuelLog() { + guard let newOdometer = Double(odometer) else { + return + } + // Validate that the new odometer reading is greater than the previous record + if let previous = previousOdometer, newOdometer <= previous { + showOdometerAlert = true + return + } + + let newLog = FuelLog(context: viewContext) + newLog.id = UUID() + newLog.date = date + newLog.odometer = newOdometer + newLog.fuelVolume = Double(fuelVolume) ?? 0 + newLog.cost = Double(cost) ?? 0 + newLog.locationCoordinates = locationCoordinates + newLog.locationName = locationName + newLog.octane = Int16(selectedOctane) + newLog.pricePerGalon = Double(pricePerGalon) ?? 0 + + do { + try viewContext.save() + dismiss() + } catch { + let nsError = error as NSError + fatalError("Unresolved error \(nsError), \(nsError.userInfo)") + } + } +} diff --git a/Gas Man/AddMaintenanceView.swift b/Gas Man/AddMaintenanceView.swift new file mode 100644 index 0000000..090ff26 --- /dev/null +++ b/Gas Man/AddMaintenanceView.swift @@ -0,0 +1,73 @@ +// +// AddMaintenanceView.swift +// Gas Man +// +// Created by Kameron Kenny on 3/17/25. +// + + +import SwiftUI + +struct AddMaintenanceView: View { + @Environment(\.managedObjectContext) private var viewContext + @Environment(\.dismiss) var dismiss + + @State private var date = Date() + @State private var odometer = "" + @State private var eventType = "" + @State private var cost = "" + @State private var notes = "" + @State private var locationCoordinates = "" + @State private var locationName = "" + + var body: some View { + NavigationView { + Form { + Section(header: Text("Maintenance Details")) { + DatePicker("Date", selection: $date, displayedComponents: [.date, .hourAndMinute]) + TextField("Odometer Reading", text: $odometer) + .keyboardType(.decimalPad) + TextField("Service Type", text: $eventType) + TextField("Cost ($)", text: $cost) + .keyboardType(.decimalPad) + TextField("Notes", text: $notes) + TextField("Location Coordinates", text: $locationCoordinates) + TextField("Location Name", text: $locationName) + } + } + .navigationTitle("Add Maintenance") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + saveMaintenance() + } + } + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + } + } + } + + private func saveMaintenance() { + let newEvent = MaintenanceEvent(context: viewContext) + newEvent.id = UUID() + newEvent.date = date + newEvent.odometer = Double(odometer) ?? 0 + newEvent.eventType = eventType + newEvent.cost = Double(cost) ?? 0 + newEvent.notes = notes + newEvent.locationCoordinates = locationCoordinates + newEvent.locationName = locationName + + do { + try viewContext.save() + dismiss() + } catch { + let nsError = error as NSError + fatalError("Unresolved error \(nsError), \(nsError.userInfo)") + } + } +} diff --git a/Gas Man/ContentView.swift b/Gas Man/ContentView.swift index 9201124..e9f3237 100644 --- a/Gas Man/ContentView.swift +++ b/Gas Man/ContentView.swift @@ -6,83 +6,83 @@ // import SwiftUI -import CoreData +//import CoreData +// +//struct ContentView: View { +// @Environment(\.managedObjectContext) private var viewContext +// +// @FetchRequest( +// sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)], +// animation: .default) +// private var items: FetchedResults +// +// var body: some View { +// NavigationView { +// List { +// ForEach(items) { item in +// NavigationLink { +// Text("Item at \(item.timestamp!, formatter: itemFormatter)") +// } label: { +// Text(item.timestamp!, formatter: itemFormatter) +// } +// } +// .onDelete(perform: deleteItems) +// } +// .toolbar { +//#if os(iOS) +// ToolbarItem(placement: .navigationBarTrailing) { +// EditButton() +// } +//#endif +// ToolbarItem { +// Button(action: addItem) { +// Label("Add Item", systemImage: "plus") +// } +// } +// } +// Text("Select an item") +// } +// } +// +// private func addItem() { +// withAnimation { +// let newItem = Item(context: viewContext) +// newItem.timestamp = Date() +// +// do { +// try viewContext.save() +// } catch { +// // Replace this implementation with code to handle the error appropriately. +// // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. +// let nsError = error as NSError +// fatalError("Unresolved error \(nsError), \(nsError.userInfo)") +// } +// } +// } +// +// private func deleteItems(offsets: IndexSet) { +// withAnimation { +// offsets.map { items[$0] }.forEach(viewContext.delete) +// +// do { +// try viewContext.save() +// } catch { +// // Replace this implementation with code to handle the error appropriately. +// // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. +// let nsError = error as NSError +// fatalError("Unresolved error \(nsError), \(nsError.userInfo)") +// } +// } +// } +//} +// +//private let itemFormatter: DateFormatter = { +// let formatter = DateFormatter() +// formatter.dateStyle = .short +// formatter.timeStyle = .medium +// return formatter +//}() -struct ContentView: View { - @Environment(\.managedObjectContext) private var viewContext - - @FetchRequest( - sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)], - animation: .default) - private var items: FetchedResults - - var body: some View { - NavigationView { - List { - ForEach(items) { item in - NavigationLink { - Text("Item at \(item.timestamp!, formatter: itemFormatter)") - } label: { - Text(item.timestamp!, formatter: itemFormatter) - } - } - .onDelete(perform: deleteItems) - } - .toolbar { -#if os(iOS) - ToolbarItem(placement: .navigationBarTrailing) { - EditButton() - } -#endif - ToolbarItem { - Button(action: addItem) { - Label("Add Item", systemImage: "plus") - } - } - } - Text("Select an item") - } - } - - private func addItem() { - withAnimation { - let newItem = Item(context: viewContext) - newItem.timestamp = Date() - - do { - try viewContext.save() - } catch { - // Replace this implementation with code to handle the error appropriately. - // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. - let nsError = error as NSError - fatalError("Unresolved error \(nsError), \(nsError.userInfo)") - } - } - } - - private func deleteItems(offsets: IndexSet) { - withAnimation { - offsets.map { items[$0] }.forEach(viewContext.delete) - - do { - try viewContext.save() - } catch { - // Replace this implementation with code to handle the error appropriately. - // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. - let nsError = error as NSError - fatalError("Unresolved error \(nsError), \(nsError.userInfo)") - } - } - } -} - -private let itemFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateStyle = .short - formatter.timeStyle = .medium - return formatter -}() - -#Preview { - ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) -} +//#Preview { +// ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) +//} diff --git a/Gas Man/FuelLogDetailView.swift b/Gas Man/FuelLogDetailView.swift new file mode 100644 index 0000000..6857a66 --- /dev/null +++ b/Gas Man/FuelLogDetailView.swift @@ -0,0 +1,74 @@ +// +// FuelLogDetailView.swift +// Gas Man +// +// Created by Kameron Kenny on 3/17/25. +// + +import SwiftUI + +private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + return formatter +}() + + +struct FuelLogDetailView: View { + var fuelLog: FuelLog + + var body: some View { + Form { + Section(header: Text("Basic Information")) { + HStack { + Text("Date:") + Spacer() + Text(fuelLog.date ?? Date(), formatter: dateFormatter) + } + HStack { + Text("Odometer:") + Spacer() + Text("\(fuelLog.odometer, specifier: "%.0f") miles") + } + } + Section(header: Text("Fuel Information")) { + HStack { + Text("Fuel Volume:") + Spacer() + Text("\(fuelLog.fuelVolume, specifier: "%.3f") gallons") + } + HStack { + Text("Cost:") + Spacer() + Text("$\(fuelLog.cost, specifier: "%.2f")") + } + HStack { + Text("Price per Gallon:") + Spacer() + Text("$\(fuelLog.pricePerGalon, specifier: "%.3f")") + } + } + Section(header: Text("Location")) { + HStack { + Text("Coordinates:") + Spacer() + Text(fuelLog.locationCoordinates ?? "N/A") + } + HStack { + Text("Location Name:") + Spacer() + Text(fuelLog.locationName ?? "N/A") + } + } + Section(header: Text("Fuel Details")) { + HStack { + Text("Octane:") + Spacer() + Text("\(fuelLog.octane)") + } + } + } + .navigationTitle("Fuel Log Detail") + } +} diff --git a/Gas Man/FuelLogListView.swift b/Gas Man/FuelLogListView.swift new file mode 100644 index 0000000..f184b57 --- /dev/null +++ b/Gas Man/FuelLogListView.swift @@ -0,0 +1,85 @@ +// +// FuelLogListView.swift +// Gas Man +// +// Created by Kameron Kenny on 3/17/25. +// + + +import SwiftUI +import CoreData + +struct FuelLogListView: View { + @Environment(\.managedObjectContext) private var viewContext + @FetchRequest( + sortDescriptors: [NSSortDescriptor(keyPath: \FuelLog.date, ascending: false)], + animation: .default) + private var fuelLogs: FetchedResults + + @State private var showingAddFuelLog = false // Controls sheet presentation + + var body: some View { + NavigationView { + List { + ForEach(Array(fuelLogs.enumerated()), id: \.element) { index, log in + // Compute distance since the previous record: + let distance: Double? = { + if index < fuelLogs.count - 1 { + let previousLog = fuelLogs[index + 1] + let milesDriven = log.odometer - previousLog.odometer + return milesDriven > 0 ? milesDriven : nil + } + return nil + }() + + // Compute MPG (if possible) + let mpg: Double? = { + if index < fuelLogs.count - 1 { + let previousLog = fuelLogs[index + 1] + let milesDriven = log.odometer - previousLog.odometer + if log.fuelVolume > 0 && milesDriven > 0 { + return milesDriven / log.fuelVolume + } + } + return nil + }() + + NavigationLink(destination: FuelLogDetailView(fuelLog: log)) { + FuelLogSummaryView(log: log, mpg: mpg, distanceSincePrevious: distance) + } + } + .onDelete(perform: deleteFuelLogs) + } + .listStyle(InsetGroupedListStyle()) + .navigationTitle("Fuel Logs") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { + showingAddFuelLog = true // Present AddFuelLogView + }) { + Label("Add Fuel Log", systemImage: "plus") + } + } + ToolbarItem(placement: .navigationBarLeading) { + EditButton() + } + } + // Sheet presentation for AddFuelLogView + .sheet(isPresented: $showingAddFuelLog) { + AddFuelLogView().environment(\.managedObjectContext, viewContext) + } + } + } + + private func deleteFuelLogs(offsets: IndexSet) { + withAnimation { + offsets.map { fuelLogs[$0] }.forEach(viewContext.delete) + do { + try viewContext.save() + } catch { + let nsError = error as NSError + fatalError("Unresolved error \(nsError), \(nsError.userInfo)") + } + } + } +} diff --git a/Gas Man/FuelLogSummaryView.swift b/Gas Man/FuelLogSummaryView.swift new file mode 100644 index 0000000..c1d2acc --- /dev/null +++ b/Gas Man/FuelLogSummaryView.swift @@ -0,0 +1,84 @@ +// +// FuelLogSummaryView.swift +// Gas Man +// +// Created by Kameron Kenny on 3/17/25. +// + + +import SwiftUI + +private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + return formatter +}() + +struct FuelLogSummaryView: View { + var log: FuelLog + var mpg: Double? + var distanceSincePrevious: Double? + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // Row 1: Date and MPG (if available) + HStack { + Text(log.date ?? Date(), formatter: dateFormatter) + .font(.subheadline) + .foregroundColor(.primary) + Spacer() + if let mpg = mpg { + Text("MPG: \(mpg, specifier: "%.3f")") + .font(.subheadline) + .foregroundColor(.secondary) + } + } + Divider() + // Row 2: Distance (instead of odometer) and Fuel Volume + HStack { + VStack(alignment: .leading) { + Text("Distance") + .font(.caption) + .foregroundColor(.secondary) + if let distance = distanceSincePrevious { + Text("\(distance, specifier: "%.0f") miles") + .bold() + } else { + Text("N/A") + .bold() + } + } + Spacer() + VStack(alignment: .leading) { + Text("Fuel") + .font(.caption) + .foregroundColor(.secondary) + Text("\(log.fuelVolume, specifier: "%.3f") gal") + .bold() + } + } + // Row 3: Cost and Price per Gallon + HStack { + VStack(alignment: .leading) { + Text("Cost") + .font(.caption) + .foregroundColor(.secondary) + Text("$\(log.cost, specifier: "%.2f")") + .bold() + } + Spacer() + VStack(alignment: .leading) { + Text("Price/Gal") + .font(.caption) + .foregroundColor(.secondary) + Text("$\(log.pricePerGalon, specifier: "%.3f")") + .bold() + } + } + } + .padding(8) + .background(Color(.secondarySystemBackground)) + .cornerRadius(8) + } +} diff --git a/Gas Man/Gas_Man.entitlements b/Gas Man/Gas_Man.entitlements index 53000f3..ba12f8a 100644 --- a/Gas Man/Gas_Man.entitlements +++ b/Gas Man/Gas_Man.entitlements @@ -14,7 +14,9 @@ com.apple.security.app-sandbox - com.apple.security.files.user-selected.read-only + com.apple.security.files.user-selected.read-write + + com.apple.security.personal-information.location diff --git a/Gas Man/Gas_Man.xcdatamodeld/Gas_Man.xcdatamodel/contents b/Gas Man/Gas_Man.xcdatamodeld/Gas_Man.xcdatamodel/contents index e8d6ec8..066b8a8 100644 --- a/Gas Man/Gas_Man.xcdatamodeld/Gas_Man.xcdatamodel/contents +++ b/Gas Man/Gas_Man.xcdatamodeld/Gas_Man.xcdatamodel/contents @@ -1,9 +1,25 @@ - - - + + + + + + + + + + + + + + + + + + + + + + - - - \ No newline at end of file diff --git a/Gas Man/Gas_ManApp.swift b/Gas Man/Gas_ManApp.swift index 5dc3538..c989573 100644 --- a/Gas Man/Gas_ManApp.swift +++ b/Gas Man/Gas_ManApp.swift @@ -13,8 +13,9 @@ struct Gas_ManApp: App { var body: some Scene { WindowGroup { - ContentView() + MainTabView() // Replace with your actual view name. .environment(\.managedObjectContext, persistenceController.container.viewContext) } } } + diff --git a/Gas Man/Info.plist b/Gas Man/Info.plist index ca9a074..5aee0c5 100644 --- a/Gas Man/Info.plist +++ b/Gas Man/Info.plist @@ -2,6 +2,8 @@ + NSLocationWhenInUseUsageDescription + This app needs your location to automatically fill in location details for fuel and maintenance logs UIBackgroundModes remote-notification diff --git a/Gas Man/LocationManager.swift b/Gas Man/LocationManager.swift new file mode 100644 index 0000000..0cf34fb --- /dev/null +++ b/Gas Man/LocationManager.swift @@ -0,0 +1,55 @@ +// +// LocationManager.swift +// Gas Man +// +// Created by Kameron Kenny on 3/17/25. +// + + +import Foundation +import CoreLocation +import Combine + +class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate { + private let manager = CLLocationManager() + + @Published var location: CLLocation? + @Published var placemark: CLPlacemark? + + override init() { + super.init() + manager.delegate = self + manager.desiredAccuracy = kCLLocationAccuracyBest + } + + func requestLocation() { + manager.requestWhenInUseAuthorization() + manager.requestLocation() + } + + // CLLocationManagerDelegate + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + if let loc = locations.first { + DispatchQueue.main.async { + self.location = loc + } + // Reverse geocode + let geocoder = CLGeocoder() + geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, error in + if let error = error { + print("Reverse geocode error: \(error)") + return + } + if let firstPlacemark = placemarks?.first { + DispatchQueue.main.async { + self?.placemark = firstPlacemark + } + } + } + } + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + print("Failed to get user location: \(error)") + } +} diff --git a/Gas Man/MainTabView.swift b/Gas Man/MainTabView.swift new file mode 100644 index 0000000..aa38f94 --- /dev/null +++ b/Gas Man/MainTabView.swift @@ -0,0 +1,23 @@ +// +// MainTabView.swift +// Gas Man +// +// Created by Kameron Kenny on 3/17/25. +// + +import SwiftUI + +struct MainTabView: View { + var body: some View { + TabView { + FuelLogListView() + .tabItem { + Label("Fuel", systemImage: "fuelpump.fill") + } + MaintenanceListView() + .tabItem { + Label("Maintenance", systemImage: "wrench.fill") + } + } + } +} diff --git a/Gas Man/MaintenanceListView.swift b/Gas Man/MaintenanceListView.swift new file mode 100644 index 0000000..0eae57a --- /dev/null +++ b/Gas Man/MaintenanceListView.swift @@ -0,0 +1,78 @@ +// +// MaintenanceListView.swift +// Gas Man +// +// Created by Kameron Kenny on 3/17/25. +// + + +import SwiftUI +import CoreData + +struct MaintenanceListView: View { + @Environment(\.managedObjectContext) private var viewContext + @FetchRequest( + sortDescriptors: [NSSortDescriptor(keyPath: \MaintenanceEvent.date, ascending: false)], + animation: .default) + private var maintenanceEvents: FetchedResults + + @State private var showingAddMaintenance = false + + var body: some View { + NavigationView { + List { + ForEach(maintenanceEvents) { event in + VStack(alignment: .leading, spacing: 4) { + Text("Date: \(event.date ?? Date(), formatter: dateFormatter)") + Text("Odometer: \(event.odometer, specifier: "%.0f") miles") + Text("Type: \(event.eventType)") + Text("Cost: $\(event.cost, specifier: "%.2f")") + if let notes = event.notes, !notes.isEmpty { + Text("Notes: \(notes)") + } + if let locationName = event.locationName, !locationName.isEmpty { + Text("Location: \(locationName)") + } + if let locationCoordinates = event.locationCoordinates, !locationCoordinates.isEmpty { + Text("Coordinates: \(locationCoordinates)") + } + } + } + .onDelete(perform: deleteMaintenance) + } + .navigationTitle("Maintenance") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { showingAddMaintenance.toggle() }) { + Label("Add Maintenance", systemImage: "plus") + } + } + ToolbarItem(placement: .navigationBarLeading) { + EditButton() + } + } + .sheet(isPresented: $showingAddMaintenance) { + AddMaintenanceView().environment(\.managedObjectContext, viewContext) + } + } + } + + private func deleteMaintenance(offsets: IndexSet) { + withAnimation { + offsets.map { maintenanceEvents[$0] }.forEach(viewContext.delete) + do { + try viewContext.save() + } catch { + let nsError = error as NSError + fatalError("Unresolved error \(nsError), \(nsError.userInfo)") + } + } + } +} + +private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + return formatter +}() diff --git a/Gas Man/Persistence.swift b/Gas Man/Persistence.swift index e9118a4..535289a 100644 --- a/Gas Man/Persistence.swift +++ b/Gas Man/Persistence.swift @@ -9,49 +9,31 @@ import CoreData struct PersistenceController { static let shared = PersistenceController() - - @MainActor - static let preview: PersistenceController = { - let result = PersistenceController(inMemory: true) - let viewContext = result.container.viewContext - for _ in 0..<10 { - let newItem = Item(context: viewContext) - newItem.timestamp = Date() - } - do { - try viewContext.save() - } catch { - // Replace this implementation with code to handle the error appropriately. - // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. - let nsError = error as NSError - fatalError("Unresolved error \(nsError), \(nsError.userInfo)") - } - return result - }() - + let container: NSPersistentCloudKitContainer init(inMemory: Bool = false) { + // "GasMan" must match the name of your .xcdatamodeld file. container = NSPersistentCloudKitContainer(name: "Gas_Man") + if inMemory { - container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null") + container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null") } - container.loadPersistentStores(completionHandler: { (storeDescription, error) in + + // Enable CloudKit options if necessary + guard let description = container.persistentStoreDescriptions.first else { + fatalError("No persistent store description found.") + } + description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey) + description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(containerIdentifier: "iCloud.com.pro.thelinux.GasMan") // Update with your container ID + + container.loadPersistentStores { storeDescription, error in if let error = error as NSError? { - // Replace this implementation with code to handle the error appropriately. - // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. - - /* - Typical reasons for an error here include: - * The parent directory does not exist, cannot be created, or disallows writing. - * The persistent store is not accessible, due to permissions or data protection when the device is locked. - * The device is out of space. - * The store could not be migrated to the current model version. - Check the error message to determine what the actual problem was. - */ fatalError("Unresolved error \(error), \(error.userInfo)") } - }) + } + + // Automatically merge changes from iCloud container.viewContext.automaticallyMergesChangesFromParent = true } }