├── .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 | 7 | 8 | 9 | 19 | 22 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------