diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..243c2a14 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://editorconfig.org +[*.swift] + +indent_style = space +tab_width = 4 +indent_size = 4 + +end_of_line = lf +insert_final_newline = true + +max_line_length = 160 +trim_trailing_whitespace = false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/improve.md b/.github/ISSUE_TEMPLATE/improve.md index b5674c44..185e9f3e 100644 --- a/.github/ISSUE_TEMPLATE/improve.md +++ b/.github/ISSUE_TEMPLATE/improve.md @@ -2,7 +2,7 @@ name: Improve a Feature about: Create a report to help us improve an existing feature title: IMPROVE - -labels: improve +labels: improvement assignees: '' --- diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 71131c53..ec460c7c 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,5 +1,5 @@ # What it Does -* Closes issue # +* Closes #issueNumber * Describe what your change does # How I Tested diff --git a/.github/workflows/docc.yml b/.github/workflows/docc.yml new file mode 100644 index 00000000..5896c83b --- /dev/null +++ b/.github/workflows/docc.yml @@ -0,0 +1,46 @@ +name: Deploy Documentation +on: + push: + branches: [main, dev] + workflow_dispatch: +permissions: + contents: read + pages: write + id-token: write +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + build: + runs-on: macos-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + - name: Setup Config file + run: | + cp Basic-Car-Maintenance.xcconfig.template Basic-Car-Maintenance.xcconfig + - name: Run Build Docs + run: ./build-docc.sh + - name: Setup Pages + id: pages + uses: actions/configure-pages@v5 + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: .docs + deploy: + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} diff --git a/.github/workflows/issue-metrics.yml b/.github/workflows/issue-metrics.yml new file mode 100644 index 00000000..05a8d438 --- /dev/null +++ b/.github/workflows/issue-metrics.yml @@ -0,0 +1,57 @@ +name: Weekly issue metrics +on: + workflow_dispatch: + +permissions: + issues: write + pull-requests: read + +jobs: + build: + name: issue metrics + runs-on: ubuntu-latest + steps: + - name: Get dates for last week + shell: bash + run: | + ########################################################## + # Create report for the previous week + ########################################################## + # Get the current date + current_date=$(date +'%Y-%m-%d') + # Calculate the start of the previous week (last Sunday) + start_of_week=$(date -d "last-sunday - 1 week" +'%Y-%m-%d') + # Calculate the end of the previous week (last Saturday) + end_of_week=$(date -d "last-saturday" +'%Y-%m-%d') + + echo "$start_of_week..$end_of_week" + echo "prev_week=$start_of_week..$end_of_week" >> "$GITHUB_ENV" + + - name: Run issue-metrics tool for issues and PRs opened last week + uses: github-community-projects/issue-metrics@v3 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SEARCH_QUERY: 'mikaelacaron/Basic-Car-Maintenance created:${{ env.prev_week }}' + + - name: Create issue for opened issues and PRs last week + uses: peter-evans/create-issue-from-file@v5 + with: + title: Weekly issue metrics report for opened issues and PRs + token: ${{ secrets.GITHUB_TOKEN }} + content-filepath: ./issue_metrics.md + assignees: mikaelacaron + labels: weekly-report + + - name: Run issue-metrics tool for issues and PRs closed last week + uses: github-community-projects/issue-metrics@v3 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SEARCH_QUERY: 'mikaelacaron/Basic-Car-Maintenance closed:${{ env.prev_week }}' + + - name: Create issue for closed issues and prs + uses: peter-evans/create-issue-from-file@v5 + with: + title: Weekly issue metrics report for closed issues and prs + content-filepath: ./issue_metrics.md + assignees: mikaelacaron + labels: weekly-report diff --git a/.github/workflows/swiftlint.yml b/.github/workflows/swiftlint.yml new file mode 100644 index 00000000..b4ad3788 --- /dev/null +++ b/.github/workflows/swiftlint.yml @@ -0,0 +1,21 @@ +name: SwiftLint +on: + workflow_dispatch: + pull_request: + branches: + - main + - dev + push: + branches: + - main + - dev +jobs: + SwiftLint: + runs-on: macos-13 + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: SwiftLint + # To make SwiftLint treat all warnings as errors, add the --strict flag: + # run: swiftlint --strict --quiet --reporter github-actions-logging + run: swiftlint --quiet --reporter github-actions-logging diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 00000000..adfcf4fe --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,47 @@ +name: Unit Tests + +on: + workflow_dispatch: + pull_request: + branches: + - dev + push: + branches: + - main + - dev + +# Limit only the latest workflow created to run +concurrency: + group: build + cancel-in-progress: true + +jobs: + build: + runs-on: macos-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Prepare xcconfig + run: cp Basic-Car-Maintenance.xcconfig.template Basic-Car-Maintenance.xcconfig + + - name: Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Install packages + run: xcodebuild -resolvePackageDependencies + + - name: Enable Build Tools + run: cp fastlane/enable-build-tool-plugins.json ~/Library/org.swift.swiftpm/security/plugins.json + + - name: Install dependencies + run: bundle install + + - name: Install build log formatter + run: brew install xcbeautify + + - name: Run Unit Tests + run: FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT=60 bundle exec fastlane unit_tests diff --git a/.gitignore b/.gitignore index bf48fd85..b5b1fd05 100644 --- a/.gitignore +++ b/.gitignore @@ -111,6 +111,8 @@ fastlane/report.xml fastlane/Preview.html fastlane/screenshots/**/*.png fastlane/test_output +fastlane/UnitTestsReport +fastlane/CodeCoverageReport # Code Injection # After new code Injection tools there's a generated folder /iOSInjectionProject diff --git a/.swiftlint.yml b/.swiftlint.yml index 1e986c42..f9e43d30 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,14 +1,57 @@ # By default, SwiftLint uses a set of sensible default rules you can adjust: disabled_rules: # rule identifiers turned on by default to exclude from running + - trailing_whitespace + - empty_parentheses_with_trailing_closure +opt_in_rules: # some rules are turned off by default, so you need to opt-in + - closure_parameter_position + - closure_spacing + - collection_alignment - colon - comma + - compiler_protocol_init + - contains_over_filter_count + - contains_over_filter_is_empty + - contains_over_first_not_nil + - contains_over_range_nil_comparison - control_statement - - type_name - - trailing_whitespace - - identifier_name + - convenience_type + - cyclomatic_complexity + - duplicate_imports + - dynamic_inline + - empty_collection_literal + - empty_count + - empty_enum_arguments + - empty_parameters - empty_parentheses_with_trailing_closure -opt_in_rules: # some rules are turned off by default, so you need to opt-in - - empty_count # Find all the available rules by running: `swiftlint rules` + - empty_xctest_method + - explicit_init + - for_where + - force_try + - leading_whitespace + - local_doc_comment + - lower_acl_than_parent + - nslocalizedstring_key + - opening_brace + - operator_usage_whitespace + - operator_whitespace + - overridden_super_call + - period_spacing + - private_over_fileprivate + - private_swiftui_state + - syntactic_sugar + - test_case_accessibility + - trailing_comma + - trailing_newline + - trailing_semicolon + - unavailable_function + - unused_closure_parameter + - unused_control_flow_label + - unused_enumerated + - unused_optional_binding + - unused_setter_value + - weak_delegate + - xctfail_message + - yoda_condition # Alternatively, specify all rules explicitly by uncommenting this option: # only_rules: # delete `disabled_rules` & `opt_in_rules` if using this @@ -21,7 +64,7 @@ excluded: # paths to ignore during linting. Takes precedence over `included`. - Pods - Source/ExcludedFolder - Source/ExcludedFile.swift - - Source/*/ExcludedFile.swift # Exclude files with a wildcard + - .derivedData # Exclude files with a wildcard analyzer_rules: # Rules run by `swiftlint analyze` (experimental) - explicit_self @@ -31,6 +74,11 @@ force_cast: warning # implicitly force_try: severity: warning # explicitly # rules that have both warning and error levels, can set just the warning level +identifier_name: + excluded: + - id + - db + - URL # implicitly line_length: 110 # they can set both implicitly with an array diff --git a/Basic-Car-Maintenance-Tests/Shared/Models/ContributorTests.swift b/Basic-Car-Maintenance-Tests/Shared/Models/ContributorTests.swift new file mode 100644 index 00000000..80ca1a25 --- /dev/null +++ b/Basic-Car-Maintenance-Tests/Shared/Models/ContributorTests.swift @@ -0,0 +1,70 @@ +// +// ContributorTests.swift +// Basic-Car-Maintenance-Tests +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import Foundation +import Testing +@testable import Basic_Car_Maintenance + +struct ContributorTests { + private let jsonDecoder = { + let jsonDecoder = JSONDecoder() + jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase + return jsonDecoder + }() + + private let jsonEncoder = { + let jsonEncoder = JSONEncoder() + jsonEncoder.keyEncodingStrategy = .convertToSnakeCase + return jsonEncoder + }() + + @Test() + func contributorDecoding() throws { + let testContributorJson = """ + { + "login": "Drag0ndust", + "id": 12915108, + "node_id": "MDQ6VXNlcjEyOTE1MTA4", + "avatar_url": "https://avatars.githubusercontent.com/u/12915108?v=4", + "url": "https://api.github.com/users/Drag0ndust", + "html_url": "https://github.com/Drag0ndust", + "contributions": 0 + } +""" + let data = try #require(testContributorJson.data(using: .utf8)) + let sut = try jsonDecoder.decode(Contributor.self, from: data) + + #expect(sut.login == "Drag0ndust") + #expect(sut.id == 12915108) + #expect(sut.nodeID == "MDQ6VXNlcjEyOTE1MTA4") + #expect(sut.avatarURL == "https://avatars.githubusercontent.com/u/12915108?v=4") + #expect(sut.url == "https://api.github.com/users/Drag0ndust") + #expect(sut.htmlURL == "https://github.com/Drag0ndust") + #expect(sut.contributions == 0) + } + + @Test() + func contributorEncoding() throws { + let contributor = Contributor( + login: "Drag0ndust", + id: 12915108, + nodeID: "MDQ6VXNlcjEyOTE1MTA4", + avatarURL: "https://avatars.githubusercontent.com/u/12915108?v=4", + url: "https://api.github.com/users/Drag0ndust", + htmlURL: "https://github.com/Drag0ndust", + contributions: 0 + ) + + let contributorData = try jsonEncoder.encode(contributor) + + // now decode it again and check if the objects are equal + let decodedContributor = try jsonDecoder.decode(Contributor.self, from: contributorData) + + #expect(contributor == decodedContributor) + } +} diff --git a/Basic-Car-Maintenance-Tests/Shared/Odometer/OdometerViewModelTests.swift b/Basic-Car-Maintenance-Tests/Shared/Odometer/OdometerViewModelTests.swift new file mode 100644 index 00000000..816d95fa --- /dev/null +++ b/Basic-Car-Maintenance-Tests/Shared/Odometer/OdometerViewModelTests.swift @@ -0,0 +1,121 @@ +// +// OdometerViewModelTests.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import Firebase +import FirebaseAuth +import FirebaseFirestore +import Foundation +import Testing +@testable import Basic_Car_Maintenance + +@Suite(.disabled("Requires Firebase emulator running. Re-enable once CI is configured to run the emulator.")) +class OdometerViewModelTests { + let userUID: String + let viewModel: OdometerViewModel + let newReading: OdometerReading + var dbReading: OdometerReading? + + init() { + UserDefaults.standard.set(true, forKey: "useEmulator") + + self.userUID = UUID().uuidString + self.viewModel = OdometerViewModel(userUID: userUID, firebaseService: FirebaseService()) + self.newReading = OdometerReading(userID: userUID, + date: Date.now, + distance: 138542, + isMetric: true, + vehicleID: "LV0000") + } + + // helper functions + private func dbContainsReading(reading: OdometerReading) async -> Bool { + let docRef = Firestore.firestore().collectionGroup(FirestoreCollection.odometerReadings) + .whereField(FirestoreField.userID, isEqualTo: userUID) + + let querySnapshot = try? await docRef.getDocuments() + + if let querySnapshot { + for document in querySnapshot.documents { + if let reading = try? document.data(as: OdometerReading.self) { + dbReading = reading + return dbReading?.userID == newReading.userID && + dbReading?.vehicleID == newReading.vehicleID && + dbReading?.distance == newReading.distance + } + } + } + + return false + } + + // tests + @Test func viewModelInitialState() throws { + #expect(viewModel.userUID == userUID) + #expect(viewModel.readings.isEmpty) + #expect(viewModel.vehicles.isEmpty) + #expect(viewModel.errorMessage.isEmpty) + #expect(viewModel.showAddErrorAlert == false) + #expect(viewModel.isShowingAddOdometerReading == false) + #expect(viewModel.showEditErrorAlert == false) + #expect(viewModel.isShowingEditReadingView == false) + } + + @Test func addReading() async { + try? viewModel.addReading(newReading); + let dbContainsNewReading = await dbContainsReading(reading: newReading) + + #expect(dbContainsNewReading == true, "New reading should be added to database") + } + + @Test func deleteReading() async { + try? viewModel.addReading(newReading); + let dbContainsNewReading = await dbContainsReading(reading: newReading) + + if dbContainsNewReading, let dbReading { + await viewModel.firebaseService.deleteReading(dbReading); + let dbContainsDeletedReading = await dbContainsReading(reading: newReading) + + #expect(dbContainsDeletedReading == false, "New reading should be removed from database") + } + } + + @Test func updateOdometerReading() async { + try? viewModel.addReading(newReading); + let dbContainsNewReading = await dbContainsReading(reading: newReading) + + if let dbReading { + let updatedReading = OdometerReading(id: dbReading.id, + userID: userUID, + date: Date.now, + distance: 138542, + isMetric: true, + vehicleID: "LV0000") + + viewModel.updateOdometerReading(updatedReading) + let dbContainsUpdatedReading = await dbContainsReading(reading: updatedReading); + + #expect(dbContainsUpdatedReading == true, "Database reading should reflect updates") + } + } + + @Test func getReadings() async { + await viewModel.getOdometerReadings() + + let dbReadings = await viewModel.firebaseService.getReadings(userUID: userUID) + + #expect(viewModel.readings == dbReadings, "View model vehicles should match database readings") + } + + @Test func getVehicles() async { + await viewModel.getVehicles() + + let dbVehicles = await viewModel.firebaseService.getVehicles(userUID: userUID) + + #expect(viewModel.vehicles == dbVehicles, "View model vehicles should match database readings") + } +} diff --git a/Basic-Car-Maintenance-UITests/BasicCarMaintenanceUITests.swift b/Basic-Car-Maintenance-UITests/BasicCarMaintenanceUITests.swift new file mode 100644 index 00000000..49045609 --- /dev/null +++ b/Basic-Car-Maintenance-UITests/BasicCarMaintenanceUITests.swift @@ -0,0 +1,40 @@ +// +// Basic_Car_Maintenance-UITests.swift +// Basic-Car-Maintenance-UITests +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import XCTest + +final class BasicCarMaintenanceUITests: XCTestCase { + + private let app = XCUIApplication() + + override func setUp() { + continueAfterFailure = false + app.launch() + } + + func testAddVehicle() { + // Navigate to the AddVehicleView + app.buttons["Settings"].tap() + app.collectionViews.buttons["Add Vehicle"].tap() + + let nameTextField = app.textFields["Vehicle Name"] + nameTextField.tap() + nameTextField.typeText("My Car") + + let makeTextField = app.textFields["Vehicle Make"] + makeTextField.tap() + makeTextField.typeText("Toyota") + + let modelTextField = app.textFields["Vehicle Model"] + modelTextField.tap() + modelTextField.typeText("Camry") + + app.buttons["Add"].tap() + } + +} diff --git a/Basic-Car-Maintenance-Widget/AppIntent.swift b/Basic-Car-Maintenance-Widget/AppIntent.swift new file mode 100644 index 00000000..97c4ed9c --- /dev/null +++ b/Basic-Car-Maintenance-Widget/AppIntent.swift @@ -0,0 +1,19 @@ +// +// AppIntent.swift +// Basic-Car-Maintenance-Widget +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import WidgetKit +import AppIntents + +struct ConfigurationAppIntent: WidgetConfigurationIntent { + static var title: LocalizedStringResource = "Select vehicle" + // swiftlint:disable:next line_length + static var description = IntentDescription("Selects the vehicle to display total number of maintenance events for.") + + @Parameter(title: "Selected vehicle") + var selectedVehicle: VehicleAppEntity? +} diff --git a/Basic-Car-Maintenance-Widget/Assets.xcassets/AccentColor.colorset/Contents.json b/Basic-Car-Maintenance-Widget/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/Basic-Car-Maintenance-Widget/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Basic-Car-Maintenance-Widget/Assets.xcassets/AppIcon.appiconset/Contents.json b/Basic-Car-Maintenance-Widget/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..13613e3e --- /dev/null +++ b/Basic-Car-Maintenance-Widget/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Basic-Car-Maintenance-Widget/Assets.xcassets/Contents.json b/Basic-Car-Maintenance-Widget/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Basic-Car-Maintenance-Widget/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Basic-Car-Maintenance-Widget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/Basic-Car-Maintenance-Widget/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/Basic-Car-Maintenance-Widget/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Basic-Car-Maintenance-Widget/BasicCarMaintenanceWidget.swift b/Basic-Car-Maintenance-Widget/BasicCarMaintenanceWidget.swift new file mode 100644 index 00000000..10bfac07 --- /dev/null +++ b/Basic-Car-Maintenance-Widget/BasicCarMaintenanceWidget.swift @@ -0,0 +1,88 @@ +// +// Basic-Car-Maintenance-Widget.swift +// Basic-Car-Maintenance-Widget +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// +import Firebase +import FirebaseAuth +import WidgetKit +import SwiftUI + +struct Provider: AppIntentTimelineProvider { + func placeholder(in context: Context) -> MaintenanceEventsCountEntry { + return MaintenanceEventsCountEntry( + date: Date(), + configuration: .demo, + maintenanceEventsCount: 0 + ) + } + + func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> MaintenanceEventsCountEntry { + MaintenanceEventsCountEntry.demo + } + + func timeline(for configuration: ConfigurationAppIntent, + in context: Context) async -> Timeline { + var entries: [MaintenanceEventsCountEntry] = [] + + let currentDate = Date() + let nextUpdate = Calendar.current.date(byAdding: .hour, value: 1, to: currentDate)! + let vehicleID = configuration.selectedVehicle?.id; + var entry: MaintenanceEventsCountEntry; + do { + let maintenanceEventsCount = try await DataService.fetchMaintenanceEventsCount(for: vehicleID) + entry = MaintenanceEventsCountEntry(date: currentDate, configuration: configuration, maintenanceEventsCount: maintenanceEventsCount) + } catch { + entry = MaintenanceEventsCountEntry( + date: currentDate, + configuration: configuration, + error: error.localizedDescription + ) + } + + entries.append(entry) + + return Timeline(entries: entries, policy: .after(nextUpdate)) + } +} + +struct MaintenanceEventsCountEntry: TimelineEntry { + let date: Date + let configuration: ConfigurationAppIntent + let maintenanceEventsCount: Int? + let error: String? + + init( + date: Date, + configuration: ConfigurationAppIntent, + maintenanceEventsCount: Int? = 0, + error: String? = nil + ) { + self.date = date + self.configuration = configuration + self.maintenanceEventsCount = maintenanceEventsCount + self.error = error + } +} + +struct BasicCarMaintenanceWidget: Widget { + let kind: String = "BasicCarMaintenanceWidget" + + var body: some WidgetConfiguration { + AppIntentConfiguration(kind: kind, + intent: ConfigurationAppIntent.self, + provider: Provider()) { entry in + BasicCarMaintenanceWidgetEntryView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + } +} + + +#Preview(as: .systemSmall) { + BasicCarMaintenanceWidget() +} timeline: { + MaintenanceEventsCountEntry.demo +} diff --git a/Basic-Car-Maintenance-Widget/BasicCarMaintenanceWidgetBundle.swift b/Basic-Car-Maintenance-Widget/BasicCarMaintenanceWidgetBundle.swift new file mode 100644 index 00000000..6c70d9de --- /dev/null +++ b/Basic-Car-Maintenance-Widget/BasicCarMaintenanceWidgetBundle.swift @@ -0,0 +1,35 @@ +// +// BasicCarMaintenanceWidgetBundle.swift +// Basic-Car-Maintenance-Widget +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// +import FirebaseAuth +import Firebase +import WidgetKit +import SwiftUI + +@main +struct BasicCarMaintenanceWidgetBundle: WidgetBundle { + var body: some Widget { + BasicCarMaintenanceWidget() + } + + init() { + // Since this widget accesses Firebase, the same configuration as the main application is needed. + FirebaseApp.configure() + + try? Auth.auth().useUserAccessGroup(Bundle.main.keychainAccessGroup) + let useEmulator = true + if useEmulator { + let settings = Firestore.firestore().settings + settings.host = "localhost:8080" + settings.cacheSettings = MemoryCacheSettings() + settings.isSSLEnabled = false + Firestore.firestore().settings = settings + + Auth.auth().useEmulator(withHost: "127.0.0.1", port: 9099) + } + } +} diff --git a/Basic-Car-Maintenance-Widget/BasicCarMaintenanceWidgetEntryView.swift b/Basic-Car-Maintenance-Widget/BasicCarMaintenanceWidgetEntryView.swift new file mode 100644 index 00000000..71d241f4 --- /dev/null +++ b/Basic-Car-Maintenance-Widget/BasicCarMaintenanceWidgetEntryView.swift @@ -0,0 +1,31 @@ +// +// BasicCarMaintenanceWidgetEntryView.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import FirebaseAuth +import SwiftUI +import WidgetKit + +struct BasicCarMaintenanceWidgetEntryView: View { + @Environment(\.widgetFamily) var widgetFamily + + var entry: Provider.Entry + + var body: some View { + if Auth.auth().currentUser != nil { + switch widgetFamily { + case .systemSmall: + SmallMaintenanceEventsCountWidgetView(entry: entry) + default: + Text("Unimplemented widget family: \(widgetFamily.rawValue)") + } + } else { + Text("Please sign in to use this widget.") + } + } +} + diff --git a/Basic-Car-Maintenance-Widget/CarInfoWithMaintenanceEventsCount.swift b/Basic-Car-Maintenance-Widget/CarInfoWithMaintenanceEventsCount.swift new file mode 100644 index 00000000..71e143fa --- /dev/null +++ b/Basic-Car-Maintenance-Widget/CarInfoWithMaintenanceEventsCount.swift @@ -0,0 +1,50 @@ +// +// CarInfoWithMaintenanceEventsCount.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import SwiftUI + +struct CarInfoWithMaintenanceEventsCount: View { + let vehicle: VehicleAppEntity + let maintenanceEventsCount: Int + + var body: some View { + VStack(alignment: .leading) { + Image(systemName: "car") + + Text(vehicle.displayString) + .font(.subheadline) + .bold() + + Text("\(vehicle.data.year ?? "") \(vehicle.data.make) \(vehicle.data.model)") + .font(.footnote) + .foregroundStyle(.gray) + .padding(.bottom, 2) + + if maintenanceEventsCount > 0 { + Text("Total events") + .font(.callout) + + HStack { + Image(systemName: SFSymbol.wrenchAndScrewdriver) + .imageScale(.medium) + + Text("\(maintenanceEventsCount)") + .font(.callout) + } + .scaledToFill() + } else { + Text("No events yet") + .font(.callout) + + Image(systemName: SFSymbol.wrenchAndScrewdriver) + .imageScale(.medium) + .scaledToFill() + } + } + } +} diff --git a/Basic-Car-Maintenance-Widget/DataService.swift b/Basic-Car-Maintenance-Widget/DataService.swift new file mode 100644 index 00000000..0d6a167a --- /dev/null +++ b/Basic-Car-Maintenance-Widget/DataService.swift @@ -0,0 +1,99 @@ +// +// DataService.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import FirebaseAuth +import FirebaseFirestore + +enum DataService { + /// Fetches totalmaintenance events count for the selected vehicle from Firestore. + /// - Parameter vehicleID: The ID of the selected vehicle. + /// - Returns: A total maintenance events count for the selected vehicle or an error if the fetch fails. + /// + /// Example usage: + /// ```swift + /// Task { + /// let result = await DataService.fetchMaintenanceEventsCount(for: "vehicle123") + /// + /// switch result { + /// case .success(let eventsCount): + /// print("Total maintenance events \(eventsCount).") + /// case .failure(let error): + /// print("Failed to fetch maintenance events count with error: \(error.localizedDescription)") + /// } + /// } + /// ``` + static func fetchMaintenanceEventsCount(for vehicleID: String?) async throws -> Int { + guard let vehicleID else { + throw FetchError.noVehicleSelected + } + + do { + let countRef = Firestore + .firestore() + .collection(FirestorePath.maintenanceEvents(vehicleID: vehicleID).path).count + let snapshot = try await countRef.getAggregation(source: .server) + return snapshot.count.intValue + } catch { + throw error + } + } + + /// Fetches vehicles for the current user from Firestore. + /// - Returns: A list of vehicles or an error if the fetch fails. + /// + /// Example usage: + /// ```swift + /// Task { + /// let result = await DataService.fetchVehicles() + /// + /// switch result { + /// case .success(let vehicles): + /// print("Fetched \(vehicles.count) vehicles.") + /// case .failure(let error): + /// print("Failed to fetch vehicles with error: \(error.localizedDescription)") + /// } + /// } + /// ``` + static func fetchVehicles() async throws -> [Vehicle] { + guard let userID = Auth.auth().currentUser?.uid else { + throw FetchError.unauthenticated + } + + let docRef = Firestore + .firestore() + .collection(FirestoreCollection.vehicles) + .whereField(FirestoreField.userID, isEqualTo: userID) + + do { + let snapshot = try await docRef.getDocuments() + let vehicles = snapshot.documents.compactMap { + try? $0.data(as: Vehicle.self) + } + return vehicles + } catch { + throw error + } + } +} + +/// Errors that can occur when fetching maintenance events. +enum FetchError: LocalizedError { + case unauthenticated + case noVehicleSelected + case unexpected + var errorDescription: String? { + switch self { + case .unauthenticated: + "You are not logged in. Please log in to continue." + case .noVehicleSelected: + "No vehicle selected. Please select a vehicle to continue." + case .unexpected: + "An unexpected error occurred." + } + } +} diff --git a/Basic-Car-Maintenance-Widget/ErrorView.swift b/Basic-Car-Maintenance-Widget/ErrorView.swift new file mode 100644 index 00000000..4367fa3f --- /dev/null +++ b/Basic-Car-Maintenance-Widget/ErrorView.swift @@ -0,0 +1,31 @@ +// +// ErrorView.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import SwiftUI + +struct ErrorView: View { + let error: String + + var body: some View { + VStack(spacing: 4) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + .font(.title) + + Text("Error") + .font(.subheadline) + .bold() + .foregroundColor(.primary) + + Text(error) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + } +} diff --git a/Basic-Car-Maintenance-Widget/Extensions/ConfigurationAppIntent+Demo.swift b/Basic-Car-Maintenance-Widget/Extensions/ConfigurationAppIntent+Demo.swift new file mode 100644 index 00000000..a6dc49f8 --- /dev/null +++ b/Basic-Car-Maintenance-Widget/Extensions/ConfigurationAppIntent+Demo.swift @@ -0,0 +1,21 @@ +// +// ConfigurableWidgetAppIntent.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import Foundation + +extension ConfigurationAppIntent { + static var demo: ConfigurationAppIntent { + let intent = ConfigurationAppIntent() + intent.selectedVehicle = VehicleAppEntity( + id: UUID().uuidString, + displayString: "Hot wheels", + data: .init(name: "Kia Sportage", make: "Kia", model: "Sportage", year: "2022") + ) + return intent + } +} diff --git a/Basic-Car-Maintenance-Widget/Extensions/MaintenanceEventsCountEntry+Demo.swift b/Basic-Car-Maintenance-Widget/Extensions/MaintenanceEventsCountEntry+Demo.swift new file mode 100644 index 00000000..2aba87ef --- /dev/null +++ b/Basic-Car-Maintenance-Widget/Extensions/MaintenanceEventsCountEntry+Demo.swift @@ -0,0 +1,15 @@ +// +// Array+Demo.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +extension MaintenanceEventsCountEntry { + static var demo = MaintenanceEventsCountEntry( + date: .now, + configuration: .demo, + maintenanceEventsCount: 2 + ) +} diff --git a/Basic-Car-Maintenance-Widget/Info.plist b/Basic-Car-Maintenance-Widget/Info.plist new file mode 100644 index 00000000..20adbf83 --- /dev/null +++ b/Basic-Car-Maintenance-Widget/Info.plist @@ -0,0 +1,13 @@ + + + + + KeychainAccessGroup + $(KEYCHAIN_SHARING_GROUP_IDENTIFIER) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/Basic-Car-Maintenance-Widget/SmallMaintenanceEventsCountWidgetView.swift b/Basic-Car-Maintenance-Widget/SmallMaintenanceEventsCountWidgetView.swift new file mode 100644 index 00000000..b74434ab --- /dev/null +++ b/Basic-Car-Maintenance-Widget/SmallMaintenanceEventsCountWidgetView.swift @@ -0,0 +1,52 @@ +// +// SmallMaintenanceEventsCountWidgetView.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + + +import Firebase +import FirebaseAuth +import WidgetKit +import SwiftUI + +struct SmallMaintenanceEventsCountWidgetView: View { + var entry: Provider.Entry + + var body: some View { + if let error = entry.error, entry.configuration.selectedVehicle != nil { + ErrorView(error: error) + } else if let selectedVehicle = entry.configuration.selectedVehicle, + let maintenanceEventsCount = entry.maintenanceEventsCount { + HStack(alignment: .center) { + CarInfoWithMaintenanceEventsCount( + vehicle: selectedVehicle, + maintenanceEventsCount: maintenanceEventsCount + ) + } + } else { + Text("No vehicle selected.") + } + } +} + + +#Preview("General View no maintenance events yet", as: .systemSmall) { + BasicCarMaintenanceWidget() +} timeline: { + MaintenanceEventsCountEntry(date:.now, configuration: .demo, maintenanceEventsCount: 0) +} + +#Preview("General View Total 2 maintenance events count", as: .systemSmall) { + BasicCarMaintenanceWidget() +} timeline: { + MaintenanceEventsCountEntry(date:.now, configuration: .demo, maintenanceEventsCount: 2) +} + +#Preview("Error View", as: .systemSmall) { + BasicCarMaintenanceWidget() +} timeline: { + MaintenanceEventsCountEntry(date:.now, configuration: .demo, error: "Unnexpected error") +} diff --git a/Basic-Car-Maintenance-Widget/VehicleAppEntity.swift b/Basic-Car-Maintenance-Widget/VehicleAppEntity.swift new file mode 100644 index 00000000..e1c0e926 --- /dev/null +++ b/Basic-Car-Maintenance-Widget/VehicleAppEntity.swift @@ -0,0 +1,47 @@ +// +// VehicleAppEntity.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// +import Foundation +import AppIntents + +struct VehicleAppEntity: AppEntity { + var id: String + var displayString: String + var data: Vehicle + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(displayString)") + } + + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Maintenance Vehicle") + static var defaultQuery = VehicleAppEntityQuery() + + init(id: String, displayString: String, data: Vehicle) { + self.id = id + self.displayString = displayString + self.data = data + } +} + +struct VehicleAppEntityQuery: EntityQuery { + func entities( + for identifiers: [VehicleAppEntity.ID] + ) async throws -> [VehicleAppEntity] { + let vehicles = try await DataService.fetchVehicles() + return vehicles.map { + VehicleAppEntity( + id: $0.id ?? UUID().uuidString, + displayString: $0.name, + data: $0 + ) + } + } + + func suggestedEntities() async throws -> [VehicleAppEntity] { + return try await entities(for: []) + } +} diff --git a/Basic-Car-Maintenance-WidgetExtension.entitlements b/Basic-Car-Maintenance-WidgetExtension.entitlements new file mode 100644 index 00000000..c753ac09 --- /dev/null +++ b/Basic-Car-Maintenance-WidgetExtension.entitlements @@ -0,0 +1,10 @@ + + + + + keychain-access-groups + + $(KEYCHAIN_SHARING_GROUP_IDENTIFIER) + + + diff --git a/Basic-Car-Maintenance.xcconfig.template b/Basic-Car-Maintenance.xcconfig.template index f7b09bc9..6ed46b30 100644 --- a/Basic-Car-Maintenance.xcconfig.template +++ b/Basic-Car-Maintenance.xcconfig.template @@ -1 +1,2 @@ DEVELOPMENT_TEAM = YOUR_TEAM_ID +PRODUCT_BUNDLE_IDENTIFIER = com.example.app \ No newline at end of file diff --git a/Basic-Car-Maintenance.xcodeproj/project.pbxproj b/Basic-Car-Maintenance.xcodeproj/project.pbxproj index 607fed88..9a301bdf 100644 --- a/Basic-Car-Maintenance.xcodeproj/project.pbxproj +++ b/Basic-Car-Maintenance.xcodeproj/project.pbxproj @@ -3,36 +3,23 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 71; objects = { /* Begin PBXBuildFile section */ - FF09FC912AB6FF44006BE61A /* AuthenticationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF09FC902AB6FF44006BE61A /* AuthenticationView.swift */; }; - FF3DDF522AA4D28F009D91C4 /* DashboardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF3DDF512AA4D28F009D91C4 /* DashboardViewModel.swift */; }; - FF5D13A72A86C2D600BC9BD6 /* BasicCarMaintenanceApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5D13A62A86C2D600BC9BD6 /* BasicCarMaintenanceApp.swift */; }; - FF5D13AB2A86C2D800BC9BD6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FF5D13AA2A86C2D800BC9BD6 /* Assets.xcassets */; }; - FF5D13AF2A86C2D800BC9BD6 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FF5D13AE2A86C2D800BC9BD6 /* Preview Assets.xcassets */; }; - FF5D13B92A86C2D800BC9BD6 /* BasicCarMaintenanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5D13B82A86C2D800BC9BD6 /* BasicCarMaintenanceTests.swift */; }; - FF5D13C32A86C2D800BC9BD6 /* BasicCarMaintenanceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5D13C22A86C2D800BC9BD6 /* BasicCarMaintenanceUITests.swift */; }; - FF5D13C52A86C2D800BC9BD6 /* BasicCarMaintenanceUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5D13C42A86C2D800BC9BD6 /* BasicCarMaintenanceUITestsLaunchTests.swift */; }; - FF748B5E2AB3589C004748A5 /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF748B5D2AB3589C004748A5 /* AuthenticationViewModel.swift */; }; - FF755B3C2A908E3E00F49A13 /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF755B3B2A908E3E00F49A13 /* DashboardView.swift */; }; - FF755B3E2A908E7A00F49A13 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF755B3D2A908E7A00F49A13 /* SettingsView.swift */; }; - FF755B432A90915E00F49A13 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = FF755B422A90915E00F49A13 /* Localizable.xcstrings */; }; - FF755B462A90969D00F49A13 /* Bundle+extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF755B452A90969D00F49A13 /* Bundle+extension.swift */; }; - FF755B492A909A0000F49A13 /* AddMaintenanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF755B482A909A0000F49A13 /* AddMaintenanceView.swift */; }; - FFAA56ED2AC8905C000120EE /* Documentation.docc in Sources */ = {isa = PBXBuildFile; fileRef = FFAA56EC2AC8905C000120EE /* Documentation.docc */; }; - FFBFE0912A98EFEC000A9BEB /* MaintenanceEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFBFE0902A98EFEC000A9BEB /* MaintenanceEvent.swift */; }; - FFBFE0932A98F212000A9BEB /* Vehicle.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFBFE0922A98F212000A9BEB /* Vehicle.swift */; }; - FFBFE0972A98F7CB000A9BEB /* AddVehicleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFBFE0962A98F7CB000A9BEB /* AddVehicleView.swift */; }; - FFC67D1D2AAEF7920073B338 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFC67D1C2AAEF7920073B338 /* SettingsViewModel.swift */; }; - FFC8CDA42AA385E800D129A6 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = FFC8CDA32AA385E800D129A6 /* GoogleService-Info.plist */; }; + 9DBF34442E9298F40081DF0E /* FirebaseFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = 9DBF34432E9298F40081DF0E /* FirebaseFirestore */; }; + 9DBF34462E9299000081DF0E /* FirebaseAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 9DBF34452E9299000081DF0E /* FirebaseAuth */; }; + 9DBF34482E9299220081DF0E /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = 9DBF34472E9299220081DF0E /* FirebaseCore */; }; + FF153AFF2B07C3E000D0BA30 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = FF153AFE2B07C3E000D0BA30 /* FirebaseCrashlytics */; }; + FF4E82BE2AD39863004949AF /* FirebaseRemoteConfig in Frameworks */ = {isa = PBXBuildFile; productRef = FF4E82BD2AD39863004949AF /* FirebaseRemoteConfig */; }; + FF4E82C22AD39863004949AF /* FirebaseStorage in Frameworks */ = {isa = PBXBuildFile; productRef = FF4E82C12AD39863004949AF /* FirebaseStorage */; }; FFC8CDA72AA3867A00D129A6 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = FFC8CDA62AA3867A00D129A6 /* FirebaseAnalytics */; }; - FFC8CDA92AA3867A00D129A6 /* FirebaseAnalyticsSwift in Frameworks */ = {isa = PBXBuildFile; productRef = FFC8CDA82AA3867A00D129A6 /* FirebaseAnalyticsSwift */; }; FFC8CDAB2AA3867A00D129A6 /* FirebaseAuth in Frameworks */ = {isa = PBXBuildFile; productRef = FFC8CDAA2AA3867A00D129A6 /* FirebaseAuth */; }; FFC8CDAD2AA3867A00D129A6 /* FirebaseFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = FFC8CDAC2AA3867A00D129A6 /* FirebaseFirestore */; }; - FFC8CDAF2AA3867A00D129A6 /* FirebaseFirestoreSwift in Frameworks */ = {isa = PBXBuildFile; productRef = FFC8CDAE2AA3867A00D129A6 /* FirebaseFirestoreSwift */; }; FFC8CDB32AA4226900D129A6 /* AdSupport.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FFC8CDB22AA4226900D129A6 /* AdSupport.framework */; }; + FFDADF7F2ACD35A100DDEF79 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FFDADF7E2ACD35A100DDEF79 /* WidgetKit.framework */; }; + FFDADF812ACD35A100DDEF79 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FFDADF802ACD35A100DDEF79 /* SwiftUI.framework */; }; + FFDADF8E2ACD35A200DDEF79 /* Basic-Car-Maintenance-WidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FFDADF7D2ACD35A100DDEF79 /* Basic-Car-Maintenance-WidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -50,47 +37,99 @@ remoteGlobalIDString = FF5D13A22A86C2D600BC9BD6; remoteInfo = "Basic-Car-Maintenance"; }; + FFDADF8C2ACD35A200DDEF79 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = FF5D139B2A86C2D500BC9BD6 /* Project object */; + proxyType = 1; + remoteGlobalIDString = FFDADF7C2ACD35A100DDEF79; + remoteInfo = "Basic-Car-Maintenance-WidgetExtension"; + }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + FFDADF8F2ACD35A200DDEF79 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + FFDADF8E2ACD35A200DDEF79 /* Basic-Car-Maintenance-WidgetExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ + 9DBF35902E947CFA0081DF0E /* Basic-Car-Maintenance-WidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Basic-Car-Maintenance-WidgetExtension.entitlements"; sourceTree = ""; }; FF098EFA2AB3424E003EC0FE /* Basic-Car-Maintenance.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Basic-Car-Maintenance.xcconfig"; sourceTree = SOURCE_ROOT; }; - FF09FC902AB6FF44006BE61A /* AuthenticationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationView.swift; sourceTree = ""; }; - FF3DDF512AA4D28F009D91C4 /* DashboardViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardViewModel.swift; sourceTree = ""; }; FF5D13A32A86C2D600BC9BD6 /* Basic-Car-Maintenance.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Basic-Car-Maintenance.app"; sourceTree = BUILT_PRODUCTS_DIR; }; - FF5D13A62A86C2D600BC9BD6 /* BasicCarMaintenanceApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicCarMaintenanceApp.swift; sourceTree = ""; }; - FF5D13AA2A86C2D800BC9BD6 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - FF5D13AC2A86C2D800BC9BD6 /* Basic_Car_Maintenance.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Basic_Car_Maintenance.entitlements; sourceTree = ""; }; - FF5D13AE2A86C2D800BC9BD6 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; - FF5D13B42A86C2D800BC9BD6 /* Basic-Car-MaintenanceTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Basic-Car-MaintenanceTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; - FF5D13B82A86C2D800BC9BD6 /* BasicCarMaintenanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicCarMaintenanceTests.swift; sourceTree = ""; }; - FF5D13BE2A86C2D800BC9BD6 /* Basic-Car-MaintenanceUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Basic-Car-MaintenanceUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; - FF5D13C22A86C2D800BC9BD6 /* BasicCarMaintenanceUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicCarMaintenanceUITests.swift; sourceTree = ""; }; - FF5D13C42A86C2D800BC9BD6 /* BasicCarMaintenanceUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicCarMaintenanceUITestsLaunchTests.swift; sourceTree = ""; }; - FF748B5D2AB3589C004748A5 /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = ""; }; - FF755B3B2A908E3E00F49A13 /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = ""; }; - FF755B3D2A908E7A00F49A13 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; - FF755B422A90915E00F49A13 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; - FF755B452A90969D00F49A13 /* Bundle+extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+extension.swift"; sourceTree = ""; }; - FF755B482A909A0000F49A13 /* AddMaintenanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddMaintenanceView.swift; sourceTree = ""; }; - FFAA56EC2AC8905C000120EE /* Documentation.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = Documentation.docc; sourceTree = ""; }; - FFBFE0902A98EFEC000A9BEB /* MaintenanceEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaintenanceEvent.swift; sourceTree = ""; }; - FFBFE0922A98F212000A9BEB /* Vehicle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Vehicle.swift; sourceTree = ""; }; - FFBFE0962A98F7CB000A9BEB /* AddVehicleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddVehicleView.swift; sourceTree = ""; }; - FFC67D1C2AAEF7920073B338 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; - FFC8CDA32AA385E800D129A6 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; + FF5D13B42A86C2D800BC9BD6 /* Basic-Car-Maintenance-Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Basic-Car-Maintenance-Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + FF5D13BE2A86C2D800BC9BD6 /* Basic-Car-Maintenance-UITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Basic-Car-Maintenance-UITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + FFA392762C54738E00A0AD6D /* Basic-Car-Maintenance.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = "Basic-Car-Maintenance.xctestplan"; sourceTree = ""; }; FFC8CDB22AA4226900D129A6 /* AdSupport.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AdSupport.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS17.0.sdk/System/Library/Frameworks/AdSupport.framework; sourceTree = DEVELOPER_DIR; }; + FFDADF7D2ACD35A100DDEF79 /* Basic-Car-Maintenance-WidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Basic-Car-Maintenance-WidgetExtension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; + FFDADF7E2ACD35A100DDEF79 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + FFDADF802ACD35A100DDEF79 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 9DBF34402E9295F80081DF0E /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + "Shared/GoogleService-Info.plist", + Shared/Models/MaintenanceEvent.swift, + Shared/Models/Vehicle.swift, + "Shared/Utilities/Bundle+extension.swift", + Shared/Utilities/Constants.swift, + ); + target = FFDADF7C2ACD35A100DDEF79 /* Basic-Car-Maintenance-WidgetExtension */; + }; + 9DBF358D2E945E890081DF0E /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + AppIntent.swift, + DataService.swift, + VehicleAppEntity.swift, + ); + target = FF5D13A22A86C2D600BC9BD6 /* Basic-Car-Maintenance */; + }; + FF52DEBC2CADE9EF0023F8DE /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Shared/Info.plist, + Shared/PrivacyInfo.xcprivacy, + ); + target = FF5D13A22A86C2D600BC9BD6 /* Basic-Car-Maintenance */; + }; + FF52DEC82CADEA000023F8DE /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = FFDADF7C2ACD35A100DDEF79 /* Basic-Car-Maintenance-WidgetExtension */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + FF52DE902CADE9EF0023F8DE /* Basic-Car-Maintenance */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (FF52DEBC2CADE9EF0023F8DE /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 9DBF34402E9295F80081DF0E /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = "Basic-Car-Maintenance"; sourceTree = ""; }; + FF52DEC22CADEA000023F8DE /* Basic-Car-Maintenance-Widget */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (9DBF358D2E945E890081DF0E /* PBXFileSystemSynchronizedBuildFileExceptionSet */, FF52DEC82CADEA000023F8DE /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = "Basic-Car-Maintenance-Widget"; sourceTree = ""; }; + FF52DECC2CADEA030023F8DE /* Basic-Car-Maintenance-Tests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "Basic-Car-Maintenance-Tests"; sourceTree = ""; }; + FF52DECF2CADEA060023F8DE /* Basic-Car-Maintenance-UITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "Basic-Car-Maintenance-UITests"; sourceTree = ""; }; + FF52DED52CADEA150023F8DE /* Configurations */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Configurations; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ FF5D13A02A86C2D600BC9BD6 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - FFC8CDAF2AA3867A00D129A6 /* FirebaseFirestoreSwift in Frameworks */, - FFC8CDA92AA3867A00D129A6 /* FirebaseAnalyticsSwift in Frameworks */, + FF4E82BE2AD39863004949AF /* FirebaseRemoteConfig in Frameworks */, FFC8CDAD2AA3867A00D129A6 /* FirebaseFirestore in Frameworks */, FFC8CDAB2AA3867A00D129A6 /* FirebaseAuth in Frameworks */, FFC8CDA72AA3867A00D129A6 /* FirebaseAnalytics in Frameworks */, + FF153AFF2B07C3E000D0BA30 /* FirebaseCrashlytics in Frameworks */, + FF4E82C22AD39863004949AF /* FirebaseStorage in Frameworks */, FFC8CDB32AA4226900D129A6 /* AdSupport.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -109,33 +148,32 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FFDADF7A2ACD35A100DDEF79 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + FFDADF812ACD35A100DDEF79 /* SwiftUI.framework in Frameworks */, + 9DBF34482E9299220081DF0E /* FirebaseCore in Frameworks */, + FFDADF7F2ACD35A100DDEF79 /* WidgetKit.framework in Frameworks */, + 9DBF34442E9298F40081DF0E /* FirebaseFirestore in Frameworks */, + 9DBF34462E9299000081DF0E /* FirebaseAuth in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - FF3DDF492AA4386C009D91C4 /* Views */ = { - isa = PBXGroup; - children = ( - FF755B3B2A908E3E00F49A13 /* DashboardView.swift */, - FF755B482A909A0000F49A13 /* AddMaintenanceView.swift */, - ); - path = Views; - sourceTree = ""; - }; - FF3DDF502AA4D282009D91C4 /* ViewModels */ = { - isa = PBXGroup; - children = ( - FF3DDF512AA4D28F009D91C4 /* DashboardViewModel.swift */, - ); - path = ViewModels; - sourceTree = ""; - }; FF5D139A2A86C2D500BC9BD6 = { isa = PBXGroup; children = ( + 9DBF35902E947CFA0081DF0E /* Basic-Car-Maintenance-WidgetExtension.entitlements */, + FFA392762C54738E00A0AD6D /* Basic-Car-Maintenance.xctestplan */, + FF52DED52CADEA150023F8DE /* Configurations */, FF098EFA2AB3424E003EC0FE /* Basic-Car-Maintenance.xcconfig */, - FF5D13A52A86C2D600BC9BD6 /* Basic-Car-Maintenance */, - FF5D13B72A86C2D800BC9BD6 /* Basic-Car-MaintenanceTests */, - FF5D13C12A86C2D800BC9BD6 /* Basic-Car-MaintenanceUITests */, + FF52DE902CADE9EF0023F8DE /* Basic-Car-Maintenance */, + FF52DEC22CADEA000023F8DE /* Basic-Car-Maintenance-Widget */, + FF52DECC2CADEA030023F8DE /* Basic-Car-Maintenance-Tests */, + FF52DECF2CADEA060023F8DE /* Basic-Car-Maintenance-UITests */, FF5D13A42A86C2D600BC9BD6 /* Products */, FFC8CDB12AA4226900D129A6 /* Frameworks */, ); @@ -145,121 +183,19 @@ isa = PBXGroup; children = ( FF5D13A32A86C2D600BC9BD6 /* Basic-Car-Maintenance.app */, - FF5D13B42A86C2D800BC9BD6 /* Basic-Car-MaintenanceTests.xctest */, - FF5D13BE2A86C2D800BC9BD6 /* Basic-Car-MaintenanceUITests.xctest */, + FF5D13B42A86C2D800BC9BD6 /* Basic-Car-Maintenance-Tests.xctest */, + FF5D13BE2A86C2D800BC9BD6 /* Basic-Car-Maintenance-UITests.xctest */, + FFDADF7D2ACD35A100DDEF79 /* Basic-Car-Maintenance-WidgetExtension.appex */, ); name = Products; sourceTree = ""; }; - FF5D13A52A86C2D600BC9BD6 /* Basic-Car-Maintenance */ = { - isa = PBXGroup; - children = ( - FFAA56EC2AC8905C000120EE /* Documentation.docc */, - FF755B412A908F4100F49A13 /* Shared */, - FF5D13AA2A86C2D800BC9BD6 /* Assets.xcassets */, - FF5D13AC2A86C2D800BC9BD6 /* Basic_Car_Maintenance.entitlements */, - FF5D13AD2A86C2D800BC9BD6 /* Preview Content */, - ); - path = "Basic-Car-Maintenance"; - sourceTree = ""; - }; - FF5D13AD2A86C2D800BC9BD6 /* Preview Content */ = { - isa = PBXGroup; - children = ( - FF5D13AE2A86C2D800BC9BD6 /* Preview Assets.xcassets */, - ); - path = "Preview Content"; - sourceTree = ""; - }; - FF5D13B72A86C2D800BC9BD6 /* Basic-Car-MaintenanceTests */ = { - isa = PBXGroup; - children = ( - FF5D13B82A86C2D800BC9BD6 /* BasicCarMaintenanceTests.swift */, - ); - path = "Basic-Car-MaintenanceTests"; - sourceTree = ""; - }; - FF5D13C12A86C2D800BC9BD6 /* Basic-Car-MaintenanceUITests */ = { - isa = PBXGroup; - children = ( - FF5D13C22A86C2D800BC9BD6 /* BasicCarMaintenanceUITests.swift */, - FF5D13C42A86C2D800BC9BD6 /* BasicCarMaintenanceUITestsLaunchTests.swift */, - ); - path = "Basic-Car-MaintenanceUITests"; - sourceTree = ""; - }; - FF755B3F2A908EC400F49A13 /* Dashboard */ = { - isa = PBXGroup; - children = ( - FF3DDF492AA4386C009D91C4 /* Views */, - FF3DDF502AA4D282009D91C4 /* ViewModels */, - ); - path = Dashboard; - sourceTree = ""; - }; - FF755B402A908EC900F49A13 /* Settings */ = { - isa = PBXGroup; - children = ( - FFC67D1E2AAEF7960073B338 /* Views */, - FFC67D1F2AAEF7A00073B338 /* ViewModels */, - ); - path = Settings; - sourceTree = ""; - }; - FF755B412A908F4100F49A13 /* Shared */ = { - isa = PBXGroup; - children = ( - FF755B422A90915E00F49A13 /* Localizable.xcstrings */, - FFC8CDA32AA385E800D129A6 /* GoogleService-Info.plist */, - FF5D13A62A86C2D600BC9BD6 /* BasicCarMaintenanceApp.swift */, - FFBFE08F2A98EFDD000A9BEB /* Models */, - FF755B3F2A908EC400F49A13 /* Dashboard */, - FF755B402A908EC900F49A13 /* Settings */, - FF755B442A90968D00F49A13 /* Utilities */, - ); - path = Shared; - sourceTree = ""; - }; - FF755B442A90968D00F49A13 /* Utilities */ = { - isa = PBXGroup; - children = ( - FF755B452A90969D00F49A13 /* Bundle+extension.swift */, - ); - path = Utilities; - sourceTree = ""; - }; - FFBFE08F2A98EFDD000A9BEB /* Models */ = { - isa = PBXGroup; - children = ( - FFBFE0902A98EFEC000A9BEB /* MaintenanceEvent.swift */, - FFBFE0922A98F212000A9BEB /* Vehicle.swift */, - ); - path = Models; - sourceTree = ""; - }; - FFC67D1E2AAEF7960073B338 /* Views */ = { - isa = PBXGroup; - children = ( - FF755B3D2A908E7A00F49A13 /* SettingsView.swift */, - FFBFE0962A98F7CB000A9BEB /* AddVehicleView.swift */, - FF09FC902AB6FF44006BE61A /* AuthenticationView.swift */, - ); - path = Views; - sourceTree = ""; - }; - FFC67D1F2AAEF7A00073B338 /* ViewModels */ = { - isa = PBXGroup; - children = ( - FFC67D1C2AAEF7920073B338 /* SettingsViewModel.swift */, - FF748B5D2AB3589C004748A5 /* AuthenticationViewModel.swift */, - ); - path = ViewModels; - sourceTree = ""; - }; FFC8CDB12AA4226900D129A6 /* Frameworks */ = { isa = PBXGroup; children = ( FFC8CDB22AA4226900D129A6 /* AdSupport.framework */, + FFDADF7E2ACD35A100DDEF79 /* WidgetKit.framework */, + FFDADF802ACD35A100DDEF79 /* SwiftUI.framework */, ); name = Frameworks; sourceTree = ""; @@ -274,27 +210,34 @@ FF5D139F2A86C2D600BC9BD6 /* Sources */, FF5D13A02A86C2D600BC9BD6 /* Frameworks */, FF5D13A12A86C2D600BC9BD6 /* Resources */, - FF748B5A2AB34A61004748A5 /* SwiftLintScript */, + FFDADF8F2ACD35A200DDEF79 /* Embed Foundation Extensions */, + FF50DDC22B07DF0C00E87362 /* ShellScript */, ); buildRules = ( ); dependencies = ( + C90C058C2CB0FC8000046C42 /* PBXTargetDependency */, + FFDADF8D2ACD35A200DDEF79 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + FF52DE902CADE9EF0023F8DE /* Basic-Car-Maintenance */, ); name = "Basic-Car-Maintenance"; packageProductDependencies = ( FFC8CDA62AA3867A00D129A6 /* FirebaseAnalytics */, - FFC8CDA82AA3867A00D129A6 /* FirebaseAnalyticsSwift */, FFC8CDAA2AA3867A00D129A6 /* FirebaseAuth */, FFC8CDAC2AA3867A00D129A6 /* FirebaseFirestore */, - FFC8CDAE2AA3867A00D129A6 /* FirebaseFirestoreSwift */, + FF4E82BD2AD39863004949AF /* FirebaseRemoteConfig */, + FF4E82C12AD39863004949AF /* FirebaseStorage */, + FF153AFE2B07C3E000D0BA30 /* FirebaseCrashlytics */, ); productName = "Basic-Car-Maintenance"; productReference = FF5D13A32A86C2D600BC9BD6 /* Basic-Car-Maintenance.app */; productType = "com.apple.product-type.application"; }; - FF5D13B32A86C2D800BC9BD6 /* Basic-Car-MaintenanceTests */ = { + FF5D13B32A86C2D800BC9BD6 /* Basic-Car-Maintenance-Tests */ = { isa = PBXNativeTarget; - buildConfigurationList = FF5D13CB2A86C2D800BC9BD6 /* Build configuration list for PBXNativeTarget "Basic-Car-MaintenanceTests" */; + buildConfigurationList = FF5D13CB2A86C2D800BC9BD6 /* Build configuration list for PBXNativeTarget "Basic-Car-Maintenance-Tests" */; buildPhases = ( FF5D13B02A86C2D800BC9BD6 /* Sources */, FF5D13B12A86C2D800BC9BD6 /* Frameworks */, @@ -305,14 +248,17 @@ dependencies = ( FF5D13B62A86C2D800BC9BD6 /* PBXTargetDependency */, ); - name = "Basic-Car-MaintenanceTests"; + fileSystemSynchronizedGroups = ( + FF52DECC2CADEA030023F8DE /* Basic-Car-Maintenance-Tests */, + ); + name = "Basic-Car-Maintenance-Tests"; productName = "Basic-Car-MaintenanceTests"; - productReference = FF5D13B42A86C2D800BC9BD6 /* Basic-Car-MaintenanceTests.xctest */; + productReference = FF5D13B42A86C2D800BC9BD6 /* Basic-Car-Maintenance-Tests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; - FF5D13BD2A86C2D800BC9BD6 /* Basic-Car-MaintenanceUITests */ = { + FF5D13BD2A86C2D800BC9BD6 /* Basic-Car-Maintenance-UITests */ = { isa = PBXNativeTarget; - buildConfigurationList = FF5D13CE2A86C2D800BC9BD6 /* Build configuration list for PBXNativeTarget "Basic-Car-MaintenanceUITests" */; + buildConfigurationList = FF5D13CE2A86C2D800BC9BD6 /* Build configuration list for PBXNativeTarget "Basic-Car-Maintenance-UITests" */; buildPhases = ( FF5D13BA2A86C2D800BC9BD6 /* Sources */, FF5D13BB2A86C2D800BC9BD6 /* Frameworks */, @@ -323,11 +269,34 @@ dependencies = ( FF5D13C02A86C2D800BC9BD6 /* PBXTargetDependency */, ); - name = "Basic-Car-MaintenanceUITests"; + fileSystemSynchronizedGroups = ( + FF52DECF2CADEA060023F8DE /* Basic-Car-Maintenance-UITests */, + ); + name = "Basic-Car-Maintenance-UITests"; productName = "Basic-Car-MaintenanceUITests"; - productReference = FF5D13BE2A86C2D800BC9BD6 /* Basic-Car-MaintenanceUITests.xctest */; + productReference = FF5D13BE2A86C2D800BC9BD6 /* Basic-Car-Maintenance-UITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + FFDADF7C2ACD35A100DDEF79 /* Basic-Car-Maintenance-WidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = FFDADF922ACD35A200DDEF79 /* Build configuration list for PBXNativeTarget "Basic-Car-Maintenance-WidgetExtension" */; + buildPhases = ( + FFDADF792ACD35A100DDEF79 /* Sources */, + FFDADF7A2ACD35A100DDEF79 /* Frameworks */, + FFDADF7B2ACD35A100DDEF79 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + FF52DEC22CADEA000023F8DE /* Basic-Car-Maintenance-Widget */, + ); + name = "Basic-Car-Maintenance-WidgetExtension"; + productName = "Basic-Car-Maintenance-WidgetExtension"; + productReference = FFDADF7D2ACD35A100DDEF79 /* Basic-Car-Maintenance-WidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -336,7 +305,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1500; - LastUpgradeCheck = 1500; + LastUpgradeCheck = 1600; TargetAttributes = { FF5D13A22A86C2D600BC9BD6 = { CreatedOnToolsVersion = 15.0; @@ -349,6 +318,9 @@ CreatedOnToolsVersion = 15.0; TestTargetID = FF5D13A22A86C2D600BC9BD6; }; + FFDADF7C2ACD35A100DDEF79 = { + CreatedOnToolsVersion = 15.0; + }; }; }; buildConfigurationList = FF5D139E2A86C2D500BC9BD6 /* Build configuration list for PBXProject "Basic-Car-Maintenance" */; @@ -358,18 +330,31 @@ knownRegions = ( en, Base, + cs, + de, + fr, + uk, + hi, + ko, + be, + ru, + "pt-BR", + nl, + fa, ); mainGroup = FF5D139A2A86C2D500BC9BD6; packageReferences = ( FFC8CDA52AA3867A00D129A6 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, + C90C058A2CB0FC5800046C42 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */, ); productRefGroup = FF5D13A42A86C2D600BC9BD6 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( FF5D13A22A86C2D600BC9BD6 /* Basic-Car-Maintenance */, - FF5D13B32A86C2D800BC9BD6 /* Basic-Car-MaintenanceTests */, - FF5D13BD2A86C2D800BC9BD6 /* Basic-Car-MaintenanceUITests */, + FF5D13B32A86C2D800BC9BD6 /* Basic-Car-Maintenance-Tests */, + FF5D13BD2A86C2D800BC9BD6 /* Basic-Car-Maintenance-UITests */, + FFDADF7C2ACD35A100DDEF79 /* Basic-Car-Maintenance-WidgetExtension */, ); }; /* End PBXProject section */ @@ -379,10 +364,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - FF5D13AF2A86C2D800BC9BD6 /* Preview Assets.xcassets in Resources */, - FFC8CDA42AA385E800D129A6 /* GoogleService-Info.plist in Resources */, - FF5D13AB2A86C2D800BC9BD6 /* Assets.xcassets in Resources */, - FF755B432A90915E00F49A13 /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -400,10 +381,17 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FFDADF7B2ACD35A100DDEF79 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - FF748B5A2AB34A61004748A5 /* SwiftLintScript */ = { + FF50DDC22B07DF0C00E87362 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; @@ -412,15 +400,19 @@ inputFileListPaths = ( ); inputPaths = ( + " $(SRCROOT)/${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}", + " $(SRCROOT)/${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${PRODUCT_NAME}", + " $(SRCROOT)/${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist", + " $(SRCROOT)/$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/GoogleService-Info.plist", + " $(SRCROOT)/$(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)", ); - name = SwiftLintScript; outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n\nif [[ \"$(uname -m)\" == arm64 ]]; then\n export PATH=\"/opt/homebrew/bin:$PATH\"\nfi\n\nif which swiftlint > /dev/null; then\n swiftlint\nelse\n echo \"warning: SwiftLint not installed\"\nfi\n"; + shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n\n\"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run\"\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -429,19 +421,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - FFBFE0912A98EFEC000A9BEB /* MaintenanceEvent.swift in Sources */, - FF755B492A909A0000F49A13 /* AddMaintenanceView.swift in Sources */, - FF09FC912AB6FF44006BE61A /* AuthenticationView.swift in Sources */, - FF755B3E2A908E7A00F49A13 /* SettingsView.swift in Sources */, - FF3DDF522AA4D28F009D91C4 /* DashboardViewModel.swift in Sources */, - FFBFE0972A98F7CB000A9BEB /* AddVehicleView.swift in Sources */, - FFC67D1D2AAEF7920073B338 /* SettingsViewModel.swift in Sources */, - FF755B3C2A908E3E00F49A13 /* DashboardView.swift in Sources */, - FFBFE0932A98F212000A9BEB /* Vehicle.swift in Sources */, - FF5D13A72A86C2D600BC9BD6 /* BasicCarMaintenanceApp.swift in Sources */, - FF748B5E2AB3589C004748A5 /* AuthenticationViewModel.swift in Sources */, - FF755B462A90969D00F49A13 /* Bundle+extension.swift in Sources */, - FFAA56ED2AC8905C000120EE /* Documentation.docc in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -449,7 +428,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - FF5D13B92A86C2D800BC9BD6 /* BasicCarMaintenanceTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -457,14 +435,23 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - FF5D13C52A86C2D800BC9BD6 /* BasicCarMaintenanceUITestsLaunchTests.swift in Sources */, - FF5D13C32A86C2D800BC9BD6 /* BasicCarMaintenanceUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FFDADF792ACD35A100DDEF79 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + C90C058C2CB0FC8000046C42 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + productRef = C90C058B2CB0FC8000046C42 /* SwiftLintBuildToolPlugin */; + }; FF5D13B62A86C2D800BC9BD6 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = FF5D13A22A86C2D600BC9BD6 /* Basic-Car-Maintenance */; @@ -475,15 +462,22 @@ target = FF5D13A22A86C2D600BC9BD6 /* Basic-Car-Maintenance */; targetProxy = FF5D13BF2A86C2D800BC9BD6 /* PBXContainerItemProxy */; }; + FFDADF8D2ACD35A200DDEF79 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = FFDADF7C2ACD35A100DDEF79 /* Basic-Car-Maintenance-WidgetExtension */; + targetProxy = FFDADF8C2ACD35A200DDEF79 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ FF5D13C62A86C2D800BC9BD6 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = FF098EFA2AB3424E003EC0FE /* Basic-Car-Maintenance.xcconfig */; + baseConfigurationReferenceAnchor = FF52DED52CADEA150023F8DE /* Configurations */; + baseConfigurationReferenceRelativePath = Project.xcconfig; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -513,7 +507,8 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -542,10 +537,12 @@ }; FF5D13C72A86C2D800BC9BD6 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = FF098EFA2AB3424E003EC0FE /* Basic-Car-Maintenance.xcconfig */; + baseConfigurationReferenceAnchor = FF52DED52CADEA150023F8DE /* Configurations */; + baseConfigurationReferenceRelativePath = Project.xcconfig; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -575,6 +572,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -596,16 +594,20 @@ }; FF5D13C92A86C2D800BC9BD6 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = FF098EFA2AB3424E003EC0FE /* Basic-Car-Maintenance.xcconfig */; buildSettings = { + ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = ""; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = "Basic-Car-Maintenance/Basic_Car_Maintenance.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Basic-Car-Maintenance/Preview Content\""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Basic-Car-Maintenance/Shared/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = "Basic Car"; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; @@ -617,16 +619,15 @@ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 13.4; - MARKETING_VERSION = 1.0; + MACOSX_DEPLOYMENT_TARGET = 15.0; OTHER_LDFLAGS = "-ObjC"; - PRODUCT_BUNDLE_IDENTIFIER = "com.icyappstudio.Basic-Car-Maintenance"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -635,16 +636,20 @@ }; FF5D13CA2A86C2D800BC9BD6 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = FF098EFA2AB3424E003EC0FE /* Basic-Car-Maintenance.xcconfig */; buildSettings = { + ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = ""; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = "Basic-Car-Maintenance/Basic_Car_Maintenance.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Basic-Car-Maintenance/Preview Content\""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Basic-Car-Maintenance/Shared/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = "Basic Car"; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; @@ -656,16 +661,15 @@ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 13.4; - MARKETING_VERSION = 1.0; + MACOSX_DEPLOYMENT_TARGET = 15.0; OTHER_LDFLAGS = "-ObjC"; - PRODUCT_BUNDLE_IDENTIFIER = "com.icyappstudio.Basic-Car-Maintenance"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -674,16 +678,15 @@ }; FF5D13CC2A86C2D800BC9BD6 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = FF52DED52CADEA150023F8DE /* Configurations */; + baseConfigurationReferenceRelativePath = UnitTests.xcconfig; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - MACOSX_DEPLOYMENT_TARGET = 13.4; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "com.icyappstudio.Basic-Car-MaintenanceTests"; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MACOSX_DEPLOYMENT_TARGET = 15.0; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -696,16 +699,15 @@ }; FF5D13CD2A86C2D800BC9BD6 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = FF52DED52CADEA150023F8DE /* Configurations */; + baseConfigurationReferenceRelativePath = UnitTests.xcconfig; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - MACOSX_DEPLOYMENT_TARGET = 13.4; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "com.icyappstudio.Basic-Car-MaintenanceTests"; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MACOSX_DEPLOYMENT_TARGET = 15.0; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -718,15 +720,14 @@ }; FF5D13CF2A86C2D800BC9BD6 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = FF52DED52CADEA150023F8DE /* Configurations */; + baseConfigurationReferenceRelativePath = UITests.xcconfig; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - MACOSX_DEPLOYMENT_TARGET = 13.4; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "com.icyappstudio.Basic-Car-MaintenanceUITests"; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MACOSX_DEPLOYMENT_TARGET = 15.0; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -739,15 +740,14 @@ }; FF5D13D02A86C2D800BC9BD6 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = FF52DED52CADEA150023F8DE /* Configurations */; + baseConfigurationReferenceRelativePath = UITests.xcconfig; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - MACOSX_DEPLOYMENT_TARGET = 13.4; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "com.icyappstudio.Basic-Car-MaintenanceUITests"; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MACOSX_DEPLOYMENT_TARGET = 15.0; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; @@ -758,6 +758,65 @@ }; name = Release; }; + FFDADF902ACD35A200DDEF79 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = FF52DED52CADEA150023F8DE /* Configurations */; + baseConfigurationReferenceRelativePath = Widget.xcconfig; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = "Basic-Car-Maintenance-WidgetExtension.entitlements"; + CODE_SIGN_STYLE = Automatic; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Basic-Car-Maintenance-Widget/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "Basic-Car-Maintenance-Widget"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + FFDADF912ACD35A200DDEF79 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = FF52DED52CADEA150023F8DE /* Configurations */; + baseConfigurationReferenceRelativePath = Widget.xcconfig; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = "Basic-Car-Maintenance-WidgetExtension.entitlements"; + CODE_SIGN_STYLE = Automatic; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Basic-Car-Maintenance-Widget/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "Basic-Car-Maintenance-Widget"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -779,7 +838,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - FF5D13CB2A86C2D800BC9BD6 /* Build configuration list for PBXNativeTarget "Basic-Car-MaintenanceTests" */ = { + FF5D13CB2A86C2D800BC9BD6 /* Build configuration list for PBXNativeTarget "Basic-Car-Maintenance-Tests" */ = { isa = XCConfigurationList; buildConfigurations = ( FF5D13CC2A86C2D800BC9BD6 /* Debug */, @@ -788,7 +847,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - FF5D13CE2A86C2D800BC9BD6 /* Build configuration list for PBXNativeTarget "Basic-Car-MaintenanceUITests" */ = { + FF5D13CE2A86C2D800BC9BD6 /* Build configuration list for PBXNativeTarget "Basic-Car-Maintenance-UITests" */ = { isa = XCConfigurationList; buildConfigurations = ( FF5D13CF2A86C2D800BC9BD6 /* Debug */, @@ -797,29 +856,76 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + FFDADF922ACD35A200DDEF79 /* Build configuration list for PBXNativeTarget "Basic-Car-Maintenance-WidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FFDADF902ACD35A200DDEF79 /* Debug */, + FFDADF912ACD35A200DDEF79 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + C90C058A2CB0FC5800046C42 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SimplyDanny/SwiftLintPlugins"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.57.0; + }; + }; FFC8CDA52AA3867A00D129A6 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/firebase/firebase-ios-sdk.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 10.14.0; + minimumVersion = 12.0.0; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - FFC8CDA62AA3867A00D129A6 /* FirebaseAnalytics */ = { + 9DBF34432E9298F40081DF0E /* FirebaseFirestore */ = { isa = XCSwiftPackageProductDependency; package = FFC8CDA52AA3867A00D129A6 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseAnalytics; + productName = FirebaseFirestore; + }; + 9DBF34452E9299000081DF0E /* FirebaseAuth */ = { + isa = XCSwiftPackageProductDependency; + package = FFC8CDA52AA3867A00D129A6 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAuth; + }; + 9DBF34472E9299220081DF0E /* FirebaseCore */ = { + isa = XCSwiftPackageProductDependency; + package = FFC8CDA52AA3867A00D129A6 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseCore; + }; + C90C058B2CB0FC8000046C42 /* SwiftLintBuildToolPlugin */ = { + isa = XCSwiftPackageProductDependency; + package = C90C058A2CB0FC5800046C42 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */; + productName = "plugin:SwiftLintBuildToolPlugin"; + }; + FF153AFE2B07C3E000D0BA30 /* FirebaseCrashlytics */ = { + isa = XCSwiftPackageProductDependency; + package = FFC8CDA52AA3867A00D129A6 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseCrashlytics; + }; + FF4E82BD2AD39863004949AF /* FirebaseRemoteConfig */ = { + isa = XCSwiftPackageProductDependency; + package = FFC8CDA52AA3867A00D129A6 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseRemoteConfig; + }; + FF4E82C12AD39863004949AF /* FirebaseStorage */ = { + isa = XCSwiftPackageProductDependency; + package = FFC8CDA52AA3867A00D129A6 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseStorage; }; - FFC8CDA82AA3867A00D129A6 /* FirebaseAnalyticsSwift */ = { + FFC8CDA62AA3867A00D129A6 /* FirebaseAnalytics */ = { isa = XCSwiftPackageProductDependency; package = FFC8CDA52AA3867A00D129A6 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseAnalyticsSwift; + productName = FirebaseAnalytics; }; FFC8CDAA2AA3867A00D129A6 /* FirebaseAuth */ = { isa = XCSwiftPackageProductDependency; @@ -831,11 +937,6 @@ package = FFC8CDA52AA3867A00D129A6 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; productName = FirebaseFirestore; }; - FFC8CDAE2AA3867A00D129A6 /* FirebaseFirestoreSwift */ = { - isa = XCSwiftPackageProductDependency; - package = FFC8CDA52AA3867A00D129A6 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseFirestoreSwift; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = FF5D139B2A86C2D500BC9BD6 /* Project object */; diff --git a/Basic-Car-Maintenance.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Basic-Car-Maintenance.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f74a036e..dc53eee4 100644 --- a/Basic-Car-Maintenance.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Basic-Car-Maintenance.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,12 +1,22 @@ { + "originHash" : "e930e24c9c553650612765cdca20ed1a1250fa5cff303bbfe41305b5b6feb700", "pins" : [ { "identity" : "abseil-cpp-binary", "kind" : "remoteSourceControl", "location" : "https://github.com/google/abseil-cpp-binary.git", "state" : { - "revision" : "bfc0b6f81adc06ce5121eb23f628473638d67c5c", - "version" : "1.2022062300.0" + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", + "version" : "11.2.0" } }, { @@ -14,8 +24,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/firebase/firebase-ios-sdk.git", "state" : { - "revision" : "8a8ec57a272e0d31480fb0893dda0cf4f769b57e", - "version" : "10.15.0" + "revision" : "1cce11cf94d27e2fc194112cc7ad51e8fb279230", + "version" : "12.3.0" + } + }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "c7d04b7592d3a1d6f8b7ce4e103cfbcbd766f419", + "version" : "3.0.0" } }, { @@ -23,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleAppMeasurement.git", "state" : { - "revision" : "03b9beee1a61f62d32c521e172e192a1663a5e8b", - "version" : "10.13.0" + "revision" : "34f5306ed8c9d9493f6390b4c2a18f19ecad1da4", + "version" : "12.3.0" } }, { @@ -32,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleDataTransport.git", "state" : { - "revision" : "aae45a320fd0d11811820335b1eabc8753902a40", - "version" : "9.2.5" + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" } }, { @@ -41,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleUtilities.git", "state" : { - "revision" : "c38ce365d77b04a9a300c31061c5227589e5597b", - "version" : "7.11.5" + "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", + "version" : "8.1.0" } }, { @@ -50,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/grpc-binary.git", "state" : { - "revision" : "f1b366129d1125be7db83247e003fc333104b569", - "version" : "1.50.2" + "revision" : "cc0001a0cf963aa40501d9c2b181e7fc9fd8ec71", + "version" : "1.69.0" } }, { @@ -59,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/gtm-session-fetcher.git", "state" : { - "revision" : "d415594121c9e8a4f9d79cecee0965cf35e74dbd", - "version" : "3.1.1" + "revision" : "4d70340d55d7d07cc2fdf8e8125c4c126c1d5f35", + "version" : "4.4.0" } }, { @@ -68,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/interop-ios-for-google-sdks.git", "state" : { - "revision" : "2d12673670417654f08f5f90fdd62926dc3a2648", - "version" : "100.0.0" + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" } }, { @@ -77,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/firebase/leveldb.git", "state" : { - "revision" : "0706abcc6b0bd9cedfbb015ba840e4a780b5159b", - "version" : "1.22.2" + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" } }, { @@ -86,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/firebase/nanopb.git", "state" : { - "revision" : "819d0a2173aff699fb8c364b6fb906f7cdb1a692", - "version" : "2.30909.0" + "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", + "version" : "2.30910.0" } }, { @@ -95,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/promises.git", "state" : { - "revision" : "e70e889c0196c76d22759eb50d6a0270ca9f1d9e", - "version" : "2.3.1" + "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", + "version" : "2.4.0" } }, { @@ -104,10 +123,19 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-protobuf.git", "state" : { - "revision" : "3c54ab05249f59f2c6641dd2920b8358ea9ed127", - "version" : "1.24.0" + "revision" : "d72aed98f8253ec1aa9ea1141e28150f408cf17f", + "version" : "1.29.0" + } + }, + { + "identity" : "swiftlintplugins", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SimplyDanny/SwiftLintPlugins", + "state" : { + "revision" : "7a3d77f3dd9f91d5cea138e52c20cfceabf352de", + "version" : "0.58.2" } } ], - "version" : 2 + "version" : 3 } diff --git a/Basic-Car-Maintenance.xcodeproj/xcshareddata/IDETemplateMacros.plist b/Basic-Car-Maintenance.xcodeproj/xcshareddata/IDETemplateMacros.plist new file mode 100644 index 00000000..ab8fbe5f --- /dev/null +++ b/Basic-Car-Maintenance.xcodeproj/xcshareddata/IDETemplateMacros.plist @@ -0,0 +1,14 @@ + + + + + FILEHEADER + +// ___FILENAME___ +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + + \ No newline at end of file diff --git a/Basic-Car-Maintenance.xcodeproj/xcshareddata/xcschemes/Basic-Car-Maintenance-WidgetExtension.xcscheme b/Basic-Car-Maintenance.xcodeproj/xcshareddata/xcschemes/Basic-Car-Maintenance-WidgetExtension.xcscheme new file mode 100644 index 00000000..dfc216e7 --- /dev/null +++ b/Basic-Car-Maintenance.xcodeproj/xcshareddata/xcschemes/Basic-Car-Maintenance-WidgetExtension.xcscheme @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Basic-Car-Maintenance.xcodeproj/xcshareddata/xcschemes/Basic-Car-Maintenance.xcscheme b/Basic-Car-Maintenance.xcodeproj/xcshareddata/xcschemes/Basic-Car-Maintenance.xcscheme index b0da3245..25497343 100644 --- a/Basic-Car-Maintenance.xcodeproj/xcshareddata/xcschemes/Basic-Car-Maintenance.xcscheme +++ b/Basic-Car-Maintenance.xcodeproj/xcshareddata/xcschemes/Basic-Car-Maintenance.xcscheme @@ -1,6 +1,6 @@ + shouldUseLaunchSchemeArgsEnv = "YES"> + + + + @@ -46,10 +51,15 @@ + + + + @@ -73,6 +83,20 @@ ReferencedContainer = "container:Basic-Car-Maintenance.xcodeproj"> + + + + + + + + com.apple.security.files.user-selected.read-only + keychain-access-groups + + $(KEYCHAIN_SHARING_GROUP_IDENTIFIER) + diff --git a/Basic-Car-Maintenance/Documentation.docc/AppDesign.md b/Basic-Car-Maintenance/Documentation.docc/AppDesign.md new file mode 100644 index 00000000..81e512b7 --- /dev/null +++ b/Basic-Car-Maintenance/Documentation.docc/AppDesign.md @@ -0,0 +1,14 @@ +# App Design + +This app should follow the [Human Interface Guidelines](https://developer.apple.com/go/?id=higs) by Apple. + +## App Icon +The app icon contains a Car, Tools, and Gear + +Icons have the following colors: + +* Yellow `#FFC91F` +* Red `#DA1918` +* Blue `#253AA7` +* Black `#000000` +* Orange `#F78616` diff --git a/Basic-Car-Maintenance/Documentation.docc/AppStoreListing.md b/Basic-Car-Maintenance/Documentation.docc/AppStoreListing.md new file mode 100644 index 00000000..d293abcb --- /dev/null +++ b/Basic-Car-Maintenance/Documentation.docc/AppStoreListing.md @@ -0,0 +1,51 @@ +# App Store Listing + +## Overview + +This article contains suggested texts and settings for the App Store listing, which will be set up in App Store Connect after Hacktoberfest. + +Here's the Apple developer site covering how to set up an app's product page, as well as the many other things you can do with App Store Connect: [https://developer.apple.com/app-store-connect/](https://developer.apple.com/app-store-connect/) + +### App Information + +Set up the app's name and subtitle under **General** ▸ **App Information** in the **Localizable Information** section at the top of the page. You have a maximum of 30 characters for each field. According to [Appfigures](https://appfigures.com/resources/guides/app-name-optimization), you should portion out keywords between the name and the subtitle for optimal ASO, App Store Optimization, which is the magic of doing everything you can in your App Store listing to make it highly visible in the Store. + +**Name**: `Basic Auto Vehicle Maintenance` + +**Subtitle**: `Service Records & Mile Tracker` + +The remainder of the **General Information** on this page gets set when the app is first added to App Store Connect and cannot be changed thereafter, although primary and secondary category can be changed when releasing a new version if desired. + +- **Bundle ID** is set in Xcode and read from the app's build. +- **SKU** (which stands for Stock Keeping Unit) is an ID you assign and can see later in reports. It's not visible to users either in the Store or in your app, so we can use whatever short code we want here, like `BasicCarMaintenance`. +- **Content Rights** is a dialog in which you say whether or not your app uses third-party content and affirm that if it does, you have the legal rights to use this content. Currently, `No, it does not contain, show, or access third-party content` is the correct setting for this app. +- **Age Rating** contains a list of potentially concerning types of content your app may contain, such as profanity or simulated gambling, and you choose whether your app contains None, Infrequent/Mild, or Frequent/Intense amounts of that type of content. For this app, each type can be set to `None`. Next, you're asked whether your app offers unrestricted web access or instances of gambling. For this app, these should both be answered **No**. The final screen here shows the Age Rating based on your selections, which should be Age 4+ for this app. In the Advanced section, one can also choose to set the app as Made for Kids (which cannot ever be undone once it's been set and apparently also cannot be made available on visionOS) or Restrict to 17+. Neither of this make sense for this Basic Car Maintenance app. +- **License Agreement** is where you can use Apple's standard End User License Agreement (EULA) or paste in your own, then select the countries or regions to which your own custom agreement should apply. For this app, we can simply `Apply Apple's standard end user license agreement (EULA) to all countries or regions`. +- **Primary Language** is set in the build and will be `English (U.S.)` for this app. +- **Category** Looking at potential competitors for this app, there's no clear winner as to category. `Utilities` could be a logical choice, as could `Lifestyle`. At the time of this writing, Apple currently shows Lifestyle in Top Categories (without needed to tap See All) although it does not show Utilities, so this suggests we should pick `Lifestyle`. It's not clear whether there's a benefit to setting the optional Secondary category, so let's leave this unset. + +### Version Information +Enter the following metadata on the version information page, which appears under **iOS App** at the top left-hand side of the App Store tab in App Store Connect + +#### Description +`Basic Auto Vehicle Maintenance lets you easily log repairs and routine service. You can also track odometer readings, handy for business travel or just to figure out how far your fuel or battery charge gets you.` + +`This app began as a Hacktoberfest 2023 project and remains open source, maintained by Mikaela Caron. Look for mikaelacaron / Basic-Car-Maintenance and come contribute!` + +#### Keywords +You have 100 characters of keyword space, which get combined with the Name and Subtitle fields discussed above when a user searches the App Store. Ariel Michaeli of Appfigures recently advised to put the more important keywords closer to the start of this field. Separate keywords with commas; omit spaces. + +Suggested: `car,van,electric,bike,care,repair,manage,easy,simple,oil,change,tire,rotation,record,fuel,gas,charge` + +#### Privacy Info +The `PrivacyInfo.xcprivacy` file was created with https://www.privacymanifest.dev/ + +Checked APIs: +* User Defaults APIs with the usage reason CA92.1 +* Collected Data Types: + * Name - linked to identity and app functionality + * Email Address - linked to identity and app functionality + * Phone number - linked to identity and app functionality + * Photos or videos - linked to identity + * User ID - linked to identity and app functionality + diff --git a/Basic-Car-Maintenance/Documentation.docc/Changelog.md b/Basic-Car-Maintenance/Documentation.docc/Changelog.md new file mode 100644 index 00000000..396e3fe7 --- /dev/null +++ b/Basic-Car-Maintenance/Documentation.docc/Changelog.md @@ -0,0 +1,9 @@ +# Changelog + +Features and changes of released versions on the App Store (link coming soon) + +## v1.0 +* Anonymous Authentication +* Enter unlimited vehicles +* Add / edit / delete vehicles +* Store vehicle data in Firebase Firestore diff --git a/Basic-Car-Maintenance/Documentation.docc/Documentation.md b/Basic-Car-Maintenance/Documentation.docc/Documentation.md index ca1d2c9d..7edcec7c 100644 --- a/Basic-Car-Maintenance/Documentation.docc/Documentation.md +++ b/Basic-Car-Maintenance/Documentation.docc/Documentation.md @@ -1,7 +1,17 @@ -# Basic-Car-Maintenance +# ``Basic_Car_Maintenance`` A basic app to track your car's maintenance, like oil changes, tire rotation, etc. ## Overview -This app is open source for Hacktoberfest 2023! More documentation to be added soon! +This app is open source for [Hacktoberfest 2024](https://hacktoberfest.com/)! + +Use this app to gain experience getting started in open source for iOS and macOS development using Swift and SwiftUI. + +More documentation to be added soon! + +## Topics + +### Essentials +- +- diff --git a/Basic-Car-Maintenance/Documentation.docc/FirestoreCollections.md b/Basic-Car-Maintenance/Documentation.docc/FirestoreCollections.md new file mode 100644 index 00000000..1aa692bb --- /dev/null +++ b/Basic-Car-Maintenance/Documentation.docc/FirestoreCollections.md @@ -0,0 +1,78 @@ +# 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 +will only be displayed to the user once. + +| Fields | Firestore Type | Description | +| ---------- | -------------- | ----------- | +| actionText | Text | Text that will appear on the action button +| actionURL | Text | URL that the user will be redirected to when the action button is tapped +| emojiIcon | Text | The emoji that will appear on the alert message +| id | Text | Identifies a specific alert +| isOn | Boolean | Toggles if the alert is active. Only one alert will be active at a time. +| message | Text | Text content of the alert +| title | Text | Main title given to the alert + +### maintenance_events + +Maintenance events collection contains the individual maintenance events that are associated +with a specific user. + +| Fields | Firestore Type | Description | +| --------- | -------------- | ----------- | +| id | DocumentID | Unique maintenance event identifier that is auto populated by Firestore +| userID | Text | Identifies the user associated with the maintenance event +| title | Text | Customized name given to the maintenance event +| date | Date | Date that the maintenance event was recorded +| notes | Text | Extra details about the maintenance event +| vehicleID | Text | Identifies the vehicle associated with the maintenance event + +### vehicles + +The vehicles collection contains all the vehicles associated with a specific user. + +| Fields | Firestore Type | Description | +| ------------------ | -------------- | ----------- | +| id | DocumentID | Unique vehicle identifier that is auto populated by Firestore +| userID | Text | Identifies the user associated with the vehicle +| name | Text | Customized name of the vehicle +| make | Text | The brand of the vehicle +| model | Text | Model of the vehicle for this brand +| year | Text | Year the vehicle was released by the manufacturer +| color | Text | Manufacturer color associated with the vehicle +| vin | Text | A Unique vehicle identification number associated with the manufacturer +| licensePlateNumber | Text | Number associated with a specific license plate + +## Firestore Rules + +**alerts** : read-only for all users + +**vehicles**: Authorized users can ready and write to vehicles collection that is associated with their `userID`. With `rules_version` set to `2`, the subcollections (`maintenance_events` and `odometer_readings`) will automatically have the same rules + +> At the moment this is recommended, but not in production yet, because this is failing in the emulator + + +``` +rules_version = '2'; + +service cloud.firestore { + match /databases/{database}/documents { + + match /alerts/{document=**} { + allow read; + } + + match /vehicles/{vehicleId}/{document=**} { + // Allow users to create vehicles if authenticated + allow create: if request.auth != null; + allow read, update, delete: if request.auth != null && resource.data.userID == request.auth.uid; + } + } +} +``` diff --git a/Basic-Car-Maintenance/Documentation.docc/Images/1-intro.png b/Basic-Car-Maintenance/Documentation.docc/Images/1-intro.png new file mode 100644 index 00000000..b134f577 Binary files /dev/null and b/Basic-Car-Maintenance/Documentation.docc/Images/1-intro.png differ diff --git a/Basic-Car-Maintenance/Documentation.docc/Images/10-checkout-dev.png b/Basic-Car-Maintenance/Documentation.docc/Images/10-checkout-dev.png new file mode 100644 index 00000000..dd2c6112 Binary files /dev/null and b/Basic-Car-Maintenance/Documentation.docc/Images/10-checkout-dev.png differ diff --git a/Basic-Car-Maintenance/Documentation.docc/Images/11-fetch.png b/Basic-Car-Maintenance/Documentation.docc/Images/11-fetch.png new file mode 100644 index 00000000..768c04cd Binary files /dev/null and b/Basic-Car-Maintenance/Documentation.docc/Images/11-fetch.png differ diff --git a/Basic-Car-Maintenance/Documentation.docc/Images/12-pull.png b/Basic-Car-Maintenance/Documentation.docc/Images/12-pull.png new file mode 100644 index 00000000..19cdd346 Binary files /dev/null and b/Basic-Car-Maintenance/Documentation.docc/Images/12-pull.png differ diff --git a/Basic-Car-Maintenance/Documentation.docc/Images/13-checkout-feature.png b/Basic-Car-Maintenance/Documentation.docc/Images/13-checkout-feature.png new file mode 100644 index 00000000..529884f4 Binary files /dev/null and b/Basic-Car-Maintenance/Documentation.docc/Images/13-checkout-feature.png differ diff --git a/Basic-Car-Maintenance/Documentation.docc/Images/14-merge-dev.png b/Basic-Car-Maintenance/Documentation.docc/Images/14-merge-dev.png new file mode 100644 index 00000000..be9a086b Binary files /dev/null and b/Basic-Car-Maintenance/Documentation.docc/Images/14-merge-dev.png differ diff --git a/Basic-Car-Maintenance/Documentation.docc/Images/15-push.png b/Basic-Car-Maintenance/Documentation.docc/Images/15-push.png new file mode 100644 index 00000000..635e86ed Binary files /dev/null and b/Basic-Car-Maintenance/Documentation.docc/Images/15-push.png differ diff --git a/Basic-Car-Maintenance/Documentation.docc/Images/2-fork.png b/Basic-Car-Maintenance/Documentation.docc/Images/2-fork.png new file mode 100644 index 00000000..ef81a8ee Binary files /dev/null and b/Basic-Car-Maintenance/Documentation.docc/Images/2-fork.png differ diff --git a/Basic-Car-Maintenance/Documentation.docc/Images/3-clone.png b/Basic-Car-Maintenance/Documentation.docc/Images/3-clone.png new file mode 100644 index 00000000..62a922b1 Binary files /dev/null and b/Basic-Car-Maintenance/Documentation.docc/Images/3-clone.png differ diff --git a/Basic-Car-Maintenance/Documentation.docc/Images/4-config.png b/Basic-Car-Maintenance/Documentation.docc/Images/4-config.png new file mode 100644 index 00000000..ba4adf83 Binary files /dev/null and b/Basic-Car-Maintenance/Documentation.docc/Images/4-config.png differ diff --git a/Basic-Car-Maintenance/Documentation.docc/Images/5-dev-id.png b/Basic-Car-Maintenance/Documentation.docc/Images/5-dev-id.png new file mode 100644 index 00000000..77c9fb16 Binary files /dev/null and b/Basic-Car-Maintenance/Documentation.docc/Images/5-dev-id.png differ diff --git a/Basic-Car-Maintenance/Documentation.docc/Images/6-build.png b/Basic-Car-Maintenance/Documentation.docc/Images/6-build.png new file mode 100644 index 00000000..d0612deb Binary files /dev/null and b/Basic-Car-Maintenance/Documentation.docc/Images/6-build.png differ diff --git a/Basic-Car-Maintenance/Documentation.docc/Images/7-issue.png b/Basic-Car-Maintenance/Documentation.docc/Images/7-issue.png new file mode 100644 index 00000000..10136a00 Binary files /dev/null and b/Basic-Car-Maintenance/Documentation.docc/Images/7-issue.png differ diff --git a/Basic-Car-Maintenance/Documentation.docc/Images/8-checkout.png b/Basic-Car-Maintenance/Documentation.docc/Images/8-checkout.png new file mode 100644 index 00000000..d5e1f53a Binary files /dev/null and b/Basic-Car-Maintenance/Documentation.docc/Images/8-checkout.png differ diff --git a/Basic-Car-Maintenance/Documentation.docc/Images/9-sync.png b/Basic-Car-Maintenance/Documentation.docc/Images/9-sync.png new file mode 100644 index 00000000..12ab06d5 Binary files /dev/null and b/Basic-Car-Maintenance/Documentation.docc/Images/9-sync.png differ 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/Documentation.docc/Images/xcode-about.png b/Basic-Car-Maintenance/Documentation.docc/Images/xcode-about.png new file mode 100644 index 00000000..aa3d3fef Binary files /dev/null and b/Basic-Car-Maintenance/Documentation.docc/Images/xcode-about.png differ diff --git a/Basic-Car-Maintenance/Documentation.docc/Tutorial Table of Contents.tutorial b/Basic-Car-Maintenance/Documentation.docc/Tutorial Table of Contents.tutorial new file mode 100644 index 00000000..ac5f1fd2 --- /dev/null +++ b/Basic-Car-Maintenance/Documentation.docc/Tutorial Table of Contents.tutorial @@ -0,0 +1,16 @@ +@Tutorials(name: "Basic-Car-Maintenance") { + @Intro(title: "Getting Started") { + How to get started contributing to this project + + @Image(source: 1-intro.png, alt: "Screenshot of Basic-Car-Maintenance GitHub page") + } + + @Chapter(name: "Getting Started") { + Prerequisites + + @Image(source: "xcode-about.png", alt: "Xcode 15 about screen") + + @TutorialReference(tutorial: "doc:getting-started") + @TutorialReference(tutorial: "doc:branch-updates") + } +} diff --git a/Basic-Car-Maintenance/Documentation.docc/branch-updates.tutorial b/Basic-Car-Maintenance/Documentation.docc/branch-updates.tutorial new file mode 100644 index 00000000..83245be9 --- /dev/null +++ b/Basic-Car-Maintenance/Documentation.docc/branch-updates.tutorial @@ -0,0 +1,69 @@ +@Tutorial(time: 10) { + @Intro(title: "Updating Feature Branch") { + During the PR process, it is important to make sure the local feature branch has been updated with the latest `dev` branch changes before pushing updates to the PR. Follow these instructions to keep a feature branch up to date with the latest `dev` changes during the PR process. + + } + + @Section(title: "Grab the Latest Changes") { + @ContentAndMedia { + Update local your `dev` branch with changes from the remote `dev` branch. + } + + @Steps { + @Step { + Sync your forked repo with the latest changes from `dev` branch by clicking the `Sync fork` button and following the prompts. + + @Image(source: 9-sync.png, alt: "GitHub repository sync button" ) + } + @Step { + In a command prompt, use `git checkout dev` to checkout the `dev` branch in your local repo. + @Image(source: 10-checkout-dev.png, alt: "Command prompt using git checkout dev") + } + + @Step { + Update the remote tracking branches by using `git fetch` + + Note - `git fetch` is optional as it is done within a `git pull` For a more in-depth discussion on these commands, see [Git Pull GitHub Guide](https://github.com/git-guides/git-pull) + + @Image(source: 11-fetch.png, alt: "Command prompt using git fetch") + } + @Step { + Update your current local working branch with `git pull` + + The changes shown in Terminal afterwards will look different than this screenshot. + + @Image(source: 12-pull.png, alt: "Command prompt using git pull") + } + } + } + + @Section(title: "Update Feature Branch and Push to PR") { + @ContentAndMedia { + Now that your local `dev` branch is up to date, merge these changes into your feature branch. This will make it ready to push. + } + + @Steps { + @Step { + Switch to your local feature branch by using `git checkout feature-branch` + + @Image(source: 13-checkout-feature.png, alt: "Command promopt using git checkout with example feature branch") + } + + @Step { + Merge your local dev branch into your local feature branch by using `git merge dev` + + Resolve any merge conflicts if applicable. See [Merge Conflicts in Command Line GitHub guide](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/addressing-merge-conflicts/resolving-a-merge-conflict-using-the-command-line) for more information. + + @Image(source: 14-merge-dev.png, alt: "Command prompt using git merge dev") + } + + @Step { + Push your local changes up the remote branch using `git push` + + The PR in the original `Basic-Car-Maintenance` repo will automatically update with these changes. + + @Image(source: 15-push.png, alt: "Command prompt using git push") + } + } + } +} diff --git a/Basic-Car-Maintenance/Documentation.docc/getting-started.tutorial b/Basic-Car-Maintenance/Documentation.docc/getting-started.tutorial new file mode 100644 index 00000000..92c483b0 --- /dev/null +++ b/Basic-Car-Maintenance/Documentation.docc/getting-started.tutorial @@ -0,0 +1,181 @@ +@Tutorial(time: 20) { + @Intro(title: "How to Contribute to this Project") { + Here's instructions on how to prepare, fork, clone, configure, build and contribute to this project. This tutorial uses the [GitHub CLI](https://cli.github.com/) + + } + + @Section(title: "Prerequisites") { + + @ContentAndMedia { + Complete these steps before beginning. + + @Image(source: 1-intro.png, alt:"GitHub Screenshot") + } + + @Steps { + @Step { + Read the Code of Conduct + } + @Step { + Read the CONTRIBUTING guidelines + } + @Step { + Download [Xcode 16.0](https://developer.apple.com/xcode/resources/) or later. + + } + @Step { + Install [GitHub CLI](https://cli.github.com/) command line tools + } + + @Step { + Use `gh auth login` to authenticate with the [GitHub CLI](https://cli.github.com/manual/gh_auth_login) + } + } + } + + @Section(title: "Fork and Clone") { + @ContentAndMedia { + Fork and clone + + @Image(source: 2-fork.png, alt: "GitHub Fork") + } + + @Steps { + @Step { + Fork the repo on GitHub to your own account. Select Fork from the Basic-Car-Maintenance github page. + + This example shows a fork to `ampsonic`'s profile + + @Image(source: 2-fork.png, alt: "GitHub Fork") + } + + @Step { + Clone your fork to your computer. + + `gh repo clone USERNAME/Basic-Car-Maintenance` + + @Image(source: 3-clone.png, alt: "CLI github clone") + } + } + } + @Section(title: "Configuring the project with the xcconfig files to build the project on your machine") { + @ContentAndMedia { + Copy the template file, edit it to include your development team ID. Set an appropriate bundle id. + + @Image(source: 4-config.png, alt: "Copying template file") + } + + @Steps { + @Step { + In the same folder that contains the Basic-Car-Maintenance.xcconfig.template, run this command, in Terminal, to create a new Xcode configuration file (which properly sets up the signing information) + + `cp Basic-Car-Maintenance.xcconfig.template Basic-Car-Maintenance.xcconfig` + + @Image(source: 4-config.png, alt: "Copying template file") + } + + @Step { + In the Basic-Car-Maintenance.xcconfig file, fill in your DEVELOPMENT_TEAM and PRODUCT_BUNDLE_IDENTIFIER. You can find this by logging into the Apple Developer Portal + + Note: A free account does not have a `DEVELOPMENT_TEAM`. Instead fill in `ABC123`. Do NOT run this app on a real device due to issues with the Sign in With Apple capability. + + @Image(source: 5-dev-id.png, alt: "Edit config with dev id") + } + @Step { + Build project in Xcode + + @Image(source: 6-build.png, alt: "Basic Car Maitnenace running in simulator") + } + } + } + + @Section(title: "Setting Up Firebase Local Emulator") { + @ContentAndMedia { + Follow these steps to set up the Firebase Local Emulator to load data locally and not affect production. Please do not skip this step. + } + + @Steps { + @Step { + Install Homebrew, a package manager for macOS, if you haven't already: + + `/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"` + } + @Step { + Install Xcode command line tools: + + `xcode-select --install` + } + @Step { + Install Node Version Manager (NVM). You then don't need to update the system node version. + + `brew install nvm` + } + @Step { + Add the executable to the `$PATH` via `.zshrc` or `.bashrc` file as prompted after installation. Do NOT forget this! (and then restart your Terminal) + } + @Step { + Download and use the latest stable version of Node.js: + + `nvm install stable` + } + @Step { + `nvm use stable` + } + @Step { + Install OpenJDK, and add the executable to the `$PATH` via `.zshrc` or `.bashrc` file as prompted after installation. Do NOT forget this! (and then restart your Terminal) + + `brew install openjdk` + } + @Step { + Install Firebase Tools for running the emulator. + + `npm install -g firebase-tools` + } + } + } + + @Section(title: "Start Working on an Issue") { + @ContentAndMedia { + **BEFORE** starting on an issue, comment on the issue you want to work on. + + This prevents two people from working on the same issue. [Mikaela](https://github.com/mikaelacaron) (the maintainer) will assign you that issue, and you can get started on it. + + @Image(source: 7-issue.png, alt: "GitHub issue") + } + + @Steps { + @Step { + Checkout a new branch (from the `dev` branch) to work on an issue. + + The `feature-name` part of the branch can be shortened or omitted and you add your username instead. No commits should be made to the `main` branch directly. The `main` branch shall only consist of the deployed code. Developers are expected to work on feature branches, and upon successful development and testing, a PR (pull request) must be opened to merge with `dev`. `git checkout -b issueNumber-feature-name` + + @Image(source: 8-checkout.png, alt: "Checkout issue locally") + } + @Step { + Anytime you run the project, first in Terminal `cd` to `backend` in the Basic-Car-Maintenance directory. This is the directory with the `firebase.json` file, you should see that if you type `ls` + + `cd backend` + } + @Step { + Start the Firebase Emulator. Which will start the emulators, and keep your data in local-data directory. Meaning when you start and stop the emulator your data will persist. + + `firebase emulators:start --import=./local-data --export-on-exit` + } + @Step { + Run the app. You should see your anonymous user in Authentication, and once you add new data, see it in Firestore emulator UI at: http://127.0.0.1:4000/firestore + + If you don't see your user, delete the app from the simulator, and in the menu go to Device > Erase All Content and Settings (which resets your simulator), and try to run again + } + + @Step { + If you receive the following error when you launch the emulator: _'firebase-tools no longer supports Java version before 11. Please upgrade to Java version 11 or above to continue using the emulators'_ + + The openJDK install failed and you will have to install the latest JDK manually. You can download the latest version here [JDK23](https://www.oracle.com/java/technologies/downloads/#jdk23-mac) + } + + @Step { + When your feature or fix is complete, open a pull request (PR) from your feature branch to the `dev` branch. Make sure to use a descriptive PR title and fill out the entire PR template without deleting any sections. + } + } + } +} diff --git a/Basic-Car-Maintenance/Shared/BasicCarMaintenanceApp.swift b/Basic-Car-Maintenance/Shared/BasicCarMaintenanceApp.swift index 193875bc..05e71e08 100644 --- a/Basic-Car-Maintenance/Shared/BasicCarMaintenanceApp.swift +++ b/Basic-Car-Maintenance/Shared/BasicCarMaintenanceApp.swift @@ -2,40 +2,112 @@ // BasicCarMaintenanceApp.swift // Basic-Car-Maintenance // -// Created by Mikaela Caron on 8/11/23. +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. // +import FirebaseAuth import FirebaseCore +import FirebaseFirestore import SwiftUI - -class AppDelegate: NSObject, UIApplicationDelegate { - func application(_ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil - ) -> Bool { - FirebaseApp.configure() - return true - } -} +import TipKit @main struct BasicCarMaintenanceApp: App { + @State private var actionService = ActionService.shared @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate - @StateObject var authenticationViewModel = AuthenticationViewModel() + // Logic to load Onboarding screen when app was first launched +// @AppStorage("isFirstTime") private var isFirstTime: Bool = true var body: some Scene { WindowGroup { - TabView { - DashboardView(authenticationViewModel: authenticationViewModel) - .tabItem { - Label("Dashboard", systemImage: "list.dash.header.rectangle") - } - - SettingsView(authenticationViewModel: authenticationViewModel) - .tabItem { - Label("Settings", systemImage: "gear") - } + MainTabView() + .environment(actionService) + .modelContainer(for: [AcknowledgedAlert.self]) + .task { + try? Tips.configure() + } +// .sheet(isPresented: $isFirstTime) { +// WelcomeView() +// .interactiveDismissDisabled() +// } + } + } +} + +class AppDelegate: NSObject, UIApplicationDelegate { + private let actionService = ActionService.shared + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + + FirebaseApp.configure() + + let useEmulator = true + if useEmulator { + let settings = Firestore.firestore().settings + settings.host = "localhost:8080" + settings.cacheSettings = MemoryCacheSettings() + settings.isSSLEnabled = false + Firestore.firestore().settings = settings + + Auth.auth().useEmulator(withHost: "127.0.0.1", port: 9099) + + let accessGroup = Bundle.main.keychainAccessGroup + do { + try Auth.auth().useUserAccessGroup(accessGroup) + } catch let error as NSError { + print("Error changing user access group: %@", error) } } + + return true + } + + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + // get the shortcut when the app is launching + if let shortcutItem = options.shortcutItem, + let action = Action(shortcutItem: shortcutItem) { + actionService.updateIncoming(action) + } + + let configuration = UISceneConfiguration( + name: connectingSceneSession.configuration.name, + sessionRole: connectingSceneSession.role + ) + configuration.delegateClass = SceneDelegate.self + return configuration + } +} + +class SceneDelegate: NSObject, UIWindowSceneDelegate { + private let actionService = ActionService.shared + + func windowScene( + _ windowScene: UIWindowScene, + performActionFor shortcutItem: UIApplicationShortcutItem, + completionHandler: @escaping (Bool) -> Void + ) { + // get the shortcut if the app is already running + let action = Action(shortcutItem: shortcutItem) + actionService.updateIncoming(action) + completionHandler(true) + } +} + +@Observable +class ActionService: ObservableObject { + static let shared = ActionService() + private(set) var action: Action? + + fileprivate func updateIncoming(_ action: Action?) { + self.action = action } } diff --git a/Basic-Car-Maintenance/Shared/Dashboard/CSVEncoder.swift b/Basic-Car-Maintenance/Shared/Dashboard/CSVEncoder.swift new file mode 100644 index 00000000..435ecb6a --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Dashboard/CSVEncoder.swift @@ -0,0 +1,195 @@ +// +// CSVEncoder.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import Foundation + +extension BidirectionalCollection where Element == String { + var commaDelimited: String { joined(separator: ",") } + var newlineDelimited: String { joined(separator: "\r\n") } +} + +struct CSVColumn { + /// The header name to use for the column in the CSV file's first row. + private(set) var header: String + + private(set) var attribute: (Record) -> CSVEncodable + + init( _ header: String, attribute: @escaping (Record) -> CSVEncodable) { + self.header = header + self.attribute = attribute + } + + init (_ header: String, _ keyPath: KeyPath) { + self.init(header, attribute: { $0[keyPath: keyPath] }) + } +} + +protocol CSVEncodable { + /// Derive the string representation to be used in the exported CSV. + func encode(configuration: CSVEncoderConfiguration) -> String +} + +extension String: CSVEncodable { + func encode(configuration: CSVEncoderConfiguration) -> String { + self + } +} + +extension Date: CSVEncodable { + func encode(configuration: CSVEncoderConfiguration) -> String { + switch configuration.dateEncodingStrategy { + case .deferredToDate: + String(self.timeIntervalSinceReferenceDate) + case .iso8601: + ISO8601DateFormatter().string(from: self) + case .formatted(let dateFormatter): + dateFormatter.string(from: self) + case .custom(let customFunc): + customFunc(self) + } + } +} + +extension UUID: CSVEncodable { + func encode(configuration: CSVEncoderConfiguration) -> String { + uuidString + } +} + +extension Int: CSVEncodable { + func encode(configuration: CSVEncoderConfiguration) -> String { + String(self) + } +} + +extension Double: CSVEncodable { + func encode(configuration: CSVEncoderConfiguration) -> String { + String(self) + } +} + +extension Bool: CSVEncodable { + func encode(configuration: CSVEncoderConfiguration) -> String { + let (trueValue, falseValue) = configuration.encodingValues + + return self == true ? trueValue : falseValue + } +} + +extension Optional: CSVEncodable where Wrapped: CSVEncodable { + func encode(configuration: CSVEncoderConfiguration) -> String { + switch self { + case .none: + "" + case .some(let wrapped): + wrapped.encode(configuration: configuration) + } + } +} + +extension CSVEncodable { + func escapedOutput(configuration: CSVEncoderConfiguration) -> String { + let output = self.encode(configuration: configuration) + if output.contains(",") || output.contains("\"") || output.contains(#"\n"#) + || output.hasPrefix(" ") || output.hasSuffix(" ") { + // Escape existing double quotes by doubling them + let escapedQuotes = output.replacingOccurrences(of: "\"", with: "\"\"") + + // Wrap the string in double quotes + return "\"\(escapedQuotes)\"" + } else { + // No special characters, return as is + return output + } + } +} + +struct CSVEncoderConfiguration { + /// The strategy to use when encoding dates. + private(set) var dateEncodingStrategy: DateEncodingStrategy = .iso8601 + + /// The strategy to use when encoding Boolean values. + private(set) var boolEncodingStrategy: BoolEncodingStrategy = .trueFalse + + init( + dateEncodingStrategy: DateEncodingStrategy = .iso8601, + boolEncodingStrategy: BoolEncodingStrategy = .trueFalse + ) { + self.dateEncodingStrategy = dateEncodingStrategy + self.boolEncodingStrategy = boolEncodingStrategy + } + + /// The strategy to use when encoding `Date` objects for CSV output. + enum DateEncodingStrategy { + case deferredToDate + case iso8601 + case formatted(DateFormatter) + case custom(@Sendable (Date) -> String) + } + + /// The strategy to use when encoding `Bool` objects for CSV output. + enum BoolEncodingStrategy { + case trueFalse + case trueFalseUppercase + case yesNo + case yesNoUppercase + case integer + case custom(true: String, false: String) + } + + var encodingValues: (String, String) { + switch boolEncodingStrategy { + case .trueFalse: + return ("true", "false") + case .trueFalseUppercase: + return ("TRUE", "FALSE") + case .yesNo: + return ("yes", "no") + case .yesNoUppercase: + return ("YES", "NO") + case .integer: + return ("1", "0") + case .custom(let trueValue, let falseValue): + return (trueValue, falseValue) + } + } + + static var `default`: CSVEncoderConfiguration = CSVEncoderConfiguration() +} + +struct CSVTable { + /// A description of all the columns of the CSV file, order from left to right. + private(set) var columns: [CSVColumn] + + /// The set of configuration parameters to use while encoding attributes and the whole file. + private(set) var configuration: CSVEncoderConfiguration + + private var headers: String { + columns.map { $0.header.escapedOutput(configuration: configuration) }.commaDelimited + } + + /// Create a CSV table definition. + init( + columns: [CSVColumn], + configuration: CSVEncoderConfiguration = .default + ) { + self.columns = columns + self.configuration = configuration + } + + /// Constructs a CSV text file structure from the given rows of data. + func export(rows: any Sequence) -> String { + ([headers] + allRows(rows: rows)).newlineDelimited + } + + private func allRows(rows: any Sequence) -> [String] { + rows.map { row in + columns.map { $0.attribute(row).escapedOutput(configuration: configuration) }.commaDelimited + } + } +} diff --git a/Basic-Car-Maintenance/Shared/Dashboard/CarMaintenancePDFGenerator.swift b/Basic-Car-Maintenance/Shared/Dashboard/CarMaintenancePDFGenerator.swift new file mode 100644 index 00000000..138c8825 --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Dashboard/CarMaintenancePDFGenerator.swift @@ -0,0 +1,186 @@ +// +// CarMaintenancePDFGenerator.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import UIKit +import PDFKit + +final class CarMaintenancePDFGenerator { + private let vehicleName: String + private let events: [MaintenanceEvent] + + // Define page margins + private let topMargin: CGFloat = 50 + private let bottomMargin: CGFloat = 50 + private let leftMargin: CGFloat = 20 + private let rightMargin: CGFloat = 20 + private let columnWidth: CGFloat + private let documentsDirectory = FileManager + .default + .urls(for: .documentDirectory, in: .userDomainMask) + .first + + init(vehicleName: String, events: [MaintenanceEvent]) { + self.vehicleName = vehicleName + self.events = events + self.columnWidth = (PageDimension.A4.pageWidth - leftMargin - rightMargin) / 3 + } + + // swiftlint:disable:next function_body_length + func generatePDF() -> PDFDocument? { + guard !events.isEmpty else { return nil } + let pdfRenderer = UIGraphicsPDFRenderer(bounds: CGRect(origin: .zero, size: PageDimension.A4.size)) + let pdfData = pdfRenderer.pdfData { context in + var yPosition: CGFloat = topMargin + + beginNewPage( + context: context, + yPosition: &yPosition, + isFirstPage: true + ) + + let tableRowAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 14), + .foregroundColor: UIColor.black + ] + + for (index, event) in events.enumerated() { + if yPosition + 60 > PageDimension.A4.pageHeight - bottomMargin { + beginNewPage( + context: context, + yPosition: &yPosition, + isFirstPage: index == 0 + ) + } + + event.date + .formatted() + .draw(at: CGPoint(x: leftMargin, y: yPosition), withAttributes: tableRowAttributes) + + vehicleName.draw( + at: CGPoint(x: leftMargin + columnWidth, y: yPosition), + withAttributes: tableRowAttributes + ) + + let notesRect = CGRect( + x: leftMargin + 2 * columnWidth, + y: yPosition, + width: columnWidth - 20, + height: 50 + ) + event.notes.draw(in: notesRect, withAttributes: tableRowAttributes) + + yPosition += 60 + } + } + + do { + guard let fileURL = documentsDirectory? + .appendingPathComponent("\(vehicleName)-MaintenanceReport.pdf") + else { return nil } + + if FileManager.default.fileExists(atPath: fileURL.absoluteString) { + try FileManager.default.removeItem(at: fileURL) + } + + try pdfData.write(to: fileURL) + print("PDF saved to: \(fileURL.path)") + return PDFDocument(url: fileURL) + } catch { + print("Could not save the PDF: \(error)") + return nil + } + } + + /// Draw the center header and header columns + private func drawHeader(context: UIGraphicsPDFRendererContext, yPosition: inout CGFloat) { + let titleAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.boldSystemFont(ofSize: 20), + .foregroundColor: UIColor.black + ] + let subtitleAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 16), + .foregroundColor: UIColor.black + ] + + let titleString = "Basic Car Maintenance" + let eventTitleString = "Maintenance Events" + guard + let startDate = events.first?.date.formatted(date: .numeric, time: .omitted), + let endDate = events.last?.date.formatted(date: .numeric, time: .omitted) + else { return } + let dateRangeString = "From \(startDate) to \(endDate)" + + let titleSize = titleString.size(withAttributes: titleAttributes) + let vehicleSize = vehicleName.size(withAttributes: subtitleAttributes) + let eventTitleSize = eventTitleString.size(withAttributes: subtitleAttributes) + let dateRangeSize = dateRangeString.size(withAttributes: subtitleAttributes) + + let titleX = (PageDimension.A4.pageWidth - titleSize.width) / 2 + let vehicleX = (PageDimension.A4.pageWidth - vehicleSize.width) / 2 + let eventTitleX = (PageDimension.A4.pageWidth - eventTitleSize.width) / 2 + let dateRangeX = (PageDimension.A4.pageWidth - dateRangeSize.width) / 2 + + titleString.draw(at: CGPoint(x: titleX, y: yPosition), withAttributes: titleAttributes) + yPosition += 30 + + vehicleName.draw(at: CGPoint(x: vehicleX, y: yPosition), withAttributes: subtitleAttributes) + yPosition += 30 + + eventTitleString.draw(at: CGPoint(x: eventTitleX, y: yPosition), withAttributes: subtitleAttributes) + yPosition += 30 + + dateRangeString.draw(at: CGPoint(x: dateRangeX, y: yPosition), withAttributes: subtitleAttributes) + yPosition += 50 + + drawColumnsHeaders(yPosition: &yPosition) + } + + private func beginNewPage( + context: UIGraphicsPDFRendererContext, + yPosition: inout CGFloat, + isFirstPage: Bool + ) { + context.beginPage() + yPosition = topMargin + + if isFirstPage { + drawHeader(context: context, yPosition: &yPosition) + } + } + + private func drawColumnsHeaders(yPosition: inout CGFloat) { + let dateColumnHeader = "Date" + let vehicleColumnHeader = "Vehicle Name" + let noteColumnHeader = "Notes" + + let subtitleAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.boldSystemFont(ofSize: 16), + .foregroundColor: UIColor.black + ] + + dateColumnHeader.draw( + at: CGPoint(x: leftMargin, y: yPosition), + withAttributes: subtitleAttributes + ) + + vehicleColumnHeader.draw( + at: CGPoint(x: leftMargin + columnWidth, y: yPosition), + withAttributes: subtitleAttributes + ) + + let notesRect = CGRect( + x: leftMargin + 2 * columnWidth, + y: yPosition, + width: columnWidth - 20, + height: 50 + ) + noteColumnHeader.draw(in: notesRect, withAttributes: subtitleAttributes) + + yPosition += 30 + } +} diff --git a/Basic-Car-Maintenance/Shared/Dashboard/ViewModels/DashboardViewModel.swift b/Basic-Car-Maintenance/Shared/Dashboard/ViewModels/DashboardViewModel.swift index 110de889..f35ab68c 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/ViewModels/DashboardViewModel.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/ViewModels/DashboardViewModel.swift @@ -2,67 +2,222 @@ // DashboardViewModel.swift // Basic-Car-Maintenance // -// Created by Mikaela Caron on 9/3/23. +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. // import FirebaseFirestore -import FirebaseFirestoreSwift import Foundation +import WidgetKit -@MainActor -class DashboardViewModel: ObservableObject { +@Observable +class DashboardViewModel { - let authenticationViewModel: AuthenticationViewModel + let userUID: String? - @Published var events = [MaintenanceEvent]() + var events = [MaintenanceEvent]() + var showAddErrorAlert = false + var showErrorAlert = false + var isShowingAddMaintenanceEvent = false + var errorMessage: String = "" + var sortOption: SortOption = .custom + var vehicles = [Vehicle]() + var searchText: String = "" + var isLoading = false + var selectedVehicleToSort: Vehicle? + + var sortedEvents: [MaintenanceEvent] { + switch sortOption { + case .oldestToNewest: + return events.sorted { $0.date < $1.date } + case .newestToOldest: + return events.sorted { $0.date > $1.date } + case .byVehicle: + if let vehicle = selectedVehicleToSort { + return events.filter { $0.vehicleID == vehicle.id } + } else { + return events + } + case .custom: + return events + } + } + + var vehiclesWithSortedEventsDict: [Vehicle: [MaintenanceEvent]] { + vehicles.reduce(into: [Vehicle: [MaintenanceEvent]]()) { result, currentVehicle in + result[currentVehicle] = events + .filter { $0.vehicleID == currentVehicle.id } + .sorted(by: { $0.date < $1.date }) + } + } - init(authenticationViewModel: AuthenticationViewModel) { - self.authenticationViewModel = authenticationViewModel + var searchedEvents: [MaintenanceEvent] { + if searchText.isEmpty { + sortedEvents + } else { + sortedEvents.filter { $0.title.localizedStandardContains(searchText) } + } + } + + init(userUID: String?) { + self.userUID = userUID } - func addEvent(_ maintenanceEvent: MaintenanceEvent) async { - if let uid = authenticationViewModel.user?.uid { + /// Adding a `MaintenanceEvent` in Firestore at: + /// `vehicles/{vehicleDocumentID}/maintenance_events/{maintenceEventDocumentID}` + /// - Parameter maintenanceEvent: The `MaintenanceEvent` to save + func addEvent(_ maintenanceEvent: MaintenanceEvent) { + if let uid = userUID { var eventToAdd = maintenanceEvent eventToAdd.userID = uid - try? Firestore - .firestore() - .collection("maintenance_events") - .addDocument(from: eventToAdd) + do { + try Firestore + .firestore() + .collection(FirestorePath.maintenanceEvents(vehicleID: eventToAdd.vehicleID).path) + .addDocument(from: eventToAdd) + + events.append(maintenanceEvent) + AnalyticsService.shared.logEvent(.maintenanceCreate) + + errorMessage = "" + isShowingAddMaintenanceEvent = false + } catch { + showAddErrorAlert.toggle() + errorMessage = error.localizedDescription + } } - - events.append(maintenanceEvent) } func getMaintenanceEvents() async { - if let uid = authenticationViewModel.user?.uid { + isLoading = true + + if let userUID = userUID { let db = Firestore.firestore() - let docRef = db.collection("maintenance_events").whereField("userID", isEqualTo: uid) - - let querySnapshot = try? await docRef.getDocuments() - - var events = [MaintenanceEvent]() - - if let querySnapshot { + do { + let docRef = db.collectionGroup(FirestoreCollection.maintenanceEvents) + .whereField(FirestoreField.userID, isEqualTo: userUID) + + let querySnapshot = try await docRef.getDocuments() + + var events = [MaintenanceEvent]() + for document in querySnapshot.documents { if let event = try? document.data(as: MaintenanceEvent.self) { events.append(event) } } + self.isLoading = false self.events = events + WidgetCenter.shared.reloadAllTimelines() + } catch { + self.isLoading = false } } + } + + func updateEvent(_ maintenanceEvent: MaintenanceEvent) async { + if let uid = userUID { + guard let id = maintenanceEvent.id else { return } + var eventToUpdate = maintenanceEvent + eventToUpdate.userID = uid + + do { + try Firestore + .firestore() + .collection(FirestorePath.maintenanceEvents(vehicleID: eventToUpdate.vehicleID).path) + .document(id) + .setData(from: eventToUpdate) + } catch { + showAddErrorAlert.toggle() + errorMessage = error.localizedDescription + } + } + + AnalyticsService.shared.logEvent(.maintenanceUpdate) + + await self.getMaintenanceEvents() } func deleteEvent(_ event: MaintenanceEvent) async { guard let documentId = event.id else { fatalError("Event \(event.title) has no document ID.") } - try? await Firestore - .firestore() - .collection("maintenance_events") - .document(documentId) - .delete() + + do { + try await Firestore + .firestore() + .collection(FirestorePath.maintenanceEvents(vehicleID: event.vehicleID).path) + .document(documentId) + .delete() + errorMessage = "" + + if let eventIndex = events.firstIndex(of: event) { + events.remove(at: eventIndex) + } + } catch { + showErrorAlert.toggle() + errorMessage = error.localizedDescription + } + + AnalyticsService.shared.logEvent(.maintenanceDelete) + } + + /// Fetches the user's vehicles from Firestore based on their unique user ID. + func getVehicles() async { + if let uid = userUID { + let db = Firestore.firestore() + let docRef = db.collection("vehicles").whereField("userID", isEqualTo: uid) + + let querySnapshot = try? await docRef.getDocuments() + + var vehicles = [Vehicle]() + + if let querySnapshot { + for document in querySnapshot.documents { + if let vehicle = try? document.data(as: Vehicle.self) { + vehicles.append(vehicle) + } + } + + self.vehicles = vehicles + } + } + } +} + +// MARK: - Sort Option +extension DashboardViewModel { + enum SortOption: Int, CaseIterable, Identifiable { + case oldestToNewest = 0 + case newestToOldest = 1 + case byVehicle = 2 + case custom = 3 + + var id: Int { + rawValue + } + + var label: LocalizedStringResource { + switch self { + case .oldestToNewest: + LocalizedStringResource( + "Oldest to Newest", + comment: "Sorting option that displays older items first.") + case .newestToOldest: + LocalizedStringResource( + "Newest to Oldest", + comment: "Sorting option that displays newer items first.") + case .custom: + LocalizedStringResource( + "Custom", + comment: "Sorting option that sorts items according to the user's preferences.") + case .byVehicle: + LocalizedStringResource( + "By Vehicle", + comment: "Sorting option that sorts items according to the vehicle.") + } + } } } diff --git a/Basic-Car-Maintenance/Shared/Dashboard/Views/AddMaintenanceView.swift b/Basic-Car-Maintenance/Shared/Dashboard/Views/AddMaintenanceView.swift index 4446f29d..81a8ce6e 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/Views/AddMaintenanceView.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/AddMaintenanceView.swift @@ -2,59 +2,107 @@ // AddMaintenanceView.swift // Basic-Car-Maintenance // -// Created by Mikaela Caron on 8/19/23. +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. // import SwiftUI struct AddMaintenanceView: View { + let vehicles: [Vehicle] let addTapped: (MaintenanceEvent) -> Void @State private var title = "" @State private var date = Date() + @State private var selectedVehicleID: String? @State private var notes = "" - @Environment(\.dismiss) var dismiss var body: some View { NavigationStack { Form { Section { - TextField("Title of the maintenance event", - text: $title, - prompt: Text("The title of the event")) + TextField(text: $title, + prompt: Text("The title of the event", + comment: "Maintenance event title text field label placeholder")) { + Text("Title of the maintenance event", + comment: "Maintenance event title text field label") + } + } header: { + Text("Title", + comment: "Maintenance event title text field header") + } + + Section { + Picker(selection: $selectedVehicleID) { + ForEach(vehicles) { vehicle in + Text(vehicle.name).tag(vehicle.id) + } + } label: { + Text("Select a vehicle", + comment: "Picker for selecting a vehicle") + } + .pickerStyle(.menu) } header: { - Text("Title") + Text("Vehicle", + comment: "Maintenance event vehicle picker header") } DatePicker(selection: $date, displayedComponents: .date) { - Text("Date") + Text("Date", + comment: "Date picker label") } + .dynamicTypeSize(...DynamicTypeSize.accessibility2) Section { - TextField("Notes", text: $notes, prompt: Text("Additional Notes"), axis: .vertical) + TextField(text: $notes, + prompt: Text("Additional Notes", + comment: "Notes text field placeholder"), + axis: .vertical) { + Text("Notes", + comment: "Maintenance event notes text field label") + } } header: { - Text("Notes") + Text("Notes", + comment: "Maintenance event notes text field label") + } + } + .analyticsView("\(Self.self)") + .navigationTitle(Text("Add Maintenance", + comment: "Nagivation title for Add Maintenance view")) + .onAppear { + if !vehicles.isEmpty { + selectedVehicleID = vehicles[0].id } } - .navigationTitle(Text("Add Maintenance")) .toolbar { ToolbarItem { Button { - let event = MaintenanceEvent(title: title, date: date, notes: notes) - addTapped(event) - dismiss() + if let selectedVehicleID { + let event = MaintenanceEvent(vehicleID: selectedVehicleID, + title: title, + date: date, + notes: notes) + addTapped(event) + dismiss() + } } label: { - Text("Add") + Text("Add", + comment: "Label for submit button on form to add an entry") } + .disabled(title.isEmpty) } } - } } } #Preview { - AddMaintenanceView() { _ in } + let sampleVehicles = [ + Vehicle(name: "Lexus", make: "Lexus", model: "White"), + Vehicle(name: "Test", make: "Lexus", model: "White") + ] + + AddMaintenanceView(vehicles: sampleVehicles) { _ in } } diff --git a/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVGeneratorView.swift b/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVGeneratorView.swift new file mode 100644 index 00000000..3efa75e8 --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/CSVGeneratorView.swift @@ -0,0 +1,115 @@ +// +// CSVGeneratorView.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import Foundation +import SwiftUI + +struct CSVGeneratorView: View { + @Environment(\.dismiss) var dismiss + + let events: [MaintenanceEvent] + let vehicleName: String + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + List { + Grid(alignment: .leading, verticalSpacing: 5) { + GridRow { + Text("Date") + Text("Vehicle Name") + Text("Notes") + } + .font(.headline) + .frame(height: 50) + + Divider() + + ForEach(events) { event in + GridRow(alignment: .firstTextBaseline) { + Text(event.date.formatted()) + .frame(maxWidth: 100, maxHeight: .infinity) + Text(event.title) + Text(event.notes) + } + .font(.subheadline) + if event != events.last { + Divider() + } + } + } + } + VStack { + if let fileURL = generateCSVFile(vehicle: vehicleName) { + ShareLink(item: fileURL) { + Label("Share", systemImage: SFSymbol.share) + } + } else { + Text("Error: Failed to save CSV file.") + .foregroundColor(.red) + .font(.subheadline) + } + } + .safeAreaPadding(.bottom) + } + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Close") { + dismiss() + } + } + } + } + } + + private func csvData() -> String { + let table = CSVTable( + columns: [ + CSVColumn("Date") { $0.date.formatted() }, + CSVColumn("Vehicle Name", \.title), + CSVColumn("Notes", \.notes) + ], + configuration: CSVEncoderConfiguration() + ) + return table.export(rows: events) + } + + private func generateCSVFile(vehicle: String) -> URL? { + let fileManager = FileManager.default + guard let documentsDirectory = fileManager.urls( + for: .documentDirectory, in: .userDomainMask).first else { + print("Failed to locate the Documents Directory.") + return nil + } + + let fileName = "\(vehicle)-MaintenanceReport" + let fileURL = documentsDirectory.appendingPathComponent(fileName).appendingPathExtension("csv") + + do { + try csvData().write(to: fileURL, atomically: true, encoding: .utf8) + print("File saved to \(fileURL)") + return fileURL + } catch { + print("Failed to save CSV file: \(error.localizedDescription)") + return nil + } + } +} + +#Preview { + CSVGeneratorView( + events: [ + .init( + vehicleID: "1", + title: "1st service", + date: .now, + notes: "Maintenance and service" + )], + vehicleName: "" + ) +} diff --git a/Basic-Car-Maintenance/Shared/Dashboard/Views/DashboardView.swift b/Basic-Car-Maintenance/Shared/Dashboard/Views/DashboardView.swift index 5e757665..66fab4e8 100644 --- a/Basic-Car-Maintenance/Shared/Dashboard/Views/DashboardView.swift +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/DashboardView.swift @@ -2,70 +2,256 @@ // DashboardView.swift // Basic-Car-Maintenance // -// Created by Mikaela Caron on 8/19/23. +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. // import SwiftUI struct DashboardView: View { + @Environment(ActionService.self) var actionService + @Environment(\.scenePhase) var scenePhase @State private var isShowingAddView = false + @State private var viewModel: DashboardViewModel + @State private var isShowingEditView = false + @State private var isShowingExportOptionsView = false + @State private var isShowingVehicleSelection = false + @State private var selectedMaintenanceEvent: MaintenanceEvent? - @StateObject private var viewModel: DashboardViewModel - - init(authenticationViewModel: AuthenticationViewModel) { - self._viewModel = StateObject(wrappedValue: DashboardViewModel(authenticationViewModel: authenticationViewModel)) // swiftlint:disable:this line_length + init(userUID: String?) { + _viewModel = State(initialValue: DashboardViewModel(userUID: userUID)) } + private var eventDateFormat: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + return formatter + }() + var body: some View { NavigationStack { List { - ForEach(viewModel.events) { event in - VStack(alignment: .leading, spacing: 8) { + ForEach(viewModel.searchedEvents) { event in + VStack(alignment: .leading, spacing: 4) { Text(event.title) .font(.title3) + .fontWeight(.bold) - Text("\(event.date.formatted(date: .abbreviated, time: .omitted))") + Text(event.date, formatter: self.eventDateFormat) + .foregroundStyle(.secondary) + + let vehicleName = viewModel.vehicles.first { $0.id == event.vehicleID }?.name + if let vehicleName { + Text("For: \(vehicleName)", comment: "the vehcile name is filled in here") + .foregroundStyle(.secondary) + } - Text(event.notes) - .lineLimit(0) + if !event.notes.isEmpty { + Text("Notes:") + .foregroundStyle(.secondary) + Text(event.notes) + } } + .accessibilityElement(children: .combine) .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button(role: .destructive) { Task { await viewModel.deleteEvent(event) } } label: { - Image(systemName: "trash") + Image(systemName: SFSymbol.trash) } + + Button { + selectedMaintenanceEvent = event + isShowingEditView = true + } label: { + VStack { + Text("Edit", + comment: "Button label to edit this maintenance") + Image(systemName: SFSymbol.pencil) + } + } + } + .sheet(isPresented: $isShowingEditView) { + EditMaintenanceEventView( + selectedEvent: $selectedMaintenanceEvent, viewModel: viewModel) } } .listStyle(.inset) } - .navigationTitle(Text("Dashboard")) - .sheet(isPresented: $isShowingAddView) { - AddMaintenanceView() { event in - Task { - await viewModel.addEvent(event) + .analyticsView("\(Self.self)") + .searchable( + text: $viewModel.searchText, + prompt: Text("Search", comment: "Prompt to search maintenance events") + ) + .overlay { + if viewModel.isLoading { + ProgressView("Loading...") + } else { + if viewModel.events.isEmpty { + ContentUnavailableView( + "Tap the + to begin", + systemImage: "wrench", + description: Text("Add your first maintenance") + ) + } else if viewModel.sortedEvents.isEmpty && viewModel.sortOption == .byVehicle { + ContentUnavailableView( + "No Results", + systemImage: SFSymbol.magnifyingGlass, + description: Text("No maintenance events found for the selected vehicle.") + ) + } else if viewModel.searchedEvents.isEmpty && !viewModel.searchText.isEmpty { + ContentUnavailableView("No results", + systemImage: SFSymbol.magnifyingGlass, + description: noSearchResultsDescription) } - } } + .animation(.linear, value: viewModel.searchedEvents) + .navigationTitle(Text("Dashboard", + comment: "Title label for Dashboard view")) + .alert(Text("Failed To Delete Event", + comment: "Title for alert shown when deleting maintenance event fails"), + isPresented: $viewModel.showErrorAlert) { + Button { + viewModel.showErrorAlert = false + } label: { + Text("OK", comment: "Label to dismiss alert") + } + } message: { + Text(viewModel.errorMessage).padding() + } + .navigationDestination(isPresented: $viewModel.isShowingAddMaintenanceEvent) { + makeAddMaintenanceView() + } .toolbar { - ToolbarItem(placement: .primaryAction) { + ToolbarItemGroup(placement: .primaryAction) { + Menu { + Picker(selection: $viewModel.sortOption) { + ForEach(DashboardViewModel.SortOption.allCases) { option in + if option != .byVehicle { + Text(option.label) + .tag(option) + } + } + } label: { + EmptyView() + } + + Button { + viewModel.sortOption = .byVehicle + isShowingVehicleSelection = true + } label: { + Text(DashboardViewModel.SortOption.byVehicle.label) + } + } label: { + Image(systemName: SFSymbol.filter) + } + .accessibilityShowsLargeContentViewer { + Label { + Text("Filter", comment: "Label for filtering on Dashboard view") + } icon: { + Image(systemName: SFSymbol.filter) + } + } + Button { - isShowingAddView.toggle() + viewModel.isShowingAddMaintenanceEvent = true } label: { - Image(systemName: "plus") + Image(systemName: SFSymbol.plus) + } + .accessibilityShowsLargeContentViewer { + Label { + Text("AddEvent", comment: "Label for adding maintenance event on Dashboard view") + } icon: { + Image(systemName: SFSymbol.plus) + } + } + } + } + .toolbar { + if !viewModel.events.isEmpty { + ToolbarItem(placement: .topBarLeading) { + Button { + isShowingExportOptionsView = true + } label: { + Image(systemName: SFSymbol.share) + } + .accessibilityShowsLargeContentViewer { + Label { + Text("Export Event", comment: "Label for exporting maintenance events") + } icon: { + Image(systemName: SFSymbol.share) + } + } } } } .task { await viewModel.getMaintenanceEvents() + await viewModel.getVehicles() + } + .sheet(isPresented: $isShowingAddView) { + makeAddMaintenanceView() + } + .sheet(isPresented: $isShowingExportOptionsView) { + ExportOptionsView(dataSource: viewModel.vehiclesWithSortedEventsDict) + .presentationDetents([.medium]) + } + .sheet(isPresented: $isShowingVehicleSelection) { + VehicleSelectionView( + selectedVehicle: $viewModel.selectedVehicleToSort, + vehicles: viewModel.vehicles + ) + } + } + .onChange(of: scenePhase) { _, newScenePhase in + guard case .active = newScenePhase else { return } + + guard let action = actionService.action, + action == .newMaintenance + else { + // another action has been triggered, so we will need to dismiss the current presented view + isShowingAddView = false + return + } + + // if the view is already presented, do nothing + guard !isShowingAddView else { return } + // delay the presentation of the view a bit + // to make sure the already presented view is dismissed. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + isShowingAddView = true + } + } + } + + private func makeAddMaintenanceView() -> some View { + AddMaintenanceView(vehicles: viewModel.vehicles) { event in + viewModel.addEvent(event) + Task { + await viewModel.getMaintenanceEvents() + } + } + .alert(Text("An Error Occurred", + comment: "Title for alert shown when adding maintenance event fails"), + isPresented: $viewModel.showAddErrorAlert) { + Button(role: .cancel) {} label: { + Text("OK", comment: "Label to dismiss alert") } + } message: { + Text(viewModel.errorMessage) } } + + private var noSearchResultsDescription: Text { + Text("There were no maintenance events for '\(viewModel.searchText)'. Try a new search.", + comment: "Text shwon when there are no results for maintenance search") + } } #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 new file mode 100644 index 00000000..5e33923d --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/EditEventDetailView.swift @@ -0,0 +1,109 @@ +// +// EditMaintenanceEventView.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import SwiftUI + +struct EditMaintenanceEventView: View { + @Binding var selectedEvent: MaintenanceEvent? + var viewModel: DashboardViewModel + @State private var title = "" + @State private var date = Date() + @State private var notes = "" + @Environment(\.dismiss) var dismiss + + var body: some View { + NavigationStack { + Form { + Section { + TextField("Title", text: $title) + } header: { + Text("Title") + } + + Section { + if let vehicleName = viewModel.vehicles + .filter({ $0.id == selectedEvent?.vehicleID }).first?.name { + Text(vehicleName) + .opacity(0.3) + } + } header: { + Text("Vehicle") + } + + DatePicker(selection: $date, displayedComponents: .date) { + Text("Date") + } + + Section { + TextField("Notes", text: $notes, prompt: Text("Additional Notes"), axis: .vertical) + } header: { + Text("Notes") + } + } + .analyticsView("\(Self.self)") + .onAppear { + guard let selectedEvent = selectedEvent else { return } + setMaintenanceEventValues(event: selectedEvent) + } + .navigationTitle(Text("Update Maintenance")) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + dismiss() + } label: { + Text("Cancel") + } + } + + ToolbarItem(placement: .topBarTrailing) { + Button { + if let selectedEvent { + var event = MaintenanceEvent(vehicleID: selectedEvent.vehicleID, + title: title, + date: date, + notes: notes) + event.id = selectedEvent.id + Task { + await viewModel.updateEvent(event) + dismiss() + } + } + } label: { + Text("Update") + } + .disabled(title.isEmpty) + } + } + } + } + + func setMaintenanceEventValues(event: MaintenanceEvent) { + self.title = event.title + self.date = event.date + self.notes = event.notes + } +} + +#Preview() { + + @Previewable @State var selectedEvent: MaintenanceEvent? = MaintenanceEvent( + id: UUID().uuidString, + userID: "user123", + vehicleID: "vehicle123", + title: "Oil Change", + date: Date(), + notes: "Changed engine oil" + ) + + let viewModel = DashboardViewModel(userUID: "user123") + + EditMaintenanceEventView( + selectedEvent: $selectedEvent, + viewModel: viewModel + ) +} diff --git a/Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift b/Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift new file mode 100644 index 00000000..f2092a36 --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/ExportOptionsView.swift @@ -0,0 +1,131 @@ +// +// ExportOptionsView.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import SwiftUI +import PDFKit + +enum ExportOption: String, Identifiable, CaseIterable { + case pdf = "PDF" + case csv = "CSV" + var id: Self { self } +} + +struct ExportOptionsView: View { + @Environment(\.dismiss) var dismiss + @State private var selectedVehicle: Vehicle? + @State private var isShowingThumbnail = false + @State private var pdfDoc: PDFDocument? + @State private var showingErrorAlert = false + @State private var selectedOption: ExportOption? + @State private var showingCSVExporter = false + + private let dataSource: [Vehicle: [MaintenanceEvent]] + + init(dataSource: [Vehicle: [MaintenanceEvent]]) { + self.dataSource = dataSource + self._selectedVehicle = State(initialValue: dataSource.first?.key) + } + + var body: some View { + NavigationView { + VStack(alignment: .leading, spacing: 16) { + Text("Select the vehicle you want to export the maintenance events for:") + .font(.headline) + .padding(.top, 20) + + Picker("Select a Vehicle", selection: $selectedVehicle) { + ForEach(dataSource.map(\.key)) { vehicle in + Text(vehicle.name) + .tag(vehicle) + } + } + .pickerStyle(.wheel) + } + .padding(.horizontal) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Picker("Export", selection: $selectedOption) { + ForEach(ExportOption.allCases) { option in + Text(option.rawValue).tag(option) + } + } + } label: { + Text("Export") + } + .onChange(of: selectedOption, { _, _ in + if let selectedVehicle, + let events = self.dataSource[selectedVehicle] { + if !events.isEmpty { + switch selectedOption { + case .pdf: + selectedOption = nil + let pdfGenerator = CarMaintenancePDFGenerator( + vehicleName: selectedVehicle.name, + events: events + ) + self.pdfDoc = pdfGenerator.generatePDF() + isShowingThumbnail = true + case .csv: + selectedOption = nil + showingCSVExporter = true + case .none: + print("No option selected, do nothing") + } + } else { + showingErrorAlert = true + } + } + }) + } + } + .sheet(isPresented: $isShowingThumbnail) { + if let pdfDoc, + let url = pdfDoc.documentURL, + let thumbnail = pdfDoc + .page(at: .zero)? + .thumbnail( + of: CGSize( + width: UIScreen.main.bounds.width, + height: UIScreen.main.bounds.height / 2), + for: .mediaBox + ) { + ShareLink(item: url) { + VStack { + Image(uiImage: thumbnail) + Label("Share", systemImage: SFSymbol.share) + } + .safeAreaPadding(.bottom) + } + .presentationDetents([.medium]) + } + } + .sheet(isPresented: $showingCSVExporter) { + if let selectedVehicle, + let events = self.dataSource[selectedVehicle] { + CSVGeneratorView(events: events, vehicleName: selectedVehicle.name) + .presentationDetents([.medium]) + } + } + .alert( + Text( + "Failed to Export Events", + comment: "Title for alert shown when there are no events to export for a vehicle" + ), + isPresented: $showingErrorAlert) { + Button { + showingErrorAlert = false + } label: { + Text("OK", comment: "Label to dismiss alert") + } + } message: { + Text("No events to export for this vehicle.") + } + } + } +} diff --git a/Basic-Car-Maintenance/Shared/Dashboard/Views/VehicleSelectionView.swift b/Basic-Car-Maintenance/Shared/Dashboard/Views/VehicleSelectionView.swift new file mode 100644 index 00000000..870ed9f1 --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Dashboard/Views/VehicleSelectionView.swift @@ -0,0 +1,42 @@ +// +// VehicleSelectionView.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import SwiftUI + +struct VehicleSelectionView: View { + @Binding var selectedVehicle: Vehicle? + + var vehicles: [Vehicle] + + @Environment(\.dismiss) var dismiss + + var body: some View { + NavigationView { + List { + Button { + selectedVehicle = nil + dismiss() + } label: { + Text("All Vehicles") + .fontWeight(.bold) + .foregroundColor(.blue) + } + + ForEach(vehicles) { vehicle in + Button { + selectedVehicle = vehicle + dismiss() + } label: { + Text(vehicle.name) + } + } + } + .navigationTitle("Select Vehicle") + } + } +} diff --git a/Basic-Car-Maintenance/Shared/GoogleService-Info.plist b/Basic-Car-Maintenance/Shared/GoogleService-Info.plist index 433ad2cb..7dcf8701 100644 --- a/Basic-Car-Maintenance/Shared/GoogleService-Info.plist +++ b/Basic-Car-Maintenance/Shared/GoogleService-Info.plist @@ -19,16 +19,16 @@ STORAGE_BUCKET basic-car-maintenance.appspot.com IS_ADS_ENABLED - + IS_ANALYTICS_ENABLED - + IS_APPINVITE_ENABLED - + IS_GCM_ENABLED - + IS_SIGNIN_ENABLED - + GOOGLE_APP_ID 1:255838601762:ios:12417d95fcb477338f807b - \ No newline at end of file + diff --git a/Basic-Car-Maintenance/Shared/Info.plist b/Basic-Car-Maintenance/Shared/Info.plist new file mode 100644 index 00000000..61b20d06 --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Info.plist @@ -0,0 +1,33 @@ + + + + + KeychainAccessGroup + $(KEYCHAIN_SHARING_GROUP_IDENTIFIER) + FIREBASE_ANALYTICS_COLLECTION_ENABLED + + FirebaseAutomaticScreenReportingEnabled + + FirebaseCrashlyticsCollectionEnabled + + UIApplicationShortcutItems + + + UIApplicationShortcutItemIconSymbolName + wrench.and.screwdriver.fill + UIApplicationShortcutItemTitle + New Maintenence + UIApplicationShortcutItemType + NewMaintenance + + + UIApplicationShortcutItemIconSymbolName + plus.circle.fill + UIApplicationShortcutItemTitle + Add Vehicle + UIApplicationShortcutItemType + AddVehicle + + + + diff --git a/Basic-Car-Maintenance/Shared/Localizable.xcstrings b/Basic-Car-Maintenance/Shared/Localizable.xcstrings index 39892c8e..7bf8901c 100644 --- a/Basic-Car-Maintenance/Shared/Localizable.xcstrings +++ b/Basic-Car-Maintenance/Shared/Localizable.xcstrings @@ -1,94 +1,6753 @@ { "sourceLanguage" : "en", "strings" : { - "" : { - - }, "%@" : { - - }, - "Add" : { - + "comment" : "Maintenance list item description 'Date' formatted", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + } + } }, - "Add Maintenance" : { - + "%lld %@" : { + "comment" : "A text label followed by a value, indicating the distance traveled by a vehicle. The value is formatted as a number followed by a unit of distance (either kilometers or miles).", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld %2$@" + } + } + } }, - "Add Vehicle" : { - + "^[%lld contributions](inflect: true)" : { + "comment" : "the number of contributions by a contributor", + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "^[%lld bijdragen](inflect: true)" + } + } + } }, - "Additional Notes" : { - + "🦄 Mikaela Caron - Maintainer" : { + "comment" : "Link to maintainer Github account.", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "🦄 Mikaela Caron - Суправаджальніца" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "🦄 Mikaela Caron - Hlavní vývojář" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "🦄 Mikaela Caron - Maintainer" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "🦄 Mikaela Caron - نگهدارنده" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "🦄 Mikaela Caron - Maintaineur" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "🦄 मिकाएला कैरोन - अभिरक्षक" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "🦄 Mikaela Caron - 유지관리자" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "🦄 Mikaela Caron - Beheerder" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "🦄 Mikaela Caron - Opiekun" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "🦄 Mikaela Caron - Lider" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "🦄 Микаэла Карон - администратор" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "🦄 Mikaela Caron - Sorumlu" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "🦄 Мікаела Карон - адміністраторка" + } + } + } }, - "Dashboard" : { - + "about" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "درباره" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "over" + } + } + } }, - "Date" : { - + "Add" : { + "comment" : "Label for submit button on form to add an entry", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дадаць" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Přidat" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hinzufügen" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "اضافه کردن" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "दर्ज करें" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "추가" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toevoegen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dodaj" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adicionar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ekle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Додати" + } + } + } }, - "GitHub Repo" : { - + "Add Maintenance" : { + "comment" : "Nagivation title for Add Maintenance view", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дадаць абслугоўванне" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Přidat servisní záznam" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wartung hinzufügen" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar Mantenimiento" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "اضافه کردن نگهداری" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter une maintenance" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "रखरखाव दर्ज करें" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "유지 추가" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Onderhoud toevoegen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dodaj serwis" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adicionar manutenção" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить обслуживание" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bakım Ekle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Додати обслуговування" + } + } + } }, - "Logged in anonymously with ID: %@" : { - + "Add Reading" : { + "comment" : "Title for form when adding an odometer reading", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "KM-Stand hinzufügen" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "اضافه کردن کیلومتر شمار" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "दूरी दर्ज करें" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilometerstand toevoegen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dodaj przebieg" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adicionar leitura" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Okuma Ekle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Додати значення" + } + } + } }, - "Make" : { - + "Add the details below" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "جزئیات زیر را اضافه کنید" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voeg de informatie beneden toe" + } + } + } }, - "Model" : { - + "Add Vehicle" : { + "comment" : "Label to add a vehicle.", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дадаць транспартны сродак" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Přidat vozidlo" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fahrzeug hinzufügen" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Añadir Vehículo" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "اضافه کردن وسیله نقلیه" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter un véhicule" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "वाहन दर्ज करें" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "차량 추가" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voertuig toevoegen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dodaj pojazd" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adicionar veiculo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить машину" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Araç Ekle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Додати авто" + } + } + } }, - "Name" : { - + "Add your first maintenance" : { + "comment" : "Placeholder text for empty maintenance list prompting the user to add a maintenance event", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дадайце першае абслугоўванне" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Přidat první servisní záznam" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erste Wartung hinzufügen" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agrega tu primer mantenimiento" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "اضافه کردن اولین رویداد نگهداری" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajoutez votre première maintenance" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "अपना पहला रखरखाव दर्ज करें" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "첫번째 유지관리 추가" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voeg uw eerste onderhoud toe" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dodaj swój pierwszy serwis" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adicionar primeira manutençao" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить свое первое обслуживание" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İlk bakımını ekle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Додайте своє перше обслуговування" + } + } + } }, - "Notes" : { - + "Add your first odometer" : { + "comment" : "Placeholder description for empty odometer reading list", + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ersten Kilometerstand hinzufügen" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "اضافه کردن اولین کیلومتر شمار" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "अपना पहला ओडोमीटर जोड़ें" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voeg uw eerste kilometerstand toe" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dodaj swój pierwszy przebieg" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adicionar primeiro odometro" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İlk kilometre ölçümünü ekle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Додайте свій перший одометр" + } + } + } }, - "Profile" : { + "Add your first odometer reading" : { + "comment" : "Placeholder text for empty odometer reading list" + }, + "AddEvent" : { + "comment" : "Label for adding maintenance event on Dashboard view", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "اضافه کردن" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toevoegen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dodaj" + } + } + } + }, + "Additional Notes" : { + "comment" : "Notes text field placeholder", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дадатковая інфармацыя" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Další poznámky" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weitere Notizen" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notas Adicionales" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "یادداشت‌ های اضافی" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notes additionnelles" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "अतिरिक्त टिप्पणी" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "추가 노트" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aanvullende opmerkingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dodatkowe notatki" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adicionar notas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дополнительные заметки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ek Notlar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Додаткові замітки" + } + } + } + }, + "All" : { + + }, + "All Vehicles" : { + + }, + "An Error Occurred" : { + "comment" : "Title for alert shown when adding maintenance event fails", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Адбылася памылка" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nastala chyba" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Es ist ein Fehler aufgetreten" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ocurrió un Error" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "یک خطا رخ داد" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Une erreur est survenue" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "एक त्रुटि हुई" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "에러 발생" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Er is een fout opgetreden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wystąpił błąd" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ocorreu um erro" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Произошла ошибка" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bir Hata Oluştu" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сталася помилка" + } + } + } + }, + "Back" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "بازگشت" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terug" + } + } + } + }, + "Basic" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "پایه" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basis" + } + } + } + }, + "Basic Car" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "ماشین پایه" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standaard auto" + } + } + } + }, + "Built collaboratively with contributors, enhancing the app functionality." : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "ساخته شده با همکاری مشارکت‌کنندگان که به عملکرد بهتر برنامه کمک می‌کند." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gebouwd samen met bijdragers die de functionaliteit van de app vergroten." + } + } + } + }, + "By Vehicle" : { + "comment" : "Sorting option that sorts items according to the vehicle." + }, + "Can't Delete Last Vehicle" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "آخرین وسیله نقلیه را نمی‌توان حذف کرد" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kan laatste voertuig niet verwijderen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie można usunąć ostatniego pojazdu" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Невозможно удалить последний автомобиль" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Son Araç Silinemiyor" + } + } + } + }, + "Cancel" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Адмяніць" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zrušit" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abbrechen" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "لغو" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annuler" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "रद्द करें" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : " 취소" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annuleren" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anuluj" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancelar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отменить" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İptal" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скасувати" + } + } + } + }, + "Car Maintenance" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "نگهداری ماشین" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auto onderhoud" + } + } + } + }, + "Change App Icon" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Змяніць значок дадатка" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "App Icon wechseln" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "تغییر آیکون برنامه" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "एप्लिकेशन आइकन बदलें" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "앱아이콘 변경" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "App-pictogram wijzigen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zmień ikonę aplikacji" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trocar icone do App" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сменить иконку" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aplikasyon İkonunu Değiştir" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Змінити іконку додатку" + } + } + } + }, + "Choose App Icon" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберыце значок дадатка" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wähle ein App Icon" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "انتخاب آیکون برنامه" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ऐप आइकन चुनें" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "앱아이콘 선택" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kies App-pictogram" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wybierz ikonę aplikacji" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escolha um icone" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать иконку" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aplikasyon İkonunu Seç" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Оберіть іконки додатку" + } + } + } + }, + "Close" : { }, - "Settings" : { - + "Color" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Колер" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Farbe" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "رنگ" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "रंग" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "색깔" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kleur" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kolor" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cor" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цвет" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renk" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Колір" + } + } + } + }, + "Color: %@" : { + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kleur: %@" + } + } + } + }, + "Continue" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "ادامه" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ga verder" + } + } + } + }, + "Contributors" : { + "comment" : "Link to contributors list.", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удзельнікі" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Další vývojáři" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mitwirkende" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contribuyentes" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "مشارکت کنندگان" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contributeurs" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "योगदानकर्ता" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기여자들" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bijdragers" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Współautorzy" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Equipe" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Участники" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Katılımcılar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Співтворці" + } + } + } + }, + "Copied!" : { + "comment" : "Text to notify user that app version was copied", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скапіравана!" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kopiert!" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "کپی شد!" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "कॉपी किया गया" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "복사완료!" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gekopieerd!" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skopiowane!" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copiado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скопировано!" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kopyalandı!" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скопійовано!" + } + } + } + }, + "Custom" : { + "comment" : "Sorting option that sorts items according to the user's preferences.", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вольны парадак" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vlastní" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Benutzerdefiniert" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Personalizado" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "سفارشی" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Personnalisé" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "कस्टम" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "커스텀" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aangepast" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niestandardowy" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Personalizado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Изменить" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kişisel" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Власне" + } + } + } + }, + "Dashboard" : { + "comment" : "Title label for Dashboard view", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дэшборд" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Přehled" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dashboard" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Panel" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "داشبورد" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tableau de bord" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "डैशबोर्ड" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "대시보드" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dashboard" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pulpit nawigacyjny" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Painel" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дэшборд" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gösterge Paneli" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Зведення" + } + } + } + }, + "Date" : { + "comment" : "Date picker label", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дата" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Datum" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Datum" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fecha" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "تاریخ" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Date" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "तारीख" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "날짜" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Datum" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Data" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Data" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дата" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tarih" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дата" + } + } + } + }, + "Delete" : { + "comment" : "Label to delete a vehicle", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выдаліць" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Smazat" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Löschen" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Borrar" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "حذف" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supprimer" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "मिटायें" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "삭제" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verwijderen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usuń" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deletar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sil" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Видалити" + } + } + } + }, + "Dismiss" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Адхіліць" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verwerfen" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "بستن" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "खारिज" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "닫기" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sluiten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odrzuć" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dispensar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отклонить" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bırak" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрити" + } + } + } + }, + "Distance" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entfernung" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "فاصله" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "दूरी" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afstand" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dystans" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Distancia" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mesafe" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Відстань" + } + } + } + }, + "Edit" : { + "comment" : "Button label to edit this maintenance", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Рэдагаваць" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Upravit" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bearbeiten" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "ویرایش" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modifier" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "संपादित करें" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "편집" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bewerken" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edytuj" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Editar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Редактировать" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Düzenle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Редагувати" + } + } + } + }, + "Edit Reading" : { + "comment" : "Title for form when editing an odometer reading", + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "ویرایش شمارش کیلومتر شمار" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pas kilometerstand aan" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edytuj przebieg" + } + } + } + }, + "Error: Failed to save CSV file." : { + + }, + "Export" : { + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exporteer" + } + } + } + }, + "Export Event" : { + "comment" : "Label for exporting maintenance events", + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exporteer onderhoudsbeurt" + } + } + } + }, + "Failed To Add Vehicle" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не атрымалася дадаць транспартны сродак" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nelze přidat vozidlo" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fahrzeug konnte nicht hinzugefügt werden" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "وسیله نقلیه اضافه نشد" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible d'ajouter le véhicule" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "वाहन दर्ज करने में विफलता।" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "차량 추가 실패" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niet gelukt om voertuig toe te voegen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie udało się dodać pojazdu" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erro ao adicionar veiculo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось добавить авто" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Araç Eklenemedi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не вдалося видалити авто" + } + } + } + }, + "Failed To Add Vehicle\nDetails:%@" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не атрымалася дадаць транспартны сродак\nПадрабязнасці:%@" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nelze přidat detaily vozidla: %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fahrzeug konnte nicht hinzugefügt werden\nDetails:%@" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "وسیله نقلیه اضافه نشد. جزئیات: %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible d'ajouter le véhicule\nDétails : %@" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "वाहन दर्ज करने में विफलता:%@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "차량 세부 정보를 불러오지 못했습니다: %@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niet gelukt om voertuiggegevens toe te voegen:%@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie udało się dodać pojazdu\nSzczegóły:%@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ocorreu um erro ao adicionar veiculo. Erro: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось добавить авто\nДетали:%@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Araç Detayları Eklenemedi: %@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не вдалося додати авто\nДеталі:%@" + } + } + } + }, + "Failed To Add Vehicle. Unknown Error." : { + "comment" : "Label to display error details.", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не атрымалася дадаць транспартны сродак. Невядомая памылка." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nelze přidat vozidlo. Příčina neznámá." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fahrzeug konnte nicht hinzugefügt werden. Unbekannter Fehler." + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se pudo Agregar el Vehículo. Error Desconocido." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "وسیله نقلیه اضافه نشد. خطای ناشناخته." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible d'ajouter le véhicule. Erreur inconnue." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "वाहन दर्ज करने में विफलता। अज्ञात त्रुटि।" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "차량 추가에 실패했습니다. 알 수 없는 오류입니다." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niet gelukt om voertuig toe te voegen. Onbekende fout." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie udało się dodać pojazdu. Nieznany błąd." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ocorreu um erro ao adicionar o veiculo. Erro desconhecido" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось добавить авто. Неизвестная ошибка" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Araç Eklenemedi. Bilinmeyen Hata" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не вдалося додати авто. Невідома помилка." + } + } + } + }, + "Failed To Delete Event" : { + "comment" : "Title for alert shown when deleting maintenance event fails", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не ўдалося выдаліць падзею" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nelze smazat událost" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Event konnte nicht gelöscht werden" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se pudo Eliminar el Evento" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "رویداد حذف نشد" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible de supprimer l'évènement" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "कार्यक्रम हटाने में विफलता" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이벤트 삭제에 실패했습니다." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niet gelukt om onderhoudsbeurt te verwijderen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie udało się usunąć zdarzenia" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erro ao deletar evento" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удаление невозможно" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Araç Bilgisi Silinememesi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не вдалося видалити подію" + } + } + } + }, + "Failed To Delete Vehicle" : { + "comment" : "Label to dsplay title of the delete vehicle alert", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не ўдалося выдаліць транспартны сродак" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nelze smazat vozidlo" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fahrzeug konnte nicht gelöscht werden" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se pudo Eliminar el Vehículo" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "وسیله نقلیه حذف نشد" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible de supprimer le véhicule" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "वाहन मिटाने में विफलता" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "차량 삭제에 실패했습니다." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niet gelukt om voertuig te verwijderen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie udało się usunąć pojazdu" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erro ao deletar veiculo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удаление авто невозможно" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Araç Bilgisi Silinemedi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не вдалося видалити авто" + } + } + } + }, + "Failed To Delete Vehicle\nDetails:%@" : { + "comment" : "Label to display localized error description.", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не ўдалося выдаліць транспартны сродак Падрабязнасці:%@" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nelze smazat detail vozidla: %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fahrzeug konnte nicht gelöscht werden.\nDetails: %@" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se pudieron Eliminar los Detalles del Vehículo: %@" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "وسیله نقلیه حذف نشد. جزئیات: %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible de supprimer le véhicule\nDétails : %@" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "वाहन की जानकारी मिटाने में विफलता:%@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "차량 세부 정보 삭제에 실패했습니다: %@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niet gelukt om voertuiggegevens te verwijderen:%@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie udało się usunąć pojazdu\nSzczegóły:%@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erro ao deletar veiculo. Erro: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удаление авто невозможно\nДетали:%@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Araç Bilgisi Silinemedi. Detay: %@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не вдалося видалити авто\nДеталі:%@" + } + } + } + }, + "Failed To Delete Vehicle. Unknown Error." : { + "comment" : "Label to display error details.", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не ўдалося выдаліць аўтамабіль. Невядомая памылка." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nelze smazat vozidlo. Příčina neznámá." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fahrzeug konnte nicht gelöscht werden. Unbekannter Fehler." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "وسیله نقلیه حذف نشد. خطای ناشناخته." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible de supprimer le véhicule. Erreur inconnue." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "वाहन मिटाने में विफलता। अज्ञात त्रुटि।" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "차량 삭제에 실패했습니다. 알 수 없는 오류입니다." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niet gelukt om voertuig te verwijderen. Onbekende fout." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie udało się usunąć pojazdu. Nieznany błąd." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erro ao deletar veiculo. Erro desconhecido" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удаление авто невозможно. Ошибка неизвестна" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Araç Bilgisi Silinemedi. Bilinmeyen Hata" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не вдалося видалити авто. Невідома помилка." + } + } + } + }, + "Failed to Export Events" : { + "comment" : "Title for alert shown when there are no events to export for a vehicle" + }, + "Filter" : { + "comment" : "Label for filtering on Dashboard view", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Фільтр" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filter" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "فیلتر" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "फ़िल्टर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filteren" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filtr" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filtro" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filtrele" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Фільтр" + } + } + } + }, + "Filter.Odometer" : { + "comment" : "Label for filtering on Odometer view", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filter" + } + } + } + }, + "For: %@" : { + "comment" : "the vehcile name is filled in here", + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voor: %@" + } + } + } + }, + "GitHub Repo" : { + "comment" : "Link to the Basic Car Maintenance GitHub repo.", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Рэпазітар на GitHub" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Github Repozitář" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub Repo" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Repositorio de GitHub" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "مخزن گیت‌هاب" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Repo GitHub" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "गिटहब रेपो" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub 저장소" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub Repo" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Repozytorium GitHub" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Repositorio do Github" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub репозиторий" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Github Repo" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сховище на GitHub" + } + } + } + }, + "Imperial" : { + "comment" : "Imperial unit system", + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Empirisch" + } + } + } + }, + "It's open source and anyone can contribute to it." : { + "comment" : "Tells the user they can contribute to the codebase.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Es ist Open Source und Jede/Jeder kann mitwirken." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "این متن‌باز است و هرکسی می‌تواند مشارکت کند." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "यह खुला स्रोत है और कोई भी इसमें योगदान दे सकता है।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Het is open source en iedereen kan eraan bijdragen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jest to oprogramowanie open source i każdy może wnieść do niego swój wkład." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esse é um projeto open source todos podem contribuir" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Açık kaynak kodludur ve herkes katkıda bulunabilir." + } + } + } + }, + "Key" : { + "extractionState" : "manual", + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sleutel" + } + } + } + }, + "Kilometers" : { + "comment" : "Label for kilometers unit", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilometer" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "کیلومتر" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "किलोमीटर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilometers" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilometry" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilometros" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilometre" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Кілометри" + } + } + } + }, + "License Plate Number" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нумарны знак" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autokennzeichen" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "شماره پلاک" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "लाइसेंस प्लेट नंबर" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "차량 번호판" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kenteken" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Numer tablicy rejestracyjnej" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Numero da placa" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Номер лицензии" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plaka" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Номерний знак авта" + } + } + } + }, + "Loading..." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lade…" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "درحال بارگذاری…" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Laden…" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ładowanie..." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yükleniyor..." + } + } + } + }, + "Logged in anonymously with ID: %@" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ананімны ўваход з ідэнтыфікатарам: %@" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Přihlášen anonymně jako ID: %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anonym angemeldet mit ID: %@" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Iniciar sesión de forma anónima con ID: %@" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "وارد شده به شکل ناشناس با آیدی: %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connecté anonymement avec l'ID : %@" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "गुमनाम आईडी से लॉग इन किया गया: %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "익명으로 로그인되었습니다. ID: %@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anoniem ingelogd met ID: %@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zalogowano się anonimowo przy użyciu ID: %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logado anonimamente com ID: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включен анонимный режим с ID: %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ olarak anonim olarak giriş yapıldı." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ви увійшли анонімно з ID: %@" + } + } + } + }, + "Maintenance Vehicle" : { + + }, + "Make" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вытворца" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Výrobce" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hersteller" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Marca" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "ایجاد" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fabricant" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "वाहन कंपनी" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제조사" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bouwjaar" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Marka" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Marca" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Производитель" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Marka" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Виробник" + } + } + } + }, + "Metric" : { + "comment" : "Metric unit system", + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Metrisch" + } + } + } + }, + "Miles" : { + "comment" : "Label for miles unit", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meilen" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "مایل" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "मील" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilometers" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mile" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Milhas" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mil" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Милі" + } + } + } + }, + "Model" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Мадэль" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Model" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modell" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modelo" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "مدل" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modèle" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "मॉडल" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모델" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Model" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Model" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modelo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Модель" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Model" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Модель" + } + } + } + }, + "Name" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Імя" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Název" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Name" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nombre" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "نام" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nom" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "नाम" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "차량명" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Naam" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nazwa" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nome" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Имя" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İsim" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Імʼя" + } + } + } + }, + "Newest to Oldest" : { + "comment" : "Sorting option that displays newer items first.", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ад найноўшых да самых старых" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Od nejnovějšího" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Neu vor alt" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Más Nuevo a Más Antiguo" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "جدیدترین به قدیمی‌ترین" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Du plus au moins récent" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "नवीनतम से सबसे पुराना" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "최신순에서 오래된 순" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nieuwste naar oudste" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Od najnowszego do najstarszego" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mais novo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "От нового к предыдущим" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yeniden Eskiye" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Від найновіших до найстаріших" + } + } + } + }, + "No events to export for this vehicle." : { + + }, + "No maintenance events found for the selected vehicle." : { + + }, + "No Name" : { + "extractionState" : "manual", + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geen naam" + } + } + } + }, + "No results" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вынікаў няма" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kein Ergebnis" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "بدون نتیجه" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "कोई परिणाम नहीं" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "결과 없음" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geen resultaten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brak wyników" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sem resultados" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Результата нет" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sonuç yok" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Немає результатів" + } + } + } + }, + "No Results" : { + + }, + "Notes" : { + "comment" : "Maintenance event notes text field label", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нататкі" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Poznámky" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notizen" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notas" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "یادداشت‌ها" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notes" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "टिप्पणी" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "노트" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notities" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notatki" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Заметки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notlar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Замітки" + } + } + } + }, + "Notes:" : { + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notities:" + } + } + } + }, + "Odometer" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Адометр" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilometerzähler" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "کیلومتر شمار" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ओडोमीटर" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "주행거리 계기" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilometerteller" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Licznik przebiegu" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odometro" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Одометр" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kilometre ölçer" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Одометр" + } + } + } + }, + "OK" : { + "comment" : "Label to dismiss alert", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добра" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "فهمیدم" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ठीक है" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "확인" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ОК" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добре" + } + } + } + }, + "Oldest to Newest" : { + "comment" : "Sorting option that displays older items first.", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ад самых старых да новых" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Od nejstaršího" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alt vor neu" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Más Antiguo a Más Nuevo " + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "قدیمی‌ترین به جدیدترین" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Du moins au plus récent" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सबसे पुराने से नवीनतम" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "오래된 순에서 최신순으로" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oudste naar nieuwste" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Od najstarszego do najnowszego" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mais antigo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "От предыдущих до новых" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eskiden Yeniye" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Від найстаріших до найновіших" + } + } + } + }, + "Open Source" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "متن باز" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Source" + } + } + } + }, + "Plate: %@" : { + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kenteken: %@" + } + } + } + }, + "Preferred System" : { + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voorkeurssysteem" + } + } + } + }, + "Preferred units" : { + "comment" : "Label for units selected when adding an odometer reading", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "واحد‌های مورد نظر" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "पसंदीदा इकाइयाँ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voorkeurseenheden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jednostki preferowane" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unidades preferenciais" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tercih edilen birim" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Бажані одиниці виміру" + } + } + } + }, + "Privacy Policy" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Палітыка прыватнасці" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Datenschutzrichtlinie" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "سیاست‌ حریم شخصی" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "गोपनीयता नीति" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Privacybeleid" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Polityka prywatności" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Politica de privacidade" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gizlilik Politikası" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Політика приватності" + } + } + } + }, + "Profile" : { + "comment" : "Link to view profile.", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Профіль" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Perfil" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "پروفایل" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "प्रोफ़ाइल" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "프로필" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profiel" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Perfil" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Профиль" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Профіль" + } + } + } + }, + "Rate this app" : { + "comment" : "Link to rate the app." + }, + "Report a Bug" : { + "comment" : "Link to report a bug", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Паведаміць пра памылку" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nahlásit chybu" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fehler melden" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reportar un Error" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "گزارش مشکل" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rapporter un bug" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "समस्या रिपोर्ट करें" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "버그 신고" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Een bug melden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zgłoś błąd" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reportar erro" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уведомление об ошибке" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hata Raporla" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Повідомити про помилку" + } + } + } + }, + "Request a New Feature" : { + "comment" : "Link to request a new feature.", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запытаць новую функцыю" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Požadavek na novou funkci" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Neues Feature vorschlagen" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Solicitar una Nueva Característica" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "درخواست قابلیت جدید" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Demander une nouvelle fonctionnalité" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "नई सुविधा का अनुरोध करें" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새로운 기능 요청" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Een nieuwe functie aanvragen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wniosek o nową funkcję" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sugerir nova funcionalidade" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить запрос на новую функцию" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yeni Özellik Talep Et" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подати запит на нову функцію" + } + } + } + }, + "Search" : { + "comment" : "Prompt to search maintenance events", + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zoeken" + } + } + } + }, + "Select a vehicle" : { + "comment" : "Picker for selecting a vehicle", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберыце транспартны сродак" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wähle ein Fahrzeug" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "انتخاب وسیله نقلیه" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "एक वाहन चुनें" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "차량 선택" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selecteer een voertuig" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wybierz pojazd" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selecionar veiculo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать авто" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Araç seç" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Оберіть авто" + } + } + } + }, + "Select a Vehicle" : { + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selecteer een voertuig" + } + } + } + }, + "Select the vehicle you want to export the maintenance events for:" : { + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selecteer het voertuig waarvan je de onderhoudsbeurten wil exporteren:" + } + } + } + }, + "Select vehicle" : { + + }, + "Select Vehicle" : { + + }, + "Selected vehicle" : { + + }, + "Selects the vehicle to display total number of maintenance events for." : { + + }, + "Settings" : { + "comment" : "Label to display settings.", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Налады" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nastavení" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "تنظیمات" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réglages" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सेटिंग्स" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설정" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Instellingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ustawienia" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configurações" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ayarlar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Налаштування" + } + } + } + }, + "Share" : { + "comment" : "Share the exported file in the share sheet", + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deel" + } + } + } + }, + "Sign Out" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выйсці" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odhlásit" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abmelden" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desconectar" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "خروج" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se déconnecter" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "साइन आउट" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "로그아웃" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afmelden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wyloguj się" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desconectar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выйти" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Çıkış Yap" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вийти з облікового запису" + } + } + } + }, + "Signed in as %@" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Увайсці як %@" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Přihlášen jako %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Angemeldet als %@" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registrado como %@" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "وارد شده به عنوان %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connecté en tant que %@" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ आईडी से साइन इन" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@으로 로그인되었습니다" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aangemeld als %@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zalogowany jako %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectado como %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Зарегистрироваться как %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ olarak giriş yapıldı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Увійшли в додаток як %@" + } + } + } + }, + "Tap the + tadd your firsst odometer reading" : { + "comment" : "Placeholder description for empty odometer reading list" + }, + "Tap the + to begin" : { + "comment" : "Empty odometer list prompt", + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "جهت شروع روی + تپ کنید" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Druk op + om te beginnen" + } + } + } + }, + "Thanks for using this app!" : { + "comment" : "Thanks a user for using the app.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Danke, dass Sie diese App verwenden!" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "ممنون از اینکه از این برنامه استفاده می‌کنید!" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "इस ऐप का उपयोग करने के लिए धन्यवाद!" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bedankt voor het gebruik van deze app!" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dziękujemy za korzystanie z tej aplikacji!" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obrigado por usar nosso App" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aplikasyonu kullandığınız için teşekkürler." + } + } + } + }, + "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", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дзякуй, што карыстаецеся гэтым дадаткам! Ëн з адкрытым зыходным кодам, і кожны можа ўнесці свой уклад у яго." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Děkuji Vám za využívání této aplikace! Díky otevřenosti může kdokoliv přispět k samotnému vývoji. " + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vielen Dank fürs Nutzen dieser App! Sie ist Open Source und Jede*r kann zur Entwicklung beitragen." + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "¡Gracias por usar esta aplicación! Es de código abierto y cualquiera puede contribuir." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "ممنون از اینکه از این برنامه استفاده می‌کنید! این برنامه متن باز است و همه می‌توانند در آن مشارکت کنند." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Merci d'utiliser cette app ! Le code est open source et tout le monde peut contribuer." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "इस ऐप का उपयोग करने के लिए धन्यवाद! यह खुला स्रोत है और कोई भी इसमें योगदान दे सकता है।" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 앱을 사용해 주셔서 감사합니다! 이 앱은 오픈 소스로 누구나 기여할 수 있습니다." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bedankt voor het gebruik van deze app! Het is open source en iedereen kan eraan bijdragen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dziękujemy za korzystanie z tej aplikacji! Jest ona open source i każdy może ją współtworzyć." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obrigado por usar nosso App., Esse é um projeto open source qualquer um pode contribuir" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Спасибо что скачали это приложение! Оно находиться в открытом доступе и каждый может участвовать в его развитии и разработке." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aplikasyonu kullandığınız için teşekkürler! Bu aplikasyon açık kaynak kodludur ve herkes katkıda bulunabilir." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дякуємо за використання цього додатку! Він має відкритий вихідний код і будь-хто може долучитися у його розвиток." + } + } + } + }, + "The last vehicle can't be deleted. Please add a new vehicle before removing this one." : { + "comment" : "Alert message preventing users from deleting their last vehicle", + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "آخرین وسیله نقلیه را نمی‌توان حذف کرد. لطفا قبل از حدف آن، یک وسیله نقلیه جدید اضافه کنید." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Het laatste voertuig kan niet verwijderd worden. Voeg een nieuw voertuig toe voordat je deze verwijdert." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ostatni pojazd nie może zostać usunięty. Dodaj nowy pojazd przed usunięciem tego." + } + } + } + }, + "The title of the event" : { + "comment" : "Maintenance event title text field label placeholder", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Назва падзеі" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Název události" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Titel des Events" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "El título del evento" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "عنوان رویداد" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le titre de l'évènement" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "घटना का शीर्षक" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이벤트 제목" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "De titel van de onderhoudsbeurt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tytuł zdarzenia" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O titulo do evento" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Название события" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bakım dosyası adı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Назва події" + } + } + } + }, + "There were no maintenance events for '%@'. Try a new search." : { + "comment" : "Text shwon when there are no results for maintenance search", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не было тэхнічнага абслугоўвання для «%@». Паспрабуйце новы пошук." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Es gibt keine Wartung für '%@'. Versuche eine neue Suche" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "هیچ رویداد نگهداری‌ برای ‌’%@’ وجود ندارد. یک جستجو جدید را امتحان کنید." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' के लिए कोई रखरखाव कार्यक्रम नहीं थे। नई खोज का प्रयास करें।" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@에 대한 유지 보수 이벤트가 없습니다. 새로운 검색을 시도하세요." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Er waren geen onderhoudsevenementen voor '%@'. Probeer een nieuwe zoekopdracht." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie było żadnych zdarzeń serwisowych dla \"%@\". Spróbuj nowego wyszukiwania." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nao ha eventos de manutenção para %@. Faca uma nova pesquisa" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тех обслуживания не было для '%@'. Попробуйте новый поиск." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ için bakım dosyası bulunamadı. Yeni bir arama yapın." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Для '%@' не виявлено обслуговуваннь. Спробуйте новий пошук." + } + } + } + }, + "This App Icon will appear on your Home Screen." : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Значок дадатка, які з'явіцца на вашым галоўным экране." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Das App Icon wird auf deinem Home Screen erscheinen" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "این آیکون در صفحه اصلی شما ظاهر خواهد شد." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "यह ऐप आइकन आपकी होम स्क्रीन पर दिखाई देगा।" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 앱 아이콘이 홈 화면에 나타납니다." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dit app-pictogram zal verschijnen op je startscherm." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ikona aplikacji pojawi się na ekranie głównym." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esse icone aparecerá na sua Home Screen" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Эта иконка появится на экране Home." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bu Aplikasyon İkonu Ana Ekranda yer alacak." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ця іконка додатку з'явиться на вашому головному екрані." + } + } + } + }, + "Time Range" : { + + }, + "Title" : { + "comment" : "Maintenance event title text field header", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Назва" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Název" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Titel" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Título" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "عنوان" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Titre" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "शीर्षक" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제목" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Titel" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tytuł" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Titulo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Название" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Başlık" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Назва" + } + } + } + }, + "Title of the maintenance event" : { + "comment" : "Maintenance event title text field label", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Назва мерапрыемства па тэхнічным абслугоўванні" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Název údržby" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Titel des Wartungs-Events" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "El Título del evento de mantenimiento" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "عنوان رویداد نگهداری" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le titre de l'évènement de maintenance" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "रखरखाव कार्यक्रम का शीर्षक" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "유지 보수 이벤트 제목" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Titel van het onderhoudsevenement" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nazwa zdarzenia serwisowego" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Titulo do evento de manutenção" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Название обслуживания" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bakım Başlık Adı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Назва обслуговування" + } + } + } + }, + "Tracks & displays total mileage, aiding timely maintenance planning." : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "کل مسافت پیموده شده را ردیابی و نمایش می‌دهد و به نگهداری به موقع کمک می‌کند." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Houdt kilometerstand bij en geeft deze weer, zodat onderhoudsbeurten op tijd gepland kunnen worden." + } + } + } + }, + "Units" : { + "comment" : "Label to represent the options for measurement units", + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eenheden" + } + } + } + }, + "Update" : { + "comment" : "Label for submit button on form to update an existing entry", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Абнавіць" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktualizovat" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktualisierung" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "به‌روزرسانی" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mettre à jour" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "अपडेट" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "업데이트" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bijwerken" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uaktualnij" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atualizar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновить" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Güncelle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Оновити" + } + } + } + }, + "Update Maintenance" : { + "extractionState" : "manual", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Абнаўленне тэхнічнага абслугоўвання" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wartung aktualisieren" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "به‌روزرسانی نگهداری" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "रखरखाव अपडेट करें" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "유지 보수 업데이트" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Onderhoud bijwerken" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uaktualnij serwis" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atualizar manutenção" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновить тех обслуживание" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bakım Güncelle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Оновити обслуговування" + } + } + } + }, + "Update Vehicle Info" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "به‌روزرسانی اطلاعات وسیله نقلیه" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update voertuiginformatie" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uaktualnij informacje o pojeździe" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Araç Bilgilerini Güncelle" + } + } + } + }, + "User-friendly interface for controlling car maintenance tasks." : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "رابط کاربری دوستانه جهت کنترل کار های نگهداری ماشین" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gebruiksvriendelijke interface om auto onderhoudsbeurten bij te houden." + } + } + } + }, + "Vehicle" : { + "comment" : "Maintenance event vehicle picker header", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Транспартны сродак" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fahrzeug" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "وسیله نقلیه" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "वाहन" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "차량" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voertuig" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pojazd" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Veiculo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Авто" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Araç" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Авто" + } + } + } + }, + "Vehicle Color" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Колер транспартнага сродку" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fahrzeugfarbe" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "رنگ وسیله نقلیه" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "वाहन का रंग" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "차량 색상" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voertuigkleur" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kolor pojazdu" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cor do veiculo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цвет авто" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Araç Rengi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Колір авта" + } + } + } + }, + "Vehicle Details" : { + "comment" : "Label about vehicle details.", + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voertuigdetails" + } + } + } + }, + "Vehicle License Plate Number" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нумар транспартнага сродку" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fahrzeug Kennzeichen" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "شماره پلاک وسیله نقلیه" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "वाहन लाइसेंस प्लेट नंबर" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "차량 번호판 번호" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kentekennummer" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Numer rejestracyjny pojazdu" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Placa do veiculo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Номер лицензии авто" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Araç Plakası" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Номерний знак авта" + } + } + } + }, + "Vehicle Make" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вытворца транспартнага сродку" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Výrobce vozidla" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fahrzeughersteller" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Marca del Vehículo" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "ایجاد وسیله نقلیه" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fabricant du véhicule" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "वाहन कंपनी" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "차량 제조사" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voertuigmerk" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Marka pojazdu" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Marca do veiculo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Производитель авто" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Araç Markası" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Виробник авто" + } + } + } + }, + "Vehicle Model" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Мадэль транспартнага сродку" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Model vozidla" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fahrzeugmodell" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modelo vehículo" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "مدل وسیله نقلیه" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modèle du véhicule" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "वाहन मॉडल" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "차량 모델" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voertuigmodel" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Model pojazdu" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modelo do veiculo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Модель авто" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Araç Modeli" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Модель авто" + } + } + } + }, + "Vehicle Name" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Назва транспартнага сродку" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Název vozidla" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fahrzeugname" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nombre del Vehículo" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "نام وسیله نقلیه" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nom du véhicule" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "वाहन का नाम" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "차량 이름" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voertuignaam" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nazwa pojazdu" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nome do veiculo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Название авто" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Araç Adı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Імʼя авто" + } + } + } + }, + "Vehicle VIN" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ідэнтыфікатар транспартнага сродку" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fahrgestellnummer" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "شماره VIN وسیله نقلیه" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "वाहन पहचान संख्या" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "차량 VIN" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voertuigidentificatienummer" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "VIN pojazdu" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Numero do registro do veiculo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "VIN номер" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Araç VIN Kodu" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "VIN код авта" + } + } + } }, - "Sign Out" : { - + "Vehicle Year" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Год транспартнага сродку" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Baujahr" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "سال وسیله نقلیه" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "वाहन निर्माण वर्ष" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "차량 연도" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voertuigjaar" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rok pojazdu" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ano do veiculo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Год производства" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Araş Yaşı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Рік випуску авто" + } + } + } }, - "Signed in as %@" : { - + "Vehicles" : { + "comment" : "Label to display header title.", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Транспартныя сродкі" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vozidla" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fahrzeuge" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vehículos" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "وسایل نقلیه" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Véhicules" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "वाहन" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "차량" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voertuigen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pojazdy" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Veiculos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Авто" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Araçlar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Автомобілі" + } + } + } }, - "Thanks for using this app! It's open source and anyone can contribute to it." : { - + "VehicleSectionHeader" : { + "comment" : "Label for Picker for selecting a vehicle", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fahrzeug" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vehicle" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "وسیله نقلیه" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "वाहन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voertuig" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pojazd" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Veiculo" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "AraçBölümBaşlığı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Авто" + } + } + } }, - "The title of the event" : { - + "Version %@ (%@)" : { + "comment" : "Label to display version and build number.", + "extractionState" : "stale", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Версія %@ (%@)" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verze %1$@ (%2$@)" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versión %@ (%@)" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "نسخه %@ (%@)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "वरजन %@ (%@)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "버전 %@ (%@)" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versie %@ (%@)" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versão: %@ (%@)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Версия %@ (%@)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versiyon: %@ (%@)" + } + } + } }, - "Title" : { - + "VIN" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "ІТС" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fahrgestellnummer" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "VIN" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "वाहन पहचान संख्या" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "VIN" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "VIN" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "VIN" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Numero de registro" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "VIN" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "VIN Kodu" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "VIN" + } + } + } }, - "Title of the maintenance event" : { - + "VIN: %@" : { + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "VIN: %@" + } + } + } }, - "Vehicle Make" : { - + "Welcome 🥳" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "خوش آمدید 🥳" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Welkom 🥳" + } + } + } }, - "Vehicle Model" : { - + "Welcome to" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "خوش آمدید به" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Welkom bij" + } + } + } }, - "Vehicle Name" : { - + "Year" : { + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Год" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Baujahr" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "سال" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "वर्ष" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연도" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jaar" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rok" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ano" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Год" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yıl" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Рік" + } + } + } }, - "Vehicles" : { - + "You can edit more data about the vehicle in the 'Settings' tab." : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "شما می‌توانید اطلاعات بیشتری را درباره وسیله نقلیه در بخش ‘تنظیمات’ ویرایش کنید." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Je kunt meer voertuiggegevens bewerken in het tabblad ‘Instellingen’." + } + } + } }, - "Version %@ (%@)" : { + "your vehicle" : { "localizations" : { - "en" : { + "fa" : { "stringUnit" : { - "state" : "new", - "value" : "Version %1$@ (%2$@)" + "state" : "translated", + "value" : "وسیله نقلیه شما" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "jouw voertuig" } } } } }, - "version" : "1.0" + "version" : "1.1" } \ No newline at end of file diff --git a/Basic-Car-Maintenance/Shared/MainView/ViewModels/MainTabViewModel.swift b/Basic-Car-Maintenance/Shared/MainView/ViewModels/MainTabViewModel.swift new file mode 100644 index 00000000..66704a8f --- /dev/null +++ b/Basic-Car-Maintenance/Shared/MainView/ViewModels/MainTabViewModel.swift @@ -0,0 +1,54 @@ +// +// MainTabViewModel.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import Foundation +import FirebaseFirestore + +@Observable +class MainTabViewModel { + @MainActor var alert: AlertItem? + + /// Update the UI once a new alert is sent + func fetchNewestAlert(ignoring acknowledgedAlerts: [String]) async { + + var query = Firestore + .firestore() + .collection(FirestoreCollection.alerts) + .whereField(FirestoreField.isOn, isEqualTo: true) + .limit(to: 1) + + if !acknowledgedAlerts.isEmpty { + query = query + .whereField(FirestoreField.id, notIn: acknowledgedAlerts) + } + + do { + let snapshot = try await query.getDocuments() + + let newAlert = snapshot.documents + .compactMap { + do { + return try $0.data(as: AlertItem.self) + } catch { + print("Error decoding to AlertItem: ", error) + return nil + } + } + .first + + if let newAlert { + Task { @MainActor in + self.alert = newAlert + } + } + + } catch { + print("Error getting documents: \(error)") + } + } +} diff --git a/Basic-Car-Maintenance/Shared/MainView/Views/AlertView.swift b/Basic-Car-Maintenance/Shared/MainView/Views/AlertView.swift new file mode 100644 index 00000000..18b6d82e --- /dev/null +++ b/Basic-Car-Maintenance/Shared/MainView/Views/AlertView.swift @@ -0,0 +1,84 @@ +// +// AlertView.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import SwiftUI + +struct AlertView: View { + let alert: AlertItem + @Environment(\.dismiss) var dismiss + + var body: some View { + NavigationView { + VStack { + ScrollView { + VStack(alignment: .center) { + Text(alert.emojiIcon) + .font(.system(size: 100)) + + Text(alert.title) + .font(.title) + .minimumScaleFactor(0.7) + .lineLimit(2) + .bold() + + Text(alert.message.replacingOccurrences(of: "\\n", with: "\n")) + } + .multilineTextAlignment(.center) + } + .scrollIndicators(.hidden) + .padding(.horizontal, 24) + + Button { + guard let url = URL(string: alert.actionURL), + UIApplication.shared.canOpenURL(url) else { + dismiss() + return + } + + UIApplication.shared.open(url) + } label: { + Text(alert.actionText) + .font(.title3) + .foregroundStyle(.black) + .frame(maxWidth: .infinity) + } + .frame(minHeight: 44) + .background { + Color(.basicGreen) + } + .clipShape(.capsule) + .padding([.bottom, .trailing, .leading], 24) + } + .toolbar { + ToolbarItemGroup(placement: .topBarLeading) { + Button { + dismiss() + } label: { + Text("Dismiss") + .bold() + } + } + } + } + .interactiveDismissDisabled() + } +} + +#Preview { + AlertView( + alert: AlertItem( + id: nil, + actionText: "", + actionURL: "", + emojiIcon: "", + isOn: false, + message: "", + title: "" + ) + ) +} diff --git a/Basic-Car-Maintenance/Shared/MainView/Views/MainTabView.swift b/Basic-Car-Maintenance/Shared/MainView/Views/MainTabView.swift new file mode 100644 index 00000000..704c723b --- /dev/null +++ b/Basic-Car-Maintenance/Shared/MainView/Views/MainTabView.swift @@ -0,0 +1,165 @@ +// +// MainTabView.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import SwiftData +import SwiftUI + +enum TabSelection: Int, Identifiable, CaseIterable { + var id: Self { self } + + case dashboard = 0 + case odometer = 1 + case settings = 2 +} + +extension TabSelection { + var label: LocalizedStringKey { + switch self { + case .dashboard: + return "Dashboard" + case .odometer: + return "Odometer" + case .settings: + return "Settings" + } + } + + var image: String { + switch self { + case .dashboard: + return SFSymbol.dashboard + case .odometer: + return SFSymbol.gauge + case .settings: + return SFSymbol.gear + } + } +} + +@MainActor +struct MainTabView: View { + @Query var acknowledgedAlerts: [AcknowledgedAlert] + + @Environment(ActionService.self) var actionService + @Environment(\.modelContext) private var context + @Environment(\.scenePhase) var scenePhase + + @AppStorage("lastTabOpen") var selectedTab = TabSelection.dashboard + + @State private var selectedTabId: TabSelection.ID? = .dashboard + @State private var columnVisibility = NavigationSplitViewVisibility.automatic + + @State private var authenticationViewModel = AuthenticationViewModel() + @State private var viewModel = MainTabViewModel() + + init() { + _selectedTabId = State(initialValue: selectedTab) + } + + var body: some View { + Group { + #if os(iOS) + if UIDevice.current.userInterfaceIdiom == .pad { + navigationSplitView() + } else { + tabView() + } + #else + navigationSplitView() + #endif + } + .sheet(item: $viewModel.alert) { alert in + AlertView(alert: alert) + .presentationDetents([.medium]) + } + .onChange(of: scenePhase) { _, newScenePhase in + guard + case .active = newScenePhase, + let action = actionService.action + else { return } + + // select the tab where the desired view is located to make sure + // it is presented from the proper hierarchy. + switch action { + case .newMaintenance: + selectedTab = .dashboard + case .addVehicle: + selectedTab = .settings + } + } + .onAppear { + Task { @MainActor in + await viewModel.fetchNewestAlert(ignoring: acknowledgedAlerts.map(\.id)) + } + } + .onChange(of: viewModel.alert) { _, newValue in + guard let id = newValue?.id else { return } + saveNewAlert(id) + } + .onChange(of: selectedTabId ?? TabSelection.dashboard) { _, newValue in + selectedTab = newValue + } + } + + /// Save newly acknowledged alert to DB + /// - Parameter id: alert's id + private func saveNewAlert(_ id: String) { + let acknowledgedAlert = AcknowledgedAlert(id: id) + context.insert(acknowledgedAlert) + } + + /// Save screen content for specific selection + /// - Parameter selection: tab selection enum value + @ViewBuilder + private func selectionContent(for selection: TabSelection) -> some View { + switch selection { + case .dashboard: + DashboardView(userUID: authenticationViewModel.user?.uid) + case .odometer: + OdometerView(userUID: authenticationViewModel.user?.uid) + case .settings: + SettingsView(authenticationViewModel: authenticationViewModel) + } + } + + /// Primarily used on iPad and Mac devices + /// - Returns: `NavigationSplitView` navigation + @ViewBuilder + private func navigationSplitView() -> some View { + NavigationSplitView(columnVisibility: $columnVisibility) { + List(TabSelection.allCases, selection: $selectedTabId) { tabSelection in + Label(tabSelection.label, systemImage: tabSelection.image) + } + .navigationTitle("Basic Car") + } detail: { + if let tabSelection = selectedTabId { + selectionContent(for: tabSelection) + .tag(tabSelection) + } + } + } + + /// Primarily used on iPhone devices + /// - Returns: `TabView` navigation + @ViewBuilder func tabView() -> some View { + TabView(selection: $selectedTab) { + ForEach(TabSelection.allCases) { tabSelection in + selectionContent(for: tabSelection) + .tag(tabSelection) + .tabItem { + Label(tabSelection.label, systemImage: tabSelection.image) + } + } + } + } +} + +#Preview { + MainTabView() + .environment(ActionService.shared) +} diff --git a/Basic-Car-Maintenance/Shared/Models/Action.swift b/Basic-Car-Maintenance/Shared/Models/Action.swift new file mode 100644 index 00000000..248f8da8 --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Models/Action.swift @@ -0,0 +1,32 @@ +// +// Action.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import UIKit + +enum ActionType: String { + case newMaintenance = "NewMaintenance" + case addVehicle = "AddVehicle" +} + +enum Action: Equatable { + case newMaintenance + case addVehicle + + init?(shortcutItem: UIApplicationShortcutItem) { + guard let type = ActionType(rawValue: shortcutItem.type) else { + return nil + } + + switch type { + case .newMaintenance: + self = .newMaintenance + case .addVehicle: + self = .addVehicle + } + } +} diff --git a/Basic-Car-Maintenance/Shared/Models/AlertItem.swift b/Basic-Car-Maintenance/Shared/Models/AlertItem.swift new file mode 100644 index 00000000..4bdfeb8e --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Models/AlertItem.swift @@ -0,0 +1,46 @@ +// +// AlertItem.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import Foundation +import FirebaseFirestore +import SwiftData + +struct AlertItem: Codable, Identifiable { + @DocumentID var id: String? + var actionText: String + var actionURL: String + var emojiIcon: String + var isOn: Bool + var message: String + var title: String + + enum CodingKeys: String, CodingKey { + case id + case actionText + case actionURL + case emojiIcon + case isOn + case message + case title + } +} + +extension AlertItem: Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id + } +} + +@Model +final class AcknowledgedAlert { + var id: String + + init(id: String) { + self.id = id + } +} diff --git a/Basic-Car-Maintenance/Shared/Models/AppIcon.swift b/Basic-Car-Maintenance/Shared/Models/AppIcon.swift new file mode 100644 index 00000000..38188715 --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Models/AppIcon.swift @@ -0,0 +1,61 @@ +// +// ChooseAppIconView.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import UIKit + +enum AppIcon: String, CaseIterable, Identifiable { + case primary = "AppIcon" + case carRed = "AppIcon-car-red" + case carYellow = "AppIcon-car-yellow" + case carBlack = "AppIcon-car-black" + case carOrange = "AppIcon-car-orange" + + var id: String { rawValue } + + /// the name of the icon in the App Bundle. + var iconName: String? { + switch self { + /// returns `nil`. Use this case to reset the app icon back to its primary icon. + case .primary: + return nil + default: + return rawValue + } + } + + /// A UI presentable string. + var description: String { + switch self { + case .primary: + return "Default" + case .carRed: + return "Red Car" + case .carYellow: + return "Yellow Car" + case .carBlack: + return "Black Car" + case .carOrange: + return "Orange Car" + } + } + + var previewImage: String { + switch self { + case .primary: + return "Preview-appIcon" + case .carRed: + return "Preview-car-red" + case .carYellow: + return "Preview-car-yellow" + case .carBlack: + return "Preview-car-black" + case .carOrange: + return "Preview-car-orange" + } + } +} diff --git a/Basic-Car-Maintenance/Shared/Models/Contributor.swift b/Basic-Car-Maintenance/Shared/Models/Contributor.swift new file mode 100644 index 00000000..12ff317d --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Models/Contributor.swift @@ -0,0 +1,53 @@ +// +// Contributor.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import Foundation + +/// A model representing a contributor. +/// +/// Contributors are people who have supported this project in any manner. +/// +/// - Note: In this project, we don't actually create this ``Contributor`` +/// anywhere, we are getting all the contributors from [GitHub's Repository Statistics +/// API](https://api.github.com/repos/mikaelacaron/Basic-Car-Maintenance/contributors) +/// +/// On status code of 200, we decode the response into ``SettingsViewModel/contributors`` +/// array of ``Contributor`` type and display them in ``ContributorsListView``. +struct Contributor: Codable, Hashable, Identifiable { + + /// The handle for the GitHub user account + let login: String + + /// The unique identifier for an account + let id: Int + + /// The ID used to move between the REST API and the GraphQL API + let nodeID: String + + /// The link to profile image of the user + let avatarURL: String + + /// The endpoint for a user's profile data + let url: String + + /// The url to this account on GitHub + let htmlURL: String + + /// The number of Pull Requests successfully merged + let contributions: Int + + /// Keys to be used for encoding and decoding. + enum CodingKeys: String, CodingKey { + case login, id + case nodeID = "nodeId" + case avatarURL = "avatarUrl" + case url + case htmlURL = "htmlUrl" + case contributions + } +} diff --git a/Basic-Car-Maintenance/Shared/Models/MaintenanceEvent.swift b/Basic-Car-Maintenance/Shared/Models/MaintenanceEvent.swift index c1b4e2eb..18daa147 100644 --- a/Basic-Car-Maintenance/Shared/Models/MaintenanceEvent.swift +++ b/Basic-Car-Maintenance/Shared/Models/MaintenanceEvent.swift @@ -2,15 +2,18 @@ // MaintenanceEvent.swift // Basic-Car-Maintenance // -// Created by Mikaela Caron on 8/25/23. +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. // +import FirebaseFirestore import Foundation -import FirebaseFirestoreSwift struct MaintenanceEvent: Codable, Identifiable, Hashable { @DocumentID var id: String? var userID: String? + + let vehicleID: String let title: String let date: Date let notes: String diff --git a/Basic-Car-Maintenance/Shared/Models/OdometerReading.swift b/Basic-Car-Maintenance/Shared/Models/OdometerReading.swift new file mode 100644 index 00000000..91bba73a --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Models/OdometerReading.swift @@ -0,0 +1,28 @@ +// +// OdometerReading.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import Foundation +import FirebaseFirestore + +struct OdometerReading: Codable, Identifiable, Hashable, Equatable { + @DocumentID var id: String? + var userID: String? + let date: Date + let distance: Int + let isMetric: Bool + let vehicleID: String + + // MARK: - Equatable + + static func == (lhs: OdometerReading, rhs: OdometerReading) -> Bool { + return lhs.userID == rhs.userID && + lhs.date == rhs.date && + lhs.distance == rhs.distance && + lhs.vehicleID == rhs.vehicleID + } +} diff --git a/Basic-Car-Maintenance/Shared/Models/Vehicle.swift b/Basic-Car-Maintenance/Shared/Models/Vehicle.swift index 136c9c00..b30b5bd9 100644 --- a/Basic-Car-Maintenance/Shared/Models/Vehicle.swift +++ b/Basic-Car-Maintenance/Shared/Models/Vehicle.swift @@ -2,16 +2,41 @@ // Vehicle.swift // Basic-Car-Maintenance // -// Created by Mikaela Caron on 8/25/23. +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. // +import FirebaseFirestore import Foundation -import FirebaseFirestoreSwift -struct Vehicle: Codable, Identifiable { +struct Vehicle: Codable, Identifiable, Hashable { @DocumentID var id: String? var userID: String? let name: String let make: String let model: String + let year: String? + let color: String? + let vin: String? + let licensePlateNumber: String? + + 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 + self.userID = userID + self.name = name + self.make = make + self.model = model + self.year = year + self.color = color + self.vin = vin + self.licensePlateNumber = licensePlateNumber + } } diff --git a/Basic-Car-Maintenance/Shared/Odometer/ViewModels/OdometerViewModel.swift b/Basic-Car-Maintenance/Shared/Odometer/ViewModels/OdometerViewModel.swift new file mode 100644 index 00000000..b2f1195c --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Odometer/ViewModels/OdometerViewModel.swift @@ -0,0 +1,78 @@ +// +// OdometerViewModel.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import FirebaseFirestore +import Foundation + +@Observable +class OdometerViewModel { + + let userUID: String? + + var readings = [OdometerReading]() + var showAddErrorAlert = false + var isShowingAddOdometerReading = false + var errorMessage: String = "" + + var showEditErrorAlert = false + var selectedReading: OdometerReading? + var isShowingEditReadingView = false + var vehicles = [Vehicle]() + var selectedVehicle: Vehicle? + + let firebaseService: FirebaseServiceProtocol + + init(userUID: String?, firebaseService: FirebaseServiceProtocol) { + self.userUID = userUID + self.firebaseService = firebaseService + } + + func addReading(_ odometerReading: OdometerReading) throws { + if let uid = userUID { + var readingToAdd = odometerReading + readingToAdd.userID = uid + + try firebaseService.addReading(readingToAdd) + AnalyticsService.shared.logEvent(.odometerCreate) + } + } + + func deleteReading(_ reading: OdometerReading) async { + if let eventIndex = readings.firstIndex(of: reading) { + readings.remove(at: eventIndex) + + await firebaseService.deleteReading(reading) + AnalyticsService.shared.logEvent(.odometerDelete) + } + } + + func getOdometerReadings() async { + if let userUID = userUID { + self.readings = await firebaseService.getReadings(userUID: userUID) + } + } + + func updateOdometerReading(_ reading: OdometerReading) { + do { + try firebaseService.updateReading(reading) + + AnalyticsService.shared.logEvent(.odometerUpdate) + + isShowingEditReadingView = false + } catch { + errorMessage = error.localizedDescription + showEditErrorAlert = true + } + } + + func getVehicles() async { + if let userUID = userUID { + self.vehicles = await firebaseService.getVehicles(userUID: userUID) + } + } +} diff --git a/Basic-Car-Maintenance/Shared/Odometer/Views/AddOdometerReadingView.swift b/Basic-Car-Maintenance/Shared/Odometer/Views/AddOdometerReadingView.swift new file mode 100644 index 00000000..b36ec03b --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Odometer/Views/AddOdometerReadingView.swift @@ -0,0 +1,124 @@ +// +// AddOdometerReadingView.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import SwiftUI + +struct AddOdometerReadingView: View { + + let vehicles: [Vehicle] + let addTapped: (OdometerReading) -> Void + + @AppStorage(AppStorageKeys.measurementSystem) + private var defaultUnitSystem: MeasurementSystem = .userDefault + + @Environment(\.dismiss) var dismiss + + @State private var date = Date() + @State private var selectedVehicleID: String? + @State private var isMetric = false + @State private var distance = 0 + + init( + vehicles: [Vehicle], + addTapped: @escaping (OdometerReading) -> Void + ) { + self.vehicles = vehicles + self.addTapped = addTapped + self.isMetric = _defaultUnitSystem.wrappedValue == .metric + } + + var body: some View { + NavigationStack { + Form { + Section { + HStack { + Image(systemName: SFSymbol.gaugeWithNeedle) + .foregroundStyle(.secondary) + TextField("Distance", value: $distance, format: .number) + + Picker(selection: $isMetric) { + Text("Miles", comment: "Label for miles unit") + .tag(false) + Text("Kilometers", comment: "Label for kilometers unit") + .tag(true) + } label: { + Text("Preferred units", + comment: "Label for units selected when adding an odometer reading") + } + .pickerStyle(.segmented) + } + } + + Section { + HStack { + Image(systemName: SFSymbol.carFill) + .foregroundStyle(.secondary) + + Picker(selection: $selectedVehicleID) { + ForEach(vehicles) { vehicle in + Text(vehicle.name) + .tag(vehicle.id) + } + } label: { + Text("Select a vehicle", + comment: "Picker for selecting a vehicle") + } + .pickerStyle(.menu) + } + } header: { + Text("VehicleSectionHeader", + comment: "Label for Picker for selecting a vehicle") + } + + HStack { + Image(systemName: SFSymbol.calendar) + .foregroundStyle(.secondary) + + DatePicker(selection: $date, displayedComponents: .date) { + Text("Date", comment: "Date picker label") + } + .dynamicTypeSize(...DynamicTypeSize.accessibility2) + } + } + .onAppear { + if !vehicles.isEmpty { + selectedVehicleID = vehicles[0].id + } + } + .navigationTitle(Text("Add Reading", + comment: "Title for form when adding an odometer reading")) + .toolbar { + ToolbarItem { + Button(role: .confirm) { + if let selectedVehicleID { + let reading = OdometerReading(date: date, + distance: distance, + isMetric: isMetric, + vehicleID: selectedVehicleID) + addTapped(reading) + } + } label: { + Label("Add", systemImage: "checkmark") + .labelStyle(.iconOnly) + } + .disabled(distance < 0) + } + } + } + .analyticsView("\(Self.self)") + } +} + +#Preview { + let sampleVehicles = [ + Vehicle(name: "Nate Forester", make: "Subaru", model: "Forester"), + Vehicle(name: "Dani Impreza", make: "Subaru", model: "Impreza") + ] + + AddOdometerReadingView(vehicles: sampleVehicles) { _ in } +} diff --git a/Basic-Car-Maintenance/Shared/Odometer/Views/EditOdometerReadingView.swift b/Basic-Car-Maintenance/Shared/Odometer/Views/EditOdometerReadingView.swift new file mode 100644 index 00000000..cdc5c3a9 --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Odometer/Views/EditOdometerReadingView.swift @@ -0,0 +1,120 @@ +// +// EditOdometerReadingView.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import SwiftUI + +struct EditOdometerReadingView: View { + let selectedReading: OdometerReading + + let vehicles: [Vehicle] + let updateTapped: (OdometerReading) -> Void + + @State private var date = Date() + @State private var isMetric = false + @State private var distance = 0 + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Form { + Section { + HStack { + Image(systemName: SFSymbol.gaugeWithNeedle) + .foregroundStyle(.secondary) + TextField("Distance", value: $distance, format: .number) + + Picker(selection: $isMetric) { + Text("Miles", comment: "Label for miles unit") + .tag(false) + Text("Kilometers", comment: "Label for kilometers unit") + .tag(true) + } label: { + Text("Preferred units", + comment: "Label for units selected when adding an odometer reading") + } + .pickerStyle(.segmented) + } + } + + Section { + HStack { + Image(systemName: SFSymbol.carFill) + .foregroundStyle(.secondary) + + if let vehicleName = vehicles + .filter({ $0.id == selectedReading.vehicleID }).first?.name { + Text(vehicleName) + .opacity(0.3) + } + } + } header: { + Text("Vehicle") + } + + HStack { + Image(systemName: SFSymbol.calendar) + .foregroundStyle(.secondary) + + DatePicker(selection: $date, displayedComponents: .date) { + Text("Date", comment: "Date picker label") + } + .dynamicTypeSize(...DynamicTypeSize.accessibility2) + } + } + .onAppear { + setEditReadingValues(selectedReading) + } + .navigationTitle(Text("Edit Reading", + comment: "Title for form when editing an odometer reading")) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button(role: .cancel) { + dismiss() + } + } + + ToolbarItem { + Button(role: .confirm) { + let reading = OdometerReading(id: selectedReading.id, + date: date, + distance: distance, + isMetric: isMetric, + vehicleID: selectedReading.vehicleID) + updateTapped(reading) + } label: { + Label("Update", systemImage: "checkmark") + .labelStyle(.iconOnly) + } + .disabled(distance < 0) + } + } + } + .analyticsView("\(Self.self)") + } + + func setEditReadingValues(_ reading: OdometerReading) { + self.date = reading.date + self.isMetric = reading.isMetric + self.distance = reading.distance + } +} + +#Preview { + let sampleVehicles = [ + Vehicle(id: UUID().uuidString, name: "Nate Forester", make: "Subaru", model: "Forester"), + Vehicle(id: UUID().uuidString, name: "Dani Impreza", make: "Subaru", model: "Impreza") + ] + + EditOdometerReadingView( + selectedReading: OdometerReading(date: Date(), + distance: 0, + isMetric: false, + vehicleID: sampleVehicles[0].id!), + vehicles: sampleVehicles) { _ in } +} diff --git a/Basic-Car-Maintenance/Shared/Odometer/Views/OdometerRowView.swift b/Basic-Car-Maintenance/Shared/Odometer/Views/OdometerRowView.swift new file mode 100644 index 00000000..1f1ca645 --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Odometer/Views/OdometerRowView.swift @@ -0,0 +1,44 @@ +// +// OdometerRowView.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import SwiftUI + +struct OdometerRowView: View { + let reading: OdometerReading + let vehicleName: String? + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: SFSymbol.carFill) + + Text(vehicleName ?? "No Name") + .font(.title3) + } + + VStack(alignment: .leading, spacing: 4) { + Label { + Text("\(reading.distance) \(reading.isMetric ? "km" : "mi")") + } icon: { + Image(systemName: SFSymbol.gaugeWithNeedle) + } + + Label { + Text(reading.date.formatted(date: .abbreviated, time: .omitted)) + } icon: { + Image(systemName: SFSymbol.calendar) + } + } + } + } +} + +#Preview { + OdometerRowView(reading: .init(date: .now, distance: 1000, isMetric: true, vehicleID: "1234"), + vehicleName: "Sample Vehicle Name") +} diff --git a/Basic-Car-Maintenance/Shared/Odometer/Views/OdometerView.swift b/Basic-Car-Maintenance/Shared/Odometer/Views/OdometerView.swift new file mode 100644 index 00000000..5326b475 --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Odometer/Views/OdometerView.swift @@ -0,0 +1,306 @@ +// +// OdometerView.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import Foundation +import SwiftUI +import Charts + +struct OdometerView: View { + @Environment(ActionService.self) var actionService + + @State private var viewModel: OdometerViewModel + @State private var selectedTimeRange: TimeRange = .all + + init(userUID: String?) { + self.init(viewModel: OdometerViewModel(userUID: userUID, firebaseService: FirebaseService())) + } + + fileprivate init(viewModel: OdometerViewModel) { + self.viewModel = viewModel + } + + // swiftlint:disable:next line_length + /// Filter the readings based on the selected vehicle in the filter, and if no vehicle is selected, it shows all the vehicles' readings + private var filteredReadings: [OdometerReading] { + if viewModel.selectedVehicle == nil { + return viewModel.readings + } else { + return viewModel.readings.filter { $0.vehicleID == viewModel.selectedVehicle?.id } + } + } + + var body: some View { + NavigationStack { + VStack { + Picker("Time Range", selection: $selectedTimeRange) { + ForEach(TimeRange.allCases) { timeRange in + Text(timeRange.rawValue).tag(timeRange) + } + } + .pickerStyle(.segmented) + .padding(.horizontal) + + if !viewModel.readings.isEmpty { + GroupBox { + Chart { + ForEach(viewModel.vehicles) { vehicle in + let vehicleReadings = filteredReadings(for: vehicle) + + if !vehicleReadings.isEmpty { + ForEach(vehicleReadings) { reading in + LineMark( + x: .value("Date", reading.date, unit: .day), + y: .value("Odometer", reading.distance) + ) + } + .foregroundStyle(by: .value("Vehicle", vehicle.name)) + .symbol(by: .value("Vehicle", vehicle.name)) + .interpolationMethod(.monotone) + } + } + } + .frame(height: 200) + } + .padding(.horizontal) + .listRowSeparator(.hidden) + } + + List { + ForEach(filteredReadings) { reading in + let vehicleName = viewModel.vehicles.first { $0.id == reading.vehicleID }?.name + OdometerRowView(reading: reading, vehicleName: vehicleName) + .foregroundStyle(.primary) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + Task { + await viewModel.deleteReading(reading) + } + } label: { + Image(systemName: SFSymbol.trash) + } + + Button { + viewModel.selectedReading = reading + viewModel.isShowingEditReadingView = true + } label: { + Label { + Text("Edit") + } icon: { + Image(systemName: SFSymbol.pencil) + } + } + } + } + .listStyle(.inset) + } + } + .overlay { + if viewModel.readings.isEmpty { + ContentUnavailableView { + Label { + Text("Add your first odometer reading", + comment: "Placeholder text for empty odometer reading list") + } icon: { + Image(systemName: SFSymbol.gaugeWithNeedle) + } + } description: { + Text("Tap the + tadd your firsst odometer reading", + comment: "Placeholder description for empty odometer reading list") + } + } + } + .navigationTitle(Text("Odometer")) + .navigationDestination(isPresented: $viewModel.isShowingAddOdometerReading) { + makeAddOdometerView() + } + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + Menu { + Button("All") { + viewModel.selectedVehicle = nil + applyFilter() + } + ForEach(viewModel.vehicles) { vehicle in + Button(vehicle.name) { + viewModel.selectedVehicle = vehicle + applyFilter() + } + } + } label: { + Image(systemName: SFSymbol.filter) + } + .accessibilityShowsLargeContentViewer { + Label { + Text("Filter.Odometer", comment: "Label for filtering on Odometer view") + } icon: { + Image(systemName: SFSymbol.filter) + } + } + Button { + viewModel.isShowingAddOdometerReading = true + } label: { + Image(systemName: SFSymbol.plus) + } + } + } + .task { + await viewModel.getOdometerReadings() + await viewModel.getVehicles() + } + .sheet(isPresented: $viewModel.isShowingEditReadingView) { + if let selectedReading = viewModel.selectedReading { + // swiftlint:disable:next line_length + EditOdometerReadingView(selectedReading: selectedReading, vehicles: viewModel.vehicles) { updatedReading in + viewModel.updateOdometerReading(updatedReading) + } + .alert("An Error Occurred", isPresented: $viewModel.showEditErrorAlert) { + Button("OK", role: .cancel) { } + } message: { + Text(viewModel.errorMessage) + } + } + } + } + .analyticsView("\(Self.self)") + } + + /// Checks if we have selected any vehicle for filtering the readings. + private func applyFilter() { + if let selectedVehicle = viewModel.selectedVehicle { + viewModel.selectedVehicle = viewModel.vehicles.first(where: { $0.id == selectedVehicle.id }) + } else { + viewModel.selectedVehicle = nil + } + } + + // swiftlint:disable:next line_length + /// Filter the readings based on the selected time range, and if there are no readings in the last 30 days, just show the last reading. + /// - Parameter vehicle: The vehicle for these readings. + /// - Returns: The `[OdometerReading]`s for this vehicle in the time range. + private func filteredReadings(for vehicle: Vehicle) -> [OdometerReading] { + let vehicleReadings = viewModel.readings.filter { $0.vehicleID == vehicle.id } + + switch selectedTimeRange { + case .all: + return vehicleReadings + case .last30Days: + guard let lastReadingDate = vehicleReadings.map({ $0.date }).max() else { + return [] + } + let thirtyDaysAgo = Calendar.current.date(byAdding: .day, value: -30, to: Date()) ?? Date() + + // If the last reading is older than 30 days, include only the last reading + if lastReadingDate < thirtyDaysAgo { + if let lastReading = vehicleReadings.max(by: { $0.date < $1.date }) { + return [lastReading] + } else { + return [] + } + } else { + return vehicleReadings.filter { $0.date >= thirtyDaysAgo } + } + } + } + + private func makeAddOdometerView() -> some View { + AddOdometerReadingView(vehicles: viewModel.vehicles) { reading in + do { + try viewModel.addReading(reading) + viewModel.isShowingAddOdometerReading = false + Task { + await viewModel.getOdometerReadings() + } + } catch { + viewModel.errorMessage = error.localizedDescription + viewModel.showAddErrorAlert = true + } + } + .alert("An Error Occurred", isPresented: $viewModel.showAddErrorAlert) { + Button("OK", role: .cancel) { } + } message: { + Text(viewModel.errorMessage) + } + } +} + +/// The time range options in the picker for the graph. +enum TimeRange: String, CaseIterable, Identifiable { + case all = "All readings" + case last30Days = "Latest readings" + + var id: String { self.rawValue } +} + +#Preview { + let firstCar = createVehicle(id: "id1", name: "My 1st car") + let secondCar = createVehicle(id: "id2", name: "2nd Car") + let thirdCar = createVehicle(id: "id3", name: "3rd Car") + + let firstReading = createReading(vehicleID: firstCar.id!, + date: "2024/10/18", + distance: 35) + let secondReading = createReading(vehicleID: firstCar.id!, + date: "2024/10/19", + distance: 564) + let thirdReading = createReading(vehicleID: firstCar.id!, + date: "2024/11/23", + distance: 1000) + + let fourthReading = createReading(vehicleID: firstCar.id!, + date: "2024/11/30", + distance: 1024) + let fifthReading = createReading(vehicleID: secondCar.id!, + date: "2024/10/1", + distance: 1000) + + let sixthReading = createReading(vehicleID: secondCar.id!, + date: "2024/10/13", + distance: 1144) + let seventhReading = createReading(vehicleID: secondCar.id!, + date: "2024/10/15", + distance: 1412) + + let eighthReading = createReading(vehicleID: secondCar.id!, + date: "2024/11/13", + distance: 1542) + let ninthReading = createReading(vehicleID: thirdCar.id!, + date: "2024/11/16", + distance: 1600) + + let viewModel = OdometerViewModel(userUID: nil, firebaseService: FirebaseService()) + + // swiftlint:disable:next line_length + viewModel.readings.append(contentsOf: [firstReading, secondReading, thirdReading, fourthReading, fifthReading, sixthReading, seventhReading, eighthReading, ninthReading]) + + 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) + } +} diff --git a/Basic-Car-Maintenance/Shared/Onboarding/Views/WelcomeView.swift b/Basic-Car-Maintenance/Shared/Onboarding/Views/WelcomeView.swift new file mode 100644 index 00000000..b46a5f34 --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Onboarding/Views/WelcomeView.swift @@ -0,0 +1,98 @@ +// +// WelcomeView.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import SwiftUI + +struct WelcomeView: View { + + var body: some View { + NavigationView { + VStack(spacing: 15) { + VStack { + HStack(spacing: 5) { + Text("Welcome to") + Text("Basic") + .foregroundStyle(Color("basicGreen")) + } + Text("Car Maintenance") + .foregroundStyle(Color("basicGreen")) + } + .font(.largeTitle.bold()) + .multilineTextAlignment(.center) + .padding(.top, 65) + .padding(.bottom, 35) + .padding(15) + + VStack(alignment: .leading, spacing: 25) { + pointView( + symbol: "car", + title: "Dashboard", + subTitle: "User-friendly interface for controlling car maintenance tasks." + ) + + pointView( + symbol: "gauge.with.dots.needle.bottom.50percent.badge.plus", + title: "Odometer", + subTitle: "Tracks & displays total mileage, aiding timely maintenance planning." + ) + + pointView( + symbol: "lock.open", + title: "Open Source", + subTitle: "Built collaboratively with contributors, enhancing the app functionality." + ) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 15) + .padding(15) + + Spacer(minLength: 10) + + NavigationLink(destination: WelcomeViewAddVehicle()) { + Text("Continue") + .fontWeight(.bold) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .contentShape(.rect) + } + .tint(.basicGreen) + .padding(.horizontal, 15) + .buttonStyle(.glassProminent) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background { + Color(UIColor.secondarySystemBackground) + .ignoresSafeArea() + } + } + } + + @ViewBuilder + func pointView(symbol: String, title: LocalizedStringKey, subTitle: LocalizedStringKey) -> some View { + HStack(spacing: 20) { + Image(systemName: symbol) + .font(.largeTitle) + .foregroundStyle(Color("basicGreen")) + .frame(width: 45) + + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.title3) + .fontWeight(.semibold) + + Text(subTitle) + .foregroundStyle(.gray) + } + } + } +} + +#Preview { + WelcomeView() +} diff --git a/Basic-Car-Maintenance/Shared/Onboarding/Views/WelcomeViewAddVehicle.swift b/Basic-Car-Maintenance/Shared/Onboarding/Views/WelcomeViewAddVehicle.swift new file mode 100644 index 00000000..94989640 --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Onboarding/Views/WelcomeViewAddVehicle.swift @@ -0,0 +1,112 @@ +// +// WelcomeViewAddVehicle.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import SwiftUI + +struct WelcomeViewAddVehicle: View { + + // Logic to remember Onboarding screen to not load again when app is launched +// @AppStorage("isFirstTime") private var isFirstTime: Bool = true + @Environment(\.dismiss) var dismiss + + @State private var vehicleName: String = "" + @State private var vehicleMake: String = "" + @State private var vehicleModel: String = "" + + var body: some View { + VStack(spacing: 15) { + VStack { + Text("Add the details below") + HStack(spacing: 5) { + Text("about") + Text("your vehicle") + .foregroundStyle(Color("basicGreen")) + } + } + .font(.largeTitle.bold()) + .multilineTextAlignment(.center) + .padding(.top, 65) + .padding(.bottom, 15) + + VStack { + Image(systemName: "car.side.lock.open") + .font(.system(size: 45)) + .foregroundStyle(Color("basicGreen")) + + List { + HStack { + Text("Name") + Spacer() + .frame(width: 40) + TextField("Vehicle Name", text: $vehicleName) + } + + HStack { + Text("Make") + Spacer() + .frame(width: 45) + TextField("Vehicle Make", text: $vehicleMake) + } + HStack { + Text("Model") + Spacer() + .frame(width: 40) + TextField("Vehicle Model", text: $vehicleModel) + } + } + .frame(maxHeight: 200) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 15) + + Text("You can edit more data about the vehicle in the 'Settings' tab.") + .foregroundStyle(.gray) + .padding(.horizontal, 25) + + Spacer(minLength: 10) + + Button { +// isFirstTime = false + } label: { + Text("Welcome 🥳") + .fontWeight(.bold) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .contentShape(.rect) + } + .padding(15) + .padding(.horizontal, 15) + .tint(.basicGreen) + .buttonStyle(.glassProminent) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background { + Color(UIColor.secondarySystemBackground) + .ignoresSafeArea() + } + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + dismiss() + } label: { + HStack { + Image(systemName: "arrow.left.circle") + Text("Back") + } + .tint(Color("basicGreen")) + } + } + } + } +} + +#Preview { + WelcomeViewAddVehicle() +} diff --git a/Basic-Car-Maintenance/Shared/PrivacyInfo.xcprivacy b/Basic-Car-Maintenance/Shared/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..9add5039 --- /dev/null +++ b/Basic-Car-Maintenance/Shared/PrivacyInfo.xcprivacy @@ -0,0 +1,85 @@ + + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + NSPrivacyCollectedDataTypes + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeName + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypePurposes + + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeEmailAddress + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypePurposes + + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypePhoneNumber + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypePurposes + + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypePhotosorVideos + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypePurposes + + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeUserID + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypePurposes + + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + + + + diff --git a/Basic-Car-Maintenance/Shared/Settings/ViewModels/AuthenticationViewModel.swift b/Basic-Car-Maintenance/Shared/Settings/ViewModels/AuthenticationViewModel.swift index 6fbb713b..3bae8b5b 100644 --- a/Basic-Car-Maintenance/Shared/Settings/ViewModels/AuthenticationViewModel.swift +++ b/Basic-Car-Maintenance/Shared/Settings/ViewModels/AuthenticationViewModel.swift @@ -2,14 +2,15 @@ // AuthenticationViewModel.swift // Basic-Car-Maintenance // -// Created by Mikaela Caron on 9/14/23. +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. // -import Foundation -import FirebaseAuth -import SwiftUI import AuthenticationServices import CryptoKit +import FirebaseAuth +import Foundation +import SwiftUI enum AuthenticationState { case unauthenticated @@ -22,21 +23,22 @@ enum AuthenticationFlow { case signUp } -@MainActor -final class AuthenticationViewModel: ObservableObject { +@Observable +final class AuthenticationViewModel { - @Published var email = "" - @Published var password = "" - @Published var confirmPassword = "" - @Published var authenticationState: AuthenticationState = .unauthenticated + var email = "" + var password = "" + var confirmPassword = "" + var authenticationState: AuthenticationState = .unauthenticated - @Published var user: User? + var user: User? - @Published var flow: AuthenticationFlow = .signUp + var flow: AuthenticationFlow = .signUp private var authStateHandler: AuthStateDidChangeListenerHandle? private var currentNonce: String? + @MainActor init() { registerAuthStateHandler() verifySignInWithAppleAuthenticationState() @@ -49,6 +51,7 @@ final class AuthenticationViewModel: ObservableObject { } } + @MainActor func signIn() { if Auth.auth().currentUser == nil { print("No user signed in. Trying to sign in anonymously.") @@ -63,6 +66,7 @@ final class AuthenticationViewModel: ObservableObject { print("User is signed in") if let user = Auth.auth().currentUser { self.user = user + AnalyticsService.shared.setUserID(user.uid) } } } @@ -89,6 +93,7 @@ final class AuthenticationViewModel: ObservableObject { } } + @MainActor private func registerAuthStateHandler() { if authStateHandler == nil { authStateHandler = Auth.auth().addStateDidChangeListener { _, user in @@ -118,11 +123,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 } @@ -131,7 +136,7 @@ extension AuthenticationViewModel { appleIDCredential.fullName) Task { do { - let result = try await Auth.auth().signIn(with: credential) + _ = try await Auth.auth().signIn(with: credential) authenticationState = .authenticated } catch { print("Error authenticating: \(error.localizedDescription)") @@ -141,6 +146,7 @@ extension AuthenticationViewModel { } } + @MainActor func verifySignInWithAppleAuthenticationState() { let appleIDProvider = ASAuthorizationAppleIDProvider() let providerData = Auth.auth().currentUser?.providerData @@ -165,47 +171,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/ChooseAppIconViewModel.swift b/Basic-Car-Maintenance/Shared/Settings/ViewModels/ChooseAppIconViewModel.swift new file mode 100644 index 00000000..50019eb4 --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Settings/ViewModels/ChooseAppIconViewModel.swift @@ -0,0 +1,51 @@ +// +// ChooseAppIconViewModel.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import UIKit +import Observation + +/// The ViewModel responsible for allowing users to change the AppIcon +@Observable class ChooseAppIconViewModel { + private(set) var selectedAppIcon: AppIcon + + init() { + if let iconName = UIApplication.shared.alternateIconName, + let appIcon = AppIcon(rawValue: iconName) { + self._selectedAppIcon = appIcon + } else { + self._selectedAppIcon = .primary + } + } + + func updateAppIcon(to icon: AppIcon) { + let previousAppIcon = selectedAppIcon + selectedAppIcon = icon + + Task { @MainActor in + guard UIApplication.shared.alternateIconName != icon.iconName else { + // No need to update icon since we're already using this icon. + return + } + + do { + try await UIApplication.shared.setAlternateIconName(icon.iconName) + } catch { + let feedbackGenerator = UINotificationFeedbackGenerator() + feedbackGenerator.prepare() + + // We're only logging the error here and not actively handling the app icon failure + // since it's very unlikely to fail. + print("Updating icon to \(String(describing: icon.iconName)) failed.") + feedbackGenerator.notificationOccurred(.error) + + // Restore previous app icon + selectedAppIcon = previousAppIcon + } + } + } +} diff --git a/Basic-Car-Maintenance/Shared/Settings/ViewModels/SettingsViewModel.swift b/Basic-Car-Maintenance/Shared/Settings/ViewModels/SettingsViewModel.swift index 4fba9a55..8d49c60d 100644 --- a/Basic-Car-Maintenance/Shared/Settings/ViewModels/SettingsViewModel.swift +++ b/Basic-Car-Maintenance/Shared/Settings/ViewModels/SettingsViewModel.swift @@ -2,43 +2,138 @@ // SettingsViewModel.swift // Basic-Car-Maintenance // -// Created by Mikaela Caron on 9/11/23. +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. // -import Foundation -import FirebaseFirestoreSwift import FirebaseFirestore +import Foundation -@MainActor -final class SettingsViewModel: ObservableObject { - +@Observable +final class SettingsViewModel { let authenticationViewModel: AuthenticationViewModel + + var contributors: [Contributor]? + + var vehicles = [Vehicle]() + var errorMessage: String = "" + var showErrorAlert = false - @Published var vehicles = [Vehicle]() + var sortedContributors: [Contributor] { + guard let contributors = contributors, !contributors.isEmpty else { + return [] + } + + return contributors.sorted { (contributor1, contributor2) in + switch (contributor1.contributions, contributor2.contributions) { + case let (contributor1, contributor2) where contributor1 > contributor2: + return true + case let (contributor1, contributor2) where contributor1 < contributor2: + return false + default: + return contributor1.login < contributor2.login + } + } + } init(authenticationViewModel: AuthenticationViewModel) { self.authenticationViewModel = authenticationViewModel } - func addVehicle(_ vehicle: Vehicle) async { + // 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 { + let url = GitHubURL.apiContributors + let decoder = JSONDecoder() + var page = 1 + var allContributors: [Contributor] = [] + decoder.keyDecodingStrategy = .convertFromSnakeCase + + while true { + guard var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + fatalError("Failed to construct URLComponents for contributors API call.") + } + + urlComponents.queryItems = [ + URLQueryItem(name: "page", value: "\(page)"), + URLQueryItem(name: "per_page", value: "100") + ] + + guard let updatedURL = urlComponents.url else { + fatalError("Failed to construct a valid URL for the contributors API call.") + } + + do { + let (data, _) = try await URLSession.shared.data(from: updatedURL) + print(data) + let contributorsForPage = try decoder.decode([Contributor].self, from: data) + + if contributorsForPage.isEmpty { + break + } + + allContributors.append(contentsOf: contributorsForPage) + self.contributors = allContributors + page += 1 + } catch { + print("Error fetching or decoding contributors: \(error)") + } + } + } + + /// Adds a new vehicle to the Firestore database and the local ``SettingsViewModel/vehicles`` array. + /// + /// - Parameter vehicle: The vehicle to be added. + /// - Throws: An error if there's an issue adding the vehicle to Firestore. + func addVehicle(_ vehicle: Vehicle) async throws { if let uid = authenticationViewModel.user?.uid { var vehicleToAdd = vehicle vehicleToAdd.userID = uid - try? Firestore - .firestore() - .collection("vehicles") - .addDocument(from: vehicleToAdd) - - vehicles.append(vehicleToAdd) + do { + try Firestore + .firestore() + .collection(FirestoreCollection.vehicles) + .addDocument(from: vehicleToAdd) + vehicles.append(vehicleToAdd) + } catch { + throw error + } + + AnalyticsService.shared.logEvent(.vehicleCreate) } } + 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 { let db = Firestore.firestore() - let docRef = db.collection("vehicles").whereField("userID", isEqualTo: uid) + let docRef = db.collection(FirestoreCollection.vehicles) + .whereField(FirestoreField.userID, isEqualTo: uid) let querySnapshot = try? await docRef.getDocuments() @@ -55,15 +150,28 @@ final class SettingsViewModel: ObservableObject { } } } - - func deleteVehicle(_ vehicle: Vehicle) async { - guard let documentId = vehicle.id else { - fatalError("Event \(vehicle.name) has no document ID.") - } - try? await Firestore - .firestore() - .collection("vehicles") - .document(documentId) - .delete() + + /// 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 +// } + + AnalyticsService.shared.logEvent(.vehicleDelete) } } diff --git a/Basic-Car-Maintenance/Shared/Settings/Views/AddVehicleView.swift b/Basic-Car-Maintenance/Shared/Settings/Views/AddVehicleView.swift index 7c3b2fbf..31919af0 100644 --- a/Basic-Car-Maintenance/Shared/Settings/Views/AddVehicleView.swift +++ b/Basic-Car-Maintenance/Shared/Settings/Views/AddVehicleView.swift @@ -2,7 +2,8 @@ // AddVehicleView.swift // Basic-Car-Maintenance // -// Created by Mikaela Caron on 8/25/23. +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. // import SwiftUI @@ -14,8 +15,13 @@ struct AddVehicleView: View { @State private var name = "" @State private var make = "" @State private var model = "" - - @Environment(\.dismiss) var dismiss + @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 { @@ -37,18 +43,55 @@ struct AddVehicleView: View { } header: { Text("Model") } + + Section { + TextField("Vehicle Year", text: $year, prompt: Text("Vehicle Year")) + .keyboardType(.numberPad) + } header: { + Text("Year") + } + + Section { + TextField("Vehicle Color", text: $color, prompt: Text("Vehicle Color")) + } header: { + Text("Color") + } + + Section { + TextField("Vehicle VIN", text: $VIN, prompt: Text("Vehicle VIN")) + .textInputAutocapitalization(.characters) + } header: { + Text("VIN") + } + + Section { + TextField("Vehicle License Plate Number", + text: $licensePlateNumber, + prompt: Text("Vehicle License Plate Number")) + .textInputAutocapitalization(.characters) + } header: { + Text("License Plate Number") + } } + .analyticsView("\(Self.self)") .toolbar { ToolbarItem { Button { - let vehicle = Vehicle(name: name, make: make, model: model) + let vehicle = Vehicle(name: name, + make: make, + model: model, + year: year, + color: color, + vin: VIN, + licensePlateNumber: licensePlateNumber) addTapped(vehicle) - dismiss() } label: { Text("Add") } + .disabled(!isVehicleValid) } } + .navigationTitle(Text("Add Vehicle", comment: "Label to add a vehicle.")) } } } diff --git a/Basic-Car-Maintenance/Shared/Settings/Views/AuthenticationView.swift b/Basic-Car-Maintenance/Shared/Settings/Views/AuthenticationView.swift index b716bfc8..642c0ee0 100644 --- a/Basic-Car-Maintenance/Shared/Settings/Views/AuthenticationView.swift +++ b/Basic-Car-Maintenance/Shared/Settings/Views/AuthenticationView.swift @@ -2,16 +2,17 @@ // AuthenticationView.swift // Basic-Car-Maintenance // -// Created by Mikaela Caron on 9/17/23. +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. // -import SwiftUI import AuthenticationServices +import SwiftUI struct AuthenticationView: View { @Environment(\.colorScheme) var colorScheme - @ObservedObject var viewModel: AuthenticationViewModel + var viewModel: AuthenticationViewModel init(viewModel: AuthenticationViewModel) { self.viewModel = viewModel @@ -42,6 +43,7 @@ struct AuthenticationView: View { .frame(minHeight: 44) } } + .listRowBackground(Color.clear) } else { VStack(alignment: .center, spacing: 8) { Text("Signed in as \(viewModel.user?.email ?? "No Email Found")") @@ -55,6 +57,7 @@ struct AuthenticationView: View { } } } + .analyticsView("\(Self.self)") } } diff --git a/Basic-Car-Maintenance/Shared/Settings/Views/ChooseAppIconView.swift b/Basic-Car-Maintenance/Shared/Settings/Views/ChooseAppIconView.swift new file mode 100644 index 00000000..278df58e --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Settings/Views/ChooseAppIconView.swift @@ -0,0 +1,77 @@ +// +// ChooseAppIconView.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import SwiftUI + +struct ChooseAppIconView: View { + @State private var viewModel: ChooseAppIconViewModel = .init() + + private func isSelected(_ icon: AppIcon) -> Bool { + return viewModel.selectedAppIcon == icon + } + + private let columns = [ + GridItem(.flexible(minimum: 30, maximum: 300)), + GridItem(.flexible(minimum: 30, maximum: 300)) + ] + + var body: some View { + Form { + Section { + HStack { + Image(systemName: SFSymbol.iPhoneWithApps) + .font(.title) + Text("This App Icon will appear on your Home Screen.") + } + } + + LazyVGrid(columns: columns) { + ForEach(AppIcon.allCases) { icon in + IconChoice(icon: icon, isSelected: self.isSelected(icon)) + .onTapGesture { + withAnimation { + viewModel.updateAppIcon(to: icon) + } + } + } + } + } + .navigationTitle("Choose App Icon") + .analyticsView("\(Self.self)") + } +} + +extension ChooseAppIconView { + + private struct IconChoice: View { + let icon: AppIcon + let isSelected: Bool + var checkmarkImage: String { + isSelected ? SFSymbol.checkmarkFill : SFSymbol.circle + } + + var body: some View { + GroupBox { + HStack { + Image(systemName: checkmarkImage) + .foregroundStyle(.blue) + Text(icon.description) + Spacer() + } + Image(icon.previewImage) + .resizable() + .aspectRatio(contentMode: .fit) + .clipShape(.rect(cornerRadius: 20)) + } + } + } +} + +#Preview { + ChooseAppIconView() +} diff --git a/Basic-Car-Maintenance/Shared/Settings/Views/ContributorsListView.swift b/Basic-Car-Maintenance/Shared/Settings/Views/ContributorsListView.swift new file mode 100644 index 00000000..1e15a360 --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Settings/Views/ContributorsListView.swift @@ -0,0 +1,40 @@ +// +// ContributorsListView.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import SwiftUI + +struct ContributorsListView: View { + var viewModel: SettingsViewModel + + var body: some View { + List { + if !viewModel.sortedContributors.isEmpty { + ForEach(viewModel.sortedContributors) { contributor in + Link(destination: URL(string: contributor.htmlURL) ?? GitHubURL.repo) { + ContributorsProfileView(contributor: contributor) + } + .foregroundStyle(.primary) + } + } else { + ProgressView() + } + } + .analyticsView("\(Self.self)") + .task { + await viewModel.getContributors() + } + .navigationTitle("Contributors") + } +} + +#Preview { + let viewModel = SettingsViewModel(authenticationViewModel: AuthenticationViewModel()) + return NavigationStack { + ContributorsListView(viewModel: viewModel) + } +} diff --git a/Basic-Car-Maintenance/Shared/Settings/Views/ContributorsProfileView.swift b/Basic-Car-Maintenance/Shared/Settings/Views/ContributorsProfileView.swift new file mode 100644 index 00000000..b0cf6d96 --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Settings/Views/ContributorsProfileView.swift @@ -0,0 +1,74 @@ +// +// ContributorsProfileView.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import SwiftUI + +struct ContributorsProfileView: View { + + let contributor: Contributor + + @ScaledMetric(relativeTo: .largeTitle) private var imageSize: CGFloat = 50 + + var body: some View { + HStack { + AsyncImage(url: URL(string: contributor.avatarURL)!) { phase in + switch phase { + case .empty: + ProgressView() + .frame(width: imageSize, height: imageSize) + + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: imageSize, height: imageSize) + .clipShape(Circle()) + + case .failure: + Image(systemName: SFSymbol.personCircle) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: imageSize, height: imageSize) + .foregroundColor(.gray) + + @unknown default: + Image(systemName: SFSymbol.personCircle) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: imageSize, height: imageSize) + .foregroundColor(.gray) + } + } + + VStack(alignment: .leading) { + Text(contributor.login) + .bold() + + Text( + "^[\(contributor.contributions) contributions](inflect: true)", + comment: "the number of contributions by a contributor" + ) + .foregroundStyle(.secondary) + } + } + } +} + +#Preview { + ContributorsProfileView( + contributor: Contributor( + login: "", + id: 1, + nodeID: "", + avatarURL: "https://avatars.githubusercontent.com/u/22946902?v=4", + url: "", + htmlURL: "", + contributions: 100 + ) + ) +} 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..4fcb9e45 --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Settings/Views/EditVehicleView.swift @@ -0,0 +1,146 @@ +// +// EditVehicleView.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import SwiftUI + +struct EditVehicleView: View, Observable { + @Binding var selectedVehicle: Vehicle? + var viewModel: SettingsViewModel + + /// closure to update the values passed in, and set them to all the state properties + var onVehicleUpdated: ((Vehicle) -> Void)? + + @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) + if let onVehicleUpdated { + onVehicleUpdated(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 { + + @Previewable @State var selectedVehicle: Vehicle? = Vehicle( + id: UUID().uuidString, + name: "My Car", + make: "Ford", + model: "F-150", + year: "2020", + color: "Red", + vin: "5YJSA1E26JF123456", + licensePlateNumber: "ABC123" + ) + let viewModel = SettingsViewModel(authenticationViewModel: AuthenticationViewModel()) + + EditVehicleView(selectedVehicle: $selectedVehicle, viewModel: viewModel) + +} diff --git a/Basic-Car-Maintenance/Shared/Settings/Views/SettingsView.swift b/Basic-Car-Maintenance/Shared/Settings/Views/SettingsView.swift index d4eb42de..ca16ad07 100644 --- a/Basic-Car-Maintenance/Shared/Settings/Views/SettingsView.swift +++ b/Basic-Car-Maintenance/Shared/Settings/Views/SettingsView.swift @@ -2,87 +2,344 @@ // SettingsView.swift // Basic-Car-Maintenance // -// Created by Mikaela Caron on 8/19/23. +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. // import SwiftUI +import UniformTypeIdentifiers +import TipKit struct SettingsView: View { + @Environment(ActionService.self) var actionService + @Environment(\.scenePhase) var scenePhase + @Environment(\.colorScheme) var colorScheme + @Environment(\.openURL) private var openURL - @StateObject private var viewModel: SettingsViewModel + @ScaledMetric(relativeTo: .largeTitle) var iconDimension = 20.0 + + // swiftlint:disable:next line_length + @AppStorage(AppStorageKeys.measurementSystem) private var defaultUnitSystem: MeasurementSystem = .userDefault + + @State private var viewModel: SettingsViewModel @State private var isShowingAddVehicle = false - @ObservedObject var authenticationViewModel: AuthenticationViewModel + @State private var showDeleteVehicleError = false + @State private var showDeleteVehicleAlert = false + @State private var showAddVehicleError = false + @State private var errorDetails: Error? + @State private var copiedAppVersion: Bool = false + + @State private var selectedVehicle: Vehicle? + @State private var isShowingEditVehicleView = false + + @State private var isShowingVehicleDetailView = false + + private let appVersion = "Version \(Bundle.main.versionNumber) (\(Bundle.main.buildNumber))" init(authenticationViewModel: AuthenticationViewModel) { - self._viewModel = StateObject(wrappedValue: SettingsViewModel(authenticationViewModel: authenticationViewModel)) // swiftlint:disable:this line_length - self.authenticationViewModel = authenticationViewModel + let settingsViewModel = SettingsViewModel(authenticationViewModel: authenticationViewModel) + _viewModel = .init(initialValue: settingsViewModel) } var body: some View { NavigationStack { Form { - Text("Thanks for using this app! It's open source and anyone can contribute to it.") - - Link(destination: URL(string: "https://github.com/mikaelacaron/Basic-Car-Maintenance")!) { - Label { - Text("GitHub Repo") - } icon: { - Image("github-logo") - .resizable() - .frame(width: 20, height: 20) + Section { + // 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: GitHubURL.repo) { + Label { + Text("GitHub Repo", comment: "Link to the Basic Car Maintenance GitHub repo.") + } icon: { + Image("github-logo") + .resizable() + .frame(width: iconDimension, height: iconDimension) + } + } + .popoverTip(ContributionTip(), arrowEdge: .bottom) + + Link(destination: GitHubURL.mikaelaCaronProfile) { + Text("🦄 Mikaela Caron - Maintainer", comment: "Link to maintainer Github account.") + } + + Link(destination: GitHubURL.featureRequest) { + Label { + Text("Request a New Feature", comment: "Link to request a new feature.") + } icon: { + Image(systemName: SFSymbol.document) + .resizable() + .frame(width: iconDimension, height: iconDimension) + } } + + Link(destination: GitHubURL.bugReport) { + Label { + Text("Report a Bug", comment: "Link to report a bug") + } icon: { + Image(systemName: SFSymbol.ladybug) + .resizable() + .frame(width: iconDimension, height: iconDimension) + } + } + + if false { + Button { + requestAppReview() + } label: { + Label { + Text("Rate this app", comment: "Link to rate the app.") + } icon: { + Image(systemName: "star.fill") + .resizable() + .frame(width: iconDimension, height: iconDimension) + .foregroundStyle(.yellow) + } + } + } + + NavigationLink { + ContributorsListView(viewModel: viewModel) + } label: { + HStack { + Image(systemName: SFSymbol.contributors) + Text("Contributors", comment: "Link to contributors list.") + } + } + .foregroundStyle(.blue) } Section { ForEach(viewModel.vehicles) { vehicle in - VStack { - Text(vehicle.name) - .font(.headline) - - Text(vehicle.make) + Button { + selectedVehicle = vehicle + isShowingVehicleDetailView = true + } label: { + VStack(alignment: .leading, spacing: 2) { + Text("\(vehicle.name)") + .fontWeight(.bold) + .font(.headline) + + Group { + HStack { + if let year = vehicle.year, !year.isEmpty { + Text(year) + } + + Text(vehicle.make) + + Text(vehicle.model) + } + + if let licensePlateNumber = + vehicle.licensePlateNumber, + !licensePlateNumber.isEmpty { + Text("Plate: \(licensePlateNumber)") + } + + if let vin = vehicle.vin, !vin.isEmpty { + Text("VIN: \(vin)") + } + + if let color = vehicle.color, !color.isEmpty { + Text("Color: \(color)") + } + } + .font(.callout) + .foregroundStyle(.secondary) + } + } + .buttonStyle(.plain) + .swipeActions { + Button(role: .destructive) { + Task { + do { + if viewModel.vehicles.count > 1 { + try await viewModel.deleteVehicle(vehicle) + } else { + showDeleteVehicleAlert = true + } + } catch { + errorDetails = error + showDeleteVehicleError = true + } + } + } label: { + Text("Delete", comment: "Label to delete a vehicle") + } - Text(vehicle.model) + Button { + selectedVehicle = vehicle + isShowingEditVehicleView = true + } label: { + Label { + Text("Edit") + } icon: { + Image(systemName: SFSymbol.pencil) + } + } } } Button { - isShowingAddVehicle.toggle() + isShowingAddVehicle = true } label: { - Text("Add Vehicle") + Text("Add Vehicle", comment: "Label to add a vehicle.") } } header: { - Text("Vehicles") + Text("Vehicles", comment: "Label to display header title.") + } + + Section { + Picker("Preferred System", selection: $defaultUnitSystem) { + ForEach(MeasurementSystem.allCases) { unit in + Text(unit.title) + .tag(unit) + } + } + .foregroundStyle(.blue) + } header: { + Text("Units", comment: "Label to represent the options for measurement units") } Section { NavigationLink { - AuthenticationView(viewModel: authenticationViewModel) + AuthenticationView(viewModel: viewModel.authenticationViewModel) } label: { Label { - Text("Profile") + Text("Profile", comment: "Link to view profile.") } icon: { - Image(systemName: "person") + Image(systemName: SFSymbol.person) } } + + NavigationLink { + ChooseAppIconView() + } label: { + Label("Change App Icon", systemImage: SFSymbol.iPhoneWithApps) + } } - Text("Version \(Bundle.main.versionNumber) (\(Bundle.main.buildNumber))") - } - .navigationTitle(Text("Settings")) - .task { - await viewModel.getVehicles() + Link("Privacy Policy", destination: GitHubURL.privacy) + + Text(LocalizedStringKey(appVersion), + comment: "Label to display version and build number.") + .frame(maxWidth: .infinity, alignment: .center) + .onLongPressGesture { + let clipboard = UIPasteboard.general + clipboard.setValue(appVersion, forPasteboardType: UTType.plainText.identifier) + copiedAppVersion = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + copiedAppVersion = false + } + } + .overlay { + // A toast view to notify the user of version copy + Text("Copied!", comment: "Text to notify user that app version was copied") + .font(.callout) + .padding(8) + .foregroundStyle(colorScheme == .light ? .white : .black) + .background(colorScheme == .light ? .black : .white) + .clipShape(Capsule()) + .opacity(copiedAppVersion ? 1 : 0) + .animation(.linear(duration: 0.2), value: copiedAppVersion) + } } - .sheet(isPresented: $isShowingAddVehicle) { + .analyticsView("\(Self.self)") + .navigationDestination(isPresented: $isShowingAddVehicle) { AddVehicleView() { vehicle in Task { - await viewModel.addVehicle(vehicle) + do { + try await viewModel.addVehicle(vehicle) + await viewModel.getVehicles() + isShowingAddVehicle = false + } catch { + errorDetails = error + showAddVehicleError = true + } + } + } + .alert("Failed To Add Vehicle", isPresented: $showAddVehicleError) { + Button("OK") { + showAddVehicleError = false + } + } message: { + if let errorDetails { + Text("Failed To Add Vehicle\nDetails:\(errorDetails.localizedDescription)") + } else { + Text("Failed To Add Vehicle. Unknown Error.") } } } + .navigationDestination(isPresented: $isShowingVehicleDetailView) { + VehicleDetailView(selectedVehicle: $selectedVehicle, viewModel: viewModel) + } + .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) { + Button { + showDeleteVehicleError = false + } label: { + Text("OK", comment: "Label to dismiss alert") + } + } message: { + if let errorDetails { + Text("Failed To Delete Vehicle\nDetails:\(errorDetails.localizedDescription)", + comment: "Label to display localized error description.") + } else { + Text("Failed To Delete Vehicle. Unknown Error.", + comment: "Label to display error details.") + } + } + .alert("Can't Delete Last Vehicle", isPresented: $showDeleteVehicleAlert) { + Button("OK", role: .cancel) { + showDeleteVehicleAlert = false + } + } message: { + // swiftlint:disable:next line_length + Text("The last vehicle can't be deleted. Please add a new vehicle before removing this one.", comment: "Alert message preventing users from deleting their last vehicle") + } + .navigationTitle(Text("Settings", comment: "Label to display settings.")) + .task { + await viewModel.getVehicles() + } + } + .onChange(of: scenePhase) { _, newScenePhase in + guard case .active = newScenePhase else { return } + + guard let action = actionService.action, + action == .addVehicle + else { + // another action has been triggered + // so we will need to dismiss the current presented view + isShowingAddVehicle = false + return + } + + // if the view is already presented, do nothing + guard !isShowingAddVehicle else { return } + // delay the presentation of the view a bit + // to make sure the already presented view is dismissed. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + isShowingAddVehicle = true + } + } + } + private func requestAppReview() { + let url = "https://apps.apple.com/app/idYOURAPPSTOREID?action=write-review" + + guard let writeReviewURL = URL(string: url) else { + fatalError("Expected a valid URL") } + + openURL(writeReviewURL) } } #Preview { SettingsView(authenticationViewModel: AuthenticationViewModel()) + .environment(ActionService.shared) } diff --git a/Basic-Car-Maintenance/Shared/Settings/Views/VehicleDetailView.swift b/Basic-Car-Maintenance/Shared/Settings/Views/VehicleDetailView.swift new file mode 100644 index 00000000..81c7b89a --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Settings/Views/VehicleDetailView.swift @@ -0,0 +1,121 @@ +// +// VehicleDetailView.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import SwiftUI + +struct VehicleDetailView: View { + + @Binding var selectedVehicle: Vehicle? + var viewModel: SettingsViewModel + + @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 = "" + + @State private var isShowingEditVehicleView = false + + var body: some View { + NavigationStack { + Form { + Section { + Text(name) + } header: { + Text("Name") + } + + Section { + Text(make) + } header: { + Text("Make") + } + + Section { + Text(model) + } header: { + Text("Model") + } + + Section { + Text(year) + } header: { + Text("Year") + } + + Section { + Text(color) + } header: { + Text("Color") + } + + Section { + Text(VIN) + } header: { + Text("VIN") + } + + Section { + Text(licensePlateNumber) + } header: { + Text("License Plate Number") + } + } + .analyticsView("\(Self.self)") + .onAppear { + guard let selectedVehicle else { return } + setVehicleValues(selectedVehicle) + } + .toolbar { + ToolbarItem { + Button { + isShowingEditVehicleView.toggle() + } label: { + Text("Edit") + } + } + } + .navigationTitle(Text("Vehicle Details", comment: "Label about vehicle details.")) + .sheet(isPresented: $isShowingEditVehicleView) { + EditVehicleView( + selectedVehicle: $selectedVehicle, + viewModel: viewModel, + onVehicleUpdated: setVehicleValues + ) + } + } + } + + private func setVehicleValues(_ 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 { + @Previewable @State var selectedVehicle: Vehicle? = Vehicle( + id: UUID().uuidString, + name: "My Car", + make: "Ford", + model: "F-150", + year: "2020", + color: "Red", + vin: "5YJSA1E26JF123456", + licensePlateNumber: "ABC123" + ) + let viewModel = SettingsViewModel(authenticationViewModel: AuthenticationViewModel()) + + VehicleDetailView(selectedVehicle: $selectedVehicle, viewModel: viewModel) +} diff --git a/Basic-Car-Maintenance/Shared/Tips/ContributionTip.swift b/Basic-Car-Maintenance/Shared/Tips/ContributionTip.swift new file mode 100644 index 00000000..9fd52249 --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Tips/ContributionTip.swift @@ -0,0 +1,24 @@ +// +// ContributionTip.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import TipKit + +struct ContributionTip: Tip { + var title: Text { + Text("Thanks for using this app!", comment: "Thanks a user for using the app.") + } + + var message: Text? { + // swiftlint:disable:next line_length + Text("It's open source and anyone can contribute to it.", comment: "Tells the user they can contribute to the codebase.") + } + + var image: Image? { + Image(systemName: "heart") + } +} diff --git a/Basic-Car-Maintenance/Shared/Utilities/AnalyticsService.swift b/Basic-Car-Maintenance/Shared/Utilities/AnalyticsService.swift new file mode 100644 index 00000000..253d7376 --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Utilities/AnalyticsService.swift @@ -0,0 +1,42 @@ +// +// AnalyticsService.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import Foundation +import FirebaseAnalytics + +class AnalyticsService { + private init() { } + + static let shared = AnalyticsService() + + /// Log an `AnalyticEvent` that happened + /// - Parameter event: The event that happened + /// - Parameter parameters: Extra parameters for more information about this event, if needed + func logEvent(_ event: AnalyticEvent, with parameters: [String: String] = [:]) { + Analytics.logEvent(event.rawValue, parameters: parameters) + } + + func setUserID(_ id: String) { + Analytics.setUserID(id) + } +} + +enum AnalyticEvent: String { + + case maintenanceCreate = "maintenance_create" + case maintenanceUpdate = "maintenance_update" + case maintenanceDelete = "maintenance_delete" + + case odometerCreate = "odometer_create" + case odometerUpdate = "odometer_update" + case odometerDelete = "odometer_delete" + + case vehicleCreate = "vehicle_create" + case vehicleUpdate = "vehicle_update" + case vehicleDelete = "vehicle_delete" +} diff --git a/Basic-Car-Maintenance/Shared/Utilities/Bundle+extension.swift b/Basic-Car-Maintenance/Shared/Utilities/Bundle+extension.swift index 5aa84f36..9481b14d 100644 --- a/Basic-Car-Maintenance/Shared/Utilities/Bundle+extension.swift +++ b/Basic-Car-Maintenance/Shared/Utilities/Bundle+extension.swift @@ -2,7 +2,8 @@ // Bundle+extension.swift // Basic-Car-Maintenance // -// Created by Mikaela Caron on 8/19/23. +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. // import Foundation @@ -15,4 +16,8 @@ extension Bundle { var buildNumber: String { return infoDictionary?["CFBundleVersion"] as? String ?? "1" } + + var keychainAccessGroup: String { + return infoDictionary?["KeychainAccessGroup"] as? String ?? "\(bundleIdentifier!).Shared" + } } diff --git a/Basic-Car-Maintenance/Shared/Utilities/Constants.swift b/Basic-Car-Maintenance/Shared/Utilities/Constants.swift new file mode 100644 index 00000000..73353b51 --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Utilities/Constants.swift @@ -0,0 +1,100 @@ +// +// Constants.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +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" + static let odometerReadings = "odometer_readings" + static let alerts = "alerts" +} + +enum FirestoreField { + static let userID = "userID" + static let isOn = "isOn" + 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" + static let gauge = "gauge" + static let gear = "gear" + + // Navigation Items + static let filter = "line.3.horizontal.decrease" + static let plus = "plus" + static let share = "square.and.arrow.up" + static let carFill = "car.fill" + + // Dashboard + static let trash = "trash" + static let pencil = "pencil" + static let magnifyingGlass = "magnifyingglass" + static let gaugeWithNeedle = "gauge.with.needle" + static let calendar = "calendar" + + // Settings + static let document = "doc.badge.plus" + static let ladybug = "ladybug" + static let contributors = "person.3.fill" + static let person = "person" + static let iPhoneWithApps = "apps.iphone" + + // ChoseAopIcon View + static let checkmarkFill = "checkmark.circle.fill" + static let circle = "circle" + + // ContributorsProfileView + static let personCircle = "person.circle.fill" + + // SmallBasicCarMaintenanceWidget + static let wrenchAndScrewdriver = "wrench.and.screwdriver" + +} + +enum AppStorageKeys { + static let measurementSystem = "defaultMeasurementSystem" +} + +// swiftlint:enable line_length diff --git a/Basic-Car-Maintenance/Shared/Utilities/FirebaseAnalytics+Extension.swift b/Basic-Car-Maintenance/Shared/Utilities/FirebaseAnalytics+Extension.swift new file mode 100644 index 00000000..f80406d1 --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Utilities/FirebaseAnalytics+Extension.swift @@ -0,0 +1,26 @@ +// +// FirebaseAnalytics+Extension.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import SwiftUI +import FirebaseAnalytics + +struct FirebaseAnalyticsModifier: ViewModifier { + + let screenName: String + + func body(content: Content) -> some View { + content + .analyticsScreen(name: screenName) + } +} + +extension View { + func analyticsView(_ name: String) -> some View { + modifier(FirebaseAnalyticsModifier(screenName: name)) + } +} diff --git a/Basic-Car-Maintenance/Shared/Utilities/FirebaseService.swift b/Basic-Car-Maintenance/Shared/Utilities/FirebaseService.swift new file mode 100644 index 00000000..8241a80d --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Utilities/FirebaseService.swift @@ -0,0 +1,82 @@ +// +// FirebaseService.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import Firebase +import FirebaseFirestore +import Foundation + +class FirebaseService: FirebaseServiceProtocol { + + let db = Firestore.firestore() + + func addReading(_ reading: OdometerReading) throws { + try db + .collection(FirestorePath.odometerReadings(vehicleID: reading.vehicleID).path) + .addDocument(from: reading) + } + + func deleteReading(_ reading: OdometerReading) async { + guard let documentId = reading.id else { + fatalError("Reading Entry has no document ID.") + } + try? await db + .collection(FirestorePath.odometerReadings(vehicleID: reading.vehicleID).path) + .document(documentId) + .delete() + } + + func getReadings(userUID: String) async -> [OdometerReading] { + let docRef = db.collectionGroup(FirestoreCollection.odometerReadings) + .whereField(FirestoreField.userID, isEqualTo: userUID) + + let querySnapshot = try? await docRef.getDocuments() + + var readings = [OdometerReading]() + + if let querySnapshot { + for document in querySnapshot.documents { + if let reading = try? document.data(as: OdometerReading.self) { + readings.append(reading) + } + } + } + + return readings + } + + func updateReading(_ reading: OdometerReading) throws { + if let documentId = reading.id, let userUID = reading.userID { + var readingToUpdate = reading + readingToUpdate.userID = userUID + + try db + .collection(FirestorePath.odometerReadings(vehicleID: readingToUpdate.vehicleID).path) + .document(documentId) + .setData(from: readingToUpdate) + } + } + + func getVehicles(userUID: String) async -> [Vehicle] { + let docRef = db.collection(FirestoreCollection.vehicles) + .whereField(FirestoreField.userID, isEqualTo: userUID) + + let querySnapshot = try? await docRef.getDocuments() + + var vehicles = [Vehicle]() + + if let querySnapshot { + for document in querySnapshot.documents { + if let vehicle = try? document.data(as: Vehicle.self) { + vehicles.append(vehicle) + } + } + } + + return vehicles + } +} diff --git a/Basic-Car-Maintenance/Shared/Utilities/FirebaseServiceProtocol.swift b/Basic-Car-Maintenance/Shared/Utilities/FirebaseServiceProtocol.swift new file mode 100644 index 00000000..bc054e27 --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Utilities/FirebaseServiceProtocol.swift @@ -0,0 +1,17 @@ +// +// FirebaseServiceProtocol.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import Foundation + +protocol FirebaseServiceProtocol { + func addReading(_ reading: OdometerReading) throws + func deleteReading(_ reading: OdometerReading) async + func updateReading(_ reading: OdometerReading) throws + func getReadings(userUID: String) async -> [OdometerReading] + func getVehicles(userUID: String) async -> [Vehicle] +} diff --git a/Basic-Car-Maintenance/Shared/Utilities/MeasurementSystem.swift b/Basic-Car-Maintenance/Shared/Utilities/MeasurementSystem.swift new file mode 100644 index 00000000..d7f35f4c --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Utilities/MeasurementSystem.swift @@ -0,0 +1,42 @@ +// +// MeasurementSystem.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import SwiftUI + +enum MeasurementSystem: String, Identifiable, CaseIterable { + case imperial + case metric + + var id: String { + return rawValue + } + + var title: LocalizedStringResource { + switch self { + case .imperial: + return LocalizedStringResource( + "Imperial", + defaultValue: "Imperial", + comment: "Imperial unit system" + ) + case .metric: + return LocalizedStringResource("Metric", defaultValue: "Metric", comment: "Metric unit system") + } + } + + static var userDefault: MeasurementSystem { + switch Locale.current.measurementSystem { + case .uk: + return .metric + case .us: + return .imperial + default: + return .metric + } + } +} diff --git a/Basic-Car-Maintenance/Shared/Utilities/PageDimension.swift b/Basic-Car-Maintenance/Shared/Utilities/PageDimension.swift new file mode 100644 index 00000000..a831b647 --- /dev/null +++ b/Basic-Car-Maintenance/Shared/Utilities/PageDimension.swift @@ -0,0 +1,32 @@ +// +// PageDimension.swift +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +import Foundation + +enum PageDimension { + case A4 // swiftlint:disable:this identifier_name + + var pageWidth: CGFloat { + switch self { + case .A4: + return 595.2 + } + } + + var pageHeight: CGFloat { + switch self { + case .A4: + return 841.8 + } + } + + var size: CGSize { + CGSize(width: pageWidth, height: pageHeight) + } + +} diff --git a/Basic-Car-MaintenanceTests/BasicCarMaintenanceTests.swift b/Basic-Car-MaintenanceTests/BasicCarMaintenanceTests.swift deleted file mode 100644 index 52de5771..00000000 --- a/Basic-Car-MaintenanceTests/BasicCarMaintenanceTests.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// Basic_Car_MaintenanceTests.swift -// Basic-Car-MaintenanceTests -// -// Created by Mikaela Caron on 8/11/23. -// - -import XCTest - -final class BasicCarMaintenanceTests: XCTestCase { - - override func setUpWithError() throws { - } - - override func tearDownWithError() throws { - } - - func testExample() throws { - } -} diff --git a/Basic-Car-MaintenanceUITests/BasicCarMaintenanceUITests.swift b/Basic-Car-MaintenanceUITests/BasicCarMaintenanceUITests.swift deleted file mode 100644 index aaca9fb5..00000000 --- a/Basic-Car-MaintenanceUITests/BasicCarMaintenanceUITests.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// Basic_Car_MaintenanceUITests.swift -// Basic-Car-MaintenanceUITests -// -// Created by Mikaela Caron on 8/11/23. -// - -import XCTest - -final class BasicCarMaintenanceUITests: XCTestCase { - - override func setUpWithError() throws { - continueAfterFailure = false - } - - override func tearDownWithError() throws { - } - - func testExample() throws { - // UI tests must launch the application that they test. - let app = XCUIApplication() - app.launch() - - // Use XCTAssert and related functions to verify your tests produce the correct results. - } -} diff --git a/Basic-Car-MaintenanceUITests/BasicCarMaintenanceUITestsLaunchTests.swift b/Basic-Car-MaintenanceUITests/BasicCarMaintenanceUITestsLaunchTests.swift deleted file mode 100644 index ece255c3..00000000 --- a/Basic-Car-MaintenanceUITests/BasicCarMaintenanceUITestsLaunchTests.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// Basic_Car_MaintenanceUITestsLaunchTests.swift -// Basic-Car-MaintenanceUITests -// -// Created by Mikaela Caron on 8/11/23. -// - -import XCTest - -final class BasicCarMaintenanceUITestsLaunchTests: XCTestCase { - - override class var runsForEachTargetApplicationUIConfiguration: Bool { - true - } - - override func setUpWithError() throws { - continueAfterFailure = false - } - - func testLaunch() throws { - let app = XCUIApplication() - app.launch() - - // Insert steps here to perform after app launch but before taking a screenshot, - // such as logging into a test account or navigating somewhere in the app - - let attachment = XCTAttachment(screenshot: app.screenshot()) - attachment.name = "Launch Screen" - attachment.lifetime = .keepAlways - add(attachment) - } -} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 051a96db..3480815c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,43 +1,80 @@ # Contribution Guidelines This document contains the rules and guidelines that developers are expected to follow, while contributing to this repository. -* All contributions must NOT add any SwiftLint warnings or errors. There is a GitHub action setup for any PRs to dev, and Xcode will show any warnings/errors. +* All contributions must NOT add any SwiftLint warnings or errors. There is a GitHub action setup for any PRs to `dev`, and Xcode will show any warnings/errors. # About the Project -This app was created for [Hacktoberfest](https://hacktoberfest.com/), to help beginners in iOS dev contribute to open source. It is an app to help keep track of your car maintenance activites. This project uses [Firebase](https://firebase.google.com) and [Firestore](https://firebase.google.com/products/firestore). +This app was created for [Hacktoberfest](https://hacktoberfest.com/), to help beginners in iOS dev contribute to open source. It is an app to help keep track of your car maintenance activites. This project uses [Firebase](https://firebase.google.com), [Firestore](https://firebase.google.com/products/firestore), and Sign in With Apple. ### Project Status -This is a deployed app on the Apple App Store, available for iOS 17.0 or later. After Hacktoberfest, a new version will be created and pushed to the App Store by Mikaela Caron (the maintainer). +This app will be deployed on the Apple App Store, available for iOS 17.0 or later, [Mikaela Caron](https://github.com/mikaelacaron) (the maintainer) will upload it to the App Store after Hacktoberfest. # Getting Started ## Prerequisites -* Download Xcode 15.0 or later, and macOS 14.0 or later. -* Install [SwiftLint](https://github.com/realm/SwiftLint) onto your machine via [Homebrew](https://brew.sh/) - * This is not a requirement, but is preferred. -```sh -brew install swiftlint -``` +* Download Xcode 16.0 or later +* Set your Xcode settings correctly: + * Open Xcode Settings `Cmd + ,` + * Text Editing + * Indentation tab + * Prefer Indent using Spaces + * Tab Width: 4 + * Indent Width: 4 + * Xcode 16+ + * Check "Prefer Settings from EditorConfig" ## Start Here -* Fork the repo to your profile -* Clone to your computer -* Setup the upstream remote +* **Fork** this repo +* **Clone** the repo to your machine (do **not** open Xcode yet) +* In the same folder that contains the `Basic-Car-Maintenance.xcconfig.template`, run this command, in Terminal, to create a new Xcode configuration file (which properly sets up the signing information) ```sh -git remote add upstream https://github.com/mikaelacaron/Basic-Car-Maintenance.git +cp Basic-Car-Maintenance.xcconfig.template Basic-Car-Maintenance.xcconfig +``` + +* Open Xcode + +* In the `Basic-Car-Maintenance.xcconfig` file, fill in your `DEVELOPMENT_TEAM` and `PRODUCT_BUNDLE_IDENTIFIER`. + * You can find this by logging into the Apple Developer Portal + * This works with both free or paid Apple Developer accounts. Do **NOT** run this app on a real device due to issues with the Sign in With Apple capability. +``` +DEVELOPMENT_TEAM = ABC123 +PRODUCT_BUNDLE_IDENTIFIER = com.mycompany.Basic-Car-Maintenance ``` -* **BEFORE** starting on an issue, comment on the issue you want to work on. - * This prevents two people from working on the same issue. Mikaela (the maintainer) will assign you that issue, and you can get started on it. +* Build the project ✅ -* Checkout a new branch (from the `dev` branch) to work on an issue - * The `feature-name` part of the branch can be shortened or omitted and you add your username instead +## Setup the Firebase Emulator +We are going to set up the Firebase emulator to be able to load the data locally and not affect production. Please do not skip this step. +* Install [Homebrew](https://brew.sh/) + * Package manager for macOS, to install more things +* Install Xcode command line tools with `xcode-select --install` +* `brew install nvm` + * Installs node version manager, so you don't need to update the system node version + * Add the executable to the $PATH via .zshrc or .bashrc file as prompted after installation, do **NOT** forget this! (and then restart your Terminal) +* `nvm install stable` +* `nvm use stable` + * to download and use the latest stable version of node +* `brew install openjdk` + * Add the executable to the $PATH via .zshrc or .bashrc file as prompted after installation, do **NOT** forget this! (and then restart your Terminal) +* `npm install -g firebase-tools` + * Installs the Firebase tools for running the emulator + +## Start Working on an Issue +* Anytime you run the project, first in Terminal `cd` to `backend` in the `Basic-Car-Maintenance` directory + * this is the directory with the `firebase.json` file, you should see that if you type `ls` +* Run this command, which will start the emulators, and keep your data in `local-data` directory. ```sh -git checkout -b issueNumber-feature-name +firebase emulators:start --import=./local-data --export-on-exit ``` -* When your feature/fix is complete open a pull request, PR, from your feature branch to the `dev` branch - * Use a descriptive PR title + * Meaning when you start and stop the emulator your data will persist. +* Run the app + * You should see your anonymous user in Authentication, and once you add new data, see it in Firestore emulator UI at: http://127.0.0.1:4000/firestore + * If you don't see your user, delete the app from the simulator, and in the menu go to Device > Erase All Content and Settings (which resets your simulator), and try to run again + * If you receive the following error when you launch the emulator: _'firebase-tools no longer supports Java version before 11. Please upgrade to Java version 11 or above to continue using the emulators.'_ The openJDK install failed and you will have to install the latest JDK manually. You can download the latest version here [JDK23](https://www.oracle.com/java/technologies/downloads/#jdk23-mac) +* **Checkout** a new branch (from the `dev` branch) to work on an issue +* When your feature / fix is complete open a pull request, PR, from your feature branch to the `dev` branch + * Use a descriptive PR title and fill out the entire PR template, do not delete any sections. # Branches and PRs * No commits should be made to the `main` branch directly. The `main` branch shall only consist of the deployed code @@ -46,7 +83,7 @@ git checkout -b issueNumber-feature-name ✅ **Examples of valid branch names:** * 8123-fix-title-of-issue (issue number) * 8123-feature-name (issue number) - + ❌ **Examples of invalid branch names**: * username-testing * attemptToFixAuth diff --git a/Configurations/Project.xcconfig b/Configurations/Project.xcconfig new file mode 100644 index 00000000..4d231590 --- /dev/null +++ b/Configurations/Project.xcconfig @@ -0,0 +1,17 @@ +// +// Project.xcconfig +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +// Configuration settings file format documentation can be found at: +// https://help.apple.com/xcode/#/dev745c5c974 + +#include "Basic-Car-Maintenance.xcconfig" + +CURRENT_PROJECT_VERSION = 1 +MARKETING_VERSION = 1.0 + +KEYCHAIN_SHARING_GROUP_IDENTIFIER = $(PRODUCT_BUNDLE_IDENTIFIER).Shared diff --git a/Configurations/UITests.xcconfig b/Configurations/UITests.xcconfig new file mode 100644 index 00000000..f94413dd --- /dev/null +++ b/Configurations/UITests.xcconfig @@ -0,0 +1,12 @@ +// +// UITests.xcconfig +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +// Configuration settings file format documentation can be found at: +// https://help.apple.com/xcode/#/dev745c5c974 + +PRODUCT_BUNDLE_IDENTIFIER = $(PRODUCT_BUNDLE_IDENTIFIER).UITests diff --git a/Configurations/UnitTests.xcconfig b/Configurations/UnitTests.xcconfig new file mode 100644 index 00000000..a0eadff0 --- /dev/null +++ b/Configurations/UnitTests.xcconfig @@ -0,0 +1,12 @@ +// +// UnitTests.xcconfig +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +// Configuration settings file format documentation can be found at: +// https://help.apple.com/xcode/#/dev745c5c974 + +PRODUCT_BUNDLE_IDENTIFIER = $(PRODUCT_BUNDLE_IDENTIFIER).Tests diff --git a/Configurations/Widget.xcconfig b/Configurations/Widget.xcconfig new file mode 100644 index 00000000..ffd4b8d6 --- /dev/null +++ b/Configurations/Widget.xcconfig @@ -0,0 +1,12 @@ +// +// Widget.xcconfig +// Basic-Car-Maintenance +// +// https://github.com/mikaelacaron/Basic-Car-Maintenance +// See LICENSE for license information. +// + +// Configuration settings file format documentation can be found at: +// https://help.apple.com/xcode/#/dev745c5c974 + +PRODUCT_BUNDLE_IDENTIFIER = $(PRODUCT_BUNDLE_IDENTIFIER).Widget diff --git a/Gemfile b/Gemfile new file mode 100644 index 00000000..57d9ed7a --- /dev/null +++ b/Gemfile @@ -0,0 +1,4 @@ +source "https://rubygems.org" + +gem "fastlane" +gem "xcov" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 00000000..7579a261 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,231 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.7) + base64 + nkf + rexml + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + artifactory (3.0.17) + atomos (0.1.3) + aws-eventstream (1.3.0) + aws-partitions (1.1001.0) + aws-sdk-core (3.211.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + jmespath (~> 1, >= 1.6.1) + aws-sdk-kms (1.95.0) + aws-sdk-core (~> 3, >= 3.210.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.169.0) + aws-sdk-core (~> 3, >= 3.210.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.10.1) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + base64 (0.2.0) + claide (1.1.0) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + declarative (0.0.20) + digest-crc (0.6.5) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.6.20240107) + dotenv (2.8.1) + emoji_regex (3.2.3) + excon (0.112.0) + faraday (1.10.4) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.7) + faraday (>= 0.8.0) + http-cookie (~> 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.0.4) + multipart-post (~> 2) + faraday-net_http (1.0.2) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) + faraday_middleware (1.2.1) + faraday (~> 1.0) + fastimage (2.3.1) + fastlane (2.225.0) + CFPropertyList (>= 2.3, < 4.0.0) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.0) + babosa (>= 1.0.3, < 2.0.0) + bundler (>= 1.12.0, < 3.0.0) + colored (~> 1.2) + commander (~> 4.6) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.0.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, < 2.0.0) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + http-cookie (~> 1.0.5) + json (< 3.0.0) + jwt (>= 2.1.0, < 3) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (>= 2.0.0, < 3.0.0) + naturally (~> 2.2) + optparse (>= 0.1.1, < 1.0.0) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.5) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (~> 3) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.3.0) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-sirp (1.0.0) + sysrandom (~> 1.0) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.54.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-core (0.11.3) + addressable (~> 2.5, >= 2.5.1) + googleauth (>= 0.16.2, < 2.a) + httpclient (>= 2.8.1, < 3.a) + mini_mime (~> 1.0) + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + rexml + google-apis-iamcredentials_v1 (0.17.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-playcustomapp_v1 (0.13.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-storage_v1 (0.31.0) + google-apis-core (>= 0.11.0, < 2.a) + google-cloud-core (1.7.1) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (1.6.0) + faraday (>= 0.17.3, < 3.0) + google-cloud-errors (1.4.0) + google-cloud-storage (1.47.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-iamcredentials_v1 (~> 0.1) + google-apis-storage_v1 (~> 0.31.0) + google-cloud-core (~> 1.6) + googleauth (>= 0.16.2, < 2.a) + mini_mime (~> 1.0) + googleauth (1.8.1) + faraday (>= 0.17.3, < 3.a) + jwt (>= 1.4, < 3.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.7) + domain_name (~> 0.5) + httpclient (2.8.3) + jmespath (1.6.2) + json (2.7.6) + jwt (2.9.3) + base64 + mini_magick (4.13.2) + mini_mime (1.1.5) + multi_json (1.15.0) + multipart-post (2.4.1) + nanaimo (0.4.0) + naturally (2.2.1) + nkf (0.2.0) + optparse (0.5.0) + os (1.1.4) + plist (3.7.1) + public_suffix (6.0.1) + rake (13.2.1) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.1.2) + rexml (3.3.9) + rouge (2.0.7) + ruby2_keywords (0.0.5) + rubyzip (2.3.2) + security (0.1.5) + signet (0.19.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 3.0) + multi_json (~> 1.10) + simctl (1.6.10) + CFPropertyList + naturally + slack-notifier (2.4.0) + sysrandom (1.0.5) + terminal-notifier (2.0.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.2) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + uber (0.1.0) + unicode-display_width (2.6.0) + word_wrap (1.0.0) + xcodeproj (1.27.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.4.0) + rexml (>= 3.3.6, < 4.0) + xcov (1.8.1) + fastlane (>= 2.141.0, < 3.0.0) + multipart-post + slack-notifier + terminal-table + xcodeproj + xcresult (~> 0.2.0) + xcpretty (0.3.0) + rouge (~> 2.0.7) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + xcresult (0.2.1) + +PLATFORMS + arm64-darwin-22 + +DEPENDENCIES + fastlane + xcov + +BUNDLED WITH + 2.4.20 diff --git a/README.md b/README.md index 36994ea6..22d5a004 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,87 @@ # Basic Car Maintenance -Welcome to my open source app! At this moment it's a work in progress, but it will be ready for contributors for [Hacktoberfest](https://hacktoberfest.com/)! Use this app to gain experience getting started in open source for iOS and macOS development using Swift! +![Static Badge](https://img.shields.io/badge/status-active-brightgreen) +![GitHub last commit (branch)](https://img.shields.io/github/last-commit/mikaelacaron/Basic-Car-Maintenance/dev?logo=github) +![GitHub contributors](https://img.shields.io/github/contributors/mikaelacaron/Basic-Car-Maintenance) +[![first-timers-only](https://img.shields.io/badge/first--timers--only-friendly-blue.svg)](https://www.firsttimersonly.com/) +[![Unit Tests](https://github.com/mikaelacaron/Basic-Car-Maintenance/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/mikaelacaron/Basic-Car-Maintenance/actions/workflows/unit-tests.yml) +[![Deploy DocC Documentation](https://github.com/mikaelacaron/Basic-Car-Maintenance/actions/workflows/docc.yml/badge.svg?branch=dev)](https://github.com/mikaelacaron/Basic-Car-Maintenance/actions/workflows/docc.yml) +![Alt](https://repobeats.axiom.co/api/embed/889ac81d440c882bb217c6ee7dda4601e0d8d6d9.svg "Repobeats analytics image") + +Welcome to Basic Car Maintenance, an open-source app designed to help you get started with iOS development using Swift and SwiftUI. +This project is beginner-friendly and open to contributions, especially during [Hacktoberfest](https://hacktoberfest.com/)! + +## App Features and Tech Stack +
+Features and Tech Stack + +* Maintenance Tracking - Log & monitor oil changes, tire rotations, brake checks, etc. +* Multiple Vehicle Support - Manage maintenance records for all your vehicles in one place. +* Dark Mode Support - Sleek dark theme for better nighttime usability. + +### Tech Stack + +* Swift and SwiftUI - the programming language and UI framework +* Firebase and Firestore - backend for user authentication and data storage +* GitHub Actions - CI/CD for automated unit testing and deploying the DocC documentation +* Fastlane - running the unit tests + +
+ # Getting Started -* Read the [Code of Conduct](https://github.com/mikaelacaron/Basic-Car-Maintenance/blob/main/CODE_OF_CONDUCT.md) -* Read the [CONTRIBUTING.md](https://github.com/mikaelacaron/Basic-Car-Maintenance/blob/main/CONTRIBUTING.md) guidelines -* Download Xcode 15 or later, and macOS 14.0 or later +Before contributing, follow these steps: + +* Read the [Code of Conduct](https://github.com/mikaelacaron/Basic-Car-Maintenance/blob/dev/CODE_OF_CONDUCT.md) +* Read the [CONTRIBUTING.md](https://github.com/mikaelacaron/Basic-Car-Maintenance/blob/dev/CONTRIBUTING.md) guidelines +* Download Xcode 16.0 or later * Browse the open [issues](https://github.com/mikaelacaron/Basic-Car-Maintenance/issues) and **comment** which you would like to work on * It is only one person per issue, except where noted. -* Fork this repo -* Clone the repo to your machine -* In the same folder that contains the `Basic-Car-Maintenance.xcconfig.template`, run this command to create a new Xcode configuration file (which properly sets up the signing information) +* **Fork** this repo +* **Clone** the repo to your machine (do **not** open Xcode yet) +* In the same folder that contains the `Basic-Car-Maintenance.xcconfig.template`, run this command, in Terminal, to create a new Xcode configuration file (which properly sets up the signing information) ```sh cp Basic-Car-Maintenance.xcconfig.template Basic-Car-Maintenance.xcconfig ``` -* Fill in your `DEVELOPMENT_TEAM`, no spaces. - * Example: `DEVELOPMENT_TEAM = IdNumber` +* Open Xcode + +* In the `Basic-Car-Maintenance.xcconfig` file, fill in your `DEVELOPMENT_TEAM` and `PRODUCT_BUNDLE_IDENTIFIER`. * You can find this by logging into the Apple Developer Portal -* Build the project + * This works with both free or paid Apple Developer accounts. Do **NOT** run this app on a real device due to issues with the Sign in With Apple capability. +``` +DEVELOPMENT_TEAM = ABC123 +PRODUCT_BUNDLE_IDENTIFIER = com.mycompany.Basic-Car-Maintenance +``` + +* Build the project ✅ + +* Setup the Firebase emulator, following the steps in [CONTRIBUTING.md](https://github.com/mikaelacaron/Basic-Car-Maintenance/blob/dev/CONTRIBUTING.md#setup-the-firebase-Emulator) + +> [!WARNING] +> DO **NOT** skip setting up the emulators! or your app won't work -* Checkout a new branch (from the `dev` branch) to work on an issue +* Start the emulator with: `firebase emulators:start --import=./local-data --export-on-exit` + * Be sure to be in the `backend` folder that contains the `firebase.json` file. + +* **Checkout** a new branch, from the `dev` branch, to work on an issue # Contributing -To start contributing, review [CONTRIBUTING.md](https://github.com/mikaelacaron/Basic-Car-Maintenance/blob/main/CONTRIBUTING.md). New contributors are always welcome to support this project. Issues labeled `good-first-issue` are great for beginners. +To start contributing, review [CONTRIBUTING.md](https://github.com/mikaelacaron/Basic-Car-Maintenance/blob/main/CONTRIBUTING.md). New contributors are always welcome to support this project. :eyes: **Please be sure to comment on an issue you'd like to work on and [Mikaela Caron](https://github.com/mikaelacaron), the maintainer of this project, will assign it to you!** You can only work on **ONE** issue at a time. Checkout any issue labeled `hacktoberfest` to start contributing. +> [!IMPORTANT] +> View the [GitHub Discussions](https://github.com/mikaelacaron/Basic-Car-Maintenance/discussions) for the latest information about the repo. + +## Issue Labels +* `first-timers-only` is only for someone who has **not** contributed to the repo yet! (and is new to open source and iOS development) +* `good-first-issue` is an issue that's beginner level + +Please choose an appropriate issue for your skill level + ### Contributors @@ -41,7 +94,7 @@ Made with [contrib.rocks](https://contrib.rocks). - + Star History Chart diff --git a/backend/.firebaserc b/backend/.firebaserc new file mode 100644 index 00000000..c360e913 --- /dev/null +++ b/backend/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "basic-car-maintenance" + } +} diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 00000000..6b5557eb --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,72 @@ +# Ignore the local data using the emulator +local-data + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +firebase-debug.log* +firebase-debug.*.log* + +# Firebase cache +.firebase/ + +# Firebase config + +# Uncomment this if you'd like others to create their own Firebase project. +# For a team working on the same Firebase project(s), it is recommended to leave +# it commented so all members can deploy to the same project(s) in .firebaserc. +# .firebaserc + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# dataconnect generated files +.dataconnect diff --git a/backend/firebase.json b/backend/firebase.json new file mode 100644 index 00000000..494cec0a --- /dev/null +++ b/backend/firebase.json @@ -0,0 +1,18 @@ +{ + "firestore": { + "rules": "firestore.rules", + "indexes": "firestore.indexes.json" + }, + "emulators": { + "auth": { + "port": 9099 + }, + "firestore": { + "port": 8080 + }, + "ui": { + "enabled": true + }, + "singleProjectMode": true + } +} diff --git a/backend/firestore.indexes.json b/backend/firestore.indexes.json new file mode 100644 index 00000000..6f59674f --- /dev/null +++ b/backend/firestore.indexes.json @@ -0,0 +1,74 @@ +{ + "indexes": [ + { + "collectionGroup": "alerts", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "isOn", + "order": "ASCENDING" + }, + { + "fieldPath": "_id", + "order": "ASCENDING" + } + ] + } + ], + "fieldOverrides": [ + { + "collectionGroup": "maintenance_events", + "fieldPath": "userID", + "ttl": false, + "indexes": [ + { + "order": "ASCENDING", + "queryScope": "COLLECTION" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION" + }, + { + "arrayConfig": "CONTAINS", + "queryScope": "COLLECTION" + }, + { + "order": "ASCENDING", + "queryScope": "COLLECTION_GROUP" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION_GROUP" + } + ] + }, + { + "collectionGroup": "odometer_readings", + "fieldPath": "userID", + "ttl": false, + "indexes": [ + { + "order": "ASCENDING", + "queryScope": "COLLECTION" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION" + }, + { + "arrayConfig": "CONTAINS", + "queryScope": "COLLECTION" + }, + { + "order": "ASCENDING", + "queryScope": "COLLECTION_GROUP" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION_GROUP" + } + ] + } + ] +} diff --git a/backend/firestore.rules b/backend/firestore.rules new file mode 100644 index 00000000..1b8a8e58 --- /dev/null +++ b/backend/firestore.rules @@ -0,0 +1,24 @@ +rules_version = '2'; + +service cloud.firestore { + match /databases/{database}/documents { + + match /alerts/{document=**} { + allow read; + } + + match /vehicles/{vehicleId}/{document=**} { + // Allow users to create vehicles if authenticated + allow read, write: if true; + } + + // collection grouop query + match /{path=**}/maintenance_events/{events} { + allow read: if true; + } + + match /{path=**}/odometer_readings/{readings} { + allow read: if true; + } + } +} \ No newline at end of file diff --git a/build-docc.sh b/build-docc.sh new file mode 100755 index 00000000..034574d4 --- /dev/null +++ b/build-docc.sh @@ -0,0 +1,16 @@ +##!/bin/sh + +xcrun xcodebuild docbuild \ + -scheme Basic-Car-Maintenance \ + -destination 'generic/platform=iOS Simulator' \ + -skipPackagePluginValidation \ + -derivedDataPath "$PWD/.derivedData" + +xcrun docc process-archive transform-for-static-hosting \ + "$PWD/.derivedData/Build/Products/Debug-iphonesimulator/Basic-Car-Maintenance.doccarchive" \ + --output-path ".docs" \ + --hosting-base-path "Basic-Car-Maintenance" + +echo '' > .docs/index.html + + diff --git a/fastlane/.xcovignore b/fastlane/.xcovignore new file mode 100644 index 00000000..c0457479 --- /dev/null +++ b/fastlane/.xcovignore @@ -0,0 +1,3 @@ +# Exclude following files +# All files ending by "View.swift" +- .*View.swift \ No newline at end of file diff --git a/fastlane/Appfile b/fastlane/Appfile new file mode 100644 index 00000000..4282947e --- /dev/null +++ b/fastlane/Appfile @@ -0,0 +1,6 @@ +# app_identifier("[[APP_IDENTIFIER]]") # The bundle identifier of your app +# apple_id("[[APPLE_ID]]") # Your Apple Developer Portal username + + +# For more information about the Appfile, see: +# https://docs.fastlane.tools/advanced/#appfile diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 00000000..a1a12fe8 --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,43 @@ +# This file contains the fastlane.tools configuration +# You can find the documentation at https://docs.fastlane.tools +# +# For a list of all available actions, check out +# +# https://docs.fastlane.tools/actions +# +# For a list of all available plugins, check out +# +# https://docs.fastlane.tools/plugins/available-plugins +# + +# Uncomment the line if you want fastlane to automatically update itself +# update_fastlane + +require 'fileutils' + +default_platform(:ios) + +$ignore_file_path = '.xcovignore' + +before_all do + FileUtils.rm_rf("./UnitTestsReport") + FileUtils.rm_rf("./CodeCoverageReport") +end + +platform :ios do + desc "Run tests and create a unit-test report" + + lane :unit_tests do + run_tests( + scheme: "Basic-Car-Maintenance", + only_testing: "Basic-Car-Maintenance-Tests", + xcargs: "-skipPackagePluginValidation", + device: "iPhone 16 Pro", + skip_build: true, + xcodebuild_formatter: 'xcbeautify -qq --is-ci --renderer github-actions', + output_types: 'junit', + output_directory: "./fastlane/UnitTestsReport" + ) + end + +end diff --git a/fastlane/README.md b/fastlane/README.md new file mode 100644 index 00000000..1ebdd83b --- /dev/null +++ b/fastlane/README.md @@ -0,0 +1,32 @@ +fastlane documentation +---- + +# Installation + +Make sure you have the latest version of the Xcode command line tools installed: + +```sh +xcode-select --install +``` + +For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) + +# Available Actions + +## iOS + +### ios unit_tests + +```sh +[bundle exec] fastlane ios unit_tests +``` + +Run tests and create a unit-test report + +---- + +This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. + +More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). + +The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). diff --git a/fastlane/enable-build-tool-plugins.json b/fastlane/enable-build-tool-plugins.json new file mode 100644 index 00000000..738e40d1 --- /dev/null +++ b/fastlane/enable-build-tool-plugins.json @@ -0,0 +1,12 @@ +[ + { + "fingerprint" : "f17a4f9dfb6a6afb0408426354e4180daaf49cee", + "packageIdentity" : "swiftlint", + "targetName" : "SwiftLintPlugin" + }, + { + "fingerprint" : "7c80ce6f142164b0201871e580b021d1b2c69804", + "packageIdentity" : "swiftlintplugins", + "targetName" : "SwiftLintBuildToolPlugin" + } +] \ No newline at end of file