diff --git a/lib/model/shifter_types.dart b/lib/model/shifter_types.dart index 9d34111..cb57b6b 100644 --- a/lib/model/shifter_types.dart +++ b/lib/model/shifter_types.dart @@ -112,6 +112,7 @@ enum TrainerConnectionState { idle, connecting, pairing, + connected, discoveringFtms, ftmsReady, error, @@ -131,6 +132,8 @@ class TrainerStatus { return 'Connecting'; case TrainerConnectionState.pairing: return 'Pairing'; + case TrainerConnectionState.connected: + return 'Connected'; case TrainerConnectionState.discoveringFtms: return 'Discovering FTMS'; case TrainerConnectionState.ftmsReady: @@ -148,9 +151,11 @@ class TrainerStatus { case 2: return const TrainerStatus(state: TrainerConnectionState.pairing); case 3: + return const TrainerStatus(state: TrainerConnectionState.connected); + case 4: return const TrainerStatus( state: TrainerConnectionState.discoveringFtms); - case 4: + case 5: return const TrainerStatus(state: TrainerConnectionState.ftmsReady); default: return const TrainerStatus(state: TrainerConnectionState.idle); @@ -160,7 +165,7 @@ class TrainerStatus { if (raw is List && raw.isNotEmpty) { final variant = raw.first; final value = raw.length > 1 ? raw[1] : null; - if (variant is int && variant == 5) { + if (variant is int && (variant == 5 || variant == 6)) { return TrainerStatus( state: TrainerConnectionState.error, errorCode: value is int ? value : null, @@ -173,7 +178,7 @@ class TrainerStatus { if (entry != null) { final key = entry.key; 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'))) { return TrainerStatus( state: TrainerConnectionState.error, @@ -191,6 +196,9 @@ class TrainerStatus { if (normalized.contains('pairing')) { return const TrainerStatus(state: TrainerConnectionState.pairing); } + if (normalized.contains('connected')) { + return const TrainerStatus(state: TrainerConnectionState.connected); + } if (normalized.contains('discover')) { return const TrainerStatus( state: TrainerConnectionState.discoveringFtms); diff --git a/lib/pages/device_details_page.dart b/lib/pages/device_details_page.dart index f8dd297..4b5418f 100644 --- a/lib/pages/device_details_page.dart +++ b/lib/pages/device_details_page.dart @@ -62,6 +62,7 @@ class _DeviceDetailsPageState extends ConsumerState { bool _hasLoadedGearRatios = false; String? _gearRatiosError; List _gearRatios = const []; + int _defaultGearIndex = 0; @override void initState() { @@ -245,20 +246,28 @@ class _DeviceDetailsPageState extends ConsumerState { } setState(() { - _gearRatios = result.unwrap(); + final data = result.unwrap(); + _gearRatios = data.ratios; + _defaultGearIndex = data.defaultGearIndex; _isGearRatiosLoading = false; _hasLoadedGearRatios = true; _gearRatiosError = null; }); } - Future _saveGearRatios(List ratios) async { + Future _saveGearRatios( + List ratios, int defaultGearIndex) async { final shifter = _shifterService; if (shifter == null) { 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()) { return 'Could not save gear ratios: ${result.unwrapErr()}'; } @@ -269,6 +278,9 @@ class _DeviceDetailsPageState extends ConsumerState { setState(() { _gearRatios = List.from(ratios); + _defaultGearIndex = ratios.isEmpty + ? 0 + : defaultGearIndex.clamp(0, ratios.length - 1).toInt(); _hasLoadedGearRatios = true; _gearRatiosError = null; }); @@ -495,6 +507,7 @@ class _DeviceDetailsPageState extends ConsumerState { const SizedBox(height: 16), GearRatioEditorCard( ratios: _gearRatios, + defaultGearIndex: _defaultGearIndex, isLoading: _isGearRatiosLoading, errorText: _gearRatiosError, onRetry: _loadGearRatios, diff --git a/lib/service/shifter_service.dart b/lib/service/shifter_service.dart index 4de7d70..0892067 100644 --- a/lib/service/shifter_service.dart +++ b/lib/service/shifter_service.dart @@ -20,6 +20,8 @@ class ShifterService { Stream get statusStream => _statusController.stream; 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; @@ -56,7 +58,7 @@ class ShifterService { return writeCommand(UniversalShifterCommand.connectToDevice); } - Future>> readGearRatios() async { + Future> readGearRatios() async { final readRes = await _bluetooth.readCharacteristic( buttonDeviceId, universalShifterControlServiceUuid, @@ -67,25 +69,43 @@ class ShifterService { } final raw = readRes.unwrap(); - if (raw.length > _gearRatioSlots) { + if (raw.length > _gearRatioPayloadBytes) { 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.filled(_gearRatioSlots, 0, growable: false); + final normalizedRaw = List.filled( + _gearRatioPayloadBytes, + 0, + growable: false, + ); for (var i = 0; i < raw.length; i++) { normalizedRaw[i] = raw[i]; } - final ratios = normalizedRaw - .where((v) => v > 0) - .map((v) => _decodeGearRatio(v)) - .toList(growable: false); - return Ok(ratios); + final ratios = []; + for (var i = 0; i < _gearRatioSlots; i++) { + final value = normalizedRaw[i]; + if (value == 0) { + break; + } + ratios.add(_decodeGearRatio(value)); + } + + final defaultIndexRaw = normalizedRaw[_defaultGearIndexOffset]; + final defaultGearIndex = ratios.isEmpty + ? 0 + : defaultIndexRaw.clamp(0, ratios.length - 1).toInt(); + return Ok( + GearRatiosData( + ratios: ratios, + defaultGearIndex: defaultGearIndex, + ), + ); } - Future> writeGearRatios(List ratios) async { + Future> writeGearRatios(GearRatiosData data) async { final mtuResult = await _bluetooth.requestMtu(buttonDeviceId, mtu: _gearRatioWriteMtu); if (mtuResult.isErr()) { @@ -93,12 +113,16 @@ class ShifterService { 'Could not request MTU before writing gear ratios: ${mtuResult.unwrapErr()}'); } - final payload = List.filled(_gearRatioSlots, 0, growable: false); + final payload = + List.filled(_gearRatioPayloadBytes, 0, growable: false); + final ratios = data.ratios; final limit = ratios.length < _gearRatioSlots ? ratios.length : _gearRatioSlots; for (var i = 0; i < limit; i++) { payload[i] = _encodeGearRatio(ratios[i]); } + payload[_defaultGearIndexOffset] = + limit == 0 ? 0 : data.defaultGearIndex.clamp(0, limit - 1).toInt(); return _bluetooth.writeCharacteristic( buttonDeviceId, @@ -177,3 +201,13 @@ class ShifterService { return raw / 64.0; } } + +class GearRatiosData { + const GearRatiosData({ + required this.ratios, + required this.defaultGearIndex, + }); + + final List ratios; + final int defaultGearIndex; +} diff --git a/lib/widgets/gear_ratio_editor_card.dart b/lib/widgets/gear_ratio_editor_card.dart index 49ebc47..b77051e 100644 --- a/lib/widgets/gear_ratio_editor_card.dart +++ b/lib/widgets/gear_ratio_editor_card.dart @@ -17,6 +17,7 @@ class GearRatioPreset { class GearRatioEditorCard extends StatefulWidget { const GearRatioEditorCard({ required this.ratios, + required this.defaultGearIndex, required this.isLoading, required this.onSave, required this.presets, @@ -26,8 +27,10 @@ class GearRatioEditorCard extends StatefulWidget { }); final List ratios; + final int defaultGearIndex; final bool isLoading; - final Future Function(List ratios) onSave; + final Future Function(List ratios, int defaultGearIndex) + onSave; final List presets; final String? errorText; final VoidCallback? onRetry; @@ -43,6 +46,13 @@ class _GearRatioEditorCardState extends State { static const double _sliderPivotV = 1.00; static const Duration _animDuration = Duration(milliseconds: 280); 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 _isEditing = false; @@ -54,20 +64,34 @@ class _GearRatioEditorCardState extends State { List _committed = const []; List _draft = const []; + int _committedDefaultGearIndex = 0; + int _draftDefaultGearIndex = 0; @override void initState() { super.initState(); _committed = List.from(widget.ratios); _draft = List.from(widget.ratios); + _committedDefaultGearIndex = _normalizeDefaultIndex( + widget.defaultGearIndex, + _committed.length, + ); + _draftDefaultGearIndex = _committedDefaultGearIndex; } @override void didUpdateWidget(covariant GearRatioEditorCard oldWidget) { super.didUpdateWidget(oldWidget); - if (!_isEditing && !_listEquals(oldWidget.ratios, widget.ratios)) { + if (!_isEditing && + (!_listEquals(oldWidget.ratios, widget.ratios) || + oldWidget.defaultGearIndex != widget.defaultGearIndex)) { _committed = List.from(widget.ratios); _draft = List.from(widget.ratios); + _committedDefaultGearIndex = _normalizeDefaultIndex( + widget.defaultGearIndex, + _committed.length, + ); + _draftDefaultGearIndex = _committedDefaultGearIndex; } } @@ -183,7 +207,11 @@ class _GearRatioEditorCardState extends State { if (!_isExpanded && !_isEditing && _committed.isNotEmpty) Padding( padding: const EdgeInsets.fromLTRB(14, 0, 14, 10), - child: _compactRatioStrip(context, _committed), + child: _compactRatioStrip( + context, + _committed, + defaultGearIndex: _committedDefaultGearIndex, + ), ), AnimatedSize( duration: _animDuration, @@ -197,7 +225,12 @@ class _GearRatioEditorCardState extends State { runSpacing: 8, children: [ 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 { switchInCurve: _animCurve, switchOutCurve: Curves.easeInCubic, transitionBuilder: _snappyTransition, - child: Wrap( - key: ValueKey('editors-$_gearLayoutVersion'), - spacing: 8, - runSpacing: 8, - children: [ - for (var i = 0; i < _draft.length; i++) - KeyedSubtree( - key: ValueKey('editor-${i + 1}'), - child: _buildGearEditor(context, i), - ), - ], + child: Padding( + padding: EdgeInsets.only( + top: _editorTilesTopInset, + ), + child: Column( + key: ValueKey('editors-$_gearLayoutVersion'), + crossAxisAlignment: CrossAxisAlignment.stretch, + children: _buildEditableGearTiles(context), + ), ), ), ), @@ -314,73 +345,307 @@ class _GearRatioEditorCardState extends State { ); } - Widget _buildGearEditor(BuildContext context, int index) { - final ratio = _draft[index]; - final sliderValue = _valueToSlider(ratio); - return ConstrainedBox( - constraints: const BoxConstraints(minWidth: 230, maxWidth: 280), - child: Container( - padding: const EdgeInsets.fromLTRB(10, 8, 10, 4), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.7), - border: Border.all( - color: Theme.of(context) - .colorScheme - .outlineVariant - .withValues(alpha: 0.6), + List _buildEditableGearTiles(BuildContext context) { + if (_draft.isEmpty) { + return [_buildStandaloneAddGearButton(context)]; + } + + final widgets = []; + 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, ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - 'Gear ${index + 1}', - style: const TextStyle(fontWeight: FontWeight.w600), - ), - const Spacer(), - InkWell( - borderRadius: BorderRadius.circular(6), - onTap: () => _editRatioText(index), - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: Text( - ratio.toStringAsFixed(2), - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - ), - ), - ), - ), - ], - ), - Slider( - min: 0, - max: 1, - value: sliderValue, - onChanged: (value) { - setState(() { - _draft[index] = _quantizeRatio(_sliderToValue(value)); - }); - }, - onChangeEnd: (_) { - setState(() { - if (_sortAscending) { - _sortDraft(animate: true); - } - }); - }, - ), - ], + ); + } + } + 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) { + final theme = Theme.of(context); + final canAdd = _draft.length < _maxGears; + final ratio = _draft[index]; + final sliderValue = _valueToSlider(ratio); + final isDefault = index == _draftDefaultGearIndex; + 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( + padding: const EdgeInsets.fromLTRB(10, 8, 10, 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: borderColor, + width: isDefault ? 1.6 : 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Gear ${index + 1}', + 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(), + IconButton( + visualDensity: VisualDensity.compact, + tooltip: 'Delete gear', + onPressed: () => _deleteGear(index), + icon: const Icon(Icons.delete_outline), + ), + InkWell( + borderRadius: BorderRadius.circular(6), + onTap: () => _editRatioText(index), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: Text( + ratio.toStringAsFixed(2), + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + Slider( + min: 0, + max: 1, + value: sliderValue, + onChanged: (value) { + setState(() { + _draft[index] = + _quantizeRatio(_sliderToValue(value)); + }); + }, + onChangeEnd: (_) { + setState(() { + if (_sortAscending) { + _sortDraft(animate: true); + } + }); + }, + ), + ], + ), + ), + ), + ), + ), + ), + 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.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.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 _editRatioText(int index) async { final controller = TextEditingController( text: _draft[index].toStringAsFixed(2), @@ -493,6 +758,7 @@ class _GearRatioEditorCardState extends State { setState(() { _draft = selected.ratios.map(_quantizeRatio).toList(growable: false); + _draftDefaultGearIndex = _normalizeDefaultIndex(0, _draft.length); if (_sortAscending) { _sortDraft(animate: true); } @@ -500,11 +766,14 @@ class _GearRatioEditorCardState extends State { } void _sortDraft({bool animate = false}) { - final sorted = _sorted(_draft); - if (animate && !_listEquals(_draft, sorted)) { + final sorted = _sortedWithDefault(_draft, _draftDefaultGearIndex); + final sortedValues = sorted.$1; + final sortedDefaultIndex = sorted.$2; + if (animate && !_listEquals(_draft, sortedValues)) { _gearLayoutVersion++; } - _draft = sorted; + _draft = sortedValues; + _draftDefaultGearIndex = sortedDefaultIndex; } void _enterEditMode() { @@ -514,6 +783,10 @@ class _GearRatioEditorCardState extends State { _stretchFactor = 1.0; _stretchBase = null; _draft = List.from(_committed); + _draftDefaultGearIndex = _normalizeDefaultIndex( + _committedDefaultGearIndex, + _draft.length, + ); }); } @@ -521,6 +794,10 @@ class _GearRatioEditorCardState extends State { setState(() { _isEditing = false; _draft = List.from(_committed); + _draftDefaultGearIndex = _normalizeDefaultIndex( + _committedDefaultGearIndex, + _draft.length, + ); _stretchFactor = 1.0; _stretchBase = null; }); @@ -531,7 +808,10 @@ class _GearRatioEditorCardState extends State { _isSaving = true; }); - final message = await widget.onSave(List.from(_draft)); + final message = await widget.onSave( + List.from(_draft), + _normalizeDefaultIndex(_draftDefaultGearIndex, _draft.length), + ); if (!mounted) { return; } @@ -540,6 +820,10 @@ class _GearRatioEditorCardState extends State { _isSaving = false; if (message == null) { _committed = List.from(_draft); + _committedDefaultGearIndex = _normalizeDefaultIndex( + _draftDefaultGearIndex, + _committed.length, + ); _isEditing = false; } }); @@ -551,7 +835,12 @@ class _GearRatioEditorCardState extends State { } } - Widget _ratioChip(BuildContext context, int gear, double ratio) { + Widget _ratioChip( + BuildContext context, + int gear, + double ratio, { + bool isDefault = false, + }) { final theme = Theme.of(context); return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), @@ -559,10 +848,17 @@ class _GearRatioEditorCardState extends State { borderRadius: BorderRadius.circular(999), color: theme.colorScheme.surface.withValues(alpha: 0.7), 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 { BuildContext context, List ratios, { bool showGearLabel = true, + int? defaultGearIndex, }) { final theme = Theme.of(context); return SizedBox( @@ -588,13 +885,18 @@ class _GearRatioEditorCardState extends State { borderRadius: BorderRadius.circular(999), color: theme.colorScheme.surface.withValues(alpha: 0.7), border: Border.all( - color: theme.colorScheme.outlineVariant - .withValues(alpha: 0.55), + color: i == defaultGearIndex + ? theme.colorScheme.primary + : theme.colorScheme.outlineVariant + .withValues(alpha: 0.55), + width: i == defaultGearIndex ? 1.3 : 1, ), ), child: Text( 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), style: theme.textTheme.bodySmall?.copyWith( fontWeight: FontWeight.w600, @@ -613,9 +915,34 @@ class _GearRatioEditorCardState extends State { return ((clamped * 64).round() / 64.0).clamp(_sliderMin, _sliderMax); } - List _sorted(List values) { - final out = List.from(values)..sort(); - return out; + (List, int) _sortedWithDefault( + List values, int defaultIndex) { + 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 a, List b) { @@ -661,6 +988,69 @@ class _GearRatioEditorCardState extends State { } } +class _DraftGearEntry { + const _DraftGearEntry({required this.ratio, required this.isDefault}); + + final double ratio; + final bool isDefault; +} + +class _GearEditorNotchClipper extends CustomClipper { + 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 { const _GearRatioGraph({required this.ratios, required this.compact});