Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement AddOdometerReading AppIntent #302

Open
wants to merge 2 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's add OdometerViewModel as a dependency too

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"

Check warning on line 92 in Basic-Car-Maintenance/Shared/AppIntents/AddOdometerReadingIntent/AddOdometerReadingIntent.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Line should be 110 characters or less; currently it has 114 characters (line_length)
)
)
)
}
}
Original file line number Diff line number Diff line change
@@ -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"
]
}
}
Original file line number Diff line number Diff line change
@@ -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.

Check warning on line 16 in Basic-Car-Maintenance/Shared/AppIntents/AddOdometerReadingIntent/OdometerReadingIntentError.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Line should be 110 characters or less; currently it has 112 characters (line_length)
/// - `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.",

Check warning on line 33 in Basic-Car-Maintenance/Shared/AppIntents/AddOdometerReadingIntent/OdometerReadingIntentError.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Line should be 110 characters or less; currently it has 188 characters (line_length)
comment: "an error shown when attempting to add an odometer while there are no vehicles added"
)
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
8 changes: 5 additions & 3 deletions Basic-Car-Maintenance/Shared/BasicCarMaintenanceApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,17 @@
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())

Check failure on line 23 in Basic-Car-Maintenance/Shared/BasicCarMaintenanceApp.swift

View workflow job for this annotation

GitHub Actions / build

call to main actor-isolated initializer 'init()' in a synchronous nonisolated context
}
// Logic to load Onboarding screen when app was first launched
// @AppStorage("isFirstTime") private var isFirstTime: Bool = true

Expand All @@ -43,9 +48,6 @@
_ 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
Expand Down
29 changes: 27 additions & 2 deletions Basic-Car-Maintenance/Shared/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,9 @@
}
}
},
OmarHegazy93 marked this conversation as resolved.
Show resolved Hide resolved
"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" : {
Expand Down Expand Up @@ -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" : {
Expand Down Expand Up @@ -1563,7 +1569,7 @@
}
},
"Date" : {
"comment" : "Date picker label",
"comment" : "Date picker label\nThe date when the reading should be logged.",
"localizations" : {
"be" : {
"stringUnit" : {
Expand Down Expand Up @@ -1811,6 +1817,7 @@
}
},
"Distance" : {
"comment" : "The distance value",
"localizations" : {
"de" : {
"stringUnit" : {
Expand Down Expand Up @@ -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" : {
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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" : {

Expand Down Expand Up @@ -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" : {
Expand Down Expand Up @@ -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" : {
Expand Down Expand Up @@ -6306,6 +6328,9 @@
}
}
}
},
"Which vehicle would you like to add this to?" : {

},
"Year" : {
"localizations" : {
Expand Down
49 changes: 38 additions & 11 deletions Basic-Car-Maintenance/Shared/Models/Vehicle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentID shouldn't be set from the id, because it would always be empty

this gets set properly when fetching from Firebase

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So there are a couple of things going on here:

  1. Since Vehicle conforms now to AppEntity protocol, it must be identifiable, meaning it must have a non-optional id
  2. The property documentID was previously named id and since it's defined using @DocumentID property wrapper, it must be an optional value, so by keeping it as id will give a compilation error indicating that Type Vehicle does not conform to protocol AppEntity, which must be non-optional as motioned in the aforementioned point
  3. The documentID will correspond to the key "_id" when sending/retrieving vehicle from firebase as the CodingKeys enum indicates
  4. The reason why documentID is set to private property is not to confuse the caller for having documentID and id so I wanted the caller to deal with only one value that refers to the id of the vehicle and under the hood, we pass this id to the documentID and when calling vehicle.id, this implicitly get the documentID

Hopefully that made it clear

self.userID = userID
self.name = name
self.make = make
Expand All @@ -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
}
}
Loading
Loading