Initial version
This commit is contained in:
@@ -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),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user