feat: ui rework and gear generator
This commit is contained in:
20
lib/controller/shifter_device_telemetry.dart
Normal file
20
lib/controller/shifter_device_telemetry.dart
Normal file
@ -0,0 +1,20 @@
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
final shifterDeviceTelemetryCacheProvider = StateNotifierProvider<
|
||||
ShifterDeviceTelemetryCache, Map<String, ShifterDeviceTelemetry>>(
|
||||
(ref) => ShifterDeviceTelemetryCache(),
|
||||
);
|
||||
|
||||
class ShifterDeviceTelemetryCache
|
||||
extends StateNotifier<Map<String, ShifterDeviceTelemetry>> {
|
||||
ShifterDeviceTelemetryCache() : super(const {});
|
||||
|
||||
void upsert(String deviceId, ShifterDeviceTelemetry telemetry) {
|
||||
final existing = state[deviceId];
|
||||
state = {
|
||||
...state,
|
||||
deviceId: existing == null ? telemetry : existing.merge(telemetry),
|
||||
};
|
||||
}
|
||||
}
|
||||
91
lib/model/gear_configurator.dart
Normal file
91
lib/model/gear_configurator.dart
Normal file
@ -0,0 +1,91 @@
|
||||
import 'package:abawo_bt_app/model/gear_ratio_codec.dart';
|
||||
|
||||
enum GearRetentionMode { keepHighest, keepAll }
|
||||
|
||||
class GearConfiguratorCalculation {
|
||||
const GearConfiguratorCalculation({
|
||||
required this.ratios,
|
||||
required this.discardedBelowRange,
|
||||
required this.discardedAboveRange,
|
||||
required this.duplicateCount,
|
||||
required this.truncatedCount,
|
||||
});
|
||||
|
||||
final List<double> ratios;
|
||||
final int discardedBelowRange;
|
||||
final int discardedAboveRange;
|
||||
final int duplicateCount;
|
||||
final int truncatedCount;
|
||||
}
|
||||
|
||||
GearConfiguratorCalculation calculateGearRatios({
|
||||
required List<int> chainrings,
|
||||
required List<int> sprockets,
|
||||
required GearRetentionMode mode,
|
||||
int maxRatios = 32,
|
||||
}) {
|
||||
final sortedChainrings = List<int>.from(chainrings)..sort();
|
||||
final sortedSprockets = List<int>.from(sprockets)..sort((a, b) => b - a);
|
||||
|
||||
var discardedBelowRange = 0;
|
||||
var discardedAboveRange = 0;
|
||||
var duplicateCount = 0;
|
||||
final encoded = <int>{};
|
||||
final ratios = <double>[];
|
||||
|
||||
void addRatio(double ratio) {
|
||||
if (ratio < gearRatioMin) {
|
||||
discardedBelowRange++;
|
||||
return;
|
||||
}
|
||||
if (ratio > gearRatioMax) {
|
||||
discardedAboveRange++;
|
||||
return;
|
||||
}
|
||||
|
||||
final raw = encodeGearRatioByte(ratio);
|
||||
if (!encoded.add(raw)) {
|
||||
duplicateCount++;
|
||||
return;
|
||||
}
|
||||
ratios.add(decodeGearRatioByte(raw));
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case GearRetentionMode.keepAll:
|
||||
for (final chainring in sortedChainrings) {
|
||||
for (final sprocket in sortedSprockets) {
|
||||
addRatio(chainring / sprocket);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case GearRetentionMode.keepHighest:
|
||||
var threshold = double.negativeInfinity;
|
||||
for (final chainring in sortedChainrings) {
|
||||
final beforeLength = ratios.length;
|
||||
for (final sprocket in sortedSprockets) {
|
||||
final ratio = chainring / sprocket;
|
||||
if (ratio >= threshold) {
|
||||
addRatio(ratio);
|
||||
}
|
||||
}
|
||||
if (ratios.length > beforeLength) {
|
||||
threshold = ratios.reduce((a, b) => a > b ? a : b);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
ratios.sort();
|
||||
final truncatedCount =
|
||||
ratios.length > maxRatios ? ratios.length - maxRatios : 0;
|
||||
final limited = ratios.take(maxRatios).toList(growable: false);
|
||||
|
||||
return GearConfiguratorCalculation(
|
||||
ratios: limited,
|
||||
discardedBelowRange: discardedBelowRange,
|
||||
discardedAboveRange: discardedAboveRange,
|
||||
duplicateCount: duplicateCount,
|
||||
truncatedCount: truncatedCount,
|
||||
);
|
||||
}
|
||||
31
lib/model/gear_ratio_codec.dart
Normal file
31
lib/model/gear_ratio_codec.dart
Normal file
@ -0,0 +1,31 @@
|
||||
const double gearRatioMin = 0.25;
|
||||
const double gearRatioMax = 5.75;
|
||||
const int gearRatioEmptyRaw = 0;
|
||||
const int gearRatioMinRaw = 1;
|
||||
const int gearRatioMaxRaw = 255;
|
||||
|
||||
const double gearRatioStep =
|
||||
(gearRatioMax - gearRatioMin) / (gearRatioMaxRaw - gearRatioMinRaw);
|
||||
|
||||
int encodeGearRatioByte(double value) {
|
||||
if (value <= 0) {
|
||||
return gearRatioEmptyRaw;
|
||||
}
|
||||
final clamped = value.clamp(gearRatioMin, gearRatioMax);
|
||||
final scaled = ((clamped - gearRatioMin) / gearRatioStep).round();
|
||||
return (gearRatioMinRaw + scaled)
|
||||
.clamp(gearRatioMinRaw, gearRatioMaxRaw)
|
||||
.toInt();
|
||||
}
|
||||
|
||||
double decodeGearRatioByte(int raw) {
|
||||
if (raw <= gearRatioEmptyRaw) {
|
||||
return 0;
|
||||
}
|
||||
final clamped = raw.clamp(gearRatioMinRaw, gearRatioMaxRaw).toInt();
|
||||
return gearRatioMin + (clamped - gearRatioMinRaw) * gearRatioStep;
|
||||
}
|
||||
|
||||
double quantizeGearRatio(double value) {
|
||||
return decodeGearRatioByte(encodeGearRatioByte(value));
|
||||
}
|
||||
@ -1,6 +1,4 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cbor/simple.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
const String universalShifterControlServiceUuid =
|
||||
'0993826f-0ee4-4b37-9614-d13ecba4ffc2';
|
||||
@ -19,6 +17,13 @@ const String universalShifterDfuDataCharacteristicUuid =
|
||||
const String universalShifterDfuAckCharacteristicUuid =
|
||||
'0993826f-0ee4-4b37-9614-d13ecba4000a';
|
||||
const String ftmsServiceUuid = '00001826-0000-1000-8000-00805f9b34fb';
|
||||
const String batteryServiceUuid = '0000180f-0000-1000-8000-00805f9b34fb';
|
||||
const String batteryLevelCharacteristicUuid =
|
||||
'00002a19-0000-1000-8000-00805f9b34fb';
|
||||
const String deviceInformationServiceUuid =
|
||||
'0000180a-0000-1000-8000-00805f9b34fb';
|
||||
const String firmwareRevisionCharacteristicUuid =
|
||||
'00002a26-0000-1000-8000-00805f9b34fb';
|
||||
|
||||
const int universalShifterDfuOpcodeStart = 0x01;
|
||||
const int universalShifterDfuOpcodeFinish = 0x02;
|
||||
@ -239,6 +244,49 @@ enum UniversalShifterCommand {
|
||||
final int value;
|
||||
}
|
||||
|
||||
class ShifterDeviceTelemetry {
|
||||
const ShifterDeviceTelemetry({
|
||||
this.batteryPercent,
|
||||
this.firmwareRevision,
|
||||
});
|
||||
|
||||
final int? batteryPercent;
|
||||
final String? firmwareRevision;
|
||||
|
||||
String get batteryLabel => batteryPercent == null ? '--' : '$batteryPercent%';
|
||||
|
||||
String get firmwareLabel => firmwareRevision ?? '--';
|
||||
|
||||
ShifterDeviceTelemetry merge(ShifterDeviceTelemetry next) {
|
||||
return ShifterDeviceTelemetry(
|
||||
batteryPercent: next.batteryPercent ?? batteryPercent,
|
||||
firmwareRevision: next.firmwareRevision ?? firmwareRevision,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
int parseBatteryLevelPercent(List<int> payload) {
|
||||
if (payload.isEmpty) {
|
||||
throw const FormatException('Battery level payload is empty.');
|
||||
}
|
||||
|
||||
final value = payload.first;
|
||||
if (value < 0 || value > 100) {
|
||||
throw FormatException('Battery level out of range: $value.');
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
String? parseGattUtf8String(List<int> payload) {
|
||||
if (payload.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final value = utf8.decode(payload).replaceAll('\u0000', '').trim();
|
||||
return value.isEmpty ? null : value;
|
||||
}
|
||||
|
||||
enum ControlConnectionState {
|
||||
disconnected,
|
||||
connected;
|
||||
@ -412,58 +460,50 @@ class CentralStatus {
|
||||
);
|
||||
}
|
||||
|
||||
static CentralStatus fromCborBytes(List<int> bytes) {
|
||||
static CentralStatus fromBytes(List<int> bytes) {
|
||||
if (bytes.isEmpty) {
|
||||
throw const FormatException('Status payload is empty.');
|
||||
}
|
||||
|
||||
final decoded = cbor.decode(bytes);
|
||||
if (decoded is! Map) {
|
||||
if (bytes.length != 12) {
|
||||
throw FormatException(
|
||||
'Status payload must decode to a CBOR map, got ${decoded.runtimeType}.',
|
||||
'Status payload must be 12 bytes, got ${bytes.length}.',
|
||||
);
|
||||
}
|
||||
|
||||
final controlRaw = _readMapValue(decoded, [0, 'control']);
|
||||
final trainerRaw = _readMapValue(decoded, [1, 'trainer']);
|
||||
final hasSavedBondRaw = _readMapValue(decoded, [2, 'has_saved_bond']);
|
||||
final connectedTrainerAddrRaw =
|
||||
_readMapValue(decoded, [3, 'connected_trainer_addr']);
|
||||
final lastFailureRaw = _readMapValue(decoded, [4, 'last_failure']);
|
||||
final protocolVersion = bytes[0];
|
||||
if (protocolVersion != 1) {
|
||||
throw FormatException(
|
||||
'Unsupported status protocol version: $protocolVersion.',
|
||||
);
|
||||
}
|
||||
|
||||
final flags = bytes[4];
|
||||
final hasSavedBond = (flags & 0x01) != 0;
|
||||
final hasConnectedTrainerAddr = (flags & 0x02) != 0;
|
||||
final hasLastFailure = (flags & 0x04) != 0;
|
||||
final trainerErrorCode = bytes[3] == 0 ? null : bytes[3];
|
||||
final trainer = bytes[2] == 6
|
||||
? TrainerStatus(
|
||||
state: TrainerConnectionState.error,
|
||||
errorCode: trainerErrorCode,
|
||||
)
|
||||
: TrainerStatus.fromRaw(bytes[2]);
|
||||
final connectedTrainerAddr = hasConnectedTrainerAddr
|
||||
? bytes.sublist(5, 11).toList(growable: false)
|
||||
: null;
|
||||
final lastFailure = hasLastFailure && bytes[11] != 0 ? bytes[11] : null;
|
||||
|
||||
return CentralStatus(
|
||||
control: ControlConnectionState.fromRaw(controlRaw),
|
||||
trainer: TrainerStatus.fromRaw(trainerRaw),
|
||||
hasSavedBond: hasSavedBondRaw is bool ? hasSavedBondRaw : false,
|
||||
connectedTrainerAddr: _toByteList(connectedTrainerAddrRaw),
|
||||
lastFailure: lastFailureRaw is int ? lastFailureRaw : null,
|
||||
raw: decoded,
|
||||
control: ControlConnectionState.fromRaw(bytes[1]),
|
||||
trainer: trainer,
|
||||
hasSavedBond: hasSavedBond,
|
||||
connectedTrainerAddr: connectedTrainerAddr,
|
||||
lastFailure: lastFailure,
|
||||
raw: bytes.toList(growable: false),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
dynamic _readMapValue(Map<dynamic, dynamic> map, List<dynamic> keys) {
|
||||
for (final key in keys) {
|
||||
if (map.containsKey(key)) {
|
||||
return map[key];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<int>? _toByteList(dynamic value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value is Uint8List) {
|
||||
return value.toList(growable: false);
|
||||
}
|
||||
if (value is List) {
|
||||
return value.whereType<int>().toList(growable: false);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<int> parseMacToLittleEndianBytes(String macAddress) {
|
||||
final compact = macAddress.replaceAll(':', '').replaceAll('-', '');
|
||||
if (compact.length != 12) {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:abawo_bt_app/controller/shifter_device_telemetry.dart';
|
||||
import 'package:abawo_bt_app/model/firmware_file_selection.dart';
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
import 'package:abawo_bt_app/service/firmware_file_selection_service.dart';
|
||||
@ -69,6 +70,8 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
String? _gearRatiosError;
|
||||
List<double> _gearRatios = const [];
|
||||
int _defaultGearIndex = 0;
|
||||
bool _isDeviceTelemetryLoading = false;
|
||||
bool _hasLoadedDeviceTelemetry = false;
|
||||
|
||||
late final FirmwareFileSelectionService _firmwareFileSelectionService;
|
||||
FirmwareUpdateService? _firmwareUpdateService;
|
||||
@ -195,6 +198,9 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
if (!_hasLoadedGearRatios && !_isGearRatiosLoading) {
|
||||
unawaited(_loadGearRatios());
|
||||
}
|
||||
if (!_hasLoadedDeviceTelemetry && !_isDeviceTelemetryLoading) {
|
||||
unawaited(_loadDeviceTelemetry());
|
||||
}
|
||||
return;
|
||||
}
|
||||
final asyncBluetooth = ref.read(bluetoothProvider);
|
||||
@ -238,6 +244,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
_shifterService = service;
|
||||
});
|
||||
unawaited(_loadGearRatios());
|
||||
unawaited(_loadDeviceTelemetry());
|
||||
}
|
||||
|
||||
Future<void> _showPairingRecoveryDialog() async {
|
||||
@ -276,6 +283,8 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
await _disposeFirmwareUpdateService();
|
||||
await _shifterService?.dispose();
|
||||
_shifterService = null;
|
||||
_isDeviceTelemetryLoading = false;
|
||||
_hasLoadedDeviceTelemetry = false;
|
||||
}
|
||||
|
||||
Future<void> _disposeFirmwareUpdateService() async {
|
||||
@ -285,6 +294,34 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
_firmwareUpdateService = null;
|
||||
}
|
||||
|
||||
Future<void> _loadDeviceTelemetry({bool force = false}) async {
|
||||
final shifter = _shifterService;
|
||||
if (shifter == null || _isDeviceTelemetryLoading) {
|
||||
return;
|
||||
}
|
||||
if (_hasLoadedDeviceTelemetry && !force) {
|
||||
return;
|
||||
}
|
||||
|
||||
_isDeviceTelemetryLoading = true;
|
||||
final result = await shifter.readDeviceTelemetry();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
_isDeviceTelemetryLoading = false;
|
||||
if (result.isErr()) {
|
||||
_hasLoadedDeviceTelemetry = false;
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(shifterDeviceTelemetryCacheProvider.notifier).upsert(
|
||||
widget.deviceAddress,
|
||||
result.unwrap(),
|
||||
);
|
||||
_hasLoadedDeviceTelemetry = true;
|
||||
}
|
||||
|
||||
Future<void> _loadGearRatios() async {
|
||||
final shifter = _shifterService;
|
||||
if (shifter == null || _isGearRatiosLoading || _isFirmwareUpdateBusy) {
|
||||
@ -539,6 +576,11 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
_firmwareUserMessage = result.unwrapErr().toString();
|
||||
}
|
||||
});
|
||||
|
||||
if (result.isOk()) {
|
||||
_hasLoadedDeviceTelemetry = false;
|
||||
unawaited(_loadDeviceTelemetry(force: true));
|
||||
}
|
||||
}
|
||||
|
||||
String _dfuPhaseText(DfuUpdateState state) {
|
||||
@ -1208,6 +1250,11 @@ Widget _buildDeviceOverviewCard(
|
||||
required CentralStatus? status,
|
||||
}) {
|
||||
final asyncSavedDevices = ref.watch(nConnectedDevicesProvider);
|
||||
final telemetry = ref.watch(
|
||||
shifterDeviceTelemetryCacheProvider.select(
|
||||
(cache) => cache[deviceAddress],
|
||||
),
|
||||
);
|
||||
|
||||
return asyncSavedDevices.when(
|
||||
data: (devices) {
|
||||
@ -1229,12 +1276,11 @@ Widget _buildDeviceOverviewCard(
|
||||
);
|
||||
}
|
||||
|
||||
// TODO(yannik): Replace these overview placeholder metrics with actual
|
||||
// battery, signal, and firmware values once the device exposes them.
|
||||
return _DeviceOverviewCard(
|
||||
device: currentDeviceData,
|
||||
connectionStatus: connectionStatus,
|
||||
status: status,
|
||||
telemetry: telemetry,
|
||||
);
|
||||
},
|
||||
loading: () => const Card(
|
||||
@ -1257,11 +1303,13 @@ class _DeviceOverviewCard extends StatelessWidget {
|
||||
required this.device,
|
||||
required this.connectionStatus,
|
||||
required this.status,
|
||||
required this.telemetry,
|
||||
});
|
||||
|
||||
final ConnectedDevice device;
|
||||
final ConnectionStatus connectionStatus;
|
||||
final CentralStatus? status;
|
||||
final ShifterDeviceTelemetry? telemetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -1317,27 +1365,27 @@ class _DeviceOverviewCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Row(
|
||||
children: const [
|
||||
children: [
|
||||
Expanded(
|
||||
child: _OverviewMetricTile(
|
||||
label: 'Battery',
|
||||
value: '--',
|
||||
value: telemetry?.batteryLabel ?? '--',
|
||||
icon: Icons.battery_charging_full_rounded,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10),
|
||||
Expanded(
|
||||
const SizedBox(width: 10),
|
||||
const Expanded(
|
||||
child: _OverviewMetricTile(
|
||||
label: 'Signal',
|
||||
value: 'Ready',
|
||||
icon: Icons.signal_cellular_alt_rounded,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _OverviewMetricTile(
|
||||
label: 'Firmware',
|
||||
value: '--',
|
||||
value: telemetry?.firmwareLabel ?? '--',
|
||||
icon: Icons.memory_rounded,
|
||||
),
|
||||
),
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
||||
import 'package:abawo_bt_app/controller/shifter_device_telemetry.dart';
|
||||
import 'package:abawo_bt_app/database/database.dart';
|
||||
import 'package:abawo_bt_app/model/bluetooth_device_model.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@ -253,7 +254,7 @@ class _SavedDevicesListState extends ConsumerState<_SavedDevicesList> {
|
||||
}
|
||||
}
|
||||
|
||||
class _ActiveDeviceCard extends StatelessWidget {
|
||||
class _ActiveDeviceCard extends ConsumerWidget {
|
||||
const _ActiveDeviceCard({
|
||||
required this.devices,
|
||||
required this.connectionData,
|
||||
@ -263,7 +264,7 @@ class _ActiveDeviceCard extends StatelessWidget {
|
||||
final (ConnectionStatus, String?)? connectionData;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final shifterDevices = devices
|
||||
.where(
|
||||
(device) =>
|
||||
@ -291,11 +292,14 @@ class _ActiveDeviceCard extends StatelessWidget {
|
||||
final isConnected = connectedId == primaryDevice.deviceAddress &&
|
||||
connectionData?.$1 == ConnectionStatus.connected;
|
||||
|
||||
// TODO(yannik): Populate battery, signal, and firmware from real device
|
||||
// telemetry once these values are exposed in the saved-device overview.
|
||||
const batteryLabel = '--';
|
||||
final telemetry = ref.watch(
|
||||
shifterDeviceTelemetryCacheProvider.select(
|
||||
(cache) => cache[primaryDevice.deviceAddress],
|
||||
),
|
||||
);
|
||||
final batteryLabel = telemetry?.batteryLabel ?? '--';
|
||||
const signalLabel = 'Ready';
|
||||
const firmwareLabel = '--';
|
||||
final firmwareLabel = telemetry?.firmwareLabel ?? '--';
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
@ -350,7 +354,7 @@ class _ActiveDeviceCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Row(
|
||||
children: const [
|
||||
children: [
|
||||
Expanded(
|
||||
child: _MetricTile(
|
||||
label: 'Battery',
|
||||
@ -358,15 +362,15 @@ class _ActiveDeviceCard extends StatelessWidget {
|
||||
icon: Icons.battery_charging_full_rounded,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10),
|
||||
Expanded(
|
||||
const SizedBox(width: 10),
|
||||
const Expanded(
|
||||
child: _MetricTile(
|
||||
label: 'Signal',
|
||||
value: signalLabel,
|
||||
icon: Icons.signal_cellular_alt,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _MetricTile(
|
||||
label: 'Firmware',
|
||||
|
||||
@ -544,8 +544,8 @@ abstract interface class FirmwareUpdateTransport {
|
||||
|
||||
/// Verifies that the device is reachable after reconnect.
|
||||
///
|
||||
/// Current limitation: strict firmware version comparison is not possible
|
||||
/// yet because no firmware version characteristic is exposed by the device.
|
||||
/// Current limitation: strict firmware image comparison is not possible from
|
||||
/// the selected binary metadata alone, even though DIS exposes a revision.
|
||||
Future<Result<void>> verifyDeviceReachable({
|
||||
required Duration timeout,
|
||||
});
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
||||
import 'package:abawo_bt_app/model/gear_ratio_codec.dart';
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
import 'package:anyhow/anyhow.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@ -43,7 +44,6 @@ class ShifterService {
|
||||
static const int _gearRatioSlots = 32;
|
||||
static const int _defaultGearIndexOffset = _gearRatioSlots;
|
||||
static const int _gearRatioPayloadBytes = _gearRatioSlots + 1;
|
||||
static const double _maxGearRatio = 255 / 64;
|
||||
static const int _gearRatioWriteMtu = 64;
|
||||
|
||||
Future<Result<void>> writeConnectToAddress(String bikeDeviceId) async {
|
||||
@ -166,12 +166,61 @@ class ShifterService {
|
||||
}
|
||||
|
||||
try {
|
||||
return Ok(CentralStatus.fromCborBytes(readRes.unwrap()));
|
||||
return Ok(CentralStatus.fromBytes(readRes.unwrap()));
|
||||
} catch (e) {
|
||||
return bail('Failed to decode status payload: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Result<ShifterDeviceTelemetry>> readDeviceTelemetry() async {
|
||||
int? batteryPercent;
|
||||
String? firmwareRevision;
|
||||
final errors = <String>[];
|
||||
|
||||
final batteryResult = await _requireBluetooth.readCharacteristic(
|
||||
buttonDeviceId,
|
||||
batteryServiceUuid,
|
||||
batteryLevelCharacteristicUuid,
|
||||
);
|
||||
if (batteryResult.isOk()) {
|
||||
try {
|
||||
batteryPercent = parseBatteryLevelPercent(batteryResult.unwrap());
|
||||
} catch (error) {
|
||||
errors.add('battery parse failed: $error');
|
||||
}
|
||||
} else {
|
||||
errors.add('battery read failed: ${batteryResult.unwrapErr()}');
|
||||
}
|
||||
|
||||
final firmwareResult = await _requireBluetooth.readCharacteristic(
|
||||
buttonDeviceId,
|
||||
deviceInformationServiceUuid,
|
||||
firmwareRevisionCharacteristicUuid,
|
||||
);
|
||||
if (firmwareResult.isOk()) {
|
||||
try {
|
||||
firmwareRevision = parseGattUtf8String(firmwareResult.unwrap());
|
||||
} catch (error) {
|
||||
errors.add('firmware parse failed: $error');
|
||||
}
|
||||
} else {
|
||||
errors.add('firmware read failed: ${firmwareResult.unwrapErr()}');
|
||||
}
|
||||
|
||||
if (batteryPercent == null &&
|
||||
firmwareRevision == null &&
|
||||
errors.isNotEmpty) {
|
||||
return bail('Could not read battery or firmware: ${errors.join('; ')}');
|
||||
}
|
||||
|
||||
return Ok(
|
||||
ShifterDeviceTelemetry(
|
||||
batteryPercent: batteryPercent,
|
||||
firmwareRevision: firmwareRevision,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Result<DfuPreflightResult>> runDfuPreflight({
|
||||
int requestedMtu = universalShifterDfuPreferredMtu,
|
||||
}) async {
|
||||
@ -253,7 +302,7 @@ class ShifterService {
|
||||
.listen(
|
||||
(data) {
|
||||
try {
|
||||
final status = CentralStatus.fromCborBytes(data);
|
||||
final status = CentralStatus.fromBytes(data);
|
||||
_statusController.add(status);
|
||||
} catch (error, st) {
|
||||
_log.warning(
|
||||
@ -284,19 +333,11 @@ class ShifterService {
|
||||
}
|
||||
|
||||
int _encodeGearRatio(double value) {
|
||||
if (value <= 0) {
|
||||
return 0;
|
||||
}
|
||||
final clamped = value.clamp(0, _maxGearRatio);
|
||||
final scaled = (clamped * 64).round();
|
||||
if (scaled <= 0) {
|
||||
return 1;
|
||||
}
|
||||
return scaled.clamp(1, 255);
|
||||
return encodeGearRatioByte(value);
|
||||
}
|
||||
|
||||
double _decodeGearRatio(int raw) {
|
||||
return raw / 64.0;
|
||||
return decodeGearRatioByte(raw);
|
||||
}
|
||||
|
||||
String _formatBytes(List<int> bytes) {
|
||||
|
||||
1004
lib/widgets/gear_configurator_dialog.dart
Normal file
1004
lib/widgets/gear_configurator_dialog.dart
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,7 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:abawo_bt_app/model/gear_ratio_codec.dart';
|
||||
import 'package:abawo_bt_app/widgets/gear_configurator_dialog.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class GearRatioPreset {
|
||||
@ -76,8 +78,8 @@ class GearRatioEditorCard extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
static const double _sliderMin = 0.10;
|
||||
static const double _sliderMax = 3.90;
|
||||
static const double _sliderMin = gearRatioMin;
|
||||
static const double _sliderMax = gearRatioMax;
|
||||
static const double _sliderPivotT = 0.50;
|
||||
static const double _sliderPivotV = 1.00;
|
||||
static const Duration _animDuration = Duration(milliseconds: 280);
|
||||
@ -165,10 +167,22 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
if (!_isEditing)
|
||||
IconButton(
|
||||
tooltip: 'Configure drivetrain',
|
||||
onPressed: (widget.isLoading ||
|
||||
widget.errorText != null ||
|
||||
_isSaving)
|
||||
? null
|
||||
: _openGearConfigurator,
|
||||
icon: const Icon(Icons.settings_input_component_outlined),
|
||||
),
|
||||
if (!_isEditing)
|
||||
IconButton(
|
||||
tooltip: 'Edit ratios',
|
||||
onPressed: (widget.isLoading || widget.errorText != null)
|
||||
onPressed: (widget.isLoading ||
|
||||
widget.errorText != null ||
|
||||
_isSaving)
|
||||
? null
|
||||
: _enterEditMode,
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
@ -830,6 +844,62 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
_loadPreset(selected);
|
||||
}
|
||||
|
||||
Future<void> _openGearConfigurator() async {
|
||||
final calculation = await showGearConfiguratorDialog(context);
|
||||
if (!mounted || calculation == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isSaving = true;
|
||||
});
|
||||
|
||||
final ratios = List<double>.from(calculation.ratios);
|
||||
final message =
|
||||
await widget.onSave(ratios, _normalizeDefaultIndex(0, ratios.length));
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isSaving = false;
|
||||
if (message == null) {
|
||||
_committed = ratios;
|
||||
_draft = List<double>.from(ratios);
|
||||
_committedDefaultGearIndex = _normalizeDefaultIndex(0, ratios.length);
|
||||
_draftDefaultGearIndex = _committedDefaultGearIndex;
|
||||
_isEditing = false;
|
||||
_isExpanded = true;
|
||||
}
|
||||
});
|
||||
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
if (message != null) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(message)));
|
||||
return;
|
||||
}
|
||||
|
||||
final notices = <String>[
|
||||
if (calculation.discardedBelowRange > 0)
|
||||
'${calculation.discardedBelowRange} below range skipped',
|
||||
if (calculation.discardedAboveRange > 0)
|
||||
'${calculation.discardedAboveRange} above range skipped',
|
||||
if (calculation.duplicateCount > 0)
|
||||
'${calculation.duplicateCount} duplicates removed',
|
||||
if (calculation.truncatedCount > 0)
|
||||
'${calculation.truncatedCount} high gears truncated',
|
||||
];
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
notices.isEmpty
|
||||
? 'Applied ${ratios.length} calculated gear ratios.'
|
||||
: 'Applied ${ratios.length} calculated gear ratios (${notices.join(', ')}).',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _applyNamedPreset(String name) {
|
||||
for (final preset in widget.presets) {
|
||||
if (preset.name.toLowerCase() == name.toLowerCase()) {
|
||||
@ -995,8 +1065,7 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
}
|
||||
|
||||
double _quantizeRatio(double raw) {
|
||||
final clamped = raw.clamp(_sliderMin, _sliderMax);
|
||||
return ((clamped * 64).round() / 64.0).clamp(_sliderMin, _sliderMax);
|
||||
return quantizeGearRatio(raw).clamp(_sliderMin, _sliderMax);
|
||||
}
|
||||
|
||||
(List<double>, int) _sortedWithDefault(
|
||||
|
||||
Reference in New Issue
Block a user