Compare commits
10 Commits
5c4dbce95e
...
76f850e256
Author | SHA1 | Date |
---|---|---|
|
76f850e256 | |
|
00d6d89fcb | |
|
e4818a2c2f | |
|
b628154257 | |
|
c3bb63ed62 | |
|
e103168802 | |
|
db49cf17cd | |
|
292c9770ef | |
|
16d65536d0 | |
|
75af0a4ebf |
|
@ -199,7 +199,7 @@
|
|||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 3D34444B2D889F7D00AA3172 /* Build configuration list for PBXProject "Gas Man" */;
|
||||
buildConfigurationList = 3D34444B2D889F7D00AA3172 /* Build configuration list for PBXProject "Fuel Man" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
|
@ -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;
|
||||
|
@ -312,7 +312,7 @@
|
|||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = "1.1-beta";
|
||||
MARKETING_VERSION = 1.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "pro.thelinux.Gas-Man";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
|
@ -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;
|
||||
|
@ -355,7 +355,7 @@
|
|||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = "1.1-beta";
|
||||
MARKETING_VERSION = 1.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "pro.thelinux.Gas-Man";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
|
@ -575,7 +575,7 @@
|
|||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
3D34444B2D889F7D00AA3172 /* Build configuration list for PBXProject "Gas Man" */ = {
|
||||
3D34444B2D889F7D00AA3172 /* Build configuration list for PBXProject "Fuel Man" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
3D34447D2D889F8000AA3172 /* Debug */,
|
|
@ -18,7 +18,7 @@
|
|||
BlueprintIdentifier = "3D34444F2D889F7D00AA3172"
|
||||
BuildableName = "Gas Man.app"
|
||||
BlueprintName = "Gas Man"
|
||||
ReferencedContainer = "container:Gas Man.xcodeproj">
|
||||
ReferencedContainer = "container:Fuel Man.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
|
@ -38,7 +38,7 @@
|
|||
BlueprintIdentifier = "3D3444662D889F7F00AA3172"
|
||||
BuildableName = "Gas ManTests.xctest"
|
||||
BlueprintName = "Gas ManTests"
|
||||
ReferencedContainer = "container:Gas Man.xcodeproj">
|
||||
ReferencedContainer = "container:Fuel Man.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
|
@ -49,7 +49,7 @@
|
|||
BlueprintIdentifier = "3D3444702D889F8000AA3172"
|
||||
BuildableName = "Gas ManUITests.xctest"
|
||||
BlueprintName = "Gas ManUITests"
|
||||
ReferencedContainer = "container:Gas Man.xcodeproj">
|
||||
ReferencedContainer = "container:Fuel Man.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
|
@ -71,7 +71,7 @@
|
|||
BlueprintIdentifier = "3D34444F2D889F7D00AA3172"
|
||||
BuildableName = "Gas Man.app"
|
||||
BlueprintName = "Gas Man"
|
||||
ReferencedContainer = "container:Gas Man.xcodeproj">
|
||||
ReferencedContainer = "container:Fuel Man.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
|
@ -88,7 +88,7 @@
|
|||
BlueprintIdentifier = "3D34444F2D889F7D00AA3172"
|
||||
BuildableName = "Gas Man.app"
|
||||
BlueprintName = "Gas Man"
|
||||
ReferencedContainer = "container:Gas Man.xcodeproj">
|
||||
ReferencedContainer = "container:Fuel Man.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
|
@ -23,9 +23,9 @@ struct AddFuelLogView: View {
|
|||
@State private var locationName = ""
|
||||
@State private var selectedOctane: Int = 87 // Default or from previous record
|
||||
@State private var pricePerGalon = ""
|
||||
// New Full Tank toggle; default is on (true)
|
||||
@State private var fullTank: Bool = true
|
||||
|
||||
@State private var missedPrevious: Bool = false
|
||||
|
||||
// Allowed octane options
|
||||
let octaneOptions = [87, 89, 91, 92, 93, 95]
|
||||
|
||||
|
@ -115,6 +115,9 @@ struct AddFuelLogView: View {
|
|||
Toggle("Full Tank", isOn: $fullTank)
|
||||
.toggleStyle(SwitchToggleStyle(tint: .blue))
|
||||
|
||||
Toggle("Missed Previous Fill-up", isOn: $missedPrevious)
|
||||
.toggleStyle(SwitchToggleStyle(tint: .blue))
|
||||
|
||||
Button("Get Current Location") {
|
||||
locationManager.requestLocation()
|
||||
}
|
||||
|
@ -255,8 +258,8 @@ struct AddFuelLogView: View {
|
|||
newLog.locationName = locationName
|
||||
newLog.octane = Int16(selectedOctane)
|
||||
newLog.pricePerGalon = Double(pricePerGalon) ?? 0
|
||||
// Set the fullTank property from the toggle:
|
||||
newLog.fullTank = fullTank
|
||||
newLog.missedPrevious = missedPrevious
|
||||
|
||||
if let vehicleID = selectedVehicleID,
|
||||
let selectedVehicle = vehicles.first(where: { $0.id == vehicleID }) {
|
||||
|
|
|
@ -24,8 +24,8 @@ struct EditFuelLogView: View {
|
|||
@State private var locationName = ""
|
||||
@State private var selectedOctane: Int = 87
|
||||
@State private var pricePerGalon = ""
|
||||
// Full Tank toggle
|
||||
@State private var fullTank: Bool = true
|
||||
@State private var missedPrevious: Bool = false
|
||||
|
||||
// Allowed octane options
|
||||
let octaneOptions = [87, 89, 91, 92, 93, 95]
|
||||
|
@ -89,6 +89,9 @@ struct EditFuelLogView: View {
|
|||
Toggle("Full Tank", isOn: $fullTank)
|
||||
.toggleStyle(SwitchToggleStyle(tint: .blue))
|
||||
|
||||
Toggle("Missed Previous Fill-up", isOn: $missedPrevious)
|
||||
.toggleStyle(SwitchToggleStyle(tint: .blue))
|
||||
|
||||
Button("Get Current Location") {
|
||||
// Optionally trigger location update
|
||||
}
|
||||
|
@ -130,6 +133,7 @@ struct EditFuelLogView: View {
|
|||
cost = String(format: "%.2f", fuelLog.cost)
|
||||
pricePerGalon = String(format: "%.3f", fuelLog.pricePerGalon)
|
||||
fullTank = fuelLog.fullTank
|
||||
missedPrevious = fuelLog.missedPrevious
|
||||
locationCoordinates = fuelLog.locationCoordinates ?? ""
|
||||
locationName = fuelLog.locationName ?? ""
|
||||
selectedOctane = Int(fuelLog.octane)
|
||||
|
@ -187,6 +191,7 @@ struct EditFuelLogView: View {
|
|||
fuelLog.octane = Int16(selectedOctane)
|
||||
fuelLog.pricePerGalon = Double(pricePerGalon) ?? 0
|
||||
fuelLog.fullTank = fullTank
|
||||
fuelLog.missedPrevious = missedPrevious
|
||||
|
||||
do {
|
||||
try viewContext.save()
|
||||
|
|
|
@ -73,6 +73,21 @@ struct FuelLogDetailView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
Section {
|
||||
HStack {
|
||||
Text("Previous Fill-Up")
|
||||
Spacer()
|
||||
if fuelLog.missedPrevious {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.red)
|
||||
Text("Missed")
|
||||
} else {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("Exists")
|
||||
}
|
||||
}
|
||||
}
|
||||
Section(header: Text("Location")) {
|
||||
HStack {
|
||||
Text("Coordinates:")
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -20,10 +20,11 @@ struct FuelLogListView: View {
|
|||
@State private var selectedVehicleID: UUID? = nil
|
||||
@State private var showAddVehicleSheet = false
|
||||
|
||||
// For tracking the previous odometer reading
|
||||
// For tracking the previous odometer reading (used for validation)
|
||||
@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 {
|
||||
|
@ -44,9 +45,32 @@ struct FuelLogListView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// Computed property to calculate average MPG for full-tank logs.
|
||||
// MPG for an individual log is computed as:
|
||||
// (current.odometer - next.odometer) / current.fuelVolume
|
||||
private var averageMPG: Double? {
|
||||
let logs = filteredFuelLogs
|
||||
guard logs.count > 1 else { return nil }
|
||||
var mpgValues: [Double] = []
|
||||
// Iterate over logs except the oldest.
|
||||
for i in 0..<logs.count - 1 {
|
||||
let current = logs[i]
|
||||
let next = logs[i + 1]
|
||||
// Only include if fullTank is true, fuelVolume is positive, and odometer difference is positive.
|
||||
if current.fullTank, !current.missedPrevious, current.fuelVolume > 0, current.odometer > next.odometer {
|
||||
let mpg = (current.odometer - next.odometer) / current.fuelVolume
|
||||
mpgValues.append(mpg)
|
||||
}
|
||||
}
|
||||
guard !mpgValues.isEmpty else { return nil }
|
||||
let total = mpgValues.reduce(0, +)
|
||||
return total / Double(mpgValues.count)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
if vehicles.isEmpty {
|
||||
// Empty state: no vehicles exist.
|
||||
VStack(spacing: 20) {
|
||||
Text("No vehicles found.")
|
||||
.font(.headline)
|
||||
|
@ -67,8 +91,9 @@ struct FuelLogListView: View {
|
|||
}
|
||||
} else {
|
||||
List {
|
||||
// Vehicle Picker Section
|
||||
Section {
|
||||
Picker("Vehicle", selection: $selectedVehicleID) {
|
||||
Picker("Vehicle:", selection: $selectedVehicleID) {
|
||||
ForEach(vehicles, id: \.id) { vehicle in
|
||||
Text("\(vehicle.year ?? "") \(vehicle.make ?? "") \(vehicle.model ?? "")")
|
||||
.tag(vehicle.id)
|
||||
|
@ -77,6 +102,45 @@ struct FuelLogListView: View {
|
|||
.pickerStyle(MenuPickerStyle())
|
||||
}
|
||||
|
||||
// Vehicle Photo Section
|
||||
Section {
|
||||
if let selectedVehicle = vehicles.first(where: { $0.id == selectedVehicleID }) {
|
||||
if let photoData = selectedVehicle.photo, let uiImage = UIImage(data: photoData) {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(maxWidth: .infinity, maxHeight: 200)
|
||||
.clipShape(Circle())
|
||||
// .cornerRadius(8)
|
||||
} else {
|
||||
// Fallback image if no photo is available
|
||||
Image(systemName: "car.fill")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(maxWidth: .infinity, maxHeight: 200)
|
||||
.foregroundColor(.gray)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.black)
|
||||
|
||||
// Average MPG Section
|
||||
Section {
|
||||
HStack {
|
||||
Text("Average MPG:")
|
||||
Spacer()
|
||||
if let avg = averageMPG {
|
||||
Text("\(avg, specifier: "%.1f")")
|
||||
.bold()
|
||||
} else {
|
||||
Text("N/A")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fuel Logs Section (filtered by selected vehicle)
|
||||
ForEach(filteredFuelLogs.indices, id: \.self) { index in
|
||||
let log = filteredFuelLogs[index]
|
||||
let distance: Double? = {
|
||||
|
@ -88,6 +152,7 @@ struct FuelLogListView: View {
|
|||
let mpg: Double? = {
|
||||
// Only calculate MPG if fullTank is true.
|
||||
guard log.fullTank else { return nil }
|
||||
guard !log.missedPrevious else { return nil }
|
||||
guard index < filteredFuelLogs.count - 1 else { return nil }
|
||||
let previousLog = filteredFuelLogs[index + 1]
|
||||
let milesDriven = log.odometer - previousLog.odometer
|
||||
|
@ -114,10 +179,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()
|
||||
|
@ -131,6 +214,7 @@ struct FuelLogListView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// Set default vehicle selection on appear.
|
||||
private func setDefaultVehicle() {
|
||||
if selectedVehicleID == nil {
|
||||
if let lastFuelLog = fuelLogs.first, let vehicle = lastFuelLog.vehicle {
|
||||
|
|
|
@ -21,7 +21,13 @@ struct FuelLogSummaryView: View {
|
|||
var distanceSincePrevious: Double?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Rectangle()
|
||||
.fill(Color.gray)
|
||||
.frame(height: 2)
|
||||
|
||||
// Row 1: Date and MPG (if available)
|
||||
HStack {
|
||||
Text(log.date ?? Date(), formatter: dateFormatter)
|
||||
|
@ -31,13 +37,18 @@ struct FuelLogSummaryView: View {
|
|||
if let mpg = mpg {
|
||||
Text("MPG: \(mpg, specifier: "%.3f")")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(.primary)
|
||||
.bold()
|
||||
}
|
||||
}
|
||||
.padding(2)
|
||||
// .background(Color.accentColor)
|
||||
.cornerRadius(4)
|
||||
|
||||
Divider()
|
||||
// Row 2: Distance (instead of odometer) and Fuel Volume
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
HStack() {
|
||||
Text("Distance")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
@ -50,17 +61,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 +79,7 @@ struct FuelLogSummaryView: View {
|
|||
.bold()
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .leading) {
|
||||
HStack() {
|
||||
Text("Price/Gal")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
@ -76,6 +87,9 @@ struct FuelLogSummaryView: View {
|
|||
.bold()
|
||||
}
|
||||
}
|
||||
Rectangle()
|
||||
.fill(Color.gray)
|
||||
.frame(height: 2)
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
|
|
|
@ -11,12 +11,21 @@
|
|||
<key>com.apple.developer.icloud-services</key>
|
||||
<array>
|
||||
<string>CloudKit</string>
|
||||
<string>CloudDocuments</string>
|
||||
</array>
|
||||
<key>com.apple.developer.ubiquity-container-identifiers</key>
|
||||
<array/>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.assets.pictures.read-only</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.location</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.photos-library</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||
<attribute name="locationCoordinates" optional="YES" attributeType="String"/>
|
||||
<attribute name="locationName" optional="YES" attributeType="String"/>
|
||||
<attribute name="missedPrevious" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="octane" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="odometer" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||
<attribute name="pricePerGalon" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||
|
@ -34,6 +35,7 @@
|
|||
<attribute name="notes" optional="YES" attributeType="String"/>
|
||||
<attribute name="odometerAtPurchase" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||
<attribute name="odometerAtSale" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||
<attribute name="photo" optional="YES" attributeType="Binary"/>
|
||||
<attribute name="purchaseDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="purchasePrice" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||
<attribute name="soldDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
//
|
||||
// ImagePicker.swift
|
||||
// Gas Man
|
||||
//
|
||||
// Created by Kameron Kenny on 3/19/25.
|
||||
//
|
||||
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct ImagePicker: UIViewControllerRepresentable {
|
||||
class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
|
||||
let parent: ImagePicker
|
||||
init(_ parent: ImagePicker) {
|
||||
self.parent = parent
|
||||
}
|
||||
func imagePickerController(_ picker: UIImagePickerController,
|
||||
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
|
||||
if let uiImage = info[.originalImage] as? UIImage {
|
||||
parent.image = uiImage
|
||||
}
|
||||
parent.presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
var sourceType: UIImagePickerController.SourceType = .photoLibrary
|
||||
@Binding var image: UIImage?
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
func makeUIViewController(context: Context) -> UIImagePickerController {
|
||||
let picker = UIImagePickerController()
|
||||
picker.sourceType = sourceType
|
||||
picker.delegate = context.coordinator
|
||||
return picker
|
||||
}
|
||||
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) { }
|
||||
}
|
|
@ -22,6 +22,10 @@ struct MainTabView: View {
|
|||
.tabItem {
|
||||
Label("Maintenance", systemImage: "wrench.fill")
|
||||
}
|
||||
StatsView()
|
||||
.tabItem {
|
||||
Label("Stats", systemImage: "chart.bar.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
//
|
||||
// DistanceBetweenFillupsTrendChartView.swift
|
||||
// Gas Man
|
||||
//
|
||||
// Created by Kameron Kenny on 3/19/25.
|
||||
//
|
||||
|
||||
|
||||
import SwiftUI
|
||||
import Charts
|
||||
import CoreData
|
||||
|
||||
struct DistanceBetweenFillupsTrendChartView: View {
|
||||
let fuelLogs: [FuelLog]
|
||||
|
||||
// Compute trend data by sorting logs by date ascending,
|
||||
// then mapping each log's date and pricePerGalon.
|
||||
var trendData: [(date: Date, distance: Double)] {
|
||||
let sortedLogs = fuelLogs.sorted { ($0.date ?? Date()) < ($1.date ?? Date()) }
|
||||
var data: [(Date, Double)] = []
|
||||
// Start from index 1 because we need a previous log to compute the difference.
|
||||
for i in 1..<sortedLogs.count {
|
||||
let previous = sortedLogs[i - 1]
|
||||
let current = sortedLogs[i]
|
||||
if let date = current.date,
|
||||
current.odometer > previous.odometer,
|
||||
current.fuelVolume > 0 {
|
||||
let distance = (current.odometer - previous.odometer)
|
||||
data.append((date, distance))
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if trendData.isEmpty {
|
||||
Text("No trend data available.")
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Chart {
|
||||
ForEach(trendData, id: \.date) { point in
|
||||
LineMark(
|
||||
x: .value("Date", point.date),
|
||||
y: .value("Distance", point.distance)
|
||||
)
|
||||
PointMark(
|
||||
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 {
|
||||
AxisMarks(values: .automatic(desiredCount: 4))
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(values: .automatic(desiredCount: 5))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Distance Between Fuel-ups Trend")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
//
|
||||
// GalPerFuelUpTrendChartView.swift
|
||||
// Gas Man
|
||||
//
|
||||
// Created by Kameron Kenny on 3/19/25.
|
||||
//
|
||||
|
||||
|
||||
import SwiftUI
|
||||
import Charts
|
||||
import CoreData
|
||||
|
||||
struct GalPerFuelUpTrendChartView: View {
|
||||
let fuelLogs: [FuelLog]
|
||||
|
||||
// Compute trend data by sorting logs by date ascending,
|
||||
// then mapping each log's date and pricePerGalon.
|
||||
var trendData: [(date: Date, gal: Double)] {
|
||||
let sortedLogs = fuelLogs.sorted { ($0.date ?? Date()) < ($1.date ?? Date()) }
|
||||
return sortedLogs.compactMap { log in
|
||||
if let date = log.date {
|
||||
return (date, log.fuelVolume)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if trendData.isEmpty {
|
||||
Text("No trend data available.")
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Chart {
|
||||
ForEach(trendData, id: \.date) { point in
|
||||
LineMark(
|
||||
x: .value("Date", point.date),
|
||||
y: .value("Price", point.gal)
|
||||
)
|
||||
PointMark(
|
||||
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 {
|
||||
AxisMarks(values: .automatic(desiredCount: 4))
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(values: .automatic(desiredCount: 5))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Fuel Volume Trend")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
import SwiftUI
|
||||
import Charts
|
||||
import CoreData
|
||||
|
||||
struct MPGTrendChartView: View {
|
||||
let fuelLogs: [FuelLog]
|
||||
|
||||
// Compute trend data by sorting logs by date ascending
|
||||
// and calculating MPG for each interval where fullTank is true.
|
||||
var trendData: [(date: Date, mpg: Double)] {
|
||||
let sortedLogs = fuelLogs.sorted { ($0.date ?? Date()) < ($1.date ?? Date()) }
|
||||
var data: [(Date, Double)] = []
|
||||
// Start from index 1 because we need a previous log to compute the difference.
|
||||
for i in 1..<sortedLogs.count {
|
||||
let previous = sortedLogs[i - 1]
|
||||
let current = sortedLogs[i]
|
||||
if current.fullTank, !current.missedPrevious, let date = current.date,
|
||||
current.odometer > previous.odometer,
|
||||
current.fuelVolume > 0 {
|
||||
let mpg = (current.odometer - previous.odometer) / current.fuelVolume
|
||||
data.append((date, mpg))
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if trendData.isEmpty {
|
||||
Text("No MPG trend data available.")
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Chart {
|
||||
// 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)
|
||||
)
|
||||
PointMark(
|
||||
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: 6))
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(values: .automatic(desiredCount: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("MPG Trend")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
//
|
||||
// PriceTrendChartView.swift
|
||||
// Gas Man
|
||||
//
|
||||
// Created by Kameron Kenny on 3/19/25.
|
||||
//
|
||||
|
||||
|
||||
import SwiftUI
|
||||
import Charts
|
||||
import CoreData
|
||||
|
||||
struct PricePerGallonTrendChartView: View {
|
||||
let fuelLogs: [FuelLog]
|
||||
|
||||
// Compute trend data by sorting logs by date ascending,
|
||||
// then mapping each log's date and pricePerGalon.
|
||||
var trendData: [(date: Date, price: Double)] {
|
||||
let sortedLogs = fuelLogs.sorted { ($0.date ?? Date()) < ($1.date ?? Date()) }
|
||||
return sortedLogs.compactMap { log in
|
||||
if let date = log.date {
|
||||
return (date, log.pricePerGalon)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if trendData.isEmpty {
|
||||
Text("No price trend data available.")
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Chart {
|
||||
ForEach(trendData, id: \.date) { point in
|
||||
LineMark(
|
||||
x: .value("Date", point.date),
|
||||
y: .value("Price", point.price)
|
||||
)
|
||||
PointMark(
|
||||
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 {
|
||||
AxisMarks(values: .automatic(desiredCount: 4))
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(values: .automatic(desiredCount: 5))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Price/Gallon Trend")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,274 @@
|
|||
//
|
||||
// StatsView.swift
|
||||
// Gas Man
|
||||
//
|
||||
// Created by Kameron Kenny on 3/19/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
struct StatsView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
|
||||
// Fetch all fuel logs sorted by date (descending)
|
||||
@FetchRequest(
|
||||
sortDescriptors: [NSSortDescriptor(keyPath: \FuelLog.date, ascending: false)],
|
||||
animation: .default)
|
||||
private var fuelLogs: FetchedResults<FuelLog>
|
||||
|
||||
// Fetch all vehicles sorted by make
|
||||
@FetchRequest(
|
||||
sortDescriptors: [NSSortDescriptor(keyPath: \Vehicle.make, ascending: true)],
|
||||
animation: .default)
|
||||
private var vehicles: FetchedResults<Vehicle>
|
||||
|
||||
@State private var selectedVehicleID: UUID? = nil
|
||||
|
||||
// Filter fuel logs by the selected vehicle.
|
||||
private var filteredFuelLogs: [FuelLog] {
|
||||
if let vehicleID = selectedVehicleID {
|
||||
return fuelLogs.filter { log in
|
||||
log.vehicle?.id == vehicleID
|
||||
}
|
||||
} else {
|
||||
return Array(fuelLogs)
|
||||
}
|
||||
}
|
||||
|
||||
// Total number of fill-ups.
|
||||
private var fillUpCount: Int {
|
||||
filteredFuelLogs.count
|
||||
}
|
||||
|
||||
// Average MPG computed only for full-tank logs.
|
||||
private var averageMPG: Double? {
|
||||
let logs = filteredFuelLogs
|
||||
guard logs.count > 1 else { return nil }
|
||||
var mpgValues: [Double] = []
|
||||
for i in 0..<logs.count - 1 {
|
||||
let current = logs[i]
|
||||
let next = logs[i + 1]
|
||||
if current.fullTank, !current.missedPrevious, current.fuelVolume > 0, current.odometer > next.odometer {
|
||||
let mpg = (current.odometer - next.odometer) / current.fuelVolume
|
||||
mpgValues.append(mpg)
|
||||
}
|
||||
}
|
||||
guard !mpgValues.isEmpty else { return nil }
|
||||
let sum = mpgValues.reduce(0, +)
|
||||
return sum / Double(mpgValues.count)
|
||||
}
|
||||
|
||||
// Price per gallon stats.
|
||||
private var pricePerGallonStats: (min: Double?, max: Double?, avg: Double?) {
|
||||
let prices = filteredFuelLogs.map { $0.pricePerGalon }
|
||||
guard !prices.isEmpty else { return (nil, nil, nil) }
|
||||
let minPrice = prices.min()
|
||||
let maxPrice = prices.max()
|
||||
let avgPrice = prices.reduce(0, +) / Double(prices.count)
|
||||
return (minPrice, maxPrice, avgPrice)
|
||||
}
|
||||
|
||||
// Distance between fill-ups stats.
|
||||
private var distanceStats: (min: Double?, max: Double?, avg: Double?) {
|
||||
let logs = filteredFuelLogs
|
||||
guard logs.count > 1 else { return (nil, nil, nil) }
|
||||
var distances: [Double] = []
|
||||
for i in 0..<logs.count - 1 {
|
||||
let current = logs[i]
|
||||
let next = logs[i + 1]
|
||||
let dist = current.odometer - next.odometer
|
||||
if dist > 0 {
|
||||
distances.append(dist)
|
||||
}
|
||||
}
|
||||
guard !distances.isEmpty else { return (nil, nil, nil) }
|
||||
let minDistance = distances.min()!
|
||||
let maxDistance = distances.max()!
|
||||
let avgDistance = distances.reduce(0, +) / Double(distances.count)
|
||||
return (minDistance, maxDistance, avgDistance)
|
||||
}
|
||||
|
||||
// Gallons per fill-up stats.
|
||||
private var gallonsStats: (min: Double?, max: Double?, avg: Double?) {
|
||||
let gallons = filteredFuelLogs.map { $0.fuelVolume }
|
||||
guard !gallons.isEmpty else { return (nil, nil, nil) }
|
||||
let minGallons = gallons.min()
|
||||
let maxGallons = gallons.max()
|
||||
let avgGallons = gallons.reduce(0, +) / Double(gallons.count)
|
||||
return (minGallons, maxGallons, avgGallons)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
// Vehicle Selector Section
|
||||
Section(header: Text("Select Vehicle")) {
|
||||
Picker("Vehicle", selection: $selectedVehicleID) {
|
||||
ForEach(vehicles, id: \.id) { vehicle in
|
||||
Text("\(vehicle.year ?? "") \(vehicle.make ?? "") \(vehicle.model ?? "")")
|
||||
.tag(vehicle.id)
|
||||
}
|
||||
}
|
||||
.pickerStyle(MenuPickerStyle())
|
||||
}
|
||||
|
||||
// Fill-Up Count Section
|
||||
Section {
|
||||
HStack {
|
||||
Text("Fill-Up Count:")
|
||||
Spacer()
|
||||
Text("\(fillUpCount)")
|
||||
.bold()
|
||||
}
|
||||
}
|
||||
|
||||
// Average MPG Section with NavigationLink to chart.
|
||||
Section {
|
||||
NavigationLink(destination: MPGTrendChartView(fuelLogs: filteredFuelLogs)) {
|
||||
HStack {
|
||||
Text("Average MPG:")
|
||||
Spacer()
|
||||
if let avgMPG = averageMPG {
|
||||
Text("\(avgMPG, specifier: "%.1f") MPG")
|
||||
.bold()
|
||||
} else {
|
||||
Text("N/A")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Image(systemName: "chart.bar.fill")
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Price per Gallon Stats
|
||||
Section(header: Text("Price per Gallon")) {
|
||||
let stats = pricePerGallonStats
|
||||
HStack {
|
||||
Text("Lowest:")
|
||||
Spacer()
|
||||
if let minPrice = stats.min {
|
||||
Text("$\(minPrice, specifier: "%.3f")")
|
||||
} else {
|
||||
Text("N/A")
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Text("Highest:")
|
||||
Spacer()
|
||||
if let maxPrice = stats.max {
|
||||
Text("$\(maxPrice, specifier: "%.3f")")
|
||||
} else {
|
||||
Text("N/A")
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Text("Average:")
|
||||
Spacer()
|
||||
if let avgPrice = stats.avg {
|
||||
Text("$\(avgPrice, specifier: "%.3f")")
|
||||
} else {
|
||||
Text("N/A")
|
||||
}
|
||||
}
|
||||
NavigationLink(destination: PricePerGallonTrendChartView(fuelLogs: filteredFuelLogs)) {
|
||||
HStack {
|
||||
Text("Trends:")
|
||||
Spacer()
|
||||
Image(systemName: "chart.bar.fill")
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Distance Between Fill-Ups Stats
|
||||
Section(header: Text("Distance Between Fill-Ups")) {
|
||||
let dStats = distanceStats
|
||||
HStack {
|
||||
Text("Lowest:")
|
||||
Spacer()
|
||||
if let minDistance = dStats.min {
|
||||
Text("\(minDistance, specifier: "%.0f") miles")
|
||||
} else {
|
||||
Text("N/A")
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Text("Highest:")
|
||||
Spacer()
|
||||
if let maxDistance = dStats.max {
|
||||
Text("\(maxDistance, specifier: "%.0f") miles")
|
||||
} else {
|
||||
Text("N/A")
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Text("Average:")
|
||||
Spacer()
|
||||
if let avgDistance = dStats.avg {
|
||||
Text("\(avgDistance, specifier: "%.0f") miles")
|
||||
} else {
|
||||
Text("N/A")
|
||||
}
|
||||
}
|
||||
NavigationLink(destination: DistanceBetweenFillupsTrendChartView(fuelLogs: filteredFuelLogs)) {
|
||||
HStack {
|
||||
Text("Trends:")
|
||||
Spacer()
|
||||
Image(systemName: "chart.bar.fill")
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gallons per Fill-Up Stats
|
||||
Section(header: Text("Gallons per Fill-Up")) {
|
||||
let gStats = gallonsStats
|
||||
HStack {
|
||||
Text("Lowest:")
|
||||
Spacer()
|
||||
if let minGallons = gStats.min {
|
||||
Text("\(minGallons, specifier: "%.3f") gallons")
|
||||
} else {
|
||||
Text("N/A")
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Text("Highest:")
|
||||
Spacer()
|
||||
if let maxGallons = gStats.max {
|
||||
Text("\(maxGallons, specifier: "%.3f") gallons")
|
||||
} else {
|
||||
Text("N/A")
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Text("Average:")
|
||||
Spacer()
|
||||
if let avgGallons = gStats.avg {
|
||||
Text("\(avgGallons, specifier: "%.3f") gallons")
|
||||
} else {
|
||||
Text("N/A")
|
||||
}
|
||||
}
|
||||
NavigationLink(destination: GalPerFuelUpTrendChartView(fuelLogs: filteredFuelLogs)) {
|
||||
HStack {
|
||||
Text("Trends:")
|
||||
Spacer()
|
||||
Image(systemName: "chart.bar.fill")
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Stats")
|
||||
}
|
||||
.onAppear {
|
||||
if selectedVehicleID == nil {
|
||||
selectedVehicleID = vehicles.first?.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ extension Vehicle: Identifiable {
|
|||
id ?? UUID()
|
||||
}
|
||||
|
||||
@NSManaged public var photo: Data?
|
||||
@NSManaged public var year: String?
|
||||
@NSManaged public var make: String?
|
||||
@NSManaged public var model: String?
|
||||
|
|
|
@ -9,6 +9,14 @@ import SwiftUI
|
|||
struct VehicleDetailView: View {
|
||||
@ObservedObject var vehicle: Vehicle
|
||||
@State private var showingEditVehicle = false
|
||||
|
||||
// Image state for the vehicle photo.
|
||||
@State private var vehicleImage: Image? = nil
|
||||
@State private var inputImage: UIImage? = nil
|
||||
@State private var showImagePicker = false
|
||||
@State private var imagePickerSource: UIImagePickerController.SourceType = .photoLibrary
|
||||
@State private var showImageSourceOptions = false
|
||||
|
||||
private let dateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .short
|
||||
|
@ -17,6 +25,45 @@ struct VehicleDetailView: View {
|
|||
|
||||
var body: some View {
|
||||
Form {
|
||||
// Photo Section at the very top
|
||||
Section {
|
||||
ZStack {
|
||||
if let vehicleImage = vehicleImage {
|
||||
vehicleImage
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.2))
|
||||
Image(systemName: "camera.fill")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.frame(height: 200)
|
||||
.clipped()
|
||||
.onTapGesture {
|
||||
showImageSourceOptions = true
|
||||
}
|
||||
.confirmationDialog("Select Photo Source", isPresented: $showImageSourceOptions) {
|
||||
if UIImagePickerController.isSourceTypeAvailable(.camera) {
|
||||
Button("Camera") {
|
||||
imagePickerSource = .camera
|
||||
showImagePicker = true
|
||||
}
|
||||
}
|
||||
Button("Photo Library") {
|
||||
imagePickerSource = .photoLibrary
|
||||
showImagePicker = true
|
||||
}
|
||||
Button("Cancel", role: .cancel) { }
|
||||
}
|
||||
.sheet(isPresented: $showImagePicker, onDismiss: loadImage) {
|
||||
ImagePicker(sourceType: imagePickerSource, image: $inputImage)
|
||||
}
|
||||
}
|
||||
|
||||
// Rest of the details...
|
||||
Section(header: Text("Basic Information")) {
|
||||
HStack {
|
||||
Text("Vehicle Type:")
|
||||
|
@ -31,17 +78,17 @@ struct VehicleDetailView: View {
|
|||
HStack {
|
||||
Text("Make:")
|
||||
Spacer()
|
||||
Text(vehicle.make ?? "")
|
||||
Text(vehicle.make ?? "N/A")
|
||||
}
|
||||
HStack {
|
||||
Text("Model:")
|
||||
Spacer()
|
||||
Text(vehicle.model ?? "")
|
||||
Text(vehicle.model ?? "N/A")
|
||||
}
|
||||
HStack {
|
||||
Text("Color:")
|
||||
Spacer()
|
||||
Text(vehicle.color ?? "")
|
||||
Text(vehicle.color ?? "N/A")
|
||||
}
|
||||
}
|
||||
Section(header: Text("Purchase Information")) {
|
||||
|
@ -69,7 +116,6 @@ struct VehicleDetailView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Engine & Transmission")) {
|
||||
HStack {
|
||||
Text("Engine Name:")
|
||||
|
@ -91,7 +137,6 @@ struct VehicleDetailView: View {
|
|||
Text(vehicle.transmission ?? "Automatic")
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Tires")) {
|
||||
HStack {
|
||||
Text("Brand:")
|
||||
|
@ -123,10 +168,8 @@ struct VehicleDetailView: View {
|
|||
} else {
|
||||
Text("-")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Wheels")) {
|
||||
HStack {
|
||||
Text("Brand:")
|
||||
|
@ -154,7 +197,6 @@ struct VehicleDetailView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if vehicle.soldDate != nil {
|
||||
Section(header: Text("Sale Information")) {
|
||||
HStack {
|
||||
|
@ -173,7 +215,6 @@ struct VehicleDetailView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Notes")) {
|
||||
Text(vehicle.notes ?? "")
|
||||
}
|
||||
|
@ -190,6 +231,21 @@ struct VehicleDetailView: View {
|
|||
EditVehicleView(vehicle: vehicle)
|
||||
.environment(\.managedObjectContext, vehicle.managedObjectContext!)
|
||||
}
|
||||
.onAppear {
|
||||
if let data = vehicle.photo, let uiImage = UIImage(data: data) {
|
||||
vehicleImage = Image(uiImage: uiImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This function is called when the image picker is dismissed.
|
||||
func loadImage() {
|
||||
guard let inputImage = inputImage else { return }
|
||||
vehicleImage = Image(uiImage: inputImage)
|
||||
// Save the image to the vehicle.
|
||||
if let imageData = inputImage.jpegData(compressionQuality: 0.8) {
|
||||
vehicle.photo = imageData
|
||||
try? vehicle.managedObjectContext?.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
//
|
||||
// VehicleListView.swift
|
||||
// Gas Man
|
||||
//
|
||||
// Created by Kameron Kenny on 3/18/25.
|
||||
//
|
||||
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
struct VehicleListView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
@FetchRequest(
|
||||
sortDescriptors: [NSSortDescriptor(keyPath: \Vehicle.make, ascending: true)],
|
||||
animation: .default)
|
||||
private var vehicles: FetchedResults<Vehicle>
|
||||
|
||||
@State private var showingAddVehicle = false
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
ForEach(vehicles) { vehicle in
|
||||
NavigationLink(destination: VehicleDetailView(vehicle: vehicle)) {
|
||||
VehicleRowView(vehicle: vehicle)
|
||||
}
|
||||
}
|
||||
.onDelete(perform: deleteVehicles)
|
||||
}
|
||||
.navigationTitle("Vehicles")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: { showingAddVehicle = true }) {
|
||||
Label("Add Vehicle", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
EditButton()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingAddVehicle) {
|
||||
AddVehicleView().environment(\.managedObjectContext, viewContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteVehicles(offsets: IndexSet) {
|
||||
withAnimation {
|
||||
offsets.map { vehicles[$0] }.forEach(viewContext.delete)
|
||||
do {
|
||||
try viewContext.save()
|
||||
} catch {
|
||||
let nsError = error as NSError
|
||||
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -9,15 +9,31 @@
|
|||
import SwiftUI
|
||||
|
||||
struct VehicleRowView: View {
|
||||
var vehicle: Vehicle
|
||||
|
||||
let vehicle: Vehicle
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text("\(vehicle.year ?? "") \(vehicle.make ?? "") \(vehicle.model ?? "")")
|
||||
.font(.headline)
|
||||
Text(vehicle.color ?? "")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
HStack {
|
||||
if let imageData = vehicle.photo, let uiImage = UIImage(data: imageData) {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 50, height: 50)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
Circle()
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.frame(width: 50, height: 50)
|
||||
.overlay(
|
||||
Image(systemName: "car.fill")
|
||||
.foregroundColor(.gray)
|
||||
)
|
||||
}
|
||||
VStack(alignment: .leading) {
|
||||
Text("\(vehicle.year ?? "") \(vehicle.make ?? "") \(vehicle.model ?? "")")
|
||||
.font(.headline)
|
||||
// Add other details if needed.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue