1283 lines
40 KiB
Dart
1283 lines
40 KiB
Dart
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<double> 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<double> ratios;
|
|
final int defaultGearIndex;
|
|
final bool isLoading;
|
|
final Future<String?> Function(List<double> ratios, int defaultGearIndex)
|
|
onSave;
|
|
final List<GearRatioPreset> presets;
|
|
final String? errorText;
|
|
final VoidCallback? onRetry;
|
|
|
|
@override
|
|
State<GearRatioEditorCard> createState() => _GearRatioEditorCardState();
|
|
}
|
|
|
|
class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
|
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<double>? _stretchBase;
|
|
int _gearLayoutVersion = 0;
|
|
|
|
List<double> _committed = const [];
|
|
List<double> _draft = const [];
|
|
int _committedDefaultGearIndex = 0;
|
|
int _draftDefaultGearIndex = 0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_committed = List<double>.from(widget.ratios);
|
|
_draft = List<double>.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<double>.from(widget.ratios);
|
|
_draft = List<double>.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<double>.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<double> animation) {
|
|
final curved = CurvedAnimation(parent: animation, curve: _animCurve);
|
|
return FadeTransition(
|
|
opacity: curved,
|
|
child: SizeTransition(
|
|
sizeFactor: curved,
|
|
axisAlignment: -1,
|
|
child: child,
|
|
),
|
|
);
|
|
}
|
|
|
|
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) {
|
|
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<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 {
|
|
final controller = TextEditingController(
|
|
text: _draft[index].toStringAsFixed(2),
|
|
);
|
|
|
|
final value = await showDialog<double>(
|
|
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<void> _openPresetPicker() async {
|
|
final selected = await showModalBottomSheet<GearRatioPreset>(
|
|
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<double>.from(_committed);
|
|
_draftDefaultGearIndex = _normalizeDefaultIndex(
|
|
_committedDefaultGearIndex,
|
|
_draft.length,
|
|
);
|
|
});
|
|
}
|
|
|
|
void _onCancel() {
|
|
setState(() {
|
|
_isEditing = false;
|
|
_draft = List<double>.from(_committed);
|
|
_draftDefaultGearIndex = _normalizeDefaultIndex(
|
|
_committedDefaultGearIndex,
|
|
_draft.length,
|
|
);
|
|
_stretchFactor = 1.0;
|
|
_stretchBase = null;
|
|
});
|
|
}
|
|
|
|
Future<void> _onSave() async {
|
|
setState(() {
|
|
_isSaving = true;
|
|
});
|
|
|
|
final message = await widget.onSave(
|
|
List<double>.from(_draft),
|
|
_normalizeDefaultIndex(_draftDefaultGearIndex, _draft.length),
|
|
);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_isSaving = false;
|
|
if (message == null) {
|
|
_committed = List<double>.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<double> 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<double>, int) _sortedWithDefault(
|
|
List<double> 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<double> a, List<double> 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<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 {
|
|
const _GearRatioGraph({required this.ratios, required this.compact});
|
|
|
|
final List<double> 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<double> 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;
|
|
}
|
|
}
|