1005 lines
31 KiB
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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|