feat: new shifter types and better gear ratio editor

This commit is contained in:
2026-02-23 11:45:25 +01:00
parent dcb1e6596e
commit 575ccaae42
4 changed files with 551 additions and 106 deletions

View File

@ -112,6 +112,7 @@ enum TrainerConnectionState {
idle, idle,
connecting, connecting,
pairing, pairing,
connected,
discoveringFtms, discoveringFtms,
ftmsReady, ftmsReady,
error, error,
@ -131,6 +132,8 @@ class TrainerStatus {
return 'Connecting'; return 'Connecting';
case TrainerConnectionState.pairing: case TrainerConnectionState.pairing:
return 'Pairing'; return 'Pairing';
case TrainerConnectionState.connected:
return 'Connected';
case TrainerConnectionState.discoveringFtms: case TrainerConnectionState.discoveringFtms:
return 'Discovering FTMS'; return 'Discovering FTMS';
case TrainerConnectionState.ftmsReady: case TrainerConnectionState.ftmsReady:
@ -148,9 +151,11 @@ class TrainerStatus {
case 2: case 2:
return const TrainerStatus(state: TrainerConnectionState.pairing); return const TrainerStatus(state: TrainerConnectionState.pairing);
case 3: case 3:
return const TrainerStatus(state: TrainerConnectionState.connected);
case 4:
return const TrainerStatus( return const TrainerStatus(
state: TrainerConnectionState.discoveringFtms); state: TrainerConnectionState.discoveringFtms);
case 4: case 5:
return const TrainerStatus(state: TrainerConnectionState.ftmsReady); return const TrainerStatus(state: TrainerConnectionState.ftmsReady);
default: default:
return const TrainerStatus(state: TrainerConnectionState.idle); return const TrainerStatus(state: TrainerConnectionState.idle);
@ -160,7 +165,7 @@ class TrainerStatus {
if (raw is List && raw.isNotEmpty) { if (raw is List && raw.isNotEmpty) {
final variant = raw.first; final variant = raw.first;
final value = raw.length > 1 ? raw[1] : null; final value = raw.length > 1 ? raw[1] : null;
if (variant is int && variant == 5) { if (variant is int && (variant == 5 || variant == 6)) {
return TrainerStatus( return TrainerStatus(
state: TrainerConnectionState.error, state: TrainerConnectionState.error,
errorCode: value is int ? value : null, errorCode: value is int ? value : null,
@ -173,7 +178,7 @@ class TrainerStatus {
if (entry != null) { if (entry != null) {
final key = entry.key; final key = entry.key;
final value = entry.value; final value = entry.value;
if ((key is int && key == 5) || if ((key is int && (key == 5 || key == 6)) ||
(key is String && key.toLowerCase().contains('error'))) { (key is String && key.toLowerCase().contains('error'))) {
return TrainerStatus( return TrainerStatus(
state: TrainerConnectionState.error, state: TrainerConnectionState.error,
@ -191,6 +196,9 @@ class TrainerStatus {
if (normalized.contains('pairing')) { if (normalized.contains('pairing')) {
return const TrainerStatus(state: TrainerConnectionState.pairing); return const TrainerStatus(state: TrainerConnectionState.pairing);
} }
if (normalized.contains('connected')) {
return const TrainerStatus(state: TrainerConnectionState.connected);
}
if (normalized.contains('discover')) { if (normalized.contains('discover')) {
return const TrainerStatus( return const TrainerStatus(
state: TrainerConnectionState.discoveringFtms); state: TrainerConnectionState.discoveringFtms);

View File

@ -62,6 +62,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
bool _hasLoadedGearRatios = false; bool _hasLoadedGearRatios = false;
String? _gearRatiosError; String? _gearRatiosError;
List<double> _gearRatios = const []; List<double> _gearRatios = const [];
int _defaultGearIndex = 0;
@override @override
void initState() { void initState() {
@ -245,20 +246,28 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
} }
setState(() { setState(() {
_gearRatios = result.unwrap(); final data = result.unwrap();
_gearRatios = data.ratios;
_defaultGearIndex = data.defaultGearIndex;
_isGearRatiosLoading = false; _isGearRatiosLoading = false;
_hasLoadedGearRatios = true; _hasLoadedGearRatios = true;
_gearRatiosError = null; _gearRatiosError = null;
}); });
} }
Future<String?> _saveGearRatios(List<double> ratios) async { Future<String?> _saveGearRatios(
List<double> ratios, int defaultGearIndex) async {
final shifter = _shifterService; final shifter = _shifterService;
if (shifter == null) { if (shifter == null) {
return 'Status channel is not ready yet.'; return 'Status channel is not ready yet.';
} }
final result = await shifter.writeGearRatios(ratios); final result = await shifter.writeGearRatios(
GearRatiosData(
ratios: ratios,
defaultGearIndex: defaultGearIndex,
),
);
if (result.isErr()) { if (result.isErr()) {
return 'Could not save gear ratios: ${result.unwrapErr()}'; return 'Could not save gear ratios: ${result.unwrapErr()}';
} }
@ -269,6 +278,9 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
setState(() { setState(() {
_gearRatios = List<double>.from(ratios); _gearRatios = List<double>.from(ratios);
_defaultGearIndex = ratios.isEmpty
? 0
: defaultGearIndex.clamp(0, ratios.length - 1).toInt();
_hasLoadedGearRatios = true; _hasLoadedGearRatios = true;
_gearRatiosError = null; _gearRatiosError = null;
}); });
@ -495,6 +507,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
const SizedBox(height: 16), const SizedBox(height: 16),
GearRatioEditorCard( GearRatioEditorCard(
ratios: _gearRatios, ratios: _gearRatios,
defaultGearIndex: _defaultGearIndex,
isLoading: _isGearRatiosLoading, isLoading: _isGearRatiosLoading,
errorText: _gearRatiosError, errorText: _gearRatiosError,
onRetry: _loadGearRatios, onRetry: _loadGearRatios,

View File

@ -20,6 +20,8 @@ class ShifterService {
Stream<CentralStatus> get statusStream => _statusController.stream; Stream<CentralStatus> get statusStream => _statusController.stream;
static const int _gearRatioSlots = 32; static const int _gearRatioSlots = 32;
static const int _defaultGearIndexOffset = _gearRatioSlots;
static const int _gearRatioPayloadBytes = _gearRatioSlots + 1;
static const double _maxGearRatio = 255 / 64; static const double _maxGearRatio = 255 / 64;
static const int _gearRatioWriteMtu = 64; static const int _gearRatioWriteMtu = 64;
@ -56,7 +58,7 @@ class ShifterService {
return writeCommand(UniversalShifterCommand.connectToDevice); return writeCommand(UniversalShifterCommand.connectToDevice);
} }
Future<Result<List<double>>> readGearRatios() async { Future<Result<GearRatiosData>> readGearRatios() async {
final readRes = await _bluetooth.readCharacteristic( final readRes = await _bluetooth.readCharacteristic(
buttonDeviceId, buttonDeviceId,
universalShifterControlServiceUuid, universalShifterControlServiceUuid,
@ -67,25 +69,43 @@ class ShifterService {
} }
final raw = readRes.unwrap(); final raw = readRes.unwrap();
if (raw.length > _gearRatioSlots) { if (raw.length > _gearRatioPayloadBytes) {
return bail( return bail(
'Invalid gear ratio payload length: expected at most $_gearRatioSlots, got ${raw.length}', 'Invalid gear ratio payload length: expected at most $_gearRatioPayloadBytes, got ${raw.length}',
); );
} }
final normalizedRaw = List<int>.filled(_gearRatioSlots, 0, growable: false); final normalizedRaw = List<int>.filled(
_gearRatioPayloadBytes,
0,
growable: false,
);
for (var i = 0; i < raw.length; i++) { for (var i = 0; i < raw.length; i++) {
normalizedRaw[i] = raw[i]; normalizedRaw[i] = raw[i];
} }
final ratios = normalizedRaw final ratios = <double>[];
.where((v) => v > 0) for (var i = 0; i < _gearRatioSlots; i++) {
.map((v) => _decodeGearRatio(v)) final value = normalizedRaw[i];
.toList(growable: false); if (value == 0) {
return Ok(ratios); break;
}
ratios.add(_decodeGearRatio(value));
} }
Future<Result<void>> writeGearRatios(List<double> ratios) async { final defaultIndexRaw = normalizedRaw[_defaultGearIndexOffset];
final defaultGearIndex = ratios.isEmpty
? 0
: defaultIndexRaw.clamp(0, ratios.length - 1).toInt();
return Ok(
GearRatiosData(
ratios: ratios,
defaultGearIndex: defaultGearIndex,
),
);
}
Future<Result<void>> writeGearRatios(GearRatiosData data) async {
final mtuResult = final mtuResult =
await _bluetooth.requestMtu(buttonDeviceId, mtu: _gearRatioWriteMtu); await _bluetooth.requestMtu(buttonDeviceId, mtu: _gearRatioWriteMtu);
if (mtuResult.isErr()) { if (mtuResult.isErr()) {
@ -93,12 +113,16 @@ class ShifterService {
'Could not request MTU before writing gear ratios: ${mtuResult.unwrapErr()}'); 'Could not request MTU before writing gear ratios: ${mtuResult.unwrapErr()}');
} }
final payload = List<int>.filled(_gearRatioSlots, 0, growable: false); final payload =
List<int>.filled(_gearRatioPayloadBytes, 0, growable: false);
final ratios = data.ratios;
final limit = final limit =
ratios.length < _gearRatioSlots ? ratios.length : _gearRatioSlots; ratios.length < _gearRatioSlots ? ratios.length : _gearRatioSlots;
for (var i = 0; i < limit; i++) { for (var i = 0; i < limit; i++) {
payload[i] = _encodeGearRatio(ratios[i]); payload[i] = _encodeGearRatio(ratios[i]);
} }
payload[_defaultGearIndexOffset] =
limit == 0 ? 0 : data.defaultGearIndex.clamp(0, limit - 1).toInt();
return _bluetooth.writeCharacteristic( return _bluetooth.writeCharacteristic(
buttonDeviceId, buttonDeviceId,
@ -177,3 +201,13 @@ class ShifterService {
return raw / 64.0; return raw / 64.0;
} }
} }
class GearRatiosData {
const GearRatiosData({
required this.ratios,
required this.defaultGearIndex,
});
final List<double> ratios;
final int defaultGearIndex;
}

View File

@ -17,6 +17,7 @@ class GearRatioPreset {
class GearRatioEditorCard extends StatefulWidget { class GearRatioEditorCard extends StatefulWidget {
const GearRatioEditorCard({ const GearRatioEditorCard({
required this.ratios, required this.ratios,
required this.defaultGearIndex,
required this.isLoading, required this.isLoading,
required this.onSave, required this.onSave,
required this.presets, required this.presets,
@ -26,8 +27,10 @@ class GearRatioEditorCard extends StatefulWidget {
}); });
final List<double> ratios; final List<double> ratios;
final int defaultGearIndex;
final bool isLoading; final bool isLoading;
final Future<String?> Function(List<double> ratios) onSave; final Future<String?> Function(List<double> ratios, int defaultGearIndex)
onSave;
final List<GearRatioPreset> presets; final List<GearRatioPreset> presets;
final String? errorText; final String? errorText;
final VoidCallback? onRetry; final VoidCallback? onRetry;
@ -43,6 +46,13 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
static const double _sliderPivotV = 1.00; static const double _sliderPivotV = 1.00;
static const Duration _animDuration = Duration(milliseconds: 280); static const Duration _animDuration = Duration(milliseconds: 280);
static const Curve _animCurve = Cubic(0.2, 0.8, 0.2, 1.0); static const Curve _animCurve = Cubic(0.2, 0.8, 0.2, 1.0);
static const int _maxGears = 32;
static const double _inlineAddRadius = 24;
static const double _editorTileOverlap = 20;
double get _inlineAddEdgeOffset => _inlineAddRadius + 2;
double get _editorTilesTopInset => _inlineAddEdgeOffset + _inlineAddRadius;
bool _isExpanded = false; bool _isExpanded = false;
bool _isEditing = false; bool _isEditing = false;
@ -54,20 +64,34 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
List<double> _committed = const []; List<double> _committed = const [];
List<double> _draft = const []; List<double> _draft = const [];
int _committedDefaultGearIndex = 0;
int _draftDefaultGearIndex = 0;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_committed = List<double>.from(widget.ratios); _committed = List<double>.from(widget.ratios);
_draft = List<double>.from(widget.ratios); _draft = List<double>.from(widget.ratios);
_committedDefaultGearIndex = _normalizeDefaultIndex(
widget.defaultGearIndex,
_committed.length,
);
_draftDefaultGearIndex = _committedDefaultGearIndex;
} }
@override @override
void didUpdateWidget(covariant GearRatioEditorCard oldWidget) { void didUpdateWidget(covariant GearRatioEditorCard oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (!_isEditing && !_listEquals(oldWidget.ratios, widget.ratios)) { if (!_isEditing &&
(!_listEquals(oldWidget.ratios, widget.ratios) ||
oldWidget.defaultGearIndex != widget.defaultGearIndex)) {
_committed = List<double>.from(widget.ratios); _committed = List<double>.from(widget.ratios);
_draft = List<double>.from(widget.ratios); _draft = List<double>.from(widget.ratios);
_committedDefaultGearIndex = _normalizeDefaultIndex(
widget.defaultGearIndex,
_committed.length,
);
_draftDefaultGearIndex = _committedDefaultGearIndex;
} }
} }
@ -183,7 +207,11 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
if (!_isExpanded && !_isEditing && _committed.isNotEmpty) if (!_isExpanded && !_isEditing && _committed.isNotEmpty)
Padding( Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 10), padding: const EdgeInsets.fromLTRB(14, 0, 14, 10),
child: _compactRatioStrip(context, _committed), child: _compactRatioStrip(
context,
_committed,
defaultGearIndex: _committedDefaultGearIndex,
),
), ),
AnimatedSize( AnimatedSize(
duration: _animDuration, duration: _animDuration,
@ -197,7 +225,12 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
runSpacing: 8, runSpacing: 8,
children: [ children: [
for (var i = 0; i < _committed.length; i++) for (var i = 0; i < _committed.length; i++)
_ratioChip(context, i + 1, _committed[i]), _ratioChip(
context,
i + 1,
_committed[i],
isDefault: i == _committedDefaultGearIndex,
),
], ],
), ),
) )
@ -280,17 +313,15 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
switchInCurve: _animCurve, switchInCurve: _animCurve,
switchOutCurve: Curves.easeInCubic, switchOutCurve: Curves.easeInCubic,
transitionBuilder: _snappyTransition, transitionBuilder: _snappyTransition,
child: Wrap( child: Padding(
key: ValueKey('editors-$_gearLayoutVersion'), padding: EdgeInsets.only(
spacing: 8, top: _editorTilesTopInset,
runSpacing: 8, ),
children: [ child: Column(
for (var i = 0; i < _draft.length; i++) key: ValueKey('editors-$_gearLayoutVersion'),
KeyedSubtree( crossAxisAlignment: CrossAxisAlignment.stretch,
key: ValueKey('editor-${i + 1}'), children: _buildEditableGearTiles(context),
child: _buildGearEditor(context, i),
), ),
],
), ),
), ),
), ),
@ -314,21 +345,122 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
); );
} }
List<Widget> _buildEditableGearTiles(BuildContext context) {
if (_draft.isEmpty) {
return [_buildStandaloneAddGearButton(context)];
}
final widgets = <Widget>[];
for (var i = 0; i < _draft.length; i++) {
final editor = KeyedSubtree(
key: ValueKey('editor-${i + 1}'),
child: _buildGearEditor(context, i),
);
if (i == 0) {
widgets.add(editor);
} else {
widgets.add(
Transform.translate(
offset: Offset(0, -i * _editorTileOverlap),
child: editor,
),
);
}
}
return widgets;
}
Widget _buildStandaloneAddGearButton(BuildContext context) {
final canAdd = _draft.length < _maxGears;
return SizedBox(
width: double.infinity,
height: 118,
child: Center(
child: _buildInlineAddButton(
context,
afterIndex: -1,
canAdd: canAdd,
),
),
);
}
Widget _buildInlineAddButton(
BuildContext context, {
required int afterIndex,
required bool canAdd,
String? tooltip,
}) {
final theme = Theme.of(context);
return Tooltip(
message: canAdd ? 'Add gear' : 'Maximum $_maxGears gears reached',
child: Material(
type: MaterialType.transparency,
child: Ink(
width: _inlineAddRadius * 2,
height: _inlineAddRadius * 2,
decoration: ShapeDecoration(
shape: CircleBorder(
side: BorderSide(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.8),
),
),
color: theme.colorScheme.surface,
shadows: [
BoxShadow(
blurRadius: 12,
spreadRadius: 1,
color: Colors.black.withValues(alpha: 0.12),
),
],
),
child: IconButton(
onPressed: canAdd ? () => _insertGearAfter(afterIndex) : null,
icon: const Icon(Icons.add),
iconSize: 22,
splashRadius: _inlineAddRadius,
tooltip: canAdd ? tooltip ?? 'Add gear' : null,
),
),
),
);
}
Widget _buildGearEditor(BuildContext context, int index) { Widget _buildGearEditor(BuildContext context, int index) {
final theme = Theme.of(context);
final canAdd = _draft.length < _maxGears;
final ratio = _draft[index]; final ratio = _draft[index];
final sliderValue = _valueToSlider(ratio); final sliderValue = _valueToSlider(ratio);
return ConstrainedBox( final isDefault = index == _draftDefaultGearIndex;
constraints: const BoxConstraints(minWidth: 230, maxWidth: 280), final borderColor = isDefault
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant.withValues(alpha: 0.6);
return Stack(
children: [
Padding(
padding: EdgeInsets.only(
right: _inlineAddEdgeOffset,
top: index == 0 ? _inlineAddEdgeOffset : 0,
bottom: _inlineAddEdgeOffset,
),
child: ClipPath(
clipper: _GearEditorNotchClipper(
topNotchRadius: _inlineAddRadius + 2,
bottomNotchRadius: _inlineAddRadius + 2,
),
child: Material(
color: theme.colorScheme.surface.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(12),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _setDefaultGear(index),
child: Container( child: Container(
padding: const EdgeInsets.fromLTRB(10, 8, 10, 4), padding: const EdgeInsets.fromLTRB(10, 8, 10, 4),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.7),
border: Border.all( border: Border.all(
color: Theme.of(context) color: borderColor,
.colorScheme width: isDefault ? 1.6 : 1,
.outlineVariant
.withValues(alpha: 0.6),
), ),
), ),
child: Column( child: Column(
@ -340,13 +472,45 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
'Gear ${index + 1}', 'Gear ${index + 1}',
style: const TextStyle(fontWeight: FontWeight.w600), style: const TextStyle(fontWeight: FontWeight.w600),
), ),
const SizedBox(width: 8),
if (isDefault)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
color: theme.colorScheme.primaryContainer,
border: Border.all(
color: theme.colorScheme.primary.withValues(
alpha: 0.65,
),
),
),
child: Text(
'default',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w700,
),
),
),
const Spacer(), const Spacer(),
IconButton(
visualDensity: VisualDensity.compact,
tooltip: 'Delete gear',
onPressed: () => _deleteGear(index),
icon: const Icon(Icons.delete_outline),
),
InkWell( InkWell(
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
onTap: () => _editRatioText(index), onTap: () => _editRatioText(index),
child: Padding( child: Padding(
padding: padding: const EdgeInsets.symmetric(
const EdgeInsets.symmetric(horizontal: 8, vertical: 4), horizontal: 8,
vertical: 4,
),
child: Text( child: Text(
ratio.toStringAsFixed(2), ratio.toStringAsFixed(2),
style: const TextStyle( style: const TextStyle(
@ -364,7 +528,8 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
value: sliderValue, value: sliderValue,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
_draft[index] = _quantizeRatio(_sliderToValue(value)); _draft[index] =
_quantizeRatio(_sliderToValue(value));
}); });
}, },
onChangeEnd: (_) { onChangeEnd: (_) {
@ -378,9 +543,109 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
], ],
), ),
), ),
),
),
),
),
if (index == 0)
Positioned(
top: 0,
right: 0,
child: _buildInlineAddButton(
context,
afterIndex: index - 1,
canAdd: canAdd,
tooltip: 'Insert gear before',
),
),
Positioned(
bottom: 0,
right: 0,
child: _buildInlineAddButton(
context,
afterIndex: index,
canAdd: canAdd,
tooltip: 'Insert gear after',
),
),
],
); );
} }
void _insertGearAfter(int index) {
if (_draft.length >= _maxGears) {
return;
}
final insertIndex = (index + 1).clamp(0, _draft.length);
final newRatio = _nextInsertedRatio(insertIndex);
setState(() {
final next = List<double>.from(_draft)..insert(insertIndex, newRatio);
_draft = next;
if (insertIndex <= _draftDefaultGearIndex) {
_draftDefaultGearIndex += 1;
}
_draftDefaultGearIndex = _normalizeDefaultIndex(
_draftDefaultGearIndex,
_draft.length,
);
if (_sortAscending) {
_sortDraft(animate: true);
} else {
_gearLayoutVersion++;
}
_stretchBase = null;
});
}
void _deleteGear(int index) {
if (_draft.isEmpty || index < 0 || index >= _draft.length) {
return;
}
setState(() {
final next = List<double>.from(_draft)..removeAt(index);
_draft = next;
if (_draft.isEmpty) {
_draftDefaultGearIndex = 0;
} else if (index < _draftDefaultGearIndex) {
_draftDefaultGearIndex -= 1;
} else if (index == _draftDefaultGearIndex) {
_draftDefaultGearIndex = index.clamp(0, _draft.length - 1);
}
_draftDefaultGearIndex = _normalizeDefaultIndex(
_draftDefaultGearIndex,
_draft.length,
);
_gearLayoutVersion++;
_stretchBase = null;
});
}
void _setDefaultGear(int index) {
if (index < 0 || index >= _draft.length) {
return;
}
setState(() {
_draftDefaultGearIndex = index;
});
}
double _nextInsertedRatio(int insertIndex) {
if (_draft.isEmpty) {
return _quantizeRatio(1.0);
}
if (insertIndex <= 0) {
return _quantizeRatio(_draft.first * 0.9);
}
if (insertIndex >= _draft.length) {
return _quantizeRatio(_draft.last * 1.1);
}
return _quantizeRatio((_draft[insertIndex - 1] + _draft[insertIndex]) / 2);
}
Future<void> _editRatioText(int index) async { Future<void> _editRatioText(int index) async {
final controller = TextEditingController( final controller = TextEditingController(
text: _draft[index].toStringAsFixed(2), text: _draft[index].toStringAsFixed(2),
@ -493,6 +758,7 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
setState(() { setState(() {
_draft = selected.ratios.map(_quantizeRatio).toList(growable: false); _draft = selected.ratios.map(_quantizeRatio).toList(growable: false);
_draftDefaultGearIndex = _normalizeDefaultIndex(0, _draft.length);
if (_sortAscending) { if (_sortAscending) {
_sortDraft(animate: true); _sortDraft(animate: true);
} }
@ -500,11 +766,14 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
} }
void _sortDraft({bool animate = false}) { void _sortDraft({bool animate = false}) {
final sorted = _sorted(_draft); final sorted = _sortedWithDefault(_draft, _draftDefaultGearIndex);
if (animate && !_listEquals(_draft, sorted)) { final sortedValues = sorted.$1;
final sortedDefaultIndex = sorted.$2;
if (animate && !_listEquals(_draft, sortedValues)) {
_gearLayoutVersion++; _gearLayoutVersion++;
} }
_draft = sorted; _draft = sortedValues;
_draftDefaultGearIndex = sortedDefaultIndex;
} }
void _enterEditMode() { void _enterEditMode() {
@ -514,6 +783,10 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
_stretchFactor = 1.0; _stretchFactor = 1.0;
_stretchBase = null; _stretchBase = null;
_draft = List<double>.from(_committed); _draft = List<double>.from(_committed);
_draftDefaultGearIndex = _normalizeDefaultIndex(
_committedDefaultGearIndex,
_draft.length,
);
}); });
} }
@ -521,6 +794,10 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
setState(() { setState(() {
_isEditing = false; _isEditing = false;
_draft = List<double>.from(_committed); _draft = List<double>.from(_committed);
_draftDefaultGearIndex = _normalizeDefaultIndex(
_committedDefaultGearIndex,
_draft.length,
);
_stretchFactor = 1.0; _stretchFactor = 1.0;
_stretchBase = null; _stretchBase = null;
}); });
@ -531,7 +808,10 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
_isSaving = true; _isSaving = true;
}); });
final message = await widget.onSave(List<double>.from(_draft)); final message = await widget.onSave(
List<double>.from(_draft),
_normalizeDefaultIndex(_draftDefaultGearIndex, _draft.length),
);
if (!mounted) { if (!mounted) {
return; return;
} }
@ -540,6 +820,10 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
_isSaving = false; _isSaving = false;
if (message == null) { if (message == null) {
_committed = List<double>.from(_draft); _committed = List<double>.from(_draft);
_committedDefaultGearIndex = _normalizeDefaultIndex(
_draftDefaultGearIndex,
_committed.length,
);
_isEditing = false; _isEditing = false;
} }
}); });
@ -551,7 +835,12 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
} }
} }
Widget _ratioChip(BuildContext context, int gear, double ratio) { Widget _ratioChip(
BuildContext context,
int gear,
double ratio, {
bool isDefault = false,
}) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
@ -559,10 +848,17 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
color: theme.colorScheme.surface.withValues(alpha: 0.7), color: theme.colorScheme.surface.withValues(alpha: 0.7),
border: Border.all( border: Border.all(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.6), color: isDefault
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant.withValues(alpha: 0.6),
width: isDefault ? 1.4 : 1,
), ),
), ),
child: Text('G$gear ${ratio.toStringAsFixed(2)}'), child: Text(
isDefault
? 'G$gear ${ratio.toStringAsFixed(2)} default'
: 'G$gear ${ratio.toStringAsFixed(2)}',
),
); );
} }
@ -570,6 +866,7 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
BuildContext context, BuildContext context,
List<double> ratios, { List<double> ratios, {
bool showGearLabel = true, bool showGearLabel = true,
int? defaultGearIndex,
}) { }) {
final theme = Theme.of(context); final theme = Theme.of(context);
return SizedBox( return SizedBox(
@ -588,13 +885,18 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
color: theme.colorScheme.surface.withValues(alpha: 0.7), color: theme.colorScheme.surface.withValues(alpha: 0.7),
border: Border.all( border: Border.all(
color: theme.colorScheme.outlineVariant color: i == defaultGearIndex
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant
.withValues(alpha: 0.55), .withValues(alpha: 0.55),
width: i == defaultGearIndex ? 1.3 : 1,
), ),
), ),
child: Text( child: Text(
showGearLabel showGearLabel
? 'G${i + 1} ${ratios[i].toStringAsFixed(2)}' ? (i == defaultGearIndex
? 'G${i + 1} ${ratios[i].toStringAsFixed(2)} default'
: 'G${i + 1} ${ratios[i].toStringAsFixed(2)}')
: ratios[i].toStringAsFixed(2), : ratios[i].toStringAsFixed(2),
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -613,9 +915,34 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
return ((clamped * 64).round() / 64.0).clamp(_sliderMin, _sliderMax); return ((clamped * 64).round() / 64.0).clamp(_sliderMin, _sliderMax);
} }
List<double> _sorted(List<double> values) { (List<double>, int) _sortedWithDefault(
final out = List<double>.from(values)..sort(); List<double> values, int defaultIndex) {
return out; final normalizedDefault =
_normalizeDefaultIndex(defaultIndex, values.length);
final decorated = List<_DraftGearEntry>.generate(
values.length,
(i) => _DraftGearEntry(
ratio: values[i],
isDefault: i == normalizedDefault,
),
growable: false,
)..sort((a, b) => a.ratio.compareTo(b.ratio));
final sortedValues = decorated.map((entry) => entry.ratio).toList(
growable: false,
);
final sortedDefaultIndex = decorated.indexWhere((entry) => entry.isDefault);
return (
sortedValues,
_normalizeDefaultIndex(sortedDefaultIndex, sortedValues.length),
);
}
int _normalizeDefaultIndex(int index, int length) {
if (length <= 0) {
return 0;
}
return index.clamp(0, length - 1).toInt();
} }
bool _listEquals(List<double> a, List<double> b) { bool _listEquals(List<double> a, List<double> b) {
@ -661,6 +988,69 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
} }
} }
class _DraftGearEntry {
const _DraftGearEntry({required this.ratio, required this.isDefault});
final double ratio;
final bool isDefault;
}
class _GearEditorNotchClipper extends CustomClipper<Path> {
const _GearEditorNotchClipper({
required this.topNotchRadius,
required this.bottomNotchRadius,
});
final double topNotchRadius;
final double bottomNotchRadius;
@override
Path getClip(Size size) {
final base = Path()
..addRRect(
RRect.fromRectAndRadius(
Offset.zero & size,
const Radius.circular(12),
),
);
if (topNotchRadius <= 0 && bottomNotchRadius <= 0) {
return base;
}
var clipped = base;
if (topNotchRadius > 0) {
final topNotch = Path()
..addOval(
Rect.fromCircle(
center: Offset(size.width, 0),
radius: topNotchRadius,
),
);
clipped = Path.combine(PathOperation.difference, clipped, topNotch);
}
if (bottomNotchRadius > 0) {
final bottomNotch = Path()
..addOval(
Rect.fromCircle(
center: Offset(size.width, size.height),
radius: bottomNotchRadius,
),
);
clipped = Path.combine(PathOperation.difference, clipped, bottomNotch);
}
return clipped;
}
@override
bool shouldReclip(covariant _GearEditorNotchClipper oldClipper) {
return oldClipper.topNotchRadius != topNotchRadius ||
oldClipper.bottomNotchRadius != bottomNotchRadius;
}
}
class _GearRatioGraph extends StatelessWidget { class _GearRatioGraph extends StatelessWidget {
const _GearRatioGraph({required this.ratios, required this.compact}); const _GearRatioGraph({required this.ratios, required this.compact});