feat: ui rework and gear generator

This commit is contained in:
2026-04-28 17:13:30 +02:00
parent 82ea8125e1
commit 57a14134a6
300 changed files with 2901 additions and 135 deletions

View 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,
);
}

View 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));
}

View File

@ -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) {