Compare commits
No commits in common. "main" and "1.1-beta" have entirely different histories.
|
@ -1,149 +0,0 @@
|
||||||
# ---> Swift
|
|
||||||
# Xcode
|
|
||||||
#
|
|
||||||
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
|
|
||||||
|
|
||||||
## User settings
|
|
||||||
xcuserdata/
|
|
||||||
|
|
||||||
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
|
||||||
*.xcscmblueprint
|
|
||||||
*.xccheckout
|
|
||||||
|
|
||||||
## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
|
|
||||||
build/
|
|
||||||
DerivedData/
|
|
||||||
*.moved-aside
|
|
||||||
*.pbxuser
|
|
||||||
!default.pbxuser
|
|
||||||
*.mode1v3
|
|
||||||
!default.mode1v3
|
|
||||||
*.mode2v3
|
|
||||||
!default.mode2v3
|
|
||||||
*.perspectivev3
|
|
||||||
!default.perspectivev3
|
|
||||||
|
|
||||||
## Obj-C/Swift specific
|
|
||||||
*.hmap
|
|
||||||
|
|
||||||
## App packaging
|
|
||||||
*.ipa
|
|
||||||
*.dSYM.zip
|
|
||||||
*.dSYM
|
|
||||||
|
|
||||||
## Playgrounds
|
|
||||||
timeline.xctimeline
|
|
||||||
playground.xcworkspace
|
|
||||||
|
|
||||||
# Swift Package Manager
|
|
||||||
#
|
|
||||||
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
|
|
||||||
# Packages/
|
|
||||||
# Package.pins
|
|
||||||
# Package.resolved
|
|
||||||
# *.xcodeproj
|
|
||||||
#
|
|
||||||
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
|
|
||||||
# hence it is not needed unless you have added a package configuration file to your project
|
|
||||||
# .swiftpm
|
|
||||||
|
|
||||||
.build/
|
|
||||||
|
|
||||||
# CocoaPods
|
|
||||||
#
|
|
||||||
# We recommend against adding the Pods directory to your .gitignore. However
|
|
||||||
# you should judge for yourself, the pros and cons are mentioned at:
|
|
||||||
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
|
|
||||||
#
|
|
||||||
# Pods/
|
|
||||||
#
|
|
||||||
# Add this line if you want to avoid checking in source code from the Xcode workspace
|
|
||||||
# *.xcworkspace
|
|
||||||
|
|
||||||
# Carthage
|
|
||||||
#
|
|
||||||
# Add this line if you want to avoid checking in source code from Carthage dependencies.
|
|
||||||
# Carthage/Checkouts
|
|
||||||
|
|
||||||
Carthage/Build/
|
|
||||||
|
|
||||||
# Accio dependency management
|
|
||||||
Dependencies/
|
|
||||||
.accio/
|
|
||||||
|
|
||||||
# fastlane
|
|
||||||
#
|
|
||||||
# It is recommended to not store the screenshots in the git repo.
|
|
||||||
# Instead, use fastlane to re-generate the screenshots whenever they are needed.
|
|
||||||
# For more information about the recommended setup visit:
|
|
||||||
# https://docs.fastlane.tools/best-practices/source-control/#source-control
|
|
||||||
|
|
||||||
fastlane/report.xml
|
|
||||||
fastlane/Preview.html
|
|
||||||
fastlane/screenshots/**/*.png
|
|
||||||
fastlane/test_output
|
|
||||||
|
|
||||||
# Code Injection
|
|
||||||
#
|
|
||||||
# After new code Injection tools there's a generated folder /iOSInjectionProject
|
|
||||||
# https://github.com/johnno1962/injectionforxcode
|
|
||||||
|
|
||||||
iOSInjectionProject/
|
|
||||||
|
|
||||||
# ---> Xcode
|
|
||||||
## User settings
|
|
||||||
xcuserdata/
|
|
||||||
|
|
||||||
## Xcode 8 and earlier
|
|
||||||
*.xcscmblueprint
|
|
||||||
*.xccheckout
|
|
||||||
|
|
||||||
# ---> macOS
|
|
||||||
# General
|
|
||||||
.DS_Store
|
|
||||||
.AppleDouble
|
|
||||||
.LSOverride
|
|
||||||
|
|
||||||
# Icon must end with two \r
|
|
||||||
Icon
|
|
||||||
|
|
||||||
# Thumbnails
|
|
||||||
._*
|
|
||||||
|
|
||||||
# Files that might appear in the root of a volume
|
|
||||||
.DocumentRevisions-V100
|
|
||||||
.fseventsd
|
|
||||||
.Spotlight-V100
|
|
||||||
.TemporaryItems
|
|
||||||
.Trashes
|
|
||||||
.VolumeIcon.icns
|
|
||||||
.com.apple.timemachine.donotpresent
|
|
||||||
|
|
||||||
# Directories potentially created on remote AFP share
|
|
||||||
.AppleDB
|
|
||||||
.AppleDesktop
|
|
||||||
Network Trash Folder
|
|
||||||
Temporary Items
|
|
||||||
.apdisk
|
|
||||||
|
|
||||||
# ---> Vim
|
|
||||||
# Swap
|
|
||||||
[._]*.s[a-v][a-z]
|
|
||||||
!*.svg # comment out if you don't need vector files
|
|
||||||
[._]*.sw[a-p]
|
|
||||||
[._]s[a-rt-v][a-z]
|
|
||||||
[._]ss[a-gi-z]
|
|
||||||
[._]sw[a-p]
|
|
||||||
|
|
||||||
# Session
|
|
||||||
Session.vim
|
|
||||||
Sessionx.vim
|
|
||||||
|
|
||||||
# Temporary
|
|
||||||
.netrwhist
|
|
||||||
*~
|
|
||||||
# Auto-generated tag files
|
|
||||||
tags
|
|
||||||
# Persistent undo
|
|
||||||
[._]*.un~
|
|
||||||
|
|
|
@ -199,7 +199,7 @@
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = 3D34444B2D889F7D00AA3172 /* Build configuration list for PBXProject "Gas Man" */;
|
buildConfigurationList = 3D34444B2D889F7D00AA3172 /* Build configuration list for PBXProject "Fuel Man" */;
|
||||||
developmentRegion = en;
|
developmentRegion = en;
|
||||||
hasScannedForEncodings = 0;
|
hasScannedForEncodings = 0;
|
||||||
knownRegions = (
|
knownRegions = (
|
||||||
|
@ -289,7 +289,7 @@
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = "Gas Man/Gas_Man.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "Gas Man/Gas_Man.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 202503191418;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"Gas Man/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"Gas Man/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = Z734T5CD6B;
|
DEVELOPMENT_TEAM = Z734T5CD6B;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
|
@ -312,7 +312,7 @@
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "pro.thelinux.Gas-Man";
|
PRODUCT_BUNDLE_IDENTIFIER = "pro.thelinux.Gas-Man";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
|
@ -332,7 +332,7 @@
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = "Gas Man/Gas_Man.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "Gas Man/Gas_Man.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 202503191418;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"Gas Man/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"Gas Man/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = Z734T5CD6B;
|
DEVELOPMENT_TEAM = Z734T5CD6B;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
|
@ -355,7 +355,7 @@
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "pro.thelinux.Gas-Man";
|
PRODUCT_BUNDLE_IDENTIFIER = "pro.thelinux.Gas-Man";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
|
@ -575,7 +575,7 @@
|
||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
3D34444B2D889F7D00AA3172 /* Build configuration list for PBXProject "Gas Man" */ = {
|
3D34444B2D889F7D00AA3172 /* Build configuration list for PBXProject "Fuel Man" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
3D34447D2D889F8000AA3172 /* Debug */,
|
3D34447D2D889F8000AA3172 /* Debug */,
|
|
@ -18,7 +18,7 @@
|
||||||
BlueprintIdentifier = "3D34444F2D889F7D00AA3172"
|
BlueprintIdentifier = "3D34444F2D889F7D00AA3172"
|
||||||
BuildableName = "Gas Man.app"
|
BuildableName = "Gas Man.app"
|
||||||
BlueprintName = "Gas Man"
|
BlueprintName = "Gas Man"
|
||||||
ReferencedContainer = "container:Gas Man.xcodeproj">
|
ReferencedContainer = "container:Fuel Man.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildActionEntry>
|
</BuildActionEntry>
|
||||||
</BuildActionEntries>
|
</BuildActionEntries>
|
||||||
|
@ -38,7 +38,7 @@
|
||||||
BlueprintIdentifier = "3D3444662D889F7F00AA3172"
|
BlueprintIdentifier = "3D3444662D889F7F00AA3172"
|
||||||
BuildableName = "Gas ManTests.xctest"
|
BuildableName = "Gas ManTests.xctest"
|
||||||
BlueprintName = "Gas ManTests"
|
BlueprintName = "Gas ManTests"
|
||||||
ReferencedContainer = "container:Gas Man.xcodeproj">
|
ReferencedContainer = "container:Fuel Man.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</TestableReference>
|
</TestableReference>
|
||||||
<TestableReference
|
<TestableReference
|
||||||
|
@ -49,7 +49,7 @@
|
||||||
BlueprintIdentifier = "3D3444702D889F8000AA3172"
|
BlueprintIdentifier = "3D3444702D889F8000AA3172"
|
||||||
BuildableName = "Gas ManUITests.xctest"
|
BuildableName = "Gas ManUITests.xctest"
|
||||||
BlueprintName = "Gas ManUITests"
|
BlueprintName = "Gas ManUITests"
|
||||||
ReferencedContainer = "container:Gas Man.xcodeproj">
|
ReferencedContainer = "container:Fuel Man.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</TestableReference>
|
</TestableReference>
|
||||||
</Testables>
|
</Testables>
|
||||||
|
@ -71,7 +71,7 @@
|
||||||
BlueprintIdentifier = "3D34444F2D889F7D00AA3172"
|
BlueprintIdentifier = "3D34444F2D889F7D00AA3172"
|
||||||
BuildableName = "Gas Man.app"
|
BuildableName = "Gas Man.app"
|
||||||
BlueprintName = "Gas Man"
|
BlueprintName = "Gas Man"
|
||||||
ReferencedContainer = "container:Gas Man.xcodeproj">
|
ReferencedContainer = "container:Fuel Man.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
|
@ -88,7 +88,7 @@
|
||||||
BlueprintIdentifier = "3D34444F2D889F7D00AA3172"
|
BlueprintIdentifier = "3D34444F2D889F7D00AA3172"
|
||||||
BuildableName = "Gas Man.app"
|
BuildableName = "Gas Man.app"
|
||||||
BlueprintName = "Gas Man"
|
BlueprintName = "Gas Man"
|
||||||
ReferencedContainer = "container:Gas Man.xcodeproj">
|
ReferencedContainer = "container:Fuel Man.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
</ProfileAction>
|
</ProfileAction>
|
|
@ -1,249 +0,0 @@
|
||||||
//
|
|
||||||
// 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> = 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)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
|
"filename" : "fuel pump-1024.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
@ -12,6 +13,7 @@
|
||||||
"value" : "dark"
|
"value" : "dark"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"filename" : "fuel pump-1024 1.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
@ -23,56 +25,67 @@
|
||||||
"value" : "tinted"
|
"value" : "tinted"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"filename" : "fuel pump-1024 2.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "fuel pump-16.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "16x16"
|
"size" : "16x16"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "fuel pump-32.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "16x16"
|
"size" : "16x16"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "fuel pump-32 1.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "32x32"
|
"size" : "32x32"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "fuel pump-64.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "32x32"
|
"size" : "32x32"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "fuel pump-128.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "128x128"
|
"size" : "128x128"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "fuel pump-256 1.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "128x128"
|
"size" : "128x128"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "fuel pump-256.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "256x256"
|
"size" : "256x256"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "fuel pump-512 1.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "256x256"
|
"size" : "256x256"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "fuel pump-512.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "1x",
|
"scale" : "1x",
|
||||||
"size" : "512x512"
|
"size" : "512x512"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename" : "fuel pump-1024 3.png",
|
||||||
"idiom" : "mac",
|
"idiom" : "mac",
|
||||||
"scale" : "2x",
|
"scale" : "2x",
|
||||||
"size" : "512x512"
|
"size" : "512x512"
|
||||||
|
|
After Width: | Height: | Size: 3.1 MiB |
After Width: | Height: | Size: 3.1 MiB |
After Width: | Height: | Size: 3.1 MiB |
After Width: | Height: | Size: 3.1 MiB |
After Width: | Height: | Size: 73 KiB |
After Width: | Height: | Size: 3.8 KiB |
After Width: | Height: | Size: 245 KiB |
After Width: | Height: | Size: 245 KiB |
After Width: | Height: | Size: 8.5 KiB |
After Width: | Height: | Size: 8.5 KiB |
After Width: | Height: | Size: 911 KiB |
After Width: | Height: | Size: 911 KiB |
After Width: | Height: | Size: 23 KiB |
|
@ -1,88 +0,0 @@
|
||||||
//
|
|
||||||
// ContentView.swift
|
|
||||||
// Gas Man
|
|
||||||
//
|
|
||||||
// Created by Kameron Kenny on 3/17/25.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
//import CoreData
|
|
||||||
//
|
|
||||||
//struct ContentView: View {
|
|
||||||
// @Environment(\.managedObjectContext) private var viewContext
|
|
||||||
//
|
|
||||||
// @FetchRequest(
|
|
||||||
// sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
|
|
||||||
// animation: .default)
|
|
||||||
// private var items: FetchedResults<Item>
|
|
||||||
//
|
|
||||||
// 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)
|
|
||||||
//}
|
|
|
@ -1,85 +0,0 @@
|
||||||
//
|
|
||||||
// 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<FuelLog>
|
|
||||||
|
|
||||||
@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)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,278 @@
|
||||||
|
//
|
||||||
|
// 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 for fuel log
|
||||||
|
@State private var date = Date()
|
||||||
|
@State private var odometer = ""
|
||||||
|
@State private var fuelVolume: String = ""
|
||||||
|
@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 = ""
|
||||||
|
@State private var fullTank: Bool = true
|
||||||
|
@State private var missedPrevious: Bool = false
|
||||||
|
|
||||||
|
// 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
|
||||||
|
@State private var isUpdatingCalculation = false
|
||||||
|
|
||||||
|
// Vehicle selection:
|
||||||
|
@FetchRequest(
|
||||||
|
sortDescriptors: [NSSortDescriptor(keyPath: \Vehicle.make, ascending: true)],
|
||||||
|
animation: .default)
|
||||||
|
private var vehicles: FetchedResults<Vehicle>
|
||||||
|
|
||||||
|
// Use the new UUID id for vehicle selection
|
||||||
|
@State private var selectedVehicleID: UUID? = nil
|
||||||
|
|
||||||
|
// Computed property for formatted previous odometer value
|
||||||
|
private var previousOdometerString: String {
|
||||||
|
previousOdometer.map { String(format: "%.0f", $0) } ?? "N/A"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
Form {
|
||||||
|
Section(header: Text("Fuel Log Details")) {
|
||||||
|
DatePicker("Date", selection: $date, displayedComponents: [.date, .hourAndMinute])
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("Odometer:")
|
||||||
|
TextField("", text: $odometer)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("Gallons:")
|
||||||
|
TextField("", text: $fuelVolume)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.onChange(of: fuelVolume) { newValue in
|
||||||
|
// Only reformat if the input is not already in the proper format.
|
||||||
|
// if newValue.contains(".") { return }
|
||||||
|
let pattern = #"^\d+\.\d{3}$"#
|
||||||
|
if newValue.range(of: pattern, options: .regularExpression) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let formatted = formatInput(newValue)
|
||||||
|
if formatted != newValue {
|
||||||
|
fuelVolume = formatted
|
||||||
|
}
|
||||||
|
updateCalculatedValues()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("Price/Gal:")
|
||||||
|
TextField("", text: $pricePerGalon)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.onChange(of: pricePerGalon) { newValue in
|
||||||
|
// if newValue.contains(".") { return }
|
||||||
|
let pattern = #"^\d+\.\d{3}$"#
|
||||||
|
if newValue.range(of: pattern, options: .regularExpression) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let formatted = formatInput(newValue)
|
||||||
|
if formatted != newValue {
|
||||||
|
pricePerGalon = formatted
|
||||||
|
}
|
||||||
|
updateCalculatedValues()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("Cost:")
|
||||||
|
TextField("", text: $cost)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.onChange(of: cost) { _ in updateCalculatedValues() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// New Full Tank toggle
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
if !locationCoordinates.isEmpty {
|
||||||
|
Text("Coordinates: \(locationCoordinates)")
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("Location Name", text: $locationName)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
|
||||||
|
Picker("Octane", selection: $selectedOctane) {
|
||||||
|
ForEach(octaneOptions, id: \.self) { option in
|
||||||
|
Text("\(option)").tag(option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(MenuPickerStyle())
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(header: Text("Vehicle")) {
|
||||||
|
Picker("Select Vehicle", selection: $selectedVehicleID) {
|
||||||
|
ForEach(vehicles, id: \.id) { vehicle in
|
||||||
|
Text("\(vehicle.year ?? "") \(vehicle.make ?? "") \(vehicle.model ?? "")")
|
||||||
|
.tag(vehicle.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(MenuPickerStyle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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 {
|
||||||
|
// Fetch the last FuelLog record to set defaults:
|
||||||
|
let fetchRequest: NSFetchRequest<FuelLog> = 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)
|
||||||
|
|
||||||
|
// Set default vehicle from previous record if available:
|
||||||
|
if let lastVehicle = lastFuelLog.vehicle {
|
||||||
|
selectedVehicleID = lastVehicle.id
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectedOctane = 87
|
||||||
|
odometer = ""
|
||||||
|
// If no previous fuel log, default to the first available vehicle.
|
||||||
|
selectedVehicleID = vehicles.first?.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert(isPresented: $showOdometerAlert) {
|
||||||
|
Alert(title: Text("Odometer Reading Error"),
|
||||||
|
message: Text("Odometer reading must be greater than the previous record (\(previousOdometerString))."),
|
||||||
|
dismissButton: .default(Text("OK")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
guard !isUpdatingCalculation else { return }
|
||||||
|
isUpdatingCalculation = true
|
||||||
|
|
||||||
|
let fuel = Double(fuelVolume)
|
||||||
|
let costVal = Double(cost)
|
||||||
|
let price = Double(pricePerGalon)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
} 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
|
||||||
|
}
|
||||||
|
} 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 }
|
||||||
|
|
||||||
|
// Re-fetch the latest fuel log from Core Data
|
||||||
|
let fetchRequest: NSFetchRequest<FuelLog> = FuelLog.fetchRequest()
|
||||||
|
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]
|
||||||
|
fetchRequest.fetchLimit = 1
|
||||||
|
if let lastFuelLog = try? viewContext.fetch(fetchRequest).first {
|
||||||
|
if newOdometer <= lastFuelLog.odometer {
|
||||||
|
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
|
||||||
|
newLog.fullTank = fullTank
|
||||||
|
newLog.missedPrevious = missedPrevious
|
||||||
|
|
||||||
|
if let vehicleID = selectedVehicleID,
|
||||||
|
let selectedVehicle = vehicles.first(where: { $0.id == vehicleID }) {
|
||||||
|
newLog.vehicle = selectedVehicle
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try viewContext.save()
|
||||||
|
print("Saved fuel log with vehicle: \(newLog.vehicle?.make ?? "none")")
|
||||||
|
dismiss()
|
||||||
|
} catch {
|
||||||
|
let nsError = error as NSError
|
||||||
|
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,204 @@
|
||||||
|
//
|
||||||
|
// EditFuelLogView.swift
|
||||||
|
// Gas Man
|
||||||
|
//
|
||||||
|
// Created by Kameron Kenny on 3/19/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
struct EditFuelLogView: View {
|
||||||
|
@Environment(\.managedObjectContext) private var viewContext
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
|
@ObservedObject var fuelLog: FuelLog
|
||||||
|
|
||||||
|
// Form fields for fuel log
|
||||||
|
@State private var date = Date()
|
||||||
|
@State private var odometer = ""
|
||||||
|
@State private var fuelVolume: String = ""
|
||||||
|
@State private var cost = ""
|
||||||
|
@State private var locationCoordinates = ""
|
||||||
|
@State private var locationName = ""
|
||||||
|
@State private var selectedOctane: Int = 87
|
||||||
|
@State private var pricePerGalon = ""
|
||||||
|
@State private var fullTank: Bool = true
|
||||||
|
@State private var missedPrevious: Bool = false
|
||||||
|
|
||||||
|
// Allowed octane options
|
||||||
|
let octaneOptions = [87, 89, 91, 92, 93, 95]
|
||||||
|
|
||||||
|
// For calculation update control
|
||||||
|
@State private var isUpdatingCalculation = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
Form {
|
||||||
|
Section(header: Text("Fuel Log Details")) {
|
||||||
|
DatePicker("Date", selection: $date, displayedComponents: [.date, .hourAndMinute])
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("Odometer:")
|
||||||
|
TextField("", text: $odometer)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("Gallons:")
|
||||||
|
TextField("", text: $fuelVolume)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.onChange(of: fuelVolume) { newValue in
|
||||||
|
if !newValue.contains(".") {
|
||||||
|
let formatted = formatInput(newValue)
|
||||||
|
if formatted != newValue {
|
||||||
|
fuelVolume = formatted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateCalculatedValues()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("Price/Gal:")
|
||||||
|
TextField("", text: $pricePerGalon)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.onChange(of: pricePerGalon) { newValue in
|
||||||
|
if !newValue.contains(".") {
|
||||||
|
let formatted = formatInput(newValue)
|
||||||
|
if formatted != newValue {
|
||||||
|
pricePerGalon = formatted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateCalculatedValues()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("Cost:")
|
||||||
|
TextField("", text: $cost)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.onChange(of: cost) { _ in updateCalculatedValues() }
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
if !locationCoordinates.isEmpty {
|
||||||
|
Text("Coordinates: \(locationCoordinates)")
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("Location Name", text: $locationName)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
|
||||||
|
Picker("Octane", selection: $selectedOctane) {
|
||||||
|
ForEach(octaneOptions, id: \.self) { option in
|
||||||
|
Text("\(option)").tag(option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(MenuPickerStyle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Edit Fuel Log")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Save") { saveFuelLog() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
loadFuelLogData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadFuelLogData() {
|
||||||
|
date = fuelLog.date ?? Date()
|
||||||
|
odometer = String(format: "%.0f", fuelLog.odometer)
|
||||||
|
fuelVolume = String(format: "%.3f", fuelLog.fuelVolume)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
guard !isUpdatingCalculation else { return }
|
||||||
|
isUpdatingCalculation = true
|
||||||
|
|
||||||
|
let fuel = Double(fuelVolume)
|
||||||
|
let costVal = Double(cost)
|
||||||
|
let price = Double(pricePerGalon)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
} 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
|
||||||
|
}
|
||||||
|
} 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 }
|
||||||
|
// Optional: Validate odometer vs. previous logs if needed.
|
||||||
|
|
||||||
|
fuelLog.date = date
|
||||||
|
fuelLog.odometer = newOdometer
|
||||||
|
fuelLog.fuelVolume = Double(fuelVolume) ?? 0
|
||||||
|
fuelLog.cost = Double(cost) ?? 0
|
||||||
|
fuelLog.locationCoordinates = locationCoordinates
|
||||||
|
fuelLog.locationName = locationName
|
||||||
|
fuelLog.octane = Int16(selectedOctane)
|
||||||
|
fuelLog.pricePerGalon = Double(pricePerGalon) ?? 0
|
||||||
|
fuelLog.fullTank = fullTank
|
||||||
|
fuelLog.missedPrevious = missedPrevious
|
||||||
|
|
||||||
|
do {
|
||||||
|
try viewContext.save()
|
||||||
|
dismiss()
|
||||||
|
} catch {
|
||||||
|
let nsError = error as NSError
|
||||||
|
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,13 +14,22 @@ private let dateFormatter: DateFormatter = {
|
||||||
return formatter
|
return formatter
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
||||||
struct FuelLogDetailView: View {
|
struct FuelLogDetailView: View {
|
||||||
var fuelLog: FuelLog
|
@ObservedObject var fuelLog: FuelLog
|
||||||
|
@State private var showingEditFuelLog = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
Section(header: Text("Basic Information")) {
|
// Vehicle header section
|
||||||
|
if let vehicle = fuelLog.vehicle {
|
||||||
|
Section {
|
||||||
|
Text("\(vehicle.year ?? "") \(vehicle.model ?? "")")
|
||||||
|
.font(.headline)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(header: Text("")) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Date:")
|
Text("Date:")
|
||||||
Spacer()
|
Spacer()
|
||||||
|
@ -49,6 +58,36 @@ struct FuelLogDetailView: View {
|
||||||
Text("$\(fuelLog.pricePerGalon, specifier: "%.3f")")
|
Text("$\(fuelLog.pricePerGalon, specifier: "%.3f")")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Section(header: Text("Tank Status")) {
|
||||||
|
HStack {
|
||||||
|
Text("Tank:")
|
||||||
|
Spacer()
|
||||||
|
if fuelLog.fullTank {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(.green)
|
||||||
|
Text("Full")
|
||||||
|
} else {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
Text("Not Full")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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")) {
|
Section(header: Text("Location")) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Coordinates:")
|
Text("Coordinates:")
|
||||||
|
@ -70,5 +109,16 @@ struct FuelLogDetailView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Fuel Log Detail")
|
.navigationTitle("Fuel Log Detail")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Edit") {
|
||||||
|
showingEditFuelLog = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingEditFuelLog) {
|
||||||
|
EditFuelLogView(fuelLog: fuelLog)
|
||||||
|
.environment(\.managedObjectContext, fuelLog.managedObjectContext!)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,240 @@
|
||||||
|
import SwiftUI
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
struct FuelLogListView: View {
|
||||||
|
@Environment(\.managedObjectContext) private var viewContext
|
||||||
|
|
||||||
|
// All fuel logs (sorted by date descending)
|
||||||
|
@FetchRequest(
|
||||||
|
sortDescriptors: [NSSortDescriptor(keyPath: \FuelLog.date, ascending: false)],
|
||||||
|
animation: .default)
|
||||||
|
private var fuelLogs: FetchedResults<FuelLog>
|
||||||
|
|
||||||
|
// All vehicles (sorted by make)
|
||||||
|
@FetchRequest(
|
||||||
|
sortDescriptors: [NSSortDescriptor(keyPath: \Vehicle.make, ascending: true)],
|
||||||
|
animation: .default)
|
||||||
|
private var vehicles: FetchedResults<Vehicle>
|
||||||
|
|
||||||
|
@State private var showingAddFuelLog = false
|
||||||
|
@State private var selectedVehicleID: UUID? = nil
|
||||||
|
@State private var showAddVehicleSheet = false
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
previousOdometer.map { String(format: "%.0f", $0) } ?? "N/A"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter fuel logs by the selected vehicle.
|
||||||
|
private var filteredFuelLogs: [FuelLog] {
|
||||||
|
if let vehicleID = selectedVehicleID {
|
||||||
|
return fuelLogs.filter { log in
|
||||||
|
if let logVehicleID = log.vehicle?.id {
|
||||||
|
return logVehicleID == vehicleID
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Array(fuelLogs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
Text("Please add a vehicle before logging fuel records.")
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal)
|
||||||
|
Button("Add Vehicle") {
|
||||||
|
showAddVehicleSheet = true
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.blue)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
.navigationTitle("Fuel Logs")
|
||||||
|
.sheet(isPresented: $showAddVehicleSheet) {
|
||||||
|
AddVehicleView().environment(\.managedObjectContext, viewContext)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
// Vehicle Picker Section
|
||||||
|
Section {
|
||||||
|
Picker("Vehicle:", selection: $selectedVehicleID) {
|
||||||
|
ForEach(vehicles, id: \.id) { vehicle in
|
||||||
|
Text("\(vehicle.year ?? "") \(vehicle.make ?? "") \(vehicle.model ?? "")")
|
||||||
|
.tag(vehicle.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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? = {
|
||||||
|
guard index < filteredFuelLogs.count - 1 else { return nil }
|
||||||
|
let previousLog = filteredFuelLogs[index + 1]
|
||||||
|
let milesDriven = log.odometer - previousLog.odometer
|
||||||
|
return milesDriven > 0 ? milesDriven : nil
|
||||||
|
}()
|
||||||
|
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
|
||||||
|
guard log.fuelVolume > 0, milesDriven > 0 else { return nil }
|
||||||
|
return milesDriven / log.fuelVolume
|
||||||
|
}()
|
||||||
|
|
||||||
|
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 {
|
||||||
|
showingAddFuelLog = true
|
||||||
|
} label: {
|
||||||
|
Label("Add Fuel Log", systemImage: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert(isPresented: $showOdometerAlert) {
|
||||||
|
Alert(title: Text("Odometer Reading Error"),
|
||||||
|
message: Text("Odometer reading must be greater than the previous record (\(previousOdometerString))."),
|
||||||
|
dismissButton: .default(Text("OK")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default vehicle selection on appear.
|
||||||
|
private func setDefaultVehicle() {
|
||||||
|
if selectedVehicleID == nil {
|
||||||
|
if let lastFuelLog = fuelLogs.first, let vehicle = lastFuelLog.vehicle {
|
||||||
|
selectedVehicleID = vehicle.id
|
||||||
|
} else {
|
||||||
|
selectedVehicleID = vehicles.first?.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteFuelLogs(offsets: IndexSet) {
|
||||||
|
withAnimation {
|
||||||
|
let logsToDelete = offsets.map { filteredFuelLogs[$0] }
|
||||||
|
logsToDelete.forEach { viewContext.delete($0) }
|
||||||
|
do {
|
||||||
|
try viewContext.save()
|
||||||
|
} catch {
|
||||||
|
let nsError = error as NSError
|
||||||
|
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -21,7 +21,13 @@ struct FuelLogSummaryView: View {
|
||||||
var distanceSincePrevious: Double?
|
var distanceSincePrevious: Double?
|
||||||
|
|
||||||
var body: some View {
|
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)
|
// Row 1: Date and MPG (if available)
|
||||||
HStack {
|
HStack {
|
||||||
Text(log.date ?? Date(), formatter: dateFormatter)
|
Text(log.date ?? Date(), formatter: dateFormatter)
|
||||||
|
@ -31,13 +37,18 @@ struct FuelLogSummaryView: View {
|
||||||
if let mpg = mpg {
|
if let mpg = mpg {
|
||||||
Text("MPG: \(mpg, specifier: "%.3f")")
|
Text("MPG: \(mpg, specifier: "%.3f")")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.primary)
|
||||||
|
.bold()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(2)
|
||||||
|
// .background(Color.accentColor)
|
||||||
|
.cornerRadius(4)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
// Row 2: Distance (instead of odometer) and Fuel Volume
|
// Row 2: Distance (instead of odometer) and Fuel Volume
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading) {
|
HStack() {
|
||||||
Text("Distance")
|
Text("Distance")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
@ -50,17 +61,17 @@ struct FuelLogSummaryView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
VStack(alignment: .leading) {
|
HStack() {
|
||||||
Text("Fuel")
|
Text("Fuel (Gal)")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
Text("\(log.fuelVolume, specifier: "%.3f") gal")
|
Text("\(log.fuelVolume, specifier: "%.3f")")
|
||||||
.bold()
|
.bold()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Row 3: Cost and Price per Gallon
|
// Row 3: Cost and Price per Gallon
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading) {
|
HStack() {
|
||||||
Text("Cost")
|
Text("Cost")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
@ -68,7 +79,7 @@ struct FuelLogSummaryView: View {
|
||||||
.bold()
|
.bold()
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
VStack(alignment: .leading) {
|
HStack() {
|
||||||
Text("Price/Gal")
|
Text("Price/Gal")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
@ -76,6 +87,9 @@ struct FuelLogSummaryView: View {
|
||||||
.bold()
|
.bold()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.gray)
|
||||||
|
.frame(height: 2)
|
||||||
}
|
}
|
||||||
.padding(8)
|
.padding(8)
|
||||||
.background(Color(.secondarySystemBackground))
|
.background(Color(.secondarySystemBackground))
|
|
@ -11,12 +11,21 @@
|
||||||
<key>com.apple.developer.icloud-services</key>
|
<key>com.apple.developer.icloud-services</key>
|
||||||
<array>
|
<array>
|
||||||
<string>CloudKit</string>
|
<string>CloudKit</string>
|
||||||
|
<string>CloudDocuments</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>com.apple.developer.ubiquity-container-identifiers</key>
|
||||||
|
<array/>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<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>
|
<key>com.apple.security.files.user-selected.read-write</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.personal-information.location</key>
|
<key>com.apple.security.personal-information.location</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.personal-information.photos-library</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
@ -1,15 +1,18 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23507" systemVersion="23H222" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES" userDefinedModelVersionIdentifier="">
|
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23507" systemVersion="23H222" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES" userDefinedModelVersionIdentifier="">
|
||||||
<entity name="FuelLog" representedClassName="FuelLog" isAbstract="YES" syncable="YES" codeGenerationType="class">
|
<entity name="FuelLog" representedClassName="FuelLog" isAbstract="YES" syncable="YES" codeGenerationType="class">
|
||||||
<attribute name="cost" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
<attribute name="cost" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||||
<attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
<attribute name="date" attributeType="Date" defaultDateTimeInterval="764004600" usesScalarValueType="NO"/>
|
||||||
<attribute name="fuelVolume" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
<attribute name="fuelVolume" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="fullTank" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
<attribute name="locationCoordinates" optional="YES" attributeType="String"/>
|
<attribute name="locationCoordinates" optional="YES" attributeType="String"/>
|
||||||
<attribute name="locationName" optional="YES" attributeType="String"/>
|
<attribute name="locationName" optional="YES" attributeType="String"/>
|
||||||
<attribute name="octane" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
<attribute name="missedPrevious" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
<attribute name="odometer" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
<attribute name="octane" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||||
<attribute name="pricePerGalon" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
<attribute name="odometer" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="pricePerGalon" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||||
|
<relationship name="vehicle" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Vehicle" inverseName="fuelLog" inverseEntity="Vehicle"/>
|
||||||
</entity>
|
</entity>
|
||||||
<entity name="Item" representedClassName="Item" syncable="YES" codeGenerationType="class"/>
|
<entity name="Item" representedClassName="Item" syncable="YES" codeGenerationType="class"/>
|
||||||
<entity name="MaintenanceEvent" representedClassName="MaintenanceEvent" isAbstract="YES" syncable="YES" codeGenerationType="class">
|
<entity name="MaintenanceEvent" representedClassName="MaintenanceEvent" isAbstract="YES" syncable="YES" codeGenerationType="class">
|
||||||
|
@ -22,4 +25,32 @@
|
||||||
<attribute name="notes" optional="YES" attributeType="String"/>
|
<attribute name="notes" optional="YES" attributeType="String"/>
|
||||||
<attribute name="odometer" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
<attribute name="odometer" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||||
</entity>
|
</entity>
|
||||||
|
<entity name="Vehicle" representedClassName="Vehicle" syncable="YES">
|
||||||
|
<attribute name="color" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="engineDisplacement" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="engineName" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO" customClassName="NSUUID"/>
|
||||||
|
<attribute name="make" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="model" attributeType="String" defaultValueString=""/>
|
||||||
|
<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"/>
|
||||||
|
<attribute name="tireBrand" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="tireModel" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="tireSizeHeight" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="tireSizeRadius" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="tireSizeWidth" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="transmission" optional="YES" attributeType="String" defaultValueString="Automatic"/>
|
||||||
|
<attribute name="vehicleType" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="wheelBrand" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="wheelModel" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="wheelSizeDiameter" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="wheelSizeWidth" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="year" attributeType="String" minValueString="4" maxValueString="4" defaultValueString="2025" regularExpressionString="[0-9][0-9][0-9][0-9]"/>
|
||||||
|
<relationship name="fuelLog" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="FuelLog" inverseName="vehicle" inverseEntity="FuelLog"/>
|
||||||
|
</entity>
|
||||||
</model>
|
</model>
|
|
@ -0,0 +1,37 @@
|
||||||
|
//
|
||||||
|
// HelperFormat.swift
|
||||||
|
// Gas Man
|
||||||
|
//
|
||||||
|
// Created by Kameron Kenny on 3/19/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// Helper function to check if a string is properly formatted (e.g., "18.526")
|
||||||
|
func isProperlyFormatted(_ input: String) -> Bool {
|
||||||
|
let pattern = #"^\d+\.\d{3}$"#
|
||||||
|
return input.range(of: pattern, options: .regularExpression) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// helper function to format input.
|
||||||
|
func formatInput(_ input: String) -> String {
|
||||||
|
// Extract only numeric characters.
|
||||||
|
let digitsOnly = input.filter { $0.isNumber }
|
||||||
|
// If no digits, return empty.
|
||||||
|
guard !digitsOnly.isEmpty else { return "" }
|
||||||
|
// Convert to an integer to remove any leading zeros.
|
||||||
|
let numberValue = Int(digitsOnly) ?? 0
|
||||||
|
// Convert back to a string.
|
||||||
|
let digits = String(numberValue)
|
||||||
|
|
||||||
|
if digits.count > 3 {
|
||||||
|
// Insert decimal point so that the last 3 digits are decimals.
|
||||||
|
let integerPart = digits.dropLast(3)
|
||||||
|
let decimalPart = digits.suffix(3)
|
||||||
|
return "\(integerPart).\(decimalPart)"
|
||||||
|
} else {
|
||||||
|
// If fewer than 4 digits, pad with zeros on the left to 3 digits.
|
||||||
|
let padded = String(repeating: "0", count: 3 - digits.count) + digits
|
||||||
|
return "0." + padded
|
||||||
|
}
|
||||||
|
}
|
|
@ -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) { }
|
||||||
|
}
|
|
@ -14,10 +14,18 @@ struct MainTabView: View {
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Fuel", systemImage: "fuelpump.fill")
|
Label("Fuel", systemImage: "fuelpump.fill")
|
||||||
}
|
}
|
||||||
|
VehicleListView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Vehicles", systemImage: "car.fill")
|
||||||
|
}
|
||||||
MaintenanceListView()
|
MaintenanceListView()
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Maintenance", systemImage: "wrench.fill")
|
Label("Maintenance", systemImage: "wrench.fill")
|
||||||
}
|
}
|
||||||
|
StatsView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Stats", systemImage: "chart.bar.fill")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@ struct PersistenceController {
|
||||||
init(inMemory: Bool = false) {
|
init(inMemory: Bool = false) {
|
||||||
// "GasMan" must match the name of your .xcdatamodeld file.
|
// "GasMan" must match the name of your .xcdatamodeld file.
|
||||||
container = NSPersistentCloudKitContainer(name: "Gas_Man")
|
container = NSPersistentCloudKitContainer(name: "Gas_Man")
|
||||||
|
|
||||||
|
|
||||||
if inMemory {
|
if inMemory {
|
||||||
container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
|
container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,177 @@
|
||||||
|
//
|
||||||
|
// AddVehicleView.swift
|
||||||
|
// Gas Man
|
||||||
|
//
|
||||||
|
// Created by Kameron Kenny on 3/18/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AddVehicleView: View {
|
||||||
|
@Environment(\.managedObjectContext) private var viewContext
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
|
// Basic information
|
||||||
|
@State private var year: String = ""
|
||||||
|
@State private var make: String = ""
|
||||||
|
@State private var model: String = ""
|
||||||
|
@State private var color: String = ""
|
||||||
|
|
||||||
|
// Purchase information
|
||||||
|
@State private var purchaseDate: Date = Date()
|
||||||
|
@State private var purchaseDateEnabled: Bool = false
|
||||||
|
@State private var purchasePrice: String = ""
|
||||||
|
@State private var odometerAtPurchase: String = ""
|
||||||
|
|
||||||
|
// Sale information
|
||||||
|
@State private var soldDate: Date = Date()
|
||||||
|
@State private var soldDateEnabled: Bool = false
|
||||||
|
@State private var odometerAtSale: String = ""
|
||||||
|
|
||||||
|
// Additional notes
|
||||||
|
@State private var notes: String = ""
|
||||||
|
|
||||||
|
// New additional vehicle fields
|
||||||
|
@State private var engineName: String = ""
|
||||||
|
@State private var engineDisplacement: String = ""
|
||||||
|
@State private var transmission: String = "Automatic" // default value
|
||||||
|
@State private var vehicleType: String = ""
|
||||||
|
@State private var wheelSizeWidth: String = ""
|
||||||
|
@State private var wheelSizeDiameter: String = ""
|
||||||
|
@State private var tireSizeWidth: String = ""
|
||||||
|
@State private var tireSizeHeight: String = ""
|
||||||
|
@State private var tireSizeRadius: String = ""
|
||||||
|
|
||||||
|
// New additional vehicle fields for tire and wheel info:
|
||||||
|
@State private var tireBrand: String = ""
|
||||||
|
@State private var tireModel: String = ""
|
||||||
|
@State private var wheelBrand: String = ""
|
||||||
|
@State private var wheelModel: String = ""
|
||||||
|
|
||||||
|
private let dateFormatter: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .short
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
Form {
|
||||||
|
Section(header: Text("Basic Information")) {
|
||||||
|
Picker("Vehicle Type", selection: $vehicleType) {
|
||||||
|
Text("Sedan").tag("Sedan")
|
||||||
|
Text("SUV").tag("SUV")
|
||||||
|
Text("Truck").tag("Truck")
|
||||||
|
Text("Van").tag("Van")
|
||||||
|
}
|
||||||
|
.pickerStyle(MenuPickerStyle())
|
||||||
|
TextField("Year", text: $year)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
TextField("Make", text: $make)
|
||||||
|
TextField("Model", text: $model)
|
||||||
|
TextField("Color", text: $color)
|
||||||
|
}
|
||||||
|
Section(header: Text("Purchase Information")) {
|
||||||
|
Toggle("Add Purchase Date", isOn: $purchaseDateEnabled)
|
||||||
|
if purchaseDateEnabled {
|
||||||
|
DatePicker("Purchase Date", selection: $purchaseDate, displayedComponents: .date)
|
||||||
|
TextField("Purchase Price", text: $purchasePrice)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
TextField("Odometer at Purchase", text: $odometerAtPurchase)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section(header: Text("Engine & Transmission")) {
|
||||||
|
TextField("Engine Name", text: $engineName)
|
||||||
|
TextField("Engine Displacement (L)", text: $engineDisplacement)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
Picker("Transmission", selection: $transmission) {
|
||||||
|
Text("Automatic").tag("Automatic")
|
||||||
|
Text("Manual").tag("Manual")
|
||||||
|
}
|
||||||
|
.pickerStyle(MenuPickerStyle())
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(header: Text("Tires")) {
|
||||||
|
TextField("Tire Brand", text: $tireBrand)
|
||||||
|
TextField("Tire Model", text: $tireModel)
|
||||||
|
TextField("Tire Size Width", text: $tireSizeWidth)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
TextField("Tire Size Height", text: $tireSizeHeight)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
TextField("Tire Size Radius", text: $tireSizeRadius)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(header: Text("Wheels")) {
|
||||||
|
TextField("Wheel Brand", text: $wheelBrand)
|
||||||
|
TextField("Wheel Model", text: $wheelModel)
|
||||||
|
TextField("Wheel Size Width", text: $wheelSizeWidth)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
TextField("Wheel Size Diameter", text: $wheelSizeDiameter)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
}
|
||||||
|
Section(header: Text("Sale Information")) {
|
||||||
|
Toggle("Add Sold Date", isOn: $soldDateEnabled)
|
||||||
|
if soldDateEnabled {
|
||||||
|
DatePicker("Date Sold", selection: $soldDate, displayedComponents: .date)
|
||||||
|
TextField("Odometer at Sale", text: $odometerAtSale)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
Section(header: Text("Notes")) {
|
||||||
|
TextField("Notes", text: $notes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Add Vehicle")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Save") { saveVehicle() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveVehicle() {
|
||||||
|
let newVehicle = Vehicle(context: viewContext)
|
||||||
|
newVehicle.id = UUID()
|
||||||
|
newVehicle.year = year
|
||||||
|
newVehicle.make = make
|
||||||
|
newVehicle.model = model
|
||||||
|
newVehicle.color = color
|
||||||
|
newVehicle.purchaseDate = purchaseDateEnabled ? purchaseDate : nil
|
||||||
|
newVehicle.purchasePrice = Double(purchasePrice).map { NSNumber(value: $0) }
|
||||||
|
newVehicle.odometerAtPurchase = Double(odometerAtPurchase).map { NSNumber(value: $0) }
|
||||||
|
newVehicle.soldDate = soldDateEnabled ? soldDate : nil
|
||||||
|
newVehicle.odometerAtSale = Double(odometerAtSale).map { NSNumber(value: $0) }
|
||||||
|
newVehicle.notes = notes.isEmpty ? nil : notes
|
||||||
|
newVehicle.tireBrand = tireBrand.isEmpty ? nil : tireBrand
|
||||||
|
newVehicle.tireModel = tireModel.isEmpty ? nil : tireModel
|
||||||
|
newVehicle.wheelBrand = wheelBrand.isEmpty ? nil : wheelBrand
|
||||||
|
newVehicle.wheelModel = wheelModel.isEmpty ? nil : wheelModel
|
||||||
|
|
||||||
|
// Save additional vehicle information:
|
||||||
|
newVehicle.engineName = engineName.isEmpty ? nil : engineName
|
||||||
|
newVehicle.engineDisplacement = Double(engineDisplacement).map { NSNumber(value: $0) }
|
||||||
|
newVehicle.transmission = transmission.isEmpty ? "Automatic" : transmission
|
||||||
|
newVehicle.vehicleType = vehicleType.isEmpty ? nil : vehicleType
|
||||||
|
newVehicle.wheelSizeWidth = Double(wheelSizeWidth).map { NSNumber(value: $0) }
|
||||||
|
newVehicle.wheelSizeDiameter = Double(wheelSizeDiameter).map { NSNumber(value: $0) }
|
||||||
|
newVehicle.tireSizeWidth = Double(tireSizeWidth).map { NSNumber(value: $0) }
|
||||||
|
newVehicle.tireSizeHeight = Double(tireSizeHeight).map { NSNumber(value: $0) }
|
||||||
|
newVehicle.tireSizeRadius = Double(tireSizeRadius).map { NSNumber(value: $0) }
|
||||||
|
|
||||||
|
do {
|
||||||
|
try viewContext.save()
|
||||||
|
dismiss()
|
||||||
|
} catch {
|
||||||
|
let nsError = error as NSError
|
||||||
|
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,256 @@
|
||||||
|
//
|
||||||
|
// EditVehicleView.swift
|
||||||
|
// Gas Man
|
||||||
|
//
|
||||||
|
// Created by Kameron Kenny on 3/18/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct EditVehicleView: View {
|
||||||
|
@Environment(\.managedObjectContext) private var viewContext
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
|
@ObservedObject var vehicle: Vehicle
|
||||||
|
|
||||||
|
// Basic Information
|
||||||
|
@State private var year: String = ""
|
||||||
|
@State private var make: String = ""
|
||||||
|
@State private var model: String = ""
|
||||||
|
@State private var color: String = ""
|
||||||
|
|
||||||
|
// Purchase Information
|
||||||
|
@State private var purchaseDate: Date = Date()
|
||||||
|
@State private var purchaseDateEnabled: Bool = false
|
||||||
|
@State private var purchasePrice: String = ""
|
||||||
|
@State private var odometerAtPurchase: String = ""
|
||||||
|
|
||||||
|
// Sale Information
|
||||||
|
@State private var soldDate: Date = Date()
|
||||||
|
@State private var soldDateEnabled: Bool = false
|
||||||
|
@State private var odometerAtSale: String = ""
|
||||||
|
|
||||||
|
// Additional Vehicle Information
|
||||||
|
@State private var engineName: String = ""
|
||||||
|
@State private var engineDisplacement: String = ""
|
||||||
|
@State private var transmission: String = "Automatic" // default
|
||||||
|
@State private var vehicleType: String = ""
|
||||||
|
@State private var wheelSizeWidth: String = ""
|
||||||
|
@State private var wheelSizeDiameter: String = ""
|
||||||
|
@State private var tireSizeWidth: String = ""
|
||||||
|
@State private var tireSizeHeight: String = ""
|
||||||
|
@State private var tireSizeRadius: String = ""
|
||||||
|
|
||||||
|
// Tire & Wheel Brand/Model
|
||||||
|
@State private var tireBrand: String = ""
|
||||||
|
@State private var tireModel: String = ""
|
||||||
|
@State private var wheelBrand: String = ""
|
||||||
|
@State private var wheelModel: String = ""
|
||||||
|
|
||||||
|
// Additional Notes
|
||||||
|
@State private var notes: String = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
Form {
|
||||||
|
Section(header: Text("Basic Information")) {
|
||||||
|
Picker("Vehicle Type", selection: $vehicleType) {
|
||||||
|
Text("Sedan").tag("Sedan")
|
||||||
|
Text("SUV").tag("SUV")
|
||||||
|
Text("Truck").tag("Truck")
|
||||||
|
Text("Van").tag("Van")
|
||||||
|
}
|
||||||
|
.pickerStyle(MenuPickerStyle())
|
||||||
|
TextField("Year", text: $year)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
TextField("Make", text: $make)
|
||||||
|
TextField("Model", text: $model)
|
||||||
|
TextField("Color", text: $color)
|
||||||
|
}
|
||||||
|
Section(header: Text("Purchase Information")) {
|
||||||
|
Toggle("Add Purchase Date", isOn: $purchaseDateEnabled)
|
||||||
|
if purchaseDateEnabled {
|
||||||
|
DatePicker("Purchase Date", selection: $purchaseDate, displayedComponents: .date)
|
||||||
|
TextField("Purchase Price", text: $purchasePrice)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
TextField("Odometer at Purchase", text: $odometerAtPurchase)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section(header: Text("Engine & Transmission")) {
|
||||||
|
TextField("Engine Name", text: $engineName)
|
||||||
|
TextField("Engine Displacement (L)", text: $engineDisplacement)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
Picker("Transmission", selection: $transmission) {
|
||||||
|
Text("Automatic").tag("Automatic")
|
||||||
|
Text("Manual").tag("Manual")
|
||||||
|
}
|
||||||
|
.pickerStyle(MenuPickerStyle())
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(header: Text("Tires")) {
|
||||||
|
TextField("Tire Brand", text: $tireBrand)
|
||||||
|
TextField("Tire Model", text: $tireModel)
|
||||||
|
TextField("Tire Size Width", text: $tireSizeWidth)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
TextField("Tire Size Height", text: $tireSizeHeight)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
TextField("Tire Size Radius", text: $tireSizeRadius)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(header: Text("Wheels")) {
|
||||||
|
TextField("Wheel Brand", text: $wheelBrand)
|
||||||
|
TextField("Wheel Model", text: $wheelModel)
|
||||||
|
TextField("Wheel Size Width", text: $wheelSizeWidth)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
TextField("Wheel Size Diameter", text: $wheelSizeDiameter)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
}
|
||||||
|
Section(header: Text("Sale Information")) {
|
||||||
|
Toggle("Add Sold Date", isOn: $soldDateEnabled)
|
||||||
|
if soldDateEnabled {
|
||||||
|
DatePicker("Date Sold", selection: $soldDate, displayedComponents: .date)
|
||||||
|
TextField("Odometer at Sale", text: $odometerAtSale)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
Section(header: Text("Notes")) {
|
||||||
|
TextField("Notes", text: $notes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Edit Vehicle")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Save") { saveChanges() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear(perform: loadVehicleData)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadVehicleData() {
|
||||||
|
// Basic Information
|
||||||
|
year = vehicle.year ?? ""
|
||||||
|
make = vehicle.make ?? ""
|
||||||
|
model = vehicle.model ?? ""
|
||||||
|
color = vehicle.color ?? ""
|
||||||
|
|
||||||
|
// Purchase Information
|
||||||
|
if let pDate = vehicle.purchaseDate {
|
||||||
|
purchaseDate = pDate
|
||||||
|
purchaseDateEnabled = true
|
||||||
|
} else {
|
||||||
|
purchaseDateEnabled = false
|
||||||
|
}
|
||||||
|
if let pPrice = vehicle.purchasePrice?.doubleValue {
|
||||||
|
purchasePrice = String(format: "%.2f", pPrice)
|
||||||
|
} else {
|
||||||
|
purchasePrice = ""
|
||||||
|
}
|
||||||
|
if let odoPurchase = vehicle.odometerAtPurchase?.doubleValue {
|
||||||
|
odometerAtPurchase = String(format: "%.0f", odoPurchase)
|
||||||
|
} else {
|
||||||
|
odometerAtPurchase = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sale Information
|
||||||
|
if let sDate = vehicle.soldDate {
|
||||||
|
soldDate = sDate
|
||||||
|
soldDateEnabled = true
|
||||||
|
} else {
|
||||||
|
soldDateEnabled = false
|
||||||
|
}
|
||||||
|
if let odoSale = vehicle.odometerAtSale?.doubleValue {
|
||||||
|
odometerAtSale = String(format: "%.0f", odoSale)
|
||||||
|
} else {
|
||||||
|
odometerAtSale = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional Vehicle Information
|
||||||
|
engineName = vehicle.engineName ?? ""
|
||||||
|
if let ed = vehicle.engineDisplacement?.doubleValue {
|
||||||
|
engineDisplacement = String(format: "%.3f", ed)
|
||||||
|
} else {
|
||||||
|
engineDisplacement = ""
|
||||||
|
}
|
||||||
|
transmission = vehicle.transmission ?? "Automatic"
|
||||||
|
vehicleType = vehicle.vehicleType ?? ""
|
||||||
|
if let wsWidth = vehicle.wheelSizeWidth?.doubleValue {
|
||||||
|
wheelSizeWidth = String(format: "%.3f", wsWidth)
|
||||||
|
} else {
|
||||||
|
wheelSizeWidth = ""
|
||||||
|
}
|
||||||
|
if let wsDiameter = vehicle.wheelSizeDiameter?.doubleValue {
|
||||||
|
wheelSizeDiameter = String(format: "%.3f", wsDiameter)
|
||||||
|
} else {
|
||||||
|
wheelSizeDiameter = ""
|
||||||
|
}
|
||||||
|
if let tsWidth = vehicle.tireSizeWidth?.doubleValue {
|
||||||
|
tireSizeWidth = String(format: "%.3f", tsWidth)
|
||||||
|
} else {
|
||||||
|
tireSizeWidth = ""
|
||||||
|
}
|
||||||
|
if let tsHeight = vehicle.tireSizeHeight?.doubleValue {
|
||||||
|
tireSizeHeight = String(format: "%.3f", tsHeight)
|
||||||
|
} else {
|
||||||
|
tireSizeHeight = ""
|
||||||
|
}
|
||||||
|
if let tsRadius = vehicle.tireSizeRadius?.doubleValue {
|
||||||
|
tireSizeRadius = String(format: "%.3f", tsRadius)
|
||||||
|
} else {
|
||||||
|
tireSizeRadius = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tire & Wheel Brand/Model
|
||||||
|
tireBrand = vehicle.tireBrand ?? ""
|
||||||
|
tireModel = vehicle.tireModel ?? ""
|
||||||
|
wheelBrand = vehicle.wheelBrand ?? ""
|
||||||
|
wheelModel = vehicle.wheelModel ?? ""
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
notes = vehicle.notes ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveChanges() {
|
||||||
|
vehicle.year = year
|
||||||
|
vehicle.make = make
|
||||||
|
vehicle.model = model
|
||||||
|
vehicle.color = color
|
||||||
|
vehicle.purchaseDate = purchaseDateEnabled ? purchaseDate : nil
|
||||||
|
vehicle.purchasePrice = Double(purchasePrice).map { NSNumber(value: $0) }
|
||||||
|
vehicle.odometerAtPurchase = Double(odometerAtPurchase).map { NSNumber(value: $0) }
|
||||||
|
vehicle.soldDate = soldDateEnabled ? soldDate : nil
|
||||||
|
vehicle.odometerAtSale = Double(odometerAtSale).map { NSNumber(value: $0) }
|
||||||
|
|
||||||
|
vehicle.engineName = engineName.isEmpty ? nil : engineName
|
||||||
|
vehicle.engineDisplacement = Double(engineDisplacement).map { NSNumber(value: $0) }
|
||||||
|
vehicle.transmission = transmission.isEmpty ? "Automatic" : transmission
|
||||||
|
vehicle.vehicleType = vehicleType.isEmpty ? nil : vehicleType
|
||||||
|
vehicle.wheelSizeWidth = Double(wheelSizeWidth).map { NSNumber(value: $0) }
|
||||||
|
vehicle.wheelSizeDiameter = Double(wheelSizeDiameter).map { NSNumber(value: $0) }
|
||||||
|
vehicle.tireSizeWidth = Double(tireSizeWidth).map { NSNumber(value: $0) }
|
||||||
|
vehicle.tireSizeHeight = Double(tireSizeHeight).map { NSNumber(value: $0) }
|
||||||
|
vehicle.tireSizeRadius = Double(tireSizeRadius).map { NSNumber(value: $0) }
|
||||||
|
|
||||||
|
vehicle.tireBrand = tireBrand.isEmpty ? nil : tireBrand
|
||||||
|
vehicle.tireModel = tireModel.isEmpty ? nil : tireModel
|
||||||
|
vehicle.wheelBrand = wheelBrand.isEmpty ? nil : wheelBrand
|
||||||
|
vehicle.wheelModel = wheelModel.isEmpty ? nil : wheelModel
|
||||||
|
|
||||||
|
vehicle.notes = notes.isEmpty ? nil : notes
|
||||||
|
|
||||||
|
do {
|
||||||
|
try viewContext.save()
|
||||||
|
dismiss()
|
||||||
|
} catch {
|
||||||
|
let nsError = error as NSError
|
||||||
|
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,57 @@
|
||||||
|
//
|
||||||
|
// Vehicle.swift
|
||||||
|
// Gas Man
|
||||||
|
//
|
||||||
|
// Created by Kameron Kenny on 3/18/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
@objc(Vehicle)
|
||||||
|
public class Vehicle: NSManagedObject {
|
||||||
|
public override func awakeFromInsert() {
|
||||||
|
super.awakeFromInsert()
|
||||||
|
self.id = UUID()
|
||||||
|
print("Assigned new UUID: \(self.id)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Vehicle: Identifiable {
|
||||||
|
@NSManaged public var id: UUID?
|
||||||
|
|
||||||
|
// Computed property to always get a non-optional UUID.
|
||||||
|
public var nonOptionalID: UUID {
|
||||||
|
id ?? UUID()
|
||||||
|
}
|
||||||
|
|
||||||
|
@NSManaged public var photo: Data?
|
||||||
|
@NSManaged public var year: String?
|
||||||
|
@NSManaged public var make: String?
|
||||||
|
@NSManaged public var model: String?
|
||||||
|
@NSManaged public var color: String?
|
||||||
|
@NSManaged public var purchaseDate: Date?
|
||||||
|
@NSManaged public var purchasePrice: NSNumber?
|
||||||
|
@NSManaged public var soldDate: Date?
|
||||||
|
@NSManaged public var odometerAtPurchase: NSNumber?
|
||||||
|
@NSManaged public var odometerAtSale: NSNumber?
|
||||||
|
@NSManaged public var notes: String?
|
||||||
|
|
||||||
|
// Existing additional fields
|
||||||
|
@NSManaged public var engineName: String?
|
||||||
|
@NSManaged public var engineDisplacement: NSNumber?
|
||||||
|
@NSManaged public var transmission: String?
|
||||||
|
@NSManaged public var vehicleType: String?
|
||||||
|
@NSManaged public var wheelSizeWidth: NSNumber?
|
||||||
|
@NSManaged public var wheelSizeDiameter: NSNumber?
|
||||||
|
@NSManaged public var tireSizeWidth: NSNumber?
|
||||||
|
@NSManaged public var tireSizeHeight: NSNumber?
|
||||||
|
@NSManaged public var tireSizeRadius: NSNumber?
|
||||||
|
|
||||||
|
// New fields for tire and wheel brands/models
|
||||||
|
@NSManaged public var tireBrand: String?
|
||||||
|
@NSManaged public var tireModel: String?
|
||||||
|
@NSManaged public var wheelBrand: String?
|
||||||
|
@NSManaged public var wheelModel: String?
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,251 @@
|
||||||
|
//
|
||||||
|
// VehicleDetailView.swift
|
||||||
|
// Gas Man
|
||||||
|
//
|
||||||
|
// Created by Kameron Kenny on 3/18/25.
|
||||||
|
//
|
||||||
|
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
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
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:")
|
||||||
|
Spacer()
|
||||||
|
Text(vehicle.vehicleType ?? "")
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("Year:")
|
||||||
|
Spacer()
|
||||||
|
Text(vehicle.year ?? "")
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("Make:")
|
||||||
|
Spacer()
|
||||||
|
Text(vehicle.make ?? "N/A")
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("Model:")
|
||||||
|
Spacer()
|
||||||
|
Text(vehicle.model ?? "N/A")
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("Color:")
|
||||||
|
Spacer()
|
||||||
|
Text(vehicle.color ?? "N/A")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section(header: Text("Purchase Information")) {
|
||||||
|
HStack {
|
||||||
|
Text("Date:")
|
||||||
|
Spacer()
|
||||||
|
Text(vehicle.purchaseDate != nil ? dateFormatter.string(from: vehicle.purchaseDate!) : "")
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("Price:")
|
||||||
|
Spacer()
|
||||||
|
if let price = vehicle.purchasePrice {
|
||||||
|
Text("$\(price.doubleValue, specifier: "%.2f")")
|
||||||
|
} else {
|
||||||
|
Text("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("Odometer:")
|
||||||
|
Spacer()
|
||||||
|
if let odo = vehicle.odometerAtPurchase {
|
||||||
|
Text("\(odo.doubleValue, specifier: "%.0f") miles")
|
||||||
|
} else {
|
||||||
|
Text("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section(header: Text("Engine & Transmission")) {
|
||||||
|
HStack {
|
||||||
|
Text("Engine Name:")
|
||||||
|
Spacer()
|
||||||
|
Text(vehicle.engineName ?? "")
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("Engine Displacement:")
|
||||||
|
Spacer()
|
||||||
|
if let ed = vehicle.engineDisplacement {
|
||||||
|
Text("\(ed.doubleValue, specifier: "%.3f") L")
|
||||||
|
} else {
|
||||||
|
Text("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("Transmission:")
|
||||||
|
Spacer()
|
||||||
|
Text(vehicle.transmission ?? "Automatic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section(header: Text("Tires")) {
|
||||||
|
HStack {
|
||||||
|
Text("Brand:")
|
||||||
|
Spacer()
|
||||||
|
Text(vehicle.tireBrand ?? "")
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("Model:")
|
||||||
|
Spacer()
|
||||||
|
Text(vehicle.tireModel ?? "")
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("Size:")
|
||||||
|
Spacer()
|
||||||
|
if let tw = vehicle.tireSizeWidth {
|
||||||
|
Text("\(tw.doubleValue, specifier: "%.0f")")
|
||||||
|
} else {
|
||||||
|
Text("-")
|
||||||
|
}
|
||||||
|
Text("/")
|
||||||
|
if let th = vehicle.tireSizeHeight {
|
||||||
|
Text("\(th.doubleValue, specifier: "%.0f")")
|
||||||
|
} else {
|
||||||
|
Text("-")
|
||||||
|
}
|
||||||
|
Text("r")
|
||||||
|
if let tr = vehicle.tireSizeRadius {
|
||||||
|
Text("\(tr.doubleValue, specifier: "%.0f")")
|
||||||
|
} else {
|
||||||
|
Text("-")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section(header: Text("Wheels")) {
|
||||||
|
HStack {
|
||||||
|
Text("Brand:")
|
||||||
|
Spacer()
|
||||||
|
Text(vehicle.wheelBrand ?? "")
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("Model:")
|
||||||
|
Spacer()
|
||||||
|
Text(vehicle.wheelModel ?? "")
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("Size:")
|
||||||
|
Spacer()
|
||||||
|
if let wd = vehicle.wheelSizeDiameter {
|
||||||
|
Text("\(wd.doubleValue, specifier: "%.3f")")
|
||||||
|
} else {
|
||||||
|
Text("-")
|
||||||
|
}
|
||||||
|
Text(" x ")
|
||||||
|
if let ws = vehicle.wheelSizeWidth {
|
||||||
|
Text("\(ws.doubleValue, specifier: "%.3f")")
|
||||||
|
} else {
|
||||||
|
Text("-")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if vehicle.soldDate != nil {
|
||||||
|
Section(header: Text("Sale Information")) {
|
||||||
|
HStack {
|
||||||
|
Text("Date Sold:")
|
||||||
|
Spacer()
|
||||||
|
Text(vehicle.soldDate != nil ? dateFormatter.string(from: vehicle.soldDate!) : "")
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("Odometer at Sale:")
|
||||||
|
Spacer()
|
||||||
|
if let odo = vehicle.odometerAtSale {
|
||||||
|
Text("\(odo.doubleValue, specifier: "%.0f") miles")
|
||||||
|
} else {
|
||||||
|
Text("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section(header: Text("Notes")) {
|
||||||
|
Text(vehicle.notes ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("\(vehicle.make ?? "") \(vehicle.model ?? "")")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Edit") {
|
||||||
|
showingEditVehicle = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingEditVehicle) {
|
||||||
|
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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
//
|
||||||
|
// VehicleRowView.swift
|
||||||
|
// Gas Man
|
||||||
|
//
|
||||||
|
// Created by Kameron Kenny on 3/18/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct VehicleRowView: View {
|
||||||
|
let vehicle: Vehicle
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|