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

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

View File

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

View File

@ -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',

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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(