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.isLoading, required this.onSave, required this.presets, this.errorText, this.onRetry, super.key, }); final List ratios; final bool isLoading; final Future Function(List ratios) 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); 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 []; @override void initState() { super.initState(); _committed = List.from(widget.ratios); _draft = List.from(widget.ratios); } @override void didUpdateWidget(covariant GearRatioEditorCard oldWidget) { super.didUpdateWidget(oldWidget); if (!_isEditing && !_listEquals(oldWidget.ratios, widget.ratios)) { _committed = List.from(widget.ratios); _draft = List.from(widget.ratios); } } @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), ), 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]), ], ), ) : 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: 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), ), ], ), ), ), ], ], ], ), ), ); } 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, ), ); } 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), ), ), 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); } }); }, ), ], ), ), ); } 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); if (_sortAscending) { _sortDraft(animate: true); } }); } void _sortDraft({bool animate = false}) { final sorted = _sorted(_draft); if (animate && !_listEquals(_draft, sorted)) { _gearLayoutVersion++; } _draft = sorted; } void _enterEditMode() { setState(() { _isEditing = true; _isExpanded = true; _stretchFactor = 1.0; _stretchBase = null; _draft = List.from(_committed); }); } void _onCancel() { setState(() { _isEditing = false; _draft = List.from(_committed); _stretchFactor = 1.0; _stretchBase = null; }); } Future _onSave() async { setState(() { _isSaving = true; }); final message = await widget.onSave(List.from(_draft)); if (!mounted) { return; } setState(() { _isSaving = false; if (message == null) { _committed = List.from(_draft); _isEditing = false; } }); if (message != null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(message)), ); } } Widget _ratioChip(BuildContext context, int gear, double ratio) { 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: theme.colorScheme.outlineVariant.withValues(alpha: 0.6), ), ), child: Text('G$gear ${ratio.toStringAsFixed(2)}'), ); } Widget _compactRatioStrip( BuildContext context, List ratios, { bool showGearLabel = true, }) { 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: theme.colorScheme.outlineVariant .withValues(alpha: 0.55), ), ), child: Text( showGearLabel ? '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 _sorted(List values) { final out = List.from(values)..sort(); return out; } 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 _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; } }