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

1005 lines
31 KiB
Dart

import 'dart:math' as math;
import 'package:abawo_bt_app/model/gear_configurator.dart';
import 'package:abawo_bt_app/model/gear_ratio_codec.dart';
import 'package:flutter/material.dart';
const _roadPreset = _DrivetrainPreset(
label: 'Road default',
chainrings: [34, 50],
sprockets: [11, 12, 13, 14, 15, 16, 17, 19, 21, 24, 27, 30],
);
const _gravelPreset = _DrivetrainPreset(
label: 'Gravel default',
chainrings: [40],
sprockets: [10, 11, 13, 15, 17, 19, 21, 24, 28, 32, 38, 44],
);
const _mtbPreset = _DrivetrainPreset(
label: 'MTB default',
chainrings: [32],
sprockets: [10, 12, 14, 16, 18, 21, 24, 28, 32, 38, 45, 51],
);
const List<_DrivetrainPreset> _drivetrainPresets = [
_roadPreset,
_gravelPreset,
_mtbPreset,
];
class _DrivetrainPreset {
const _DrivetrainPreset({
required this.label,
required this.chainrings,
required this.sprockets,
});
final String label;
final List<int> chainrings;
final List<int> sprockets;
}
Future<GearConfiguratorCalculation?> showGearConfiguratorDialog(
BuildContext context,
) {
return showDialog<GearConfiguratorCalculation>(
context: context,
builder: (context) => const _GearConfiguratorDialog(),
);
}
class _GearConfiguratorDialog extends StatefulWidget {
const _GearConfiguratorDialog();
@override
State<_GearConfiguratorDialog> createState() =>
_GearConfiguratorDialogState();
}
class _GearConfiguratorDialogState extends State<_GearConfiguratorDialog> {
static const int _minTeeth = 5;
static const int _maxTeeth = 60;
List<int> _chainrings = List<int>.from(_roadPreset.chainrings);
List<int> _sprockets = List<int>.from(_roadPreset.sprockets);
GearRetentionMode _mode = GearRetentionMode.keepHighest;
GearConfiguratorCalculation get _calculation => calculateGearRatios(
chainrings: _chainrings,
sprockets: _sprockets,
mode: _mode,
);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final calculation = _calculation;
return Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 820, maxHeight: 780),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 12, 8),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Gear Configurator',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
'${calculation.ratios.length} ratios, ${gearRatioMin.toStringAsFixed(2)}-${gearRatioMax.toStringAsFixed(2)} supported range',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(
alpha: 0.64,
),
),
),
],
),
),
IconButton(
tooltip: 'Close',
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
),
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_GearPreview(
chainrings: _chainrings,
sprockets: _sprockets,
),
const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final preset in _drivetrainPresets)
_PresetChip(
label: preset.label,
onPressed: () => _loadDrivetrainPreset(preset),
),
],
),
const SizedBox(height: 14),
LayoutBuilder(
builder: (context, constraints) {
final editors = [
_ToothListEditor(
title: 'Chainrings',
subtitle: 'Up to 3 rings',
values: _chainrings,
maxItems: 3,
minTeeth: _minTeeth,
maxTeeth: _maxTeeth,
onAdd: () => _addChainring(),
onChanged: (index, value) =>
_changeChainring(index, value),
onRemove: (index) => _removeChainring(index),
),
_ToothListEditor(
title: 'Sprockets',
subtitle: 'Up to 12 sprockets',
values: _sprockets,
maxItems: 12,
minTeeth: _minTeeth,
maxTeeth: _maxTeeth,
onAdd: () => _addSprocket(),
onChanged: (index, value) =>
_changeSprocket(index, value),
onRemove: (index) => _removeSprocket(index),
),
];
if (constraints.maxWidth >= 620) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: editors[0]),
const SizedBox(width: 12),
Expanded(child: editors[1]),
],
);
}
return Column(
children: [
editors[0],
const SizedBox(height: 12),
editors[1],
],
);
},
),
const SizedBox(height: 12),
_CalculationSummary(calculation: calculation),
const SizedBox(height: 12),
_ModeSelector(
mode: _mode,
onChanged: (mode) {
setState(() {
_mode = mode;
});
},
),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
const SizedBox(width: 8),
FilledButton.icon(
style: FilledButton.styleFrom(
minimumSize: const Size(0, 52),
),
onPressed: calculation.ratios.isEmpty
? null
: () => Navigator.of(context).pop(calculation),
icon: const Icon(Icons.calculate_outlined),
label: const Text('Calculate and Apply'),
),
],
),
),
],
),
),
);
}
void _loadDrivetrainPreset(_DrivetrainPreset preset) {
setState(() {
_chainrings = List<int>.from(preset.chainrings);
_sprockets = List<int>.from(preset.sprockets);
});
}
void _addChainring() {
if (_chainrings.length >= 3) {
return;
}
setState(() {
final next = (_chainrings.isEmpty ? 40 : _chainrings.last + 12)
.clamp(_minTeeth, _maxTeeth);
_chainrings = [..._chainrings, next];
});
}
void _addSprocket() {
if (_sprockets.length >= 12) {
return;
}
setState(() {
final next = (_sprockets.isEmpty ? 16 : _sprockets.last + 2)
.clamp(_minTeeth, _maxTeeth);
_sprockets = [..._sprockets, next];
});
}
void _changeChainring(int index, int value) {
setState(() {
_chainrings = List<int>.from(_chainrings)
..[index] = value.clamp(_minTeeth, _maxTeeth);
});
}
void _changeSprocket(int index, int value) {
setState(() {
_sprockets = List<int>.from(_sprockets)
..[index] = value.clamp(_minTeeth, _maxTeeth);
});
}
void _removeChainring(int index) {
if (_chainrings.length <= 1) {
return;
}
setState(() {
_chainrings = List<int>.from(_chainrings)..removeAt(index);
});
}
void _removeSprocket(int index) {
if (_sprockets.length <= 1) {
return;
}
setState(() {
_sprockets = List<int>.from(_sprockets)..removeAt(index);
});
}
}
class _PresetChip extends StatelessWidget {
const _PresetChip({required this.label, required this.onPressed});
final String label;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return OutlinedButton(
onPressed: onPressed,
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)),
),
child: Text(label),
);
}
}
class _GearPreview extends StatelessWidget {
const _GearPreview({required this.chainrings, required this.sprockets});
final List<int> chainrings;
final List<int> sprockets;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AspectRatio(
aspectRatio: 2.25,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(22),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.88),
theme.colorScheme.surface.withValues(alpha: 0.96),
],
),
border: Border.all(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.68),
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(22),
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final height = constraints.maxHeight;
final previewBase = math.min(width, height);
return Stack(
children: [
Positioned.fill(
child: CustomPaint(
painter: _PreviewGridPainter(
color:
theme.colorScheme.primary.withValues(alpha: 0.08),
),
),
),
Positioned(
left: 16,
top: 12,
child: Text(
'chainrings',
style: theme.textTheme.labelSmall?.copyWith(
letterSpacing: 1.2,
color:
theme.colorScheme.onSurface.withValues(alpha: 0.52),
),
),
),
Positioned(
right: 16,
top: 12,
child: Text(
'cassette',
style: theme.textTheme.labelSmall?.copyWith(
letterSpacing: 1.2,
color:
theme.colorScheme.onSurface.withValues(alpha: 0.52),
),
),
),
_GearStack(
gears: chainrings,
center: Offset(width * 0.25, height * 0.56),
maxSize: previewBase * 0.58,
depthStep: const Offset(6, -2),
rotation: -0.22,
verticalScale: 0.90,
minScale: 0.48,
backColor:
theme.colorScheme.onSurface.withValues(alpha: 0.92),
frontColor: theme.colorScheme.onSurface,
hubColor: theme.colorScheme.primaryContainer,
drawHub: false,
),
_GearStack(
gears: sprockets,
center: Offset(width * 0.73, height * 0.58),
maxSize: previewBase * 0.72,
depthStep: const Offset(2.2, -0.7),
rotation: -0.20,
verticalScale: 0.92,
minScale: 0.34,
backColor: theme.colorScheme.onSurface.withValues(
alpha: 0.62,
),
frontColor: theme.colorScheme.onSurface,
hubColor:
theme.colorScheme.onSurface.withValues(alpha: 0.72),
drawHub: false,
),
],
);
},
),
),
),
);
}
}
class _GearStack extends StatelessWidget {
const _GearStack({
required this.gears,
required this.center,
required this.maxSize,
required this.depthStep,
required this.rotation,
required this.verticalScale,
required this.minScale,
required this.backColor,
required this.frontColor,
required this.hubColor,
required this.drawHub,
});
final List<int> gears;
final Offset center;
final double maxSize;
final Offset depthStep;
final double rotation;
final double verticalScale;
final double minScale;
final Color backColor;
final Color frontColor;
final Color hubColor;
final bool drawHub;
@override
Widget build(BuildContext context) {
final sorted = List<int>.from(gears)..sort((a, b) => b - a);
if (sorted.isEmpty) {
return const SizedBox.shrink();
}
final largest = sorted.first.toDouble();
final depthOrigin = center - depthStep * ((sorted.length - 1) / 2);
final frontCenter =
depthOrigin + depthStep * (sorted.length - 1).toDouble();
final frontGearSize =
maxSize * (minScale + (1 - minScale) * sorted.last / largest);
return Positioned.fill(
child: Stack(
clipBehavior: Clip.none,
children: [
for (var i = 0; i < sorted.length; i++)
_ProjectedGearLayer(
teeth: sorted[i],
center: depthOrigin + depthStep * i.toDouble(),
size: maxSize * (minScale + (1 - minScale) * sorted[i] / largest),
tint: Color.lerp(
backColor,
frontColor,
i / math.max(1, sorted.length - 1),
)!,
rotation: rotation,
verticalScale: verticalScale,
depth: i,
),
if (drawHub)
_ProjectedHub(
center: frontCenter,
size: frontGearSize * 0.11,
rotation: rotation,
verticalScale: verticalScale,
color: hubColor,
),
],
),
);
}
}
class _ProjectedGearLayer extends StatelessWidget {
const _ProjectedGearLayer({
required this.teeth,
required this.center,
required this.size,
required this.tint,
required this.rotation,
required this.verticalScale,
required this.depth,
});
final int teeth;
final Offset center;
final double size;
final Color tint;
final double rotation;
final double verticalScale;
final int depth;
@override
Widget build(BuildContext context) {
return Positioned(
left: center.dx - size / 2,
top: center.dy - size / 2,
width: size,
height: size,
child: Transform.rotate(
angle: rotation,
child: Transform.scale(
scaleY: verticalScale,
child: Stack(
children: [
Positioned.fill(
left: 3 + depth * 0.16,
top: 6 + depth * 0.08,
child: Opacity(
opacity: 0.32,
child: ColorFiltered(
colorFilter: const ColorFilter.mode(
Colors.black,
BlendMode.srcIn,
),
child: Image.asset(
_gearAssetForTeeth(teeth),
fit: BoxFit.contain,
),
),
),
),
Positioned.fill(
child: ColorFiltered(
colorFilter: ColorFilter.mode(tint, BlendMode.srcIn),
child: Image.asset(
_gearAssetForTeeth(teeth),
fit: BoxFit.contain,
),
),
),
],
),
),
),
);
}
}
class _ProjectedHub extends StatelessWidget {
const _ProjectedHub({
required this.center,
required this.size,
required this.rotation,
required this.verticalScale,
required this.color,
});
final Offset center;
final double size;
final double rotation;
final double verticalScale;
final Color color;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Positioned(
left: center.dx - size / 2,
top: center.dy - size / 2,
width: size,
height: size,
child: Transform.rotate(
angle: rotation,
child: Transform.scale(
scaleY: verticalScale,
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color,
border: Border.all(
color: theme.colorScheme.surface.withValues(alpha: 0.72),
width: 1.1,
),
boxShadow: [
BoxShadow(
blurRadius: 4,
offset: const Offset(1, 2),
color: Colors.black.withValues(alpha: 0.18),
),
],
),
child: FractionallySizedBox(
widthFactor: 0.46,
heightFactor: 0.46,
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: theme.colorScheme.surface.withValues(alpha: 0.86),
),
),
),
),
),
),
);
}
}
String _gearAssetForTeeth(int teeth) {
final padded = teeth.toString().padLeft(2, '0');
return 'assets/images/gears/png/filled/chainring_${padded}_filled.png';
}
class _PreviewGridPainter extends CustomPainter {
const _PreviewGridPainter({required this.color});
final Color color;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = 1;
for (var x = -size.height; x < size.width; x += 28) {
canvas.drawLine(
Offset(x, size.height), Offset(x + size.height, 0), paint);
}
}
@override
bool shouldRepaint(covariant _PreviewGridPainter oldDelegate) {
return oldDelegate.color != color;
}
}
class _ToothListEditor extends StatelessWidget {
const _ToothListEditor({
required this.title,
required this.subtitle,
required this.values,
required this.maxItems,
required this.minTeeth,
required this.maxTeeth,
required this.onAdd,
required this.onChanged,
required this.onRemove,
});
final String title;
final String subtitle;
final List<int> values;
final int maxItems;
final int minTeeth;
final int maxTeeth;
final VoidCallback onAdd;
final void Function(int index, int value) onChanged;
final void Function(int index) onRemove;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
color:
theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.42),
border: Border.all(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.62),
),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
Text(
subtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(
alpha: 0.6,
),
),
),
],
),
),
IconButton.filledTonal(
onPressed: values.length >= maxItems ? null : onAdd,
tooltip: 'Add $title',
icon: const Icon(Icons.add),
),
],
),
const SizedBox(height: 8),
for (var i = 0; i < values.length; i++) ...[
_ToothRow(
label: '${i + 1}',
value: values[i],
canRemove: values.length > 1,
minTeeth: minTeeth,
maxTeeth: maxTeeth,
onChanged: (value) => onChanged(i, value),
onRemove: () => onRemove(i),
),
if (i != values.length - 1) const SizedBox(height: 6),
],
],
),
),
);
}
}
class _ToothRow extends StatelessWidget {
const _ToothRow({
required this.label,
required this.value,
required this.canRemove,
required this.minTeeth,
required this.maxTeeth,
required this.onChanged,
required this.onRemove,
});
final String label;
final int value;
final bool canRemove;
final int minTeeth;
final int maxTeeth;
final ValueChanged<int> onChanged;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
color: theme.colorScheme.surface.withValues(alpha: 0.66),
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
children: [
SizedBox(
width: 28,
child: Text(
label,
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w800,
color: theme.colorScheme.onSurface.withValues(alpha: 0.58),
),
),
),
IconButton(
visualDensity: VisualDensity.compact,
onPressed: value <= minTeeth ? null : () => onChanged(value - 1),
icon: const Icon(Icons.remove),
),
Expanded(
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () => _editValue(context),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Center(
child: Text(
'$value teeth',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
),
),
),
IconButton(
visualDensity: VisualDensity.compact,
onPressed: value >= maxTeeth ? null : () => onChanged(value + 1),
icon: const Icon(Icons.add),
),
IconButton(
visualDensity: VisualDensity.compact,
onPressed: canRemove ? onRemove : null,
tooltip: 'Remove',
icon: const Icon(Icons.delete_outline),
),
],
),
),
);
}
Future<void> _editValue(BuildContext context) async {
final controller = TextEditingController(text: value.toString());
final selected = await showDialog<int>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Set teeth'),
content: TextField(
controller: controller,
autofocus: true,
keyboardType: TextInputType.number,
decoration: InputDecoration(
hintText: '$minTeeth-$maxTeeth',
suffixText: 'teeth',
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final parsed = int.tryParse(controller.text.trim());
Navigator.of(context).pop(parsed);
},
child: const Text('Set'),
),
],
);
},
);
if (selected != null) {
onChanged(selected.clamp(minTeeth, maxTeeth));
}
}
}
class _CalculationSummary extends StatelessWidget {
const _CalculationSummary({required this.calculation});
final GearConfiguratorCalculation calculation;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final warnings = <String>[
if (calculation.discardedBelowRange > 0)
'${calculation.discardedBelowRange} below range skipped',
if (calculation.discardedAboveRange > 0)
'${calculation.discardedAboveRange} above range skipped',
if (calculation.duplicateCount > 0)
'${calculation.duplicateCount} duplicates removed',
if (calculation.truncatedCount > 0)
'${calculation.truncatedCount} high gears truncated',
];
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: theme.colorScheme.surface.withValues(alpha: 0.72),
border: Border.all(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.55),
),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
calculation.ratios.isEmpty
? 'No valid ratios'
: 'Preview: ${calculation.ratios.first.toStringAsFixed(2)} -> ${calculation.ratios.last.toStringAsFixed(2)}',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
if (warnings.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
warnings.join(' · '),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.tertiary,
),
),
],
],
),
),
);
}
}
class _ModeSelector extends StatelessWidget {
const _ModeSelector({required this.mode, required this.onChanged});
final GearRetentionMode mode;
final ValueChanged<GearRetentionMode> onChanged;
@override
Widget build(BuildContext context) {
return Column(
children: [
_ModeTile(
selected: mode == GearRetentionMode.keepHighest,
title: const Text('Keep Highest (recommended)'),
subtitle:
const Text('Drop overlapping lower ratios from larger rings.'),
onTap: () => onChanged(GearRetentionMode.keepHighest),
),
_ModeTile(
selected: mode == GearRetentionMode.keepAll,
title: const Text('Keep All'),
subtitle:
const Text('Keep every unique combination until the gear limit.'),
onTap: () => onChanged(GearRetentionMode.keepAll),
),
],
);
}
}
class _ModeTile extends StatelessWidget {
const _ModeTile({
required this.selected,
required this.title,
required this.subtitle,
required this.onTap,
});
final bool selected;
final Widget title;
final Widget subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
selected
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
color: selected
? theme.colorScheme.primary
: theme.colorScheme.onSurface.withValues(alpha: 0.62),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DefaultTextStyle.merge(
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w700,
),
child: title,
),
const SizedBox(height: 2),
DefaultTextStyle.merge(
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(
alpha: 0.62,
),
),
child: subtitle,
),
],
),
),
],
),
),
),
);
}
}