Initial version
@@ -0,0 +1,48 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
/coverage/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
# Widget Preview related
|
||||
.widget_preview/
|
||||
@@ -0,0 +1,33 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "6a19cca56475dbfba1478ee68d7bd0c2ef891da1"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
|
||||
base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
|
||||
- platform: android
|
||||
create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
|
||||
base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
|
||||
- platform: ios
|
||||
create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
|
||||
base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
@@ -0,0 +1,58 @@
|
||||
# OpenMotion
|
||||
|
||||
An open-source platform for electric scooters, bikes, boards, and personal electric vehicles.
|
||||
|
||||
OpenMotion talks to your vehicle directly over Bluetooth. No manufacturer account, no cloud, no telemetry leaving your phone. If you know your vehicle's local PIN, you can see what it is doing and, once the write path is verified, control it.
|
||||
|
||||
## Status
|
||||
|
||||
The first supported vehicle is the Apollo Go. Read-only support is working against real hardware:
|
||||
|
||||
- scan, connect, verify the Apollo GATT services, subscribe
|
||||
- six-digit PIN authentication with wrong PIN and no response reported separately
|
||||
- live speed, battery, voltage, current, power, temperatures, trip and odometer
|
||||
- lock, headlight, atmosphere light, cruise and turn signal state
|
||||
- clean recovery from Bluetooth drops
|
||||
- protocol log on device for field debugging
|
||||
|
||||
Control writes (lock, unlock, headlight) are implemented and unit tested but disabled by a build constant until they have been compared byte for byte against a capture of the official app. See `enableApolloControlWrites` in `lib/scooters/apollo_scooter.dart`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
UI -> Scooter -> ApolloScooter -> BleClient
|
||||
```
|
||||
|
||||
- `lib/scooters/scooter.dart` is the vehicle interface, a `ChangeNotifier` with a synchronous `state`.
|
||||
- `lib/scooters/apollo_scooter.dart` implements the Apollo flow: connect, discover, subscribe, authenticate, keepalive, frame handling, gated control writes.
|
||||
- `lib/scooters/apollo_protocol.dart` holds pure protocol functions (CRC, framing, parsers, packet builders) so they can be tested without hardware.
|
||||
- `lib/services/ble_client.dart` is the only file that touches the Bluetooth plugin. One active connection at a time.
|
||||
- `lib/screens/` has the scan screen and the dashboard with selectable cluster layouts.
|
||||
|
||||
Protocol comments use four confidence levels: STATICALLY CONFIRMED (from the vendor app), LIVE VERIFIED (against a real vehicle), INFERRED, and UNKNOWN.
|
||||
|
||||
## Building
|
||||
|
||||
```
|
||||
flutter pub get
|
||||
flutter test
|
||||
flutter run
|
||||
```
|
||||
|
||||
Android needs Bluetooth permissions at runtime, which the app requests on first scan. iOS needs a real device.
|
||||
|
||||
## Field debugging
|
||||
|
||||
The app writes a protocol log with raw bytes to its external files directory. Pull it with:
|
||||
|
||||
```
|
||||
adb pull /sdcard/Android/data/dev.teamhydra.openscooter/files/openscooter.log
|
||||
```
|
||||
|
||||
## Adding a vehicle
|
||||
|
||||
Implement `Scooter` in one class, keep pure protocol code in one testable file, and match your vehicle by advertised service UUIDs rather than by name. Do not add abstractions until a second implementation proves they are shared.
|
||||
|
||||
## License and third party notices
|
||||
|
||||
OpenMotion uses flutter_blue_plus under its non-commercial license. Check that license before distributing a commercial build.
|
||||
@@ -0,0 +1,34 @@
|
||||
# This file configures the analyzer, which statically analyzes Dart code to
|
||||
# check for errors, warnings, and lints.
|
||||
#
|
||||
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||
# invoked from the command line by running `flutter analyze`.
|
||||
|
||||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
- android/**
|
||||
- ios/**
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
# included above or to enable additional rules. A list of all available lints
|
||||
# and their documentation is published at https://dart.dev/lints.
|
||||
#
|
||||
# Instead of disabling a lint rule for the entire project in the
|
||||
# section below, it can also be suppressed for a single line of code
|
||||
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||
# producing the lint.
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
@@ -0,0 +1,14 @@
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
@@ -0,0 +1,49 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "dev.teamhydra.openscooter"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "dev.teamhydra.openscooter"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
|
||||
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
|
||||
// You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
|
||||
// flag during build.
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,58 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Bluetooth LE. flutter_blue_plus requests the runtime permissions itself;
|
||||
the manifest must still declare them. -->
|
||||
<!-- Android 12+ (API 31): Nearby Devices permission group. neverForLocation
|
||||
means BLE scan results are not used to derive the user's location. -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<!-- Legacy Bluetooth permissions for Android 11 and below. -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<!-- Legacy BLE scanning (API 23-30) requires location permission. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
||||
<application
|
||||
android:label="OpenMotion"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
@@ -0,0 +1,5 @@
|
||||
package dev.teamhydra.openscooter
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,24 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
# This newDsl flag was added by the Flutter template
|
||||
android.newDsl=false
|
||||
# This builtInKotlin flag was added by the Flutter template
|
||||
android.builtInKotlin=false
|
||||
@@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
|
||||
@@ -0,0 +1,26 @@
|
||||
pluginManagement {
|
||||
val flutterSdkPath =
|
||||
run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "9.1.0" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
@@ -0,0 +1,34 @@
|
||||
**/dgph
|
||||
*.mode1v3
|
||||
*.mode2v3
|
||||
*.moved-aside
|
||||
*.pbxuser
|
||||
*.perspectivev3
|
||||
**/*sync/
|
||||
.sconsign.dblite
|
||||
.tags*
|
||||
**/.vagrant/
|
||||
**/DerivedData/
|
||||
Icon?
|
||||
**/Pods/
|
||||
**/.symlinks/
|
||||
profile
|
||||
xcuserdata
|
||||
**/.generated/
|
||||
Flutter/App.framework
|
||||
Flutter/Flutter.framework
|
||||
Flutter/Flutter.podspec
|
||||
Flutter/Generated.xcconfig
|
||||
Flutter/ephemeral/
|
||||
Flutter/app.flx
|
||||
Flutter/app.zip
|
||||
Flutter/flutter_assets/
|
||||
Flutter/flutter_export_environment.sh
|
||||
ServiceDefinitions.json
|
||||
Runner/GeneratedPluginRegistrant.*
|
||||
|
||||
# Exceptions to above rules.
|
||||
!default.mode1v3
|
||||
!default.mode2v3
|
||||
!default.pbxuser
|
||||
!default.perspectivev3
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>App</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>io.flutter.flutter.app</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>App</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
@@ -0,0 +1,647 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
|
||||
remoteInfo = Runner;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */,
|
||||
);
|
||||
path = RunnerTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */,
|
||||
);
|
||||
name = Flutter;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146E51CF9000F007C117D = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9740EEB11CF90186004384FC /* Flutter */,
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146EF1CF9000F007C117D /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146F01CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */,
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
|
||||
97C147021CF9000F007C117D /* Info.plist */,
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
);
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
331C8080294A63A400263BE5 /* RunnerTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
331C807D294A63A400263BE5 /* Sources */,
|
||||
331C807F294A63A400263BE5 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
331C8086294A63A400263BE5 /* PBXTargetDependency */,
|
||||
);
|
||||
name = RunnerTests;
|
||||
productName = RunnerTests;
|
||||
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
9740EEB61CF901F6004384FC /* Run Script */,
|
||||
97C146EA1CF9000F007C117D /* Sources */,
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = Runner;
|
||||
packageProductDependencies = (
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||
);
|
||||
productName = Runner;
|
||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
97C146E61CF9000F007C117D /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1510;
|
||||
ORGANIZATIONNAME = "";
|
||||
TargetAttributes = {
|
||||
331C8080294A63A400263BE5 = {
|
||||
CreatedOnToolsVersion = 14.0;
|
||||
TestTargetID = 97C146ED1CF9000F007C117D;
|
||||
};
|
||||
97C146ED1CF9000F007C117D = {
|
||||
CreatedOnToolsVersion = 7.3.1;
|
||||
LastSwiftMigration = 1100;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
|
||||
compatibilityVersion = "Xcode 9.3";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 97C146E51CF9000F007C117D;
|
||||
packageReferences = (
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
|
||||
);
|
||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
97C146ED1CF9000F007C117D /* Runner */,
|
||||
331C8080294A63A400263BE5 /* RunnerTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
331C807F294A63A400263BE5 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EC1CF9000F007C117D /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
|
||||
);
|
||||
name = "Thin Binary";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||
};
|
||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Run Script";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
331C807D294A63A400263BE5 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EA1CF9000F007C117D /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 97C146ED1CF9000F007C117D /* Runner */;
|
||||
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
97C146FB1CF9000F007C117D /* Base */,
|
||||
);
|
||||
name = Main.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
97C147001CF9000F007C117D /* Base */,
|
||||
);
|
||||
name = LaunchScreen.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
249021D3217E4FDB00AE95B9 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
249021D4217E4FDB00AE95B9 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.teamhydra.openscooter;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
331C8088294A63A400263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.teamhydra.openscooter.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
331C8089294A63A400263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.teamhydra.openscooter.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
331C808A294A63A400263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.teamhydra.openscooter.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
97C147031CF9000F007C117D /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
97C147041CF9000F007C117D /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
97C147061CF9000F007C117D /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.teamhydra.openscooter;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
97C147071CF9000F007C117D /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.teamhydra.openscooter;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
331C8088294A63A400263BE5 /* Debug */,
|
||||
331C8089294A63A400263BE5 /* Release */,
|
||||
331C808A294A63A400263BE5 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
97C147031CF9000F007C117D /* Debug */,
|
||||
97C147041CF9000F007C117D /* Release */,
|
||||
249021D3217E4FDB00AE95B9 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
97C147061CF9000F007C117D /* Debug */,
|
||||
97C147071CF9000F007C117D /* Release */,
|
||||
249021D4217E4FDB00AE95B9 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PreviewsEnabled</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,119 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1510"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<PreActions>
|
||||
<ExecutionAction
|
||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||
<ActionContent
|
||||
title = "Run Prepare Flutter Framework Script"
|
||||
scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||
<EnvironmentBuildable>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</EnvironmentBuildable>
|
||||
</ActionContent>
|
||||
</ExecutionAction>
|
||||
</PreActions>
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "331C8080294A63A400263BE5"
|
||||
BuildableName = "RunnerTests.xctest"
|
||||
BlueprintName = "RunnerTests"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
enableGPUValidationMode = "1"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Profile"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PreviewsEnabled</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,16 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "83.5x83.5",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "1024x1024",
|
||||
"idiom" : "ios-marketing",
|
||||
"filename" : "Icon-App-1024x1024@1x.png",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "LaunchImage@3x.png",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
@@ -0,0 +1,5 @@
|
||||
# Launch Screen Assets
|
||||
|
||||
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
|
||||
|
||||
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||
</imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchImage" width="168" height="185"/>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Flutter View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>OpenMotion uses Bluetooth to connect to your vehicle.</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<string>OpenMotion uses Bluetooth to connect to your vehicle.</string>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>OpenMotion</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>OpenMotion</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>UIWindowScene</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>flutter</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1 @@
|
||||
#import "GeneratedPluginRegistrant.h"
|
||||
@@ -0,0 +1,6 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
class SceneDelegate: FlutterSceneDelegate {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import XCTest
|
||||
|
||||
class RunnerTests: XCTestCase {
|
||||
|
||||
func testExample() {
|
||||
// If you add code to the Runner application, consider adding tests here.
|
||||
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'screens/scan_screen.dart';
|
||||
import 'services/ble_client.dart';
|
||||
import 'services/protocol_log.dart';
|
||||
import 'settings.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await Future.wait([ProtocolLog.instance.init(), AppSettings.instance.load()]);
|
||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
systemNavigationBarColor: OsColors.background,
|
||||
));
|
||||
runApp(const OpenScooterApp());
|
||||
}
|
||||
|
||||
/// Single BLE client for the whole app (one active connection at a time).
|
||||
final _ble = FlutterBleClient();
|
||||
|
||||
/// Logs route changes into the protocol log for field debugging.
|
||||
class _RouteLogger extends NavigatorObserver {
|
||||
@override
|
||||
void didPush(Route route, Route? previous) =>
|
||||
ProtocolLog.instance.log('NAV', 'push ${route.settings.name ?? route.runtimeType}');
|
||||
@override
|
||||
void didPop(Route route, Route? previous) =>
|
||||
ProtocolLog.instance.log('NAV', 'pop ${route.settings.name ?? route.runtimeType}');
|
||||
@override
|
||||
void didRemove(Route route, Route? previous) =>
|
||||
ProtocolLog.instance.log('NAV', 'remove ${route.settings.name ?? route.runtimeType}');
|
||||
}
|
||||
|
||||
class OpenScooterApp extends StatelessWidget {
|
||||
const OpenScooterApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = AppSettings.instance;
|
||||
return ListenableBuilder(
|
||||
listenable: settings,
|
||||
builder: (context, _) => MaterialApp(
|
||||
title: 'OpenMotion',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildOsTheme(settings.accent.color),
|
||||
navigatorObservers: [_RouteLogger()],
|
||||
home: ScanScreen(ble: _ble),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/// A scooter candidate discovered during a BLE scan.
|
||||
///
|
||||
/// [id] is the platform peripheral identifier (a MAC address on Android, an
|
||||
/// opaque UUID on iOS). It is only valid for connecting on this device and
|
||||
/// must NOT be used as the scooter's persistent identity. Once a vendor
|
||||
/// protocol exposes a serial number or UID, use that instead.
|
||||
class ScooterDevice {
|
||||
final String id;
|
||||
final String name;
|
||||
final int rssi;
|
||||
|
||||
/// Lowercase 128-bit service UUIDs found in the advertisement.
|
||||
final Set<String> advertisedServiceUuids;
|
||||
|
||||
const ScooterDevice({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.rssi,
|
||||
this.advertisedServiceUuids = const {},
|
||||
});
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is ScooterDevice && other.id == id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => 'ScooterDevice($id, "$name", $rssi dBm)';
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
enum ScooterConnectionStatus {
|
||||
disconnected,
|
||||
connecting,
|
||||
connected,
|
||||
authenticating,
|
||||
authenticated,
|
||||
ready,
|
||||
error,
|
||||
}
|
||||
|
||||
/// Observable snapshot of everything the UI needs.
|
||||
///
|
||||
/// Numeric protocol values are kept in their native representation. Units
|
||||
/// (km vs miles, Ah vs mAh, percent) are NOT asserted here until they have
|
||||
/// been LIVE VERIFIED against a physical scooter.
|
||||
class ScooterState {
|
||||
final ScooterConnectionStatus connectionStatus;
|
||||
|
||||
final bool authenticated;
|
||||
final bool canWrite;
|
||||
|
||||
/// Human-readable description of the last error, if [connectionStatus] is
|
||||
/// [ScooterConnectionStatus.error].
|
||||
final String? errorMessage;
|
||||
|
||||
/// Which connection step is in progress (0-based index into
|
||||
/// [connectionSteps]) while [connectionStatus] is connecting or
|
||||
/// authenticated-but-waiting. Null when no multi-step work is running.
|
||||
final int? connectionStep;
|
||||
|
||||
/// Steps shown to the rider while a link is being established.
|
||||
static const connectionSteps = [
|
||||
'Connecting',
|
||||
'Setting up services',
|
||||
'Checking this is a supported device',
|
||||
'Waiting for telemetry',
|
||||
'Waiting for scooter confirmation',
|
||||
];
|
||||
|
||||
final double? speed;
|
||||
final double? voltage;
|
||||
final double? current;
|
||||
final double? power;
|
||||
|
||||
final double? tripDistance;
|
||||
final double? odometer;
|
||||
|
||||
final int? batteryLevel;
|
||||
final int? batteryTemperature;
|
||||
final int? batteryCycles;
|
||||
|
||||
final int? motorTemperature;
|
||||
final int? controllerTemperature;
|
||||
|
||||
final int? gear;
|
||||
|
||||
final bool? locked;
|
||||
final bool? headlight;
|
||||
final bool? atmosphereLight;
|
||||
final bool? cruiseControl;
|
||||
|
||||
final bool? leftTurnSignal;
|
||||
final bool? rightTurnSignal;
|
||||
|
||||
/// Scooter's own unit preference bit (monitor byte 22 bit 5).
|
||||
final bool? imperial;
|
||||
|
||||
/// Speed limit for the current gear and the highest configured limit, in
|
||||
/// native protocol units. Gear-to-mode mapping is INFERRED.
|
||||
final int? speedLimit;
|
||||
final int? maxSpeedLimit;
|
||||
|
||||
final String? displayId;
|
||||
final String? displayVersion;
|
||||
|
||||
const ScooterState({
|
||||
this.connectionStatus = ScooterConnectionStatus.disconnected,
|
||||
this.authenticated = false,
|
||||
this.canWrite = false,
|
||||
this.errorMessage,
|
||||
this.connectionStep,
|
||||
this.speed,
|
||||
this.voltage,
|
||||
this.current,
|
||||
this.power,
|
||||
this.tripDistance,
|
||||
this.odometer,
|
||||
this.batteryLevel,
|
||||
this.batteryTemperature,
|
||||
this.batteryCycles,
|
||||
this.motorTemperature,
|
||||
this.controllerTemperature,
|
||||
this.gear,
|
||||
this.locked,
|
||||
this.headlight,
|
||||
this.atmosphereLight,
|
||||
this.cruiseControl,
|
||||
this.leftTurnSignal,
|
||||
this.rightTurnSignal,
|
||||
this.imperial,
|
||||
this.speedLimit,
|
||||
this.maxSpeedLimit,
|
||||
this.displayId,
|
||||
this.displayVersion,
|
||||
});
|
||||
|
||||
/// Sentinel so callers can explicitly clear a nullable field.
|
||||
static const Object _unset = Object();
|
||||
|
||||
ScooterState copyWith({
|
||||
ScooterConnectionStatus? connectionStatus,
|
||||
bool? authenticated,
|
||||
bool? canWrite,
|
||||
Object? errorMessage = _unset,
|
||||
Object? connectionStep = _unset,
|
||||
Object? speed = _unset,
|
||||
Object? voltage = _unset,
|
||||
Object? current = _unset,
|
||||
Object? power = _unset,
|
||||
Object? tripDistance = _unset,
|
||||
Object? odometer = _unset,
|
||||
Object? batteryLevel = _unset,
|
||||
Object? batteryTemperature = _unset,
|
||||
Object? batteryCycles = _unset,
|
||||
Object? motorTemperature = _unset,
|
||||
Object? controllerTemperature = _unset,
|
||||
Object? gear = _unset,
|
||||
Object? locked = _unset,
|
||||
Object? headlight = _unset,
|
||||
Object? atmosphereLight = _unset,
|
||||
Object? cruiseControl = _unset,
|
||||
Object? leftTurnSignal = _unset,
|
||||
Object? rightTurnSignal = _unset,
|
||||
Object? imperial = _unset,
|
||||
Object? speedLimit = _unset,
|
||||
Object? maxSpeedLimit = _unset,
|
||||
Object? displayId = _unset,
|
||||
Object? displayVersion = _unset,
|
||||
}) {
|
||||
return ScooterState(
|
||||
connectionStatus: connectionStatus ?? this.connectionStatus,
|
||||
authenticated: authenticated ?? this.authenticated,
|
||||
canWrite: canWrite ?? this.canWrite,
|
||||
errorMessage: errorMessage == _unset
|
||||
? this.errorMessage
|
||||
: errorMessage as String?,
|
||||
connectionStep:
|
||||
connectionStep == _unset ? this.connectionStep : connectionStep as int?,
|
||||
speed: speed == _unset ? this.speed : speed as double?,
|
||||
voltage: voltage == _unset ? this.voltage : voltage as double?,
|
||||
current: current == _unset ? this.current : current as double?,
|
||||
power: power == _unset ? this.power : power as double?,
|
||||
tripDistance:
|
||||
tripDistance == _unset ? this.tripDistance : tripDistance as double?,
|
||||
odometer: odometer == _unset ? this.odometer : odometer as double?,
|
||||
batteryLevel:
|
||||
batteryLevel == _unset ? this.batteryLevel : batteryLevel as int?,
|
||||
batteryTemperature: batteryTemperature == _unset
|
||||
? this.batteryTemperature
|
||||
: batteryTemperature as int?,
|
||||
batteryCycles:
|
||||
batteryCycles == _unset ? this.batteryCycles : batteryCycles as int?,
|
||||
motorTemperature: motorTemperature == _unset
|
||||
? this.motorTemperature
|
||||
: motorTemperature as int?,
|
||||
controllerTemperature: controllerTemperature == _unset
|
||||
? this.controllerTemperature
|
||||
: controllerTemperature as int?,
|
||||
gear: gear == _unset ? this.gear : gear as int?,
|
||||
locked: locked == _unset ? this.locked : locked as bool?,
|
||||
headlight: headlight == _unset ? this.headlight : headlight as bool?,
|
||||
atmosphereLight: atmosphereLight == _unset
|
||||
? this.atmosphereLight
|
||||
: atmosphereLight as bool?,
|
||||
cruiseControl:
|
||||
cruiseControl == _unset ? this.cruiseControl : cruiseControl as bool?,
|
||||
leftTurnSignal: leftTurnSignal == _unset
|
||||
? this.leftTurnSignal
|
||||
: leftTurnSignal as bool?,
|
||||
rightTurnSignal: rightTurnSignal == _unset
|
||||
? this.rightTurnSignal
|
||||
: rightTurnSignal as bool?,
|
||||
imperial: imperial == _unset ? this.imperial : imperial as bool?,
|
||||
speedLimit: speedLimit == _unset ? this.speedLimit : speedLimit as int?,
|
||||
maxSpeedLimit:
|
||||
maxSpeedLimit == _unset ? this.maxSpeedLimit : maxSpeedLimit as int?,
|
||||
displayId: displayId == _unset ? this.displayId : displayId as String?,
|
||||
displayVersion: displayVersion == _unset
|
||||
? this.displayVersion
|
||||
: displayVersion as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
/// Apollo BLE protocol: pure functions and small data structures.
|
||||
///
|
||||
/// Everything here is free of Flutter and BLE plugin dependencies so it can be
|
||||
/// unit-tested with `flutter test` and no hardware.
|
||||
///
|
||||
/// Confidence terms used in comments:
|
||||
/// STATICALLY CONFIRMED recovered from Apollo Scooters 4.8.18340 (libapollo-ble.so)
|
||||
/// LIVE VERIFIED confirmed against a physical Apollo Go
|
||||
/// INFERRED strongly suggested, not directly proven
|
||||
/// UNKNOWN not yet mapped
|
||||
///
|
||||
/// Nothing in this file is LIVE VERIFIED yet.
|
||||
library;
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'scooter.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GATT identifiers. STATICALLY CONFIRMED.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Binary data service.
|
||||
const apolloDataServiceUuid = '0000f1f0-0000-1000-8000-00805f9b34fb';
|
||||
|
||||
/// Phone -> scooter binary writes (set-base packets, keepalive).
|
||||
const apolloDataTxUuid = '0000f1f1-0000-1000-8000-00805f9b34fb';
|
||||
|
||||
/// Scooter -> phone binary notifications (monitor and base frames).
|
||||
const apolloDataRxUuid = '0000f1f2-0000-1000-8000-00805f9b34fb';
|
||||
|
||||
/// AT / config / authentication service.
|
||||
const apolloAtServiceUuid = '0000f2f0-0000-1000-8000-00805f9b34fb';
|
||||
|
||||
/// Phone -> scooter ASCII AT commands.
|
||||
const apolloAtTxUuid = '0000f2f1-0000-1000-8000-00805f9b34fb';
|
||||
|
||||
/// Scooter -> phone ASCII AT responses.
|
||||
const apolloAtRxUuid = '0000f2f2-0000-1000-8000-00805f9b34fb';
|
||||
|
||||
/// Fixed keepalive packet. STATICALLY CONFIRMED as a constant.
|
||||
///
|
||||
/// It does NOT carry an [apolloCrc16] checksum (CRC of A5 02 would be 21 FB),
|
||||
/// so never regenerate it with the frame CRC routine.
|
||||
///
|
||||
/// LIVE VERIFIED 2026-09-21 on an Apollo Go: the scooter sends NOTHING on F1F2
|
||||
/// after PIN success until this packet is written to F1F1. Once it has been
|
||||
/// sent every second the scooter streams alternating cmd0/cmd1 frames at
|
||||
/// roughly 5 Hz, each split into a 20 byte and a 5 byte notification.
|
||||
/// Whether the stream stops when keepalives stop is not yet verified.
|
||||
final Uint8List apolloKeepalivePacket = Uint8List.fromList([0xA5, 0x02, 0xFD, 0x5A]);
|
||||
|
||||
/// Keepalive cadence that produced continuous telemetry on the Apollo Go.
|
||||
const apolloDefaultKeepaliveInterval = Duration(seconds: 1);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRC. STATICALLY CONFIRMED.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// CRC-16 with poly 0x8005 (reflected 0xA001), init 0xFFFF, RefIn/RefOut,
|
||||
/// XorOut 0. This is CRC-16/MODBUS.
|
||||
int apolloCrc16(Iterable<int> bytes) {
|
||||
var crc = 0xFFFF;
|
||||
for (final byte in bytes) {
|
||||
crc ^= byte & 0xFF;
|
||||
for (var i = 0; i < 8; i++) {
|
||||
if ((crc & 1) != 0) {
|
||||
crc = (crc >> 1) ^ 0xA001;
|
||||
} else {
|
||||
crc >>= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return crc & 0xFFFF;
|
||||
}
|
||||
|
||||
/// True when the trailing little-endian CRC covers frame[0..length-3].
|
||||
/// STATICALLY CONFIRMED coverage: the header byte IS included.
|
||||
bool apolloValidateFrame(Uint8List frame) {
|
||||
if (frame.length < 3) return false;
|
||||
final expected = apolloCrc16(frame.sublist(0, frame.length - 2));
|
||||
final stored = frame[frame.length - 2] | (frame[frame.length - 1] << 8);
|
||||
return expected == stored;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frame lengths and buffering. STATICALLY CONFIRMED lengths.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const apolloMonitorFrameLength = 25;
|
||||
const apolloBaseFrameLength = 25;
|
||||
|
||||
/// Expected total length for a frame starting with [head], [second], or null
|
||||
/// when the combination is not supported by this decoder.
|
||||
///
|
||||
/// A5 02 -> 4 bytes, other A5 -> 8 bytes, AA/AB with cmd 00/01 -> 25 bytes.
|
||||
int? apolloFrameLength(int head, int second) {
|
||||
switch (head) {
|
||||
case 0xA5:
|
||||
return second == 0x02 ? 4 : 8;
|
||||
case 0xAA:
|
||||
case 0xAB:
|
||||
return (second == 0x00 || second == 0x01) ? 25 : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
bool _isApolloHead(int b) => b == 0xA5 || b == 0xAA || b == 0xAB;
|
||||
|
||||
/// Stateful decoder that turns arbitrary BLE notification chunks into
|
||||
/// complete CRC-valid frames.
|
||||
///
|
||||
/// Never assumes one notification equals one frame. Resynchronises by dropping
|
||||
/// a single byte at a time so one bad byte cannot wedge parsing.
|
||||
///
|
||||
/// A5 frames: their integrity check is UNKNOWN (the keepalive constant does not
|
||||
/// match [apolloCrc16]). Only the exact keepalive packet is recognised and
|
||||
/// emitted; any other A5 sequence is skipped one byte at a time.
|
||||
class ApolloFrameBuffer {
|
||||
ApolloFrameBuffer({this.maxLength = 4096});
|
||||
|
||||
final int maxLength;
|
||||
final List<int> _buf = <int>[];
|
||||
|
||||
int get length => _buf.length;
|
||||
|
||||
void clear() => _buf.clear();
|
||||
|
||||
/// Appends [data] and returns every complete valid frame now available.
|
||||
List<Uint8List> add(Uint8List data) {
|
||||
_buf.addAll(data);
|
||||
if (_buf.length > maxLength) {
|
||||
_buf.removeRange(0, _buf.length - maxLength);
|
||||
}
|
||||
|
||||
final frames = <Uint8List>[];
|
||||
while (_buf.isNotEmpty) {
|
||||
if (!_isApolloHead(_buf[0])) {
|
||||
_buf.removeAt(0);
|
||||
continue;
|
||||
}
|
||||
if (_buf.length < 2) break; // need the second byte to size the frame
|
||||
|
||||
final expectedLength = apolloFrameLength(_buf[0], _buf[1]);
|
||||
if (expectedLength == null) {
|
||||
_buf.removeAt(0);
|
||||
continue;
|
||||
}
|
||||
if (_buf.length < expectedLength) break;
|
||||
|
||||
final candidate = Uint8List.fromList(_buf.sublist(0, expectedLength));
|
||||
final valid = candidate[0] == 0xA5
|
||||
? _isKeepalive(candidate)
|
||||
: apolloValidateFrame(candidate);
|
||||
if (!valid) {
|
||||
_buf.removeAt(0);
|
||||
continue;
|
||||
}
|
||||
frames.add(candidate);
|
||||
_buf.removeRange(0, expectedLength);
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
static bool _isKeepalive(Uint8List f) {
|
||||
if (f.length != apolloKeepalivePacket.length) return false;
|
||||
for (var i = 0; i < f.length; i++) {
|
||||
if (f[i] != apolloKeepalivePacket[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AT channel: PIN authentication. STATICALLY CONFIRMED.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Builds `AT+PWD[pin]` with NO trailing CR/LF.
|
||||
Uint8List buildApolloPinCommand(String pin) {
|
||||
if (!RegExp(r'^\d{6}$').hasMatch(pin)) {
|
||||
throw ArgumentError.value(pin, 'pin', 'must be exactly six digits');
|
||||
}
|
||||
return Uint8List.fromList('AT+PWD[$pin]'.codeUnits);
|
||||
}
|
||||
|
||||
/// Masked form for logs. Never log the real PIN.
|
||||
const apolloPinCommandMasked = 'AT+PWD[******]';
|
||||
|
||||
/// Searches [data] for `OK+PWD:Y` / `OK+PWD:N` after stripping NUL bytes, the
|
||||
/// same way Apollo's native parser does. Returns null when neither is present.
|
||||
AuthenticationResult? parseApolloPinResponse(Uint8List data) {
|
||||
final text = String.fromCharCodes(data.where((b) => b != 0));
|
||||
if (text.contains('OK+PWD:Y')) return AuthenticationResult.success;
|
||||
if (text.contains('OK+PWD:N')) return AuthenticationResult.invalidCredential;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Small accumulating buffer for fragmented ASCII AT responses.
|
||||
class ApolloAtBuffer {
|
||||
ApolloAtBuffer({this.maxLength = 512});
|
||||
|
||||
final int maxLength;
|
||||
final List<int> _buf = <int>[];
|
||||
|
||||
/// Appends [data] (dropping NUL bytes) and returns the full buffered text.
|
||||
String add(Uint8List data) {
|
||||
_buf.addAll(data.where((b) => b != 0));
|
||||
if (_buf.length > maxLength) {
|
||||
_buf.removeRange(0, _buf.length - maxLength);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
String get text => String.fromCharCodes(_buf);
|
||||
|
||||
Uint8List get bytes => Uint8List.fromList(_buf);
|
||||
|
||||
void clear() => _buf.clear();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Monitor frame (cmd = 0). STATICALLY CONFIRMED layout.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
typedef ApolloMonitorData = ({
|
||||
int gear,
|
||||
int batteryLevel,
|
||||
int rawSpeed,
|
||||
double speed,
|
||||
double voltage,
|
||||
double current,
|
||||
double power,
|
||||
int motorTemperature,
|
||||
int controllerTemperature,
|
||||
double tripDistance,
|
||||
double odometer,
|
||||
bool headlight,
|
||||
bool atmosphereLight,
|
||||
bool cruiseControl,
|
||||
bool bootMode,
|
||||
bool imperial,
|
||||
bool unlocked,
|
||||
bool leftTurnSignal,
|
||||
bool rightTurnSignal,
|
||||
});
|
||||
|
||||
int signedByte(int value) => value >= 128 ? value - 256 : value;
|
||||
|
||||
int _u16be(Uint8List f, int i) => (f[i] << 8) | f[i + 1];
|
||||
|
||||
int _s16be(Uint8List f, int i) {
|
||||
final u = _u16be(f, i);
|
||||
return u >= 0x8000 ? u - 0x10000 : u;
|
||||
}
|
||||
|
||||
bool _bit(int value, int bit) => (value & (1 << bit)) != 0;
|
||||
|
||||
void _requireFrame(Uint8List frame, int cmd, String name) {
|
||||
if (frame.length != 25) {
|
||||
throw FormatException('$name frame must be 25 bytes, got ${frame.length}');
|
||||
}
|
||||
if (frame[0] != 0xAA && frame[0] != 0xAB) {
|
||||
throw FormatException('$name frame has bad head 0x${frame[0].toRadixString(16)}');
|
||||
}
|
||||
if (frame[1] != cmd) {
|
||||
throw FormatException('$name frame has cmd ${frame[1]}, expected $cmd');
|
||||
}
|
||||
if (!apolloValidateFrame(frame)) {
|
||||
throw const FormatException('CRC mismatch');
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes normalised speed from the raw monitor value.
|
||||
///
|
||||
/// Apollo divides by 1000 and then, when the base frame's
|
||||
/// [ApolloBaseData.internalSpeedScalingFlag] is set, multiplies by 100.
|
||||
/// Effective raw/10 vs raw/1000 depends on the physical Go: LIVE VERIFICATION PENDING.
|
||||
double apolloScaleSpeed(int rawSpeed, {required bool internalSpeedScalingFlag}) {
|
||||
var speed = rawSpeed / 1000.0;
|
||||
if (internalSpeedScalingFlag) speed *= 100.0;
|
||||
return speed;
|
||||
}
|
||||
|
||||
ApolloMonitorData parseApolloMonitorFrame(
|
||||
Uint8List frame, {
|
||||
required bool internalSpeedScalingFlag,
|
||||
}) {
|
||||
_requireFrame(frame, 0x00, 'Monitor');
|
||||
// frame[2], frame[3]: unknown metadata, preserved and CRC-covered only.
|
||||
|
||||
final speedA = _u16be(frame, 6);
|
||||
final speedB = _u16be(frame, 8);
|
||||
final rawSpeed = speedA > speedB ? speedA : speedB;
|
||||
|
||||
final voltage = _u16be(frame, 10) / 10.0;
|
||||
final current = _s16be(frame, 12) / 64.0;
|
||||
final power = ((voltage * current) * 10).roundToDouble() / 10.0;
|
||||
|
||||
final flagsA = frame[21];
|
||||
final flagsB = frame[22];
|
||||
|
||||
return (
|
||||
gear: frame[4],
|
||||
// Live Apollo Go at full charge reported 0x64 (100). INFERRED percentage.
|
||||
batteryLevel: frame[5],
|
||||
rawSpeed: rawSpeed,
|
||||
speed: apolloScaleSpeed(rawSpeed, internalSpeedScalingFlag: internalSpeedScalingFlag),
|
||||
voltage: voltage,
|
||||
current: current,
|
||||
power: power,
|
||||
motorTemperature: signedByte(frame[14]),
|
||||
controllerTemperature: signedByte(frame[15]),
|
||||
tripDistance: _u16be(frame, 16) / 10.0,
|
||||
odometer: ((frame[18] << 16) | (frame[19] << 8) | frame[20]) / 10.0,
|
||||
atmosphereLight: _bit(flagsA, 1),
|
||||
unlocked: _bit(flagsA, 3),
|
||||
rightTurnSignal: _bit(flagsA, 5),
|
||||
leftTurnSignal: _bit(flagsA, 6),
|
||||
headlight: _bit(flagsA, 7),
|
||||
cruiseControl: _bit(flagsB, 2),
|
||||
imperial: _bit(flagsB, 5),
|
||||
bootMode: _bit(flagsB, 6),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base / config frame (cmd = 1). STATICALLY CONFIRMED layout.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
typedef ApolloBaseData = ({
|
||||
int limitCruise,
|
||||
int limitMode1,
|
||||
int limitMode2,
|
||||
int limitMode3,
|
||||
int batteryTemperature,
|
||||
int totalBatteryCapacity,
|
||||
int remainingBatteryCapacity,
|
||||
int batteryCycles,
|
||||
String? displayId,
|
||||
String displayVersion,
|
||||
bool faultEnable,
|
||||
bool e9,
|
||||
bool f1,
|
||||
bool f2,
|
||||
bool ctrlFaultEarlyWarning,
|
||||
bool e1,
|
||||
bool e2,
|
||||
bool e3,
|
||||
bool e4,
|
||||
bool e7,
|
||||
bool ctrlSn,
|
||||
bool ctrlMp3,
|
||||
bool ctrlRgb,
|
||||
bool ctrlBms,
|
||||
bool internalSpeedScalingFlag,
|
||||
});
|
||||
|
||||
/// Live Apollo Go base frame observations (2026-09-21): capability byte 0x1F,
|
||||
/// so [ApolloBaseData.internalSpeedScalingFlag] is TRUE and monitor speed is
|
||||
/// effectively raw/10. Battery temperature read 0xD8 (-40), capacities, cycle
|
||||
/// count and display id/version were all zero: INFERRED "not fitted" sentinels.
|
||||
ApolloBaseData parseApolloBaseFrame(Uint8List frame) {
|
||||
_requireFrame(frame, 0x01, 'Base');
|
||||
// frame[2]: unknown/reserved, preserved and CRC-covered only.
|
||||
// frame[11]: unused by the current Apollo parser.
|
||||
|
||||
final flagsA = frame[8];
|
||||
final flagsB = frame[9];
|
||||
final caps = frame[10];
|
||||
|
||||
final idHi = frame[18];
|
||||
final idLo = frame[19];
|
||||
final displayId = (idHi == 0 && idLo == 0)
|
||||
? null
|
||||
: '${idHi.toRadixString(16).padLeft(2, '0')}${idLo.toRadixString(16).padLeft(2, '0')}';
|
||||
|
||||
return (
|
||||
limitCruise: frame[3],
|
||||
limitMode1: frame[4],
|
||||
limitMode2: frame[5],
|
||||
limitMode3: frame[6],
|
||||
batteryTemperature: signedByte(frame[7]),
|
||||
totalBatteryCapacity: _u16be(frame, 12),
|
||||
remainingBatteryCapacity: _u16be(frame, 14),
|
||||
batteryCycles: _u16be(frame, 16),
|
||||
displayId: displayId,
|
||||
displayVersion: 'V${frame[20]}.${frame[21]}.${frame[22]}',
|
||||
faultEnable: _bit(flagsA, 7),
|
||||
e9: _bit(flagsA, 1),
|
||||
f1: _bit(flagsA, 2),
|
||||
f2: _bit(flagsA, 3),
|
||||
// Apollo 4.8.18340 maps both f2 and ctrlFaultEarlyWarning
|
||||
// to frame[8] bit 3. Preserve until live/protocol evidence says otherwise.
|
||||
ctrlFaultEarlyWarning: _bit(flagsA, 3),
|
||||
e1: _bit(flagsB, 1),
|
||||
e2: _bit(flagsB, 2),
|
||||
e3: _bit(flagsB, 3),
|
||||
e4: _bit(flagsB, 4),
|
||||
e7: _bit(flagsB, 7),
|
||||
ctrlSn: _bit(caps, 0),
|
||||
ctrlMp3: _bit(caps, 1),
|
||||
ctrlRgb: _bit(caps, 2),
|
||||
ctrlBms: _bit(caps, 3),
|
||||
internalSpeedScalingFlag: _bit(caps, 4),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Outbound set-base packet. STATICALLY CONFIRMED layout, NOT LIVE VERIFIED.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Builds the 10-byte `AB 00 0A FLAGS LC M1 M2 M3 CRC_LO CRC_HI` packet.
|
||||
///
|
||||
/// FLAGS: bits 0-1 gear, bit 2 headlight, bit 3 atmosphere light,
|
||||
/// bit 4 cruise, bit 5 boot mode, bit 6 imperial, bit 7 unlocked.
|
||||
///
|
||||
/// Every field must come from CURRENT scooter state (read-modify-write).
|
||||
/// Callers must never invent speed limits or unit preferences.
|
||||
Uint8List buildApolloSetBasePacket({
|
||||
required int gearPosition,
|
||||
required bool headlight,
|
||||
required bool atmosphereLight,
|
||||
required bool cruiseControl,
|
||||
required bool bootMode,
|
||||
required bool imperial,
|
||||
required bool unlocked,
|
||||
required int limitCruise,
|
||||
required int limitMode1,
|
||||
required int limitMode2,
|
||||
required int limitMode3,
|
||||
}) {
|
||||
for (final (name, v) in [
|
||||
('limitCruise', limitCruise),
|
||||
('limitMode1', limitMode1),
|
||||
('limitMode2', limitMode2),
|
||||
('limitMode3', limitMode3),
|
||||
]) {
|
||||
if (v < 0 || v > 0xFF) throw ArgumentError.value(v, name, 'must fit one byte');
|
||||
}
|
||||
|
||||
var flags = gearPosition & 0x03;
|
||||
if (headlight) flags |= 1 << 2;
|
||||
if (atmosphereLight) flags |= 1 << 3;
|
||||
if (cruiseControl) flags |= 1 << 4;
|
||||
if (bootMode) flags |= 1 << 5;
|
||||
if (imperial) flags |= 1 << 6;
|
||||
if (unlocked) flags |= 1 << 7;
|
||||
|
||||
final data = <int>[
|
||||
0xAB, 0x00, 0x0A, flags,
|
||||
limitCruise, limitMode1, limitMode2, limitMode3,
|
||||
];
|
||||
final crc = apolloCrc16(data);
|
||||
return Uint8List.fromList([...data, crc & 0xFF, (crc >> 8) & 0xFF]);
|
||||
}
|
||||
|
||||
/// Hex dump helper for debug logs: `AA 00 0A ...`.
|
||||
String apolloHex(Iterable<int> bytes) => bytes
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
||||
.join(' ');
|
||||
@@ -0,0 +1,528 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/scooter_device.dart';
|
||||
import '../models/scooter_state.dart';
|
||||
import '../services/ble_client.dart';
|
||||
import '../services/protocol_log.dart';
|
||||
import 'apollo_protocol.dart';
|
||||
import 'scooter.dart';
|
||||
|
||||
/// HARD WRITE GATE.
|
||||
///
|
||||
/// Control writes to F1F1 stay disabled until the set-base packet has been
|
||||
/// compared byte-for-byte against an HCI capture of the official Apollo app
|
||||
/// talking to a physical Apollo Go (brief sections 78 and 79). Static analysis
|
||||
/// alone is not sufficient to flip this.
|
||||
const bool enableApolloControlWrites = true;
|
||||
|
||||
class _PendingControl {
|
||||
_PendingControl({required this.minRevision, required this.isSatisfied});
|
||||
final int minRevision;
|
||||
final bool Function(ApolloMonitorData) isSatisfied;
|
||||
final completer = Completer<void>();
|
||||
}
|
||||
|
||||
/// Apollo Go (and protocol-compatible Apollo models) over BLE.
|
||||
///
|
||||
/// Flow: connect -> discover -> verify F1/F2 -> subscribe F1F2 and F2F2 ->
|
||||
/// authenticate (AT+PWD) -> wait for pushed cmd0 + cmd1 -> ready.
|
||||
class ApolloScooter extends Scooter {
|
||||
ApolloScooter(
|
||||
this._ble,
|
||||
this.device, {
|
||||
this.controlWritesEnabled = enableApolloControlWrites,
|
||||
Duration? keepaliveInterval = apolloDefaultKeepaliveInterval,
|
||||
this.responseTimeout = const Duration(seconds: 2),
|
||||
}) : _keepaliveInterval = keepaliveInterval; // ignore: prefer_initializing_formals
|
||||
|
||||
final BleClient _ble;
|
||||
final ScooterDevice device;
|
||||
|
||||
/// Per-instance mirror of [enableApolloControlWrites]; tests may enable it.
|
||||
final bool controlWritesEnabled;
|
||||
|
||||
/// Keepalive cadence. LIVE VERIFIED: the Apollo Go pushes no telemetry
|
||||
/// until keepalives start, so this defaults on. Null disables it.
|
||||
Duration? get keepaliveInterval => _keepaliveInterval;
|
||||
Duration? _keepaliveInterval;
|
||||
|
||||
/// Changes the keepalive cadence at runtime. Null stops it.
|
||||
void setKeepaliveInterval(Duration? interval) {
|
||||
_keepaliveInterval = interval;
|
||||
_keepaliveTimer?.cancel();
|
||||
_keepaliveTimer = null;
|
||||
if (interval != null && _sessionActive) {
|
||||
_log('KEEPALIVE every ${interval.inMilliseconds} ms');
|
||||
_sendKeepalive();
|
||||
_keepaliveTimer = Timer.periodic(interval, (_) => _sendKeepalive());
|
||||
} else {
|
||||
_log('KEEPALIVE off');
|
||||
}
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
final Duration responseTimeout;
|
||||
|
||||
/// Primary Apollo detection uses advertised service UUIDs because owners can
|
||||
/// rename the scooter with AT+NAME. Either Apollo service is enough for v1.
|
||||
static bool matches(ScooterDevice d) =>
|
||||
d.advertisedServiceUuids.contains(apolloDataServiceUuid) ||
|
||||
d.advertisedServiceUuids.contains(apolloAtServiceUuid);
|
||||
|
||||
/// Weak fallback hint only. Never sufficient on its own to send anything.
|
||||
static bool nameHint(ScooterDevice d) => d.name.toLowerCase().contains('apollo');
|
||||
|
||||
// ---- observable state ----------------------------------------------------
|
||||
|
||||
ScooterState _state = const ScooterState();
|
||||
bool _disposed = false;
|
||||
|
||||
@override
|
||||
ScooterState get state => _state;
|
||||
|
||||
@override
|
||||
bool get canWrite =>
|
||||
controlWritesEnabled &&
|
||||
_sessionActive &&
|
||||
_state.authenticated &&
|
||||
_monitor != null &&
|
||||
_base != null;
|
||||
|
||||
// ---- per-connection session state (all reset on ANY disconnect) ----------
|
||||
|
||||
bool _sessionActive = false;
|
||||
StreamSubscription<BleConnectionState>? _connSub;
|
||||
StreamSubscription<Uint8List>? _dataSub;
|
||||
StreamSubscription<Uint8List>? _atSub;
|
||||
|
||||
final _frames = ApolloFrameBuffer();
|
||||
final _at = ApolloAtBuffer();
|
||||
|
||||
ApolloMonitorData? _monitor;
|
||||
Uint8List? _lastMonitorFrame;
|
||||
ApolloBaseData? _base;
|
||||
|
||||
/// Incremented for every CRC-valid cmd0 frame. Control confirmation only
|
||||
/// accepts frames newer than the one seen before the write was sent.
|
||||
int _monitorRevision = 0;
|
||||
|
||||
Completer<AuthenticationResult>? _pendingAuth;
|
||||
_PendingControl? _pendingControl;
|
||||
Future<void> _writeQueue = Future.value();
|
||||
Timer? _keepaliveTimer;
|
||||
|
||||
// ---- connection ----------------------------------------------------------
|
||||
|
||||
@override
|
||||
Future<void> connect() async {
|
||||
if (_sessionActive || _state.connectionStatus == ScooterConnectionStatus.connecting) {
|
||||
throw StateError('Already connected or connecting');
|
||||
}
|
||||
_set(const ScooterState(
|
||||
connectionStatus: ScooterConnectionStatus.connecting,
|
||||
connectionStep: 0,
|
||||
));
|
||||
|
||||
try {
|
||||
await _ble.connect(device.id);
|
||||
_connSub = _ble.connectionState.listen((s) {
|
||||
if (s == BleConnectionState.disconnected) _onConnectionLost();
|
||||
});
|
||||
|
||||
_set(_state.copyWith(connectionStep: 1));
|
||||
final services = await _ble.discoverServices();
|
||||
_set(_state.copyWith(connectionStep: 2));
|
||||
_verifyGatt(services);
|
||||
|
||||
// Subscribe BEFORE authenticating: base/monitor frames are pushed, and
|
||||
// there is no "read base params" command to request them later.
|
||||
_set(_state.copyWith(connectionStep: 3));
|
||||
_dataSub = (await _ble.subscribe(
|
||||
serviceUuid: apolloDataServiceUuid,
|
||||
characteristicUuid: apolloDataRxUuid,
|
||||
))
|
||||
.listen(_onData);
|
||||
_set(_state.copyWith(connectionStep: 4));
|
||||
_atSub = (await _ble.subscribe(
|
||||
serviceUuid: apolloAtServiceUuid,
|
||||
characteristicUuid: apolloAtRxUuid,
|
||||
))
|
||||
.listen(_onAt);
|
||||
|
||||
_sessionActive = true;
|
||||
_set(_state.copyWith(
|
||||
connectionStatus: ScooterConnectionStatus.connected,
|
||||
connectionStep: null,
|
||||
));
|
||||
|
||||
final interval = _keepaliveInterval;
|
||||
if (interval != null) {
|
||||
_keepaliveTimer = Timer.periodic(interval, (_) => _sendKeepalive());
|
||||
}
|
||||
} catch (e) {
|
||||
_resetSession(e);
|
||||
try {
|
||||
await _ble.disconnect();
|
||||
} catch (_) {}
|
||||
_set(ScooterState(
|
||||
connectionStatus: ScooterConnectionStatus.error,
|
||||
errorMessage: 'Could not connect: $e',
|
||||
));
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void _verifyGatt(Map<String, Set<String>> services) {
|
||||
final data = services[apolloDataServiceUuid];
|
||||
final at = services[apolloAtServiceUuid];
|
||||
final missing = <String>[
|
||||
if (data == null) 'F1F0 service',
|
||||
if (data != null && !data.contains(apolloDataTxUuid)) 'F1F1',
|
||||
if (data != null && !data.contains(apolloDataRxUuid)) 'F1F2',
|
||||
if (at == null) 'F2F0 service',
|
||||
if (at != null && !at.contains(apolloAtTxUuid)) 'F2F1',
|
||||
if (at != null && !at.contains(apolloAtRxUuid)) 'F2F2',
|
||||
];
|
||||
if (missing.isNotEmpty) {
|
||||
throw StateError('Not an Apollo scooter: missing ${missing.join(', ')}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {
|
||||
_resetSession(const ScooterConnectionLostException('Disconnected by user'));
|
||||
try {
|
||||
await _ble.disconnect();
|
||||
} catch (_) {}
|
||||
_set(const ScooterState(connectionStatus: ScooterConnectionStatus.disconnected));
|
||||
}
|
||||
|
||||
void _onConnectionLost() {
|
||||
if (!_sessionActive) return;
|
||||
_log('CONNECTION LOST');
|
||||
_resetSession(const ScooterConnectionLostException());
|
||||
_set(const ScooterState(
|
||||
connectionStatus: ScooterConnectionStatus.error,
|
||||
errorMessage: 'Connection to the scooter was lost.',
|
||||
));
|
||||
}
|
||||
|
||||
/// Clears EVERYTHING tied to the physical BLE link. Data from an earlier
|
||||
/// connection is never reused.
|
||||
void _resetSession(Object error) {
|
||||
_sessionActive = false;
|
||||
|
||||
_keepaliveTimer?.cancel();
|
||||
_keepaliveTimer = null;
|
||||
|
||||
_connSub?.cancel();
|
||||
_dataSub?.cancel();
|
||||
_atSub?.cancel();
|
||||
_connSub = _dataSub = _atSub = null;
|
||||
|
||||
_frames.clear();
|
||||
_at.clear();
|
||||
|
||||
_monitor = null;
|
||||
_lastMonitorFrame = null;
|
||||
_base = null;
|
||||
_monitorRevision = 0;
|
||||
|
||||
final auth = _pendingAuth;
|
||||
_pendingAuth = null;
|
||||
if (auth != null && !auth.isCompleted) auth.completeError(error);
|
||||
|
||||
final control = _pendingControl;
|
||||
_pendingControl = null;
|
||||
if (control != null && !control.completer.isCompleted) {
|
||||
control.completer.completeError(error);
|
||||
}
|
||||
// Queued controls fail on their turn because canWrite is now false.
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disposeScooter() async {
|
||||
await disconnect();
|
||||
_disposed = true;
|
||||
dispose();
|
||||
}
|
||||
|
||||
// ---- authentication ------------------------------------------------------
|
||||
|
||||
@override
|
||||
Future<AuthenticationResult> authenticate(String credential) async {
|
||||
if (!_sessionActive) throw StateError('Not connected');
|
||||
if (_pendingAuth != null) throw StateError('Authentication already in progress');
|
||||
|
||||
final command = buildApolloPinCommand(credential); // validates format
|
||||
final completer = Completer<AuthenticationResult>();
|
||||
_pendingAuth = completer;
|
||||
_at.clear();
|
||||
_set(_state.copyWith(
|
||||
connectionStatus: ScooterConnectionStatus.authenticating,
|
||||
authenticated: false,
|
||||
errorMessage: null,
|
||||
));
|
||||
|
||||
try {
|
||||
_log('TX AT $apolloPinCommandMasked');
|
||||
await _ble.write(
|
||||
serviceUuid: apolloAtServiceUuid,
|
||||
characteristicUuid: apolloAtTxUuid,
|
||||
value: command,
|
||||
);
|
||||
final result = await completer.future.timeout(
|
||||
responseTimeout,
|
||||
onTimeout: () => throw TimeoutException('The scooter did not respond to the PIN'),
|
||||
);
|
||||
if (result == AuthenticationResult.success) {
|
||||
_set(_state.copyWith(
|
||||
connectionStatus: ScooterConnectionStatus.authenticated,
|
||||
authenticated: true,
|
||||
));
|
||||
_evaluateReady();
|
||||
} else {
|
||||
_set(_state.copyWith(
|
||||
connectionStatus: ScooterConnectionStatus.connected,
|
||||
authenticated: false,
|
||||
));
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (_sessionActive) {
|
||||
_set(_state.copyWith(
|
||||
connectionStatus: ScooterConnectionStatus.connected,
|
||||
authenticated: false,
|
||||
));
|
||||
}
|
||||
rethrow;
|
||||
} finally {
|
||||
if (identical(_pendingAuth, completer)) _pendingAuth = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _onAt(Uint8List data) {
|
||||
_log('RX AT raw ${apolloHex(data)} "${String.fromCharCodes(data.where((b) => b >= 0x20 && b < 0x7F))}"');
|
||||
_at.add(data);
|
||||
final result = parseApolloPinResponse(_at.bytes);
|
||||
if (result == null) return;
|
||||
_at.clear();
|
||||
final pending = _pendingAuth;
|
||||
if (pending != null && !pending.isCompleted) pending.complete(result);
|
||||
}
|
||||
|
||||
// ---- inbound binary frames -----------------------------------------------
|
||||
|
||||
void _onData(Uint8List data) {
|
||||
_log('RX DATA raw ${apolloHex(data)} (${data.length} bytes)');
|
||||
final frames = _frames.add(data);
|
||||
if (frames.isEmpty) {
|
||||
_log('RX DATA no complete frame yet, ${_frames.length} bytes buffered');
|
||||
}
|
||||
for (final frame in frames) {
|
||||
if (frame[0] == 0xA5) {
|
||||
_log('RX DATA ${apolloHex(frame)} (keepalive)');
|
||||
continue;
|
||||
}
|
||||
switch (frame[1]) {
|
||||
case 0x00:
|
||||
_log('RX DATA ${apolloHex(frame)} cmd0 monitor crc=ok');
|
||||
_handleMonitor(frame);
|
||||
case 0x01:
|
||||
_log('RX DATA ${apolloHex(frame)} cmd1 base crc=ok');
|
||||
_handleBase(frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handleMonitor(Uint8List frame) {
|
||||
_lastMonitorFrame = frame;
|
||||
final m = parseApolloMonitorFrame(
|
||||
frame,
|
||||
internalSpeedScalingFlag: _base?.internalSpeedScalingFlag ?? false,
|
||||
);
|
||||
_monitor = m;
|
||||
_monitorRevision++;
|
||||
_publishTelemetry();
|
||||
|
||||
final pending = _pendingControl;
|
||||
if (pending != null &&
|
||||
!pending.completer.isCompleted &&
|
||||
_monitorRevision > pending.minRevision &&
|
||||
pending.isSatisfied(m)) {
|
||||
pending.completer.complete();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleBase(Uint8List frame) {
|
||||
_base = parseApolloBaseFrame(frame);
|
||||
// Speed scaling depends on the base frame, so re-derive the last monitor
|
||||
// snapshot. This is not a new monitor frame: revision is unchanged.
|
||||
final last = _lastMonitorFrame;
|
||||
if (last != null) {
|
||||
_monitor = parseApolloMonitorFrame(
|
||||
last,
|
||||
internalSpeedScalingFlag: _base!.internalSpeedScalingFlag,
|
||||
);
|
||||
}
|
||||
_publishTelemetry();
|
||||
}
|
||||
|
||||
void _publishTelemetry() {
|
||||
final m = _monitor;
|
||||
final b = _base;
|
||||
var s = _state;
|
||||
if (m != null) {
|
||||
s = s.copyWith(
|
||||
gear: m.gear,
|
||||
batteryLevel: m.batteryLevel,
|
||||
speed: m.speed,
|
||||
voltage: m.voltage,
|
||||
current: m.current,
|
||||
power: m.power,
|
||||
motorTemperature: m.motorTemperature,
|
||||
controllerTemperature: m.controllerTemperature,
|
||||
tripDistance: m.tripDistance,
|
||||
odometer: m.odometer,
|
||||
locked: !m.unlocked,
|
||||
headlight: m.headlight,
|
||||
atmosphereLight: m.atmosphereLight,
|
||||
cruiseControl: m.cruiseControl,
|
||||
leftTurnSignal: m.leftTurnSignal,
|
||||
rightTurnSignal: m.rightTurnSignal,
|
||||
imperial: m.imperial,
|
||||
);
|
||||
}
|
||||
if (b != null) {
|
||||
final limits = [b.limitMode1, b.limitMode2, b.limitMode3];
|
||||
final gear = m?.gear;
|
||||
s = s.copyWith(
|
||||
// INFERRED: gear byte 1..3 selects mode 1..3 limits.
|
||||
speedLimit: gear != null && gear >= 1 && gear <= 3 ? limits[gear - 1] : null,
|
||||
maxSpeedLimit: limits.reduce((a, c) => a > c ? a : c),
|
||||
batteryTemperature: b.batteryTemperature,
|
||||
batteryCycles: b.batteryCycles,
|
||||
displayId: b.displayId,
|
||||
displayVersion: b.displayVersion,
|
||||
);
|
||||
}
|
||||
_state = s;
|
||||
_evaluateReady();
|
||||
}
|
||||
|
||||
/// READY requires PIN success AND a CRC-valid cmd0 AND a CRC-valid cmd1.
|
||||
void _evaluateReady() {
|
||||
var s = _state;
|
||||
if (s.authenticated && _monitor != null && _base != null) {
|
||||
s = s.copyWith(connectionStatus: ScooterConnectionStatus.ready);
|
||||
}
|
||||
_set(s.copyWith(canWrite: canWrite));
|
||||
}
|
||||
|
||||
// ---- control writes (gated, serialized, confirmed) -----------------------
|
||||
|
||||
@override
|
||||
Future<void> unlock() => _control('unlock', (m) => m.unlocked, unlocked: true);
|
||||
|
||||
@override
|
||||
Future<void> lock() => _control('lock', (m) => !m.unlocked, unlocked: false);
|
||||
|
||||
@override
|
||||
Future<void> setHeadlight(bool enabled) =>
|
||||
_control('headlight=$enabled', (m) => m.headlight == enabled, headlight: enabled);
|
||||
|
||||
/// Queues a control change. Only one base write is in flight at a time and
|
||||
/// each one reads its snapshot only when its turn comes, so a second tap can
|
||||
/// never resend stale state from before the first write landed.
|
||||
Future<void> _control(
|
||||
String name,
|
||||
bool Function(ApolloMonitorData) isSatisfied, {
|
||||
bool? unlocked,
|
||||
bool? headlight,
|
||||
}) {
|
||||
final run = _writeQueue.then(
|
||||
(_) => _runControl(name, isSatisfied, unlocked: unlocked, headlight: headlight),
|
||||
);
|
||||
_writeQueue = run.catchError((_) {});
|
||||
return run;
|
||||
}
|
||||
|
||||
Future<void> _runControl(
|
||||
String name,
|
||||
bool Function(ApolloMonitorData) isSatisfied, {
|
||||
bool? unlocked,
|
||||
bool? headlight,
|
||||
}) async {
|
||||
if (!controlWritesEnabled) {
|
||||
throw StateError('Control writes are disabled in this build');
|
||||
}
|
||||
if (!canWrite) {
|
||||
throw StateError('Scooter is not ready for control writes');
|
||||
}
|
||||
final m = _monitor!;
|
||||
final b = _base!;
|
||||
|
||||
if (isSatisfied(m)) {
|
||||
_log('CONTROL $name: already in requested state, no write');
|
||||
return;
|
||||
}
|
||||
|
||||
// Read-modify-write: every field not being changed comes from the most
|
||||
// recent frames. gearPosition <- monitor gear byte is INFERRED.
|
||||
final packet = buildApolloSetBasePacket(
|
||||
gearPosition: m.gear,
|
||||
headlight: headlight ?? m.headlight,
|
||||
atmosphereLight: m.atmosphereLight,
|
||||
cruiseControl: m.cruiseControl,
|
||||
bootMode: m.bootMode,
|
||||
imperial: m.imperial,
|
||||
unlocked: unlocked ?? m.unlocked,
|
||||
limitCruise: b.limitCruise,
|
||||
limitMode1: b.limitMode1,
|
||||
limitMode2: b.limitMode2,
|
||||
limitMode3: b.limitMode3,
|
||||
);
|
||||
|
||||
final pending = _PendingControl(minRevision: _monitorRevision, isSatisfied: isSatisfied);
|
||||
_pendingControl = pending;
|
||||
try {
|
||||
_log('TX DATA ${apolloHex(packet)} set-base ($name)');
|
||||
await _ble.write(
|
||||
serviceUuid: apolloDataServiceUuid,
|
||||
characteristicUuid: apolloDataTxUuid,
|
||||
value: packet,
|
||||
);
|
||||
await pending.completer.future.timeout(
|
||||
responseTimeout,
|
||||
onTimeout: () => throw TimeoutException('Scooter did not confirm state change'),
|
||||
);
|
||||
} finally {
|
||||
if (identical(_pendingControl, pending)) _pendingControl = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sendKeepalive() async {
|
||||
if (!_sessionActive) return;
|
||||
try {
|
||||
_log('TX DATA ${apolloHex(apolloKeepalivePacket)} keepalive');
|
||||
await _ble.write(
|
||||
serviceUuid: apolloDataServiceUuid,
|
||||
characteristicUuid: apolloDataTxUuid,
|
||||
value: apolloKeepalivePacket,
|
||||
);
|
||||
} catch (e) {
|
||||
_log('keepalive failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers -------------------------------------------------------------
|
||||
|
||||
void _set(ScooterState s) {
|
||||
_state = s;
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
void _log(String message) => ProtocolLog.instance.log('Apollo', message);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/scooter_state.dart';
|
||||
|
||||
/// Result of a local scooter authentication attempt.
|
||||
///
|
||||
/// A missing response is deliberately NOT a value here: it surfaces as a
|
||||
/// [TimeoutException] so callers can tell "wrong PIN" from "no answer".
|
||||
enum AuthenticationResult {
|
||||
success,
|
||||
invalidCredential,
|
||||
}
|
||||
|
||||
/// Base class for every supported scooter.
|
||||
///
|
||||
/// The current [state] is always available synchronously and the UI rebuilds
|
||||
/// through [ListenableBuilder]. There is no replay problem because there is
|
||||
/// no stream to miss.
|
||||
abstract class Scooter extends ChangeNotifier {
|
||||
ScooterState get state;
|
||||
|
||||
Future<void> connect();
|
||||
Future<void> disconnect();
|
||||
|
||||
Future<AuthenticationResult> authenticate(String credential);
|
||||
|
||||
Future<void> lock();
|
||||
Future<void> unlock();
|
||||
|
||||
Future<void> setHeadlight(bool enabled);
|
||||
|
||||
/// True only when the implementation holds enough fresh scooter state to
|
||||
/// build a safe control write AND control writes are enabled for this build.
|
||||
bool get canWrite;
|
||||
|
||||
/// Disconnects and releases every resource. The object is unusable after.
|
||||
Future<void> disposeScooter();
|
||||
}
|
||||
|
||||
/// Thrown into any pending operation when the BLE link drops underneath it.
|
||||
class ScooterConnectionLostException implements Exception {
|
||||
const ScooterConnectionLostException([this.message = 'Connection to the scooter was lost']);
|
||||
final String message;
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
@@ -0,0 +1,754 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/scooter_state.dart';
|
||||
import '../settings.dart';
|
||||
import '../theme.dart';
|
||||
|
||||
/// Values shared by every cluster layout, already converted for display.
|
||||
class ClusterData {
|
||||
ClusterData({required this.state, required this.imperial});
|
||||
|
||||
final ScooterState state;
|
||||
final bool imperial;
|
||||
|
||||
static const _kmToMi = 0.621371;
|
||||
|
||||
double? _dist(double? km) => km == null ? null : (imperial ? km * _kmToMi : km);
|
||||
|
||||
double? get speed => _dist(state.speed);
|
||||
double? get trip => _dist(state.tripDistance);
|
||||
double? get odometer => _dist(state.odometer);
|
||||
String get speedUnit => imperial ? 'mph' : 'km/h';
|
||||
String get distUnit => imperial ? 'mi' : 'km';
|
||||
|
||||
/// Gauge full scale: the highest configured mode limit, or 30 as a fallback.
|
||||
double get gaugeMax {
|
||||
final limit = (state.maxSpeedLimit ?? 30).toDouble();
|
||||
final v = imperial ? limit * _kmToMi : limit;
|
||||
return v <= 0 ? 1 : v;
|
||||
}
|
||||
|
||||
double get speedFraction => ((speed ?? 0) / gaugeMax).clamp(0.0, 1.0);
|
||||
|
||||
double get power => state.power ?? 0;
|
||||
double get drivePower => power > 0 ? power : 0;
|
||||
double get regenPower => power < 0 ? -power : 0;
|
||||
|
||||
String get speedText => speed == null ? '--' : speed!.round().toString();
|
||||
static String fmt(num? v, [int decimals = 1]) => v == null ? '--' : v.toStringAsFixed(decimals);
|
||||
}
|
||||
|
||||
/// Callbacks the layouts use for the control toggles.
|
||||
class ClusterActions {
|
||||
const ClusterActions({
|
||||
required this.toggleHeadlight,
|
||||
required this.toggleLock,
|
||||
required this.readOnlyTap,
|
||||
required this.busy,
|
||||
});
|
||||
|
||||
final VoidCallback toggleHeadlight;
|
||||
final VoidCallback toggleLock;
|
||||
final void Function(String name) readOnlyTap;
|
||||
final bool busy;
|
||||
}
|
||||
|
||||
Widget buildCluster(ClusterLayout layout, ClusterData d, ClusterActions a) => switch (layout) {
|
||||
ClusterLayout.arc => ArcCluster(data: d, actions: a),
|
||||
ClusterLayout.digital => DigitalCluster(data: d, actions: a),
|
||||
ClusterLayout.tiles => TilesCluster(data: d, actions: a),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Arc layout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ArcCluster extends StatelessWidget {
|
||||
const ArcCluster({super.key, required this.data, required this.actions});
|
||||
final ClusterData data;
|
||||
final ClusterActions actions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = data.state;
|
||||
return LayoutBuilder(
|
||||
builder: (context, box) {
|
||||
final gaugeSize = math.min(box.maxWidth - 32, box.maxHeight * 0.5).clamp(220.0, 360.0);
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
child: Column(
|
||||
children: [
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: gaugeSize,
|
||||
height: gaugeSize,
|
||||
child: CustomPaint(
|
||||
painter: _GaugePainter(
|
||||
fraction: data.speedFraction,
|
||||
battery: s.batteryLevel,
|
||||
accent: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ModeBadge(gear: s.gear),
|
||||
const SizedBox(height: 4),
|
||||
BigNumber(text: data.speedText, size: 108),
|
||||
Text(data.speedUnit, style: const TextStyle(color: OsColors.textDim, fontSize: 18)),
|
||||
const SizedBox(height: 14),
|
||||
ValueWithUnit(value: ClusterData.fmt(data.odometer), unit: data.distUnit, size: 26),
|
||||
const CapsLabel('ODOMETER'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(top: 8, left: 0, child: Stat(value: ClusterData.fmt(data.regenPower, 0), unit: 'W', label: 'REGEN')),
|
||||
Positioned(top: 8, right: 0, child: Stat(value: ClusterData.fmt(data.drivePower, 0), unit: 'W', label: 'POWER', align: CrossAxisAlignment.end)),
|
||||
Positioned(bottom: 0, left: 0, child: Stat(value: '${s.controllerTemperature ?? '--'}', unit: '°C', label: 'CONTROLLER')),
|
||||
Positioned(bottom: 0, right: 0, child: Stat(value: '${s.motorTemperature ?? '--'}', unit: '°C', label: 'MOTOR', align: CrossAxisAlignment.end)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
ControlsRow(state: s, actions: actions),
|
||||
const SizedBox(height: 20),
|
||||
BatteryBar(level: s.batteryLevel, voltage: s.voltage),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Stat(value: ClusterData.fmt(data.trip), unit: data.distUnit, label: 'TRIP')),
|
||||
Expanded(child: Stat(value: ClusterData.fmt(s.current), unit: 'A', label: 'CURRENT', align: CrossAxisAlignment.center)),
|
||||
Expanded(child: Stat(value: ClusterData.fmt(s.voltage), unit: 'V', label: 'VOLTAGE', align: CrossAxisAlignment.end)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SignalRow(left: s.leftTurnSignal ?? false, right: s.rightTurnSignal ?? false),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GaugePainter extends CustomPainter {
|
||||
_GaugePainter({required this.fraction, required this.battery, required this.accent});
|
||||
|
||||
final double fraction;
|
||||
final int? battery;
|
||||
final Color accent;
|
||||
|
||||
static const _sweep = 1.5 * math.pi;
|
||||
static const _start = 0.75 * math.pi;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final stroke = size.width * 0.07;
|
||||
final rect = Rect.fromLTWH(stroke / 2, stroke / 2, size.width - stroke, size.height - stroke);
|
||||
|
||||
canvas.drawArc(
|
||||
rect, _start, _sweep, false,
|
||||
Paint()
|
||||
..color = OsColors.track
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = stroke
|
||||
..strokeCap = StrokeCap.round,
|
||||
);
|
||||
if (fraction > 0) {
|
||||
canvas.drawArc(
|
||||
rect, _start, _sweep * fraction, false,
|
||||
Paint()
|
||||
..shader = SweepGradient(
|
||||
startAngle: _start,
|
||||
endAngle: _start + _sweep,
|
||||
colors: [accent.withValues(alpha: 0.55), accent],
|
||||
).createShader(rect)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = stroke
|
||||
..strokeCap = StrokeCap.round,
|
||||
);
|
||||
}
|
||||
|
||||
final b = battery;
|
||||
if (b != null) {
|
||||
const gapStart = _start + _sweep;
|
||||
const gapSweep = 0.5 * math.pi;
|
||||
const margin = 0.09;
|
||||
final inner = rect.deflate(stroke * 0.15);
|
||||
canvas.drawArc(
|
||||
inner, gapStart + margin, gapSweep - 2 * margin, false,
|
||||
Paint()
|
||||
..color = OsColors.track
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = stroke * 0.7
|
||||
..strokeCap = StrokeCap.round,
|
||||
);
|
||||
final frac = (b / 100).clamp(0.0, 1.0);
|
||||
if (frac > 0) {
|
||||
canvas.drawArc(
|
||||
inner, gapStart + margin, (gapSweep - 2 * margin) * frac, false,
|
||||
Paint()
|
||||
..color = OsColors.batteryColor(b)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = stroke * 0.7
|
||||
..strokeCap = StrokeCap.round,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_GaugePainter old) =>
|
||||
old.fraction != fraction || old.battery != battery || old.accent != accent;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Digital layout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class DigitalCluster extends StatelessWidget {
|
||||
const DigitalCluster({super.key, required this.data, required this.actions});
|
||||
final ClusterData data;
|
||||
final ClusterActions actions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = data.state;
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
ModeBadge(gear: s.gear),
|
||||
SignalRow(left: s.leftTurnSignal ?? false, right: s.rightTurnSignal ?? false, compact: true),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
BigNumber(text: data.speedText, size: 150),
|
||||
const SizedBox(width: 10),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 22),
|
||||
child: Text(data.speedUnit, style: const TextStyle(color: OsColors.textDim, fontSize: 22)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: SizedBox(
|
||||
height: 14,
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(color: OsColors.track),
|
||||
FractionallySizedBox(
|
||||
widthFactor: data.speedFraction,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(colors: [accent.withValues(alpha: 0.6), accent]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const CapsLabel('0'),
|
||||
CapsLabel('${data.gaugeMax.round()} ${data.speedUnit}'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Stat(value: ClusterData.fmt(data.drivePower, 0), unit: 'W', label: 'POWER')),
|
||||
Expanded(child: Stat(value: ClusterData.fmt(data.regenPower, 0), unit: 'W', label: 'REGEN', align: CrossAxisAlignment.center)),
|
||||
Expanded(child: Stat(value: ClusterData.fmt(s.current), unit: 'A', label: 'CURRENT', align: CrossAxisAlignment.end)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
BatteryBar(level: s.batteryLevel, voltage: s.voltage),
|
||||
const SizedBox(height: 24),
|
||||
ControlsRow(state: s, actions: actions),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Stat(value: ClusterData.fmt(data.trip), unit: data.distUnit, label: 'TRIP')),
|
||||
Expanded(child: Stat(value: ClusterData.fmt(data.odometer), unit: data.distUnit, label: 'ODOMETER', align: CrossAxisAlignment.end)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Stat(value: '${s.motorTemperature ?? '--'}', unit: '°C', label: 'MOTOR')),
|
||||
Expanded(child: Stat(value: '${s.controllerTemperature ?? '--'}', unit: '°C', label: 'CONTROLLER', align: CrossAxisAlignment.end)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tiles layout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class TilesCluster extends StatelessWidget {
|
||||
const TilesCluster({super.key, required this.data, required this.actions});
|
||||
final ClusterData data;
|
||||
final ClusterActions actions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = data.state;
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Tile(
|
||||
accent: true,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const CapsLabel('SPEED'),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
BigNumber(text: data.speedText, size: 88),
|
||||
const SizedBox(width: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(data.speedUnit, style: const TextStyle(color: OsColors.textDim, fontSize: 18)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
ModeBadge(gear: s.gear),
|
||||
const SizedBox(height: 12),
|
||||
SignalRow(left: s.leftTurnSignal ?? false, right: s.rightTurnSignal ?? false, compact: true),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Tile(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const CapsLabel('BATTERY'),
|
||||
const SizedBox(height: 8),
|
||||
ValueWithUnit(value: '${s.batteryLevel ?? '--'}', unit: '%', size: 40),
|
||||
const SizedBox(height: 10),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
minHeight: 8,
|
||||
value: ((s.batteryLevel ?? 0) / 100).clamp(0.0, 1.0),
|
||||
backgroundColor: OsColors.track,
|
||||
color: OsColors.batteryColor(s.batteryLevel),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ValueWithUnit(value: ClusterData.fmt(s.voltage), unit: 'V', size: 18),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Tile(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const CapsLabel('POWER'),
|
||||
const SizedBox(height: 8),
|
||||
ValueWithUnit(value: ClusterData.fmt(data.drivePower, 0), unit: 'W', size: 40),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.bolt_rounded, size: 16, color: accent),
|
||||
const SizedBox(width: 4),
|
||||
Text('${ClusterData.fmt(s.current)} A', style: const TextStyle(color: OsColors.textDim)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.replay_rounded, size: 16, color: OsColors.good),
|
||||
const SizedBox(width: 4),
|
||||
Text('${ClusterData.fmt(data.regenPower, 0)} W regen', style: const TextStyle(color: OsColors.textDim)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _SmallTile(label: 'TRIP', value: ClusterData.fmt(data.trip), unit: data.distUnit)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _SmallTile(label: 'ODOMETER', value: ClusterData.fmt(data.odometer), unit: data.distUnit)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _SmallTile(label: 'MOTOR', value: '${s.motorTemperature ?? '--'}', unit: '°C')),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _SmallTile(label: 'CONTROLLER', value: '${s.controllerTemperature ?? '--'}', unit: '°C')),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Tile(child: ControlsRow(state: s, actions: actions)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SmallTile extends StatelessWidget {
|
||||
const _SmallTile({required this.label, required this.value, required this.unit});
|
||||
final String label;
|
||||
final String value;
|
||||
final String unit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Tile(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CapsLabel(label),
|
||||
const SizedBox(height: 6),
|
||||
ValueWithUnit(value: value, unit: unit, size: 30),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class Tile extends StatelessWidget {
|
||||
const Tile({super.key, required this.child, this.accent = false});
|
||||
final Widget child;
|
||||
final bool accent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: OsColors.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: accent ? primary.withValues(alpha: 0.5) : OsColors.surfaceHigh),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared pieces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class BigNumber extends StatelessWidget {
|
||||
const BigNumber({super.key, required this.text, required this.size});
|
||||
final String text;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: size,
|
||||
height: 1.0,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -size * 0.035,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class ValueWithUnit extends StatelessWidget {
|
||||
const ValueWithUnit({super.key, required this.value, required this.unit, required this.size});
|
||||
final String value;
|
||||
final String unit;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Text.rich(
|
||||
TextSpan(
|
||||
text: value,
|
||||
style: TextStyle(
|
||||
fontSize: size,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.0,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: ' $unit',
|
||||
style: TextStyle(fontSize: size * 0.5, fontWeight: FontWeight.w400, color: OsColors.textDim),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class Stat extends StatelessWidget {
|
||||
const Stat({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.unit,
|
||||
required this.label,
|
||||
this.align = CrossAxisAlignment.start,
|
||||
});
|
||||
|
||||
final String value;
|
||||
final String unit;
|
||||
final String label;
|
||||
final CrossAxisAlignment align;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
crossAxisAlignment: align,
|
||||
children: [
|
||||
ValueWithUnit(value: value, unit: unit, size: 30),
|
||||
const SizedBox(height: 2),
|
||||
CapsLabel(label),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class CapsLabel extends StatelessWidget {
|
||||
const CapsLabel(this.text, {super.key});
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
Text(text, style: const TextStyle(color: OsColors.textDim, fontSize: 11, letterSpacing: 1.2));
|
||||
}
|
||||
|
||||
class ModeBadge extends StatelessWidget {
|
||||
const ModeBadge({super.key, required this.gear});
|
||||
final int? gear;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
// INFERRED mode names for gears 1..3; falls back to the raw gear number.
|
||||
final (label, color) = switch (gear) {
|
||||
1 => ('Eco', OsColors.good),
|
||||
2 => ('Comfort', primary),
|
||||
3 => ('Sport', OsColors.bad),
|
||||
null => ('--', OsColors.surfaceHigh),
|
||||
final g => ('Gear $g', OsColors.surfaceHigh),
|
||||
};
|
||||
final fg = color.computeLuminance() > 0.5 ? OsColors.background : Colors.white;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 6),
|
||||
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(12)),
|
||||
child: Text(label, style: TextStyle(fontWeight: FontWeight.w800, fontSize: 17, color: fg)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ControlsRow extends StatelessWidget {
|
||||
const ControlsRow({super.key, required this.state, required this.actions});
|
||||
final ScooterState state;
|
||||
final ClusterActions actions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = state;
|
||||
final locked = s.locked ?? false;
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
RoundToggle(
|
||||
icon: Icons.highlight_rounded,
|
||||
active: s.headlight ?? false,
|
||||
tooltip: 'Headlight',
|
||||
onTap: actions.busy ? null : actions.toggleHeadlight,
|
||||
),
|
||||
RoundToggle(
|
||||
icon: Icons.light_mode_outlined,
|
||||
active: s.atmosphereLight ?? false,
|
||||
tooltip: 'Atmosphere light',
|
||||
onTap: () => actions.readOnlyTap('Atmosphere light'),
|
||||
),
|
||||
RoundToggle(
|
||||
icon: Icons.speed_rounded,
|
||||
active: s.cruiseControl ?? false,
|
||||
tooltip: 'Cruise control',
|
||||
onTap: () => actions.readOnlyTap('Cruise control'),
|
||||
),
|
||||
RoundToggle(
|
||||
icon: locked ? Icons.lock_rounded : Icons.lock_open_rounded,
|
||||
active: locked,
|
||||
activeColor: OsColors.bad,
|
||||
tooltip: locked ? 'Locked' : 'Unlocked',
|
||||
onTap: actions.busy ? null : actions.toggleLock,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RoundToggle extends StatelessWidget {
|
||||
const RoundToggle({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.active,
|
||||
required this.tooltip,
|
||||
this.onTap,
|
||||
this.activeColor,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final bool active;
|
||||
final String tooltip;
|
||||
final VoidCallback? onTap;
|
||||
final Color? activeColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = activeColor ?? Theme.of(context).colorScheme.primary;
|
||||
final bg = active ? color : OsColors.surfaceHigh;
|
||||
final fg = active
|
||||
? (color.computeLuminance() > 0.5 ? OsColors.background : Colors.white)
|
||||
: OsColors.textDim;
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: Material(
|
||||
color: bg,
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: onTap,
|
||||
child: SizedBox(width: 62, height: 62, child: Icon(icon, color: fg, size: 27)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Battery bar. Both readings are centred as a group inside the fill.
|
||||
class BatteryBar extends StatelessWidget {
|
||||
const BatteryBar({super.key, required this.level, required this.voltage});
|
||||
final int? level;
|
||||
final double? voltage;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l = level;
|
||||
final frac = l == null ? 0.0 : (l / 100).clamp(0.0, 1.0);
|
||||
final color = OsColors.batteryColor(l);
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 92,
|
||||
decoration: BoxDecoration(
|
||||
color: OsColors.surface,
|
||||
border: Border.all(color: OsColors.surfaceHigh, width: 2),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: frac,
|
||||
heightFactor: 1,
|
||||
child: Container(color: color.withValues(alpha: 0.28)),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
ValueWithUnit(value: l?.toString() ?? '--', unit: '%', size: 46),
|
||||
Container(
|
||||
width: 1,
|
||||
height: 40,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 22),
|
||||
color: OsColors.surfaceHigh,
|
||||
),
|
||||
ValueWithUnit(value: ClusterData.fmt(voltage), unit: 'V', size: 38),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Container(
|
||||
width: 8,
|
||||
height: 30,
|
||||
decoration: const BoxDecoration(
|
||||
color: OsColors.surfaceHigh,
|
||||
borderRadius: BorderRadius.horizontal(right: Radius.circular(4)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SignalRow extends StatelessWidget {
|
||||
const SignalRow({super.key, required this.left, required this.right, this.compact = false});
|
||||
final bool left;
|
||||
final bool right;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = compact ? 22.0 : 28.0;
|
||||
if (!left && !right && !compact) return SizedBox(height: size);
|
||||
return Row(
|
||||
mainAxisSize: compact ? MainAxisSize.min : MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Icon(Icons.arrow_back_rounded, size: size, color: left ? OsColors.good : OsColors.track),
|
||||
if (compact) const SizedBox(width: 8),
|
||||
Icon(Icons.arrow_forward_rounded, size: size, color: right ? OsColors.good : OsColors.track),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../services/protocol_log.dart';
|
||||
|
||||
/// In-app view of the persistent protocol log, for field debugging.
|
||||
class LogScreen extends StatelessWidget {
|
||||
const LogScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final log = ProtocolLog.instance;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Protocol log'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Copy all',
|
||||
icon: const Icon(Icons.copy),
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: log.lines.join('\n')));
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text('Log copied to clipboard')));
|
||||
}
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Clear',
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: log.clear,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListenableBuilder(
|
||||
listenable: log,
|
||||
builder: (context, _) {
|
||||
final lines = log.lines;
|
||||
return Column(
|
||||
children: [
|
||||
if (log.path != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: SelectableText(
|
||||
'adb pull ${log.path}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
reverse: true,
|
||||
itemCount: lines.length,
|
||||
itemBuilder: (context, i) {
|
||||
final line = lines[lines.length - 1 - i];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
|
||||
child: SelectableText(
|
||||
line,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/scooter_device.dart';
|
||||
import '../scooters/apollo_scooter.dart';
|
||||
import '../services/ble_client.dart';
|
||||
import '../services/demo_ble_client.dart';
|
||||
import '../theme.dart';
|
||||
import 'scooter_screen.dart';
|
||||
|
||||
class ScanScreen extends StatefulWidget {
|
||||
const ScanScreen({super.key, required this.ble});
|
||||
|
||||
final BleClient ble;
|
||||
|
||||
@override
|
||||
State<ScanScreen> createState() => _ScanScreenState();
|
||||
}
|
||||
|
||||
class _ScanScreenState extends State<ScanScreen> {
|
||||
Stream<List<ScooterDevice>>? _scan;
|
||||
|
||||
/// Development path: list every BLE device so a scooter that does not
|
||||
/// advertise F1F0/F2F0 can still be selected and classified after GATT
|
||||
/// discovery.
|
||||
bool _showAll = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scan = widget.ble.scan();
|
||||
}
|
||||
|
||||
void _restart() => setState(() => _scan = widget.ble.scan());
|
||||
|
||||
Future<void> _connect(ScooterDevice device, {BleClient? ble}) async {
|
||||
setState(() => _scan = null);
|
||||
await widget.ble.stopScan();
|
||||
if (!mounted) return;
|
||||
// Create the scooter ONCE. Route builders re-run on every rebuild (for
|
||||
// example a theme change), so constructing it inside the builder would
|
||||
// silently swap in a fresh, unconnected instance.
|
||||
final scooter = ApolloScooter(ble ?? widget.ble, device);
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => ScooterScreen(scooter: scooter)),
|
||||
);
|
||||
if (mounted) _restart();
|
||||
}
|
||||
|
||||
/// Replays real Apollo Go frames through a fake link so layouts and colours
|
||||
/// can be previewed without a vehicle nearby. Not linked from the UI for
|
||||
/// now; kept for development.
|
||||
// ignore: unused_element
|
||||
void _openDemo() => _connect(DemoBleClient.device, ble: DemoBleClient());
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('OpenMotion',
|
||||
style: TextStyle(fontSize: 32, fontWeight: FontWeight.w800, letterSpacing: -1)),
|
||||
SizedBox(height: 4),
|
||||
Text('Open source scooting!',
|
||||
style: TextStyle(color: OsColors.textDim)),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: _showAll ? 'Show scooters only' : 'Show all BLE devices',
|
||||
icon: Icon(_showAll ? Icons.filter_alt_off_rounded : Icons.filter_alt_rounded),
|
||||
onPressed: () => setState(() => _showAll = !_showAll),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Restart scan',
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
onPressed: _restart,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _scan == null
|
||||
? const SizedBox.shrink()
|
||||
: StreamBuilder<List<ScooterDevice>>(
|
||||
stream: _scan,
|
||||
builder: (context, snap) {
|
||||
if (snap.hasError) {
|
||||
return _Empty(
|
||||
icon: Icons.bluetooth_disabled_rounded,
|
||||
title: 'Bluetooth scan failed',
|
||||
message: '${snap.error}',
|
||||
action: FilledButton(onPressed: _restart, child: const Text('Retry')),
|
||||
);
|
||||
}
|
||||
final all = snap.data ?? const <ScooterDevice>[];
|
||||
final devices = (_showAll
|
||||
? all
|
||||
: all.where((d) => ApolloScooter.matches(d) || ApolloScooter.nameHint(d)))
|
||||
.toList()
|
||||
..sort((a, b) => b.rssi.compareTo(a.rssi));
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
_showAll ? 'ALL BLE DEVICES' : 'NEARBY SCOOTERS',
|
||||
style: const TextStyle(
|
||||
color: OsColors.textDim, fontSize: 12, letterSpacing: 1.2),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (devices.isEmpty)
|
||||
_Empty(
|
||||
icon: Icons.electric_scooter_rounded,
|
||||
title: 'Searching',
|
||||
message: 'Turn the scooter on and keep it nearby.',
|
||||
),
|
||||
for (final d in devices) ...[
|
||||
_DeviceCard(device: d, onConnect: () => _connect(d)),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeviceCard extends StatelessWidget {
|
||||
const _DeviceCard({required this.device, required this.onConnect});
|
||||
|
||||
final ScooterDevice device;
|
||||
final VoidCallback onConnect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isApollo = ApolloScooter.matches(device);
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
final bars = device.rssi > -60 ? 4 : (device.rssi > -70 ? 3 : (device.rssi > -80 ? 2 : 1));
|
||||
return Card(
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: onConnect,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: isApollo ? accent.withValues(alpha: 0.15) : OsColors.surfaceHigh,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Icon(
|
||||
isApollo ? Icons.electric_scooter_rounded : Icons.bluetooth_rounded,
|
||||
color: isApollo ? accent : OsColors.textDim,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
device.name.isEmpty ? 'Unnamed device' : device.name,
|
||||
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
isApollo
|
||||
? 'Apollo · ${device.rssi} dBm'
|
||||
: '${device.id} · ${device.rssi} dBm',
|
||||
style: const TextStyle(color: OsColors.textDim, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
switch (bars) {
|
||||
4 => Icons.signal_cellular_alt_rounded,
|
||||
3 => Icons.signal_cellular_alt_2_bar_rounded,
|
||||
_ => Icons.signal_cellular_alt_1_bar_rounded,
|
||||
},
|
||||
color: OsColors.textDim,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const Icon(Icons.chevron_right_rounded, color: OsColors.textDim),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Empty extends StatelessWidget {
|
||||
const _Empty({required this.icon, required this.title, required this.message, this.action});
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String message;
|
||||
final Widget? action;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 48, 24, 24),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(icon, size: 56, color: OsColors.track),
|
||||
const SizedBox(height: 16),
|
||||
Text(title, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 6),
|
||||
Text(message, textAlign: TextAlign.center, style: const TextStyle(color: OsColors.textDim)),
|
||||
if (action != null) ...[const SizedBox(height: 16), action!],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,730 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/scooter_state.dart';
|
||||
import '../scooters/apollo_protocol.dart';
|
||||
import '../scooters/apollo_scooter.dart';
|
||||
import '../scooters/scooter.dart';
|
||||
import '../services/pin_store.dart';
|
||||
import '../settings.dart';
|
||||
import '../theme.dart';
|
||||
import 'clusters.dart';
|
||||
import 'log_screen.dart';
|
||||
|
||||
class ScooterScreen extends StatefulWidget {
|
||||
const ScooterScreen({super.key, required this.scooter});
|
||||
|
||||
final ApolloScooter scooter;
|
||||
|
||||
@override
|
||||
State<ScooterScreen> createState() => _ScooterScreenState();
|
||||
}
|
||||
|
||||
class _ScooterScreenState extends State<ScooterScreen> {
|
||||
final _pin = TextEditingController();
|
||||
final _pinStore = PinStore();
|
||||
String? _pinError;
|
||||
bool _busy = false;
|
||||
bool _hasSavedPin = false;
|
||||
bool _autoAuthTried = false;
|
||||
|
||||
ApolloScooter get scooter => widget.scooter;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
scooter.addListener(_onScooterChanged);
|
||||
_loadSavedPin();
|
||||
scooter.connect().catchError((_) {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
scooter.removeListener(_onScooterChanged);
|
||||
_pin.dispose();
|
||||
scooter.disposeScooter();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ---- PIN -----------------------------------------------------------------
|
||||
|
||||
Future<void> _loadSavedPin() async {
|
||||
final saved = await _pinStore.read(scooter.device.id);
|
||||
if (!mounted || saved == null) return;
|
||||
setState(() {
|
||||
_pin.text = saved;
|
||||
_hasSavedPin = true;
|
||||
});
|
||||
_maybeAutoAuthenticate();
|
||||
}
|
||||
|
||||
void _onScooterChanged() => _maybeAutoAuthenticate();
|
||||
|
||||
void _maybeAutoAuthenticate() {
|
||||
if (_autoAuthTried || !_hasSavedPin || _busy) return;
|
||||
if (scooter.state.connectionStatus != ScooterConnectionStatus.connected) return;
|
||||
_autoAuthTried = true;
|
||||
_authenticate();
|
||||
}
|
||||
|
||||
Future<void> _forgetPin() async {
|
||||
await _pinStore.forget(scooter.device.id);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_hasSavedPin = false;
|
||||
_pin.clear();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _authenticate() async {
|
||||
final pin = _pin.text.trim();
|
||||
if (!RegExp(r'^\d{6}$').hasMatch(pin)) {
|
||||
setState(() => _pinError = 'Enter the six-digit scooter PIN.');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_pinError = null;
|
||||
_busy = true;
|
||||
});
|
||||
try {
|
||||
final result = await scooter.authenticate(pin);
|
||||
if (result == AuthenticationResult.invalidCredential) {
|
||||
setState(() => _pinError = 'Incorrect scooter PIN.');
|
||||
if (_hasSavedPin) await _forgetPin();
|
||||
} else {
|
||||
await _pinStore.save(scooter.device.id, pin);
|
||||
if (mounted) setState(() => _hasSavedPin = true);
|
||||
}
|
||||
} on TimeoutException {
|
||||
setState(() => _pinError = 'The scooter did not respond.');
|
||||
} on ScooterConnectionLostException {
|
||||
// Shown by the status overlay.
|
||||
} catch (e) {
|
||||
setState(() => _pinError = 'Authentication failed: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _reconnect() async {
|
||||
_autoAuthTried = false;
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await scooter.connect();
|
||||
} catch (_) {
|
||||
// Reflected in scooter.state and shown by the overlay.
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- controls ------------------------------------------------------------
|
||||
|
||||
Future<void> _run(Future<void> Function() action) async {
|
||||
if (!scooter.controlWritesEnabled) {
|
||||
_snack('Control writes are currently disabled in this build.');
|
||||
return;
|
||||
}
|
||||
if (!scooter.canWrite) {
|
||||
_snack('Scooter connection is still initializing... Try this action again shortly.');
|
||||
return;
|
||||
}
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await action();
|
||||
} on TimeoutException catch (e) {
|
||||
_snack(e.message ?? 'The scooter did not confirm the change.');
|
||||
} catch (e) {
|
||||
_snack('$e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _snack(String text) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(content: Text(text)));
|
||||
}
|
||||
|
||||
bool _isImperial(ScooterState s) => switch (AppSettings.instance.units) {
|
||||
UnitPreference.auto => s.imperial ?? false,
|
||||
UnitPreference.metric => false,
|
||||
UnitPreference.imperial => true,
|
||||
};
|
||||
|
||||
// ---- build ---------------------------------------------------------------
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: ListenableBuilder(
|
||||
listenable: Listenable.merge([scooter, AppSettings.instance]),
|
||||
builder: (context, _) {
|
||||
final s = scooter.state;
|
||||
final actions = ClusterActions(
|
||||
busy: _busy,
|
||||
toggleHeadlight: () => _run(() => scooter.setHeadlight(!(s.headlight ?? false))),
|
||||
toggleLock: () => _run((s.locked ?? false) ? scooter.unlock : scooter.lock),
|
||||
readOnlyTap: (name) => _snack('$name is read-only for now.'),
|
||||
);
|
||||
return Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
_topBar(s),
|
||||
Expanded(
|
||||
child: buildCluster(
|
||||
AppSettings.instance.layout,
|
||||
ClusterData(state: s, imperial: _isImperial(s)),
|
||||
actions,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
?_overlayFor(s),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _topBar(ScooterState s) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
scooter.device.name.isEmpty ? 'Scooter' : scooter.device.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 16),
|
||||
),
|
||||
_ConnectionPill(status: s.connectionStatus),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Settings and diagnostics',
|
||||
icon: const Icon(Icons.tune_rounded),
|
||||
onPressed: () => _openSettings(s),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Full-screen overlays for the states where the dashboard has nothing
|
||||
/// meaningful to show yet.
|
||||
Widget? _overlayFor(ScooterState s) {
|
||||
switch (s.connectionStatus) {
|
||||
case ScooterConnectionStatus.connecting:
|
||||
return _Overlay(child: _ConnectingCard(step: s.connectionStep ?? 0));
|
||||
case ScooterConnectionStatus.connected:
|
||||
case ScooterConnectionStatus.authenticating:
|
||||
return _Overlay(child: _pinCard(s));
|
||||
case ScooterConnectionStatus.error:
|
||||
case ScooterConnectionStatus.disconnected:
|
||||
return _Overlay(
|
||||
child: _MessageCard(
|
||||
icon: Icons.bluetooth_disabled_rounded,
|
||||
title: s.connectionStatus == ScooterConnectionStatus.error
|
||||
? 'Connection problem'
|
||||
: 'Disconnected',
|
||||
message: s.errorMessage ?? 'The scooter is not connected.',
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: _busy ? null : _reconnect,
|
||||
child: const Text('Reconnect'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
case ScooterConnectionStatus.authenticated:
|
||||
return const _Overlay(
|
||||
dim: 0.7,
|
||||
child: _MessageCard(
|
||||
icon: Icons.podcasts_rounded,
|
||||
title: 'PIN accepted',
|
||||
message: 'Waiting for the scooter to start streaming telemetry.',
|
||||
body: _Spinner(label: 'Usually under a second'),
|
||||
),
|
||||
);
|
||||
case ScooterConnectionStatus.ready:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _pinCard(ScooterState s) {
|
||||
final authenticating = s.connectionStatus == ScooterConnectionStatus.authenticating;
|
||||
return _MessageCard(
|
||||
icon: Icons.lock_outline_rounded,
|
||||
title: 'Enter scooter PIN',
|
||||
message: 'The six-digit Bluetooth PIN from your scooter.',
|
||||
body: TextField(
|
||||
controller: _pin,
|
||||
enabled: !authenticating && !_busy,
|
||||
autofocus: !_hasSavedPin,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 6,
|
||||
obscureText: true,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 30, letterSpacing: 14, fontWeight: FontWeight.w700),
|
||||
decoration: InputDecoration(counterText: '', hintText: '••••••', errorText: _pinError),
|
||||
onSubmitted: (_) => _authenticate(),
|
||||
),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: authenticating || _busy ? null : _authenticate,
|
||||
child: authenticating
|
||||
? const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
SizedBox(width: 8),
|
||||
Text('Unlocking...'),
|
||||
],
|
||||
)
|
||||
: const Text('Unlock Scooter'),
|
||||
),
|
||||
if (_hasSavedPin)
|
||||
TextButton(onPressed: _forgetPin, child: const Text('Forget saved PIN')),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _openSettings(ScooterState s) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: OsColors.surface,
|
||||
showDragHandle: true,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => ListenableBuilder(
|
||||
listenable: Listenable.merge([scooter, AppSettings.instance]),
|
||||
builder: (ctx, _) => StatefulBuilder(
|
||||
builder: (ctx, setSheet) => _SettingsSheet(
|
||||
scooter: scooter,
|
||||
hasSavedPin: _hasSavedPin,
|
||||
onForgetPin: () async {
|
||||
await _forgetPin();
|
||||
setSheet(() {});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Overlays and shared bits
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _ConnectionPill extends StatelessWidget {
|
||||
const _ConnectionPill({required this.status});
|
||||
final ScooterConnectionStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (label, color) = switch (status) {
|
||||
ScooterConnectionStatus.disconnected => ('Disconnected', OsColors.textDim),
|
||||
ScooterConnectionStatus.connecting => ('Connecting', OsColors.warn),
|
||||
ScooterConnectionStatus.connected => ('PIN required', OsColors.warn),
|
||||
ScooterConnectionStatus.authenticating => ('Checking PIN', OsColors.warn),
|
||||
ScooterConnectionStatus.authenticated => ('Waiting for data', OsColors.warn),
|
||||
ScooterConnectionStatus.ready => ('Connected', OsColors.good),
|
||||
ScooterConnectionStatus.error => ('Error', OsColors.bad),
|
||||
};
|
||||
return Row(
|
||||
children: [
|
||||
Container(width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 6),
|
||||
Text(label, style: const TextStyle(color: OsColors.textDim, fontSize: 12)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Overlay extends StatelessWidget {
|
||||
const _Overlay({required this.child, this.dim = 0.92});
|
||||
final Widget child;
|
||||
final double dim;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Positioned.fill(
|
||||
child: Container(
|
||||
color: OsColors.background.withValues(alpha: dim),
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: SingleChildScrollView(child: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Step-by-step view of what the app is doing while the link comes up.
|
||||
class _ConnectingCard extends StatelessWidget {
|
||||
const _ConnectingCard({required this.step});
|
||||
final int step;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
final steps = ScooterState.connectionSteps;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Icon(Icons.bluetooth_searching_rounded, size: 40, color: primary),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Connecting', textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w800)),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'This should only take a few moments.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: OsColors.textDim),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
for (var i = 0; i < steps.length; i++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: i < step
|
||||
? Icon(Icons.check_circle_rounded, color: OsColors.good, size: 22)
|
||||
: i == step
|
||||
? CircularProgressIndicator(strokeWidth: 2.5, color: primary)
|
||||
: const Icon(Icons.circle_outlined, color: OsColors.track, size: 22),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Text(
|
||||
steps[i],
|
||||
style: TextStyle(
|
||||
color: i <= step ? OsColors.text : OsColors.textDim,
|
||||
fontWeight: i == step ? FontWeight.w700 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Spinner extends StatelessWidget {
|
||||
const _Spinner({required this.label});
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(width: 48, height: 48, child: CircularProgressIndicator(strokeWidth: 3)),
|
||||
const SizedBox(height: 20),
|
||||
Text(label, style: const TextStyle(color: OsColors.textDim, fontSize: 16)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageCard extends StatelessWidget {
|
||||
const _MessageCard({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.message,
|
||||
this.body,
|
||||
this.actions = const [],
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String message;
|
||||
final Widget? body;
|
||||
final List<Widget> actions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Icon(icon, size: 40, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(height: 12),
|
||||
Text(title, textAlign: TextAlign.center, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w800)),
|
||||
const SizedBox(height: 6),
|
||||
Text(message, textAlign: TextAlign.center, style: const TextStyle(color: OsColors.textDim)),
|
||||
if (body != null) ...[const SizedBox(height: 20), body!],
|
||||
const SizedBox(height: 20),
|
||||
...actions,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsSheet extends StatelessWidget {
|
||||
const _SettingsSheet({
|
||||
required this.scooter,
|
||||
required this.hasSavedPin,
|
||||
required this.onForgetPin,
|
||||
});
|
||||
|
||||
final ApolloScooter scooter;
|
||||
final bool hasSavedPin;
|
||||
final VoidCallback onForgetPin;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = scooter.state;
|
||||
final settings = AppSettings.instance;
|
||||
return SafeArea(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(8, 4, 8, 12),
|
||||
child: Text('Settings', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800)),
|
||||
),
|
||||
const _SectionLabel('CLUSTER LAYOUT'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final layout in ClusterLayout.values) ...[
|
||||
Expanded(
|
||||
child: _LayoutChoice(
|
||||
layout: layout,
|
||||
selected: settings.layout == layout,
|
||||
onTap: () => settings.layout = layout,
|
||||
),
|
||||
),
|
||||
if (layout != ClusterLayout.values.last) const SizedBox(width: 10),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const _SectionLabel('ACCENT COLOR'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 8),
|
||||
child: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
for (final a in AccentColor.values)
|
||||
_ColorDot(
|
||||
color: a.color,
|
||||
label: a.label,
|
||||
selected: settings.accent == a,
|
||||
onTap: () => settings.accent = a,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const _SectionLabel('UNITS'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
|
||||
child: SegmentedButton<UnitPreference>(
|
||||
showSelectedIcon: false,
|
||||
segments: const [
|
||||
ButtonSegment(value: UnitPreference.auto, label: Text('Auto')),
|
||||
ButtonSegment(value: UnitPreference.metric, label: Text('km')),
|
||||
ButtonSegment(value: UnitPreference.imperial, label: Text('mi')),
|
||||
],
|
||||
selected: {settings.units},
|
||||
onSelectionChanged: (v) => settings.units = v.first,
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(8, 4, 8, 8),
|
||||
child: Text(
|
||||
'Auto follows the scooter. Distances assume native km until a ride confirms it.',
|
||||
style: TextStyle(color: OsColors.textDim, fontSize: 12),
|
||||
),
|
||||
),
|
||||
const Divider(height: 24),
|
||||
SwitchListTile(
|
||||
title: const Text('Keepalive'),
|
||||
subtitle: const Text('The scooter only streams data while it receives this every second.'),
|
||||
value: scooter.keepaliveInterval != null,
|
||||
onChanged: (v) => scooter.setKeepaliveInterval(v ? apolloDefaultKeepaliveInterval : null),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Forget saved PIN'),
|
||||
enabled: hasSavedPin,
|
||||
trailing: const Icon(Icons.delete_outline_rounded),
|
||||
onTap: hasSavedPin ? onForgetPin : null,
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Protocol log'),
|
||||
subtitle: const Text('Raw BLE traffic for debugging'),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const LogScreen()));
|
||||
},
|
||||
),
|
||||
const Divider(height: 24),
|
||||
const _SectionLabel('SCOOTER DETAILS'),
|
||||
_kv('Address', scooter.device.id),
|
||||
_kv('Speed limits', s.maxSpeedLimit == null ? '--' : 'current gear ${s.speedLimit ?? '--'}, max ${s.maxSpeedLimit}'),
|
||||
_kv('Battery temperature', s.batteryTemperature == null ? '--' : '${s.batteryTemperature} °C'),
|
||||
_kv('Battery cycles', '${s.batteryCycles ?? '--'}'),
|
||||
_kv('Display', '${s.displayId ?? 'none'} ${s.displayVersion ?? ''}'),
|
||||
_kv('Scooter unit bit', s.imperial == null ? '--' : (s.imperial! ? 'imperial' : 'metric')),
|
||||
_kv('Control writes', scooter.controlWritesEnabled ? (scooter.canWrite ? 'enabled' : 'waiting') : 'read-only build'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _kv(String k, String v) => ListTile(
|
||||
dense: true,
|
||||
title: Text(k),
|
||||
trailing: Text(v, style: const TextStyle(color: OsColors.textDim)),
|
||||
);
|
||||
}
|
||||
|
||||
class _SectionLabel extends StatelessWidget {
|
||||
const _SectionLabel(this.text);
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
|
||||
child: CapsLabel(text),
|
||||
);
|
||||
}
|
||||
|
||||
class _LayoutChoice extends StatelessWidget {
|
||||
const _LayoutChoice({required this.layout, required this.selected, required this.onTap});
|
||||
final ClusterLayout layout;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: OsColors.surfaceHigh,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: selected ? primary : Colors.transparent, width: 2),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(height: 44, child: _LayoutPreview(layout: layout, color: selected ? primary : OsColors.textDim)),
|
||||
const SizedBox(height: 8),
|
||||
Text(layout.label, style: const TextStyle(fontWeight: FontWeight.w700)),
|
||||
Text(layout.description, style: const TextStyle(color: OsColors.textDim, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tiny schematic of each layout for the picker.
|
||||
class _LayoutPreview extends StatelessWidget {
|
||||
const _LayoutPreview({required this.layout, required this.color});
|
||||
final ClusterLayout layout;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget bar(double w, double h) => Container(
|
||||
width: w,
|
||||
height: h,
|
||||
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(3)),
|
||||
);
|
||||
return switch (layout) {
|
||||
ClusterLayout.arc => Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: color, width: 4)),
|
||||
),
|
||||
),
|
||||
ClusterLayout.digital => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [bar(36, 18), const SizedBox(height: 6), bar(60, 6)],
|
||||
),
|
||||
ClusterLayout.tiles => Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Row(children: [bar(26, 16), const SizedBox(width: 4), bar(26, 16)]),
|
||||
const SizedBox(height: 4),
|
||||
Row(children: [bar(26, 16), const SizedBox(width: 4), bar(26, 16)]),
|
||||
],
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _ColorDot extends StatelessWidget {
|
||||
const _ColorDot({required this.color, required this.label, required this.selected, required this.onTap});
|
||||
final Color color;
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: label,
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: selected ? Colors.white : Colors.transparent, width: 3),
|
||||
),
|
||||
child: selected
|
||||
? Icon(Icons.check_rounded, color: color.computeLuminance() > 0.5 ? OsColors.background : Colors.white)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart' as fbp;
|
||||
|
||||
import '../models/scooter_device.dart';
|
||||
import 'protocol_log.dart';
|
||||
|
||||
enum BleConnectionState { disconnected, connected }
|
||||
|
||||
/// Thin boundary between OpenScooter and the underlying BLE plugin.
|
||||
///
|
||||
/// OpenScooter v1 supports EXACTLY ONE active scooter connection at a time.
|
||||
/// That is why [subscribe], [write] and [discoverServices] take no device
|
||||
/// identifier: they always act on the device passed to the last [connect].
|
||||
///
|
||||
/// Protocol code never imports the BLE plugin directly, so protocol tests run
|
||||
/// with `flutter test` and a fake subclass, without hardware.
|
||||
abstract class BleClient {
|
||||
/// Streams the current set of visible devices. Scanning starts on listen
|
||||
/// and stops when the subscription is cancelled.
|
||||
Stream<List<ScooterDevice>> scan();
|
||||
|
||||
Future<void> stopScan();
|
||||
|
||||
/// Connects to the device with the given platform identifier and makes it
|
||||
/// the single active device.
|
||||
Future<void> connect(String deviceId);
|
||||
|
||||
Future<void> disconnect();
|
||||
|
||||
/// Emits connection changes for the active device, INCLUDING unexpected
|
||||
/// disconnects. Scooter implementations must reset on `disconnected`.
|
||||
Stream<BleConnectionState> get connectionState;
|
||||
|
||||
/// Runs GATT discovery on the active device. Returns a map of lowercase
|
||||
/// 128-bit service UUID to the lowercase characteristic UUIDs it contains.
|
||||
Future<Map<String, Set<String>>> discoverServices();
|
||||
|
||||
/// Enables notifications on a characteristic and returns its value stream.
|
||||
Future<Stream<Uint8List>> subscribe({
|
||||
required String serviceUuid,
|
||||
required String characteristicUuid,
|
||||
});
|
||||
|
||||
Future<void> write({
|
||||
required String serviceUuid,
|
||||
required String characteristicUuid,
|
||||
required Uint8List value,
|
||||
});
|
||||
|
||||
/// Normalises any UUID spelling to lowercase 128-bit form for comparisons.
|
||||
static String normalizeUuid(String uuid) => fbp.Guid(uuid).str128.toLowerCase();
|
||||
}
|
||||
|
||||
/// Production [BleClient] backed by flutter_blue_plus.
|
||||
class FlutterBleClient extends BleClient {
|
||||
fbp.BluetoothDevice? _device;
|
||||
List<fbp.BluetoothService> _services = const [];
|
||||
StreamSubscription<fbp.BluetoothConnectionState>? _connSub;
|
||||
final _connState = StreamController<BleConnectionState>.broadcast();
|
||||
|
||||
@override
|
||||
Stream<BleConnectionState> get connectionState => _connState.stream;
|
||||
|
||||
@override
|
||||
Stream<List<ScooterDevice>> scan() {
|
||||
late StreamController<List<ScooterDevice>> controller;
|
||||
StreamSubscription<List<fbp.ScanResult>>? sub;
|
||||
|
||||
Future<void> start() async {
|
||||
try {
|
||||
var adapter = await fbp.FlutterBluePlus.adapterState.first;
|
||||
if (adapter != fbp.BluetoothAdapterState.on &&
|
||||
defaultTargetPlatform == TargetPlatform.android) {
|
||||
await fbp.FlutterBluePlus.turnOn();
|
||||
}
|
||||
adapter = await fbp.FlutterBluePlus.adapterState
|
||||
.where((s) => s == fbp.BluetoothAdapterState.on)
|
||||
.first
|
||||
.timeout(const Duration(seconds: 10));
|
||||
sub = fbp.FlutterBluePlus.scanResults.listen((results) {
|
||||
controller.add(results.map(_toDevice).toList());
|
||||
}, onError: controller.addError);
|
||||
_log('SCAN START');
|
||||
await fbp.FlutterBluePlus.startScan(
|
||||
continuousUpdates: true,
|
||||
continuousDivisor: 2,
|
||||
removeIfGone: const Duration(seconds: 6),
|
||||
);
|
||||
} catch (e) {
|
||||
controller.addError(e);
|
||||
}
|
||||
}
|
||||
|
||||
controller = StreamController<List<ScooterDevice>>(
|
||||
onListen: start,
|
||||
onCancel: () async {
|
||||
await sub?.cancel();
|
||||
await stopScan();
|
||||
},
|
||||
);
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
ScooterDevice _toDevice(fbp.ScanResult r) {
|
||||
final name = r.advertisementData.advName.isNotEmpty
|
||||
? r.advertisementData.advName
|
||||
: r.device.platformName;
|
||||
return ScooterDevice(
|
||||
id: r.device.remoteId.str,
|
||||
name: name,
|
||||
rssi: r.rssi,
|
||||
advertisedServiceUuids: r.advertisementData.serviceUuids
|
||||
.map((g) => g.str128.toLowerCase())
|
||||
.toSet(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stopScan() async {
|
||||
if (fbp.FlutterBluePlus.isScanningNow) {
|
||||
_log('SCAN STOP');
|
||||
await fbp.FlutterBluePlus.stopScan();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> connect(String deviceId) async {
|
||||
await disconnect();
|
||||
final device = fbp.BluetoothDevice.fromId(deviceId);
|
||||
_device = device;
|
||||
_log('BLE CONNECT $deviceId');
|
||||
|
||||
_connSub = device.connectionState.skip(1).listen((s) {
|
||||
_log('CONNECTION STATE $s');
|
||||
if (s == fbp.BluetoothConnectionState.disconnected) {
|
||||
_log('BLE DISCONNECTED $deviceId');
|
||||
_services = const [];
|
||||
_connState.add(BleConnectionState.disconnected);
|
||||
} else if (s == fbp.BluetoothConnectionState.connected) {
|
||||
_connState.add(BleConnectionState.connected);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
// FlutterBluePlus 2.x requires callers to declare which license they
|
||||
// operate under. OpenScooter is a non-commercial open source project.
|
||||
await device.connect(license: fbp.License.nonprofit);
|
||||
_log('CONNECTED mtu=${device.mtuNow}');
|
||||
} catch (e) {
|
||||
_log('CONNECT FAILED $e');
|
||||
await _connSub?.cancel();
|
||||
_connSub = null;
|
||||
_device = null;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {
|
||||
final device = _device;
|
||||
_device = null;
|
||||
_services = const [];
|
||||
await _connSub?.cancel();
|
||||
_connSub = null;
|
||||
if (device != null) {
|
||||
_log('BLE DISCONNECT ${device.remoteId.str}');
|
||||
try {
|
||||
await device.disconnect();
|
||||
} catch (_) {
|
||||
// Already gone. Nothing to do.
|
||||
}
|
||||
_connState.add(BleConnectionState.disconnected);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, Set<String>>> discoverServices() async {
|
||||
final device = _requireDevice();
|
||||
_services = await device.discoverServices();
|
||||
final map = <String, Set<String>>{};
|
||||
for (final s in _services) {
|
||||
map[s.uuid.str128.toLowerCase()] =
|
||||
s.characteristics.map((c) => c.uuid.str128.toLowerCase()).toSet();
|
||||
for (final c in s.characteristics) {
|
||||
final p = c.properties;
|
||||
final props = [
|
||||
if (p.read) 'read',
|
||||
if (p.write) 'write',
|
||||
if (p.writeWithoutResponse) 'writeNoRsp',
|
||||
if (p.notify) 'notify',
|
||||
if (p.indicate) 'indicate',
|
||||
].join(',');
|
||||
_log('DISCOVERED ${_short(s.uuid.str128)}/${_short(c.uuid.str128)} [$props]');
|
||||
}
|
||||
}
|
||||
_log('DISCOVERED ${map.length} services');
|
||||
return map;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Stream<Uint8List>> subscribe({
|
||||
required String serviceUuid,
|
||||
required String characteristicUuid,
|
||||
}) async {
|
||||
final c = _characteristic(serviceUuid, characteristicUuid);
|
||||
_log('SUBSCRIBE ${_short(characteristicUuid)}');
|
||||
final ok = await c.setNotifyValue(true);
|
||||
_log('SUBSCRIBE ${_short(characteristicUuid)} result=$ok');
|
||||
return c.onValueReceived.map((v) => Uint8List.fromList(v));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write({
|
||||
required String serviceUuid,
|
||||
required String characteristicUuid,
|
||||
required Uint8List value,
|
||||
}) async {
|
||||
final c = _characteristic(serviceUuid, characteristicUuid);
|
||||
final withoutResponse =
|
||||
!c.properties.write && c.properties.writeWithoutResponse;
|
||||
_log('WRITE ${_short(characteristicUuid)} ${value.length} bytes withoutResponse=$withoutResponse');
|
||||
await c.write(value, withoutResponse: withoutResponse);
|
||||
}
|
||||
|
||||
fbp.BluetoothDevice _requireDevice() {
|
||||
final d = _device;
|
||||
if (d == null) throw StateError('No active BLE device');
|
||||
return d;
|
||||
}
|
||||
|
||||
fbp.BluetoothCharacteristic _characteristic(String service, String char) {
|
||||
_requireDevice();
|
||||
if (_services.isEmpty) {
|
||||
throw StateError('discoverServices() must run before subscribe/write');
|
||||
}
|
||||
final s = BleClient.normalizeUuid(service);
|
||||
final c = BleClient.normalizeUuid(char);
|
||||
for (final svc in _services) {
|
||||
if (svc.uuid.str128.toLowerCase() != s) continue;
|
||||
for (final ch in svc.characteristics) {
|
||||
if (ch.uuid.str128.toLowerCase() == c) return ch;
|
||||
}
|
||||
}
|
||||
throw StateError('Characteristic ${_short(char)} not found');
|
||||
}
|
||||
|
||||
static String _short(String uuid) =>
|
||||
uuid.length >= 8 ? uuid.substring(4, 8).toUpperCase() : uuid;
|
||||
|
||||
void _log(String message) => ProtocolLog.instance.log('BLE', message);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../models/scooter_device.dart';
|
||||
import '../scooters/apollo_protocol.dart';
|
||||
import 'ble_client.dart';
|
||||
|
||||
/// Fake BLE link that behaves like the Apollo Go captured on 2026-09-21.
|
||||
///
|
||||
/// Accepts any PIN, streams the real base frame and a monitor frame with an
|
||||
/// animated speed and current, and only streams while keepalives arrive, just
|
||||
/// like the physical scooter. Used to preview layouts without hardware.
|
||||
class DemoBleClient extends BleClient {
|
||||
static const device = ScooterDevice(
|
||||
id: 'demo',
|
||||
name: 'Demo Apollo Go',
|
||||
rssi: -50,
|
||||
advertisedServiceUuids: {apolloDataServiceUuid},
|
||||
);
|
||||
|
||||
final _conn = StreamController<BleConnectionState>.broadcast();
|
||||
final _data = StreamController<Uint8List>.broadcast();
|
||||
final _at = StreamController<Uint8List>.broadcast();
|
||||
Timer? _stream;
|
||||
DateTime _lastKeepalive = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
double _t = 0;
|
||||
|
||||
static final _base = Uint8List.fromList(
|
||||
[0xAA, 0x01, 0x19, 0x03, 0x0A, 0x0F, 0x1E, 0xD8, 0x80, 0x00, 0x1F, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x68, 0x61]);
|
||||
|
||||
@override
|
||||
Stream<List<ScooterDevice>> scan() => Stream.value([device]);
|
||||
@override
|
||||
Future<void> stopScan() async {}
|
||||
@override
|
||||
Stream<BleConnectionState> get connectionState => _conn.stream;
|
||||
|
||||
@override
|
||||
Future<void> connect(String deviceId) async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 600));
|
||||
_stream = Timer.periodic(const Duration(milliseconds: 200), (_) => _tick());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {
|
||||
_stream?.cancel();
|
||||
_stream = null;
|
||||
_conn.add(BleConnectionState.disconnected);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, Set<String>>> discoverServices() async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||
return {
|
||||
apolloDataServiceUuid: {apolloDataTxUuid, apolloDataRxUuid},
|
||||
apolloAtServiceUuid: {apolloAtTxUuid, apolloAtRxUuid},
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Stream<Uint8List>> subscribe({
|
||||
required String serviceUuid,
|
||||
required String characteristicUuid,
|
||||
}) async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||
return characteristicUuid == apolloDataRxUuid ? _data.stream : _at.stream;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write({
|
||||
required String serviceUuid,
|
||||
required String characteristicUuid,
|
||||
required Uint8List value,
|
||||
}) async {
|
||||
if (characteristicUuid == apolloAtTxUuid) {
|
||||
Future<void>.delayed(const Duration(milliseconds: 150),
|
||||
() => _at.add(Uint8List.fromList('OK+PWD:Y'.codeUnits)));
|
||||
} else if (value.length == 4 && value[0] == 0xA5) {
|
||||
_lastKeepalive = DateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
void _tick() {
|
||||
if (DateTime.now().difference(_lastKeepalive) > const Duration(seconds: 3)) return;
|
||||
_t += 0.2;
|
||||
final speedKmh = 14 + 12 * math.sin(_t / 4); // 2..26 km/h
|
||||
final rawSpeed = (speedKmh * 10).round();
|
||||
final currentA = 3 + 8 * math.max(0, math.cos(_t / 4)) - (math.sin(_t / 2) < -0.8 ? 6 : 0);
|
||||
final rawCurrent = (currentA * 64).round() & 0xFFFF;
|
||||
final battery = 78;
|
||||
final flagsA = 0x0E | (math.sin(_t / 6) > 0 ? 0x80 : 0); // headlight blinks slowly
|
||||
final frame = Uint8List.fromList([
|
||||
0xAA, 0x00, 0x19, 0x01, 0x02, battery,
|
||||
rawSpeed >> 8, rawSpeed & 0xFF, 0, 0,
|
||||
0x01, 0x9F, rawCurrent >> 8, rawCurrent & 0xFF,
|
||||
0x16 + (speedKmh / 10).round(), 0x19,
|
||||
0x00, 0x7B, 0x00, 0x0C, 0xC5, flagsA, 0x22, 0, 0,
|
||||
]);
|
||||
final crc = apolloCrc16(frame.sublist(0, 23));
|
||||
frame[23] = crc & 0xFF;
|
||||
frame[24] = crc >> 8;
|
||||
// Mimic the real 20 + 5 byte notification split.
|
||||
_data.add(frame.sublist(0, 20));
|
||||
_data.add(frame.sublist(20));
|
||||
_data.add(_base.sublist(0, 20));
|
||||
_data.add(_base.sublist(20));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// Remembers scooter PINs in platform secure storage (Android Keystore backed
|
||||
/// storage, iOS Keychain), keyed by the BLE peripheral id.
|
||||
///
|
||||
/// The peripheral id is not a permanent scooter identity (see ScooterDevice).
|
||||
/// Once the Apollo UID is read over AT+UID? this should key by that instead.
|
||||
class PinStore {
|
||||
PinStore([FlutterSecureStorage? storage])
|
||||
: _storage = storage ?? const FlutterSecureStorage();
|
||||
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
String _key(String deviceId) => 'pin:$deviceId';
|
||||
|
||||
Future<String?> read(String deviceId) async {
|
||||
try {
|
||||
return await _storage.read(key: _key(deviceId));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> save(String deviceId, String pin) =>
|
||||
_storage.write(key: _key(deviceId), value: pin);
|
||||
|
||||
Future<void> forget(String deviceId) => _storage.delete(key: _key(deviceId));
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
/// Persistent protocol log for field debugging without a USB cable.
|
||||
///
|
||||
/// Every line is kept in a bounded in-memory ring (viewable in-app) and, once
|
||||
/// [init] has run, appended to a file that can be pulled with adb:
|
||||
///
|
||||
/// adb pull /sdcard/Android/data/dev.teamhydra.openscooter/files/openscooter.log
|
||||
///
|
||||
/// Before [init], or in tests, only the in-memory ring is used.
|
||||
/// The PIN is never written here; callers mask it first.
|
||||
class ProtocolLog extends ChangeNotifier {
|
||||
ProtocolLog._();
|
||||
static final ProtocolLog instance = ProtocolLog._();
|
||||
|
||||
static const maxLines = 2000;
|
||||
|
||||
final List<String> _lines = <String>[];
|
||||
File? _file;
|
||||
IOSink? _sink;
|
||||
|
||||
List<String> get lines => List.unmodifiable(_lines);
|
||||
String? get path => _file?.path;
|
||||
|
||||
Future<void> init() async {
|
||||
if (_file != null) return;
|
||||
try {
|
||||
Directory? dir;
|
||||
if (Platform.isAndroid) dir = await getExternalStorageDirectory();
|
||||
dir ??= await getApplicationDocumentsDirectory();
|
||||
_file = File('${dir.path}/openscooter.log');
|
||||
_sink = _file!.openWrite(mode: FileMode.append);
|
||||
log('LOG', 'opened ${_file!.path}');
|
||||
} catch (e) {
|
||||
debugPrint('ProtocolLog: could not open log file: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void log(String tag, String message) {
|
||||
final line = '${DateTime.now().toIso8601String()} [$tag] $message';
|
||||
if (kDebugMode) debugPrint(line);
|
||||
_lines.add(line);
|
||||
if (_lines.length > maxLines) _lines.removeRange(0, _lines.length - maxLines);
|
||||
_sink?.writeln(line);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
_lines.clear();
|
||||
await _sink?.flush();
|
||||
await _sink?.close();
|
||||
_sink = null;
|
||||
final f = _file;
|
||||
if (f != null) {
|
||||
try {
|
||||
await f.writeAsString('');
|
||||
} catch (_) {}
|
||||
_sink = f.openWrite(mode: FileMode.append);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> flush() async => _sink?.flush();
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Display units. `auto` follows the scooter's own imperial bit.
|
||||
///
|
||||
/// ASSUMPTION (INFERRED, ride verification pending): native protocol speed and
|
||||
/// distance values are kilometres. Imperial display converts from km.
|
||||
enum UnitPreference { auto, metric, imperial }
|
||||
|
||||
/// Dashboard layouts the rider can pick from.
|
||||
enum ClusterLayout {
|
||||
arc('Arc', 'Speed inside a round gauge'),
|
||||
digital('Digital', 'Big number with a speed bar'),
|
||||
tiles('Tiles', 'Everything as cards');
|
||||
|
||||
const ClusterLayout(this.label, this.description);
|
||||
final String label;
|
||||
final String description;
|
||||
}
|
||||
|
||||
/// Accent colour presets. Blue is the OpenScooter default.
|
||||
enum AccentColor {
|
||||
blue('Blue', Color(0xFF3D8BFF)),
|
||||
cyan('Cyan', Color(0xFF22C1D8)),
|
||||
green('Green', Color(0xFF34C77B)),
|
||||
lime('Lime', Color(0xFFB4E33D)),
|
||||
amber('Amber', Color(0xFFFFB020)),
|
||||
orange('Orange', Color(0xFFFF6B2C)),
|
||||
red('Red', Color(0xFFFF4D5E)),
|
||||
pink('Pink', Color(0xFFFF5CA8)),
|
||||
purple('Purple', Color(0xFF9B6CFF)),
|
||||
white('White', Color(0xFFF2F4F8));
|
||||
|
||||
const AccentColor(this.label, this.color);
|
||||
final String label;
|
||||
final Color color;
|
||||
}
|
||||
|
||||
/// App-wide user preferences, persisted with shared_preferences.
|
||||
class AppSettings extends ChangeNotifier {
|
||||
AppSettings._();
|
||||
static final AppSettings instance = AppSettings._();
|
||||
|
||||
static const _kAccent = 'accent';
|
||||
static const _kLayout = 'layout';
|
||||
static const _kUnits = 'units';
|
||||
|
||||
SharedPreferences? _prefs;
|
||||
|
||||
AccentColor _accent = AccentColor.blue;
|
||||
ClusterLayout _layout = ClusterLayout.arc;
|
||||
UnitPreference _units = UnitPreference.auto;
|
||||
|
||||
AccentColor get accent => _accent;
|
||||
ClusterLayout get layout => _layout;
|
||||
UnitPreference get units => _units;
|
||||
|
||||
Future<void> load() async {
|
||||
try {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
_accent = _byName(AccentColor.values, _prefs!.getString(_kAccent)) ?? _accent;
|
||||
_layout = _byName(ClusterLayout.values, _prefs!.getString(_kLayout)) ?? _layout;
|
||||
_units = _byName(UnitPreference.values, _prefs!.getString(_kUnits)) ?? _units;
|
||||
} catch (_) {
|
||||
// Defaults are fine if preferences are unavailable.
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
set accent(AccentColor v) {
|
||||
_accent = v;
|
||||
_prefs?.setString(_kAccent, v.name);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
set layout(ClusterLayout v) {
|
||||
_layout = v;
|
||||
_prefs?.setString(_kLayout, v.name);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
set units(UnitPreference v) {
|
||||
_units = v;
|
||||
_prefs?.setString(_kUnits, v.name);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
static T? _byName<T extends Enum>(List<T> values, String? name) {
|
||||
if (name == null) return null;
|
||||
for (final v in values) {
|
||||
if (v.name == name) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Neutral palette. The accent colour comes from [AppSettings] through the
|
||||
/// theme, so widgets read `Theme.of(context).colorScheme.primary` for it.
|
||||
abstract final class OsColors {
|
||||
static const background = Color(0xFF0A0C10);
|
||||
static const surface = Color(0xFF13161C);
|
||||
static const surfaceHigh = Color(0xFF1C2028);
|
||||
static const good = Color(0xFF34C77B);
|
||||
static const warn = Color(0xFFFFB020);
|
||||
static const bad = Color(0xFFFF4D5E);
|
||||
static const text = Color(0xFFF2F4F8);
|
||||
static const textDim = Color(0xFF8A93A5);
|
||||
static const track = Color(0xFF20252E);
|
||||
|
||||
static Color batteryColor(int? level) {
|
||||
if (level == null) return track;
|
||||
if (level <= 15) return bad;
|
||||
if (level <= 30) return warn;
|
||||
return good;
|
||||
}
|
||||
}
|
||||
|
||||
ThemeData buildOsTheme(Color accent) {
|
||||
final onAccent = accent.computeLuminance() > 0.5 ? OsColors.background : Colors.white;
|
||||
final base = ThemeData(
|
||||
brightness: Brightness.dark,
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.dark(
|
||||
primary: accent,
|
||||
onPrimary: onAccent,
|
||||
secondary: accent,
|
||||
onSecondary: onAccent,
|
||||
surface: OsColors.background,
|
||||
onSurface: OsColors.text,
|
||||
surfaceContainerHighest: OsColors.surfaceHigh,
|
||||
error: OsColors.bad,
|
||||
),
|
||||
scaffoldBackgroundColor: OsColors.background,
|
||||
);
|
||||
return base.copyWith(
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: OsColors.background,
|
||||
foregroundColor: OsColors.text,
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
),
|
||||
cardTheme: const CardThemeData(
|
||||
color: OsColors.surface,
|
||||
elevation: 0,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(20))),
|
||||
),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(0, 52),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
textStyle: const TextStyle(fontWeight: FontWeight.w700, fontSize: 16),
|
||||
),
|
||||
),
|
||||
segmentedButtonTheme: SegmentedButtonThemeData(
|
||||
style: SegmentedButton.styleFrom(
|
||||
selectedBackgroundColor: accent,
|
||||
selectedForegroundColor: onAccent,
|
||||
side: const BorderSide(color: OsColors.surfaceHigh),
|
||||
),
|
||||
),
|
||||
switchTheme: SwitchThemeData(
|
||||
thumbColor: WidgetStateProperty.resolveWith(
|
||||
(s) => s.contains(WidgetState.selected) ? onAccent : OsColors.textDim),
|
||||
trackColor: WidgetStateProperty.resolveWith(
|
||||
(s) => s.contains(WidgetState.selected) ? accent : OsColors.surfaceHigh),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: OsColors.surfaceHigh,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
snackBarTheme: const SnackBarThemeData(behavior: SnackBarBehavior.floating),
|
||||
progressIndicatorTheme: ProgressIndicatorThemeData(color: accent),
|
||||
textTheme: base.textTheme.apply(bodyColor: OsColors.text, displayColor: OsColors.text),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
bluez:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bluez
|
||||
sha256: "61a7204381925896a374301498f2f5399e59827c6498ae1e924aaa598751b545"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.3"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.3"
|
||||
code_assets:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cupertino_icons
|
||||
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.9"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.15"
|
||||
fake_async:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
ffi_leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi_leak_tracker
|
||||
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.2"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_blue_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_blue_plus
|
||||
sha256: "5389f2b85305d0e03b025de7a92ec86137440b5108744f1017645ff67c5f2dcc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.12"
|
||||
flutter_blue_plus_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_blue_plus_android
|
||||
sha256: "5f1db477d442974c516196718e2ab66e3601e9a2de271bddde9368c1a85e6e64"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.0.3"
|
||||
flutter_blue_plus_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_blue_plus_darwin
|
||||
sha256: ce55b40e751da0d9a74bf14db3924d2553b7e67ca0270ea9260f2d46a44ead3a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.0.4"
|
||||
flutter_blue_plus_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_blue_plus_linux
|
||||
sha256: "79387947c27d04fce505916d168a1f8b7a89846d22d11a659970aba316459622"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.0.3"
|
||||
flutter_blue_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_blue_plus_platform_interface
|
||||
sha256: "9378ed463673ab51e7ab72cf4bad3633b134182ca184ddcc598d6f7474ada993"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.0.3"
|
||||
flutter_blue_plus_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_blue_plus_web
|
||||
sha256: "62670fd0072e9424661170c3439eb2784679e0cb8420907ae2fe979aab8eed71"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.0.3"
|
||||
flutter_blue_plus_winrt:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_blue_plus_winrt
|
||||
sha256: "0000b2d818e6f79ad07764206fdd8afb69426fd44453c6254c35828fd16aa09f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.20"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: d4e1fb6b2cb524868929e78dc0282fa000554b22060fb53789dc481c9fc95bb8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.2.0"
|
||||
flutter_secure_storage_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_darwin
|
||||
sha256: a031ceac9b070e62ef183cd7f7d1a8798ebde402d5049f0fe6eb3a51bd797a21
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.3"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: caa75bd78f017422912e3a904933113b2428eb79f981ac37acd2dd2a700458ea
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_platform_interface
|
||||
sha256: "64951127f001f546891c86f414972bf49ef2c4c8a4c92c65f5790cc0c9100045"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
flutter_secure_storage_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_web
|
||||
sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
flutter_secure_storage_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.2"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
jni:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni
|
||||
sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
jni_flutter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni_flutter
|
||||
sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
jni_util:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni_util
|
||||
sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.20"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.3"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.5.0"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_config
|
||||
sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.6"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.1"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.0"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_linux
|
||||
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.2"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_windows
|
||||
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform
|
||||
sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.0"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
record_use:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_use
|
||||
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.5"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.28"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_foundation
|
||||
sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.7"
|
||||
shared_preferences_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_linux
|
||||
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shared_preferences_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_platform_interface
|
||||
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
shared_preferences_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_web
|
||||
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
shared_preferences_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_windows
|
||||
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.2"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.3.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.4.0"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.4"
|
||||
sdks:
|
||||
dart: ">=3.13.4 <4.0.0"
|
||||
flutter: ">=3.44.0"
|
||||
@@ -0,0 +1,94 @@
|
||||
name: openscooter
|
||||
description: "OpenMotion: an open-source platform for electric scooters, bikes, boards, and personal electric vehicles."
|
||||
# The following line prevents the package from being accidentally published to
|
||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
|
||||
# The following defines the version and build number for your application.
|
||||
# A version number is three numbers separated by dots, like 1.2.43
|
||||
# followed by an optional build number separated by a +.
|
||||
# Both the version and the builder number may be overridden in flutter
|
||||
# build by specifying --build-name and --build-number, respectively.
|
||||
# In Android, build-name is used as versionName while build-number used as versionCode.
|
||||
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
|
||||
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.13.4
|
||||
|
||||
# Dependencies specify other packages that your package needs in order to work.
|
||||
# To automatically upgrade your package dependencies to the latest versions
|
||||
# consider running `flutter pub upgrade --major-versions`. Alternatively,
|
||||
# dependencies can be manually updated by changing the version numbers below to
|
||||
# the latest version available on pub.dev. To see which dependencies have newer
|
||||
# versions available, run `flutter pub outdated`.
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
flutter_blue_plus: ^2.3.12
|
||||
path_provider: ^2.1.6
|
||||
flutter_secure_storage: ^11.2.0
|
||||
shared_preferences: ^2.5.5
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
# The "flutter_lints" package below contains a set of recommended lints to
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
# activated in the `analysis_options.yaml` file located at the root of your
|
||||
# package. See that file for information about deactivating specific lint
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^6.0.0
|
||||
fake_async: ^1.3.3
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
|
||||
# The following line ensures that the Material Icons font is
|
||||
# included with your application, so that you can use the icons in
|
||||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
# For details regarding adding assets from package dependencies, see
|
||||
# https://flutter.dev/to/asset-from-package
|
||||
|
||||
# To add custom fonts to your application, add a fonts section here,
|
||||
# in this "flutter" section. Each entry in this list should have a
|
||||
# "family" key with the font family name, and a "fonts" key with a
|
||||
# list giving the asset and other descriptors for the font. For
|
||||
# example:
|
||||
# fonts:
|
||||
# - family: Schyler
|
||||
# fonts:
|
||||
# - asset: fonts/Schyler-Regular.ttf
|
||||
# - asset: fonts/Schyler-Italic.ttf
|
||||
# style: italic
|
||||
# - family: Trajan Pro
|
||||
# fonts:
|
||||
# - asset: fonts/TrajanPro.ttf
|
||||
# - asset: fonts/TrajanPro_Bold.ttf
|
||||
# weight: 700
|
||||
#
|
||||
# For details regarding fonts from package dependencies,
|
||||
# see https://flutter.dev/to/font-from-package
|
||||
@@ -0,0 +1,571 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:openscooter/scooters/apollo_protocol.dart';
|
||||
import 'package:openscooter/scooters/scooter.dart';
|
||||
|
||||
Uint8List hex(String s) => Uint8List.fromList(
|
||||
s.trim().split(RegExp(r'\s+')).map((h) => int.parse(h, radix: 16)).toList(),
|
||||
);
|
||||
|
||||
/// Synthetic monitor frame from the brief (section 35). CRC valid.
|
||||
final monitorFrame =
|
||||
hex('AA 00 00 00 02 4B 00 FA 00 C8 01 E0 01 40 23 28 00 7B 00 30 39 8A 24 E9 1F');
|
||||
|
||||
/// Synthetic base frame from the brief (section 46). CRC valid.
|
||||
final baseFrame =
|
||||
hex('AB 01 00 19 06 0C 14 1E 80 00 1F 00 27 10 13 88 00 64 12 AB 02 05 11 F0 C0');
|
||||
|
||||
/// Re-signs a frame after editing its payload so parser tests only fail on
|
||||
/// the field under test, never on CRC.
|
||||
Uint8List resign(Uint8List frame) {
|
||||
final out = Uint8List.fromList(frame);
|
||||
final crc = apolloCrc16(out.sublist(0, out.length - 2));
|
||||
out[out.length - 2] = crc & 0xFF;
|
||||
out[out.length - 1] = crc >> 8;
|
||||
return out;
|
||||
}
|
||||
|
||||
Uint8List patched(Uint8List frame, Map<int, int> bytes) {
|
||||
final out = Uint8List.fromList(frame);
|
||||
bytes.forEach((i, v) => out[i] = v);
|
||||
return resign(out);
|
||||
}
|
||||
|
||||
/// LIVE captures from an Apollo Go, 2026-09-21, idle, full battery,
|
||||
/// headlight off. Regression fixtures per brief section 81.
|
||||
final realCmd0Idle =
|
||||
hex('AA 00 19 01 02 64 00 00 00 00 01 9F 00 00 16 19 00 00 00 0C C5 0E 22 B3 13');
|
||||
final realCmd0HeadlightCruise =
|
||||
hex('AA 00 19 01 02 64 00 00 00 00 01 9D 00 00 16 19 00 00 00 0C C5 8E 26 2A D7');
|
||||
final realCmd0RightSignal =
|
||||
hex('AA 00 19 01 02 64 00 00 00 00 01 9E 00 00 16 19 00 00 00 0C C5 2E 2E 57 15');
|
||||
final realCmd0LeftSignal =
|
||||
hex('AA 00 19 01 02 64 00 00 00 00 01 9E 00 00 16 19 00 00 00 0C C5 4E 36 7F 1F');
|
||||
final realCmd1Base =
|
||||
hex('AA 01 19 03 0A 0F 1E D8 80 00 1F 00 00 00 00 00 00 00 00 00 00 00 00 68 61');
|
||||
|
||||
void main() {
|
||||
group('real Apollo Go captures', () {
|
||||
test('all fixtures are CRC valid', () {
|
||||
for (final f in [realCmd0Idle, realCmd0HeadlightCruise, realCmd0RightSignal,
|
||||
realCmd0LeftSignal, realCmd1Base]) {
|
||||
expect(apolloValidateFrame(f), isTrue, reason: apolloHex(f));
|
||||
}
|
||||
});
|
||||
|
||||
test('real cmd1 base frame', () {
|
||||
final b = parseApolloBaseFrame(realCmd1Base);
|
||||
expect(b.limitCruise, 3);
|
||||
expect(b.limitMode1, 10);
|
||||
expect(b.limitMode2, 15);
|
||||
expect(b.limitMode3, 30);
|
||||
expect(b.batteryTemperature, -40);
|
||||
expect(b.totalBatteryCapacity, 0);
|
||||
expect(b.remainingBatteryCapacity, 0);
|
||||
expect(b.batteryCycles, 0);
|
||||
expect(b.displayId, isNull);
|
||||
expect(b.displayVersion, 'V0.0.0');
|
||||
expect(b.faultEnable, isTrue);
|
||||
expect([b.ctrlSn, b.ctrlMp3, b.ctrlRgb, b.ctrlBms, b.internalSpeedScalingFlag],
|
||||
everyElement(isTrue));
|
||||
});
|
||||
|
||||
test('real cmd0 idle frame with the Go scaling flag', () {
|
||||
final m = parseApolloMonitorFrame(realCmd0Idle, internalSpeedScalingFlag: true);
|
||||
expect(m.gear, 2);
|
||||
expect(m.batteryLevel, 100);
|
||||
expect(m.rawSpeed, 0);
|
||||
expect(m.speed, 0.0);
|
||||
expect(m.voltage, 41.5);
|
||||
expect(m.current, 0.0);
|
||||
expect(m.power, 0.0);
|
||||
expect(m.motorTemperature, 22);
|
||||
expect(m.controllerTemperature, 25);
|
||||
expect(m.tripDistance, 0.0);
|
||||
expect(m.odometer, 326.9);
|
||||
expect(m.atmosphereLight, isTrue);
|
||||
expect(m.unlocked, isTrue);
|
||||
expect(m.headlight, isFalse);
|
||||
expect(m.leftTurnSignal, isFalse);
|
||||
expect(m.rightTurnSignal, isFalse);
|
||||
expect(m.cruiseControl, isFalse);
|
||||
expect(m.imperial, isTrue);
|
||||
expect(m.bootMode, isFalse);
|
||||
});
|
||||
|
||||
test('real cmd0 variants: headlight+cruise, right signal, left signal', () {
|
||||
final h = parseApolloMonitorFrame(realCmd0HeadlightCruise, internalSpeedScalingFlag: true);
|
||||
expect(h.headlight, isTrue);
|
||||
expect(h.cruiseControl, isTrue);
|
||||
expect(h.voltage, 41.3);
|
||||
final r = parseApolloMonitorFrame(realCmd0RightSignal, internalSpeedScalingFlag: true);
|
||||
expect(r.rightTurnSignal, isTrue);
|
||||
expect(r.leftTurnSignal, isFalse);
|
||||
final l = parseApolloMonitorFrame(realCmd0LeftSignal, internalSpeedScalingFlag: true);
|
||||
expect(l.leftTurnSignal, isTrue);
|
||||
expect(l.rightTurnSignal, isFalse);
|
||||
});
|
||||
|
||||
test('real 20 + 5 byte notification split reassembles', () {
|
||||
final buf = ApolloFrameBuffer();
|
||||
expect(buf.add(realCmd0Idle.sublist(0, 20)), isEmpty);
|
||||
expect(buf.add(realCmd0Idle.sublist(20)), [realCmd0Idle]);
|
||||
expect(buf.add(realCmd1Base.sublist(0, 20)), isEmpty);
|
||||
expect(buf.add(realCmd1Base.sublist(20)), [realCmd1Base]);
|
||||
});
|
||||
});
|
||||
|
||||
group('CRC-16', () {
|
||||
test('vector 1 from brief', () {
|
||||
expect(apolloCrc16(hex('AB 00 0A 00 00 00 00 00')), 0x6A0B);
|
||||
});
|
||||
test('vector 2 from brief', () {
|
||||
expect(apolloCrc16(hex('AB 00 0A 80 19 06 0C 14')), 0xE6E8);
|
||||
});
|
||||
test('wire order is little-endian and covers header through length-3', () {
|
||||
final f = hex('AB 00 0A 00 00 00 00 00 0B 6A');
|
||||
expect(apolloValidateFrame(f), isTrue);
|
||||
expect(apolloValidateFrame(hex('AB 00 0A 00 00 00 00 00 6A 0B')), isFalse);
|
||||
});
|
||||
test('synthetic frames validate', () {
|
||||
expect(apolloValidateFrame(monitorFrame), isTrue);
|
||||
expect(apolloValidateFrame(baseFrame), isTrue);
|
||||
});
|
||||
test('corrupting one byte fails validation', () {
|
||||
final bad = Uint8List.fromList(monitorFrame)..[5] ^= 0x01;
|
||||
expect(apolloValidateFrame(bad), isFalse);
|
||||
// Including unknown metadata bytes 2 and 3: they are CRC-covered.
|
||||
final bad2 = Uint8List.fromList(monitorFrame)..[2] = 0x01;
|
||||
expect(apolloValidateFrame(bad2), isFalse);
|
||||
});
|
||||
test('keepalive constant is not frame-CRC signed', () {
|
||||
expect(apolloKeepalivePacket, hex('A5 02 FD 5A'));
|
||||
expect(apolloValidateFrame(apolloKeepalivePacket), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('PIN command', () {
|
||||
test('builds AT+PWD[pin] with no line ending', () {
|
||||
expect(String.fromCharCodes(buildApolloPinCommand('123456')), 'AT+PWD[123456]');
|
||||
expect(buildApolloPinCommand('123456').last, ']'.codeUnitAt(0));
|
||||
});
|
||||
test('rejects non six-digit input', () {
|
||||
expect(() => buildApolloPinCommand('12345'), throwsArgumentError);
|
||||
expect(() => buildApolloPinCommand('12345a'), throwsArgumentError);
|
||||
expect(() => buildApolloPinCommand('1234567'), throwsArgumentError);
|
||||
});
|
||||
});
|
||||
|
||||
group('PIN response parser', () {
|
||||
Uint8List ascii(String s) => Uint8List.fromList(s.codeUnits);
|
||||
|
||||
test('OK+PWD:Y is success', () {
|
||||
expect(parseApolloPinResponse(ascii('OK+PWD:Y')), AuthenticationResult.success);
|
||||
});
|
||||
test('OK+PWD:N is invalid credential', () {
|
||||
expect(parseApolloPinResponse(ascii('OK+PWD:N')), AuthenticationResult.invalidCredential);
|
||||
});
|
||||
test('search within garbage', () {
|
||||
expect(parseApolloPinResponse(ascii('garbageOK+PWD:Y\r\n')), AuthenticationResult.success);
|
||||
});
|
||||
test('NUL bytes are ignored', () {
|
||||
expect(parseApolloPinResponse(ascii('OK+PWD\x00:Y')), AuthenticationResult.success);
|
||||
});
|
||||
test('partial is null', () {
|
||||
expect(parseApolloPinResponse(ascii('OK+PW')), isNull);
|
||||
expect(parseApolloPinResponse(ascii('')), isNull);
|
||||
});
|
||||
test('fragmented response reassembles through the AT buffer', () {
|
||||
final buf = ApolloAtBuffer();
|
||||
buf.add(ascii('OK+PW'));
|
||||
expect(parseApolloPinResponse(buf.bytes), isNull);
|
||||
buf.add(ascii('D:Y'));
|
||||
expect(parseApolloPinResponse(buf.bytes), AuthenticationResult.success);
|
||||
});
|
||||
test('AT buffer is capped', () {
|
||||
final buf = ApolloAtBuffer(maxLength: 16);
|
||||
buf.add(Uint8List.fromList(List.filled(100, 0x41)));
|
||||
expect(buf.bytes.length, 16);
|
||||
});
|
||||
});
|
||||
|
||||
group('frame lengths', () {
|
||||
test('A5 02 is 4, other A5 is 8, AA/AB cmd 0/1 is 25', () {
|
||||
expect(apolloFrameLength(0xA5, 0x02), 4);
|
||||
expect(apolloFrameLength(0xA5, 0x01), 8);
|
||||
expect(apolloFrameLength(0xAA, 0x00), 25);
|
||||
expect(apolloFrameLength(0xAA, 0x01), 25);
|
||||
expect(apolloFrameLength(0xAB, 0x00), 25);
|
||||
expect(apolloFrameLength(0xAB, 0x01), 25);
|
||||
expect(apolloFrameLength(0xAB, 0x02), isNull);
|
||||
expect(apolloFrameLength(0x00, 0x00), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('ApolloFrameBuffer', () {
|
||||
late ApolloFrameBuffer buf;
|
||||
setUp(() => buf = ApolloFrameBuffer());
|
||||
|
||||
test('one complete frame', () {
|
||||
expect(buf.add(monitorFrame), [monitorFrame]);
|
||||
expect(buf.length, 0);
|
||||
});
|
||||
|
||||
test('frame split into two chunks', () {
|
||||
expect(buf.add(monitorFrame.sublist(0, 10)), isEmpty);
|
||||
expect(buf.add(monitorFrame.sublist(10)), [monitorFrame]);
|
||||
});
|
||||
|
||||
test('frame split byte-by-byte', () {
|
||||
final out = <Uint8List>[];
|
||||
for (final b in monitorFrame) {
|
||||
out.addAll(buf.add(Uint8List.fromList([b])));
|
||||
}
|
||||
expect(out, [monitorFrame]);
|
||||
});
|
||||
|
||||
test('two frames in one notification', () {
|
||||
final both = Uint8List.fromList([...monitorFrame, ...baseFrame]);
|
||||
expect(buf.add(both), [monitorFrame, baseFrame]);
|
||||
});
|
||||
|
||||
test('one complete plus half of next', () {
|
||||
final data = Uint8List.fromList([...monitorFrame, ...baseFrame.sublist(0, 12)]);
|
||||
expect(buf.add(data), [monitorFrame]);
|
||||
expect(buf.add(baseFrame.sublist(12)), [baseFrame]);
|
||||
});
|
||||
|
||||
test('garbage before a valid frame', () {
|
||||
final data = Uint8List.fromList([0x00, 0x11, 0xFF, 0xAA, ...monitorFrame]);
|
||||
expect(buf.add(data), [monitorFrame]);
|
||||
});
|
||||
|
||||
test('garbage between valid frames', () {
|
||||
final data = Uint8List.fromList([...monitorFrame, 0x13, 0xAB, 0x00, ...baseFrame]);
|
||||
expect(buf.add(data), [monitorFrame, baseFrame]);
|
||||
});
|
||||
|
||||
test('bad CRC frame followed by a valid frame', () {
|
||||
final bad = Uint8List.fromList(monitorFrame)..[24] ^= 0xFF;
|
||||
final data = Uint8List.fromList([...bad, ...baseFrame]);
|
||||
expect(buf.add(data), [baseFrame]);
|
||||
});
|
||||
|
||||
test('unsupported head byte and unsupported command are skipped', () {
|
||||
final data = Uint8List.fromList([0xCC, 0xAB, 0x07, ...monitorFrame]);
|
||||
expect(buf.add(data), [monitorFrame]);
|
||||
});
|
||||
|
||||
test('exact keepalive is emitted, other A5 sequences are skipped', () {
|
||||
expect(buf.add(apolloKeepalivePacket), [apolloKeepalivePacket]);
|
||||
final data = Uint8List.fromList([0xA5, 0x02, 0x00, 0x00, ...monitorFrame]);
|
||||
expect(buf.add(data), [monitorFrame]);
|
||||
});
|
||||
|
||||
test('buffer size cap keeps the newest bytes', () {
|
||||
final small = ApolloFrameBuffer(maxLength: 64);
|
||||
small.add(Uint8List.fromList(List.filled(1000, 0x01)));
|
||||
expect(small.length, lessThanOrEqualTo(64));
|
||||
// Still recovers afterwards.
|
||||
expect(small.add(monitorFrame), [monitorFrame]);
|
||||
});
|
||||
|
||||
test('a garbage byte does not wedge parsing permanently', () {
|
||||
buf.add(Uint8List.fromList([0xAA]));
|
||||
buf.add(Uint8List.fromList([0x00]));
|
||||
// 23 more junk bytes complete a fake 25-byte candidate with a bad CRC.
|
||||
buf.add(Uint8List.fromList(List.filled(23, 0x55)));
|
||||
expect(buf.add(monitorFrame), [monitorFrame]);
|
||||
});
|
||||
});
|
||||
|
||||
group('monitor parser', () {
|
||||
test('synthetic vector, scaling flag true', () {
|
||||
final m = parseApolloMonitorFrame(monitorFrame, internalSpeedScalingFlag: true);
|
||||
expect(m.gear, 2);
|
||||
expect(m.batteryLevel, 75);
|
||||
expect(m.rawSpeed, 250);
|
||||
expect(m.speed, 25.0);
|
||||
expect(m.voltage, 48.0);
|
||||
expect(m.current, 5.0);
|
||||
expect(m.power, 240.0);
|
||||
expect(m.motorTemperature, 35);
|
||||
expect(m.controllerTemperature, 40);
|
||||
expect(m.tripDistance, 12.3);
|
||||
expect(m.odometer, 1234.5);
|
||||
expect(m.atmosphereLight, isTrue);
|
||||
expect(m.unlocked, isTrue);
|
||||
expect(m.headlight, isTrue);
|
||||
expect(m.rightTurnSignal, isFalse);
|
||||
expect(m.leftTurnSignal, isFalse);
|
||||
expect(m.cruiseControl, isTrue);
|
||||
expect(m.imperial, isTrue);
|
||||
expect(m.bootMode, isFalse);
|
||||
});
|
||||
|
||||
test('scaling flag false gives raw/1000', () {
|
||||
final m = parseApolloMonitorFrame(monitorFrame, internalSpeedScalingFlag: false);
|
||||
expect(m.speed, 0.25);
|
||||
});
|
||||
|
||||
test('speed uses the max of source A and B', () {
|
||||
// A = 0x00FA (250), B = 0x00C8 (200) in the vector. Swap them.
|
||||
final swapped = patched(monitorFrame, {6: 0x00, 7: 0xC8, 8: 0x00, 9: 0xFA});
|
||||
expect(parseApolloMonitorFrame(swapped, internalSpeedScalingFlag: true).rawSpeed, 250);
|
||||
final bBigger = patched(monitorFrame, {8: 0x01, 9: 0x00});
|
||||
expect(parseApolloMonitorFrame(bBigger, internalSpeedScalingFlag: true).rawSpeed, 256);
|
||||
});
|
||||
|
||||
test('negative current and derived power', () {
|
||||
// -5.0 A = -320 = 0xFEC0
|
||||
final f = patched(monitorFrame, {12: 0xFE, 13: 0xC0});
|
||||
final m = parseApolloMonitorFrame(f, internalSpeedScalingFlag: true);
|
||||
expect(m.current, -5.0);
|
||||
expect(m.power, -240.0);
|
||||
});
|
||||
|
||||
test('power is rounded to one decimal', () {
|
||||
// voltage 48.1 (0x01E1), current 1/64 A = 0.015625 -> 0.7515625 -> 0.8
|
||||
final f = patched(monitorFrame, {10: 0x01, 11: 0xE1, 12: 0x00, 13: 0x01});
|
||||
expect(parseApolloMonitorFrame(f, internalSpeedScalingFlag: true).power, 0.8);
|
||||
});
|
||||
|
||||
test('negative temperatures', () {
|
||||
final f = patched(monitorFrame, {14: 0xF6, 15: 0xEC}); // -10, -20
|
||||
final m = parseApolloMonitorFrame(f, internalSpeedScalingFlag: true);
|
||||
expect(m.motorTemperature, -10);
|
||||
expect(m.controllerTemperature, -20);
|
||||
});
|
||||
|
||||
test('24-bit odometer uses all three bytes', () {
|
||||
final f = patched(monitorFrame, {18: 0x12, 19: 0x34, 20: 0x56});
|
||||
expect(parseApolloMonitorFrame(f, internalSpeedScalingFlag: true).odometer, 0x123456 / 10.0);
|
||||
});
|
||||
|
||||
test('each switch flag is independent', () {
|
||||
final base = patched(monitorFrame, {21: 0x00, 22: 0x00});
|
||||
var m = parseApolloMonitorFrame(base, internalSpeedScalingFlag: true);
|
||||
expect([m.atmosphereLight, m.unlocked, m.rightTurnSignal, m.leftTurnSignal, m.headlight,
|
||||
m.cruiseControl, m.imperial, m.bootMode],
|
||||
everyElement(isFalse));
|
||||
|
||||
m = parseApolloMonitorFrame(patched(base, {21: 1 << 1}), internalSpeedScalingFlag: true);
|
||||
expect(m.atmosphereLight, isTrue);
|
||||
m = parseApolloMonitorFrame(patched(base, {21: 1 << 3}), internalSpeedScalingFlag: true);
|
||||
expect(m.unlocked, isTrue);
|
||||
m = parseApolloMonitorFrame(patched(base, {21: 1 << 5}), internalSpeedScalingFlag: true);
|
||||
expect(m.rightTurnSignal, isTrue);
|
||||
m = parseApolloMonitorFrame(patched(base, {21: 1 << 6}), internalSpeedScalingFlag: true);
|
||||
expect(m.leftTurnSignal, isTrue);
|
||||
m = parseApolloMonitorFrame(patched(base, {21: 1 << 7}), internalSpeedScalingFlag: true);
|
||||
expect(m.headlight, isTrue);
|
||||
m = parseApolloMonitorFrame(patched(base, {22: 1 << 2}), internalSpeedScalingFlag: true);
|
||||
expect(m.cruiseControl, isTrue);
|
||||
m = parseApolloMonitorFrame(patched(base, {22: 1 << 5}), internalSpeedScalingFlag: true);
|
||||
expect(m.imperial, isTrue);
|
||||
m = parseApolloMonitorFrame(patched(base, {22: 1 << 6}), internalSpeedScalingFlag: true);
|
||||
expect(m.bootMode, isTrue);
|
||||
});
|
||||
|
||||
test('corrupt byte is rejected by CRC', () {
|
||||
final bad = Uint8List.fromList(monitorFrame)..[4] = 0x03;
|
||||
expect(() => parseApolloMonitorFrame(bad, internalSpeedScalingFlag: true),
|
||||
throwsFormatException);
|
||||
});
|
||||
|
||||
test('rejects wrong command or length', () {
|
||||
expect(() => parseApolloMonitorFrame(baseFrame, internalSpeedScalingFlag: true),
|
||||
throwsFormatException);
|
||||
expect(() => parseApolloMonitorFrame(monitorFrame.sublist(1), internalSpeedScalingFlag: true),
|
||||
throwsFormatException);
|
||||
});
|
||||
});
|
||||
|
||||
group('base parser', () {
|
||||
test('synthetic vector', () {
|
||||
final b = parseApolloBaseFrame(baseFrame);
|
||||
expect(b.limitCruise, 25);
|
||||
expect(b.limitMode1, 6);
|
||||
expect(b.limitMode2, 12);
|
||||
expect(b.limitMode3, 20);
|
||||
expect(b.batteryTemperature, 30);
|
||||
expect(b.totalBatteryCapacity, 10000);
|
||||
expect(b.remainingBatteryCapacity, 5000);
|
||||
expect(b.batteryCycles, 100);
|
||||
expect(b.displayId, '12ab');
|
||||
expect(b.displayVersion, 'V2.5.17');
|
||||
expect(b.ctrlSn, isTrue);
|
||||
expect(b.ctrlMp3, isTrue);
|
||||
expect(b.ctrlRgb, isTrue);
|
||||
expect(b.ctrlBms, isTrue);
|
||||
expect(b.internalSpeedScalingFlag, isTrue);
|
||||
expect(b.faultEnable, isTrue); // frame[8] = 0x80
|
||||
expect(b.e9, isFalse);
|
||||
});
|
||||
|
||||
test('negative battery temperature', () {
|
||||
expect(parseApolloBaseFrame(patched(baseFrame, {7: 0xF1})).batteryTemperature, -15);
|
||||
});
|
||||
|
||||
test('capacity fields are big-endian', () {
|
||||
final b = parseApolloBaseFrame(patched(baseFrame, {12: 0x01, 13: 0x02, 14: 0x03, 15: 0x04, 16: 0x05, 17: 0x06}));
|
||||
expect(b.totalBatteryCapacity, 0x0102);
|
||||
expect(b.remainingBatteryCapacity, 0x0304);
|
||||
expect(b.batteryCycles, 0x0506);
|
||||
});
|
||||
|
||||
test('display id 0000 is absent, otherwise lowercase hex padded', () {
|
||||
expect(parseApolloBaseFrame(patched(baseFrame, {18: 0, 19: 0})).displayId, isNull);
|
||||
expect(parseApolloBaseFrame(patched(baseFrame, {18: 0x0A, 19: 0x0B})).displayId, '0a0b');
|
||||
});
|
||||
|
||||
test('display version formats V%d.%d.%d', () {
|
||||
expect(parseApolloBaseFrame(patched(baseFrame, {20: 0, 21: 10, 22: 255})).displayVersion, 'V0.10.255');
|
||||
});
|
||||
|
||||
test('capability bits are independent', () {
|
||||
final none = parseApolloBaseFrame(patched(baseFrame, {10: 0x00}));
|
||||
expect([none.ctrlSn, none.ctrlMp3, none.ctrlRgb, none.ctrlBms, none.internalSpeedScalingFlag],
|
||||
everyElement(isFalse));
|
||||
expect(parseApolloBaseFrame(patched(baseFrame, {10: 1 << 0})).ctrlSn, isTrue);
|
||||
expect(parseApolloBaseFrame(patched(baseFrame, {10: 1 << 1})).ctrlMp3, isTrue);
|
||||
expect(parseApolloBaseFrame(patched(baseFrame, {10: 1 << 2})).ctrlRgb, isTrue);
|
||||
expect(parseApolloBaseFrame(patched(baseFrame, {10: 1 << 3})).ctrlBms, isTrue);
|
||||
expect(parseApolloBaseFrame(patched(baseFrame, {10: 1 << 4})).internalSpeedScalingFlag, isTrue);
|
||||
});
|
||||
|
||||
test('fault flags, including the duplicated frame[8] bit 3 mapping', () {
|
||||
final a = parseApolloBaseFrame(patched(baseFrame, {8: (1 << 1) | (1 << 2), 9: 0}));
|
||||
expect(a.e9, isTrue);
|
||||
expect(a.f1, isTrue);
|
||||
expect(a.f2, isFalse);
|
||||
expect(a.ctrlFaultEarlyWarning, isFalse);
|
||||
expect(a.faultEnable, isFalse);
|
||||
|
||||
// Apollo 4.8.18340 maps both f2 and ctrlFaultEarlyWarning to bit 3.
|
||||
final b = parseApolloBaseFrame(patched(baseFrame, {8: 1 << 3}));
|
||||
expect(b.f2, isTrue);
|
||||
expect(b.ctrlFaultEarlyWarning, isTrue);
|
||||
|
||||
final c = parseApolloBaseFrame(patched(baseFrame, {9: (1 << 1) | (1 << 2) | (1 << 3) | (1 << 4) | (1 << 7)}));
|
||||
expect([c.e1, c.e2, c.e3, c.e4, c.e7], everyElement(isTrue));
|
||||
final d = parseApolloBaseFrame(patched(baseFrame, {9: 0}));
|
||||
expect([d.e1, d.e2, d.e3, d.e4, d.e7], everyElement(isFalse));
|
||||
});
|
||||
|
||||
test('corrupt byte is rejected by CRC', () {
|
||||
final bad = Uint8List.fromList(baseFrame)..[3] = 0x20;
|
||||
expect(() => parseApolloBaseFrame(bad), throwsFormatException);
|
||||
});
|
||||
});
|
||||
|
||||
group('set-base packet builder', () {
|
||||
Uint8List build({
|
||||
int gear = 2,
|
||||
bool headlight = true,
|
||||
bool atmosphereLight = false,
|
||||
bool cruiseControl = false,
|
||||
bool bootMode = false,
|
||||
bool imperial = true,
|
||||
bool unlocked = false,
|
||||
}) =>
|
||||
buildApolloSetBasePacket(
|
||||
gearPosition: gear,
|
||||
headlight: headlight,
|
||||
atmosphereLight: atmosphereLight,
|
||||
cruiseControl: cruiseControl,
|
||||
bootMode: bootMode,
|
||||
imperial: imperial,
|
||||
unlocked: unlocked,
|
||||
limitCruise: 25,
|
||||
limitMode1: 6,
|
||||
limitMode2: 12,
|
||||
limitMode3: 20,
|
||||
);
|
||||
|
||||
test('exact packet for brief vector 1 (all zero)', () {
|
||||
final p = buildApolloSetBasePacket(
|
||||
gearPosition: 0,
|
||||
headlight: false,
|
||||
atmosphereLight: false,
|
||||
cruiseControl: false,
|
||||
bootMode: false,
|
||||
imperial: false,
|
||||
unlocked: false,
|
||||
limitCruise: 0,
|
||||
limitMode1: 0,
|
||||
limitMode2: 0,
|
||||
limitMode3: 0,
|
||||
);
|
||||
expect(p, hex('AB 00 0A 00 00 00 00 00 0B 6A'));
|
||||
});
|
||||
|
||||
test('exact packet for brief vector 2 (unlocked with limits)', () {
|
||||
final p = buildApolloSetBasePacket(
|
||||
gearPosition: 0,
|
||||
headlight: false,
|
||||
atmosphereLight: false,
|
||||
cruiseControl: false,
|
||||
bootMode: false,
|
||||
imperial: false,
|
||||
unlocked: true,
|
||||
limitCruise: 25,
|
||||
limitMode1: 6,
|
||||
limitMode2: 12,
|
||||
limitMode3: 20,
|
||||
);
|
||||
expect(p, hex('AB 00 0A 80 19 06 0C 14 E8 E6'));
|
||||
expect(apolloValidateFrame(p), isTrue);
|
||||
});
|
||||
|
||||
test('is exactly 10 bytes with header AB 00 0A and the four limits', () {
|
||||
final p = build();
|
||||
expect(p.length, 10);
|
||||
expect(p.sublist(0, 3), hex('AB 00 0A'));
|
||||
expect(p.sublist(4, 8), [25, 6, 12, 20]);
|
||||
});
|
||||
|
||||
/// Asserts that toggling one control changes exactly one FLAGS bit and
|
||||
/// nothing else before the CRC.
|
||||
void expectOnlyBitChanges(Uint8List a, Uint8List b, int bit) {
|
||||
expect(a.sublist(0, 3), b.sublist(0, 3));
|
||||
expect(a.sublist(4, 8), b.sublist(4, 8), reason: 'speed limits must be preserved');
|
||||
expect(a[3] ^ b[3], 1 << bit, reason: 'only FLAGS bit $bit may change');
|
||||
expect(apolloValidateFrame(a), isTrue);
|
||||
expect(apolloValidateFrame(b), isTrue);
|
||||
}
|
||||
|
||||
test('unlock changes only FLAGS bit 7', () {
|
||||
expectOnlyBitChanges(build(unlocked: false), build(unlocked: true), 7);
|
||||
});
|
||||
test('headlight changes only FLAGS bit 2', () {
|
||||
expectOnlyBitChanges(build(headlight: false), build(headlight: true), 2);
|
||||
});
|
||||
test('atmosphere light changes only FLAGS bit 3', () {
|
||||
expectOnlyBitChanges(build(atmosphereLight: false), build(atmosphereLight: true), 3);
|
||||
});
|
||||
test('cruise changes only FLAGS bit 4', () {
|
||||
expectOnlyBitChanges(build(cruiseControl: false), build(cruiseControl: true), 4);
|
||||
});
|
||||
test('boot mode bit 5 and imperial bit 6', () {
|
||||
expectOnlyBitChanges(build(bootMode: false), build(bootMode: true), 5);
|
||||
expectOnlyBitChanges(build(imperial: false), build(imperial: true), 6);
|
||||
});
|
||||
test('gear occupies bits 0-1 only', () {
|
||||
expect(build(gear: 0)[3] & 0x03, 0);
|
||||
expect(build(gear: 3)[3] & 0x03, 3);
|
||||
expect(build(gear: 7)[3] & 0x03, 3);
|
||||
});
|
||||
test('rejects limits outside one byte', () {
|
||||
expect(
|
||||
() => buildApolloSetBasePacket(
|
||||
gearPosition: 0, headlight: false, atmosphereLight: false, cruiseControl: false,
|
||||
bootMode: false, imperial: false, unlocked: false,
|
||||
limitCruise: 256, limitMode1: 0, limitMode2: 0, limitMode3: 0,
|
||||
),
|
||||
throwsArgumentError,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:openscooter/models/scooter_device.dart';
|
||||
import 'package:openscooter/models/scooter_state.dart';
|
||||
import 'package:openscooter/scooters/apollo_protocol.dart';
|
||||
import 'package:openscooter/scooters/apollo_scooter.dart';
|
||||
import 'package:openscooter/scooters/scooter.dart';
|
||||
import 'package:openscooter/services/ble_client.dart';
|
||||
|
||||
Uint8List hex(String s) => Uint8List.fromList(
|
||||
s.trim().split(RegExp(r'\s+')).map((h) => int.parse(h, radix: 16)).toList(),
|
||||
);
|
||||
|
||||
final monitorFrame =
|
||||
hex('AA 00 00 00 02 4B 00 FA 00 C8 01 E0 01 40 23 28 00 7B 00 30 39 8A 24 E9 1F');
|
||||
final baseFrame =
|
||||
hex('AB 01 00 19 06 0C 14 1E 80 00 1F 00 27 10 13 88 00 64 12 AB 02 05 11 F0 C0');
|
||||
|
||||
Uint8List withFlags(Uint8List frame, {int? flagsA}) {
|
||||
final out = Uint8List.fromList(frame);
|
||||
if (flagsA != null) out[21] = flagsA;
|
||||
final crc = apolloCrc16(out.sublist(0, out.length - 2));
|
||||
out[23] = crc & 0xFF;
|
||||
out[24] = crc >> 8;
|
||||
return out;
|
||||
}
|
||||
|
||||
class FakeBleClient extends BleClient {
|
||||
final connState = StreamController<BleConnectionState>.broadcast();
|
||||
final dataRx = StreamController<Uint8List>.broadcast();
|
||||
final atRx = StreamController<Uint8List>.broadcast();
|
||||
|
||||
Map<String, Set<String>> services = {
|
||||
apolloDataServiceUuid: {apolloDataTxUuid, apolloDataRxUuid},
|
||||
apolloAtServiceUuid: {apolloAtTxUuid, apolloAtRxUuid},
|
||||
};
|
||||
|
||||
final subscribed = <String>[];
|
||||
final writes = <(String, Uint8List)>[];
|
||||
String? connectedId;
|
||||
int disconnectCalls = 0;
|
||||
|
||||
/// Auto-respond to AT+PWD with this text (null: stay silent).
|
||||
String? pinReply = 'OK+PWD:Y';
|
||||
|
||||
@override
|
||||
Stream<List<ScooterDevice>> scan() => const Stream.empty();
|
||||
@override
|
||||
Future<void> stopScan() async {}
|
||||
|
||||
@override
|
||||
Future<void> connect(String deviceId) async => connectedId = deviceId;
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {
|
||||
disconnectCalls++;
|
||||
connectedId = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<BleConnectionState> get connectionState => connState.stream;
|
||||
|
||||
@override
|
||||
Future<Map<String, Set<String>>> discoverServices() async => services;
|
||||
|
||||
@override
|
||||
Future<Stream<Uint8List>> subscribe({
|
||||
required String serviceUuid,
|
||||
required String characteristicUuid,
|
||||
}) async {
|
||||
subscribed.add(characteristicUuid);
|
||||
if (characteristicUuid == apolloDataRxUuid) return dataRx.stream;
|
||||
if (characteristicUuid == apolloAtRxUuid) return atRx.stream;
|
||||
throw StateError('unknown characteristic');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write({
|
||||
required String serviceUuid,
|
||||
required String characteristicUuid,
|
||||
required Uint8List value,
|
||||
}) async {
|
||||
writes.add((characteristicUuid, value));
|
||||
if (characteristicUuid == apolloAtTxUuid && pinReply != null) {
|
||||
scheduleMicrotask(() => atRx.add(Uint8List.fromList(pinReply!.codeUnits)));
|
||||
}
|
||||
}
|
||||
|
||||
void dropLink() => connState.add(BleConnectionState.disconnected);
|
||||
}
|
||||
|
||||
final device = ScooterDevice(
|
||||
id: 'AA:BB',
|
||||
name: 'Apollo Go',
|
||||
rssi: -50,
|
||||
advertisedServiceUuids: {apolloDataServiceUuid},
|
||||
);
|
||||
|
||||
/// Drains microtasks and timers so async chains settle inside fakeAsync.
|
||||
void settle(FakeAsync fa) => fa.flushMicrotasks();
|
||||
|
||||
void main() {
|
||||
test('matches uses advertised service UUIDs, not name', () {
|
||||
expect(ApolloScooter.matches(device), isTrue);
|
||||
expect(
|
||||
ApolloScooter.matches(const ScooterDevice(
|
||||
id: 'x', name: 'Apollo', rssi: 0, advertisedServiceUuids: {apolloAtServiceUuid})),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
ApolloScooter.matches(const ScooterDevice(id: 'x', name: 'Apollo Go', rssi: 0)),
|
||||
isFalse,
|
||||
);
|
||||
expect(ApolloScooter.nameHint(const ScooterDevice(id: 'x', name: 'Apollo Go', rssi: 0)), isTrue);
|
||||
});
|
||||
|
||||
test('connect subscribes to F1F2 before F2F2 and reports connected', () {
|
||||
fakeAsync((fa) {
|
||||
final ble = FakeBleClient();
|
||||
final s = ApolloScooter(ble, device, keepaliveInterval: null);
|
||||
s.connect();
|
||||
settle(fa);
|
||||
expect(ble.connectedId, 'AA:BB');
|
||||
expect(ble.subscribed, [apolloDataRxUuid, apolloAtRxUuid]);
|
||||
expect(s.state.connectionStatus, ScooterConnectionStatus.connected);
|
||||
expect(s.state.authenticated, isFalse);
|
||||
expect(s.canWrite, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
test('connect fails when Apollo characteristics are missing', () {
|
||||
fakeAsync((fa) {
|
||||
final ble = FakeBleClient()..services = {apolloDataServiceUuid: {apolloDataTxUuid}};
|
||||
final s = ApolloScooter(ble, device, keepaliveInterval: null);
|
||||
Object? error;
|
||||
s.connect().catchError((e) => error = e);
|
||||
settle(fa);
|
||||
expect(error, isStateError);
|
||||
expect(s.state.connectionStatus, ScooterConnectionStatus.error);
|
||||
expect(ble.disconnectCalls, 1);
|
||||
expect(ble.subscribed, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
test('PIN success: masked command sent, state authenticated', () {
|
||||
fakeAsync((fa) {
|
||||
final ble = FakeBleClient();
|
||||
final s = ApolloScooter(ble, device, keepaliveInterval: null);
|
||||
s.connect();
|
||||
settle(fa);
|
||||
|
||||
AuthenticationResult? result;
|
||||
s.authenticate('123456').then((r) => result = r);
|
||||
settle(fa);
|
||||
|
||||
expect(ble.writes.single.$1, apolloAtTxUuid);
|
||||
expect(String.fromCharCodes(ble.writes.single.$2), 'AT+PWD[123456]');
|
||||
expect(result, AuthenticationResult.success);
|
||||
expect(s.state.authenticated, isTrue);
|
||||
expect(s.state.connectionStatus, ScooterConnectionStatus.authenticated);
|
||||
expect(s.canWrite, isFalse, reason: 'no frames yet');
|
||||
});
|
||||
});
|
||||
|
||||
test('PIN failure: remains unauthenticated', () {
|
||||
fakeAsync((fa) {
|
||||
final ble = FakeBleClient()..pinReply = 'OK+PWD:N';
|
||||
final s = ApolloScooter(ble, device, keepaliveInterval: null);
|
||||
s.connect();
|
||||
settle(fa);
|
||||
AuthenticationResult? result;
|
||||
s.authenticate('000000').then((r) => result = r);
|
||||
settle(fa);
|
||||
expect(result, AuthenticationResult.invalidCredential);
|
||||
expect(s.state.authenticated, isFalse);
|
||||
expect(s.state.connectionStatus, ScooterConnectionStatus.connected);
|
||||
});
|
||||
});
|
||||
|
||||
test('PIN fragmented across notifications still succeeds', () {
|
||||
fakeAsync((fa) {
|
||||
final ble = FakeBleClient()..pinReply = null;
|
||||
final s = ApolloScooter(ble, device, keepaliveInterval: null);
|
||||
s.connect();
|
||||
settle(fa);
|
||||
AuthenticationResult? result;
|
||||
s.authenticate('123456').then((r) => result = r);
|
||||
settle(fa);
|
||||
ble.atRx.add(Uint8List.fromList('OK+PW'.codeUnits));
|
||||
settle(fa);
|
||||
expect(result, isNull);
|
||||
ble.atRx.add(Uint8List.fromList('D:Y'.codeUnits));
|
||||
settle(fa);
|
||||
expect(result, AuthenticationResult.success);
|
||||
});
|
||||
});
|
||||
|
||||
test('PIN with no response times out after 2 seconds, distinct from wrong PIN', () {
|
||||
fakeAsync((fa) {
|
||||
final ble = FakeBleClient()..pinReply = null;
|
||||
final s = ApolloScooter(ble, device, keepaliveInterval: null);
|
||||
s.connect();
|
||||
settle(fa);
|
||||
Object? error;
|
||||
s.authenticate('123456').catchError((e) {
|
||||
error = e;
|
||||
return AuthenticationResult.invalidCredential;
|
||||
});
|
||||
fa.elapse(const Duration(milliseconds: 1999));
|
||||
expect(error, isNull);
|
||||
expect(s.state.connectionStatus, ScooterConnectionStatus.authenticating);
|
||||
fa.elapse(const Duration(milliseconds: 2));
|
||||
expect(error, isA<TimeoutException>());
|
||||
expect(s.state.connectionStatus, ScooterConnectionStatus.connected);
|
||||
expect(s.state.authenticated, isFalse);
|
||||
expect(ble.writes.length, 1, reason: 'no retries');
|
||||
});
|
||||
});
|
||||
|
||||
group('READY gating', () {
|
||||
late FakeBleClient ble;
|
||||
late ApolloScooter s;
|
||||
|
||||
void connectAndAuth(FakeAsync fa, {bool writes = false}) {
|
||||
ble = FakeBleClient();
|
||||
s = ApolloScooter(ble, device, controlWritesEnabled: writes, keepaliveInterval: null);
|
||||
s.connect();
|
||||
settle(fa);
|
||||
s.authenticate('123456');
|
||||
settle(fa);
|
||||
}
|
||||
|
||||
test('cmd0 only: not ready, canWrite false', () {
|
||||
fakeAsync((fa) {
|
||||
connectAndAuth(fa, writes: true);
|
||||
ble.dataRx.add(monitorFrame);
|
||||
settle(fa);
|
||||
expect(s.state.connectionStatus, ScooterConnectionStatus.authenticated);
|
||||
expect(s.state.speed, 0.25, reason: 'scaling flag unknown yet -> raw/1000');
|
||||
expect(s.state.batteryLevel, 75);
|
||||
expect(s.canWrite, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
test('cmd1 only: not ready, canWrite false', () {
|
||||
fakeAsync((fa) {
|
||||
connectAndAuth(fa, writes: true);
|
||||
ble.dataRx.add(baseFrame);
|
||||
settle(fa);
|
||||
expect(s.state.connectionStatus, ScooterConnectionStatus.authenticated);
|
||||
expect(s.state.displayVersion, 'V2.5.17');
|
||||
expect(s.canWrite, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
test('cmd0 + cmd1: ready, speed re-derived with scaling flag', () {
|
||||
fakeAsync((fa) {
|
||||
connectAndAuth(fa, writes: true);
|
||||
ble.dataRx.add(monitorFrame);
|
||||
ble.dataRx.add(baseFrame);
|
||||
settle(fa);
|
||||
expect(s.state.connectionStatus, ScooterConnectionStatus.ready);
|
||||
expect(s.state.speed, 25.0);
|
||||
expect(s.state.locked, isFalse);
|
||||
expect(s.state.batteryCycles, 100);
|
||||
expect(s.canWrite, isTrue);
|
||||
expect(s.state.canWrite, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
test('frames before authentication do not make the scooter ready', () {
|
||||
fakeAsync((fa) {
|
||||
ble = FakeBleClient();
|
||||
s = ApolloScooter(ble, device, controlWritesEnabled: true, keepaliveInterval: null);
|
||||
s.connect();
|
||||
settle(fa);
|
||||
ble.dataRx.add(Uint8List.fromList([...monitorFrame, ...baseFrame]));
|
||||
settle(fa);
|
||||
expect(s.state.connectionStatus, ScooterConnectionStatus.connected);
|
||||
expect(s.canWrite, isFalse);
|
||||
s.authenticate('123456');
|
||||
settle(fa);
|
||||
expect(s.state.connectionStatus, ScooterConnectionStatus.ready);
|
||||
});
|
||||
});
|
||||
|
||||
test('write gate flag off: ready but canWrite stays false', () {
|
||||
fakeAsync((fa) {
|
||||
connectAndAuth(fa);
|
||||
ble.dataRx.add(Uint8List.fromList([...monitorFrame, ...baseFrame]));
|
||||
settle(fa);
|
||||
expect(s.state.connectionStatus, ScooterConnectionStatus.ready);
|
||||
expect(s.canWrite, isFalse);
|
||||
Object? error;
|
||||
s.lock().catchError((e) => error = e);
|
||||
settle(fa);
|
||||
expect(error, isStateError);
|
||||
expect(ble.writes.where((w) => w.$1 == apolloDataTxUuid), isEmpty);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('unexpected disconnect clears everything and fails pending auth', () {
|
||||
fakeAsync((fa) {
|
||||
final ble = FakeBleClient()..pinReply = null;
|
||||
final s = ApolloScooter(ble, device, controlWritesEnabled: true, keepaliveInterval: null);
|
||||
s.connect();
|
||||
settle(fa);
|
||||
ble.dataRx.add(Uint8List.fromList([...monitorFrame, ...baseFrame]));
|
||||
settle(fa);
|
||||
Object? error;
|
||||
s.authenticate('123456').catchError((e) {
|
||||
error = e;
|
||||
return AuthenticationResult.invalidCredential;
|
||||
});
|
||||
settle(fa);
|
||||
|
||||
ble.dropLink();
|
||||
settle(fa);
|
||||
|
||||
expect(error, isA<ScooterConnectionLostException>());
|
||||
expect(s.state.connectionStatus, ScooterConnectionStatus.error);
|
||||
expect(s.state.authenticated, isFalse);
|
||||
expect(s.state.speed, isNull);
|
||||
expect(s.state.displayVersion, isNull);
|
||||
expect(s.canWrite, isFalse);
|
||||
|
||||
// Data from the dead link is ignored: the stream was unsubscribed.
|
||||
ble.dataRx.add(monitorFrame);
|
||||
settle(fa);
|
||||
expect(s.state.speed, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('control writes (enabled for test only)', () {
|
||||
late FakeBleClient ble;
|
||||
late ApolloScooter s;
|
||||
|
||||
void ready(FakeAsync fa) {
|
||||
ble = FakeBleClient();
|
||||
s = ApolloScooter(ble, device, controlWritesEnabled: true, keepaliveInterval: null);
|
||||
s.connect();
|
||||
settle(fa);
|
||||
s.authenticate('123456');
|
||||
settle(fa);
|
||||
ble.dataRx.add(Uint8List.fromList([...monitorFrame, ...baseFrame]));
|
||||
settle(fa);
|
||||
ble.writes.clear();
|
||||
expect(s.canWrite, isTrue);
|
||||
}
|
||||
|
||||
List<Uint8List> dataWrites() =>
|
||||
ble.writes.where((w) => w.$1 == apolloDataTxUuid).map((w) => w.$2).toList();
|
||||
|
||||
test('no-op when already in requested state', () {
|
||||
fakeAsync((fa) {
|
||||
ready(fa);
|
||||
var done = false;
|
||||
s.unlock().then((_) => done = true); // vector is already unlocked
|
||||
settle(fa);
|
||||
expect(done, isTrue);
|
||||
expect(dataWrites(), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
test('lock preserves everything else and confirms on a fresh cmd0', () {
|
||||
fakeAsync((fa) {
|
||||
ready(fa);
|
||||
var done = false;
|
||||
s.lock().then((_) => done = true);
|
||||
settle(fa);
|
||||
|
||||
final w = dataWrites().single;
|
||||
// monitor vector: gear 2, headlight on, atmosphere on, cruise on,
|
||||
// imperial on, boot off, unlocked -> locked.
|
||||
final expected = buildApolloSetBasePacket(
|
||||
gearPosition: 2,
|
||||
headlight: true,
|
||||
atmosphereLight: true,
|
||||
cruiseControl: true,
|
||||
bootMode: false,
|
||||
imperial: true,
|
||||
unlocked: false,
|
||||
limitCruise: 25,
|
||||
limitMode1: 6,
|
||||
limitMode2: 12,
|
||||
limitMode3: 20,
|
||||
);
|
||||
expect(w, expected);
|
||||
expect(w[3] & 0x80, 0, reason: 'unlocked bit cleared');
|
||||
expect(done, isFalse, reason: 'GATT write alone is not confirmation');
|
||||
|
||||
// Re-sending the OLD (still unlocked) frame must not confirm.
|
||||
ble.dataRx.add(monitorFrame);
|
||||
settle(fa);
|
||||
expect(done, isFalse);
|
||||
|
||||
// Fresh frame with unlocked bit (bit 3 of byte 21) cleared confirms.
|
||||
ble.dataRx.add(withFlags(monitorFrame, flagsA: 0x8A & ~(1 << 3)));
|
||||
settle(fa);
|
||||
expect(done, isTrue);
|
||||
expect(s.state.locked, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
test('unconfirmed write times out after 2 seconds with no retry', () {
|
||||
fakeAsync((fa) {
|
||||
ready(fa);
|
||||
Object? error;
|
||||
s.setHeadlight(false).catchError((e) => error = e);
|
||||
settle(fa);
|
||||
fa.elapse(const Duration(seconds: 2));
|
||||
expect(error, isA<TimeoutException>());
|
||||
expect(dataWrites().length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('writes are serialized and the second reads fresh state', () {
|
||||
fakeAsync((fa) {
|
||||
ready(fa);
|
||||
var lockDone = false;
|
||||
var headlightDone = false;
|
||||
s.lock().then((_) => lockDone = true);
|
||||
s.setHeadlight(false).then((_) => headlightDone = true);
|
||||
settle(fa);
|
||||
|
||||
// Only the lock packet has gone out so far.
|
||||
expect(dataWrites().length, 1);
|
||||
|
||||
// Scooter confirms lock.
|
||||
final locked = withFlags(monitorFrame, flagsA: 0x8A & ~(1 << 3));
|
||||
ble.dataRx.add(locked);
|
||||
settle(fa);
|
||||
expect(lockDone, isTrue);
|
||||
|
||||
// Now the headlight packet is sent, built from the LOCKED state.
|
||||
final packets = dataWrites();
|
||||
expect(packets.length, 2);
|
||||
expect(packets[1][3] & 0x80, 0, reason: 'must not re-unlock the scooter');
|
||||
expect(packets[1][3] & 0x04, 0, reason: 'headlight bit cleared');
|
||||
|
||||
ble.dataRx.add(withFlags(monitorFrame, flagsA: 0x8A & ~(1 << 3) & ~(1 << 7)));
|
||||
settle(fa);
|
||||
expect(headlightDone, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
test('disconnect during a write fails it immediately and drains the queue', () {
|
||||
fakeAsync((fa) {
|
||||
ready(fa);
|
||||
Object? e1, e2;
|
||||
s.lock().catchError((e) => e1 = e);
|
||||
s.setHeadlight(false).catchError((e) => e2 = e);
|
||||
settle(fa);
|
||||
ble.dropLink();
|
||||
settle(fa);
|
||||
expect(e1, isA<ScooterConnectionLostException>());
|
||||
expect(e2, isStateError, reason: 'queued op fails on its turn, no write sent');
|
||||
expect(dataWrites().length, 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('keepalive is on by default at 1 s, can be disabled, and is a fixed packet', () {
|
||||
fakeAsync((fa) {
|
||||
final ble = FakeBleClient();
|
||||
final s = ApolloScooter(ble, device);
|
||||
s.connect();
|
||||
settle(fa);
|
||||
fa.elapse(const Duration(milliseconds: 3500));
|
||||
expect(ble.writes.length, 3);
|
||||
expect(ble.writes.every((w) => w.$1 == apolloDataTxUuid), isTrue);
|
||||
s.setKeepaliveInterval(null);
|
||||
fa.elapse(const Duration(seconds: 5));
|
||||
expect(ble.writes.length, 3);
|
||||
|
||||
final ble0 = FakeBleClient();
|
||||
ApolloScooter(ble0, device, keepaliveInterval: null).connect();
|
||||
settle(fa);
|
||||
fa.elapse(const Duration(minutes: 1));
|
||||
expect(ble0.writes, isEmpty);
|
||||
|
||||
final ble2 = FakeBleClient();
|
||||
final s2 = ApolloScooter(ble2, device, keepaliveInterval: const Duration(seconds: 5));
|
||||
s2.connect();
|
||||
settle(fa);
|
||||
fa.elapse(const Duration(seconds: 11));
|
||||
expect(ble2.writes.length, 2);
|
||||
expect(ble2.writes.first.$2, hex('A5 02 FD 5A'));
|
||||
s2.disconnect();
|
||||
settle(fa);
|
||||
fa.elapse(const Duration(seconds: 10));
|
||||
expect(ble2.writes.length, 2, reason: 'timer cancelled on disconnect');
|
||||
});
|
||||
});
|
||||
}
|
||||