diff --git a/Basic-Car-Maintenance/Shared/AppIntents/AddOdometerReadingIntent/AddOdometerReadingIntent.swift b/Basic-Car-Maintenance/Shared/AppIntents/AddOdometerReadingIntent/AddOdometerReadingIntent.swift new file mode 100644 index 00000000..bec52aa9 --- /dev/null +++ b/Basic-Car-Maintenance/Shared/AppIntents/AddOdometerReadingIntent/AddOdometerReadingIntent.swift @@ -0,0 +1,97 @@ +// +// VehicleQuery.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import AppIntents + +/// An `AppIntent` that allows the user to add an odometer reading for a specified vehicle. +/// +/// This intent accepts the distance traveled, the unit of distance (miles or kilometers), +/// the vehicle for which the odometer reading is being recorded, and the date of the reading. +/// +/// The intent validates the input, ensuring that the distance is a positive integer. +/// If the input is valid, the intent creates an `OdometerReading` and saves it using the `OdometerViewModel`. +/// Upon successful completion, a confirmation dialog is presented to the user. +struct AddOdometerReadingIntent: AppIntent { + @Dependency private var authViewModel: AuthenticationViewModel + + @Parameter(title: LocalizedStringResource( + "Vehicle", + comment: "The selected vehicle to add the odometer reading to.") + ) + var vehicle: Vehicle? + + @Parameter(title: LocalizedStringResource( + "Date", + comment: "The date when the reading should be logged.") + ) + var date: Date + + @Parameter( + title: LocalizedStringResource( + "Distance Unit", + comment: "The distance unit in miles or kilometers" + ), + requestValueDialog: IntentDialog("In which distance unit would you like to save the entered value?") + ) + var distanceType: DistanceUnit + + @Parameter(title: LocalizedStringResource( + "Distance", + comment: "The distance value") + ) + var distance: Int + + static var title = LocalizedStringResource( + "Add Odometer Reading", + comment: "Title for the app intent when adding an odometer reading" + ) + + private func fetchVehicles() async throws -> [Vehicle] { + let odometerVM = OdometerViewModel(userUID: authViewModel.user?.uid) + await odometerVM.getVehicles() + guard !odometerVM.vehicles.isEmpty else { + throw OdometerReadingIntentError.emptyVehicles + } + return odometerVM.vehicles + } + + func perform() async throws -> some IntentResult & ProvidesDialog { + if distance < 1 { + throw OdometerReadingIntentError.invalidDistance + } + + let selectVehicle: Vehicle + if let vehicle { + selectVehicle = vehicle + } else { + let fetchedVehicles = try await fetchVehicles() + selectVehicle = try await $vehicle.requestDisambiguation( + among: fetchedVehicles, + dialog: IntentDialog("Which vehicle would you like to add this to?") + ) + } + + let reading = OdometerReading( + date: date, + distance: distance, + isMetric: distanceType == .kilometer, + vehicleID: selectVehicle.id + ) + + let odometerVM = OdometerViewModel(userUID: authViewModel.user?.uid) + try odometerVM.addReading(reading) + return .result( + dialog: IntentDialog( + LocalizedStringResource( + "Added reading successfully", + comment: "The message shown when successfully adding an odometer reading using the app intent" + ) + ) + ) + } +} diff --git a/Basic-Car-Maintenance/Shared/AppIntents/AddOdometerReadingIntent/DistanceUnit.swift b/Basic-Car-Maintenance/Shared/AppIntents/AddOdometerReadingIntent/DistanceUnit.swift new file mode 100644 index 00000000..98aebaac --- /dev/null +++ b/Basic-Car-Maintenance/Shared/AppIntents/AddOdometerReadingIntent/DistanceUnit.swift @@ -0,0 +1,29 @@ +// +// DistanceUnit.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import AppIntents + +/// An enumeration representing the units of distance used for odometer readings. +/// +/// This enum conforms to `AppEnum` and `CaseIterable` to provide display representations +/// for the available distance units: miles and kilometers. +/// +/// - `mile`: Represents distance in miles. +/// - `kilometer`: Represents distance in kilometers. +enum DistanceUnit: String, AppEnum { + case mile + case kilometer + + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Distance Type") + static var caseDisplayRepresentations: [DistanceUnit: DisplayRepresentation] { + [ + .mile: "Miles", + .kilometer: "Kilometers" + ] + } +} diff --git a/Basic-Car-Maintenance/Shared/AppIntents/AddOdometerReadingIntent/OdometerReadingIntentError.swift b/Basic-Car-Maintenance/Shared/AppIntents/AddOdometerReadingIntent/OdometerReadingIntentError.swift new file mode 100644 index 00000000..4c096443 --- /dev/null +++ b/Basic-Car-Maintenance/Shared/AppIntents/AddOdometerReadingIntent/OdometerReadingIntentError.swift @@ -0,0 +1,38 @@ +// +// OdometerReadingIntentError.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import AppIntents + +/// An enumeration representing errors that can occur when adding an odometer reading. +/// +/// This enum conforms to `Error` and `CustomLocalizedStringResourceConvertible` to provide +/// localized error messages for specific conditions: +/// +/// - `invalidDistance`: Triggered when a distance value less than 1 (either in kilometers or miles) is entered. +/// - `emptyVehicles`: Triggered when there are no vehicles available to select for the odometer reading. +/// +/// Each case provides a user-friendly localized string resource that describes the error. +enum OdometerReadingIntentError: Error, CustomLocalizedStringResourceConvertible { + case invalidDistance + case emptyVehicles + + var localizedStringResource: LocalizedStringResource { + switch self { + case .invalidDistance: + LocalizedStringResource( + "Please add a distance of at least 1 kilometer or mile.", + comment: "an error shown when entering a zero or negative value for distance" + ) + case .emptyVehicles: + LocalizedStringResource( + "Sorry, there're no vehicles saved to add a reading, please make sure you've saved at least one vehicle. You can do this in the app and then try adding the reading again.", + comment: "an error shown when attempting to add an odometer while there are no vehicles added" + ) + } + } +} diff --git a/Basic-Car-Maintenance/Shared/AppIntents/AddOdometerReadingIntent/VehicleQuery.swift b/Basic-Car-Maintenance/Shared/AppIntents/AddOdometerReadingIntent/VehicleQuery.swift new file mode 100644 index 00000000..27f1aba4 --- /dev/null +++ b/Basic-Car-Maintenance/Shared/AppIntents/AddOdometerReadingIntent/VehicleQuery.swift @@ -0,0 +1,31 @@ +// +// VehicleQuery.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import AppIntents + +/// The query used to retrieve vehicles for adding odometer. +struct VehicleQuery: EntityQuery { + @Dependency private var authViewModel: AuthenticationViewModel + + func entities(for identifiers: [Vehicle.ID]) async throws -> [Vehicle] { + try await fetchVehicles() + } + + func suggestedEntities() async throws -> [Vehicle] { + try await fetchVehicles() + } + + private func fetchVehicles() async throws -> [Vehicle] { + let odometerVM = OdometerViewModel(userUID: authViewModel.user?.uid) + await odometerVM.getVehicles() + guard !odometerVM.vehicles.isEmpty else { + throw OdometerReadingIntentError.emptyVehicles + } + return odometerVM.vehicles + } +} diff --git a/Basic-Car-Maintenance/Shared/BasicCarMaintenanceApp.swift b/Basic-Car-Maintenance/Shared/BasicCarMaintenanceApp.swift index 77274d0e..d5cfd6fe 100644 --- a/Basic-Car-Maintenance/Shared/BasicCarMaintenanceApp.swift +++ b/Basic-Car-Maintenance/Shared/BasicCarMaintenanceApp.swift @@ -11,12 +11,17 @@ import FirebaseCore import FirebaseFirestore import SwiftUI import TipKit +import AppIntents @main struct BasicCarMaintenanceApp: App { @State private var actionService = ActionService.shared @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate + init() { + FirebaseApp.configure() + AppDependencyManager.shared.add(dependency: AuthenticationViewModel()) + } // Logic to load Onboarding screen when app was first launched // @AppStorage("isFirstTime") private var isFirstTime: Bool = true @@ -43,9 +48,6 @@ class AppDelegate: NSObject, UIApplicationDelegate { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { - - FirebaseApp.configure() - let useEmulator = UserDefaults.standard.bool(forKey: "useEmulator") if useEmulator { let settings = Firestore.firestore().settings diff --git a/Basic-Car-Maintenance/Shared/Localizable.xcstrings b/Basic-Car-Maintenance/Shared/Localizable.xcstrings index b638c89c..bd0ffe5c 100644 --- a/Basic-Car-Maintenance/Shared/Localizable.xcstrings +++ b/Basic-Car-Maintenance/Shared/Localizable.xcstrings @@ -346,6 +346,9 @@ } } }, + "Add Odometer Reading" : { + "comment" : "Title for the app intent when adding an odometer reading" + }, "Add Reading" : { "comment" : "Title for form when adding an odometer reading", "localizations" : { @@ -640,6 +643,9 @@ } } }, + "Added reading successfully" : { + "comment" : "The message shown when successfully adding an odometer reading using the app intent" + }, "AddEvent" : { "comment" : "Label for adding maintenance event on Dashboard view", "localizations" : { @@ -1563,7 +1569,7 @@ } }, "Date" : { - "comment" : "Date picker label", + "comment" : "Date picker label\nThe date when the reading should be logged.", "localizations" : { "be" : { "stringUnit" : { @@ -1811,6 +1817,7 @@ } }, "Distance" : { + "comment" : "The distance value", "localizations" : { "de" : { "stringUnit" : { @@ -1862,6 +1869,12 @@ } } }, + "Distance Type" : { + + }, + "Distance Unit" : { + "comment" : "The distance unit in miles or kilometers" + }, "Edit" : { "comment" : "Button label to edit this maintenance", "localizations" : { @@ -2724,6 +2737,9 @@ }, "Imperial" : { "comment" : "Imperial unit system" + }, + "In which distance unit would you like to save the entered value?" : { + }, "It's open source and anyone can contribute to it." : { "comment" : "Tells the user they can contribute to the codebase.", @@ -3862,6 +3878,9 @@ }, "Plate: %@" : { + }, + "Please add a distance of at least 1 kilometer or mile." : { + "comment" : "an error shown when entering a zero or negative value for distance" }, "Preferred System" : { @@ -4595,6 +4614,9 @@ } } }, + "Sorry, there're no vehicles saved to add a reading, please make sure you've saved at least one vehicle. You can do this in the app and then try adding the reading again." : { + "comment" : "an error shown when attempting to add an odometer while there are no vehicles added" + }, "Tap the + to begin" : { "localizations" : { "fa" : { @@ -5366,7 +5388,7 @@ } }, "Vehicle" : { - "comment" : "Maintenance event vehicle picker header", + "comment" : "Maintenance event vehicle picker header\nThe selected vehicle to add the odometer reading to.", "localizations" : { "be" : { "stringUnit" : { @@ -6306,6 +6328,9 @@ } } } + }, + "Which vehicle would you like to add this to?" : { + }, "Year" : { "localizations" : { diff --git a/Basic-Car-Maintenance/Shared/Models/Vehicle.swift b/Basic-Car-Maintenance/Shared/Models/Vehicle.swift index 0b80d06a..112b6168 100644 --- a/Basic-Car-Maintenance/Shared/Models/Vehicle.swift +++ b/Basic-Car-Maintenance/Shared/Models/Vehicle.swift @@ -8,9 +8,10 @@ import FirebaseFirestoreSwift import Foundation +import AppIntents struct Vehicle: Codable, Identifiable, Hashable { - @DocumentID var id: String? + @DocumentID private var documentID: String? var userID: String? let name: String let make: String @@ -19,17 +20,25 @@ struct Vehicle: Codable, Identifiable, Hashable { let color: String? let vin: String? let licensePlateNumber: String? + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(name)") + } + + static var defaultQuery = VehicleQuery() + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Vehicle") - init(id: String? = nil, - userID: String? = nil, - name: String, - make: String, - model: String, - year: String? = nil, - color: String? = nil, - vin: String? = nil, - licensePlateNumber: String? = nil) { - self.id = id + init( + id: String? = nil, + userID: String? = nil, + name: String, + make: String, + model: String, + year: String? = nil, + color: String? = nil, + vin: String? = nil, + licensePlateNumber: String? = nil + ) { + self.documentID = id self.userID = userID self.name = name self.make = make @@ -39,4 +48,22 @@ struct Vehicle: Codable, Identifiable, Hashable { self.vin = vin self.licensePlateNumber = licensePlateNumber } + + enum CodingKeys: String, CodingKey { + case documentID = "_id" + case userID + case name + case make + case model + case year + case color + case vin + case licensePlateNumber + } +} + +extension Vehicle: AppEntity { + var id: String { + documentID ?? UUID().uuidString + } } diff --git a/Basic-Car-Maintenance/Shared/Odometer/Views/OdometerView.swift b/Basic-Car-Maintenance/Shared/Odometer/Views/OdometerView.swift index 68096fde..e4e00a89 100644 --- a/Basic-Car-Maintenance/Shared/Odometer/Views/OdometerView.swift +++ b/Basic-Car-Maintenance/Shared/Odometer/Views/OdometerView.swift @@ -116,45 +116,36 @@ struct OdometerView: View { let viewModel = OdometerViewModel(userUID: nil) let firstCar = createVehicle(id: "id1", name: "My 1st car") let secondCar = createVehicle(id: "id2", name: "2nd Car") - viewModel.vehicles.append(contentsOf: [firstCar, secondCar]) - let firstReading = createReading(vehicleID: secondCar.id!, - date: "2024/10/18", - distance: 20) - let secondReading = createReading(vehicleID: firstCar.id!, - date: "2024/10/15", - distance: 1000) + let readings = [firstCar, secondCar] + .map { + OdometerReading( + id: UUID().uuidString, + userID: "", + date: .now, + distance: Int.random(in: 10...1000), + isMetric: false, + vehicleID: $0.id + ) + } - let thirdReading = createReading(vehicleID: firstCar.id!, - date: "2024/10/13", - distance: 10) - viewModel.readings.append(contentsOf: [firstReading, secondReading, thirdReading]) + viewModel.readings.append(contentsOf: readings) - return OdometerView(viewModel: viewModel) - .environment(ActionService.shared) - func createVehicle(id: String, name: String) -> Vehicle { - Vehicle(id: id, - userID: nil, - name: name, - make: "", - model: "", - year: nil, - color: nil, - vin: nil, - licensePlateNumber: nil) - } - - func createReading(vehicleID: String, date: String, distance: Int) -> OdometerReading { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy/MM/dd" - let firstDate = formatter.date(from: date)! - return OdometerReading(id: UUID().uuidString, - userID: "", - date: firstDate, - distance: distance, - isMetric: false, - vehicleID: vehicleID) + Vehicle( + id: id, + userID: nil, + name: name, + make: "", + model: "", + year: nil, + color: nil, + vin: nil, + licensePlateNumber: nil + ) } + + return OdometerView(viewModel: viewModel) + .environment(ActionService.shared) } diff --git a/Basic-Car-Maintenance/Shared/Settings/ViewModels/AuthenticationViewModel.swift b/Basic-Car-Maintenance/Shared/Settings/ViewModels/AuthenticationViewModel.swift index 3bae8b5b..69536a53 100644 --- a/Basic-Car-Maintenance/Shared/Settings/ViewModels/AuthenticationViewModel.swift +++ b/Basic-Car-Maintenance/Shared/Settings/ViewModels/AuthenticationViewModel.swift @@ -24,7 +24,7 @@ enum AuthenticationFlow { } @Observable -final class AuthenticationViewModel { +final class AuthenticationViewModel: @unchecked Sendable { var email = "" var password = "" diff --git a/Basic-Car-Maintenance/Shared/Settings/ViewModels/SettingsViewModel.swift b/Basic-Car-Maintenance/Shared/Settings/ViewModels/SettingsViewModel.swift index 2e0af72e..b7609da3 100644 --- a/Basic-Car-Maintenance/Shared/Settings/ViewModels/SettingsViewModel.swift +++ b/Basic-Car-Maintenance/Shared/Settings/ViewModels/SettingsViewModel.swift @@ -83,14 +83,13 @@ final class SettingsViewModel { func updateVehicle(_ vehicle: Vehicle) async { if let userUID = authenticationViewModel.user?.uid { - guard let vehicleID = vehicle.id else { return } var vehicleToUpdate = vehicle vehicleToUpdate.userID = userUID do { try Firestore.firestore() .collection(FirestoreCollection.vehicles) - .document(vehicleID) + .document(vehicle.id) .setData(from: vehicleToUpdate) AnalyticsService.shared.logEvent(.vehicleUpdate) diff --git a/Basic-Car-Maintenance/Shared/Settings/Views/EditVehicleView.swift b/Basic-Car-Maintenance/Shared/Settings/Views/EditVehicleView.swift index 4fcb9e45..3fe58f3c 100644 --- a/Basic-Car-Maintenance/Shared/Settings/Views/EditVehicleView.swift +++ b/Basic-Car-Maintenance/Shared/Settings/Views/EditVehicleView.swift @@ -90,7 +90,8 @@ struct EditVehicleView: View, Observable { ToolbarItem(placement: .topBarTrailing) { Button { if let selectedVehicle { - var vehicle = Vehicle( + let vehicle = Vehicle( + id: selectedVehicle.id, name: name, make: make, model: model, @@ -98,7 +99,6 @@ struct EditVehicleView: View, Observable { color: color, vin: VIN, licensePlateNumber: licensePlateNumber) - vehicle.id = selectedVehicle.id Task { await viewModel.updateVehicle(vehicle) if let onVehicleUpdated {