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 chainrings; final List sprockets; } Future showGearConfiguratorDialog( BuildContext context, ) { return showDialog( 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 _chainrings = List.from(_roadPreset.chainrings); List _sprockets = List.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.from(preset.chainrings); _sprockets = List.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.from(_chainrings) ..[index] = value.clamp(_minTeeth, _maxTeeth); }); } void _changeSprocket(int index, int value) { setState(() { _sprockets = List.from(_sprockets) ..[index] = value.clamp(_minTeeth, _maxTeeth); }); } void _removeChainring(int index) { if (_chainrings.length <= 1) { return; } setState(() { _chainrings = List.from(_chainrings)..removeAt(index); }); } void _removeSprocket(int index) { if (_sprockets.length <= 1) { return; } setState(() { _sprockets = List.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 chainrings; final List 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 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.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 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 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 _editValue(BuildContext context) async { final controller = TextEditingController(text: value.toString()); final selected = await showDialog( 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 = [ 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 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, ), ], ), ), ], ), ), ), ); } }