import 'dart:math' as math; import 'package:flutter/material.dart'; class GearRatioPreset { const GearRatioPreset({ required this.name, required this.description, required this.ratios, }); final String name; final String description; final List ratios; } class GearRatioEditorCard extends StatefulWidget { const GearRatioEditorCard({ required this.ratios, required this.defaultGearIndex, required this.isLoading, required this.onSave, required this.presets, this.errorText, this.onRetry, super.key, }); final List ratios; final int defaultGearIndex; final bool isLoading; final Future Function(List ratios, int defaultGearIndex) onSave; final List presets; final String? errorText; final VoidCallback? onRetry; @override State createState() => _GearRatioEditorCardState(); } class _GearRatioEditorCardState extends State { static const double _sliderMin = 0.10; static const double _sliderMax = 3.90; static const double _sliderPivotT = 0.50; 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; bool _sortAscending = true; bool _isSaving = false; double _stretchFactor = 1.0; List? _stretchBase; int _gearLayoutVersion = 0; 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) || oldWidget.defaultGearIndex != widget.defaultGearIndex)) { _committed = List.from(widget.ratios); _draft = List.from(widget.ratios); _committedDefaultGearIndex = _normalizeDefaultIndex( widget.defaultGearIndex, _committed.length, ); _draftDefaultGearIndex = _committedDefaultGearIndex; } } @override Widget build(BuildContext context) { final theme = Theme.of(context); return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.55), border: Border.all( color: theme.colorScheme.outlineVariant.withValues(alpha: 0.6), ), ), child: AnimatedSize( duration: _animDuration, curve: _animCurve, alignment: Alignment.topCenter, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.fromLTRB(14, 12, 10, 8), child: Row( children: [ const Expanded( child: Text( 'Gear Ratios', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700), ), ), if (_isEditing) ...[ TextButton( onPressed: _isSaving ? null : _onCancel, child: const Text('Cancel'), ), FilledButton( onPressed: _isSaving ? null : _onSave, child: _isSaving ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Text('Save'), ), ] else IconButton( tooltip: 'Edit ratios', onPressed: (widget.isLoading || widget.errorText != null) ? null : _enterEditMode, icon: const Icon(Icons.edit_outlined), ), ], ), ), if (widget.isLoading) const Padding( padding: EdgeInsets.fromLTRB(16, 20, 16, 24), child: Center(child: CircularProgressIndicator()), ) else if (widget.errorText != null) Padding( padding: const EdgeInsets.fromLTRB(16, 6, 16, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.errorText!, style: TextStyle(color: theme.colorScheme.error), ), if (widget.onRetry != null) TextButton.icon( onPressed: widget.onRetry, icon: const Icon(Icons.refresh), label: const Text('Retry'), ), ], ), ) else if (_committed.isEmpty && !_isEditing) const Padding( padding: EdgeInsets.fromLTRB(16, 8, 16, 18), child: Text('No gear ratios found on device.'), ) else ...[ if ((_isEditing ? _draft : _committed).isNotEmpty) InkWell( borderRadius: BorderRadius.circular(16), onTap: _isEditing ? null : () { setState(() { _isExpanded = !_isExpanded; }); }, child: Padding( padding: const EdgeInsets.fromLTRB(14, 2, 14, 8), child: AnimatedContainer( duration: _animDuration, curve: _animCurve, height: _isExpanded ? 210 : 130, child: _GearRatioGraph( ratios: _isEditing ? _draft : _committed, compact: !_isExpanded, ), ), ), ), if (!_isExpanded && !_isEditing && _committed.isNotEmpty) Padding( padding: const EdgeInsets.fromLTRB(14, 0, 14, 10), child: _compactRatioStrip( context, _committed, defaultGearIndex: _committedDefaultGearIndex, ), ), AnimatedSize( duration: _animDuration, curve: _animCurve, alignment: Alignment.topCenter, child: _isExpanded && !_isEditing ? Padding( padding: const EdgeInsets.fromLTRB(14, 0, 14, 12), child: Wrap( spacing: 8, runSpacing: 8, children: [ for (var i = 0; i < _committed.length; i++) _ratioChip( context, i + 1, _committed[i], isDefault: i == _committedDefaultGearIndex, ), ], ), ) : const SizedBox.shrink(), ), if (_isEditing) ...[ Padding( padding: const EdgeInsets.fromLTRB(14, 0, 14, 8), child: Row( children: [ const Text('Sort ascending'), Switch( value: _sortAscending, onChanged: (value) { setState(() { _sortAscending = value; if (_sortAscending) { _sortDraft(animate: true); } }); }, ), const Spacer(), OutlinedButton.icon( onPressed: _openPresetPicker, icon: const Icon(Icons.tune), label: const Text('Load preset'), ), ], ), ), if (_draft.isEmpty) const Padding( padding: EdgeInsets.fromLTRB(14, 0, 14, 10), child: Text('No ratios yet. Load a preset to start editing.'), ), Padding( padding: const EdgeInsets.fromLTRB(14, 0, 14, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Stretch all: ${_stretchFactor.toStringAsFixed(2)}x', style: theme.textTheme.bodyMedium, ), Slider( min: 0.0, max: 1.0, value: _stretchToSlider(_stretchFactor), onChangeStart: (_) { _stretchBase = List.from(_draft); }, onChanged: (value) { final factor = _sliderToStretch(value); final base = _stretchBase ?? _draft; setState(() { _stretchFactor = factor; _draft = base .map((ratio) => _quantizeRatio(ratio * factor)) .toList(growable: false); }); }, onChangeEnd: (_) { setState(() { if (_sortAscending) { _sortDraft(animate: true); } _stretchBase = null; }); }, ), ], ), ), Padding( padding: const EdgeInsets.fromLTRB(10, 0, 10, 12), child: AnimatedSwitcher( duration: _animDuration, switchInCurve: _animCurve, switchOutCurve: Curves.easeInCubic, transitionBuilder: _snappyTransition, child: Padding( padding: EdgeInsets.only( top: _editorTilesTopInset, ), child: Column( key: ValueKey('editors-$_gearLayoutVersion'), crossAxisAlignment: CrossAxisAlignment.stretch, children: _buildEditableGearTiles(context), ), ), ), ), ], ], ], ), ), ); } Widget _snappyTransition(Widget child, Animation animation) { final curved = CurvedAnimation(parent: animation, curve: _animCurve); return FadeTransition( opacity: curved, child: SizeTransition( sizeFactor: curved, axisAlignment: -1, child: child, ), ); } 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, ), ); } } 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), ); final value = await showDialog( context: context, builder: (context) { return AlertDialog( title: Text('Set gear ${index + 1} ratio'), content: TextField( controller: controller, keyboardType: const TextInputType.numberWithOptions(decimal: true), autofocus: true, decoration: const InputDecoration(hintText: 'e.g. 1.25'), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel'), ), FilledButton( onPressed: () { final parsed = double.tryParse(controller.text.trim()); Navigator.of(context).pop(parsed); }, child: const Text('Set'), ), ], ); }, ); if (!mounted || value == null) { return; } setState(() { _draft[index] = _quantizeRatio(value); if (_sortAscending) { _sortDraft(animate: true); } }); } Future _openPresetPicker() async { final selected = await showModalBottomSheet( context: context, showDragHandle: true, builder: (context) { return Padding( padding: const EdgeInsets.fromLTRB(12, 6, 12, 12), child: ListView.separated( itemCount: widget.presets.length, separatorBuilder: (_, __) => const SizedBox(height: 8), itemBuilder: (context, index) { final preset = widget.presets[index]; return Material( color: Theme.of(context) .colorScheme .surfaceContainerHighest .withValues(alpha: 0.45), borderRadius: BorderRadius.circular(14), child: InkWell( borderRadius: BorderRadius.circular(14), onTap: () => Navigator.of(context).pop(preset), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( preset.name, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w700, ), ), const SizedBox(height: 4), Text( preset.description, style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 10), SizedBox( height: 90, child: _GearRatioGraph( ratios: preset.ratios, compact: true), ), const SizedBox(height: 8), _compactRatioStrip( context, preset.ratios, showGearLabel: false, ), ], ), ), ), ); }, ), ); }, ); if (!mounted || selected == null) { return; } setState(() { _draft = selected.ratios.map(_quantizeRatio).toList(growable: false); _draftDefaultGearIndex = _normalizeDefaultIndex(0, _draft.length); if (_sortAscending) { _sortDraft(animate: true); } }); } void _sortDraft({bool animate = false}) { final sorted = _sortedWithDefault(_draft, _draftDefaultGearIndex); final sortedValues = sorted.$1; final sortedDefaultIndex = sorted.$2; if (animate && !_listEquals(_draft, sortedValues)) { _gearLayoutVersion++; } _draft = sortedValues; _draftDefaultGearIndex = sortedDefaultIndex; } void _enterEditMode() { setState(() { _isEditing = true; _isExpanded = true; _stretchFactor = 1.0; _stretchBase = null; _draft = List.from(_committed); _draftDefaultGearIndex = _normalizeDefaultIndex( _committedDefaultGearIndex, _draft.length, ); }); } void _onCancel() { setState(() { _isEditing = false; _draft = List.from(_committed); _draftDefaultGearIndex = _normalizeDefaultIndex( _committedDefaultGearIndex, _draft.length, ); _stretchFactor = 1.0; _stretchBase = null; }); } Future _onSave() async { setState(() { _isSaving = true; }); final message = await widget.onSave( List.from(_draft), _normalizeDefaultIndex(_draftDefaultGearIndex, _draft.length), ); if (!mounted) { return; } setState(() { _isSaving = false; if (message == null) { _committed = List.from(_draft); _committedDefaultGearIndex = _normalizeDefaultIndex( _draftDefaultGearIndex, _committed.length, ); _isEditing = false; } }); if (message != null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(message)), ); } } 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), decoration: BoxDecoration( borderRadius: BorderRadius.circular(999), color: theme.colorScheme.surface.withValues(alpha: 0.7), border: Border.all( color: isDefault ? theme.colorScheme.primary : theme.colorScheme.outlineVariant.withValues(alpha: 0.6), width: isDefault ? 1.4 : 1, ), ), child: Text( isDefault ? 'G$gear ${ratio.toStringAsFixed(2)} default' : 'G$gear ${ratio.toStringAsFixed(2)}', ), ); } Widget _compactRatioStrip( BuildContext context, List ratios, { bool showGearLabel = true, int? defaultGearIndex, }) { final theme = Theme.of(context); return SizedBox( height: 26, child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: [ for (var i = 0; i < ratios.length; i++) Padding( padding: EdgeInsets.only(right: i == ratios.length - 1 ? 0 : 6), child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( borderRadius: BorderRadius.circular(999), color: theme.colorScheme.surface.withValues(alpha: 0.7), border: Border.all( color: i == defaultGearIndex ? theme.colorScheme.primary : theme.colorScheme.outlineVariant .withValues(alpha: 0.55), width: i == defaultGearIndex ? 1.3 : 1, ), ), child: Text( showGearLabel ? (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, ), ), ), ), ], ), ), ); } double _quantizeRatio(double raw) { final clamped = raw.clamp(_sliderMin, _sliderMax); return ((clamped * 64).round() / 64.0).clamp(_sliderMin, _sliderMax); } (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) { if (a.length != b.length) { return false; } for (var i = 0; i < a.length; i++) { if (a[i] != b[i]) { return false; } } return true; } double _sliderToValue(double t) { final normalized = t.clamp(0.0, 1.0); if (normalized <= _sliderPivotT) { final u = normalized / _sliderPivotT; return _sliderMin * math.pow(_sliderPivotV / _sliderMin, u); } final u = (normalized - _sliderPivotT) / (1 - _sliderPivotT); return _sliderPivotV * math.pow(_sliderMax / _sliderPivotV, u); } double _valueToSlider(double value) { final clamped = value.clamp(_sliderMin, _sliderMax); if (clamped <= _sliderPivotV) { final u = math.log(clamped / _sliderMin) / math.log(_sliderPivotV / _sliderMin); return (u * _sliderPivotT).clamp(0.0, 1.0); } final u = math.log(clamped / _sliderPivotV) / math.log(_sliderMax / _sliderPivotV); return (_sliderPivotT + u * (1 - _sliderPivotT)).clamp(0.0, 1.0); } double _sliderToStretch(double t) { return (0.6 + (1.6 - 0.6) * t).clamp(0.6, 1.6); } double _stretchToSlider(double factor) { return ((factor - 0.6) / (1.6 - 0.6)).clamp(0.0, 1.0); } } 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}); final List ratios; final bool compact; @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; return DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(14), color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.65), ), child: Padding( padding: const EdgeInsets.fromLTRB(10, 10, 10, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (!compact) Text( 'Input RPM -> Output RPM', style: textTheme.bodySmall, ), Expanded( child: CustomPaint( painter: _GearRatioGraphPainter( ratios: ratios, axisColor: Theme.of(context).colorScheme.outline, lineColor: Theme.of(context).colorScheme.primary, textColor: Theme.of(context).colorScheme.onSurface, compact: compact, ), ), ), ], ), ), ); } } class _GearRatioGraphPainter extends CustomPainter { const _GearRatioGraphPainter({ required this.ratios, required this.axisColor, required this.lineColor, required this.textColor, required this.compact, }); final List ratios; final Color axisColor; final Color lineColor; final Color textColor; final bool compact; @override void paint(Canvas canvas, Size size) { if (ratios.isEmpty) { return; } const left = 28.0; const right = 10.0; const top = 8.0; const bottom = 24.0; final chart = Rect.fromLTWH( left, top, size.width - left - right, size.height - top - bottom, ); final axisPaint = Paint() ..color = axisColor.withValues(alpha: 0.55) ..strokeWidth = 1.2; canvas.drawRect(chart, axisPaint..style = PaintingStyle.stroke); final maxRatio = ratios.reduce(math.max); final xMax = 120.0; final computedYMax = (xMax * math.max(maxRatio, 1.0)).toDouble(); final yMax = math.min(400.0, computedYMax); for (var i = 1; i <= 3; i++) { final y = chart.bottom - (chart.height * (i / 4)); canvas.drawLine( Offset(chart.left, y), Offset(chart.right, y), axisPaint..color = axisColor.withValues(alpha: 0.2), ); } for (var i = 1; i <= 3; i++) { final x = chart.left + (chart.width * (i / 4)); canvas.drawLine( Offset(x, chart.top), Offset(x, chart.bottom), axisPaint..color = axisColor.withValues(alpha: 0.15), ); } for (var i = 0; i < ratios.length; i++) { final ratio = ratios[i]; final p = i / math.max(1, ratios.length - 1); final color = Color.lerp( lineColor.withValues(alpha: 0.40), lineColor, p, )!; final endYValue = xMax * ratio; final isClipped = endYValue > yMax; final endX = isClipped ? chart.left + chart.width * (yMax / endYValue) : chart.right; final endY = isClipped ? chart.top : chart.bottom - (endYValue / yMax) * chart.height; final linePaint = Paint() ..color = color ..strokeWidth = 2; canvas.drawLine( Offset(chart.left, chart.bottom), Offset(endX, endY), linePaint, ); if (!compact && i % 2 == 0) { final tp = TextPainter( text: TextSpan( text: 'G${i + 1}', style: TextStyle( fontSize: 10, color: textColor.withValues(alpha: 0.75)), ), textDirection: TextDirection.ltr, )..layout(); tp.paint( canvas, Offset( (endX - tp.width - 2).clamp(chart.left, chart.right - tp.width), (endY - 10).clamp(chart.top, chart.bottom - tp.height), ), ); } } final xLabel = TextPainter( text: TextSpan( text: 'In RPM', style: TextStyle(fontSize: 10, color: textColor.withValues(alpha: 0.75)), ), textDirection: TextDirection.ltr, )..layout(); xLabel.paint(canvas, Offset(chart.right - xLabel.width, chart.bottom + 17)); final xMinValue = TextPainter( text: TextSpan( text: '0', style: TextStyle(fontSize: 9, color: textColor.withValues(alpha: 0.65)), ), textDirection: TextDirection.ltr, )..layout(); xMinValue.paint(canvas, Offset(chart.left, chart.bottom + 5)); final xMaxValue = TextPainter( text: TextSpan( text: xMax.toStringAsFixed(0), style: TextStyle(fontSize: 9, color: textColor.withValues(alpha: 0.65)), ), textDirection: TextDirection.ltr, )..layout(); xMaxValue.paint( canvas, Offset(chart.right - xMaxValue.width, chart.bottom + 5), ); final yLabel = TextPainter( text: TextSpan( text: 'Out RPM', style: TextStyle(fontSize: 10, color: textColor.withValues(alpha: 0.75)), ), textDirection: TextDirection.ltr, )..layout(); yLabel.paint(canvas, Offset(chart.left - 28, chart.top - 14)); final yMinValue = TextPainter( text: TextSpan( text: '0', style: TextStyle(fontSize: 9, color: textColor.withValues(alpha: 0.65)), ), textDirection: TextDirection.ltr, )..layout(); yMinValue.paint( canvas, Offset(chart.left - yMinValue.width - 4, chart.bottom - yMinValue.height), ); final yMaxValue = TextPainter( text: TextSpan( text: yMax.toStringAsFixed(0), style: TextStyle(fontSize: 9, color: textColor.withValues(alpha: 0.65)), ), textDirection: TextDirection.ltr, )..layout(); yMaxValue.paint( canvas, Offset(chart.left - yMaxValue.width - 4, chart.top), ); } @override bool shouldRepaint(covariant _GearRatioGraphPainter oldDelegate) { if (oldDelegate.compact != compact || oldDelegate.ratios.length != ratios.length) { return true; } for (var i = 0; i < ratios.length; i++) { if (ratios[i] != oldDelegate.ratios[i]) { return true; } } return false; } }