Files
abawo-bt-app/lib/widgets/gear_ratio_editor_card.dart

893 lines
28 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.isLoading,
required this.onSave,
required this.presets,
this.errorText,
this.onRetry,
super.key,
});
final List<double> ratios;
final bool isLoading;
final Future<String?> Function(List<double> ratios) 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);
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 [];
@override
void initState() {
super.initState();
_committed = List<double>.from(widget.ratios);
_draft = List<double>.from(widget.ratios);
}
@override
void didUpdateWidget(covariant GearRatioEditorCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (!_isEditing && !_listEquals(oldWidget.ratios, widget.ratios)) {
_committed = List<double>.from(widget.ratios);
_draft = List<double>.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<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: 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<double> 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<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);
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<double>.from(_committed);
});
}
void _onCancel() {
setState(() {
_isEditing = false;
_draft = List<double>.from(_committed);
_stretchFactor = 1.0;
_stretchBase = null;
});
}
Future<void> _onSave() async {
setState(() {
_isSaving = true;
});
final message = await widget.onSave(List<double>.from(_draft));
if (!mounted) {
return;
}
setState(() {
_isSaving = false;
if (message == null) {
_committed = List<double>.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<double> 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<double> _sorted(List<double> values) {
final out = List<double>.from(values)..sort();
return out;
}
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 _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;
}
}