├── .gitignore
├── SwiftOBD2App
├── Assets.xcassets
│ ├── Contents.json
│ ├── car.imageset
│ │ ├── car.png
│ │ └── Contents.json
│ ├── Logo.imageset
│ │ ├── SMARTOBD.png
│ │ └── Contents.json
│ ├── AccentColor.colorset
│ │ └── Contents.json
│ ├── AppIcon.appiconset
│ │ └── Contents.json
│ └── racecar.imageset
│ │ ├── Contents.json
│ │ └── racecar.svg
├── Preview Content
│ └── Preview Assets.xcassets
│ │ └── Contents.json
├── Views
│ ├── LogsView.swift
│ ├── SubViews
│ │ ├── BatteryTestView.swift
│ │ ├── AddPIDView.swift
│ │ ├── ScrollChartView.swift
│ │ ├── GaugePickerView.swift
│ │ ├── AddVehicleView.swift
│ │ └── VehicleDiagnosticsView.swift
│ ├── MainView.swift
│ ├── Utils
│ │ ├── SectionView.swift
│ │ ├── SplashScreenView.swift
│ │ ├── VehiclePickerView.swift
│ │ └── GaugeView.swift
│ ├── AboutView.swift
│ ├── SettingsView.swift
│ ├── HomeView.swift
│ ├── GarageView.swift
│ ├── LiveDataView.swift
│ └── TestingScreen.swift
├── CustomTabNavigator
│ ├── TabBarItem.swift
│ ├── TabBarItemsPK.swift
│ ├── CustomTabBarContainerView.swift
│ └── CustomTabBarView.swift
├── Info.plist
├── SwiftOBD2App.swift
├── Extensions
│ └── extensions.swift
└── ViewModels
│ └── LiveDataViewModel.swift
├── .swiftlint.yml
├── SMARTOBD2.entitlements
├── SMARTOBD2Tests
├── BleConnectionTest.swift
├── SMARTOBD2Tests.swift
└── UnitTestingBootcampViewModelTest.swift
├── LICENSE
├── project.yml
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | *.xcodeproj
2 | *.pbxproj
3 | SwiftOBD2App.xcodeproj
4 |
--------------------------------------------------------------------------------
/SwiftOBD2App/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/.swiftlint.yml:
--------------------------------------------------------------------------------
1 | disabled_rules:
2 | - multiple_closures_with_trailing_closure
3 | - identifier_name
4 | - function_body_length
5 |
--------------------------------------------------------------------------------
/SwiftOBD2App/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/SwiftOBD2App/Assets.xcassets/car.imageset/car.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kkonteh97/SwiftOBD2App/HEAD/SwiftOBD2App/Assets.xcassets/car.imageset/car.png
--------------------------------------------------------------------------------
/SwiftOBD2App/Assets.xcassets/Logo.imageset/SMARTOBD.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kkonteh97/SwiftOBD2App/HEAD/SwiftOBD2App/Assets.xcassets/Logo.imageset/SMARTOBD.png
--------------------------------------------------------------------------------
/SMARTOBD2.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/SwiftOBD2App/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/SwiftOBD2App/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "platform" : "ios",
6 | "size" : "1024x1024"
7 | }
8 | ],
9 | "info" : {
10 | "author" : "xcode",
11 | "version" : 1
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/SwiftOBD2App/Views/LogsView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LogsView.swift
3 | // SMARTOBD2
4 | //
5 | // Created by kemo konteh on 2/28/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct LogsView: View {
11 | var body: some View {
12 | Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
13 | }
14 | }
15 |
16 | #Preview {
17 | LogsView()
18 | }
19 |
--------------------------------------------------------------------------------
/SwiftOBD2App/Views/SubViews/BatteryTestView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BatteryTestView.swift
3 | // SMARTOBD2
4 | //
5 | // Created by kemo konteh on 9/30/23.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct BatteryTestView: View {
11 | var body: some View {
12 | Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
13 | }
14 | }
15 |
16 | #Preview {
17 | BatteryTestView()
18 | }
19 |
--------------------------------------------------------------------------------
/SwiftOBD2App/Assets.xcassets/car.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "car.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/SwiftOBD2App/Assets.xcassets/Logo.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "SMARTOBD.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/SwiftOBD2App/Assets.xcassets/racecar.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "racecar.svg",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/SwiftOBD2App/CustomTabNavigator/TabBarItem.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TabBarItem.swift
3 | // SMARTOBD2
4 | //
5 | // Created by kemo konteh on 10/5/23.
6 | //
7 |
8 | import SwiftUI
9 |
10 | enum TabBarItem: Hashable {
11 | case dashBoard
12 | case features
13 |
14 | var iconName: String {
15 | switch self {
16 | case .dashBoard:
17 | return "gauge.open.with.lines.needle.33percent"
18 | case .features:
19 | return "person"
20 | }
21 | }
22 |
23 | var title: String {
24 | switch self {
25 | case .dashBoard:
26 | return "Dashboard"
27 | case .features:
28 | return "Features"
29 | }
30 | }
31 |
32 | var color: Color {
33 | switch self {
34 | case .dashBoard:
35 | return Color.red
36 | case .features:
37 | return Color.blue
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/SwiftOBD2App/CustomTabNavigator/TabBarItemsPK.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TabBarItemsPK.swift
3 | // SMARTOBD2
4 | //
5 | // Created by kemo konteh on 10/4/23.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct TabBarItemsPK: PreferenceKey {
11 | static var defaultValue: [TabBarItem] = []
12 |
13 | static func reduce(value: inout [TabBarItem], nextValue: () -> [TabBarItem]) {
14 | value += nextValue()
15 | }
16 | }
17 |
18 | struct TabBarItemsViewModifier: ViewModifier {
19 | let tab: TabBarItem
20 | @Binding var selection: TabBarItem
21 |
22 | func body(content: Content) -> some View {
23 | content
24 | .opacity(selection == tab ? 1 : 0)
25 | .preference(key: TabBarItemsPK.self, value: [tab])
26 | }
27 | }
28 |
29 | extension View {
30 | func tabBarItem(tab: TabBarItem, selection: Binding) -> some View {
31 | modifier(TabBarItemsViewModifier(tab: tab, selection: selection))
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/SMARTOBD2Tests/BleConnectionTest.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BleConnectionTest.swift
3 | // SMARTOBD2Tests
4 | //
5 | // Created by kemo konteh on 10/18/23.
6 | //
7 |
8 | import XCTest
9 | @testable import SMARTOBD2
10 |
11 | final class BleConnectionTest: XCTestCase {
12 | var mockCentralManager: CBCentralManagerMock!
13 | var mockPeripheral: CBPeripheralProtocol!
14 | var bleManager: BLEManager!
15 |
16 | override func setUpWithError() throws {
17 | // Put setup code here. This method is called before the invocation of each test method in the class.
18 |
19 | mockCentralManager = CBCentralManagerMock(delegate: nil, queue: nil)
20 | }
21 |
22 | override func tearDownWithError() throws {
23 | // Put teardown code here. This method is called after the invocation of each test method in the class.
24 | }
25 |
26 | func test_BLEManager_init() {
27 | // Given
28 |
29 | // When
30 | let bleManger = BLEManager()
31 | // Then
32 | }
33 |
34 | }
35 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Kemo Konteh
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/SMARTOBD2Tests/SMARTOBD2Tests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SMARTOBD2Tests.swift
3 | // SMARTOBD2Tests
4 | //
5 | // Created by kemo konteh on 10/18/23.
6 | //
7 |
8 | import XCTest
9 |
10 | // Naming Structure: test_UnitOfWork_StateUnderTest_ExpectedBehavior
11 | // Testing Struture: Given, When, Then
12 |
13 | final class SMARTOBD2Tests: XCTestCase {
14 |
15 | override func setUpWithError() throws {
16 | // Put setup code here. This method is called before the invocation of each test method in the class.
17 | }
18 |
19 | override func tearDownWithError() throws {
20 | // Put teardown code here. This method is called after the invocation of each test method in the class.
21 | }
22 |
23 | func testExample() throws {
24 | // This is an example of a functional test case.
25 | // Use XCTAssert and related functions to verify your tests produce the correct results.
26 | // Any test you write for XCTest can be annotated as throws and async.
27 | // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error.
28 | // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards.
29 | }
30 |
31 | func testPerformanceExample() throws {
32 | // This is an example of a performance test case.
33 | measure {
34 | // Put the code you want to measure the time of here.
35 | }
36 | }
37 |
38 | }
39 |
--------------------------------------------------------------------------------
/project.yml:
--------------------------------------------------------------------------------
1 | name: SwiftOBD2App
2 | options:
3 | bundleIdPrefix: com.myapp
4 | targets:
5 | SwiftOBD2App:
6 | type: application
7 | platform: iOS
8 | deploymentTarget: "16.0"
9 | dependencies:
10 | - package: SwiftOBD2
11 | product: SwiftOBD2
12 | info:
13 | path: "SwiftOBD2App/Info.plist"
14 | properties:
15 | CFBundleDisplayName: $(PRODUCT_NAME)
16 | CFBundleVersion: $(CURRENT_PROJECT_VERSION)
17 | CFBundleIdentifier: $(PRODUCT_BUNDLE_IDENTIFIER)
18 | CFBundlePackageType: $(PRODUCT_BUNDLE_PACKAGE_TYPE)
19 | UILaunchStoryboardName: "LaunchScreen"
20 | NSBluetoothAlwaysUsageDescription: "This app uses Bluetooth to connect to nearby devices."
21 | NSBluetoothPeripheralUsageDescription: "This app uses Bluetooth to connect to nearby devices."
22 | NSLocationAlwaysUsageDescription: "This app uses location services to connect to nearby devices."
23 | NSLocationWhenInUseUsageDescription: "This app uses location services to connect to nearby devices."
24 | UIBackgroundModes: ["bluetooth-central"]
25 | UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight]
26 | UISupportedInterfaceOrientations~ipad: [UIInterfaceOrientationPortrait, UIInterfaceOrientationPortraitUpsideDown, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight]
27 | sources:
28 | - path: "SwiftOBD2App"
29 |
30 | packages:
31 | SwiftOBD2:
32 | url: https://github.com/kkonteh97/SwiftOBD2.git
33 | requirement:
34 | branch: "main"
35 |
36 |
--------------------------------------------------------------------------------
/SwiftOBD2App/Views/MainView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TabView.swift
3 | // SmartOBD2
4 | //
5 | // Created by kemo konteh on 8/5/23.
6 | //
7 |
8 | import SwiftUI
9 | import SwiftOBD2
10 |
11 | struct MainView: View {
12 | @State private var tabSelection: TabBarItem = .dashBoard
13 | @State var displayType: BottomSheetType = .quarterScreen
14 | @State var statusMessage: String?
15 | @State var isDemoMode = false
16 |
17 | var body: some View {
18 | GeometryReader { proxy in
19 | CustomTabBarContainerView(
20 | selection: $tabSelection,
21 | maxHeight: proxy.size.height,
22 | displayType: $displayType,
23 | statusMessage: $statusMessage
24 | ) {
25 | NavigationView {
26 | HomeView(displayType: $displayType, isDemoMode: $isDemoMode, statusMessage: $statusMessage)
27 | }
28 | .navigationViewStyle(.stack)
29 | .tabBarItem(tab: .dashBoard, selection: $tabSelection)
30 |
31 | NavigationView {
32 | LiveDataView(displayType: $displayType,
33 | statusMessage: $statusMessage,
34 | isDemoMode: $isDemoMode
35 | )
36 | }
37 | .navigationViewStyle(.stack)
38 | .tabBarItem(tab: .features, selection: $tabSelection)
39 | }
40 | }
41 | }
42 | }
43 |
44 | #Preview {
45 | MainView()
46 | .environmentObject(GlobalSettings())
47 | .environmentObject(Garage())
48 | .environmentObject(OBDService())
49 | }
50 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # OBD2 Swift App
2 |
3 | Welcome to the OBD2 Swift App! This app will allow you to read error codes and view Parameter IDs (PIDs) from your vehicle's OBD2 system using Swift and CoreBluetooth.
4 |
5 | https://github.com/kkonteh97/SmartOBD2/assets/55326260/40b862b7-44c1-44ae-b402-caa7cbda7683
6 |
7 | ## Getting Started
8 |
9 | 1. Clone this repository to your local machine.
10 | 2. Navigate to project directory on the terminal
11 | ```
12 | cd SwiftOBD2App
13 | ```
14 | 3. Run command xcodegen
15 | ```
16 | xcodegen
17 | ```
18 | 4. Open the Xcode project file (`SwiftOBD2App.xcodeproj`).
19 | 5. Build and run the app on a compatible iOS device.
20 |
21 | ## MileStones
22 |
23 | - Connect to an OBD2 adapter via Bluetooth Low Energy (BLE) (completed)
24 | - Retrieve error codes (DTCs) stored in the vehicle's OBD2 system (completed)
25 | - View various OBD2 Parameter IDs (PIDs) for monitoring vehicle parameters (completed)
26 | - Clean and intuitive user interface (in progress...)
27 |
28 | ## Current Requirements
29 |
30 | - iOS 16.0+
31 | - Xcode 15.0+
32 | - Swift 5.5+
33 |
34 | ## Usage
35 |
36 | 1. Launch the app on your iOS device.
37 | 2. Make sure your OBD2 adapter is powered on and discoverable.
38 |
39 | ## Contributing
40 |
41 | Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated.
42 |
43 | If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement". Don't forget to give the project a star! Thanks again!
44 |
45 |
46 | 1. Fork the Project
47 | 2. Create your Feature Branch (git checkout -b feature/AmazingFeature)
48 | 3. Commit your Changes (git commit -m 'Add some AmazingFeature')
49 | 4. Push to the Branch (git push origin feature/AmazingFeature)
50 | 5. Open a Pull Request
51 |
52 | ## License
53 |
54 | Distributed under the MIT License. See `LICENSE` for more information.
55 |
56 |
57 |
58 |
--------------------------------------------------------------------------------
/SwiftOBD2App/Views/Utils/SectionView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SectionView.swift
3 | // SMARTOBD2
4 | //
5 | // Created by kemo konteh on 9/30/23.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct SectionView: View {
11 | let title: String
12 | let subtitle: String
13 | let iconName: String
14 | let destination: Destination
15 |
16 | init(
17 | title: String,
18 | subtitle: String,
19 | iconName: String,
20 | destination: Destination
21 | ) {
22 | self.title = title
23 | self.subtitle = subtitle
24 | self.iconName = iconName
25 | self.destination = destination
26 | }
27 |
28 | var body: some View {
29 | NavigationLink {
30 | destination
31 | }label: {
32 | VStack(alignment: .leading, spacing: 5) {
33 | Image(systemName: iconName)
34 | .font(.system(size: 30, weight: .bold))
35 | .foregroundColor(.white)
36 |
37 | Text(title)
38 | .font(.system(size: 16, weight: .bold))
39 | .foregroundColor(.white)
40 |
41 | HStack {
42 | Text(subtitle)
43 | .lineLimit(2)
44 | .font(.system(size: 12, weight: .semibold))
45 | .multilineTextAlignment(.leading)
46 | .foregroundColor(.gray)
47 |
48 | Spacer()
49 | Image(systemName: "chevron.right")
50 | .font(.system(size: 14, weight: .bold))
51 | .foregroundColor(.white)
52 | }
53 | }
54 | .padding(.horizontal)
55 | .frame(width: 160, height: 160)
56 | .background {
57 | RoundedRectangle(cornerRadius: 10)
58 | .fill(Color.cyclamen)
59 | }
60 | }
61 | }
62 | }
63 |
64 | #Preview {
65 | SectionView(title: "hello", subtitle: "ola", iconName: "car.fill", destination: Text("hello"))
66 | }
67 |
--------------------------------------------------------------------------------
/SwiftOBD2App/Assets.xcassets/racecar.imageset/racecar.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/SwiftOBD2App/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleDisplayName
8 | $(PRODUCT_NAME)
9 | CFBundleExecutable
10 | $(EXECUTABLE_NAME)
11 | CFBundleIdentifier
12 | $(PRODUCT_BUNDLE_IDENTIFIER)
13 | CFBundleInfoDictionaryVersion
14 | 6.0
15 | CFBundleName
16 | $(PRODUCT_NAME)
17 | CFBundlePackageType
18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
19 | CFBundleShortVersionString
20 | 1.0
21 | CFBundleVersion
22 | $(CURRENT_PROJECT_VERSION)
23 | NSBluetoothAlwaysUsageDescription
24 | This app uses Bluetooth to connect to nearby devices.
25 | NSBluetoothPeripheralUsageDescription
26 | This app uses Bluetooth to connect to nearby devices.
27 | NSLocationAlwaysUsageDescription
28 | This app uses location services to connect to nearby devices.
29 | NSLocationWhenInUseUsageDescription
30 | This app uses location services to connect to nearby devices.
31 | UIBackgroundModes
32 |
33 | bluetooth-central
34 |
35 | UILaunchStoryboardName
36 | LaunchScreen
37 | UISupportedInterfaceOrientations
38 |
39 | UIInterfaceOrientationPortrait
40 | UIInterfaceOrientationLandscapeLeft
41 | UIInterfaceOrientationLandscapeRight
42 |
43 | UISupportedInterfaceOrientations~ipad
44 |
45 | UIInterfaceOrientationPortrait
46 | UIInterfaceOrientationPortraitUpsideDown
47 | UIInterfaceOrientationLandscapeLeft
48 | UIInterfaceOrientationLandscapeRight
49 |
50 |
51 |
52 |
--------------------------------------------------------------------------------
/SwiftOBD2App/CustomTabNavigator/CustomTabBarContainerView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CustomTabBarContainerView.swift
3 | // SMARTOBD2
4 | //
5 | // Created by kemo konteh on 10/4/23.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct CustomTabBarContainerView: View {
11 | @Binding var selection: TabBarItem
12 | let maxHeight: Double
13 | let content: Content
14 | @State private var tabs: [TabBarItem] = []
15 | @Binding var displayType: BottomSheetType
16 | @Binding var statusMessage: String?
17 | init(
18 | selection: Binding,
19 | maxHeight: Double,
20 | displayType: Binding,
21 | statusMessage: Binding,
22 | @ViewBuilder content: () -> Content
23 | ) {
24 | self._selection = selection
25 | self.maxHeight = maxHeight
26 | self._displayType = displayType
27 | self._statusMessage = statusMessage
28 | self.content = content()
29 | }
30 |
31 | var body: some View {
32 | GeometryReader { proxy in
33 | CustomTabBarView(
34 | tabs: tabs,
35 | selection: $selection,
36 | maxHeight: proxy.size.height,
37 | displayType: $displayType,
38 | statusMessage: $statusMessage
39 | ) {
40 | content
41 | .ignoresSafeArea()
42 | }
43 | .onPreferenceChange(TabBarItemsPK.self, perform: { value in
44 | self.tabs = value
45 | })
46 | }
47 | }
48 | }
49 |
50 | // struct CustomTabBarContainerView_Previews: PreviewProvider {
51 | // static let tabs: [TabBarItem] = [.dashBoard, .features]
52 | // static var previews: some View {
53 | // GeometryReader { proxy in
54 | // CustomTabBarContainerView(selection: .constant(tabs.first!),
55 | // maxHeight: proxy.size.height,
56 | // viewModel: CustomTabBarViewModel(obdService: OBDService(),
57 | // garage: Garage())
58 | // ) {
59 | // Color.red
60 | // }
61 | // }
62 | // }
63 | // }
64 |
--------------------------------------------------------------------------------
/SwiftOBD2App/SwiftOBD2App.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SmartOBD2App.swift
3 | // SmartOBD2
4 | //
5 | // Created by kemo konteh on 8/3/23.
6 | //
7 |
8 | import SwiftUI
9 | import OSLog
10 | import SwiftOBD2
11 |
12 | extension Logger {
13 | /// Using your bundle identifier is a great way to ensure a unique identifier.
14 | private static var subsystem = Bundle.main.bundleIdentifier!
15 |
16 | /// Logs the view cycles like a view that appeared.
17 | static let elmCom = Logger(subsystem: subsystem, category: "ELM327")
18 |
19 | /// All logs related to tracking and analytics.
20 | static let bleCom = Logger(subsystem: subsystem, category: "BLEComms")
21 | }
22 |
23 | class GlobalSettings: NSObject, ObservableObject {
24 | @Published var displayType: BottomSheetType = .quarterScreen
25 | @Published var statusMessage = ""
26 | @Published var showAltText = false
27 | @Published var connectionType: ConnectionType = .bluetooth {
28 | didSet {
29 | UserDefaults.standard.set(connectionType.rawValue, forKey: "connectionType")
30 | }
31 | }
32 | @Published var selectedUnit: MeasurementUnit = .metric {
33 | didSet {
34 | UserDefaults.standard.set(selectedUnit.rawValue, forKey: "selectedUnit")
35 | }
36 | }
37 |
38 | override init() {
39 | super.init()
40 | if let unit = UserDefaults.standard.string(forKey: "selectedUnit") {
41 | selectedUnit = MeasurementUnit(rawValue: unit) ?? .metric
42 | }
43 | if let connection = UserDefaults.standard.string(forKey: "connectionType") {
44 | connectionType = ConnectionType(rawValue: connection) ?? .bluetooth
45 | }
46 | }
47 | }
48 |
49 | @main
50 | struct SMARTOBD2App: App {
51 | @StateObject var globalSettings = GlobalSettings()
52 | @StateObject var obdService = OBDService(connectionType: .bluetooth)
53 | @StateObject var garage = Garage()
54 |
55 | @State var SplashScreenIsActive: Bool = true
56 |
57 | var body: some Scene {
58 | WindowGroup {
59 | if SplashScreenIsActive {
60 | SplashScreenView(isActive: $SplashScreenIsActive)
61 | } else {
62 | MainView()
63 | .environmentObject(globalSettings)
64 | .environmentObject(garage)
65 | .environmentObject(obdService)
66 | }
67 | }
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/SwiftOBD2App/Views/AboutView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AboutView.swift
3 | // SMARTOBD2
4 | //
5 | // Created by kemo konteh on 10/1/23.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct AboutView: View {
11 | @EnvironmentObject var globalSettings: GlobalSettings
12 | @Environment(\.dismiss) var dismiss
13 |
14 | var body: some View {
15 | VStack(spacing: 100) {
16 | Text("SMARTOBD2 Version 1.0")
17 | .font(.system(size: 18, weight: .bold))
18 | .foregroundColor(.white)
19 | .padding(.bottom, 10)
20 | VStack {
21 | Text(
22 | """
23 | SMARTOBD2 lets you monitor your car's health and
24 | performance in real-time. It also lets you diagnose
25 | your car's problems and provides you with a
26 | solution to fix them.
27 | """
28 | )
29 | .multilineTextAlignment(.leading)
30 | .font(.system(size: 14, weight: .semibold))
31 | .foregroundColor(.white)
32 | .padding(.bottom, 10)
33 |
34 | Text("Dedicated to my Dad\n Lang Konteh")
35 | .multilineTextAlignment(.trailing)
36 | .font(.system(size: 14, weight: .semibold))
37 | .foregroundColor(.gray)
38 | .padding(.bottom, 10)
39 | }
40 |
41 | VStack {
42 | Text("©2023 Konteh Inc")
43 | .font(.system(size: 10, weight: .semibold))
44 | .foregroundColor(.gray)
45 |
46 | Text("All rights reserved")
47 | .font(.system(size: 10, weight: .semibold))
48 | .foregroundColor(.gray)
49 | }
50 | }
51 | .padding()
52 | .navigationBarBackButtonHidden(true)
53 | .navigationTitle("Select a Make")
54 | .toolbar {
55 | ToolbarItem(placement: .navigationBarLeading) {
56 | Button {
57 | globalSettings.displayType = .quarterScreen
58 | dismiss()
59 | } label: {
60 | HStack {
61 | Image(systemName: "chevron.backward")
62 | Text("Back")
63 | }
64 | }
65 |
66 | }
67 |
68 | }
69 | }
70 | }
71 |
72 | #Preview {
73 | AboutView()
74 | }
75 |
--------------------------------------------------------------------------------
/SwiftOBD2App/Views/SubViews/AddPIDView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AddPIDView.swift
3 | // SMARTOBD2
4 | //
5 | // Created by kemo konteh on 10/10/23.
6 | //
7 |
8 | import SwiftUI
9 | import Combine
10 | import SwiftOBD2
11 |
12 | struct AddPIDView: View {
13 | @ObservedObject var viewModel: LiveDataViewModel
14 | @EnvironmentObject var garage: Garage
15 |
16 | var body: some View {
17 | if let car = garage.currentVehicle {
18 | VStack(alignment: .leading) {
19 | Text("Supported sensors for \(car.year) \(car.make) \(car.model)")
20 | Divider().background(Color.white)
21 |
22 | ScrollView(.vertical, showsIndicators: false) {
23 | if let supportedPIDs = car.obdinfo?.supportedPIDs {
24 | ForEach(supportedPIDs.filter { $0.properties.live }.sorted(), id: \.self) { pid in
25 | HStack {
26 | Text(pid.properties.description)
27 | .font(.system(size: 14, weight: .semibold, design: .default))
28 | .padding()
29 | }
30 | .frame(maxWidth: .infinity, alignment: .leading)
31 | .background(
32 | RoundedRectangle(cornerRadius: 10)
33 | // .fill(Color.endColor())
34 | .fill(Color.clear)
35 | .contentShape(Rectangle())
36 | .background(
37 | RoundedRectangle(cornerRadius: 10)
38 | .stroke(viewModel.pidData.contains(where: { $0.command == pid }) ? Color.blue : Color.clear, lineWidth: 2)
39 | )
40 | )
41 | .padding(.horizontal)
42 | .onTapGesture {
43 | viewModel.addPIDToRequest(pid)
44 | }
45 | }
46 | }
47 | }
48 | .frame(maxWidth: .infinity, alignment: .leading)
49 | }
50 | .padding()
51 | .frame(maxWidth: .infinity, alignment: .leading)
52 | }
53 |
54 | }
55 | }
56 |
57 | #Preview {
58 | AddPIDView(viewModel: LiveDataViewModel())
59 | .environmentObject(Garage())
60 | }
61 |
--------------------------------------------------------------------------------
/SwiftOBD2App/Views/Utils/SplashScreenView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SplashScreenView.swift
3 | // SmartOBD2
4 | //
5 | // Created by kemo konteh on 8/23/23.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct SplashScreenView: View {
11 | @Binding private var isActive: Bool
12 | init(isActive: Binding) {
13 | self._isActive = isActive
14 | }
15 |
16 | @State private var size = 0.7
17 | @State private var opacity = 0.4
18 | @State private var animate = false
19 |
20 | let numberOfDashes: Int = 30
21 | let dashLength: CGFloat = 8
22 | let gapLength: CGFloat = 3
23 | let startingAngle: CGFloat = .pi / 2
24 | @State private var rotation = 0.0
25 | @State private var carX = -100.0
26 | let size1: CGFloat = 250
27 | var offset: CGFloat = 200
28 |
29 | var body: some View {
30 | VStack {
31 | VStack {
32 | ZStack {
33 | VStack(spacing: 40) {
34 | Text("SwiftOBD2")
35 | .font(.title)
36 | .fontWeight(.bold)
37 | .foregroundColor(.white)
38 | .multilineTextAlignment(.center)
39 |
40 | Text("Give us a star on GitHub")
41 | .font(.subheadline)
42 | .fontWeight(.semibold)
43 | .foregroundColor(.white)
44 | .multilineTextAlignment(.center)
45 | }
46 | .opacity(opacity)
47 | .scaleEffect(size, anchor: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/)
48 |
49 | GeometryReader { geometry in
50 | let center = CGPoint(x: geometry.size.width / 2, y: geometry.size.height / 2)
51 | Image("car")
52 | .resizable()
53 | .renderingMode(.template)
54 | .frame(width: 100, height: 100)
55 | .foregroundStyle(
56 | LinearGradient(Color.red, Color.blue)
57 | )
58 | .position(x: carX + center.x, y: center.y + 10)
59 | }
60 | .onAppear {
61 | withAnimation(.spring(duration: 1.4, bounce: 0.4, blendDuration: 2)) {
62 | self.rotation = 1
63 | self.carX = 0.0
64 | self.size = 1.2
65 | self.opacity = 0.9
66 | }
67 | }
68 | }
69 | }
70 | }
71 | .onAppear {
72 | DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
73 | withAnimation {
74 | self.isActive = false
75 | }
76 | }
77 | }
78 | .preferredColorScheme(.dark)
79 | // .background(LinearGradient(.darkStart, .darkEnd))
80 | }
81 | }
82 |
83 | #Preview {
84 | SplashScreenView(isActive: .constant(false))
85 | }
86 |
--------------------------------------------------------------------------------
/SwiftOBD2App/Extensions/extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // extensions.swift
3 | // SmartOBD2
4 | //
5 | // Created by kemo konteh on 9/1/23.
6 | //
7 |
8 | import SwiftUI
9 |
10 | extension LinearGradient {
11 | init(_ colors: Color...) {
12 | self.init(gradient: Gradient(colors: colors), startPoint: .topLeading, endPoint: .bottomTrailing)
13 | }
14 | }
15 |
16 | extension Color {
17 | enum ColorScheme {
18 | case light
19 | case dark
20 | }
21 |
22 | static func currentColorScheme() -> ColorScheme {
23 | return UITraitCollection.current.userInterfaceStyle == .dark ? .dark : .light
24 | }
25 |
26 | // static func startColor(for colorScheme: ColorScheme = currentColorScheme()) -> Color {
27 | // switch colorScheme {
28 | // case .dark: return Color(red: 238 / 255, green: 244 / 255, blue: 237 / 255)
29 | // case .light: return Color(red: 241 / 255, green: 242 / 255, blue: 246 / 255)
30 | // }
31 | // }
32 | //
33 | static func endColor(for colorScheme: ColorScheme = currentColorScheme()) -> Color {
34 | switch colorScheme {
35 | case .dark: return Color(red: 37 / 255, green: 38 / 255, blue: 31 / 255)
36 | case .light: return Color(red: 220 / 255, green: 221 / 235, blue: 226 / 255)
37 | }
38 | }
39 |
40 | static func automotivePrimaryColor() -> Color {
41 | return Color(red: 141 / 255, green: 169 / 255, blue: 196 / 255)
42 | }
43 |
44 | static func automotiveSecondaryColor() -> Color {
45 | return Color(red: 231 / 255, green: 76 / 255, blue: 60 / 255)
46 | }
47 |
48 | static func automotiveAccentColor() -> Color {
49 | return Color(red: 46 / 255, green: 204 / 255, blue: 113 / 255)
50 | }
51 |
52 | static func automotiveBackgroundColor() -> Color {
53 | return Color(red: 158 / 255, green: 144 / 255, blue: 127 / 255)
54 | }
55 | static let lightGray = Color(red: 13 / 255, green: 27 / 255, blue: 42 / 255)
56 | static let cyclamen = Color(red: 46 / 255, green: 64 / 255, blue: 89 / 255)
57 | static let pinknew = Color(red: 119 / 255, green: 141 / 255, blue: 169 / 255)
58 |
59 | static let slategray = Color(red: 107 / 255, green: 113 / 255, blue: 125 / 255)
60 | static let charcoal = Color(red: 63 / 255, green: 67 / 255, blue: 78 / 255)
61 |
62 | static let raisinblack = Color(red: 36 / 255, green: 37 / 255, blue: 43 / 255)
63 |
64 | static let darkStart = Color(red: 50 / 255, green: 60 / 255, blue: 65 / 255)
65 | static let darkEnd = Color(red: 25 / 255, green: 25 / 255, blue: 30 / 255)
66 | }
67 |
68 | extension String {
69 | func leftPadding(toLength: Int, withPad character: Character) -> String {
70 | let paddingAmount = max(0, toLength - count)
71 | let padding = String(repeating: character, count: paddingAmount)
72 | return padding + self
73 | }
74 |
75 | func hexToBytes() -> [UInt8]? {
76 | var dataBytes: [UInt8] = []
77 | for hex in stride(from: 0, to: count, by: 2) {
78 | let startIndex = index(self.startIndex, offsetBy: hex)
79 | if let endIndex = index(startIndex, offsetBy: 2, limitedBy: self.endIndex) {
80 | let byteString = self[startIndex.. Character {
94 | self[index(startIndex, offsetBy: offset)]
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/SwiftOBD2App/Views/Utils/VehiclePickerView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // VehiclePickerView.swift
3 | // SmartOBD2
4 | //
5 | // Created by kemo konteh on 9/8/23.
6 | //
7 |
8 | import SwiftUI
9 | import SwiftOBD2
10 |
11 | class VehiclePickerViewModel: ObservableObject {
12 | var carData: [Manufacturer] = []
13 | let garage: Garage
14 |
15 | init(garage: Garage) {
16 | self.garage = garage
17 | loadGarageVehicles()
18 | }
19 |
20 | private func loadGarageVehicles() {
21 | do {
22 | let url = Bundle.main.url(forResource: "Cars", withExtension: "json")!
23 | let data = try Data(contentsOf: url)
24 | self.carData = try JSONDecoder().decode([Manufacturer].self, from: data)
25 | } catch {
26 | print("error loading vehicles")
27 | }
28 | }
29 |
30 | func addVehicle(make: String, model: String, year: String,
31 | vin: String = "", obdinfo: OBDInfo?) {
32 | garage.addVehicle(make: make, model: model, year: year)
33 | }
34 | }
35 |
36 | struct VehiclePickerView: View {
37 | @ObservedObject var viewModel = VehiclePickerViewModel(garage: Garage())
38 | @State var selectedYear = -1
39 | @State var selectedModel = -1 {
40 | didSet {
41 | selectedYear = -1
42 | }
43 | }
44 |
45 | @State var selectedManufacturer = -1 {
46 | didSet {
47 | selectedModel = -1
48 | selectedYear = -1
49 | }
50 | }
51 |
52 | var models: [Model] {
53 | return (0 ..< viewModel.carData.count).contains(selectedManufacturer) ? viewModel.carData[selectedManufacturer].models : []
54 | }
55 |
56 | var years: [String] {
57 | return (0 ..< models.count).contains(selectedModel) ? models[selectedModel].years : []
58 | }
59 |
60 | var body: some View {
61 | HStack {
62 | Picker(selection: $selectedManufacturer, label: Text("Brand")) {
63 | Text("None")
64 | .tag(-1)
65 |
66 | ForEach(0 ..< viewModel.carData.count, id: \.self) { carIndex in
67 | Text(self.viewModel.carData[carIndex].make)
68 | .tag(carIndex)
69 | }
70 | }
71 | .pickerStyle(WheelPickerStyle())
72 |
73 | if !models.isEmpty {
74 | Picker(selection: $selectedModel, label: Text("Model")) {
75 | Text("None")
76 | .tag(-1)
77 |
78 | ForEach(0 ..< models.count, id: \.self) { modelIndex in
79 | Text(models[modelIndex].name)
80 | .tag(modelIndex)
81 | }
82 | }
83 | .pickerStyle(WheelPickerStyle())
84 | }
85 |
86 | if !years.isEmpty {
87 | Picker(selection: $selectedYear, label: Text("Year")) {
88 | Text("None")
89 | .tag(-1)
90 |
91 | ForEach(0 ..< years.count, id: \.self) { yearIndex in
92 | Text("\(years[yearIndex])")
93 | .tag(yearIndex)
94 | }
95 | }
96 | .pickerStyle(WheelPickerStyle())
97 | }
98 | }
99 |
100 | if selectedYear != -1 && selectedModel != -1 && selectedManufacturer != -1 {
101 | Button(action: {
102 | viewModel.addVehicle(
103 | make: viewModel.carData[selectedManufacturer].make,
104 | model: models[selectedModel].name,
105 | year: String(years[selectedYear]), obdinfo: nil
106 | )
107 | }) {
108 | Text("Add")
109 | .padding()
110 | .background(Color.blue)
111 | .foregroundColor(.white)
112 | .cornerRadius(10)
113 | }
114 | }
115 | }
116 | }
117 |
118 | #Preview {
119 | VehiclePickerView()
120 | }
121 |
--------------------------------------------------------------------------------
/SwiftOBD2App/ViewModels/LiveDataViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LiveDataViewModel.swift
3 | // SMARTOBD2
4 | //
5 | // Created by kemo konteh on 10/5/23.
6 | //
7 |
8 | import Combine
9 | import SwiftUI
10 | import SwiftOBD2
11 |
12 | struct PIDMeasurement: Identifiable, Comparable, Hashable, Codable {
13 | static func < (lhs: PIDMeasurement, rhs: PIDMeasurement) -> Bool {
14 | lhs.id < rhs.id
15 | }
16 |
17 | let id: Date
18 | let value: Double
19 |
20 | init(time: Date, value: Double) {
21 | self.value = value
22 | self.id = time
23 | }
24 | }
25 |
26 | class DataItem: Identifiable, Codable, ObservableObject {
27 | let command: OBDCommand
28 | @Published var value: Double
29 | var unit: String?
30 | var selectedGauge: GaugeType?
31 | var measurements: [PIDMeasurement]
32 |
33 | init(command: OBDCommand,
34 | value: Double = 0,
35 | unit: String? = nil,
36 | selectedGauge: GaugeType? = nil,
37 | measurements: [PIDMeasurement] = []
38 | ) {
39 | self.command = command
40 | self.value = value
41 | self.unit = unit
42 | self.selectedGauge = selectedGauge
43 | self.measurements = measurements
44 | }
45 |
46 | // MARK: - Codable
47 | enum CodingKeys: String, CodingKey {
48 | case command
49 | case value
50 | case unit
51 | case selectedGauge
52 | case measurements
53 | }
54 |
55 | required init(from decoder: Decoder) throws {
56 | let container = try decoder.container(keyedBy: CodingKeys.self)
57 | command = try container.decode(OBDCommand.self, forKey: .command)
58 | value = try container.decode(Double.self, forKey: .value)
59 | unit = try container.decode(String.self, forKey: .unit)
60 | selectedGauge = try container.decode(GaugeType.self, forKey: .selectedGauge)
61 | measurements = try container.decode([PIDMeasurement].self, forKey: .measurements)
62 | }
63 |
64 | func encode(to encoder: Encoder) throws {
65 | var container = encoder.container(keyedBy: CodingKeys.self)
66 | try container.encode(command, forKey: .command)
67 | try container.encode(value, forKey: .value)
68 | try container.encode(unit, forKey: .unit)
69 | try container.encode(selectedGauge, forKey: .selectedGauge)
70 | try container.encode(measurements, forKey: .measurements)
71 | }
72 |
73 | func update(_ value: Double) {
74 | self.value = value
75 | measurements.append(PIDMeasurement(time: Date(), value: value))
76 | if measurements.count > 100 {
77 | measurements.removeFirst()
78 | }
79 | }
80 | }
81 |
82 | class LiveDataViewModel: ObservableObject {
83 | var cancellables = Set()
84 |
85 | @Published var isRequestingPids = false
86 |
87 | @Published var pidData: [DataItem] = []
88 | @Published var isRequesting: Bool = false
89 |
90 | private let measurementTimeLimit: TimeInterval = 120
91 |
92 | init() {
93 |
94 | UserDefaults.standard.removeObject(forKey: "pidData")
95 |
96 | if let piddata = UserDefaults.standard.data(forKey: "pidData"),
97 | let pidData = try? JSONDecoder().decode([DataItem].self, from: piddata) {
98 | self.pidData = pidData
99 | } else {
100 | // default pids SPEED and RPM
101 | pidData = [DataItem(command: .mode1(.rpm), value: 0, selectedGauge: .gaugeType4),
102 | DataItem(command: .mode1(.speed), value: 0, selectedGauge: .gaugeType1)]
103 | }
104 | }
105 |
106 | deinit {
107 | saveDataItems()
108 | }
109 |
110 | func saveDataItems() {
111 | if let encodedData = try? JSONEncoder().encode(pidData) {
112 | UserDefaults.standard.set(encodedData, forKey: "pidData")
113 | }
114 | }
115 |
116 | func addPIDToRequest(_ pid: OBDCommand) {
117 | guard pidData.count < 6 else { return }
118 | if !pidData.contains(where: { $0.command == pid }) {
119 | pidData.append(DataItem(command: pid, selectedGauge: .gaugeType1))
120 | } else {
121 | pidData.removeAll(where: { $0.command == pid })
122 | }
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/SwiftOBD2App/Views/SettingsView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SettingsView.swift
3 | // SMARTOBD2
4 | //
5 | // Created by kemo konteh on 10/13/23.
6 | //
7 |
8 | import SwiftUI
9 | import SwiftOBD2
10 |
11 | class SettingsViewModel: ObservableObject {
12 |
13 | var garage: Garage
14 |
15 | init(_ garage: Garage) {
16 | self.garage = garage
17 | }
18 |
19 | func switchToDemoMode(_ isDemoMode: Bool) {
20 | garage.switchToDemoMode(isDemoMode)
21 | // obdService.switchToDemoMode(isDemoMode)
22 | }
23 | }
24 |
25 | struct SettingsView: View {
26 | @EnvironmentObject var globalSettings: GlobalSettings
27 |
28 | @EnvironmentObject var obdService: OBDService
29 | @Environment(\.dismiss) var dismiss
30 | @Binding var displayType: BottomSheetType
31 |
32 | @Binding var isDemoMode: Bool
33 |
34 | var body: some View {
35 | ZStack {
36 | BackgroundView(isDemoMode: $isDemoMode)
37 | VStack {
38 | List {
39 | connectionSection
40 | .listRowBackground(Color.clear)
41 |
42 | displaySection
43 | .listRowBackground(Color.darkStart.opacity(0.3))
44 |
45 | otherSection
46 | .listRowSeparator(.automatic)
47 | .listRowBackground(Color.darkStart.opacity(0.3))
48 | }
49 | .listStyle(.insetGrouped)
50 | .scrollContentBackground(.hidden)
51 | .foregroundColor(.white)
52 | }
53 | .navigationBarBackButtonHidden(true)
54 | .toolbar {
55 | ToolbarItem(placement: .navigationBarLeading) {
56 | Button {
57 | displayType = .quarterScreen
58 | dismiss()
59 | } label: {
60 | Label("Back", systemImage: "chevron.backward")
61 | }
62 | }
63 | }
64 | .gesture(DragGesture().onEnded({
65 | if $0.translation.width > 100 {
66 | displayType = .quarterScreen
67 | dismiss()
68 | }
69 | }))
70 | }
71 | }
72 |
73 | var displaySection: some View {
74 | Section(header: Text("Display").font(.system(size: 20, weight: .bold, design: .rounded))) {
75 | Picker("Units", selection: $globalSettings.selectedUnit) {
76 | ForEach(MeasurementUnit.allCases, id: \.self) {
77 | Text($0.rawValue)
78 | }
79 | }
80 | .pickerStyle(.menu)
81 | }
82 | }
83 |
84 | var connectionSection: some View {
85 | Section(header: Text("Connection").font(.system(size: 20, weight: .bold, design: .rounded))) {
86 | Picker("Connection Type", selection: $obdService.connectionType) {
87 | ForEach(ConnectionType.allCases, id: \.self) {
88 | Text($0.rawValue)
89 | }
90 | }
91 | .pickerStyle(.segmented)
92 | .background(Color.darkStart.opacity(0.3))
93 |
94 | switch obdService.connectionType {
95 | case .bluetooth:
96 | NavigationLink(destination: Text("Bluetooth Settings")) {
97 | Text("Bluetooth Settings")
98 | }
99 | case .wifi:
100 | Text("Wifi Settings")
101 |
102 | case .demo:
103 | Text("Demo Mode")
104 | }
105 | }
106 | .listRowSeparator(.hidden)
107 |
108 | }
109 |
110 | var otherSection: some View {
111 | Section(header: Text("Other").font(.system(size: 20, weight: .bold, design: .rounded))) {
112 | NavigationLink(destination: AboutView()) {
113 | Text("About")
114 | }
115 | }
116 | }
117 |
118 | }
119 |
120 | struct ProtocolPicker: View {
121 | @Binding var selectedProtocol: PROTOCOL
122 |
123 | var body: some View {
124 | HStack {
125 | Text("OBD Protocol: ")
126 |
127 | Picker("Select Protocol", selection: $selectedProtocol) {
128 | ForEach(PROTOCOL.asArray, id: \.self) { protocolItem in
129 | Text(protocolItem.description).tag(protocolItem)
130 | }
131 | }
132 | }
133 | .padding()
134 | .frame(maxWidth: .infinity, alignment: .leading)
135 | }
136 | }
137 |
138 | struct RoundedRectangleStyle: ViewModifier {
139 | @Environment(\.colorScheme) var colorScheme
140 |
141 | func body(content: Content) -> some View {
142 | content
143 | .padding()
144 | .background(
145 | RoundedRectangle(cornerRadius: 10)
146 | .fill(Color.endColor())
147 | )
148 | }
149 | }
150 |
151 | #Preview {
152 | SettingsView(displayType: .constant(.fullScreen), isDemoMode: .constant(true))
153 | .environmentObject(GlobalSettings())
154 | }
155 |
--------------------------------------------------------------------------------
/SwiftOBD2App/Views/Utils/GaugeView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GuageView.swift
3 | // SmartOBD2
4 | //
5 | // Created by kemo konteh on 9/17/23.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct GaugeConstants {
11 | static let gaugeSize: CGSize = CGSize(width: 175, height: 175) // Adjust the size of the gauge view
12 | static let needleSize: CGSize = CGSize(width: 70, height: 3.5) // Adjust the size of the needle
13 | static let tickWidthSmall: CGFloat = 1.5 // Adjust the width of the small tick
14 | static let tickWidthBig: CGFloat = 3.5 // Adjust the width of the big tick
15 | static let tickHeightSmall: CGFloat = 5 // Adjust the height of the small tick
16 | static let tickHeightBig: CGFloat = 15 // Adjust the height of the big tick
17 | static let circleSize: CGSize = CGSize(width: 10, height: 10) // Adjust the size of the circle
18 | }
19 |
20 | struct Needle: Shape {
21 | func path(in rect: CGRect) -> Path {
22 | var path = Path()
23 | path.move(to: CGPoint(x: 0, y: rect.height/2))
24 | path.addLine(to: CGPoint(x: rect.width, y: 0))
25 | path.addLine(to: CGPoint(x: rect.width, y: rect.height))
26 | return path
27 | }
28 | }
29 |
30 | struct CustomGaugeView: View {
31 | let coveredRadius: Double
32 | let maxValue: Double
33 | let steperSplit: Double
34 | let shrink: Bool
35 |
36 | @Binding var value: Double
37 |
38 | init(coveredRadius: Double, maxValue: Double, steperSplit: Double, value: Binding) {
39 | self.coveredRadius = coveredRadius
40 | self._value = value
41 |
42 | if maxValue > 999 {
43 | self.maxValue = maxValue / 1000
44 | self.steperSplit = steperSplit / 1000
45 | self.shrink = true
46 | } else {
47 | self.maxValue = maxValue
48 | self.steperSplit = steperSplit
49 | self.shrink = false
50 | }
51 | }
52 |
53 | private var tickCount: Double {
54 | return maxValue/steperSplit
55 | }
56 |
57 | // colormix white to red
58 | func colorMix(percent: Int) -> Color {
59 | let red = Double(percent) / 100
60 | return Color(red: red, green: 1 - red, blue: 0)
61 | }
62 |
63 | func tickText(at tick: Double, text: String) -> some View {
64 | let percent = (tick * 100) / tickCount
65 | let startAngle = coveredRadius/2 * -1 + 90
66 | let stepper = coveredRadius/Double(tickCount)
67 | let rotation = startAngle + stepper * Double(tick)
68 | return Text(text)
69 | .font(.system(size: 16, design: .rounded))
70 | .foregroundColor(colorMix(percent: Int(percent)))
71 | .rotationEffect(.init(degrees: -1 * rotation), anchor: .center)
72 | .offset(x: -60, y: 0)
73 | .rotationEffect(Angle.degrees(rotation))
74 | }
75 |
76 | func tick(at tick: Int, totalTicks: Int) -> some View {
77 | let percent = (tick * 100) / totalTicks
78 | let startAngle = coveredRadius/2 * -1
79 | let stepper = coveredRadius/Double(totalTicks)
80 | let rotation = Angle.degrees(startAngle + stepper * Double(tick))
81 | return VStack {
82 | Rectangle()
83 | .fill(colorMix(percent: percent))
84 | .frame(width: tick % 2 == 0 ? 5 : 3,
85 | height: tick % 2 == 0 ? 10 : 5) // alternet small big dash
86 | Spacer()
87 | }.rotationEffect(rotation)
88 | }
89 |
90 | var body: some View {
91 | ZStack {
92 | // Circle()
93 | // .strokeBorder(.gray, lineWidth: 2)
94 |
95 | // Circle()
96 | // .fill(Color.gray)
97 | // .frame(width: 125)
98 | // .shadow(color: Color.gray, radius: 10)
99 | // .shadow(color: Color.darkStart, radius: 10)
100 | // .blur(radius: 1.0)
101 |
102 | Text(String(format: "%.3f", $value.wrappedValue))
103 | .font(.system(size: 40, design: .rounded))
104 | .foregroundColor(.white)
105 |
106 | ForEach(0.. Double {
124 | return (value/Double(maxValue))*coveredRadius - coveredRadius/2 + 90
125 | }
126 | }
127 |
128 | struct GuageView_Previews: PreviewProvider {
129 | static var previews: some View {
130 | CustomGaugeView(coveredRadius: 250, maxValue: 80, steperSplit: 10, value: .constant(20))
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/SwiftOBD2App/Views/HomeView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HomeView.swift
3 | // SMARTOBD2
4 | //
5 | // Created by kemo konteh on 9/30/23.
6 | //
7 |
8 | import SwiftUI
9 | import SwiftOBD2
10 |
11 | struct HomeView: View {
12 | @Environment(\.colorScheme) var colorScheme
13 | @Binding var displayType: BottomSheetType
14 | @Binding var isDemoMode: Bool
15 | @Binding var statusMessage: String?
16 |
17 | var body: some View {
18 | ZStack {
19 | BackgroundView(isDemoMode: $isDemoMode)
20 | ScrollView(.vertical, showsIndicators: false) {
21 | VStack {
22 | LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 20) {
23 | SectionView(title: "Diagnostics",
24 | subtitle: "Read Vehicle Health",
25 | iconName: "wrench.and.screwdriver",
26 | destination: VehicleDiagnosticsView(displayType: $displayType, isDemoMode: $isDemoMode)
27 | )
28 |
29 | SectionView(title: "Logs",
30 | subtitle: "View Logs",
31 | iconName: "flowchart",
32 | destination: LogsView())
33 | .simultaneousGesture(TapGesture().onEnded {
34 | withAnimation {
35 | displayType = .none
36 | }
37 | })
38 | .disabled(true)
39 | .opacity(0.5)
40 | .overlay(Text("Coming Soon")
41 | .font(.caption)
42 | .foregroundColor(.white)
43 | .padding(5)
44 | .background(Color.black.opacity(0.5))
45 | .cornerRadius(5)
46 | .padding(5), alignment: .topTrailing)
47 | }
48 | .padding(20)
49 | .padding(.bottom, 20)
50 |
51 | Divider().background(Color.white).padding(.horizontal, 10)
52 | NavigationLink(destination: GarageView(displayType: $displayType,
53 | isDemoMode: $isDemoMode)) {
54 | SettingsAboutSectionView(title: "Garage", iconName: "car.circle", iconColor: .blue.opacity(0.6))
55 | }
56 | .simultaneousGesture(TapGesture().onEnded {
57 | withAnimation {
58 | displayType = .none
59 | }
60 | })
61 |
62 | NavigationLink {
63 | SettingsView(displayType: $displayType,
64 | isDemoMode: $isDemoMode)
65 | } label: {
66 | SettingsAboutSectionView(title: "Settings", iconName: "gear", iconColor: .green.opacity(0.6))
67 | }
68 | .simultaneousGesture(TapGesture().onEnded {
69 | withAnimation {
70 | displayType = .none
71 | }
72 | })
73 |
74 | NavigationLink {
75 | TestingScreen(displayType: $displayType)
76 | } label: {
77 | SettingsAboutSectionView(title: "Testing Hub", iconName: "gear", iconColor: .green.opacity(0.6))
78 | }
79 | .simultaneousGesture(TapGesture().onEnded {
80 | withAnimation {
81 | displayType = .none
82 | }
83 | })
84 | }
85 | }
86 | .safeAreaInset(edge: .bottom, spacing: 0) {
87 | HStack {
88 | Text("Powered by SMARTOBD2")
89 | .font(.caption)
90 | .foregroundColor(.white)
91 | .padding(5)
92 | }
93 | }
94 | }
95 | }
96 | }
97 |
98 | struct SettingsAboutSectionView: View {
99 | let title: String
100 | let iconName: String
101 | let iconColor: Color
102 |
103 | var body: some View {
104 | HStack {
105 | Image(systemName: iconName)
106 | .font(.system(size: 30, weight: .bold))
107 | .foregroundColor(iconColor)
108 |
109 | Text(title)
110 | .font(.system(size: 16, weight: .bold))
111 | .foregroundColor(.white)
112 |
113 | Spacer()
114 |
115 | Image(systemName: "chevron.right")
116 | .font(.system(size: 14, weight: .bold))
117 | .foregroundColor(.white)
118 |
119 | }
120 | .frame(maxWidth: .infinity, maxHeight: 400, alignment: .leading)
121 | .padding(.horizontal, 22)
122 | }
123 | }
124 |
125 | #Preview {
126 | NavigationView {
127 | HomeView(displayType: .constant(.quarterScreen), isDemoMode: .constant(true), statusMessage: .constant(nil))
128 | }.navigationViewStyle(.stack)
129 | }
130 |
--------------------------------------------------------------------------------
/SwiftOBD2App/Views/SubViews/ScrollChartView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ScrollChartView.swift
3 | // SMARTOBD2
4 | //
5 | // Created by kemo konteh on 10/29/23.
6 | //
7 |
8 | import SwiftUI
9 | import Charts
10 |
11 | struct ScrollChartView: View {
12 | private let height: CGFloat = 200
13 | private let pagingAnimationDuration: CGFloat = 0.2
14 |
15 | @State var chartContentContainerWidth: CGFloat = .zero
16 | @State private var yAxisWidth: CGFloat = .zero
17 |
18 | @GestureState private var translation: CGFloat = .zero
19 | @State private var offset: CGFloat = .zero
20 |
21 | @State var dataItem: DataItem
22 |
23 | init(dataItem: DataItem) {
24 | self.dataItem = dataItem
25 | }
26 |
27 | private var drag: some Gesture {
28 | DragGesture(minimumDistance: 0)
29 | .updating($translation) { value, state, _ in
30 | state = value.translation.width
31 | }
32 | .onEnded { value in
33 | withAnimation(.easeOut(duration: pagingAnimationDuration)) {
34 | let offset = self.offset + value.translation.width
35 | let maxOffset = chartContentContainerWidth - yAxisWidth
36 | self.offset = max(0, min(maxOffset, offset))
37 | }
38 | }
39 | }
40 |
41 | var body: some View {
42 | GeometryReader { geometry in
43 | HStack(alignment: .top, spacing: 0) {
44 | VStack(spacing: 0) {
45 | chartContent
46 | .frame(width: chartContentContainerWidth * 3, height: height)
47 | .offset(x: offset - chartContentContainerWidth)
48 | .offset(x: offset + translation)
49 | .gesture(drag)
50 | }
51 | .frame(width: chartContentContainerWidth)
52 | .clipped()
53 |
54 | chartYAxis
55 | .modifier(YAxisWidthModifier())
56 | .onPreferenceChange(YAxisWidthPreferenceyKey.self) { newValue in
57 | yAxisWidth = newValue
58 | chartContentContainerWidth = geometry.size.width - yAxisWidth
59 | }
60 | }
61 | }
62 | .frame(height: height)
63 | }
64 |
65 | var chartContent: some View {
66 | chart
67 | .chartYScale(domain: dataItem.command.properties.minValue ... dataItem.command.properties.maxValue)
68 | .chartXAxis {
69 | AxisMarks(
70 | format: .dateTime.hour().minute(),
71 | preset: .extended,
72 | values: .stride(by: .minute, roundLowerBound: true)
73 | )
74 | }
75 | .chartYAxis {
76 | AxisMarks(position: .trailing, values: .automatic(desiredCount: 4)) {
77 | AxisGridLine()
78 | }
79 | }
80 | .chartPlotStyle {
81 | $0.background(.blue.opacity(0.1))
82 | }
83 | }
84 |
85 | var chart: some View {
86 | GraphView(dataItem: dataItem,
87 | chartContentContainerWidth: $chartContentContainerWidth
88 | )
89 | }
90 |
91 | var chartYAxis: some View {
92 | chart
93 | .chartYScale(domain: dataItem.command.properties.minValue ... dataItem.command.properties.maxValue)
94 | .foregroundStyle(.clear)
95 | .chartXAxis {
96 | AxisMarks(position: .bottom, values: .automatic(desiredCount: 6))
97 | }
98 | .chartPlotStyle {
99 | $0.frame(width: 0)
100 | }
101 | }
102 | }
103 |
104 | struct GraphView: View {
105 | @State var dataItem: DataItem
106 | @State var data: [PIDMeasurement] = []
107 | @Binding var chartContentContainerWidth: CGFloat
108 | @State var upperBound: Double?
109 |
110 | var body: some View {
111 | chart
112 | }
113 |
114 | let timer = Timer.publish(
115 | every: 1,
116 | on: .main,
117 | in: .common
118 | ).autoconnect()
119 |
120 | private var chart: some View {
121 | Chart(dataItem.measurements, id: \.id) { dataPoint in
122 | LineMark(
123 | x: .value("time", dataPoint.id, unit: .second),
124 | y: .value("Value", dataPoint.value)
125 | )
126 | .lineStyle(StrokeStyle(lineWidth: 2.0))
127 | .interpolationMethod(.linear)
128 | }
129 | .onReceive(timer, perform: updateData)
130 | .onChange(of: data.count, perform: { _ in
131 | // chartContentContainerWidth = CGFloat(value) * 10
132 | })
133 | }
134 |
135 | private let measurementTimeLimit: TimeInterval = 120 // 10 minutes
136 |
137 | func updateData(_: Date) {
138 | let time: Date = Date()
139 | let value = Double.random(in: 0 ... 250)
140 | let measurement = PIDMeasurement(time: time, value: value)
141 | data.append(measurement)
142 | data = data.filter { $0.id.timeIntervalSinceNow > -self.measurementTimeLimit }
143 |
144 | }
145 | }
146 |
147 | struct YAxisWidthPreferenceyKey: PreferenceKey {
148 | static var defaultValue: CGFloat = .zero
149 | static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
150 | value += nextValue()
151 | }
152 | }
153 |
154 | struct YAxisWidthModifier: ViewModifier {
155 | func body(content: Content) -> some View {
156 | content
157 | .background(
158 | GeometryReader { geometry in
159 | Color.clear
160 | .preference(key: YAxisWidthPreferenceyKey.self,
161 | value: geometry.size.width)
162 | }
163 | )
164 | }
165 | }
166 |
167 | #Preview {
168 | ScrollChartView(dataItem: DataItem(command: .mode1(.speed),
169 | selectedGauge: .gaugeType1))
170 | }
171 |
--------------------------------------------------------------------------------
/SwiftOBD2App/Views/GarageView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GarageView.swift
3 | // SMARTOBD2
4 | //
5 | // Created by kemo konteh on 10/2/23.
6 | //
7 |
8 | import SwiftUI
9 | import SwiftOBD2
10 |
11 | struct GarageView: View {
12 | @EnvironmentObject var globalSettings: GlobalSettings
13 | @EnvironmentObject var garage: Garage
14 |
15 | @Environment(\.dismiss) var dismiss
16 | @Binding var displayType: BottomSheetType
17 | @Binding var isDemoMode: Bool
18 |
19 | @State private var isAddingVehicle = false
20 |
21 | var body: some View {
22 | ZStack {
23 | BackgroundView(isDemoMode: $isDemoMode)
24 | VStack {
25 | List(garage.garageVehicles, id: \.self, selection: $garage.currentVehicle) { vehicle in
26 | HStack {
27 | VStack(alignment: .leading, spacing: 10) {
28 | Text(vehicle.make)
29 | .font(.system(size: 20, weight: .bold, design: .default))
30 | .foregroundColor(.white)
31 |
32 | Text(vehicle.model)
33 | .font(.system(size: 14, weight: .bold, design: .default))
34 | .foregroundColor(.white)
35 |
36 | Text(vehicle.year)
37 | .font(.system(size: 14, weight: .semibold))
38 | .foregroundColor(.white)
39 | }
40 | Spacer()
41 | if garage.currentVehicle?.id == vehicle.id {
42 | Image(systemName: "checkmark.circle.fill")
43 | .foregroundColor(.white)
44 | .font(.system(size: 20))
45 | }
46 | }
47 | .listRowBackground(garage.currentVehicle?.id == vehicle.id ? Color.blue : Color.clear)
48 | .swipeActions(edge: .trailing, allowsFullSwipe: true) {
49 | Button("Delete", role: .destructive) {
50 | garage.deleteVehicle(vehicle)
51 | }
52 | }
53 | }
54 | .padding(.top, 25)
55 | .listStyle(.inset)
56 | .scrollContentBackground(.hidden)
57 | }
58 | }
59 | .navigationBarBackButtonHidden(true)
60 | .toolbar {
61 | ToolbarItem(placement: .navigationBarLeading) {
62 | Button {
63 | displayType = .quarterScreen
64 | dismiss()
65 | } label: {
66 | HStack {
67 | Image(systemName: "chevron.backward")
68 | Text("Back")
69 | }
70 | }
71 | }
72 |
73 | ToolbarItem(placement: .navigationBarTrailing) {
74 | Button("Add Vehicle", role: .none) {
75 | isAddingVehicle = true
76 | }
77 | .buttonStyle(.bordered)
78 | .shadow(color: .black.opacity(0.5), radius: 10, x: 0, y: 5)
79 | }
80 | }
81 | .gesture(DragGesture().onEnded({
82 | if $0.translation.width > 100 {
83 | displayType = .quarterScreen
84 | dismiss()
85 | }
86 | }))
87 | .sheet(isPresented: $isAddingVehicle) {
88 | AddVehicleView(isPresented: $isAddingVehicle)
89 | }
90 | }
91 | }
92 |
93 | #Preview {
94 | NavigationView {
95 | GarageView(displayType: .constant(.quarterScreen),
96 | isDemoMode: .constant(false))
97 | .background(LinearGradient(.darkStart, .darkEnd))
98 | .environmentObject(GlobalSettings())
99 | .environmentObject(Garage())
100 | }
101 | }
102 |
103 | // class MockGarage: ObservableObject, GarageProtocol {
104 | // @Published var garageVehicles: [Vehicle]
105 | // @Published var currentVehicleId: Int? {
106 | // didSet {
107 | // if let currentVehicleId = currentVehicleId {
108 | // UserDefaults.standard.set(currentVehicleId, forKey: "currentCarId")
109 | // }
110 | // }
111 | // }
112 | //
113 | // var currentVehicleIdPublisher: Published.Publisher { $currentVehicleId }
114 | //
115 | // @Published var currentVehicle: Vehicle?
116 | //
117 | // init() {
118 | // self.garageVehicles = [Vehicle(id: 1,
119 | // make: "Toyota",
120 | // model: "Camry",
121 | // year: "2019",
122 | // obdinfo: OBDInfo(vin: "",
123 | // supportedPIDs: [OBDCommand.mode1(.speed),
124 | // OBDCommand.mode1(.coolantTemp),
125 | // OBDCommand.mode1(.fuelPressure),
126 | // OBDCommand.mode1(.fuelLevel),
127 | // OBDCommand.mode1(.barometricPressure),
128 | // OBDCommand.mode1(.fuelType),
129 | // OBDCommand.mode1(.ambientAirTemp),
130 | // OBDCommand.mode1(.engineOilTemp),
131 | // OBDCommand.mode1(.engineLoad),
132 | // ],
133 | // obdProtocol: .NONE,
134 | // ecuMap: [:])
135 | // ),
136 | // Vehicle(id: 2,
137 | // make: "Nissan",
138 | // model: "Altima",
139 | // year: "2019")
140 | // ]
141 | // self.currentVehicleId = currentVehicleId
142 | // self.currentVehicle = garageVehicles[0]
143 | // }
144 | //
145 | // func setCurrentVehicle(by id: Int) {
146 | // self.currentVehicleId = id
147 | // self.currentVehicle = garageVehicles.first(where: { $0.id == id })
148 | // print("setting")
149 | // }
150 | //
151 | // func deleteVehicle(_ car: Vehicle) {
152 | // print("Deleting \(car)")
153 | // }
154 | // }
155 |
--------------------------------------------------------------------------------
/SMARTOBD2Tests/UnitTestingBootcampViewModelTest.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BleCommTest.swift
3 | // SMARTOBD2Tests
4 | //
5 | // Created by kemo konteh on 10/18/23.
6 | //
7 |
8 | import XCTest
9 | @testable import SMARTOBD2
10 | import Combine
11 |
12 | // Naming Structure: test_UnitOfWork_StateUnderTest_ExpectedBehavior
13 | // Naming Structure: test_[struct or class]_[variable or function]_[expected result]
14 |
15 | // Testing Struture: Given, When, Then
16 |
17 | final class UnitTestingBootcampViewModelTest: XCTestCase {
18 | var viewModel: UnitTestingBootcampViewModel?
19 | var cancellables = Set()
20 |
21 | override func setUpWithError() throws {
22 | // Put setup code here. This method is called before the invocation of each test method in the class.
23 | viewModel = UnitTestingBootcampViewModel(isPremium: Bool.random())
24 | }
25 |
26 | override func tearDownWithError() throws {
27 | // Put teardown code here. This method is called after the invocation of each test method in the class.
28 | viewModel = nil
29 | }
30 |
31 | func test_UnitTestingBootcampViewModel_isPremium_shouldBeTrue() {
32 | // Given
33 | let userIsPremium: Bool = true
34 | // When
35 | let vm = UnitTestingBootcampViewModel(isPremium: userIsPremium)
36 | // Then
37 | XCTAssertTrue(vm.isPremium)
38 | }
39 |
40 | func test_UnitTestingBootcampViewModel_isPremium_shouldBeFalse() {
41 | // Given
42 | let userIsPremium: Bool = false
43 | // When
44 | let vm = UnitTestingBootcampViewModel(isPremium: userIsPremium)
45 | // Then
46 | XCTAssertFalse(vm.isPremium)
47 | }
48 |
49 | func test_UnitTestingBootcampViewModel_isPremium_shouldBeInjectedValue() {
50 | // Given
51 | let userIsPremium: Bool = Bool.random()
52 | // When
53 | let vm = UnitTestingBootcampViewModel(isPremium: userIsPremium)
54 | // Then
55 | XCTAssertEqual(userIsPremium, vm.isPremium)
56 | }
57 |
58 | func test_UnitTestingBootcampViewModel_isPremium_shouldBeInjectedValue_stress() {
59 | for _ in 0..<100 {
60 | // Given
61 | let userIsPremium: Bool = Bool.random()
62 | // When
63 | let vm = UnitTestingBootcampViewModel(isPremium: userIsPremium)
64 | // Then
65 | XCTAssertEqual(userIsPremium, vm.isPremium)
66 | }
67 | }
68 |
69 | func test_UnitTestingBootcampViewModel_dataArray_shouldBeEmpty() {
70 | // Given
71 | guard let vm = viewModel else {
72 | XCTFail("View model should not be nil")
73 | return
74 | }
75 | // When
76 |
77 | // Then
78 | XCTAssertTrue(vm.dataArray.isEmpty)
79 | XCTAssertEqual(vm.dataArray.count, 0)
80 | }
81 |
82 | func test_UnitTestingBootcampViewModel_dataArray_shouldAddItems() {
83 | // Given
84 | let vm = UnitTestingBootcampViewModel(isPremium: Bool.random())
85 |
86 | // When
87 | let loopCount = Int.random(in: 1...100)
88 | for _ in 0..,
37 | statusMessage: Binding,
38 | isDemoMode: Binding
39 | ) {
40 | self._displayType = displayType
41 | self._statusMessage = statusMessage
42 | self._isDemoMode = isDemoMode
43 | }
44 |
45 | var body: some View {
46 | ZStack(alignment: .topTrailing) {
47 | BackgroundView(isDemoMode: $isDemoMode)
48 | VStack {
49 | headerButtons
50 | Picker("Display Mode", selection: $displayMode) {
51 | Text("Gauges").tag(DataDisplayMode.gauges)
52 | Text("Graphs").tag(DataDisplayMode.graphs)
53 | }
54 | .pickerStyle(SegmentedPickerStyle())
55 | .padding(.bottom, 20)
56 |
57 | switch displayMode {
58 | case .gauges:
59 | if !enLarge {
60 | gaugeView
61 | } else {
62 | gaugePicker
63 | }
64 |
65 | case .graphs:
66 | ScrollView(.vertical, showsIndicators: false) {
67 | ForEach(viewModel.pidData) { dataItem in
68 | Text(dataItem.command.properties.description +
69 | " " +
70 | String(dataItem.value) +
71 | " " +
72 | (dataItem.unit ?? "")
73 | )
74 | ScrollChartView(dataItem: dataItem)
75 | }
76 | }
77 | .frame(maxWidth: .infinity, alignment: .leading)
78 | .padding(.horizontal, 15)
79 | }
80 | }
81 | }
82 | .sheet(isPresented: $showingSheet) {
83 | AddPIDView(viewModel: viewModel)
84 | }
85 | .onDisappear {
86 | viewModel.saveDataItems()
87 | }
88 | }
89 |
90 | private var gaugeView: some View {
91 | LazyVGrid(columns: columns) {
92 | ForEach(viewModel.pidData) { dataItem in
93 | GaugeView(dataItem: dataItem,
94 | selectedGauge: nil
95 | )
96 | .onLongPressGesture(minimumDuration: 0.5, maximumDistance: 0.0) {
97 | selectedPID = dataItem
98 | withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) {
99 | enLarge.toggle()
100 | }
101 | }
102 | }
103 | }
104 | .matchedGeometryEffect(id: "Gauge", in: namespace)
105 | }
106 |
107 | private var gaugePicker: some View {
108 | GaugePickerView(viewModel: viewModel,
109 | enLarge: $enLarge,
110 | selectedPID: $selectedPID,
111 | namespace: namespace
112 | )
113 | }
114 |
115 | private var headerButtons: some View {
116 | HStack(alignment: .top) {
117 | Button(action: toggleRequestingPIDs) {
118 | Text(isRequesting ? "Stop" : "Start").font(.title)
119 | }
120 | Spacer()
121 | Button(action: { showingSheet.toggle() }) {
122 | Image(systemName: "plus.circle.fill")
123 | .font(.system(size: 30, weight: .bold))
124 | .foregroundColor(.white)
125 | }
126 | }
127 | .padding(.horizontal)
128 | }
129 |
130 | private func toggleRequestingPIDs() {
131 | guard obdService.connectionState == .connectedToVehicle else {
132 | statusMessage = "Not Connected"
133 | toggleDisplayType(to: .halfScreen)
134 | DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
135 | withAnimation {
136 | statusMessage = nil
137 | }
138 | }
139 | return
140 | }
141 | switch viewModel.isRequesting {
142 | case true:
143 | stopRequestingPIDs()
144 | case false:
145 | startRequestingPIDs()
146 | }
147 | }
148 |
149 | func startRequestingPIDs() {
150 | guard viewModel.isRequestingPids == false else {
151 | return
152 | }
153 |
154 | viewModel.isRequestingPids = true
155 | toggleDisplayType(to: .none)
156 |
157 | UIApplication.shared.isIdleTimerDisabled = true
158 |
159 | obdService.startContinuousUpdates(viewModel.pidData.map { $0.command })
160 | .receive(on: DispatchQueue.main)
161 | .sink(receiveCompletion: { completion in
162 | switch completion {
163 | case .finished:
164 | print("Finished")
165 | case .failure(let error):
166 | print("Error: \(error)")
167 | // stopRequestingPIDs()
168 | }
169 | }, receiveValue: { measurements in
170 | updateDataItems(measurements: measurements)
171 | })
172 | .store(in: &viewModel.cancellables)
173 | }
174 |
175 | func updateDataItems(measurements: [OBDCommand: MeasurementResult]) {
176 | for (pid, measurement) in measurements {
177 | if let pid = viewModel.pidData.first(where: { $0.command == pid }) {
178 | pid.update(measurement.value)
179 | }
180 | }
181 | }
182 |
183 | func stopRequestingPIDs() {
184 | guard viewModel.isRequestingPids == true else {
185 | return
186 | }
187 | UIApplication.shared.isIdleTimerDisabled = false
188 | viewModel.isRequestingPids = false
189 | viewModel.cancellables.removeAll()
190 | toggleDisplayType(to: .quarterScreen)
191 | }
192 |
193 | private func toggleDisplayType(to displayType: BottomSheetType) {
194 | withAnimation(.interactiveSpring(response: 0.5,
195 | dampingFraction: 0.8,
196 | blendDuration: 0)
197 | ) {
198 | globalSettings.displayType = displayType
199 | }
200 | }
201 | }
202 |
203 | #Preview {
204 | LiveDataView(displayType: .constant(.quarterScreen),
205 | statusMessage: .constant(""),
206 | isDemoMode: .constant(false))
207 | .environmentObject(GlobalSettings())
208 | .environmentObject(OBDService())
209 | }
210 |
--------------------------------------------------------------------------------
/SwiftOBD2App/Views/SubViews/GaugePickerView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GaugePickerView.swift
3 | // SMARTOBD2
4 | //
5 | // Created by kemo konteh on 10/29/23.
6 | //
7 |
8 | import SwiftUI
9 | import SwiftOBD2
10 |
11 | enum GaugeType: String, CaseIterable, Identifiable, Codable {
12 | case gaugeType1
13 | case gaugeType2
14 | case gaugeType3
15 | case gaugeType4
16 | // Add more gauge types as needed
17 | var id: Int {
18 | switch self {
19 | case .gaugeType1:
20 | 0
21 | case .gaugeType2:
22 | 1
23 | case .gaugeType3:
24 | 2
25 | case .gaugeType4:
26 | 3
27 | }
28 | }
29 | }
30 |
31 | struct GaugePickerView: View {
32 | @ObservedObject var viewModel: LiveDataViewModel
33 | @Binding var enLarge: Bool
34 |
35 | @Binding var selectedPID: DataItem?
36 | var namespace: Namespace.ID?
37 |
38 | @State private var currentIndex: Int = 0
39 |
40 | var body: some View {
41 | GeometryReader { geometry in
42 | let cardWidth = geometry.size.width * 0.7
43 | let cardHeight = cardWidth * 1.5
44 |
45 | VStack {
46 | if let dataItem = selectedPID {
47 | ZStack {
48 | ForEach(GaugeType.allCases, id: \.self) { gaugeType in
49 | GaugeView(
50 | dataItem: dataItem,
51 | selectedGauge: gaugeType
52 | )
53 | .frame(width: cardWidth, height: cardHeight)
54 | .offset(x: CGFloat(gaugeType.id - currentIndex) * (geometry.size.width * 0.6))
55 | .background(Color.clear)
56 | }
57 | }
58 | .gesture(
59 | DragGesture()
60 | .onEnded { value in
61 | let cardWidth = geometry.size.width * 0.3
62 | let offset = value.translation.width / cardWidth
63 |
64 | withAnimation(.spring()) {
65 | if value.translation.width < -offset {
66 | currentIndex = min(currentIndex + 1, GaugeType.allCases.count - 1)
67 | } else if value.translation.width > offset {
68 | currentIndex = max(currentIndex - 1, 0)
69 | }
70 | }
71 | }
72 | )
73 |
74 | Button {
75 | dataItem.selectedGauge = GaugeType.allCases[currentIndex]
76 | withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) {
77 | enLarge.toggle()
78 | }
79 | } label: {
80 | Text("Exit")
81 | .font(.caption)
82 | }
83 | } else {
84 | EmptyView() // Display an empty view when selectedGauge is nil
85 | }
86 | }
87 | .frame(maxWidth: .infinity, maxHeight: .infinity)
88 | .matchedGeometryEffect(id: "Gauge", in: namespace ?? Namespace().wrappedValue)
89 | }
90 | }
91 | }
92 |
93 | struct GaugeView: View {
94 | @ObservedObject var dataItem: DataItem
95 |
96 | var selectedGauge: GaugeType?
97 |
98 | var body: some View {
99 | gaugeView(for: selectedGauge ?? dataItem.selectedGauge ?? .gaugeType2)
100 | }
101 |
102 | @ViewBuilder
103 | func gaugeView(for gaugeType: GaugeType) -> some View {
104 | switch gaugeType {
105 | case .gaugeType1:
106 | GaugeType1(dataItem: dataItem)
107 | case .gaugeType2:
108 | GaugeType2(dataItem: dataItem)
109 | case .gaugeType3:
110 | GaugeType3(dataItem: dataItem)
111 | case .gaugeType4:
112 | GaugeType4(dataItem: dataItem)
113 | }
114 | }
115 | }
116 |
117 | struct GaugeType1: View {
118 | @ObservedObject var dataItem: DataItem
119 |
120 | var body: some View {
121 | Gauge(value: dataItem.value,
122 | in: 0...Double(dataItem.command.properties.maxValue)
123 | ) {
124 | Text( dataItem.command.properties.description)
125 | .font(.caption)
126 | } currentValueLabel: {
127 | Text(String(format: "%5.2f", dataItem.value) + " " + (dataItem.unit ?? ""))
128 | }
129 | .gaugeStyle(CustomGaugeView2())
130 | }
131 | }
132 |
133 | struct GaugeType2: View {
134 | @ObservedObject var dataItem: DataItem
135 | let gradient = Gradient(colors: [.blue, .green, .pink])
136 |
137 | var body: some View {
138 | Gauge(value: dataItem.value,
139 | in: 0...Double(dataItem.command.properties.maxValue)
140 | ) {
141 | Text( dataItem.command.properties.description)
142 | .font(.caption)
143 | } currentValueLabel: {
144 | Text(String(dataItem.value) + " " + (dataItem.unit ?? ""))
145 | }
146 | .gaugeStyle(.accessoryLinear)
147 | .tint(gradient)
148 | .frame(width: 150)
149 | }
150 | }
151 |
152 | struct GaugeType3: View {
153 | @ObservedObject var dataItem: DataItem
154 |
155 | var body: some View {
156 | Gauge(value: dataItem.value, in: 0...Double(dataItem.command.properties.maxValue)) {
157 | Text(dataItem.command.properties.description)
158 | .font(.caption)
159 | } currentValueLabel: {
160 | Text(String(dataItem.value) + " " + (dataItem.unit ?? ""))
161 | }
162 | .gaugeStyle(.linearCapacity)
163 | .frame(width: 150)
164 | }
165 | }
166 |
167 | struct GaugeType4: View {
168 | @ObservedObject var dataItem: DataItem
169 |
170 | var body: some View {
171 | Gauge(value: dataItem.value, in: 0...Double(dataItem.command.properties.maxValue)) {
172 | Text( dataItem.command.properties.description)
173 | .font(.caption)
174 | } currentValueLabel: {
175 | Text(String(dataItem.value) + " " + (dataItem.unit ?? ""))
176 | }
177 | .gaugeStyle(SpeedometerGaugeStyle())
178 | }
179 | }
180 | struct CustomGaugeView2: GaugeStyle {
181 | private var purpleGradient = LinearGradient(gradient: Gradient(colors: [ Color(red: 207/255, green: 150/255, blue: 207/255), Color(red: 107/255, green: 116/255, blue: 179/255) ]), startPoint: .trailing, endPoint: .leading)
182 |
183 | func makeBody(configuration: Configuration) -> some View {
184 | ZStack {
185 | Circle()
186 | .trim(from: 0.74 * configuration.value, to: 0.75 * configuration.value)
187 | .stroke(purpleGradient,
188 | style: StrokeStyle(lineWidth: 20,
189 | lineCap: .round,
190 | lineJoin: .round,
191 | dash: [20, 12],
192 | dashPhase: 2.0
193 | )
194 | )
195 | .rotationEffect(.degrees(135))
196 |
197 | Circle()
198 | .trim(from: 0, to: 0.75)
199 | .stroke(Color.white,
200 | style: StrokeStyle(lineWidth: 20,
201 | lineCap: .butt,
202 | lineJoin: .bevel,
203 | dash: [4, 32],
204 | dashPhase: 2.0))
205 | .rotationEffect(.degrees(135))
206 |
207 | VStack {
208 | configuration.currentValueLabel
209 | .font(.system(size: 20, design: .rounded))
210 | .foregroundColor(.white)
211 |
212 | configuration.label
213 | .font(.system(size: 20, weight: .bold, design: .rounded))
214 | .foregroundColor(.gray)
215 | }
216 | // var tickCount: Double {
217 | // return configuration.maximumValueLabel
218 | // }
219 | // ForEach(0.. some View {
230 | ZStack {
231 |
232 | Circle()
233 | .foregroundColor(Color(.systemGray2))
234 |
235 | Circle()
236 | .trim(from: 0, to: 0.75 * configuration.value)
237 | .stroke(purpleGradient, lineWidth: 20)
238 | .rotationEffect(.degrees(135))
239 |
240 | Circle()
241 | .trim(from: 0, to: 0.75)
242 | .stroke(Color.white, style: StrokeStyle(lineWidth: 10, lineCap: .butt, lineJoin: .round, dash: [1, 34], dashPhase: 0.0))
243 | .rotationEffect(.degrees(135))
244 |
245 | VStack {
246 | configuration.currentValueLabel
247 | .font(.system(size: 28, weight: .bold, design: .rounded))
248 | .foregroundColor(.white)
249 |
250 | configuration.label
251 | .font(.system(size: 18, weight: .bold, design: .rounded))
252 | .foregroundColor(.white)
253 |
254 | }
255 | }
256 | .frame(width: 165, height: 165)
257 | }
258 | }
259 |
260 | #Preview {
261 | // GaugePickerView(viewModel: LiveDataViewModel(obdService: OBDService(bleManager: BLEManager()),
262 | // garage: Garage()),
263 | // enLarge: .constant(false),
264 | // selectedPID: .constant(nil),
265 | // namespace: nil
266 | // )
267 | GaugeType2(dataItem: DataItem(command: OBDCommand.mode1(.speed),
268 | value: 10,
269 | selectedGauge: .gaugeType4,
270 | measurements: []))
271 | }
272 |
--------------------------------------------------------------------------------
/SwiftOBD2App/Views/SubViews/AddVehicleView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AddVehicleView.swift
3 | // SMARTOBD2
4 | //
5 | // Created by kemo konteh on 10/10/23.
6 | //
7 |
8 | import SwiftUI
9 | import SwiftOBD2
10 |
11 | struct Manufacturer: Codable, Hashable {
12 | let make: String
13 | let models: [Model]
14 | }
15 |
16 | struct Model: Codable, Hashable {
17 | let name: String
18 | let years: [String]
19 | }
20 |
21 | class AddVehicleViewModel: ObservableObject {
22 | @Published var carData: [Manufacturer]?
23 | @Published var showError = false
24 |
25 | init() {
26 | do {
27 | try fetchData()
28 | showError = false
29 | } catch {
30 | showError = true
31 | }
32 | }
33 |
34 | func fetchData() throws {
35 | let url = Bundle.main.url(forResource: "Cars", withExtension: "json")!
36 | let data = try Data(contentsOf: url)
37 | self.carData = try JSONDecoder().decode([Manufacturer].self, from: data)
38 | }
39 | }
40 |
41 | struct AddVehicleView: View {
42 | @Binding var isPresented: Bool
43 |
44 | var body: some View {
45 | NavigationView {
46 | ZStack {
47 | BackgroundView(isDemoMode: .constant(false))
48 | VStack {
49 | List {
50 | NavigationLink(destination: AutoAddVehicleView(isPresented: $isPresented)) {
51 | Text("Auto-detect Vehicle")
52 | }
53 | .listRowBackground(Color.darkStart.opacity(0.3))
54 |
55 | NavigationLink(destination: ManuallyAddVehicleView(isPresented: $isPresented)) {
56 | Text( "Manually Add Vehicle")
57 | }
58 | .listRowBackground(Color.darkStart.opacity(0.3))
59 | }
60 | .scrollContentBackground(.hidden)
61 |
62 | }
63 | }
64 | .navigationTitle("Add Vehicle")
65 | .navigationBarTitleDisplayMode(.inline)
66 | }
67 | }
68 | }
69 |
70 | struct AutoAddVehicleView: View {
71 | @EnvironmentObject var garage: Garage
72 | @EnvironmentObject var obdService: OBDService
73 | @Binding var isPresented: Bool
74 | @State var statusMessage: String = ""
75 | @State var isLoading: Bool = false
76 |
77 | let notificationFeedback = UINotificationFeedbackGenerator()
78 | let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
79 |
80 | var body: some View {
81 | ZStack(alignment: .center) {
82 | BackgroundView(isDemoMode: .constant(false))
83 | VStack(alignment: .center, spacing: 10) {
84 | Text("Before you start")
85 | .font(.title)
86 | Text("Plug in the scanner to the OBD port\nTurn on your vehicles engine\nMake sure that Bluetooth is on")
87 | .font(.subheadline)
88 | .multilineTextAlignment(.center)
89 |
90 | detectButton
91 | }
92 | .padding(30)
93 | .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20))
94 | .shadow(color: .black.opacity(0.5), radius: 10, x: 0, y: 5)
95 | .padding(.bottom, 40)
96 | }
97 | }
98 |
99 | var detectButton: some View {
100 | VStack {
101 | Text(statusMessage)
102 | .lineLimit(3)
103 | .multilineTextAlignment(.leading)
104 | .font(.system(size: 18, weight: .bold, design: .rounded))
105 | .padding(.bottom)
106 |
107 | if isLoading {
108 | ProgressView()
109 | .progressViewStyle(CircularProgressViewStyle())
110 | .scaleEffect(2.0, anchor: .center)
111 | } else {
112 | Button {
113 | impactFeedback.prepare()
114 | impactFeedback.impactOccurred()
115 | detectVehicle()
116 | } label: {
117 | Text("Detect Vehicle")
118 | .padding(10)
119 | }
120 | .buttonStyle(.bordered)
121 | .shadow(color: .black.opacity(0.5), radius: 10, x: 0, y: 5)
122 | }
123 | }
124 | .frame(maxHeight: 200)
125 | }
126 |
127 | func detectVehicle() {
128 | isLoading = true
129 | notificationFeedback.prepare()
130 |
131 | Task {
132 | do {
133 | guard let vinInfo = try await connect() else {
134 | DispatchQueue.main.async {
135 | statusMessage = "Vehicle Not Detected"
136 | isLoading = false
137 | }
138 | return
139 | }
140 | DispatchQueue.main.async {
141 | statusMessage = "Found Vehicle"
142 | notificationFeedback.notificationOccurred(.success)
143 | }
144 | DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
145 | statusMessage = "Make: \(vinInfo.Make)\nModel: \(vinInfo.Model)\nYear: \(vinInfo.ModelYear)"
146 | }
147 | DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
148 | isLoading = false
149 | isPresented = false
150 | }
151 | } catch {
152 | DispatchQueue.main.async {
153 | statusMessage = "Error: \(error.localizedDescription)"
154 | isLoading = false
155 | }
156 | }
157 | }
158 | }
159 |
160 | func connect() async throws -> VINInfo? {
161 | let obdInfo = try await obdService.startConnection()
162 |
163 | guard let vin = obdInfo.vin else {
164 | return nil
165 | }
166 |
167 | guard let vinInfo = try await getVINInfo(vin: vin).Results.first else {
168 | return nil
169 | }
170 |
171 | return vinInfo
172 | }
173 | }
174 |
175 | struct ManuallyAddVehicleView: View {
176 | @ObservedObject var viewModel = AddVehicleViewModel()
177 | @Binding var isPresented: Bool
178 |
179 | var body: some View {
180 | ZStack {
181 | BackgroundView(isDemoMode: .constant(false))
182 | if let carData = viewModel.carData {
183 | List {
184 | ForEach(carData.sorted(by: { $0.make < $1.make }), id: \.self) { manufacturer in
185 | NavigationLink(
186 | destination: ModelView(isPresented: $isPresented,
187 | manufacturer: manufacturer),
188 | label: {
189 | Text(manufacturer.make)
190 | })
191 | }
192 | .listRowBackground(Color.darkStart.opacity(0.3))
193 | }
194 | .scrollContentBackground(.hidden)
195 | .listStyle(.inset)
196 | } else {
197 | ProgressView()
198 | }
199 | }
200 | .navigationTitle("Select Make")
201 | .navigationBarTitleDisplayMode(.inline)
202 | }
203 | }
204 |
205 | struct ModelView: View {
206 | @State var selectedModel: Model?
207 | @Binding var isPresented: Bool
208 |
209 | let manufacturer: Manufacturer
210 |
211 | var body: some View {
212 | ZStack {
213 | BackgroundView(isDemoMode: .constant(false))
214 | List {
215 | ForEach(manufacturer.models.sorted(by: { $0.name < $1.name }), id: \.self) { carModel in
216 | NavigationLink(
217 | destination: YearView(isPresented: $isPresented,
218 | carModel: carModel,
219 | manufacturer: manufacturer),
220 | label: {
221 | Text(carModel.name)
222 | })
223 | }
224 | .listRowBackground(Color.darkStart.opacity(0.3))
225 | }
226 | .scrollContentBackground(.hidden)
227 | .listStyle(.inset)
228 | }
229 | .navigationBarTitleDisplayMode(.inline)
230 | .scrollContentBackground(.hidden)
231 | }
232 | }
233 |
234 | struct YearView: View {
235 | @Binding var isPresented: Bool
236 |
237 | let carModel: Model
238 | let manufacturer: Manufacturer
239 |
240 | var body: some View {
241 | ZStack {
242 | BackgroundView(isDemoMode: .constant(false))
243 | List {
244 | ForEach(carModel.years.sorted(by: { $0 > $1 }), id: \.self) { year in
245 | NavigationLink(
246 | destination: ConfirmView(isPresented: $isPresented,
247 | carModel: carModel,
248 | manufacturer: manufacturer,
249 | year: year),
250 | label: {
251 | Text("\(year)")
252 | .font(.headline)
253 | })
254 | }
255 | .listRowBackground(Color.darkStart.opacity(0.3))
256 | }
257 | .scrollContentBackground(.hidden)
258 | .listStyle(.inset)
259 | }
260 | .navigationBarTitle(manufacturer.make + " " + carModel.name)
261 | .navigationBarTitleDisplayMode(.inline)
262 | }
263 | }
264 |
265 | struct ConfirmView: View {
266 | @EnvironmentObject var garage: Garage
267 | @Binding var isPresented: Bool
268 |
269 | let carModel: Model
270 | let manufacturer: Manufacturer
271 | let year: String
272 |
273 | var body: some View {
274 | ZStack {
275 | BackgroundView(isDemoMode: .constant(false))
276 | VStack {
277 | Text("\(year) \(manufacturer.make) \(carModel.name)")
278 | .font(.title)
279 | .padding()
280 | Button {
281 | garage.addVehicle(
282 | make: manufacturer.make,
283 | model: carModel.name,
284 | year: year
285 | )
286 | isPresented = false
287 |
288 | } label: {
289 | VStack {
290 | Text("Add Vehicle")
291 | }
292 | .frame(width: 200, height: 50)
293 | }
294 | }
295 | }
296 | }
297 | }
298 |
299 | #Preview {
300 | AddVehicleView(isPresented: .constant(true))
301 | .environmentObject(GlobalSettings())
302 | .environmentObject(Garage())
303 |
304 | }
305 |
306 | struct BackgroundView: View {
307 | @Binding var isDemoMode: Bool
308 |
309 | var body: some View {
310 | LinearGradient(Color.darkStart.opacity(0.8), .darkEnd.opacity(0.4))
311 | .ignoresSafeArea()
312 |
313 | if isDemoMode {
314 | ZStack {
315 | Text("Demo Mode")
316 | .font(.system(size: 40, weight: .semibold)) // Reduced font size
317 | .foregroundColor(Color.charcoal.opacity(0.2))
318 | .offset(y: -5)
319 | .shadow(color: .black, radius: 5, x: 3, y: 3) // Softened shadow
320 | .rotationEffect(.degrees(-30))
321 |
322 | Text("Demo Mode")
323 | .font(.system(size: 40, weight: .semibold)) // Reduced font size
324 | .foregroundColor(Color.black.opacity(0.2))
325 | .offset(y: 2)
326 | .rotationEffect(.degrees(-30))
327 | }
328 | }
329 | }
330 | }
331 |
--------------------------------------------------------------------------------
/SwiftOBD2App/CustomTabNavigator/CustomTabBarView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CustomTabBarView.swift
3 | // SMARTOBD2
4 | //
5 | // Created by kemo konteh on 10/4/23.
6 | //
7 |
8 | import SwiftUI
9 | import SwiftOBD2
10 |
11 | enum BottomSheetType {
12 | case fullScreen
13 | case halfScreen
14 | case quarterScreen
15 | case none
16 | }
17 |
18 | struct CustomTabBarView: View {
19 | @State var isLoading = false
20 | @Binding var displayType: BottomSheetType
21 |
22 | @State var localSelection: TabBarItem
23 | @State var whiteStreakProgress: CGFloat = 0.0
24 | @State private var shouldGrow = false
25 |
26 | @Binding var selection: TabBarItem
27 | @GestureState var gestureOffset: CGFloat = 0
28 | @Namespace private var namespace
29 |
30 | let maxHeight: CGFloat
31 | let backgroundView: Content
32 | let tabs: [TabBarItem]
33 |
34 | @Binding var statusMessage: String?
35 | @EnvironmentObject var obdService: OBDService
36 | @EnvironmentObject var garage: Garage
37 |
38 | init(
39 | tabs: [TabBarItem],
40 | selection: Binding,
41 | maxHeight: CGFloat,
42 | displayType: Binding,
43 | statusMessage: Binding,
44 | @ViewBuilder backgroundView: () -> Content
45 | ) {
46 | self.tabs = tabs
47 | self._selection = selection
48 | self._localSelection = State(initialValue: selection.wrappedValue)
49 | self.maxHeight = maxHeight
50 | self._displayType = displayType
51 | self._statusMessage = statusMessage
52 | self.backgroundView = backgroundView()
53 | }
54 |
55 | private var offset: CGFloat {
56 | switch displayType {
57 | case .fullScreen:
58 | return maxHeight * 0.02
59 | case .halfScreen:
60 | return maxHeight * 0.60
61 | case .quarterScreen:
62 | return maxHeight * 0.90
63 | case .none:
64 | return maxHeight * 1
65 | }
66 | }
67 |
68 | @State private var showAddCarScreen = false
69 |
70 | var body: some View {
71 | NavigationStack {
72 | ZStack {
73 | backgroundView
74 |
75 | if displayType != .none {
76 | VStack(spacing: 35) {
77 | tabBar
78 | .frame(maxHeight: maxHeight * 0.1)
79 | .onChange(of: selection, perform: { value in
80 | withAnimation(.easeInOut) {
81 | localSelection = value
82 | }})
83 |
84 | carInfoView
85 | .padding(.horizontal)
86 | .frame(maxWidth: .infinity, maxHeight: maxHeight * 0.4 - maxHeight * 0.1)
87 |
88 | }
89 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
90 | .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 10))
91 | .shadow(color: .black.opacity(0.5), radius: 10, x: 0, y: 5)
92 | .offset(y: max(self.offset + self.gestureOffset, 0))
93 | .animation(.interactiveSpring(
94 | response: 0.5,
95 | dampingFraction: 0.8,
96 | blendDuration: 0),
97 | value: gestureOffset
98 | )
99 | .gesture(
100 | DragGesture()
101 | .updating($gestureOffset, body: { value, out, _ in
102 | out = value.translation.height})
103 | .onEnded({ value in
104 | let snapDistanceFullScreen = self.maxHeight * 0.60
105 | let snapDistanceHalfScreen = self.maxHeight * 0.85
106 | if value.location.y <= snapDistanceFullScreen {
107 | displayType = .fullScreen
108 | } else if value.location.y > snapDistanceFullScreen &&
109 | value.location.y <= snapDistanceHalfScreen {
110 | displayType = .halfScreen
111 | if obdService.connectionState == .connectedToVehicle {
112 | animateWhiteStreak()
113 | }
114 | } else {
115 | displayType = .quarterScreen
116 | }}))
117 | if obdService.connectionState != .connectedToVehicle {
118 | connectButton
119 | .offset(y: self.offset + self.gestureOffset - maxHeight * 0.5)
120 | .animation(.interactiveSpring(response: 0.5,
121 | dampingFraction: 0.8,
122 | blendDuration: 0),
123 | value: gestureOffset
124 | )
125 | }
126 | }
127 | }
128 | .navigationDestination(isPresented: $showAddCarScreen) {
129 | ManuallyAddVehicleView(isPresented: $showAddCarScreen)
130 | }
131 | .onAppear {
132 | if garage.currentVehicle == nil {
133 | statusMessage = "No Vehicle Selected"
134 | }
135 | }
136 | }
137 | }
138 |
139 | // Blur Radius for BG...
140 | func getOpacityRadius() -> CGFloat {
141 | let progress = (offset + gestureOffset) / ((UIScreen.main.bounds.height) * 0.50)
142 | return progress
143 | }
144 |
145 | func getBlurRadius() -> CGFloat {
146 | let progress = 1 - (offset + gestureOffset) / (UIScreen.main.bounds.height * 0.50)
147 |
148 | return progress * 30
149 | }
150 | }
151 |
152 | extension CustomTabBarView {
153 | private var connectButton: some View {
154 | Button(action: {
155 | connectButtonAction()
156 | }) {
157 | ZStack {
158 | if !isLoading {
159 | Text("START")
160 | .font(.system(size: 13, weight: .bold, design: .rounded))
161 | .foregroundColor(.white)
162 | .transition(.opacity)
163 | Ellipse()
164 | .foregroundColor(Color.clear)
165 | .frame(width: 50, height: 50)
166 | .overlay(
167 | Circle()
168 | .stroke(Color.white, lineWidth: 2)
169 | .scaleEffect(shouldGrow ? 1.5 : 1.0)
170 | .opacity(shouldGrow ? 0.0 : 1.0)
171 | )
172 | .onAppear {
173 | withAnimation(Animation.linear(duration: 1.5).repeatForever(autoreverses: false)) {
174 | self.shouldGrow = true
175 | }
176 | }
177 | } else {
178 | ProgressView()
179 | .scaleEffect(1.5)
180 | }
181 | }
182 | }
183 | .buttonStyle(CustomButtonStyle())
184 | .shadow(color: .black.opacity(0.5), radius: 5, x: 0, y: 5)
185 | }
186 |
187 | struct CustomButtonStyle: ButtonStyle {
188 | func makeBody(configuration: Configuration) -> some View {
189 | configuration.label
190 | .frame(width: 80, height: 80)
191 | .background(content: {
192 | Circle()
193 | .fill(Color(red: 39/255, green: 110/255, blue: 241/255))
194 | })
195 | .scaleEffect(configuration.isPressed ? 1.5 : 1)
196 | .animation(.easeOut(duration: 0.3), value: configuration.isPressed)
197 | }
198 | }
199 |
200 | @MainActor
201 | private func connectButtonAction() {
202 | Task {
203 | guard !isLoading else {
204 | return
205 | }
206 | self.isLoading = true
207 | let notificationFeedback = UINotificationFeedbackGenerator()
208 | let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
209 | impactFeedback.prepare()
210 | notificationFeedback.prepare()
211 | impactFeedback.impactOccurred()
212 |
213 | var vehicle = garage.currentVehicle ?? garage.newVehicle()
214 |
215 | do {
216 | self.statusMessage = "Initializing OBD Adapter (BLE)"
217 | toggleDisplayType(to: .halfScreen)
218 |
219 | vehicle.obdinfo = try await obdService.startConnection(preferedProtocol: .protocol6)
220 | vehicle.obdinfo?.supportedPIDs = await obdService.getSupportedPIDs()
221 |
222 | // if vehicle.make == "None",
223 | // let vin = vehicle.obdinfo?.vin,
224 | // vin.count > 0,
225 | // let vinResults = try? await getVINInfo(vin: vin).Results[0] {
226 | // vehicle.make = vinResults.Make
227 | // vehicle.model = vinResults.Model
228 | // vehicle.year = vinResults.ModelYear
229 | // } else {
230 | // showAddCarScreen = true
231 | // }
232 |
233 | garage.updateVehicle(vehicle)
234 | garage.setCurrentVehicle(to: vehicle)
235 |
236 | notificationFeedback.notificationOccurred(.success)
237 | withAnimation {
238 | self.statusMessage = "Connected to Vehicle"
239 | self.isLoading = false
240 | animateWhiteStreak()
241 | }
242 |
243 | DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
244 | withAnimation(.easeInOut(duration: 0.5)) {
245 | self.statusMessage = nil
246 | }
247 | }
248 |
249 | DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) {
250 | toggleDisplayType(to: .quarterScreen)
251 | }
252 |
253 | } catch {
254 | notificationFeedback.notificationOccurred(.error)
255 | self.statusMessage = "Error Connecting to Vehicle"
256 | withAnimation {
257 | self.isLoading = false
258 | }
259 | }
260 | }
261 | }
262 |
263 | private func toggleDisplayType(to displayType: BottomSheetType) {
264 | withAnimation(.interactiveSpring(response: 0.5,
265 | dampingFraction: 0.8,
266 | blendDuration: 0)
267 | ) {
268 | self.displayType = displayType
269 | }
270 | }
271 |
272 | func animateWhiteStreak() {
273 | withAnimation(.linear(duration: 2.0)) {
274 | self.whiteStreakProgress = 1.0 // Animate to 100%
275 | }
276 | }
277 | }
278 |
279 | extension CustomTabBarView {
280 | private func tabView(tab: TabBarItem) -> some View {
281 | VStack {
282 | Image(systemName: tab.iconName)
283 | .font(.system(size: 20, weight: .semibold, design: .rounded))
284 | Text(tab.title)
285 | .font(.system(size: 12, weight: .bold, design: .rounded))
286 | }
287 | .foregroundColor(localSelection == tab ? tab.color : .gray)
288 | .padding(.vertical, 8)
289 | .frame(maxWidth: .infinity)
290 | .background(
291 | ZStack {
292 | if localSelection == tab {
293 | RoundedRectangle(cornerRadius: 10)
294 | .fill(tab.color.opacity(0.2))
295 | .matchedGeometryEffect(id: "background_rect", in: namespace)
296 | }
297 | }
298 | )
299 | }
300 |
301 | private var tabBar: some View {
302 | HStack {
303 | ForEach(tabs, id: \.self) { tab in
304 | tabView(tab: tab)
305 | .onTapGesture {
306 | switchToTab(tab: tab)
307 | }
308 | }
309 | }
310 | .padding(.top, 30)
311 | .padding(.horizontal)
312 | }
313 |
314 | private func switchToTab(tab: TabBarItem) {
315 | selection = tab
316 | }
317 |
318 | private var carInfoView: some View {
319 | VStack {
320 | VStack(alignment: .center) {
321 | if let statusMessage = statusMessage {
322 | Text(statusMessage)
323 | } else {
324 | Text(garage.currentVehicle?.year ?? "")
325 | + Text(" ")
326 | + Text(garage.currentVehicle?.make ?? "")
327 | + Text(" ")
328 | + Text(garage.currentVehicle?.model ?? "")
329 | }
330 | }
331 | .font(.system(size: 22, weight: .bold, design: .rounded))
332 | .fontWeight(.bold)
333 | .padding(10)
334 | .frame(maxWidth: .infinity)
335 | .background(content: {
336 | RoundedRectangle(cornerRadius: 10)
337 | .stroke(Color.white, lineWidth: 1)
338 | .overlay(
339 | RoundedRectangle(cornerRadius: 10)
340 | .trim(from: 0, to: whiteStreakProgress)
341 | .stroke(
342 | AngularGradient(
343 | gradient: .init(colors: [.green]),
344 | center: .center,
345 | startAngle: .zero,
346 | endAngle: .degrees(360)
347 | ),
348 | style: StrokeStyle(lineWidth: 1, lineCap: .round)
349 | )
350 | )
351 | })
352 |
353 | VStack {
354 | HStack {
355 | Text("VIN" )
356 | Spacer()
357 | Text(garage.currentVehicle?.obdinfo?.vin ?? "")
358 | }
359 | HStack {
360 | Text("Protocol")
361 | Spacer()
362 | Text(garage.currentVehicle?.obdinfo?.obdProtocol?.description ?? "")
363 | }
364 | HStack {
365 | Text("ELM connection")
366 | Spacer()
367 | Text(obdService.connectionState == .connectedToAdapter || obdService.connectionState == .connectedToVehicle ? "Connected" : "disconnected")
368 | .foregroundStyle(obdService.connectionState == .connectedToAdapter || obdService.connectionState == .connectedToVehicle ? .green : .red)
369 | }
370 |
371 | HStack {
372 | Text("ECU connection")
373 | Spacer()
374 | Text(obdService.connectionState == .connectedToVehicle ? "Connected" : "disconnected")
375 | .foregroundStyle(obdService.connectionState == .connectedToVehicle ? .green : .red)
376 | }
377 | }
378 | .font(.system(size: 14, weight: .semibold))
379 | .padding(.vertical, 10)
380 | Spacer(minLength: 0)
381 | }
382 | .frame(maxWidth: .infinity)
383 | }
384 | }
385 |
386 | #Preview {
387 | ZStack {
388 | GeometryReader { proxy in
389 | CustomTabBarView(tabs: [.dashBoard, .features],
390 | selection: .constant(.dashBoard),
391 | maxHeight: proxy.size.height,
392 | displayType: .constant(.halfScreen),
393 | statusMessage: .constant(nil)
394 | ) {
395 | BackgroundView(isDemoMode: .constant(false))
396 | }
397 | .environmentObject(Garage())
398 | .environmentObject(OBDService())
399 | }
400 | }
401 | }
402 |
--------------------------------------------------------------------------------
/SwiftOBD2App/Views/SubViews/VehicleDiagnosticsView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // VehicleDiagnosticsView.swift
3 | // SMARTOBD2
4 | //
5 | // Created by kemo konteh on 9/30/23.
6 | //
7 |
8 | import SwiftUI
9 | import SwiftOBD2
10 |
11 | struct Stage: Identifiable, Hashable {
12 | let id: UUID = UUID()
13 | let name: String
14 | }
15 |
16 | struct DiagnosticsScreen: View {
17 | @State private var startPoint = UnitPoint(x: -1, y: 0.5)
18 | @State private var endPoint = UnitPoint(x: 0, y: 0.5)
19 |
20 | @Binding var stages: [Stage]
21 | @Binding var requestingTroubleCodes: Bool
22 | @Binding var requestingTroubleCodesError: Bool
23 |
24 | var body: some View {
25 | VStack(spacing: 10) {
26 | ZStack(alignment: .center) {
27 | RoundedRectangle(cornerRadius: 10, style: .continuous)
28 | .fill(Color.white)
29 | .frame(width: 220, height: 100)
30 | .overlay(alignment: .leading) {
31 | if stages.last?.name != "Complete" {
32 | RoundedRectangle(cornerRadius: 10, style: .continuous)
33 | .fill(LinearGradient(gradient: Gradient(colors: [.gray.opacity(0.5), .darkEnd.opacity(0.5)]), startPoint: startPoint, endPoint: endPoint))
34 | } else {
35 | RoundedRectangle(cornerRadius: 10, style: .continuous)
36 | .fill(Color.darkEnd.opacity(0.5))
37 | }
38 | }
39 |
40 | Image("car")
41 | .resizable()
42 | .renderingMode(.template)
43 | .frame(width: 150, height: 150)
44 | .shadow(color: .black.opacity(0.5), radius: 10, x: 0, y: 5)
45 |
46 | }
47 | .frame(maxWidth: .infinity)
48 | .onAppear {
49 | withAnimation(Animation.linear(duration: 1).repeatForever(autoreverses: true)) {
50 | self.startPoint = UnitPoint(x: 1, y: 0.5)
51 | self.endPoint = UnitPoint(x: 1.5, y: 0.5)
52 | }
53 | }
54 | .padding(.top)
55 |
56 | VStack(alignment: .leading, spacing: 10) {
57 |
58 | HStack(alignment: .lastTextBaseline) {
59 | VStack(alignment: .leading, spacing: 10) {
60 | ForEach(stages, id: \.self) { stage in
61 | Text(stage.name)
62 | .font(.system(size: 18, weight: .semibold, design: .rounded))
63 | }
64 | }
65 | .font(.system(size: 18, weight: .semibold, design: .rounded))
66 | Spacer()
67 |
68 | VStack(alignment: .leading, spacing: 10) {
69 | ForEach(stages.dropLast()) { _ in
70 | Image(systemName: "checkmark.circle.fill")
71 | .resizable()
72 | .frame(width: 20, height: 20)
73 | .foregroundColor(.green)
74 | }
75 | ZStack(alignment: .center) {
76 | if stages.last?.name == "Complete" {
77 | Image(systemName: "checkmark.circle.fill")
78 | .resizable()
79 | .frame(width: 20, height: 20)
80 | .foregroundColor(.green)
81 | } else {
82 | if !requestingTroubleCodesError {
83 | ProgressView()
84 | .progressViewStyle(CircularProgressViewStyle(tint: .white))
85 | } else {
86 | Image(systemName: "xmark.circle.fill")
87 | .resizable()
88 | .frame(width: 20, height: 20)
89 | .foregroundColor(.red)
90 | }
91 | }
92 | }
93 | }
94 | .padding(5)
95 | .background {
96 | RoundedRectangle(cornerRadius: 20, style: .continuous)
97 | .stroke(Color.white, lineWidth: 1)
98 | }
99 | .animation(.linear(duration: 0.5), value: stages.last)
100 | }
101 | .frame(maxWidth: .infinity)
102 | .padding()
103 | }
104 | .animation(.linear(duration: 0.5), value: stages.last)
105 | if stages.last?.name == "Complete" || requestingTroubleCodesError {
106 | Button {
107 | requestingTroubleCodes = false
108 | } label: {
109 | Text("Continue")
110 | .font(.body)
111 | .foregroundColor(.white)
112 | .padding()
113 | // .frame(maxWidth: .infinity)
114 | .background(Color.blue.opacity(0.8))
115 | .cornerRadius(10)
116 | .shadow(color: .black.opacity(0.5), radius: 5, x: 0, y: 5)
117 | }
118 | .padding()
119 | .buttonStyle(.plain)
120 | .transition(.slide)
121 | }
122 | Spacer()
123 | }
124 | .frame(maxWidth: .infinity, maxHeight: .infinity)
125 | .background(Color.black.opacity(0.4))
126 | }
127 | }
128 |
129 | struct VehicleDiagnosticsView: View {
130 | @EnvironmentObject var globalSettings: GlobalSettings
131 | @EnvironmentObject var garage: Garage
132 | @EnvironmentObject var obd2Service: OBDService
133 |
134 | @Environment(\.dismiss) var dismiss
135 | @Binding var displayType: BottomSheetType
136 | @Binding var isDemoMode: Bool
137 | @State private var showAlert = false
138 | @State private var alertMessage = ""
139 | @State private var loading = false
140 | @State private var clearCodeAlert = false
141 | @State var troubleCodes: [ECUID:[TroubleCode]] = [:]
142 |
143 | @State var requestingTroubleCodes = false
144 | @State var requestingTroubleCodesError = false
145 | let notificationFeedback = UINotificationFeedbackGenerator()
146 | let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
147 |
148 | func appendStage(_ stage: Stage) {
149 | stages.append(stage)
150 | }
151 |
152 | var current: Vehicle?
153 |
154 | @MainActor
155 | func scanForTroubleCodes() async {
156 | impactFeedback.prepare()
157 | impactFeedback.impactOccurred()
158 | notificationFeedback.prepare()
159 | requestingTroubleCodesError = false
160 |
161 | guard var currentVehicle = garage.currentVehicle else {
162 | self.alertMessage = "No vehicle selected"
163 | showAlert = true
164 | requestingTroubleCodes = false
165 | return
166 | }
167 | stages = [Stage(name: "Getting engine parameters")]
168 | appendStage(Stage(name: "Starting diagnostics"))
169 |
170 | do {
171 | try await Task.sleep(nanoseconds: 2_000_000_000)
172 | guard let status = try await self.getStatus() else {
173 | appendStage(Stage(name: "No status codes found"))
174 | requestingTroubleCodesError = true
175 | return
176 | }
177 | currentVehicle.status = status
178 | appendStage(Stage(name: "DTC count: \(status.dtcCount)"))
179 | guard status.dtcCount > 0 else {
180 | appendStage(Stage(name: "No trouble codes found"))
181 | appendStage(Stage(name: "Complete"))
182 | return
183 | }
184 | appendStage(Stage(name: "Reading trouble codes"))
185 | let codes = try await obd2Service.scanForTroubleCodes()
186 | try await Task.sleep(nanoseconds: 2_500_000_000)
187 |
188 | appendStage(Stage(name: "Trouble codes found"))
189 |
190 | for (ecu, troubleCodes) in codes {
191 | withAnimation {
192 | self.troubleCodes[ecu] = troubleCodes
193 | for troubleCode in troubleCodes {
194 | appendStage(Stage(name: troubleCode.code + ": " + troubleCode.description))
195 | }
196 | }
197 | }
198 | currentVehicle.troubleCodes = troubleCodes
199 |
200 | garage.updateVehicle(currentVehicle)
201 |
202 | appendStage(Stage(name: "Complete"))
203 | notificationFeedback.notificationOccurred(.success)
204 | } catch {
205 | notificationFeedback.notificationOccurred(.error)
206 | self.alertMessage = error.localizedDescription
207 | showAlert = true
208 | requestingTroubleCodesError = true
209 | }
210 | }
211 |
212 | func getStatus() async throws -> Status? {
213 | let statusResult = try await obd2Service.getStatus()
214 | switch statusResult {
215 | case .success(let status):
216 | guard let response = status.statusResult else {
217 | appendStage(Stage(name: "No status codes found"))
218 | requestingTroubleCodesError = true
219 | return nil
220 | }
221 | return response
222 | case .failure(let error):
223 | print(error.localizedDescription)
224 | return nil
225 | }
226 | }
227 |
228 | @MainActor
229 | func clearCode() async {
230 | impactFeedback.prepare()
231 | impactFeedback.impactOccurred()
232 | notificationFeedback.prepare()
233 |
234 | do {
235 | try await obd2Service.clearTroubleCodes()
236 | self.loading = true
237 | notificationFeedback.notificationOccurred(.success)
238 | } catch {
239 | print(error.localizedDescription)
240 | notificationFeedback.notificationOccurred(.error)
241 | self.alertMessage = error.localizedDescription
242 | self.showAlert = true
243 | }
244 | }
245 |
246 | @Namespace var animation
247 | @State var stages: [Stage] = []
248 | var body: some View {
249 | ZStack {
250 | BackgroundView(isDemoMode: $isDemoMode)
251 | if requestingTroubleCodes {
252 | DiagnosticsScreen(stages: $stages,
253 | requestingTroubleCodes: $requestingTroubleCodes,
254 | requestingTroubleCodesError: $requestingTroubleCodesError)
255 | .transition(.opacity)
256 | .animation(.easeInOut, value: requestingTroubleCodes)
257 | .matchedGeometryEffect(id: "diagnostics", in: animation)
258 | } else {
259 | VStack(alignment: .leading) {
260 | if let currentVehicle = garage.currentVehicle {
261 | Image("car")
262 | .resizable()
263 | .aspectRatio(contentMode: .fit)
264 | .frame(maxWidth: .infinity)
265 | .overlay(alignment: .topLeading) {
266 | VStack(alignment: .leading) {
267 | Text("\(currentVehicle.year) \(currentVehicle.make)")
268 | Text("\(currentVehicle.model)")
269 | }
270 | .padding(.horizontal)
271 | .foregroundColor(.white)
272 | .font(.system(size: 20, weight: .bold, design: .rounded))
273 | }
274 |
275 | HStack {
276 | Text("DTC count:")
277 | Spacer()
278 | if let dtcCount = currentVehicle.status?.dtcCount {
279 | Text(String(dtcCount))
280 | }
281 | }
282 | .listRowBackground(Color.darkStart.opacity(0.3))
283 | .padding()
284 | Text("Confirmed Codes")
285 | .padding(.horizontal)
286 | if let codes = currentVehicle.troubleCodes {
287 | ForEach(Array(codes.keys), id: \.self) { ecuid in
288 | ForEach(codes[ecuid] ?? [], id: \.self) { troubleCode in
289 | VStack {
290 | HStack(spacing: 20) {
291 | Text(troubleCode.code)
292 | Text(troubleCode.description)
293 | Spacer()
294 | }
295 | .frame(maxWidth: .infinity)
296 | .font(.system(size: 14))
297 | .foregroundColor(.white)
298 | }
299 | .transition(.slide)
300 | .padding(.vertical, 10)
301 | .frame(maxWidth: .infinity, alignment: .leading)
302 | .animation(.easeInOut, value: troubleCode)
303 | .listRowBackground(Color.clear)
304 | }
305 | }
306 | .listStyle(.inset)
307 | .scrollContentBackground(.hidden)
308 | }
309 | }
310 | Spacer()
311 | }
312 | .matchedGeometryEffect(id: "diagnostics", in: animation)
313 | }
314 | }
315 | .navigationBarBackButtonHidden(true)
316 | .navigationBarTitleDisplayMode(.inline)
317 | .alert("", isPresented: $showAlert) {} message: {
318 | Text(alertMessage)
319 | }
320 | .alert("Wait", isPresented: $clearCodeAlert) {
321 | Button("Cancel", role: .cancel) {}
322 |
323 | Button("Clear Codes", role: .destructive) {
324 | guard !loading else { return }
325 | self.loading = true
326 | Task {
327 | await scanForTroubleCodes()
328 | loading = false
329 | }
330 | }
331 |
332 | } message: {
333 | Text("Do not attempt to clear codes while the engine is running. Clearing codes while the engine is running can cause serious damage to your vehicle.")
334 | }
335 | .toolbar {
336 | ToolbarItem(placement: .topBarLeading) {
337 | Button {
338 | withAnimation {
339 | displayType = .quarterScreen
340 | dismiss()
341 | }
342 | } label: {
343 | Label("Back", systemImage: "chevron.backward")
344 | }
345 | }
346 |
347 | ToolbarItem(placement: .secondaryAction) {
348 | Button("Clear Codes", role: .destructive) {
349 | clearCodeAlert = true
350 | }
351 | .buttonStyle(.bordered)
352 | .disabled(loading)
353 | .shadow(color: .black.opacity(0.5), radius: 10, x: 0, y: 5)
354 | }
355 |
356 | ToolbarItem(placement: .primaryAction) {
357 | Button("Scan", role: .none) {
358 | guard !requestingTroubleCodes else { return }
359 | withAnimation {
360 | self.requestingTroubleCodes = true
361 | }
362 | Task {
363 | await scanForTroubleCodes()
364 | }
365 | }
366 | .buttonStyle(.bordered)
367 | .disabled(loading)
368 | .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 15))
369 | .shadow(color: .black.opacity(0.5), radius: 5, x: 0, y: 5)
370 | }
371 | }
372 | }
373 | }
374 |
375 | #Preview {
376 | NavigationView {
377 | VehicleDiagnosticsView(displayType: .constant(.quarterScreen), isDemoMode: .constant(false))
378 | .environmentObject(GlobalSettings())
379 | .environmentObject(Garage())
380 | }
381 | }
382 |
--------------------------------------------------------------------------------
/SwiftOBD2App/Views/TestingScreen.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CarScreen.swift
3 | // SmartOBD2
4 | //
5 | // Created by kemo konteh on 8/13/23.
6 | //
7 |
8 | import SwiftUI
9 | import CoreBluetooth
10 | import SwiftOBD2
11 |
12 | enum TestingDisplayMode {
13 | case messages
14 | case bluetooth
15 | }
16 |
17 | class TestingScreenViewModel: ObservableObject {
18 | @Published var lastMessageID: String = ""
19 | }
20 |
21 | struct TestingScreen: View {
22 | @StateObject var viewModel = TestingScreenViewModel()
23 | @State private var history: [History] = []
24 | @Environment(\.colorScheme) var colorScheme
25 | @State private var selectedCommand: OBDCommand = OBDCommand.mode6(.MONITOR_O2_B1S1)
26 | @State private var displayMode = TestingDisplayMode.bluetooth
27 | // @State private var selectedPeripheral: Peripheral?
28 | @State private var command = ""
29 |
30 | @EnvironmentObject var globalSettings: GlobalSettings
31 | @Environment(\.dismiss) var dismiss
32 | @Binding var displayType: BottomSheetType
33 |
34 | @EnvironmentObject var garage: Garage
35 | @EnvironmentObject var obdService: OBDService
36 |
37 | var body: some View {
38 | ZStack {
39 | BackgroundView(isDemoMode: .constant(false))
40 | VStack {
41 | Picker("Display Mode", selection: $displayMode) {
42 | Text("Messages").tag(TestingDisplayMode.messages)
43 | Text("Bluetooth Query").tag(TestingDisplayMode.bluetooth)
44 | }
45 | .pickerStyle(SegmentedPickerStyle())
46 | .padding()
47 |
48 | switch displayMode {
49 | case .messages:
50 | messagesSection
51 | case .bluetooth:
52 | bluetoothSection
53 | }
54 | }
55 | }
56 | .navigationBarBackButtonHidden(true)
57 | .toolbar {
58 | ToolbarItem(placement: .navigationBarLeading) {
59 | Button {
60 | displayType = .quarterScreen
61 | dismiss()
62 | } label: {
63 | Label("Back", systemImage: "chevron.backward")
64 | }
65 | }
66 | }
67 | }
68 |
69 | func sendCommand(command: OBDCommand) {
70 | Task {
71 | do {
72 | print("Sending Command: \(command.properties.command)")
73 | let response = try await obdService.sendCommandInternal(command.properties.command, retries: 3)
74 | print("Response: \(response.joined(separator: " "))")
75 | // let decodedValue = command.properties.decode(data: data)
76 | // switch decodedValue {
77 | // case .measurementMonitor(let value):
78 | // for _ in value.tests {
79 | // print("name: \(String(describing: test.value.name))\nValue: \(test.value.value ?? 0)\nMax: \(String(describing: test.value.max)) \nMin: \(String(describing: test.value.passed))")
80 | // }
81 | // case .statusResult(let value):
82 | // print("Status: \(value)")
83 | // default:
84 | //
85 | // return
86 | // }
87 | history.append(History(command: command.properties.command,
88 | response: response.joined(separator: " "))
89 | )
90 | } catch {
91 | print("Error setting up adapter: \(error)")
92 | }
93 | }
94 | }
95 |
96 | private var messagesSection: some View {
97 | VStack {
98 | Text("Request History")
99 | .font(.system(size: 20))
100 |
101 | ScrollViewReader { proxy in
102 | ScrollView(.vertical, showsIndicators: false) {
103 | ForEach(history, id: \.id) { history in
104 | TestMessageView(message: history)
105 | }
106 | }
107 | .onChange(of: viewModel.lastMessageID) { id in
108 | withAnimation {
109 | proxy.scrollTo(id, anchor: .bottom)
110 | }
111 | }
112 |
113 | HStack {
114 | if let supportedPids = garage.currentVehicle?.obdinfo?.supportedPIDs {
115 | Picker("Select A command", selection: $selectedCommand) {
116 | ForEach(supportedPids, id: \.self) { pid in
117 | Text(pid.properties.description)
118 | .tag(pid)
119 | }
120 | }
121 | .pickerStyle(.menu)
122 | }
123 | Spacer()
124 | Button {
125 | sendCommand(command: selectedCommand)
126 | } label: {
127 | Image(systemName: "arrow.up.circle.fill")
128 | .foregroundColor(.blue)
129 | .font(.system(size: 30))
130 | .fontWeight(.semibold)
131 | }
132 | }
133 | .padding(.vertical)
134 |
135 | HStack {
136 | TextField("Enter Command", text: $command)
137 | .keyboardShortcut("m", modifiers: .command)
138 | .defersSystemGestures(on: .vertical)
139 | .foregroundColor(.black)
140 |
141 | Button {
142 | guard !command.isEmpty else { return }
143 | Task {
144 | do {
145 | let response = try await sendMessage()
146 | history.append(History(command: command,
147 | response: response.joined(separator: " "))
148 | )
149 | } catch {
150 | print("Error setting up adapter: \(error)")
151 | }
152 | }
153 |
154 | } label: {
155 | Image(systemName: "arrow.up.circle.fill")
156 | .foregroundColor(.blue)
157 | .font(.system(size: 30))
158 | .fontWeight(.semibold)
159 | }
160 | }
161 | .background(
162 | RoundedRectangle(cornerRadius: 25)
163 | )
164 | }
165 | .font(.system(size: 16))
166 | }
167 | .padding()
168 | }
169 |
170 | func sendMessage() async throws -> [String] {
171 | let response = try await obdService.sendCommandInternal(command, retries: 3)
172 | return response
173 | }
174 |
175 | private var bluetoothSection: some View {
176 | VStack {
177 | // ScrollView(.vertical, showsIndicators: false) {
178 | // if let peripherals = obd2Service.foundPeripherals {
179 | // ForEach(peripherals) { peripheral in
180 | // PeripheralRow(peripheral: peripheral)
181 | // .onTapGesture {
182 | // self.selectedPeripheral = peripheral
183 | // }
184 | // }
185 | // }
186 | // }
187 | // .sheet(item: $selectedPeripheral) { peripheral in
188 | // PeripheralInfo(peripheral: peripheral)
189 | // }
190 | // Spacer()
191 | }
192 | .padding()
193 | .frame(maxWidth: .infinity, maxHeight: .infinity)
194 | .onAppear {
195 | // viewModel.startScanning()
196 | }
197 | }
198 | }
199 |
200 | // struct PeripheralRow: View {
201 | // let peripheral: Peripheral
202 | //
203 | // var body: some View {
204 | // HStack {
205 | // Text(peripheral.name)
206 | // Text(String(peripheral.rssi))
207 | // Spacer()
208 | // Text(connectedPeripheral?.identifier == peripheral.peripheral.identifier ? "Connected" : "Tap to Connect")
209 | // .foregroundColor(.white)
210 | // }
211 | // .padding()
212 | // .frame(maxWidth: .infinity, maxHeight: 50)
213 | // .background {
214 | // RoundedRectangle(cornerRadius: 10)
215 | // .fill(Color.charcoal)
216 | // }
217 | // }
218 | // }
219 |
220 | // struct PeripheralInfo: View {
221 | // @State var peripheral: Peripheral
222 | //
223 | // var body: some View {
224 | // VStack(alignment: .leading) {
225 | // Text(peripheral.peripheral.name ?? "Unknown Device")
226 | // .font(.system(size: 24, weight: .bold))
227 | //
228 | // Text("PeripheralUUID: \(peripheral.peripheral.identifier.uuidString)")
229 | //
230 | // HStack {
231 | // Button("Connect") {
232 | // obd2Service.bleManager.connect(to: peripheral.peripheral)
233 | // }
234 | // .buttonStyle(.bordered)
235 | // Button("Disconnect") {
236 | // obd2Service.disconnectPeripheral(peripheral: peripheral)
237 | // }
238 | // .buttonStyle(.bordered)
239 | // }
240 | // .frame(maxWidth: .infinity)
241 | //
242 | // ScrollView(.vertical, showsIndicators: false) {
243 | // ForEach(peripheral.peripheral.services ?? [], id:\.uuid) { service in
244 | // ServiceRow(service: service)
245 | // }
246 | // }
247 | //
248 | // Spacer()
249 | // }
250 | // .padding(10)
251 | // .frame(maxWidth: .infinity, maxHeight: .infinity)
252 | // }
253 | // }
254 |
255 | // struct ServiceRow: View {
256 | // let service: CBService
257 | //
258 | // var body: some View {
259 | // VStack(alignment: .leading, spacing: 10) {
260 | // Text("Service: \(service.uuid)")
261 | //
262 | // Text("Characteristics")
263 | // ForEach(service.characteristics ?? [], id: \.uuid) { characteristic in
264 | // CharacteristicRow(characteristic: characteristic)
265 | // }
266 | // }
267 | // .font(.system(size: 18, weight: .semibold))
268 | // .padding(.vertical)
269 | // }
270 | // }
271 |
272 | // struct CharacteristicRow: View {
273 | // let characteristic: CBCharacteristic
274 | // @State var response: String?
275 | //
276 | // var body: some View {
277 | // VStack(alignment: .leading, spacing: 10) {
278 | // Text("\(characteristic.uuid)")
279 | //
280 | // Text("Properties: [\(propertiesAsString())]")
281 | //
282 | // if let response = response {
283 | // Text("response: \(response)")
284 | // }
285 | // }
286 | // .font(.system(size: 16))
287 | // .frame(maxWidth: .infinity, alignment: .leading)
288 | //// .onTapGesture {
289 | //// Task {
290 | //// let response = try await viewModel.testCharacteristic(characteristic)
291 | //// DispatchQueue.main.async {
292 | //// self.response = response
293 | //// }
294 | //// }
295 | //// }
296 | // }
297 | // // Helper function to convert properties to a readable string
298 | // private func propertiesAsString() -> String {
299 | // var propertiesString = ""
300 | // if characteristic.properties.contains(.read) {
301 | // propertiesString += "Read, "
302 | // }
303 | // if characteristic.properties.contains(.write) {
304 | // propertiesString += "write, "
305 | // }
306 | //
307 | // if characteristic.properties.contains(.notify) {
308 | // propertiesString += "Notify, "
309 | // }
310 | //
311 | // // Remove trailing comma and space, if any
312 | // if propertiesString.hasSuffix(", ") {
313 | // propertiesString.removeLast(2)
314 | // }
315 | // return propertiesString
316 | // }
317 | // }
318 |
319 | struct History: Identifiable {
320 | var id = UUID()
321 | var command: String
322 | var response: String
323 | }
324 |
325 | struct TestMessageView: View {
326 | var message: History
327 |
328 | var body: some View {
329 | HStack {
330 | Text(message.command)
331 | Spacer()
332 | Text(message.response)
333 |
334 | }
335 | .padding()
336 | .frame(maxWidth: .infinity, maxHeight: 50)
337 | .background {
338 | RoundedRectangle(cornerRadius: 10)
339 | .fill(Color.charcoal)
340 | }
341 | }
342 | }
343 |
344 | // class TestingScreenViewModel: ObservableObject {
345 | //
346 | // let obdService: OBDServiceProtocol
347 | // let garage: GarageProtocol
348 | // private var cancellables = Set()
349 | //
350 | // @Published var command: String = ""
351 | // @Published var currentVehicle: Vehicle?
352 | // @Published var isRequestingPids = false
353 | // @Published var lastMessageID: String = ""
354 | // @Published var peripherals: [Peripheral] = []
355 | //
356 | // @Published var connectPeripheral: CBPeripheralProtocol?
357 | //
358 | // init(_ obdService: OBDServiceProtocol, _ garage: GarageProtocol) {
359 | // self.obdService = obdService
360 | // self.garage = garage
361 | // garage.currentVehiclePublisher
362 | // .sink { currentVehicle in
363 | // self.currentVehicle = currentVehicle
364 | // }
365 | // .store(in: &cancellables)
366 | //
367 | // obdService.foundPeripheralsPublisher
368 | // .sink { peripherals in
369 | // self.peripherals = peripherals
370 | // }
371 | // .store(in: &cancellables)
372 | //
373 | // }
374 | //
375 | // func sendMessage() async throws -> [String] {
376 | // return try await obdService.elm327.sendMessageAsync(command, withTimeoutSecs: 5)
377 | // }
378 | //
379 | // func startScanning() {
380 | // obdService.bleManager.startScanning()
381 | // }
382 | //
383 | // func connect(to peripheral: Peripheral) {
384 | // Task {
385 | // do {
386 | // let connectedPeripheral = try await obdService.connect(to: peripheral)
387 | // print("Connected to to ", connectedPeripheral.name ?? "No Name")
388 | //// let services = try await obdService.elm327.bleManager.discoverServicesAsync(for: connectedPeripheral)
389 | //// for service in services {
390 | //// print(service)
391 | //// let characteristics = try await obdService.elm327.bleManager.discoverCharacteristicsAsync(connectedPeripheral, for: service)
392 | //// for characteristic in characteristics {
393 | //// print(characteristic)
394 | ////// if characteristic.uuid.uuidString == "FFF1" {
395 | ////// let data = try await testCharacteristic(characteristic)
396 | ////// print("data ", data)
397 | ////// }
398 | //// }
399 | //// }
400 | //
401 | // DispatchQueue.main.async {
402 | // self.connectPeripheral = connectedPeripheral
403 | // }
404 | //
405 | // } catch {
406 | // print(error.localizedDescription)
407 | // }
408 | // }
409 | // }
410 | //
411 | // func testCharacteristic(_ characteristic: CBCharacteristic) async throws -> String {
412 | // let data = try await obdService.bleManager.sendMessageAsync("ATZ", characteristic: characteristic)
413 | // print("here ", data)
414 | // return data.joined(separator: " ")
415 | // }
416 | //
417 | // func requestPid(_ command: OBDCommand) {
418 | // guard !isRequestingPids else {
419 | // return
420 | // }
421 | // isRequestingPids = true
422 | // Task {
423 | // do {
424 | // let messages = try await obdService.elm327.requestPIDs([command])
425 | // guard !messages.isEmpty else {
426 | // return
427 | // }
428 | // guard let data = messages[0].data else {
429 | // return
430 | // }
431 | // print(data.compactMap { String(format: "%02X", $0) }.joined(separator: " "))
432 | // let decodedValue = command.properties.decoder.decode(data: data[1...])
433 | // switch decodedValue {
434 | // // case .measurementMonitor(let measurement):
435 | // // print(measurement.tests)
436 | // case .measurementResult(let status):
437 | // print(status.value)
438 | // case .stringResult(let status):
439 | // print(status)
440 | //
441 | // case .statusResult(let status):
442 | // print(status)
443 | //
444 | // default :
445 | // print("Not a measurement monitor")
446 | // }
447 | // DispatchQueue.main.async {
448 | // self.isRequestingPids = false
449 | // }
450 | // } catch {
451 | // print(error.localizedDescription)
452 | // }
453 | // }
454 | // }
455 | // }
456 |
457 | #Preview {
458 | TestingScreen(viewModel: TestingScreenViewModel(), displayType: .constant(.quarterScreen))
459 | .environmentObject(GlobalSettings())
460 | .environmentObject(OBDService())
461 | .environmentObject(Garage())
462 | }
463 |
--------------------------------------------------------------------------------