Skip to content

Commit 9059aad

Browse files
committed
Double tap and hover triggers; vertical sliders with bound min/max and thumb stroke
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013oMokUGNW6txknJs7xxTik
1 parent 67611ab commit 9059aad

4 files changed

Lines changed: 505 additions & 9 deletions

File tree

‎lib/src/transformers/node_transformers/passive_slider_transformer.dart‎

Lines changed: 122 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'dart:math' as math;
2+
13
import 'package:codelessly_api/codelessly_api.dart';
24
import 'package:flutter/material.dart';
35

@@ -94,13 +96,29 @@ class PassiveSliderWidget extends StatelessWidget {
9496
scopedValues: scopedValues,
9597
) ??
9698
node.value;
99+
final double min =
100+
PropertyValueDelegate.getPropertyValue<double>(
101+
node,
102+
'min',
103+
scopedValues: scopedValues,
104+
) ??
105+
node.properties.min;
106+
final double max =
107+
PropertyValueDelegate.getPropertyValue<double>(
108+
node,
109+
'max',
110+
scopedValues: scopedValues,
111+
) ??
112+
node.properties.max;
97113

98114
return TransformerSlider(
99115
node: node,
100116
settings: settings,
101117
variablesOverrides: variablesOverrides,
102118
onChanged: (value) => onChanged(context, value),
103119
value: value,
120+
min: min,
121+
max: max,
104122
);
105123
}
106124
}
@@ -113,6 +131,8 @@ class TransformerSlider extends StatelessWidget {
113131
required this.onChanged,
114132
required this.value,
115133
this.variablesOverrides = const [],
134+
this.min,
135+
this.max,
116136
});
117137

118138
final SliderNode node;
@@ -121,8 +141,18 @@ class TransformerSlider extends StatelessWidget {
121141
final ValueChanged<double>? onChanged;
122142
final double value;
123143

144+
/// The slider's minimum, when bound; [SliderProperties.min] otherwise.
145+
final double? min;
146+
147+
/// The slider's maximum, when bound; [SliderProperties.max] otherwise.
148+
/// Never less than the minimum.
149+
final double? max;
150+
124151
@override
125152
Widget build(BuildContext context) {
153+
final double min = this.min ?? node.properties.min;
154+
final double max = math.max(min, this.max ?? node.properties.max);
155+
final bool vertical = node.properties.orientation == AxisC.vertical;
126156
final SliderTrackShape trackShape = switch (node.properties.trackShape) {
127157
SliderTrackShapeEnum.rectangle => const RectangularSliderTrackShape(),
128158
SliderTrackShapeEnum.roundedRectangle =>
@@ -158,9 +188,12 @@ class TransformerSlider extends StatelessWidget {
158188
};
159189
final bool showsLabel = showValueIndicator != ShowValueIndicator.never;
160190

161-
return SizedBox(
162-
width: node.basicBoxLocal.width,
163-
height: node.basicBoxLocal.height,
191+
// A vertical slider is a horizontal one turned a quarter turn
192+
// counterclockwise, so min sits at the bottom. RotatedBox turns its
193+
// layout and hit testing too, so the whole node box drags the thumb.
194+
final Widget slider = SizedBox(
195+
width: vertical ? node.basicBoxLocal.height : node.basicBoxLocal.width,
196+
height: vertical ? node.basicBoxLocal.width : node.basicBoxLocal.height,
164197
child: SliderTheme(
165198
data: SliderThemeData(
166199
trackHeight: node.properties.trackHeight,
@@ -171,10 +204,12 @@ class TransformerSlider extends StatelessWidget {
171204
inactiveTrackColor: node.properties.inactiveTrackColor
172205
.toFlutterColor(),
173206
thumbColor: node.properties.thumbColor.toFlutterColor(),
174-
thumbShape: RoundSliderThumbShape(
207+
thumbShape: StrokedSliderThumbShape(
175208
enabledThumbRadius: node.properties.showThumb
176209
? node.properties.thumbRadius
177210
: 0,
211+
strokeColor: node.properties.thumbStrokeColor?.toFlutterColor(),
212+
strokeWidth: node.properties.thumbStrokeWidth,
178213
),
179214
trackShape: trackShape,
180215
// GappedSliderTrackShape requires a gap; 6 is the Material 3
@@ -208,22 +243,100 @@ class TransformerSlider extends StatelessWidget {
208243
),
209244
),
210245
child: Slider(
211-
value: value.clamp(node.properties.min, node.properties.max),
246+
value: value.clamp(min, max),
212247
onChanged: onChanged,
213248
autofocus: node.properties.autofocus,
214-
min: node.properties.min,
215-
max: node.properties.max,
249+
min: min,
250+
max: max,
216251
divisions: node.properties.isDiscrete
217252
? node.properties.divisions
218253
: null,
219254
label: showsLabel ? getSliderLabel(node, value) : null,
220255
secondaryTrackValue: node.properties.secondaryTrackValue?.clamp(
221-
node.properties.min,
222-
node.properties.max,
256+
min,
257+
max,
223258
),
224259
padding: node.properties.padding?.flutterEdgeInsets,
225260
),
226261
),
227262
);
263+
264+
final TextDirection direction =
265+
Directionality.maybeOf(context) ?? TextDirection.ltr;
266+
final TextDirection flipped = direction == TextDirection.ltr
267+
? TextDirection.rtl
268+
: TextDirection.ltr;
269+
// Vertical runs bottom to top in left to right text, so an inverted
270+
// vertical slider runs top to bottom.
271+
final Widget directed = node.properties.inverted
272+
? Directionality(textDirection: flipped, child: slider)
273+
: Directionality(textDirection: direction, child: slider);
274+
275+
if (!vertical) {
276+
return node.properties.inverted ? directed : slider;
277+
}
278+
return SizedBox(
279+
width: node.basicBoxLocal.width,
280+
height: node.basicBoxLocal.height,
281+
child: RotatedBox(quarterTurns: 3, child: directed),
282+
);
283+
}
284+
}
285+
286+
/// A [RoundSliderThumbShape] with an optional stroke drawn inside its edge.
287+
class StrokedSliderThumbShape extends RoundSliderThumbShape {
288+
/// Creates a [StrokedSliderThumbShape].
289+
const StrokedSliderThumbShape({
290+
super.enabledThumbRadius,
291+
this.strokeColor,
292+
this.strokeWidth = 0,
293+
});
294+
295+
/// Color of the stroke; no stroke when null.
296+
final Color? strokeColor;
297+
298+
/// Width of the stroke; no stroke when 0.
299+
final double strokeWidth;
300+
301+
@override
302+
void paint(
303+
PaintingContext context,
304+
Offset center, {
305+
required Animation<double> activationAnimation,
306+
required Animation<double> enableAnimation,
307+
required bool isDiscrete,
308+
required TextPainter labelPainter,
309+
required RenderBox parentBox,
310+
required SliderThemeData sliderTheme,
311+
required TextDirection textDirection,
312+
required double value,
313+
required double textScaleFactor,
314+
required Size sizeWithOverflow,
315+
}) {
316+
super.paint(
317+
context,
318+
center,
319+
activationAnimation: activationAnimation,
320+
enableAnimation: enableAnimation,
321+
isDiscrete: isDiscrete,
322+
labelPainter: labelPainter,
323+
parentBox: parentBox,
324+
sliderTheme: sliderTheme,
325+
textDirection: textDirection,
326+
value: value,
327+
textScaleFactor: textScaleFactor,
328+
sizeWithOverflow: sizeWithOverflow,
329+
);
330+
final Color? color = strokeColor;
331+
if (color == null || strokeWidth <= 0 || enabledThumbRadius <= 0) return;
332+
final double width = math.min(strokeWidth, enabledThumbRadius);
333+
context.canvas.drawCircle(
334+
center,
335+
enabledThumbRadius - width / 2,
336+
Paint()
337+
..style = PaintingStyle.stroke
338+
..strokeWidth = width
339+
..color = color,
340+
);
228341
}
229342
}

‎lib/src/transformers/passive_transformer_manager.dart‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager {
9898
if (settings.withReactions) {
9999
widget = wrapWithReaction(context, styled, widget);
100100
widget = wrapWithSwipes(context, styled, widget);
101+
widget = wrapWithPointerTriggers(context, styled, widget);
101102
widget = wrapWithSemantics(styled, widget);
102103
}
103104
if (settings.withRotation) {

‎lib/src/transformers/widget_node_transformer_manager.dart‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'dart:typed_data';
44

55
import 'package:codelessly_api/codelessly_api.dart';
66
import 'package:collection/collection.dart';
7+
import 'package:flutter/gestures.dart' show PointerDeviceKind;
78
import 'package:flutter/material.dart';
89
import 'package:flutter_svg/flutter_svg.dart';
910
import 'package:provider/provider.dart';
@@ -389,6 +390,88 @@ abstract class WidgetNodeTransformerManager
389390
);
390391
}
391392

393+
/// Runs [node]'s double tap and hover reactions.
394+
///
395+
/// A double tap recognizer is only added when a double tap reaction
396+
/// exists, since it makes single taps wait out the double tap timeout
397+
/// (300ms) before they fire `click`. Hover reactions listen with a
398+
/// [MouseRegion], which touch input never enters. The legacy
399+
/// [TriggerType.cursorEnter] and [TriggerType.cursorExit] run with
400+
/// [TriggerType.hoverEnter] and [TriggerType.hoverExit].
401+
Widget wrapWithPointerTriggers(
402+
BuildContext context,
403+
BaseNode node,
404+
Widget widget,
405+
) {
406+
if (node is! ReactionMixin) return widget;
407+
final List<Reaction> reactions = (node as ReactionMixin).reactions
408+
.where(
409+
(reaction) =>
410+
_pointerTriggers.contains(reaction.trigger.type) &&
411+
reaction.action.enabled,
412+
)
413+
.toList();
414+
if (reactions.isEmpty) return widget;
415+
bool has(TriggerType type) =>
416+
reactions.any((reaction) => reaction.trigger.type == type);
417+
418+
if (has(TriggerType.doubleTap)) {
419+
widget = GestureDetector(
420+
behavior: HitTestBehavior.opaque,
421+
onDoubleTap: () => FunctionsRepository.triggerAction(
422+
context,
423+
TriggerType.doubleTap,
424+
reactions: reactions,
425+
),
426+
child: widget,
427+
);
428+
}
429+
430+
final bool enters =
431+
has(TriggerType.hoverEnter) || has(TriggerType.cursorEnter);
432+
final bool exits =
433+
has(TriggerType.hoverExit) || has(TriggerType.cursorExit);
434+
if (enters || exits) {
435+
void run(PointerEvent event, List<TriggerType> types) {
436+
if (event.kind == PointerDeviceKind.touch) return;
437+
for (final TriggerType type in types) {
438+
FunctionsRepository.triggerAction(
439+
context,
440+
type,
441+
reactions: reactions,
442+
);
443+
}
444+
}
445+
446+
widget = MouseRegion(
447+
opaque: false,
448+
hitTestBehavior: HitTestBehavior.opaque,
449+
onEnter: enters
450+
? (event) => run(event, const [
451+
TriggerType.hoverEnter,
452+
TriggerType.cursorEnter,
453+
])
454+
: null,
455+
onExit: exits
456+
? (event) => run(event, const [
457+
TriggerType.hoverExit,
458+
TriggerType.cursorExit,
459+
])
460+
: null,
461+
child: widget,
462+
);
463+
}
464+
return widget;
465+
}
466+
467+
static const Set<TriggerType> _pointerTriggers = {
468+
TriggerType.doubleTap,
469+
TriggerType.hoverEnter,
470+
TriggerType.hoverExit,
471+
TriggerType.cursorEnter,
472+
TriggerType.cursorExit,
473+
};
474+
392475
BorderRadius? getBorderRadius(BaseNode node) {
393476
if (node is CornerMixin) {
394477
if (node.cornerRadius.linked &&

0 commit comments

Comments
 (0)