diff --git a/Basic-Car-Maintenance.xcodeproj/project.pbxproj b/Basic-Car-Maintenance.xcodeproj/project.pbxproj index e176f9ec..f94fa164 100644 --- a/Basic-Car-Maintenance.xcodeproj/project.pbxproj +++ b/Basic-Car-Maintenance.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ 8A3D748C2AD9C41D0000FEEB /* AlertItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A3D748B2AD9C41D0000FEEB /* AlertItem.swift */; }; 8AEE816F2ACF37F800FC0C2A /* Action.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AEE816E2ACF37F800FC0C2A /* Action.swift */; }; 8AEE81722ACF384D00FC0C2A /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AEE81712ACF384D00FC0C2A /* MainTabView.swift */; }; + E55B630D2B079E5A006BDDDF /* EditVehicleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E55B630C2B079E5A006BDDDF /* EditVehicleView.swift */; }; E58499662ACDDA8B00634660 /* ContributorsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E58499652ACDDA8B00634660 /* ContributorsListView.swift */; }; E58499682ACDDA9A00634660 /* ContributorsProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E58499672ACDDA9A00634660 /* ContributorsProfileView.swift */; }; E584996A2ACDDAFF00634660 /* Contributor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E58499692ACDDAFF00634660 /* Contributor.swift */; }; @@ -121,6 +122,7 @@ 8AEE816E2ACF37F800FC0C2A /* Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Action.swift; sourceTree = ""; }; 8AEE81712ACF384D00FC0C2A /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = ""; }; 8AEE81732ACF394E00FC0C2A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + E55B630C2B079E5A006BDDDF /* EditVehicleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditVehicleView.swift; sourceTree = ""; }; E58499652ACDDA8B00634660 /* ContributorsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContributorsListView.swift; sourceTree = ""; }; E58499672ACDDA9A00634660 /* ContributorsProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContributorsProfileView.swift; sourceTree = ""; }; E58499692ACDDAFF00634660 /* Contributor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Contributor.swift; sourceTree = ""; }; @@ -435,6 +437,7 @@ E58499652ACDDA8B00634660 /* ContributorsListView.swift */, E58499672ACDDA9A00634660 /* ContributorsProfileView.swift */, FF09FC902AB6FF44006BE61A /* AuthenticationView.swift */, + E55B630C2B079E5A006BDDDF /* EditVehicleView.swift */, ); path = Views; sourceTree = ""; @@ -725,6 +728,7 @@ 8A3D74862AD6D9A10000FEEB /* AlertView.swift in Sources */, 57CDD9A02ADC31A8002EFED0 /* AddOdometerReadingView.swift in Sources */, 57CDD99E2ADC3173002EFED0 /* OdometerViewModel.swift in Sources */, + E55B630D2B079E5A006BDDDF /* EditVehicleView.swift in Sources */, FF755B3E2A908E7A00F49A13 /* SettingsView.swift in Sources */, FF3DDF522AA4D28F009D91C4 /* DashboardViewModel.swift in Sources */, FFE0AF562AD66C3500AB46F8 /* OdometerView.swift in Sources */, diff --git a/Basic-Car-Maintenance/Documentation.docc/FirestoreDetails.md b/Basic-Car-Maintenance/Documentation.docc/FirestoreDetails.md index e7f77163..efcb005e 100644 --- a/Basic-Car-Maintenance/Documentation.docc/FirestoreDetails.md +++ b/Basic-Car-Maintenance/Documentation.docc/FirestoreDetails.md @@ -1,5 +1,9 @@ # Firestore Collections +All about the Firebase Firestore data structure + +![Firestore Diagram with sub-collections](FirestoreDiagram.png) + ### _alerts_ The alerts collection contains system level alerts that will be visible to all users. The alerts diff --git a/Basic-Car-Maintenance/Documentation.docc/Images/FirestoreDiagram.png b/Basic-Car-Maintenance/Documentation.docc/Images/FirestoreDiagram.png new file mode 100644 index 00000000..67b9297e Binary files /dev/null and b/Basic-Car-Maintenance/Documentation.docc/Images/FirestoreDiagram.png differ diff --git a/Basic-Car-Maintenance/Documentation.docc/Images/FirestoreDiagram~dark.png b/Basic-Car-Maintenance/Documentation.docc/Images/FirestoreDiagram~dark.png new file mode 100644 index 00000000..96a1a6d0 Binary files /dev/null and b/Basic-Car-Maintenance/Documentation.docc/Images/FirestoreDiagram~dark.png differ diff --git a/Basic-Car-Maintenance/Shared/Dashboard/ViewModels/DashboardViewModel.swift b/Basic-Car-Maintenance/Shared/Dashboard/ViewModels/DashboardViewModel.swift index 30636e52..4d9fd221 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/ViewModels/DashboardViewModel.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/ViewModels/DashboardViewModel.swift @@ -12,7 +12,7 @@ import Foundation @Observable class DashboardViewModel { - let authenticationViewModel: AuthenticationViewModel + let userUID: String? var events = [MaintenanceEvent]() var showAddErrorAlert = false @@ -40,22 +40,26 @@ class DashboardViewModel { } } - init(authenticationViewModel: AuthenticationViewModel) { - self.authenticationViewModel = authenticationViewModel + init(userUID: String?) { + self.userUID = userUID } + /// Adding a `MaintenanceEvent` in Firestore at: + /// `vehicles/{vehicleDocumentID}/maintenance_events/{maintenceEventDocumentID}` + /// - Parameter maintenanceEvent: The `MaintenanceEvent` to save func addEvent(_ maintenanceEvent: MaintenanceEvent) { - if let uid = authenticationViewModel.user?.uid { + if let uid = userUID { var eventToAdd = maintenanceEvent eventToAdd.userID = uid do { try Firestore .firestore() - .collection(FirestoreCollection.maintenanceEvents) + .collection(FirestorePath.maintenanceEvents(vehicleID: eventToAdd.vehicleID).path) .addDocument(from: eventToAdd) events.append(maintenanceEvent) + AnalyticsService.shared.logEvent(.maintenanceCreate) errorMessage = "" isShowingAddMaintenanceEvent = false @@ -63,18 +67,17 @@ class DashboardViewModel { showAddErrorAlert.toggle() errorMessage = error.localizedDescription } - - AnalyticsService.shared.logEvent(.maintenanceCreate) } } func getMaintenanceEvents() async { isLoading = true - if let uid = authenticationViewModel.user?.uid { + if let userUID = userUID { let db = Firestore.firestore() - let docRef = db.collection(FirestoreCollection.maintenanceEvents) - .whereField(FirestoreField.userID, isEqualTo: uid) + + let docRef = db.collectionGroup(FirestoreCollection.maintenanceEvents) + .whereField(FirestoreField.userID, isEqualTo: userUID) let querySnapshot = try? await docRef.getDocuments() @@ -86,6 +89,7 @@ class DashboardViewModel { events.append(event) } } + self.isLoading = false self.events = events } @@ -94,14 +98,15 @@ class DashboardViewModel { func updateEvent(_ maintenanceEvent: MaintenanceEvent) async { - if let uid = authenticationViewModel.user?.uid { + if let uid = userUID { guard let id = maintenanceEvent.id else { return } var eventToUpdate = maintenanceEvent eventToUpdate.userID = uid + do { try Firestore .firestore() - .collection(FirestoreCollection.maintenanceEvents) + .collection(FirestorePath.maintenanceEvents(vehicleID: eventToUpdate.vehicleID).path) .document(id) .setData(from: eventToUpdate) } catch { @@ -123,7 +128,7 @@ class DashboardViewModel { do { try await Firestore .firestore() - .collection(FirestoreCollection.maintenanceEvents) + .collection(FirestorePath.maintenanceEvents(vehicleID: event.vehicleID).path) .document(documentId) .delete() errorMessage = "" @@ -141,7 +146,7 @@ class DashboardViewModel { /// Fetches the user's vehicles from Firestore based on their unique user ID. func getVehicles() async { - if let uid = authenticationViewModel.user?.uid { + if let uid = userUID { let db = Firestore.firestore() let docRef = db.collection("vehicles").whereField("userID", isEqualTo: uid) diff --git a/Basic-Car-Maintenance/Shared/Dashboard/Views/AddMaintenanceView.swift b/Basic-Car-Maintenance/Shared/Dashboard/Views/AddMaintenanceView.swift index a7acb727..d33e39cd 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/Views/AddMaintenanceView.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/AddMaintenanceView.swift @@ -70,18 +70,20 @@ struct AddMaintenanceView: View { .analyticsView("\(Self.self)") .navigationTitle(Text("Add Maintenance", comment: "Nagivation title for Add Maintenance view")) + .onAppear { + if !vehicles.isEmpty { + selectedVehicleID = vehicles[0].id + } + } .toolbar { ToolbarItem { Button { - if let selectedVehicleID { - if let vehicle = vehicles.filter({ $0.id == selectedVehicleID }).first { - let event = MaintenanceEvent(title: title, - date: date, - notes: notes, - vehicle: vehicle) - addTapped(event) - } + let event = MaintenanceEvent(vehicleID: selectedVehicleID, + title: title, + date: date, + notes: notes) + addTapped(event) dismiss() } } label: { diff --git a/Basic-Car-Maintenance/Shared/Dashboard/Views/DashboardView.swift b/Basic-Car-Maintenance/Shared/Dashboard/Views/DashboardView.swift index 02d868e5..16c0e60b 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/Views/DashboardView.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/DashboardView.swift @@ -15,8 +15,8 @@ struct DashboardView: View { @State private var isShowingEditView = false @State private var selectedMaintenanceEvent: MaintenanceEvent? - init(authenticationViewModel: AuthenticationViewModel) { - viewModel = DashboardViewModel(authenticationViewModel: authenticationViewModel) + init(userUID: String?) { + viewModel = DashboardViewModel(userUID: userUID) } private var eventDateFormat: DateFormatter = { @@ -33,8 +33,11 @@ struct DashboardView: View { Text(event.title) .font(.title3) - Text("\(event.vehicle.name) on \(event.date, formatter: self.eventDateFormat)", - comment: "Maintenance list item for a vehicle on a date") + let vehicleName = viewModel.vehicles.first { $0.id == event.vehicleID }?.name + if let vehicleName { + Text("\(vehicleName) on \(event.date, formatter: self.eventDateFormat)", + comment: "Maintenance list item for a vehicle on a date") + } if !event.notes.isEmpty { Text(event.notes) @@ -196,6 +199,6 @@ struct DashboardView: View { } #Preview { - DashboardView(authenticationViewModel: AuthenticationViewModel()) + DashboardView(userUID: "") .environment(ActionService.shared) } diff --git a/Basic-Car-Maintenance/Shared/Dashboard/Views/EditEventDetailView.swift b/Basic-Car-Maintenance/Shared/Dashboard/Views/EditEventDetailView.swift index 3c206b5e..c8d4f911 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/Views/EditEventDetailView.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/EditEventDetailView.swift @@ -12,7 +12,6 @@ struct EditMaintenanceEventView: View { var viewModel: DashboardViewModel @State private var title = "" @State private var date = Date() - @State private var selectedVehicle: Vehicle? @State private var notes = "" @Environment(\.dismiss) var dismiss @@ -26,18 +25,13 @@ struct EditMaintenanceEventView: View { } Section { - Picker(selection: $selectedVehicle) { - ForEach(viewModel.vehicles) { vehicle in - Text(vehicle.name).tag(vehicle as Vehicle) - } - } label: { - Text("Select a vehicle", - comment: "Picker for selecting a vehicle") + if let vehicleName = viewModel.vehicles + .filter({ $0.id == selectedEvent?.vehicleID }).first?.name { + Text(vehicleName) + .opacity(0.3) } - .pickerStyle(.menu) } header: { - Text("Vehicle", - comment: "Maintenance event vehicle picker header") + Text("Vehicle") } DatePicker(selection: $date, displayedComponents: .date) { @@ -67,11 +61,11 @@ struct EditMaintenanceEventView: View { ToolbarItem(placement: .topBarTrailing) { Button { - if let selectedVehicle, let selectedEvent { - var event = MaintenanceEvent(title: title, + if let selectedEvent { + var event = MaintenanceEvent(vehicleID: selectedEvent.vehicleID, + title: title, date: date, - notes: notes, - vehicle: selectedVehicle) + notes: notes) event.id = selectedEvent.id Task { await viewModel.updateEvent(event) @@ -91,15 +85,18 @@ struct EditMaintenanceEventView: View { self.title = event.title self.date = event.date self.notes = event.notes - self.selectedVehicle = event.vehicle } } #Preview { EditMaintenanceEventView(selectedEvent: - .constant(MaintenanceEvent(title: "", date: Date(), notes: "", - vehicle: Vehicle(name: "", make: "", model: ""))), + .constant(MaintenanceEvent(id: "", + userID: "", + vehicleID: "", + title: "", + date: Date(), + notes: "")), viewModel: - DashboardViewModel(authenticationViewModel: AuthenticationViewModel()) + DashboardViewModel(userUID: "") ) } diff --git a/Basic-Car-Maintenance/Shared/Localizable.xcstrings b/Basic-Car-Maintenance/Shared/Localizable.xcstrings index 0a687355..39329942 100644 --- a/Basic-Car-Maintenance/Shared/Localizable.xcstrings +++ b/Basic-Car-Maintenance/Shared/Localizable.xcstrings @@ -4407,6 +4407,9 @@ } } } + }, + "Update Vehicle Info" : { + }, "Vehicle" : { "comment" : "Maintenance event vehicle picker header", diff --git a/Basic-Car-Maintenance/Shared/MainView/Views/MainTabView.swift b/Basic-Car-Maintenance/Shared/MainView/Views/MainTabView.swift index e58e6726..9194f3bb 100644 --- a/Basic-Car-Maintenance/Shared/MainView/Views/MainTabView.swift +++ b/Basic-Car-Maintenance/Shared/MainView/Views/MainTabView.swift @@ -29,13 +29,13 @@ struct MainTabView: View { var body: some View { TabView(selection: $selectedTab) { - DashboardView(authenticationViewModel: authenticationViewModel) + DashboardView(userUID: authenticationViewModel.user?.uid) .tag(TabSelection.dashboard) .tabItem { Label("Dashboard", systemImage: SFSymbol.dashboard) } - OdometerView(authenticationViewModel: authenticationViewModel) + OdometerView(userUID: authenticationViewModel.user?.uid) .tag(TabSelection.odometer) .tabItem { Label("Odometer", systemImage: SFSymbol.gauge) diff --git a/Basic-Car-Maintenance/Shared/Models/MaintenanceEvent.swift b/Basic-Car-Maintenance/Shared/Models/MaintenanceEvent.swift index 0ab87982..c2a2e1c7 100644 --- a/Basic-Car-Maintenance/Shared/Models/MaintenanceEvent.swift +++ b/Basic-Car-Maintenance/Shared/Models/MaintenanceEvent.swift @@ -11,8 +11,9 @@ import Foundation struct MaintenanceEvent: Codable, Identifiable, Hashable { @DocumentID var id: String? var userID: String? + + let vehicleID: String let title: String let date: Date let notes: String - var vehicle: Vehicle } diff --git a/Basic-Car-Maintenance/Shared/Models/OdometerReading.swift b/Basic-Car-Maintenance/Shared/Models/OdometerReading.swift index f312f555..611551eb 100644 --- a/Basic-Car-Maintenance/Shared/Models/OdometerReading.swift +++ b/Basic-Car-Maintenance/Shared/Models/OdometerReading.swift @@ -14,5 +14,5 @@ struct OdometerReading: Codable, Identifiable, Hashable { let date: Date let distance: Int let isMetric: Bool - let vehicle: Vehicle + let vehicleID: String } diff --git a/Basic-Car-Maintenance/Shared/Odometer/ViewModels/OdometerViewModel.swift b/Basic-Car-Maintenance/Shared/Odometer/ViewModels/OdometerViewModel.swift index 95a4440d..1cc732b1 100644 --- a/Basic-Car-Maintenance/Shared/Odometer/ViewModels/OdometerViewModel.swift +++ b/Basic-Car-Maintenance/Shared/Odometer/ViewModels/OdometerViewModel.swift @@ -12,7 +12,7 @@ import Foundation @Observable class OdometerViewModel { - let authenticationViewModel: AuthenticationViewModel + let userUID: String? var readings = [OdometerReading]() var showAddErrorAlert = false @@ -20,18 +20,18 @@ class OdometerViewModel { var errorMessage: String = "" var vehicles = [Vehicle]() - init(authenticationViewModel: AuthenticationViewModel) { - self.authenticationViewModel = authenticationViewModel + init(userUID: String?) { + self.userUID = userUID } func addReading(_ odometerReading: OdometerReading) throws { - if let uid = authenticationViewModel.user?.uid { + if let uid = userUID { var readingToAdd = odometerReading readingToAdd.userID = uid - _ = try Firestore + try Firestore .firestore() - .collection("odometer_readings") + .collection(FirestorePath.odometerReadings(vehicleID: readingToAdd.vehicleID).path) .addDocument(from: readingToAdd) AnalyticsService.shared.logEvent(.odometerCreate) @@ -45,7 +45,7 @@ class OdometerViewModel { try? await Firestore .firestore() - .collection(FirestoreCollection.odometerReadings) + .collection(FirestorePath.odometerReadings(vehicleID: reading.vehicleID).path) .document(documentId) .delete() @@ -57,10 +57,10 @@ class OdometerViewModel { } func getOdometerReadings() async { - if let uid = authenticationViewModel.user?.uid { + if let userUID = userUID { let db = Firestore.firestore() - let docRef = db.collection(FirestoreCollection.odometerReadings) - .whereField(FirestoreField.userID, isEqualTo: uid) + let docRef = db.collectionGroup(FirestoreCollection.odometerReadings) + .whereField(FirestoreField.userID, isEqualTo: userUID) let querySnapshot = try? await docRef.getDocuments() @@ -78,7 +78,7 @@ class OdometerViewModel { } func getVehicles() async { - if let uid = authenticationViewModel.user?.uid { + if let uid = userUID { let db = Firestore.firestore() let docRef = db.collection(FirestoreCollection.vehicles) .whereField(FirestoreField.userID, isEqualTo: uid) diff --git a/Basic-Car-Maintenance/Shared/Odometer/Views/AddOdometerReadingView.swift b/Basic-Car-Maintenance/Shared/Odometer/Views/AddOdometerReadingView.swift index dc628332..5a55900e 100644 --- a/Basic-Car-Maintenance/Shared/Odometer/Views/AddOdometerReadingView.swift +++ b/Basic-Car-Maintenance/Shared/Odometer/Views/AddOdometerReadingView.swift @@ -68,12 +68,11 @@ struct AddOdometerReadingView: View { .toolbar { ToolbarItem { Button { - let selectedVehicle = vehicles.first { $0.id == selectedVehicleID } - if let selectedVehicle { + if let selectedVehicleID { let reading = OdometerReading(date: date, distance: distance, isMetric: isMetric, - vehicle: selectedVehicle) + vehicleID: selectedVehicleID) addTapped(reading) } } label: { diff --git a/Basic-Car-Maintenance/Shared/Odometer/Views/OdometerView.swift b/Basic-Car-Maintenance/Shared/Odometer/Views/OdometerView.swift index 790d39ed..1b9390f9 100644 --- a/Basic-Car-Maintenance/Shared/Odometer/Views/OdometerView.swift +++ b/Basic-Car-Maintenance/Shared/Odometer/Views/OdometerView.swift @@ -12,8 +12,8 @@ struct OdometerView: View { @State private var viewModel: OdometerViewModel - init(authenticationViewModel: AuthenticationViewModel) { - viewModel = OdometerViewModel(authenticationViewModel: authenticationViewModel) + init(userUID: String?) { + viewModel = OdometerViewModel(userUID: userUID) } var body: some View { @@ -24,7 +24,10 @@ struct OdometerView: View { Text("\(reading.distance) \(reading.isMetric ? "km" : "mi")") .font(.title3) - Text("For \(reading.vehicle.name)") + let vehicleName = viewModel.vehicles.first { $0.id == reading.vehicleID }?.name + if let vehicleName { + Text("For \(vehicleName)") + } Text("\(reading.date.formatted(date: .abbreviated, time: .omitted))") } @@ -91,6 +94,6 @@ struct OdometerView: View { } #Preview { - OdometerView(authenticationViewModel: AuthenticationViewModel()) + OdometerView(userUID: "") .environment(ActionService.shared) } diff --git a/Basic-Car-Maintenance/Shared/Settings/ViewModels/AuthenticationViewModel.swift b/Basic-Car-Maintenance/Shared/Settings/ViewModels/AuthenticationViewModel.swift index 49af0040..831f506f 100644 --- a/Basic-Car-Maintenance/Shared/Settings/ViewModels/AuthenticationViewModel.swift +++ b/Basic-Car-Maintenance/Shared/Settings/ViewModels/AuthenticationViewModel.swift @@ -122,11 +122,11 @@ extension AuthenticationViewModel { fatalError("Invalid state: a login callback was received, but no login request was sent.") } guard let appleIDToken = appleIDCredential.identityToken else { - print("Unable to fetdch identify token.") + print("Unable to fetch identify token.") return } guard let idTokenString = String(data: appleIDToken, encoding: .utf8) else { - print("Unable to serialise token string from data: \(appleIDToken.debugDescription)") + print("Unable to serialize token string from data: \(appleIDToken.debugDescription)") return } @@ -170,47 +170,48 @@ extension AuthenticationViewModel { } } } -} - -// Adapted from https://auth0.com/docs/api-auth/tutorials/nonce#generate-a-cryptographically-random-nonce, from Firebase example // swiftlint:disable:this line_length -private func randomNonceString(length: Int = 32) -> String { - precondition(length > 0) - let charset: [Character] = - Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._") - var result = "" - var remainingLength = length - while remainingLength > 0 { - let randoms: [UInt8] = (0 ..< 16).map { _ in - var random: UInt8 = 0 - let errorCode = SecRandomCopyBytes(kSecRandomDefault, 1, &random) - if errorCode != errSecSuccess { - fatalError("Unable to generate nonce. SecRandomCopyBytes failed with OSStatus \(errorCode)") - } - return random - } + // Adapted from https://auth0.com/docs/api-auth/tutorials/nonce#generate-a-cryptographically-random-nonce, from Firebase example // swiftlint:disable:this line_length + private func randomNonceString(length: Int = 32) -> String { + precondition(length > 0) + let charset: [Character] = + Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._") + var result = "" + var remainingLength = length - randoms.forEach { random in - if remainingLength == 0 { - return + while remainingLength > 0 { + let randoms: [UInt8] = (0 ..< 16).map { _ in + var random: UInt8 = 0 + let errorCode = SecRandomCopyBytes(kSecRandomDefault, 1, &random) + if errorCode != errSecSuccess { + fatalError("Unable to generate nonce. SecRandomCopyBytes failed with OSStatus \(errorCode)") // swiftlint:disable:this line_length + } + return random } - if random < charset.count { - result.append(charset[Int(random)]) - remainingLength -= 1 + randoms.forEach { random in + if remainingLength == 0 { + return + } + + if random < charset.count { + result.append(charset[Int(random)]) + remainingLength -= 1 + } } } + + return result + } + + private func sha256(_ input: String) -> String { + let inputData = Data(input.utf8) + let hashedData = SHA256.hash(data: inputData) + let hashString = hashedData.compactMap { + String(format: "%02x", $0) + }.joined() + + return hashString } - - return result -} -private func sha256(_ input: String) -> String { - let inputData = Data(input.utf8) - let hashedData = SHA256.hash(data: inputData) - let hashString = hashedData.compactMap { - String(format: "%02x", $0) - }.joined() - - return hashString } diff --git a/Basic-Car-Maintenance/Shared/Settings/ViewModels/SettingsViewModel.swift b/Basic-Car-Maintenance/Shared/Settings/ViewModels/SettingsViewModel.swift index 46ed2ae5..99eac66d 100644 --- a/Basic-Car-Maintenance/Shared/Settings/ViewModels/SettingsViewModel.swift +++ b/Basic-Car-Maintenance/Shared/Settings/ViewModels/SettingsViewModel.swift @@ -12,11 +12,12 @@ import Foundation @Observable final class SettingsViewModel { let authenticationViewModel: AuthenticationViewModel - let privacyURL = URL(string: "https://github.com/mikaelacaron/Basic-Car-Maintenance-Privacy") var contributors: [Contributor]? var vehicles = [Vehicle]() + var errorMessage: String = "" + var showErrorAlert = false var sortedContributors: [Contributor] { guard let contributors = contributors, !contributors.isEmpty else { @@ -39,20 +40,10 @@ final class SettingsViewModel { self.authenticationViewModel = authenticationViewModel } - let urls: [String: URL] = [ - "mikaelacaronProfile": URL(string: "https://github.com/mikaelacaron")!, - "Basic-Car-MaintenanceRepo": URL(string: "https://github.com/mikaelacaron/Basic-Car-Maintenance")!, - "bugReport": URL(string: "https://github.com/mikaelacaron/Basic-Car-Maintenance/issues")! - ] - // swiftlint:disable:next line_length /// Fetches the list of contributors for the GitHub repository [Basic-Car-Maintenance](https://github.com/mikaelacaron/Basic-Car-Maintenance). func getContributors() async { - guard let url = - URL(string: "https://api.github.com/repos/mikaelacaron/Basic-Car-Maintenance/contributors") - else { - return - } + let url = GitHubURL.apiContributors do { let (data, _) = try await URLSession.shared.data(from: url) @@ -88,6 +79,29 @@ 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) + .setData(from: vehicleToUpdate) + + AnalyticsService.shared.logEvent(.vehicleUpdate) + + await getVehicles() + } catch { + errorMessage = error.localizedDescription + showErrorAlert = true + } + } + } + /// Fetches the user's vehicles from Firestore based on their unique user ID. func getVehicles() async { if let uid = authenticationViewModel.user?.uid { @@ -110,27 +124,27 @@ final class SettingsViewModel { } } } - + /// Deletes a vehicle from both Firestore and the local ``SettingsViewModel/vehicles`` array. /// /// - Parameter vehicle: The vehicle to be deleted. /// - Throws: An error if there's an issue deleting the vehicle from Firestore. func deleteVehicle(_ vehicle: Vehicle) async throws { - guard let documentId = vehicle.id else { - fatalError("Event \(vehicle.name) has no document ID.") - } - - do { - try await Firestore - .firestore() - .collection(FirestoreCollection.vehicles) - .document(documentId) - .delete() - - vehicles.removeAll { $0.id == vehicle.id } - } catch { - throw error - } +// guard let documentId = vehicle.id else { +// fatalError("Event \(vehicle.name) has no document ID.") +// } +// +// do { +// try await Firestore +// .firestore() +// .collection(FirestoreCollection.vehicles) +// .document(documentId) +// .delete() +// +// vehicles.removeAll { $0.id == vehicle.id } +// } catch { +// throw error +// } AnalyticsService.shared.logEvent(.vehicleDelete) } diff --git a/Basic-Car-Maintenance/Shared/Settings/Views/ContributorsListView.swift b/Basic-Car-Maintenance/Shared/Settings/Views/ContributorsListView.swift index 0e9aae2c..08134cb7 100644 --- a/Basic-Car-Maintenance/Shared/Settings/Views/ContributorsListView.swift +++ b/Basic-Car-Maintenance/Shared/Settings/Views/ContributorsListView.swift @@ -16,7 +16,7 @@ struct ContributorsListView: View { ForEach(viewModel.sortedContributors) { contributor in Link( destination: URL(string: contributor.htmlURL) ?? - viewModel.urls["Basic-Car-Maintenance"]!) { + GitHubURL.repo) { ContributorsProfileView(name: contributor.login, url: contributor.avatarURL) } } diff --git a/Basic-Car-Maintenance/Shared/Settings/Views/EditVehicleView.swift b/Basic-Car-Maintenance/Shared/Settings/Views/EditVehicleView.swift new file mode 100644 index 00000000..2e3e815d --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Settings/Views/EditVehicleView.swift @@ -0,0 +1,126 @@ +// +// EditVehicleView.swift +// Basic-Car-Maintenance +// +// Created by Traton Gossink on 11/6/23. +// + +import SwiftUI + +struct EditVehicleView: View, Observable { + @Binding var selectedVehicle: Vehicle? + var viewModel: SettingsViewModel + + @Environment(\.dismiss) var dismiss + + @State private var name = "" + @State private var make = "" + @State private var model = "" + @State private var year = "" + @State private var color = "" + @State private var VIN = "" + @State private var licensePlateNumber = "" + + private var isVehicleValid: Bool { + !name.isEmpty && !make.isEmpty && !model.isEmpty + } + + var body: some View { + NavigationStack { + Form { + Section { + TextField("Name", text: $name) + } header: { + Text("Name") + } + + Section { + TextField("Make", text: $make) + } header: { + Text("Make") + } + + Section { + TextField("Model", text: $model) + } header: { + Text("Model") + } + + Section { + TextField("Year", text: $year) + } header: { + Text("Year") + } + + Section { + TextField("Color", text: $color) + } header: { + Text("Color") + } + Section { + TextField("VIN", text: $VIN) + } header: { + Text("VIN") + } + Section { + TextField("License Plate Number", text: $licensePlateNumber) + } header: { + Text("License Plate Number") + } + } + .analyticsView("\(Self.self)") + .onAppear { + guard let selectedVehicle else { return } + setEditVehicleValues(selectedVehicle) + } + .navigationTitle("Update Vehicle Info") + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + dismiss() + } label: { + Text("Cancel") + } + } + + ToolbarItem(placement: .topBarTrailing) { + Button { + if let selectedVehicle { + var vehicle = Vehicle( + name: name, + make: make, + model: model, + year: year, + color: color, + vin: VIN, + licensePlateNumber: licensePlateNumber) + vehicle.id = selectedVehicle.id + Task { + await viewModel.updateVehicle(vehicle) + dismiss() + } + } + } label: { + Text("Update") + } + .disabled(!isVehicleValid) + } + } + } + } + + func setEditVehicleValues(_ vehicle: Vehicle) { + self.name = vehicle.name + self.make = vehicle.make + self.model = vehicle.model + self.year = vehicle.year ?? "" + self.color = vehicle.color ?? "" + self.VIN = vehicle.vin ?? "" + self.licensePlateNumber = vehicle.licensePlateNumber ?? "" + } +} + +#Preview { + EditVehicleView(selectedVehicle: .constant(nil), + viewModel: SettingsViewModel(authenticationViewModel: AuthenticationViewModel())) +} diff --git a/Basic-Car-Maintenance/Shared/Settings/Views/SettingsView.swift b/Basic-Car-Maintenance/Shared/Settings/Views/SettingsView.swift index 5f933bdc..c2b44555 100644 --- a/Basic-Car-Maintenance/Shared/Settings/Views/SettingsView.swift +++ b/Basic-Car-Maintenance/Shared/Settings/Views/SettingsView.swift @@ -23,6 +23,9 @@ struct SettingsView: View { @State private var errorDetails: Error? @State private var copiedAppVersion: Bool = false + @State private var selectedVehicle: Vehicle? + @State private var isShowingEditVehicleView = false + private let appVersion = "Version \(Bundle.main.versionNumber) (\(Bundle.main.buildNumber))" init(authenticationViewModel: AuthenticationViewModel) { @@ -37,7 +40,7 @@ struct SettingsView: View { // swiftlint:disable:next line_length Text("Thanks for using this app! It's open source and anyone can contribute to it.", comment: "Thanks a user for using the app and tells the user they can contribute to the codebase") - Link(destination: URL(string: "https://github.com/mikaelacaron/Basic-Car-Maintenance")!) { + Link(destination: GitHubURL.repo) { Label { Text("GitHub Repo", comment: "Link to the Basic Car Maintenance GitHub repo.") } icon: { @@ -48,12 +51,11 @@ struct SettingsView: View { } .popoverTip(ContributionTip(), arrowEdge: .bottom) - Link(destination: URL(string: "https://github.com/mikaelacaron")!) { + Link(destination: GitHubURL.mikaelaCaronProfile) { Text("🦄 Mikaela Caron - Maintainer", comment: "Link to maintainer Github account.") } - // swiftlint:disable:next line_length - Link(destination: URL(string: "https://github.com/mikaelacaron/Basic-Car-Maintenance/issues/new?assignees=&labels=feature+request&projects=&template=feature-request.md&title=FEATURE+-")!) { + Link(destination: GitHubURL.featureRequest) { Label { Text("Request a New Feature", comment: "Link to request a new feature.") } icon: { @@ -62,8 +64,8 @@ struct SettingsView: View { .frame(width: iconDimension, height: iconDimension) } } - // swiftlint:disable:next line_length - Link(destination: URL(string: "https://github.com/mikaelacaron/Basic-Car-Maintenance/issues/new?assignees=&labels=bug&projects=&template=bug-report.md&title=BUG+-")!) { + + Link(destination: GitHubURL.bugReport) { Label { Text("Report a Bug", comment: "Link to report a bug") } icon: { @@ -93,7 +95,7 @@ struct SettingsView: View { Text(vehicle.make) Text(vehicle.model) - + if let year = vehicle.year, !year.isEmpty { Text(year) } @@ -124,9 +126,20 @@ struct SettingsView: View { } label: { Text("Delete", comment: "Label to delete a vehicle") } + + Button { + selectedVehicle = vehicle + isShowingEditVehicleView = true + } label: { + Label { + Text("Edit") + } icon: { + Image(systemName: SFSymbol.pencil) + } + } } } - + Button { // TODO: Show Paywall // Show paywall if adding more than 1 vehicle, or show the `isShowingAddVehicle` view @@ -156,9 +169,7 @@ struct SettingsView: View { } } - if let privacyURL = viewModel.privacyURL, !privacyURL.absoluteString.isEmpty { - Link("Privacy Policy", destination: privacyURL) - } + Link("Privacy Policy", destination: GitHubURL.privacy) Text(LocalizedStringKey(appVersion), comment: "Label to display version and build number.") @@ -209,6 +220,9 @@ struct SettingsView: View { } } } + .sheet(isPresented: $isShowingEditVehicleView) { + EditVehicleView(selectedVehicle: $selectedVehicle, viewModel: viewModel) + } // swiftlint:disable:next line_length .alert(Text("Failed To Delete Vehicle", comment: "Label to dsplay title of the delete vehicle alert"), isPresented: $showDeleteVehicleError) { diff --git a/Basic-Car-Maintenance/Shared/Utilities/Constants.swift b/Basic-Car-Maintenance/Shared/Utilities/Constants.swift index 21f0b72a..6d9024e2 100644 --- a/Basic-Car-Maintenance/Shared/Utilities/Constants.swift +++ b/Basic-Car-Maintenance/Shared/Utilities/Constants.swift @@ -7,6 +7,26 @@ import Foundation +// swiftlint:disable line_length + +enum FirestorePath { + + /// `vehicles/{ vehicleDocumentID }/maintenance_events/{ maintenceEventDocumentID }` + case maintenanceEvents(vehicleID: String) + + /// `vehicles/{ vehicleDocumentID }/odometer_readings/{ maintenceEventDocumentID }` + case odometerReadings(vehicleID: String) + + var path: String { + switch self { + case let .maintenanceEvents(vehicleID): + return "\(FirestoreCollection.vehicles)/" + "\(vehicleID)/" + FirestoreCollection.maintenanceEvents + case let .odometerReadings(vehicleID): + return "\(FirestoreCollection.vehicles)/" + "\(vehicleID)/" + FirestoreCollection.odometerReadings + } + } +} + enum FirestoreCollection { static let maintenanceEvents = "maintenance_events" static let vehicles = "vehicles" @@ -20,6 +40,20 @@ enum FirestoreField { static let id = "_id" } +enum GitHubURL { + static let mikaelaCaronProfile = URL(string: "https://github.com/mikaelacaron")! + + static let repo = URL(string: "https://github.com/mikaelacaron/Basic-Car-Maintenance")! + + static let apiContributors = URL(string: "https://api.github.com/repos/mikaelacaron/Basic-Car-Maintenance/contributors")! + + static let featureRequest = URL(string: "https://github.com/mikaelacaron/Basic-Car-Maintenance/issues/new?assignees=&labels=feature+request&projects=&template=feature-request.md&title=FEATURE+-")! + + static let bugReport = URL(string: "https://github.com/mikaelacaron/Basic-Car-Maintenance/issues/new?assignees=&labels=bug&projects=&template=bug-report.md&title=BUG+-")! + + static let privacy = URL(string: "https://github.com/mikaelacaron/Basic-Car-Maintenance-Privacy")! +} + enum SFSymbol { // MainTabView static let dashboard = "list.dash.header.rectangle" @@ -50,3 +84,5 @@ enum SFSymbol { static let personCircle = "person.circle.fill" } + +// swiftlint:enable line_length