feat: new shifter types and better gear ratio editor
This commit is contained in:
@ -112,6 +112,7 @@ enum TrainerConnectionState {
|
|||||||
idle,
|
idle,
|
||||||
connecting,
|
connecting,
|
||||||
pairing,
|
pairing,
|
||||||
|
connected,
|
||||||
discoveringFtms,
|
discoveringFtms,
|
||||||
ftmsReady,
|
ftmsReady,
|
||||||
error,
|
error,
|
||||||
@ -131,6 +132,8 @@ class TrainerStatus {
|
|||||||
return 'Connecting';
|
return 'Connecting';
|
||||||
case TrainerConnectionState.pairing:
|
case TrainerConnectionState.pairing:
|
||||||
return 'Pairing';
|
return 'Pairing';
|
||||||
|
case TrainerConnectionState.connected:
|
||||||
|
return 'Connected';
|
||||||
case TrainerConnectionState.discoveringFtms:
|
case TrainerConnectionState.discoveringFtms:
|
||||||
return 'Discovering FTMS';
|
return 'Discovering FTMS';
|
||||||
case TrainerConnectionState.ftmsReady:
|
case TrainerConnectionState.ftmsReady:
|
||||||
@ -148,9 +151,11 @@ class TrainerStatus {
|
|||||||
case 2:
|
case 2:
|
||||||
return const TrainerStatus(state: TrainerConnectionState.pairing);
|
return const TrainerStatus(state: TrainerConnectionState.pairing);
|
||||||
case 3:
|
case 3:
|
||||||
|
return const TrainerStatus(state: TrainerConnectionState.connected);
|
||||||
|
case 4:
|
||||||
return const TrainerStatus(
|
return const TrainerStatus(
|
||||||
state: TrainerConnectionState.discoveringFtms);
|
state: TrainerConnectionState.discoveringFtms);
|
||||||
case 4:
|
case 5:
|
||||||
return const TrainerStatus(state: TrainerConnectionState.ftmsReady);
|
return const TrainerStatus(state: TrainerConnectionState.ftmsReady);
|
||||||
default:
|
default:
|
||||||
return const TrainerStatus(state: TrainerConnectionState.idle);
|
return const TrainerStatus(state: TrainerConnectionState.idle);
|
||||||
@ -160,7 +165,7 @@ class TrainerStatus {
|
|||||||
if (raw is List && raw.isNotEmpty) {
|
if (raw is List && raw.isNotEmpty) {
|
||||||
final variant = raw.first;
|
final variant = raw.first;
|
||||||
final value = raw.length > 1 ? raw[1] : null;
|
final value = raw.length > 1 ? raw[1] : null;
|
||||||
if (variant is int && variant == 5) {
|
if (variant is int && (variant == 5 || variant == 6)) {
|
||||||
return TrainerStatus(
|
return TrainerStatus(
|
||||||
state: TrainerConnectionState.error,
|
state: TrainerConnectionState.error,
|
||||||
errorCode: value is int ? value : null,
|
errorCode: value is int ? value : null,
|
||||||
@ -173,7 +178,7 @@ class TrainerStatus {
|
|||||||
if (entry != null) {
|
if (entry != null) {
|
||||||
final key = entry.key;
|
final key = entry.key;
|
||||||
final value = entry.value;
|
final value = entry.value;
|
||||||
if ((key is int && key == 5) ||
|
if ((key is int && (key == 5 || key == 6)) ||
|
||||||
(key is String && key.toLowerCase().contains('error'))) {
|
(key is String && key.toLowerCase().contains('error'))) {
|
||||||
return TrainerStatus(
|
return TrainerStatus(
|
||||||
state: TrainerConnectionState.error,
|
state: TrainerConnectionState.error,
|
||||||
@ -191,6 +196,9 @@ class TrainerStatus {
|
|||||||
if (normalized.contains('pairing')) {
|
if (normalized.contains('pairing')) {
|
||||||
return const TrainerStatus(state: TrainerConnectionState.pairing);
|
return const TrainerStatus(state: TrainerConnectionState.pairing);
|
||||||
}
|
}
|
||||||
|
if (normalized.contains('connected')) {
|
||||||
|
return const TrainerStatus(state: TrainerConnectionState.connected);
|
||||||
|
}
|
||||||
if (normalized.contains('discover')) {
|
if (normalized.contains('discover')) {
|
||||||
return const TrainerStatus(
|
return const TrainerStatus(
|
||||||
state: TrainerConnectionState.discoveringFtms);
|
state: TrainerConnectionState.discoveringFtms);
|
||||||
|
|||||||
@ -62,6 +62,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
bool _hasLoadedGearRatios = false;
|
bool _hasLoadedGearRatios = false;
|
||||||
String? _gearRatiosError;
|
String? _gearRatiosError;
|
||||||
List<double> _gearRatios = const [];
|
List<double> _gearRatios = const [];
|
||||||
|
int _defaultGearIndex = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -245,20 +246,28 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_gearRatios = result.unwrap();
|
final data = result.unwrap();
|
||||||
|
_gearRatios = data.ratios;
|
||||||
|
_defaultGearIndex = data.defaultGearIndex;
|
||||||
_isGearRatiosLoading = false;
|
_isGearRatiosLoading = false;
|
||||||
_hasLoadedGearRatios = true;
|
_hasLoadedGearRatios = true;
|
||||||
_gearRatiosError = null;
|
_gearRatiosError = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> _saveGearRatios(List<double> ratios) async {
|
Future<String?> _saveGearRatios(
|
||||||
|
List<double> ratios, int defaultGearIndex) async {
|
||||||
final shifter = _shifterService;
|
final shifter = _shifterService;
|
||||||
if (shifter == null) {
|
if (shifter == null) {
|
||||||
return 'Status channel is not ready yet.';
|
return 'Status channel is not ready yet.';
|
||||||
}
|
}
|
||||||
|
|
||||||
final result = await shifter.writeGearRatios(ratios);
|
final result = await shifter.writeGearRatios(
|
||||||
|
GearRatiosData(
|
||||||
|
ratios: ratios,
|
||||||
|
defaultGearIndex: defaultGearIndex,
|
||||||
|
),
|
||||||
|
);
|
||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
return 'Could not save gear ratios: ${result.unwrapErr()}';
|
return 'Could not save gear ratios: ${result.unwrapErr()}';
|
||||||
}
|
}
|
||||||
@ -269,6 +278,9 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_gearRatios = List<double>.from(ratios);
|
_gearRatios = List<double>.from(ratios);
|
||||||
|
_defaultGearIndex = ratios.isEmpty
|
||||||
|
? 0
|
||||||
|
: defaultGearIndex.clamp(0, ratios.length - 1).toInt();
|
||||||
_hasLoadedGearRatios = true;
|
_hasLoadedGearRatios = true;
|
||||||
_gearRatiosError = null;
|
_gearRatiosError = null;
|
||||||
});
|
});
|
||||||
@ -495,6 +507,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
GearRatioEditorCard(
|
GearRatioEditorCard(
|
||||||
ratios: _gearRatios,
|
ratios: _gearRatios,
|
||||||
|
defaultGearIndex: _defaultGearIndex,
|
||||||
isLoading: _isGearRatiosLoading,
|
isLoading: _isGearRatiosLoading,
|
||||||
errorText: _gearRatiosError,
|
errorText: _gearRatiosError,
|
||||||
onRetry: _loadGearRatios,
|
onRetry: _loadGearRatios,
|
||||||
|
|||||||
@ -20,6 +20,8 @@ class ShifterService {
|
|||||||
Stream<CentralStatus> get statusStream => _statusController.stream;
|
Stream<CentralStatus> get statusStream => _statusController.stream;
|
||||||
|
|
||||||
static const int _gearRatioSlots = 32;
|
static const int _gearRatioSlots = 32;
|
||||||
|
static const int _defaultGearIndexOffset = _gearRatioSlots;
|
||||||
|
static const int _gearRatioPayloadBytes = _gearRatioSlots + 1;
|
||||||
static const double _maxGearRatio = 255 / 64;
|
static const double _maxGearRatio = 255 / 64;
|
||||||
static const int _gearRatioWriteMtu = 64;
|
static const int _gearRatioWriteMtu = 64;
|
||||||
|
|
||||||
@ -56,7 +58,7 @@ class ShifterService {
|
|||||||
return writeCommand(UniversalShifterCommand.connectToDevice);
|
return writeCommand(UniversalShifterCommand.connectToDevice);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Result<List<double>>> readGearRatios() async {
|
Future<Result<GearRatiosData>> readGearRatios() async {
|
||||||
final readRes = await _bluetooth.readCharacteristic(
|
final readRes = await _bluetooth.readCharacteristic(
|
||||||
buttonDeviceId,
|
buttonDeviceId,
|
||||||
universalShifterControlServiceUuid,
|
universalShifterControlServiceUuid,
|
||||||
@ -67,25 +69,43 @@ class ShifterService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final raw = readRes.unwrap();
|
final raw = readRes.unwrap();
|
||||||
if (raw.length > _gearRatioSlots) {
|
if (raw.length > _gearRatioPayloadBytes) {
|
||||||
return bail(
|
return bail(
|
||||||
'Invalid gear ratio payload length: expected at most $_gearRatioSlots, got ${raw.length}',
|
'Invalid gear ratio payload length: expected at most $_gearRatioPayloadBytes, got ${raw.length}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final normalizedRaw = List<int>.filled(_gearRatioSlots, 0, growable: false);
|
final normalizedRaw = List<int>.filled(
|
||||||
|
_gearRatioPayloadBytes,
|
||||||
|
0,
|
||||||
|
growable: false,
|
||||||
|
);
|
||||||
for (var i = 0; i < raw.length; i++) {
|
for (var i = 0; i < raw.length; i++) {
|
||||||
normalizedRaw[i] = raw[i];
|
normalizedRaw[i] = raw[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
final ratios = normalizedRaw
|
final ratios = <double>[];
|
||||||
.where((v) => v > 0)
|
for (var i = 0; i < _gearRatioSlots; i++) {
|
||||||
.map((v) => _decodeGearRatio(v))
|
final value = normalizedRaw[i];
|
||||||
.toList(growable: false);
|
if (value == 0) {
|
||||||
return Ok(ratios);
|
break;
|
||||||
|
}
|
||||||
|
ratios.add(_decodeGearRatio(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Result<void>> writeGearRatios(List<double> ratios) async {
|
final defaultIndexRaw = normalizedRaw[_defaultGearIndexOffset];
|
||||||
|
final defaultGearIndex = ratios.isEmpty
|
||||||
|
? 0
|
||||||
|
: defaultIndexRaw.clamp(0, ratios.length - 1).toInt();
|
||||||
|
return Ok(
|
||||||
|
GearRatiosData(
|
||||||
|
ratios: ratios,
|
||||||
|
defaultGearIndex: defaultGearIndex,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Result<void>> writeGearRatios(GearRatiosData data) async {
|
||||||
final mtuResult =
|
final mtuResult =
|
||||||
await _bluetooth.requestMtu(buttonDeviceId, mtu: _gearRatioWriteMtu);
|
await _bluetooth.requestMtu(buttonDeviceId, mtu: _gearRatioWriteMtu);
|
||||||
if (mtuResult.isErr()) {
|
if (mtuResult.isErr()) {
|
||||||
@ -93,12 +113,16 @@ class ShifterService {
|
|||||||
'Could not request MTU before writing gear ratios: ${mtuResult.unwrapErr()}');
|
'Could not request MTU before writing gear ratios: ${mtuResult.unwrapErr()}');
|
||||||
}
|
}
|
||||||
|
|
||||||
final payload = List<int>.filled(_gearRatioSlots, 0, growable: false);
|
final payload =
|
||||||
|
List<int>.filled(_gearRatioPayloadBytes, 0, growable: false);
|
||||||
|
final ratios = data.ratios;
|
||||||
final limit =
|
final limit =
|
||||||
ratios.length < _gearRatioSlots ? ratios.length : _gearRatioSlots;
|
ratios.length < _gearRatioSlots ? ratios.length : _gearRatioSlots;
|
||||||
for (var i = 0; i < limit; i++) {
|
for (var i = 0; i < limit; i++) {
|
||||||
payload[i] = _encodeGearRatio(ratios[i]);
|
payload[i] = _encodeGearRatio(ratios[i]);
|
||||||
}
|
}
|
||||||
|
payload[_defaultGearIndexOffset] =
|
||||||
|
limit == 0 ? 0 : data.defaultGearIndex.clamp(0, limit - 1).toInt();
|
||||||
|
|
||||||
return _bluetooth.writeCharacteristic(
|
return _bluetooth.writeCharacteristic(
|
||||||
buttonDeviceId,
|
buttonDeviceId,
|
||||||
@ -177,3 +201,13 @@ class ShifterService {
|
|||||||
return raw / 64.0;
|
return raw / 64.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class GearRatiosData {
|
||||||
|
const GearRatiosData({
|
||||||
|
required this.ratios,
|
||||||
|
required this.defaultGearIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<double> ratios;
|
||||||
|
final int defaultGearIndex;
|
||||||
|
}
|
||||||
|
|||||||
@ -17,6 +17,7 @@ class GearRatioPreset {
|
|||||||
class GearRatioEditorCard extends StatefulWidget {
|
class GearRatioEditorCard extends StatefulWidget {
|
||||||
const GearRatioEditorCard({
|
const GearRatioEditorCard({
|
||||||
required this.ratios,
|
required this.ratios,
|
||||||
|
required this.defaultGearIndex,
|
||||||
required this.isLoading,
|
required this.isLoading,
|
||||||
required this.onSave,
|
required this.onSave,
|
||||||
required this.presets,
|
required this.presets,
|
||||||
@ -26,8 +27,10 @@ class GearRatioEditorCard extends StatefulWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final List<double> ratios;
|
final List<double> ratios;
|
||||||
|
final int defaultGearIndex;
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
final Future<String?> Function(List<double> ratios) onSave;
|
final Future<String?> Function(List<double> ratios, int defaultGearIndex)
|
||||||
|
onSave;
|
||||||
final List<GearRatioPreset> presets;
|
final List<GearRatioPreset> presets;
|
||||||
final String? errorText;
|
final String? errorText;
|
||||||
final VoidCallback? onRetry;
|
final VoidCallback? onRetry;
|
||||||
@ -43,6 +46,13 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
|||||||
static const double _sliderPivotV = 1.00;
|
static const double _sliderPivotV = 1.00;
|
||||||
static const Duration _animDuration = Duration(milliseconds: 280);
|
static const Duration _animDuration = Duration(milliseconds: 280);
|
||||||
static const Curve _animCurve = Cubic(0.2, 0.8, 0.2, 1.0);
|
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 _isExpanded = false;
|
||||||
bool _isEditing = false;
|
bool _isEditing = false;
|
||||||
@ -54,20 +64,34 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
|||||||
|
|
||||||
List<double> _committed = const [];
|
List<double> _committed = const [];
|
||||||
List<double> _draft = const [];
|
List<double> _draft = const [];
|
||||||
|
int _committedDefaultGearIndex = 0;
|
||||||
|
int _draftDefaultGearIndex = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_committed = List<double>.from(widget.ratios);
|
_committed = List<double>.from(widget.ratios);
|
||||||
_draft = List<double>.from(widget.ratios);
|
_draft = List<double>.from(widget.ratios);
|
||||||
|
_committedDefaultGearIndex = _normalizeDefaultIndex(
|
||||||
|
widget.defaultGearIndex,
|
||||||
|
_committed.length,
|
||||||
|
);
|
||||||
|
_draftDefaultGearIndex = _committedDefaultGearIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant GearRatioEditorCard oldWidget) {
|
void didUpdateWidget(covariant GearRatioEditorCard oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (!_isEditing && !_listEquals(oldWidget.ratios, widget.ratios)) {
|
if (!_isEditing &&
|
||||||
|
(!_listEquals(oldWidget.ratios, widget.ratios) ||
|
||||||
|
oldWidget.defaultGearIndex != widget.defaultGearIndex)) {
|
||||||
_committed = List<double>.from(widget.ratios);
|
_committed = List<double>.from(widget.ratios);
|
||||||
_draft = List<double>.from(widget.ratios);
|
_draft = List<double>.from(widget.ratios);
|
||||||
|
_committedDefaultGearIndex = _normalizeDefaultIndex(
|
||||||
|
widget.defaultGearIndex,
|
||||||
|
_committed.length,
|
||||||
|
);
|
||||||
|
_draftDefaultGearIndex = _committedDefaultGearIndex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -183,7 +207,11 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
|||||||
if (!_isExpanded && !_isEditing && _committed.isNotEmpty)
|
if (!_isExpanded && !_isEditing && _committed.isNotEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 10),
|
padding: const EdgeInsets.fromLTRB(14, 0, 14, 10),
|
||||||
child: _compactRatioStrip(context, _committed),
|
child: _compactRatioStrip(
|
||||||
|
context,
|
||||||
|
_committed,
|
||||||
|
defaultGearIndex: _committedDefaultGearIndex,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
AnimatedSize(
|
AnimatedSize(
|
||||||
duration: _animDuration,
|
duration: _animDuration,
|
||||||
@ -197,7 +225,12 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
|||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
for (var i = 0; i < _committed.length; i++)
|
for (var i = 0; i < _committed.length; i++)
|
||||||
_ratioChip(context, i + 1, _committed[i]),
|
_ratioChip(
|
||||||
|
context,
|
||||||
|
i + 1,
|
||||||
|
_committed[i],
|
||||||
|
isDefault: i == _committedDefaultGearIndex,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -280,17 +313,15 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
|||||||
switchInCurve: _animCurve,
|
switchInCurve: _animCurve,
|
||||||
switchOutCurve: Curves.easeInCubic,
|
switchOutCurve: Curves.easeInCubic,
|
||||||
transitionBuilder: _snappyTransition,
|
transitionBuilder: _snappyTransition,
|
||||||
child: Wrap(
|
child: Padding(
|
||||||
key: ValueKey('editors-$_gearLayoutVersion'),
|
padding: EdgeInsets.only(
|
||||||
spacing: 8,
|
top: _editorTilesTopInset,
|
||||||
runSpacing: 8,
|
),
|
||||||
children: [
|
child: Column(
|
||||||
for (var i = 0; i < _draft.length; i++)
|
key: ValueKey('editors-$_gearLayoutVersion'),
|
||||||
KeyedSubtree(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
key: ValueKey('editor-${i + 1}'),
|
children: _buildEditableGearTiles(context),
|
||||||
child: _buildGearEditor(context, i),
|
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -314,21 +345,122 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
Widget _buildGearEditor(BuildContext context, int index) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final canAdd = _draft.length < _maxGears;
|
||||||
final ratio = _draft[index];
|
final ratio = _draft[index];
|
||||||
final sliderValue = _valueToSlider(ratio);
|
final sliderValue = _valueToSlider(ratio);
|
||||||
return ConstrainedBox(
|
final isDefault = index == _draftDefaultGearIndex;
|
||||||
constraints: const BoxConstraints(minWidth: 230, maxWidth: 280),
|
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(
|
child: Container(
|
||||||
padding: const EdgeInsets.fromLTRB(10, 8, 10, 4),
|
padding: const EdgeInsets.fromLTRB(10, 8, 10, 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.7),
|
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: Theme.of(context)
|
color: borderColor,
|
||||||
.colorScheme
|
width: isDefault ? 1.6 : 1,
|
||||||
.outlineVariant
|
|
||||||
.withValues(alpha: 0.6),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -340,13 +472,45 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
|||||||
'Gear ${index + 1}',
|
'Gear ${index + 1}',
|
||||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
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(),
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
tooltip: 'Delete gear',
|
||||||
|
onPressed: () => _deleteGear(index),
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
),
|
||||||
InkWell(
|
InkWell(
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
onTap: () => _editRatioText(index),
|
onTap: () => _editRatioText(index),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding:
|
padding: const EdgeInsets.symmetric(
|
||||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
ratio.toStringAsFixed(2),
|
ratio.toStringAsFixed(2),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
@ -364,7 +528,8 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
|||||||
value: sliderValue,
|
value: sliderValue,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_draft[index] = _quantizeRatio(_sliderToValue(value));
|
_draft[index] =
|
||||||
|
_quantizeRatio(_sliderToValue(value));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onChangeEnd: (_) {
|
onChangeEnd: (_) {
|
||||||
@ -378,9 +543,109 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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 {
|
Future<void> _editRatioText(int index) async {
|
||||||
final controller = TextEditingController(
|
final controller = TextEditingController(
|
||||||
text: _draft[index].toStringAsFixed(2),
|
text: _draft[index].toStringAsFixed(2),
|
||||||
@ -493,6 +758,7 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
|||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_draft = selected.ratios.map(_quantizeRatio).toList(growable: false);
|
_draft = selected.ratios.map(_quantizeRatio).toList(growable: false);
|
||||||
|
_draftDefaultGearIndex = _normalizeDefaultIndex(0, _draft.length);
|
||||||
if (_sortAscending) {
|
if (_sortAscending) {
|
||||||
_sortDraft(animate: true);
|
_sortDraft(animate: true);
|
||||||
}
|
}
|
||||||
@ -500,11 +766,14 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _sortDraft({bool animate = false}) {
|
void _sortDraft({bool animate = false}) {
|
||||||
final sorted = _sorted(_draft);
|
final sorted = _sortedWithDefault(_draft, _draftDefaultGearIndex);
|
||||||
if (animate && !_listEquals(_draft, sorted)) {
|
final sortedValues = sorted.$1;
|
||||||
|
final sortedDefaultIndex = sorted.$2;
|
||||||
|
if (animate && !_listEquals(_draft, sortedValues)) {
|
||||||
_gearLayoutVersion++;
|
_gearLayoutVersion++;
|
||||||
}
|
}
|
||||||
_draft = sorted;
|
_draft = sortedValues;
|
||||||
|
_draftDefaultGearIndex = sortedDefaultIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _enterEditMode() {
|
void _enterEditMode() {
|
||||||
@ -514,6 +783,10 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
|||||||
_stretchFactor = 1.0;
|
_stretchFactor = 1.0;
|
||||||
_stretchBase = null;
|
_stretchBase = null;
|
||||||
_draft = List<double>.from(_committed);
|
_draft = List<double>.from(_committed);
|
||||||
|
_draftDefaultGearIndex = _normalizeDefaultIndex(
|
||||||
|
_committedDefaultGearIndex,
|
||||||
|
_draft.length,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -521,6 +794,10 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_isEditing = false;
|
_isEditing = false;
|
||||||
_draft = List<double>.from(_committed);
|
_draft = List<double>.from(_committed);
|
||||||
|
_draftDefaultGearIndex = _normalizeDefaultIndex(
|
||||||
|
_committedDefaultGearIndex,
|
||||||
|
_draft.length,
|
||||||
|
);
|
||||||
_stretchFactor = 1.0;
|
_stretchFactor = 1.0;
|
||||||
_stretchBase = null;
|
_stretchBase = null;
|
||||||
});
|
});
|
||||||
@ -531,7 +808,10 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
|||||||
_isSaving = true;
|
_isSaving = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
final message = await widget.onSave(List<double>.from(_draft));
|
final message = await widget.onSave(
|
||||||
|
List<double>.from(_draft),
|
||||||
|
_normalizeDefaultIndex(_draftDefaultGearIndex, _draft.length),
|
||||||
|
);
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -540,6 +820,10 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
|||||||
_isSaving = false;
|
_isSaving = false;
|
||||||
if (message == null) {
|
if (message == null) {
|
||||||
_committed = List<double>.from(_draft);
|
_committed = List<double>.from(_draft);
|
||||||
|
_committedDefaultGearIndex = _normalizeDefaultIndex(
|
||||||
|
_draftDefaultGearIndex,
|
||||||
|
_committed.length,
|
||||||
|
);
|
||||||
_isEditing = false;
|
_isEditing = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -551,7 +835,12 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _ratioChip(BuildContext context, int gear, double ratio) {
|
Widget _ratioChip(
|
||||||
|
BuildContext context,
|
||||||
|
int gear,
|
||||||
|
double ratio, {
|
||||||
|
bool isDefault = false,
|
||||||
|
}) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
@ -559,10 +848,17 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
|||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
color: theme.colorScheme.surface.withValues(alpha: 0.7),
|
color: theme.colorScheme.surface.withValues(alpha: 0.7),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.6),
|
color: isDefault
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: theme.colorScheme.outlineVariant.withValues(alpha: 0.6),
|
||||||
|
width: isDefault ? 1.4 : 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text('G$gear ${ratio.toStringAsFixed(2)}'),
|
child: Text(
|
||||||
|
isDefault
|
||||||
|
? 'G$gear ${ratio.toStringAsFixed(2)} default'
|
||||||
|
: 'G$gear ${ratio.toStringAsFixed(2)}',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -570,6 +866,7 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
|||||||
BuildContext context,
|
BuildContext context,
|
||||||
List<double> ratios, {
|
List<double> ratios, {
|
||||||
bool showGearLabel = true,
|
bool showGearLabel = true,
|
||||||
|
int? defaultGearIndex,
|
||||||
}) {
|
}) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
@ -588,13 +885,18 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
|||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
color: theme.colorScheme.surface.withValues(alpha: 0.7),
|
color: theme.colorScheme.surface.withValues(alpha: 0.7),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: theme.colorScheme.outlineVariant
|
color: i == defaultGearIndex
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: theme.colorScheme.outlineVariant
|
||||||
.withValues(alpha: 0.55),
|
.withValues(alpha: 0.55),
|
||||||
|
width: i == defaultGearIndex ? 1.3 : 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
showGearLabel
|
showGearLabel
|
||||||
? 'G${i + 1} ${ratios[i].toStringAsFixed(2)}'
|
? (i == defaultGearIndex
|
||||||
|
? 'G${i + 1} ${ratios[i].toStringAsFixed(2)} default'
|
||||||
|
: 'G${i + 1} ${ratios[i].toStringAsFixed(2)}')
|
||||||
: ratios[i].toStringAsFixed(2),
|
: ratios[i].toStringAsFixed(2),
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@ -613,9 +915,34 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
|||||||
return ((clamped * 64).round() / 64.0).clamp(_sliderMin, _sliderMax);
|
return ((clamped * 64).round() / 64.0).clamp(_sliderMin, _sliderMax);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<double> _sorted(List<double> values) {
|
(List<double>, int) _sortedWithDefault(
|
||||||
final out = List<double>.from(values)..sort();
|
List<double> values, int defaultIndex) {
|
||||||
return out;
|
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) {
|
bool _listEquals(List<double> a, List<double> b) {
|
||||||
@ -661,6 +988,69 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
class _GearRatioGraph extends StatelessWidget {
|
||||||
const _GearRatioGraph({required this.ratios, required this.compact});
|
const _GearRatioGraph({required this.ratios, required this.compact});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user