From 7257c409afaaf10b51a577856ab9623510d43e70 Mon Sep 17 00:00:00 2001 From: Daniel Eshkeri Date: Fri, 22 Nov 2024 10:17:07 +0000 Subject: [PATCH] feat(UX-881): Range Selector (#217) feat: Created Range Selector test: wrote 1 test for range selector test: testing range selector docs: widgetbook and example for range selector test: created interaction tests test: adjusted golden tolerance fix: removed rounded from widgetbook fix: removed large thumbs fix: removed defaults to true on showValues --- example/lib/home.dart | 2 + .../components/range_selector_example.dart | 202 +++++++ example/widgetbook/main.dart | 2 + .../components/range_selector_widgetbook.dart | 33 ++ example/widgetbook/utils/utils.dart | 38 ++ lib/src/components/components.dart | 1 + .../range_selector/range_selector.dart | 448 +++++++++++++++ .../range_selector_rounded_continuous.png | Bin 0 -> 4618 bytes .../range_selector_rounded_disabled.png | Bin 0 -> 4625 bytes .../golden/range_selector_rounded_stepped.png | Bin 0 -> 4692 bytes .../range_selector_sharp_continuous.png | Bin 0 -> 3611 bytes .../golden/range_selector_sharp_disabled.png | Bin 0 -> 3668 bytes .../golden/range_selector_sharp_stepped.png | Bin 0 -> 3687 bytes .../range_slider_rounded_continuous.png | Bin 0 -> 3856 bytes .../golden/range_slider_rounded_disabled.png | Bin 0 -> 3842 bytes .../golden/range_slider_rounded_stepped.png | Bin 0 -> 3990 bytes .../golden/range_slider_sharp_continuous.png | Bin 0 -> 3343 bytes .../golden/range_slider_sharp_disabled.png | Bin 0 -> 3349 bytes .../golden/range_slider_sharp_stepped.png | Bin 0 -> 3498 bytes .../range_selector/range_selector_test.dart | 509 ++++++++++++++++++ test/test_utils/utils.dart | 36 ++ 21 files changed, 1271 insertions(+) create mode 100644 example/lib/pages/components/range_selector_example.dart create mode 100644 example/widgetbook/pages/components/range_selector_widgetbook.dart create mode 100644 lib/src/components/range_selector/range_selector.dart create mode 100644 test/src/components/range_selector/golden/range_selector_rounded_continuous.png create mode 100644 test/src/components/range_selector/golden/range_selector_rounded_disabled.png create mode 100644 test/src/components/range_selector/golden/range_selector_rounded_stepped.png create mode 100644 test/src/components/range_selector/golden/range_selector_sharp_continuous.png create mode 100644 test/src/components/range_selector/golden/range_selector_sharp_disabled.png create mode 100644 test/src/components/range_selector/golden/range_selector_sharp_stepped.png create mode 100644 test/src/components/range_selector/golden/range_slider_rounded_continuous.png create mode 100644 test/src/components/range_selector/golden/range_slider_rounded_disabled.png create mode 100644 test/src/components/range_selector/golden/range_slider_rounded_stepped.png create mode 100644 test/src/components/range_selector/golden/range_slider_sharp_continuous.png create mode 100644 test/src/components/range_selector/golden/range_slider_sharp_disabled.png create mode 100644 test/src/components/range_selector/golden/range_slider_sharp_stepped.png create mode 100644 test/src/components/range_selector/range_selector_test.dart diff --git a/example/lib/home.dart b/example/lib/home.dart index b60898e3..e3a587a5 100644 --- a/example/lib/home.dart +++ b/example/lib/home.dart @@ -26,6 +26,7 @@ import 'package:zeta_example/pages/components/navigation_rail_example.dart'; import 'package:zeta_example/pages/components/notification_list_example.dart'; import 'package:zeta_example/pages/components/phone_input_example.dart'; import 'package:zeta_example/pages/components/radio_example.dart'; +import 'package:zeta_example/pages/components/range_selector_example.dart'; import 'package:zeta_example/pages/components/screen_header_bar_example.dart'; import 'package:zeta_example/pages/components/select_input_example.dart'; @@ -89,6 +90,7 @@ final List components = [ Component(TabsExample.name, (context) => const TabsExample()), Component(DialPadExample.name, (context) => const DialPadExample()), Component(RadioButtonExample.name, (context) => const RadioButtonExample()), + Component(RangeSelectorExample.name, (context) => const RangeSelectorExample()), Component(SwitchExample.name, (context) => const SwitchExample()), Component(SliderExample.name, (context) => const SliderExample()), Component(DateInputExample.name, (context) => const DateInputExample()), diff --git a/example/lib/pages/components/range_selector_example.dart b/example/lib/pages/components/range_selector_example.dart new file mode 100644 index 00000000..1820dec1 --- /dev/null +++ b/example/lib/pages/components/range_selector_example.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.dart'; +import 'package:zeta_example/widgets.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +class RangeSelectorExample extends StatefulWidget { + static const String name = 'RangeSelector'; + + const RangeSelectorExample({super.key}); + + @override + State createState() => _RangeSelectorExampleState(); +} + +class _RangeSelectorExampleState extends State { + double value = 0.5; + + @override + Widget build(BuildContext context) { + return ExampleScaffold( + name: RangeSelectorExample.name, + child: SingleChildScrollView( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Range Slider - Rounded - Continuous', style: ZetaTextStyles.bodyLarge), + ZetaRangeSelector( + onChange: (value) { + print(value.start); + print(value.end); + }, + initialValues: RangeValues(20, 80), + min: 0, + max: 100, + showValues: false, + ), + ].gap(12), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Range Slider - Rounded - Stepped', style: ZetaTextStyles.bodyLarge), + ZetaRangeSelector( + onChange: (value) {}, + initialValues: RangeValues(20, 80), + min: 0, + max: 100, + showValues: false, + divisions: 10, + ), + ].gap(12), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Range Slider - Rounded - Disabled', style: ZetaTextStyles.bodyLarge), + ZetaRangeSelector( + initialValues: RangeValues(20, 80), + min: 0, + max: 100, + showValues: false, + ), + ].gap(12), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Range Slider - Sharp - Continuous', style: ZetaTextStyles.bodyLarge), + ZetaRangeSelector( + onChange: (value) {}, + initialValues: RangeValues(20, 80), + min: 0, + max: 100, + showValues: false, + rounded: false, + ), + ].gap(12), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Range Slider - Sharp - Stepped', style: ZetaTextStyles.bodyLarge), + ZetaRangeSelector( + onChange: (value) {}, + initialValues: RangeValues(20, 80), + min: 0, + max: 100, + showValues: false, + divisions: 10, + rounded: false, + ), + ].gap(12), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Range Slider - Sharp - Disabled', style: ZetaTextStyles.bodyLarge), + ZetaRangeSelector( + initialValues: RangeValues(20, 80), + min: 0, + max: 100, + showValues: false, + rounded: false, + ), + ].gap(12), + ), + SizedBox( + height: 32, + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Range Selector - Rounded - Continuous', style: ZetaTextStyles.bodyLarge), + ZetaRangeSelector( + onChange: (value) {}, + initialValues: RangeValues(20, 80), + min: 0, + max: 100, + label: 'Label', + ), + ].gap(12), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Range Selector - Rounded - Stepped', style: ZetaTextStyles.bodyLarge), + ZetaRangeSelector( + onChange: (value) {}, + initialValues: RangeValues(20, 80), + min: 0, + max: 100, + label: 'Label', + divisions: 10, + ), + ].gap(12), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Range Selector - Rounded - Disabled', style: ZetaTextStyles.bodyLarge), + ZetaRangeSelector( + initialValues: RangeValues(20, 80), + min: 0, + max: 100, + label: 'Label', + divisions: 10, + ), + ].gap(12), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Range Selector - Sharp - Continuous', style: ZetaTextStyles.bodyLarge), + ZetaRangeSelector( + onChange: (value) {}, + initialValues: RangeValues(20, 80), + min: 0, + max: 100, + label: 'Label', + rounded: false, + ), + ].gap(12), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Range Selector - Sharp - Stepped', style: ZetaTextStyles.bodyLarge), + ZetaRangeSelector( + onChange: (value) {}, + initialValues: RangeValues(20, 80), + min: 0, + max: 100, + label: 'Label', + rounded: false, + divisions: 10, + ), + ].gap(12), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Range Selector - Sharp - Disabled', style: ZetaTextStyles.bodyLarge), + ZetaRangeSelector( + initialValues: RangeValues(20, 80), + min: 0, + max: 100, + label: 'Label', + rounded: false, + divisions: 10, + ), + ].gap(12), + ), + ].gap(32), + ).paddingVertical(32), + ), + ), + ); + } +} diff --git a/example/widgetbook/main.dart b/example/widgetbook/main.dart index 740cb129..c381b4e0 100644 --- a/example/widgetbook/main.dart +++ b/example/widgetbook/main.dart @@ -38,6 +38,7 @@ import 'pages/components/password_input_widgetbook.dart'; import 'pages/components/phone_input_widgetbook.dart'; import 'pages/components/progress_widgetbook.dart'; import 'pages/components/radio_widgetbook.dart'; +import 'pages/components/range_selector_widgetbook.dart'; import 'pages/components/screen_header_bar_widgetbook.dart'; import 'pages/components/search_bar_widgetbook.dart'; import 'pages/components/segmented_control_widgetbook.dart'; @@ -212,6 +213,7 @@ class _HotReloadState extends State { WidgetbookUseCase(name: 'Segmented Control', builder: (context) => segmentedControlUseCase(context)), WidgetbookUseCase(name: 'Select Input', builder: (context) => selectInputUseCase(context)), WidgetbookUseCase(name: 'Slider', builder: (context) => sliderUseCase(context)), + WidgetbookUseCase(name: 'Range Selector', builder: (context) => rangeSelectorUseCase(context)), WidgetbookUseCase(name: 'Snack Bar', builder: (context) => snackBarUseCase(context)), WidgetbookUseCase(name: 'Stepper Input', builder: (context) => stepperInputUseCase(context)), WidgetbookUseCase(name: 'Stepper', builder: (context) => stepperUseCase(context)), diff --git a/example/widgetbook/pages/components/range_selector_widgetbook.dart b/example/widgetbook/pages/components/range_selector_widgetbook.dart new file mode 100644 index 00000000..3c8bd50d --- /dev/null +++ b/example/widgetbook/pages/components/range_selector_widgetbook.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +import '../../utils/scaffold.dart'; +import '../../utils/utils.dart'; + +Widget rangeSelectorUseCase(BuildContext context) { + return WidgetbookScaffold( + builder: (context, _) => RangeSelectorExample(context), + ); +} + +class RangeSelectorExample extends StatelessWidget { + RangeSelectorExample(this.context); + final BuildContext context; + + @override + Widget build(BuildContext _) { + return Padding( + padding: const EdgeInsets.all(16), + child: ZetaRangeSelector( + label: context.knobs.string(label: 'Label', initialValue: 'Range Selector'), + divisions: context.knobs.intOrNull.input(label: 'Divisions', initialValue: null), + showValues: context.knobs.boolean(label: 'Show Values', initialValue: true), + onChange: disabledKnob(context) ? null : (value) {}, + initialValues: context.knobs.range(label: 'Initial Range', initialValue: RangeValues(20, 80)), + min: context.knobs.double.input(label: 'Min', initialValue: 0), + max: context.knobs.double.input(label: 'Max', initialValue: 100), + ), + ); + } +} diff --git a/example/widgetbook/utils/utils.dart b/example/widgetbook/utils/utils.dart index c3fcdfcf..a5f782f7 100644 --- a/example/widgetbook/utils/utils.dart +++ b/example/widgetbook/utils/utils.dart @@ -37,3 +37,41 @@ bool disabledKnob(BuildContext context) => context.knobs.boolean( label: 'Disabled', initialValue: false, ); + +class RangeKnob extends Knob { + RangeKnob({ + required super.label, + required super.initialValue, + }); + @override + List get fields => [ + DoubleInputField( + name: 'min-$label', + initialValue: initialValue.start, + ), + DoubleInputField( + name: 'max-$label', + initialValue: initialValue.end, + ), + ]; + @override + RangeValues valueFromQueryGroup(Map group) { + return RangeValues( + valueOf('min-$label', group)!, + valueOf('max-$label', group)!, + ); + } +} + +extension RangeKnobBuilder on KnobsBuilder { + RangeValues range({ + required String label, + RangeValues initialValue = const RangeValues(0, 10), + }) => + onKnobAdded( + RangeKnob( + label: label, + initialValue: initialValue, + ), + )!; +} diff --git a/lib/src/components/components.dart b/lib/src/components/components.dart index 01ad7ba4..acaa56b2 100644 --- a/lib/src/components/components.dart +++ b/lib/src/components/components.dart @@ -40,6 +40,7 @@ export 'phone_input/phone_input.dart'; export 'progress/progress_bar.dart'; export 'progress/progress_circle.dart'; export 'radio/radio.dart'; +export 'range_selector/range_selector.dart'; export 'screen_header_bar/screen_header_bar.dart'; export 'search_bar/search_bar.dart'; export 'segmented_control/segmented_control.dart'; diff --git a/lib/src/components/range_selector/range_selector.dart b/lib/src/components/range_selector/range_selector.dart new file mode 100644 index 00000000..de14fe8e --- /dev/null +++ b/lib/src/components/range_selector/range_selector.dart @@ -0,0 +1,448 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../zeta_flutter.dart'; + +/// The [ZetaRangeSelector] is a customizable range selector widget that +/// allows users to select a range of values within a specified minimum +/// and maximum range. It provides a visual representation of the selected +/// range and allows for precise adjustments through both dragging and direct input +class ZetaRangeSelector extends ZetaStatefulWidget { + /// Creates a new [ZetaRangeSelector] + ZetaRangeSelector({ + super.key, + super.rounded, + required this.initialValues, + this.min = 0.0, + this.max = 100, + this.label, + this.onChange, + this.divisions, + this.semanticLabel, + this.showValues = true, + }) : assert( + min <= initialValues.start && initialValues.start <= initialValues.end && initialValues.end <= max, + 'The start value must be less than or equal to the end value, and both must be within the range of min and max.', + ); + + /// The initial values of the range selector. + final RangeValues initialValues; + + /// The minimum value of the range selector. + /// Defaults to 0. + final double min; + + /// The maximum value of the range selector. + /// Defaults to 100. + final double max; + + /// The label of the range selector. + final String? label; + + /// Called with the values of the range selector whenever it is changed. + final ValueChanged? onChange; + + /// The number of divisions for the range selector. + final int? divisions; + + /// Value passed to the wrapping [Semantics] widget. + final String? semanticLabel; + + /// Whether to show the values of the range selector. + final bool showValues; + + @override + State createState() => _ZetaRangeSelectorState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DoubleProperty('min', min)) + ..add(DoubleProperty('max', max)) + ..add(StringProperty('label', label)) + ..add(ObjectFlagProperty?>.has('onChange', onChange)) + ..add(IntProperty('divisions', divisions)) + ..add(StringProperty('semanticLabel', semanticLabel)) + ..add(DiagnosticsProperty('showValues', showValues)) + ..add(DiagnosticsProperty('initialValues', initialValues)); + } +} + +class _ZetaRangeSelectorState extends State { + bool _selected = false; + + late RangeValues _values; + + final TextEditingController _lowerController = TextEditingController(); + final TextEditingController _upperController = TextEditingController(); + + bool get disabled => widget.onChange == null; + + @override + void initState() { + super.initState(); + _values = widget.initialValues; + _lowerController.text = widget.initialValues.start.round().toString(); + _upperController.text = widget.initialValues.end.round().toString(); + } + + @override + void didUpdateWidget(ZetaRangeSelector oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initialValues != oldWidget.initialValues) { + _values = widget.initialValues; + _lowerController.text = widget.initialValues.start.toString(); + _upperController.text = widget.initialValues.end.toString(); + } + } + + @override + void dispose() { + super.dispose(); + _lowerController.dispose(); + _upperController.dispose(); + } + + Color get _activeColor { + final colors = Zeta.of(context).colors; + if (widget.onChange == null) { + return colors.textDisabled; + } + return _selected ? colors.primary : colors.surfaceDefaultInverse; + } + + void _onSubmit(TextEditingController controller, {bool lower = true}) { + if (controller.text.isNotEmpty) { + double? val = double.tryParse(controller.text); + if (val != null) { + if (val < widget.min) { + val = widget.min; + } + if (lower ? val >= _values.end : val <= _values.start) { + val = lower ? _values.end : _values.start; + } + if (widget.onChange != null) { + widget.onChange?.call(RangeValues(lower ? val : _values.start, lower ? _values.end : val)); + setState(() { + _values = RangeValues(lower ? val! : _values.start, lower ? _values.end : val!); + controller.text = val!.round().toString(); + }); + } + } + } + } + + void _valueFieldOnChanged(String value, TextEditingController controller) { + double? val = double.tryParse(value); + if (val != null) { + if (val > widget.max) { + val = widget.max; + } + setState(() { + controller.text = val!.round().toString(); + }); + } + } + + void _rangeSliderOnChanged(RangeValues value) { + if (widget.onChange != null) { + widget.onChange?.call(RangeValues(value.start, value.end)); + setState(() { + _values = RangeValues(value.start, value.end); + _lowerController.text = value.start.round().toString(); + _upperController.text = value.end.round().toString(); + }); + } + } + + void _setSelected(bool selected) { + setState(() { + _selected = selected; + }); + } + + @override + Widget build(BuildContext context) { + final colors = Zeta.of(context).colors; + final spacing = Zeta.of(context).spacing; + final bool rounded = context.rounded; + + return ZetaRoundedScope( + rounded: rounded, + child: Semantics( + label: widget.semanticLabel, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.label != null) + Text( + widget.label!, + style: ZetaTextStyles.bodySmall.copyWith( + color: disabled ? colors.textDisabled : colors.textDefault, + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.showValues) + _ValueField( + controller: _lowerController, + onEditingComplete: () => _onSubmit(_lowerController), + onTapOutside: (event) => _onSubmit(_lowerController), + onChanged: (value) => _valueFieldOnChanged(value, _lowerController), + disabled: disabled, + rounded: rounded, + context: context, + ), + Expanded( + child: SliderTheme( + data: SliderThemeData( + /// Track + activeTrackColor: _activeColor, + inactiveTrackColor: colors.surfaceDisabled, + rangeTrackShape: rounded + ? const RoundedRectRangeSliderTrackShape() as RangeSliderTrackShape + : const RectangularRangeSliderTrackShape() as RangeSliderTrackShape, + + /// Ticks + activeTickMarkColor: colors.surfaceDefault, + inactiveTickMarkColor: colors.surfaceDefault, + rangeTickMarkShape: const RoundRangeSliderTickMarkShape(tickMarkRadius: 1), + + /// Thumbs + overlayShape: _DrawThumbOverlay( + size: spacing.xl / 2, + rounded: rounded, + color: _activeColor, + ), + rangeThumbShape: _DrawThumb( + size: spacing.large / 2, + rounded: rounded, + color: _activeColor, + ), + ), + child: RangeSlider( + values: _values, + onChanged: _rangeSliderOnChanged, + divisions: widget.divisions, + onChangeStart: (_) => _setSelected(true), + onChangeEnd: (_) => _setSelected(false), + min: widget.min, + max: widget.max, + mouseCursor: WidgetStatePropertyAll( + disabled ? SystemMouseCursors.basic : SystemMouseCursors.click, + ), + semanticFormatterCallback: (value) { + return '${value.round()}'; + }, + ), + ), + ), + if (widget.showValues) + _ValueField( + controller: _upperController, + onEditingComplete: () => _onSubmit(_upperController, lower: false), + onTapOutside: (event) => _onSubmit(_upperController, lower: false), + onChanged: (value) => _valueFieldOnChanged(value, _upperController), + disabled: disabled, + rounded: rounded, + context: context, + ), + ].gap(spacing.xl_4), + ), + ].gap(spacing.small), + ), + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('disabled', disabled)); + } +} + +class _ValueField extends StatelessWidget { + const _ValueField({ + required this.controller, + required this.onEditingComplete, + required this.onChanged, + required this.onTapOutside, + required this.disabled, + required this.rounded, + required this.context, + }); + + final TextEditingController controller; + final VoidCallback onEditingComplete; + final ValueChanged onChanged; + final TapRegionCallback onTapOutside; + final bool disabled; + final bool rounded; + final BuildContext context; + + ZetaColors get _colors { + return Zeta.of(context).colors; + } + + ZetaSpacingSemantics get _spacing { + return Zeta.of(context).spacing; + } + + InputBorder get _border { + final radius = Zeta.of(context).radius; + + return OutlineInputBorder( + borderSide: BorderSide( + color: !disabled ? _colors.borderDefault : _colors.borderDisabled, + ), + borderRadius: rounded ? radius.minimal : radius.none, + ); + } + + InputDecoration get _inputDecoration { + return InputDecoration( + filled: true, + fillColor: disabled ? _colors.surfaceDisabled : null, + contentPadding: EdgeInsets.zero, + constraints: BoxConstraints(maxHeight: _spacing.xl_6), + border: _border, + focusedBorder: _border, + enabledBorder: _border, + disabledBorder: _border, + ); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: _spacing.xl_9 - 8, + height: _spacing.xl_8, + child: TextFormField( + keyboardType: TextInputType.number, + enabled: !disabled, + controller: controller, + onEditingComplete: onEditingComplete, + onChanged: onChanged, + onTapOutside: onTapOutside.call, + textAlign: TextAlign.center, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: disabled ? _colors.textDisabled : _colors.textSubtle, + ), + decoration: _inputDecoration, + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('controller', controller)) + ..add(ObjectFlagProperty.has('onEditingComplete', onEditingComplete)) + ..add(ObjectFlagProperty>.has('onChanged', onChanged)) + ..add(DiagnosticsProperty('disabled', disabled)) + ..add(DiagnosticsProperty('rounded', rounded)) + ..add(DiagnosticsProperty('context', context)) + ..add(ObjectFlagProperty.has('onTapOutside', onTapOutside)); + } +} + +class _DrawThumb extends RangeSliderThumbShape { + /// Constructor for [_DrawThumb] + const _DrawThumb({required this.size, required this.rounded, required this.color}); + + /// Radius or width/height for [_DrawThumb] depending on shape + final double size; + + /// If [_DrawThumb] is circular or a square + final bool rounded; + + /// Color of [_DrawThumb] + final Color color; + + @override + Size getPreferredSize(bool isEnabled, bool isDiscrete) { + return Size.fromRadius(size); + } + + /// Paints thumb + @override + void paint( + PaintingContext context, + Offset center, { + required Animation activationAnimation, + required Animation enableAnimation, + bool? isDiscrete, + bool? isEnabled, + bool? isOnTop, + bool? isPressed, + required SliderThemeData sliderTheme, + TextDirection? textDirection, + Thumb? thumb, + }) { + final Canvas canvas = context.canvas; + + final paint = Paint() + ..style = PaintingStyle.fill + ..color = color; + + // draw icon with text painter + rounded + ? canvas.drawCircle(center, size, paint) + : canvas.drawRect(Rect.fromCenter(center: center, width: size * 2, height: size * 2), paint); + } +} + +/// Custom slider thumb component +class _DrawThumbOverlay extends SliderComponentShape { + /// Constructor for [_DrawThumbOverlay] + const _DrawThumbOverlay({required this.size, required this.rounded, required this.color}); + + /// Radius or width/height for [_DrawThumbOverlay] depending on shape + final double size; + + /// If [_DrawThumbOverlay] is circular or a square + final bool rounded; + + /// Color of [_DrawThumbOverlay] + final Color color; + + @override + Size getPreferredSize(bool isEnabled, bool isDiscrete) { + return Size.fromRadius(size); + } + + /// Paints thumb + @override + void paint( + PaintingContext context, + Offset center, { + required Animation activationAnimation, + required Animation enableAnimation, + required bool isDiscrete, + required TextPainter labelPainter, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required TextDirection textDirection, + required double value, + required double textScaleFactor, + required Size sizeWithOverflow, + }) { + final Canvas canvas = context.canvas; + + final paint = Paint() + ..style = PaintingStyle.fill + ..color = color; + + // draw icon with text painter + rounded + ? canvas.drawCircle(center, size, paint) + : canvas.drawRect(Rect.fromCenter(center: center, width: size * 2, height: size * 2), paint); + } +} diff --git a/test/src/components/range_selector/golden/range_selector_rounded_continuous.png b/test/src/components/range_selector/golden/range_selector_rounded_continuous.png new file mode 100644 index 0000000000000000000000000000000000000000..536ef9a2cbd41f94d2e425b840cdc96d793cff60 GIT binary patch literal 4618 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 literal 0 HcmV?d00001 diff --git a/test/src/components/range_selector/golden/range_selector_rounded_stepped.png b/test/src/components/range_selector/golden/range_selector_rounded_stepped.png new file mode 100644 index 0000000000000000000000000000000000000000..f4a64e7b8e29fda35456332f7bd2cb980f25beed GIT binary patch literal 4692 zcmeH}YfzKv6~`YgN)$mYN=qa_*IK8QLR$pnl4y|e2=>Y&IWkd&E_@?1(z*wGhS6Y>7t4{d!5^9+4*M z>-G@Pr(F+U7R(qIbDJBbpO$cpixb93Rf9T=9NsV-|6U{%ibpUV|C5Y=6~9JH zPNs5t5`((t>uQm>1$8)vN=L6(ET)oQpU=tjZl{q=+X1NIWJpk?!eZ;hqc(PA^1*ZZ z)@j9gI9{bP4wPhC)0;CI!*GT3p;{+B8_S(1$c(>&8o9IZbI;ZqzErt(vVeZ0RV%I! zpwbDc7*06R)zy{uct%g5L)!blBqd0bP47%+RNud;g;VlQ(6P$9A(WQdv0_;e07v8K zmX!xoYr6W14X^I;!mHR%RDmv_S1Z#g)tnd5u>53PAAi1_H9g%JNnk9psAD}E^Z0-k zeW5YUY-n8i0j-lr*_5%tgDx-(POER4REyQQIWN zHr|8SZ;kq}EU#R!azUPO^zEc5Co}U_CwHWL;_NzNk!ECiMc$0E2$q(x^Ti^c)2|K% zqe@FK7|dg{Lwhj}hr7#K%_ggdT~waFwz)>OtkOFSKJL=?8Tc$9Jpa{dFcK-&D1E~U z?qo0xp?kVFlnEnkYlf9EX{kh`#A30^DKR6p$fG^P(YZU*W*yXNo~x^w6Rbs6XI55L zN~Q;();2V78zOwu-=9KPcyleQzN9Kl2mH)>C_yEe5r;wDjM9{>dpZWta9FE8e1idj zk(P1%4g~xxTOARsgh*cxYFMLsITKV^lm&e^G&L5Gn>Pcph7Ftzl3BU z96aYl8#}<3;qqM-kB{?^q@8^S@6LUfBEuaEA&zNl7aHoWw|8YrLy)g;7QB1qL4y`X zdhwJ*{A~FJMM%ym`1_m}@%bTrPkASl_SL4%qv;IE+;7&-K{zxdJv#}|8+uG52+G6m z*iCNka-*)WP3zhIt&*;OsH>{lqkUgh6)ygq+N-6NOfAMeKXtOaJ@lY|@^qi+>58X62q+Dm%4dnGvV2+4-*&^c zXs#FxqU!B_%Jf1E(qf#3dwZu>5y2W!G|B7i%!7D5+2tF7Dph`&dv_C z$B%~wF5jcFbcYQNTYkT9AHEW`GIXU<^txhPS4|jzglUg~B-2b^=t=AAE4N)NT{TPP zgZr(W+b5lS38>{KSh;z=oIP-RH0Q%urF_&_)7r*sRdW~uL7r%yEurM+cNFWVk{M>^ zPJvrl&v=)5hiPc%W}6DzD9jUaD-aP!xC-s^cWJTjHo=OIfCj*S3_1T=>4K z?%K7MOP6ZMCr-2oa=o}*f>0q|oWj+Ik|vEwWyv2#U)xkqX&v+#C+tqR-cplM*K+^< zeK8>O({a+!@uAc%1H_S%pcE_>!&(D;n+r>(kvHHKNvTC@E?-R{Ec=- zS13FRS?YghvGJHsoRU>_boD%8M-I;t1G(L#$L(M?4|laWK}nK6INZm*(WL%ZV!1A%x$E8RvTJY< zqbVzEyk;%Vy6+J*&%EosgXZd6z+h?3r;9z* vPQXsUPQXsUPQXsUPQXsUPT>DeU?J2i89wT9c*7m&HVoqaoDj{6I(qh>akjt! literal 0 HcmV?d00001 diff --git a/test/src/components/range_selector/golden/range_selector_sharp_continuous.png b/test/src/components/range_selector/golden/range_selector_sharp_continuous.png new file mode 100644 index 0000000000000000000000000000000000000000..e263b955117355651ac24acdad7e471f52088e05 GIT binary patch literal 3611 zcmeAS@N?(olHy`uVBq!ia0y~yU{+vYV2a>i1B%QlYbpRzjKx9jP7LeL$-D$|Sc;uI zLpXq-h9ji|sOhMui(^Q|oVR!PXU$EOIq*@v=vh+}N7;)Y@Af4Dj(b?5|Hm|ylxG{p zH8eG|<~y!x-d#V*qJo>P$Vg)c&wBEC=I6(MwtcRtyf?W# zd1HhOKLbOVFT<-FyFd`ThSpzOdJRddU9x z`P}={=GFh-TYmiccl+onxoa;UB-p*X_qXEZ&$VmMZruL;^~`rH2ln6H`T63E^Ze82 z-M{-c_0F!XrOT`jYMp!i^A0c0khA<)e=uq9znX8?uV`;*dafV8*2I3keBbKu^>c69 zG0fQu6#2Q&?)kmC*I!qL9{`yXAcTm4{fs~7fAD$q^V_4FQ>Q-L%PE`iH>_6p*X;b5 zkF~t%VCC2&?Bi1B%QlYbpRzjKx9jP7LeL$-D$|Sc;uI zLpXq-h9ji|sOg`li(^Q|oVRz+XT_w-G&~fRJ>e&O*>GFarPT#XxfhlyPtohT7e4)7 z!&TSP+i|TA4z+4<{p*};xU|)&%_F9Jc8S{Oc`45-uYE48yPtLLbh$-(`o@Sk{6L!; zuOE{GQa9u#uisZ`C3E&)-P6|X_v_1l{G5J&Q)&CFia36LvZHYqe+E!-vk&K4wMxz5o^@UlP_=HP+oKW zUCJ9y28IWZ%>MqXG2%IFFn@lJ?eQoXnK%8mK%@3-^4(|uokNUFr=j~St>~hB-S@xz zhx_gS?)uW2x6fGi-;d&LUw-}FYu?^(|106;*A0I4vh#WRR;K6ulbfHX zUOV6C-_{;)vujHw!;YHGKR-WwGk@dq1kvq$dyl@+Dqt#VmGS%DcKGGJ8_6l#a`)eU zc5tut;pXb{V{*#L`)8l6yQy;f+yC9UzxVQiqw6EbHPIrIYO`+~D|2tZ-}dv@?d8e+ z6E@vHo3=k^Qtt2ncenkGV`qBO^}*-SSF_66tzY-G@NKADUB_8l^`T9F;bGMexP$g) z<;S8zyZQOg!guiVeQS?>|C5Q~ft01KbQ>^A%88C=^e8}2aS&_13B2B7Em;kW@f|xZ zZ{DaLu^yPHHZ!vT`BpYftdt~ALJlA&$&Z>zc4;vhbfZB>MkOi1B%QlYbpRzjKx9jP7LeL$-D$|Sc;uI zLpXq-h9jkefq_rZ)5S5QV$R#U_SthoWe$84J{hx0fT`?7kazpC0LL26ZT}>>)?K=L zHmQb@i%+bEJ4iN$i}C-G(hs^3FU|&R@D^X_vZ`c{cTCI?)xDnk{EOc|H-27FpI&ut z`sWGhxpwpAc^DWP4rmAS1MObZ_x1e#A4PX|e!k1C|0n17wI8p(UDw}}SN;3ij=cwV z%{I4>$~d=p&ED<55AUe>*xOns%)s!&-dur!!NIKos9%ziiGd-7g@b`%f*=`!Gq?V% zsWbR0z`$@ItALqUqh@~H>+Zj7-b0tzeI<3T1Q{3}T$uK^W}n?LiSX@uKDNg@D-5b_ zz5?C!;n1|7HSa`q7#I|k9e|$hVPIrnFk)h1U^u}6w3Af9Grto57kvM>{_*Vm{qaBA z?JB;{{rLEuKKJ_i-)qf}FP~qt=R=i+C%^o;V_#pt*WXi@{rc06`v1?9=W;gu-&OW@ zui@;sx_kcJ+dKc*RPE^FR>CqzGi~SZX2j{H_J8^jbJp0;`)B%}S92 zEq;DZ{KXW=WW;f@4RjvKevkIPQ#uUGxKwOXL~mX_fL%6`gh%|xpQ|Xr$2vp zB{}6y`OcgAr&F%Z+rN6-{mTyEm|LH)*Ja+j|F&N_R)*ewziMaUUX?6Xu~WB3MKfqLdvK7YOa zj$NgRuPptJG~aw&_;b>FZ)S#qsyDV(Mxa<>BR3Y%BXKh@-uct|m(ODcCe72XtEhiHlxV~SP711gwc#Jnh}O@ cMtCNl|9)1+&15lqpeGnSUHx3vIVCg!04gVmg#Z8m literal 0 HcmV?d00001 diff --git a/test/src/components/range_selector/golden/range_slider_rounded_continuous.png b/test/src/components/range_selector/golden/range_slider_rounded_continuous.png new file mode 100644 index 0000000000000000000000000000000000000000..3ec1c0207b210bbc6500d10c51fe27a953698bad GIT binary patch literal 3856 zcmeAS@N?(olHy`uVBq!ia0y~yU{+vYV2a>i1B%QlYbpRzjKx9jP7LeL$-D$|Sc;uI zLpXq-h9jkefq`$Ar;B4q#hkZy?7QcN${qNa{7yi*x%FyODAT!)WW{sV8#p==6yN0C zJ7^?rZT?G0j3rPfRr{tETVYR9hkin?$*D(fC)cED9X#cf@;QIq?F{8b zxgGzXRmDHnTvqe(=ydM2_it6D?D_RdJ30IH zMCJLs4AWYHWgf+VI$n^UoiDd8yu?qQ(#*`uS(g zo`1jWk9X_;t9=z1;mFN?;(y*9srz;1$#-^s{wzNGfq~u6C-;N<85p$RzDl3pzkc7p zv{!~dWFw+y_x`@_-}Bo~CO+fup>l^K?E9Dg{reiMGPl0I{*~a5xjTxVC)E7D+uM45 zcgs1IzWdehuP8Gx7&NvrKl%4nI5{`BY|G^hMsoLRDqaaPBrLV;{<-$g56)|cUW><8 z-0ytMU4NZ*e_HkLjUNxP|DRRG!w?a9`*wdX`~MFIt|V`0`opU~c}LyfUlor!FF*eF z_I7(!UTtmdp8x-Fqqlg-2CxMuz$b5{onIWujOk?vSW6Ac{k(i9ap9Xc}xFDGmxU2Ic9osHQ6@5 z-^AA!3eP#4_V~@sxgXD2?>6&h_`wOZG4N?b|Wr1mgVo%uM_K zw{MRxmlI@oS9LCUS|bA^1A~#>K_(!{#KOREf literal 0 HcmV?d00001 diff --git a/test/src/components/range_selector/golden/range_slider_rounded_disabled.png b/test/src/components/range_selector/golden/range_slider_rounded_disabled.png new file mode 100644 index 0000000000000000000000000000000000000000..4e0ab618d907b716cec1a6f5fbc9a6e17ab1335e GIT binary patch literal 3842 zcmeAS@N?(olHy`uVBq!ia0y~yU{+vYV2a>i1B%QlYbpRzjKx9jP7LeL$-D$|Sc;uI zLpXq-h9jkefq`$0r;B4q#hkZy&-YLHDtqAL`$Jg<9J@C-ZT#re%Q_`#l}dquq1>!{ z)vxVuOs;R3zD)e`l_iTDdF+Lf9yKy0PH)xKRP4Fky;ov!bK-se%awc2ou6F8H~Hro z>(6u3!{*skZn6ei%Jh4g9FU5TTOG{Lkla)F``xo0Rexu1-1@g5C)ZvAC{j|JD`Q(! zG3V#olZB6d9J`{-bb$N3U8>+3i>9Mf(l+1xar}AsG4bi^`|sYBuh(3+W=+rcb93dy z&(D|r{q5%^K8CszcBvCN1k{T2i>qrsUEkmTo4@SYu76v5zkY1z*YCM*cI|tO8AF7~ zH_dLhe?~K<&G*m!eYU9PKU?+VXWOf-gc)?Ceot{#c9?Nw|GPWV)Ai!3pZ(U0-DmbL zH}CyPABGJXZ-T`pDo6M{%AfzQIA&*6@hd}ygv`0NpP&7B(fm2M|De#U-B+sqTMZISd;#w#mQg z*i1B%QlYbpRzjKx9jP7LeL$-D$|Sc;uI zLpXq-h9jkefq~z_)5S5QV$Rz;2fO30$~HVq-e;(GhJ{C~v0;LP`hzo0bLQUN$sw|k zdtt7_CcR9i*ew_LvT!wp=xkAl^$gF{>5LNj%lyj8+RHQf#1*yGPT{dF7gT0tny+)Y zC3$O)dHl1=Yv1|yU$xwG-!@%tNA2%#`+*K~NVtB58R!=4Z`KbMtG`%x3ortJhh?)qMOC5pm%R!-vvu!OuDw5{;(o)tdRuv9PrJ|7U%4 zRbA@S&X2dU*Y{1Gs{Za*4ugUIo8@Pu%N=H%Y30t(t=uRRue(89Z%=7>I#WYsvVH2k zA54c-?Ed}ef4uYgK65j3^X*^nt&81#?DzZqufwGoHq19#F2j%_^!(i4yxZMRC6aUU z^7>X`k7#Wg>ln#bofz5Vg! zvc0b@6T_Q~>2|wq{(bvi?_;2NBOJ3cbeDN#m=p2`F-xvrAra<|1;)Te!g^N@tLWQkI$E% zt{*S6dbM_Nzft(P`Srzf?EgQzl6+?B)vHqre||bz_~*xGu+*H&OkhNqOq(`sncUNT z#cywIUFp7KxsL8T*?#$&k~ei17#F^^_;BO!cMQ2ZHh~3J{}dnU6o&0SXlGn;O@s4 z7CM*bnla1}2S)8gYXzT2A08fl{N?5RoZoxJ<9@6>D#Wla$v#!?C)1&ncXxIg&#V2m z`|+cr_W3V=0{!~&UiE(Y%{SkyJI}`O`B2@dJs(+HPM%5I{PElE=f_tD+yCnCevo4( zt?oZh;{BfDgGrnW@zD6UO|1Ppx$xVYof|i9G+Y;RQzE&i@X3i!5pn;nS(LqDPtJY2 za-};i1B%QlYbpRzjKx9jP7LeL$-D$|Sc;uI zLpXq-h9jkefq`eIr;B4q#hkZy4Y>|Eh&VWk>L;jXJp9|Ax-cPie{YTh{aO7RZP)Z+;-P=6Kyp#)##ge;U31cl+(Ht&t25;w$HU>R;0Ul$2y-Vqi#N z;b35xAgI8=;NS)loOyD2!d?0E_x+gUXH>spXL#_p@4b~Q|KxR>nNU@yeUh87@_vcq z!THgQ3IFDKyf4=|$c1Vak_9*W4CB1{8G$Zp78PJ%P*8SYU})%J016l}u`n>4;20Gg z4Uo~qFq#oYONP->akNGltqn(O!_nGsv^E^twc%$rzb7(kg;Fp2fE^14Pgg&ebxsLQ E0Q5B9i~s-t literal 0 HcmV?d00001 diff --git a/test/src/components/range_selector/golden/range_slider_sharp_disabled.png b/test/src/components/range_selector/golden/range_slider_sharp_disabled.png new file mode 100644 index 0000000000000000000000000000000000000000..0c511caf8fadfe869fbb14382dec71343a868837 GIT binary patch literal 3349 zcmeAS@N?(olHy`uVBq!ia0y~yU{+vYV2a>i1B%QlYbpRzjKx9jP7LeL$-D$|Sc;uI zLpXq-h9jkefq`egr;B4q#hkZyH})QK5OHvHFmY%p>5XM=332vdHEAok_;j`dw_97f z;`t-nz8L?`-23{i#N&;O3=AA^rpW=#i|GG*iFt$H^w+u9{yx9_{&pH;L;lmsQ|6nP zm>3vRSU4CMCI~7pFgUn102NCzf&|s(H1ho+s&>lhgBy{}k5|8e4_(l7@G zhK3#npy5VLEDQ`MeC{zDeVwhR@uOIq;mm#Emw)?IBN*Yb90Cjs3d#sAw+)Xazf=U8 zP`UxP-B=woDyV=OL!$vQ8Xyd?>^hnlMiawmV!&0IjpmNg+%cLvMso*dGk3IPphL+p ZPu)OvMgG1ID}Z^B!PC{xWt~$(695;hwaNeh literal 0 HcmV?d00001 diff --git a/test/src/components/range_selector/golden/range_slider_sharp_stepped.png b/test/src/components/range_selector/golden/range_slider_sharp_stepped.png new file mode 100644 index 0000000000000000000000000000000000000000..40c223c98dca2da9b2257dbbd6b4adbd1314dd08 GIT binary patch literal 3498 zcmeAS@N?(olHy`uVBq!ia0y~yU{+vYV2a>i1B%QlYbpRzjKx9jP7LeL$-D$|Sc;uI zLpXq-h9ji|s42wL#WAE}&fB{iy`mEt+8!ncI@&a|x&|!S9qF~uJh;es(rv*#0*WOC zCvF{dTK+Ebjjl`bedjZu&!|7VJ!$&?Cr=Xg#_i_;8r+##F z{(k5!|qehHFuxjF#K4fk0usFWLo;Ik|382Cdp{7} zR{M&b;lXd&_pe`l`s{Xn?<3Ii zqr3<``SwDYg&+)MT>e}8|U zpDiQ9w|kF2J-;IS!@+DXYxZN~Wxn?Xxc==FW!U!Lcgg#5pM$y)SiA-HYue-EAAlas zIgQ)eDU literal 0 HcmV?d00001 diff --git a/test/src/components/range_selector/range_selector_test.dart b/test/src/components/range_selector/range_selector_test.dart new file mode 100644 index 00000000..a81796b6 --- /dev/null +++ b/test/src/components/range_selector/range_selector_test.dart @@ -0,0 +1,509 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +import '../../../test_utils/test_app.dart'; +import '../../../test_utils/tolerant_comparator.dart'; +import '../../../test_utils/utils.dart'; + +void main() { + const String parentFolder = 'range_selector'; + + const goldenFile = GoldenFiles(component: parentFolder); + setUpAll(() { + goldenFileComparator = TolerantComparator(goldenFile.uri, tolerance: 0.001); + }); + + group('Accessibility Tests', () { + meetsAccessbilityGuidelinesTest( + ZetaRangeSelector( + onChange: (value) {}, + initialValues: const RangeValues(20, 80), + label: 'Range Selector', + ), + ); + }); + + group('Content Tests', () { + final debugFillProperties = { + 'label': '"Range Selector"', + 'divisions': 'null', + 'showValues': 'true', + 'rounded': 'null', + 'min': '0.0', + 'max': '100.0', + 'initialValues': 'RangeValues(20.0, 80.0)', + }; + debugFillPropertiesTest( + ZetaRangeSelector( + label: 'Range Selector', + initialValues: const RangeValues(20, 80), + onChange: (value) {}, + ), + debugFillProperties, + ); + + testWidgets('has the correct default values', (tester) async { + await tester.pumpWidget( + TestApp( + home: ZetaRangeSelector( + onChange: (value) {}, + initialValues: const RangeValues(20, 80), + ), + ), + ); + + await tester.pumpAndSettle(); + + final slider = find.byType(RangeSlider); + final sliderWidget = tester.widget(slider); + + final zetaSelector = find.byType(ZetaRangeSelector); + final zetaSelectorWidget = tester.widget(zetaSelector); + + final semantics = find.byType(Semantics).first; + final semanticsWidget = tester.widget(semantics); + + expect(zetaSelectorWidget.rounded, null); + expect(sliderWidget.values, const RangeValues(20, 80)); + expect(sliderWidget.min, 0); + expect(sliderWidget.max, 100); + expect(zetaSelectorWidget.label, null); + expect(sliderWidget.divisions, null); + expect(semanticsWidget.properties.label, null); + expect(zetaSelectorWidget.showValues, true); + }); + + testWidgets('renders two TextFormFields', (tester) async { + await tester.pumpWidget( + TestApp( + home: ZetaRangeSelector( + onChange: (value) {}, + initialValues: const RangeValues(20, 80), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byType(TextFormField), findsNWidgets(2)); + }); + + testWidgets('renders one range slider', (tester) async { + await tester.pumpWidget( + TestApp( + home: ZetaRangeSelector( + onChange: (value) {}, + initialValues: const RangeValues(20, 80), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byType(RangeSlider), findsOneWidget); + }); + + testWidgets('renders a Text widget with the correct label', (tester) async { + await tester.pumpWidget( + TestApp( + home: ZetaRangeSelector( + onChange: (value) {}, + initialValues: const RangeValues(20, 80), + label: 'Range Selector', + ), + ), + ); + + await tester.pumpAndSettle(); + + final label = find.text('Range Selector'); + expect(label, findsOneWidget); + }); + }); + + group('Dimensions Tests', () { + testWidgets('TextFormFields have the correct dimenions', (tester) async { + await tester.pumpWidget( + TestApp( + home: ZetaRangeSelector( + onChange: (value) {}, + initialValues: const RangeValues(20, 80), + ), + ), + ); + + await tester.pumpAndSettle(); + + final textFields = find.byType(TextFormField); + expect(textFields, findsNWidgets(2)); + + expect(tester.getRect(textFields.first).width, 56); + expect(tester.getRect(textFields.first).height, 48); + + expect(tester.getRect(textFields.last).width, 56); + expect(tester.getRect(textFields.last).height, 48); + }); + + // slider has the correct height + testWidgets('Slider has the correct height', (tester) async { + await tester.pumpWidget( + TestApp( + home: ZetaRangeSelector( + onChange: (value) {}, + initialValues: const RangeValues(20, 80), + ), + ), + ); + + await tester.pumpAndSettle(); + + final slider = find.byType(RangeSlider); + expect(tester.getRect(slider).height, 20); + }); + + testWidgets('Padding between TextFormFields and slider', (tester) async { + await tester.pumpWidget( + TestApp( + home: ZetaRangeSelector( + onChange: (value) {}, + initialValues: const RangeValues(20, 80), + ), + ), + ); + + await tester.pumpAndSettle(); + + final textFields = find.byType(TextFormField); + final slider = find.byType(RangeSlider); + + final textFieldRight = tester.getBottomRight(textFields.first); + final sliderLeft = tester.getBottomLeft(slider); + + expect(sliderLeft.dx - textFieldRight.dx, 32); + }); + }); + + group('Styling Tests', () { + testWidgets('Label has the correct font style', (tester) async { + await tester.pumpWidget( + TestApp( + home: ZetaRangeSelector( + onChange: (value) {}, + initialValues: const RangeValues(20, 80), + label: 'Range Selector', + ), + ), + ); + final colors = Zeta.of(tester.element(find.byType(ZetaRangeSelector))).colors; + + await tester.pumpAndSettle(); + + final label = find.text('Range Selector'); + final labelWidget = tester.widget(label); + + expect(labelWidget.style!.color, colors.textDefault); + expect(labelWidget.style!.fontSize, 14); + expect(labelWidget.style!.fontWeight, FontWeight.w400); + }); + + testWidgets('TextFormField have the correct font style', (tester) async { + await tester.pumpWidget( + TestApp( + home: ZetaRangeSelector( + onChange: (value) {}, + initialValues: const RangeValues(20, 80), + ), + ), + ); + final colors = Zeta.of(tester.element(find.byType(ZetaRangeSelector))).colors; + + await tester.pumpAndSettle(); + + final textFields = find.byType(TextField); + final firstTextFieldWidget = tester.widget(textFields.first); + + expect(firstTextFieldWidget.style!.color, colors.textSubtle); + expect(firstTextFieldWidget.style!.fontSize, 16); + expect(firstTextFieldWidget.style!.fontWeight, FontWeight.w400); + + final lastTextFieldWidget = tester.widget(textFields.last); + + expect(lastTextFieldWidget.style!.color, colors.textSubtle); + expect(lastTextFieldWidget.style!.fontSize, 16); + expect(lastTextFieldWidget.style!.fontWeight, FontWeight.w400); + }); + + testWidgets('TextFormFields have the correct border color and width', (tester) async { + await tester.pumpWidget( + TestApp( + home: ZetaRangeSelector( + onChange: (value) {}, + initialValues: const RangeValues(20, 80), + ), + ), + ); + final colors = Zeta.of(tester.element(find.byType(ZetaRangeSelector))).colors; + + await tester.pumpAndSettle(); + + final textFields = find.byType(TextField); + final firstTextFieldWidget = tester.widget(textFields.first); + + expect(firstTextFieldWidget.decoration!.border!.borderSide.color, colors.borderDefault); + expect(firstTextFieldWidget.decoration!.border!.borderSide.width, 1); + + final lastTextFieldWidget = tester.widget(textFields.last); + + expect(lastTextFieldWidget.decoration!.border!.borderSide.color, colors.borderDefault); + expect(lastTextFieldWidget.decoration!.border!.borderSide.width, 1); + }); + + // slider has the correct color + testWidgets('Slider has the correct color', (tester) async { + await tester.pumpWidget( + TestApp( + home: ZetaRangeSelector( + onChange: (value) {}, + initialValues: const RangeValues(20, 80), + ), + ), + ); + final colors = Zeta.of(tester.element(find.byType(ZetaRangeSelector))).colors; + + final sliderTheme = find.byType(SliderTheme); + final sliderThemeWidget = tester.widget(sliderTheme); + + expect(sliderThemeWidget.data.activeTrackColor, colors.surfaceDefaultInverse); + }); + }); + + group('Interaction Tests', () { + testWidgets('onChange callback is called when value is changed', (tester) async { + double? firstValue; + double? secondValue; + await tester.pumpWidget( + TestApp( + home: Padding( + padding: const EdgeInsets.all(32), + child: ZetaRangeSelector( + label: 'Range Selector', + initialValues: const RangeValues(20, 80), + showValues: false, + onChange: (newValue) { + secondValue = newValue.end; + firstValue = newValue.start; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + final slider = find.byType(RangeSlider); + + final sliderTopLeft = tester.getTopLeft(slider); + + await tester.dragFrom(sliderTopLeft.translate(0, 0), const Offset(250, 0)); + + await tester.pumpAndSettle(); + + await tester.dragFrom(sliderTopLeft.translate(700, 0), const Offset(-250, 0)); + + await tester.pumpAndSettle(); + + expect(firstValue!.round(), 34); + expect(secondValue!.round(), 61); + }); + + testWidgets('TextFormFields set the value of the slider', (tester) async { + RangeValues values = const RangeValues(20, 80); + await tester.pumpWidget( + TestApp( + home: Padding( + padding: const EdgeInsets.all(32), + child: ZetaRangeSelector( + label: 'Range Selector', + initialValues: values, + onChange: (newValues) { + values = newValues; + }, + ), + ), + ), + ); + + final firstTextFinder = find.ancestor(of: find.text('20'), matching: find.byType(TextFormField)); + final secondTextFinder = find.ancestor(of: find.text('80'), matching: find.byType(TextFormField)); + + await tester.pumpAndSettle(); + await tester.enterText(firstTextFinder, '40'); + await tester.testTextInput.receiveAction(TextInputAction.done); + + await tester.enterText(secondTextFinder, '60'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + expect(values, const RangeValues(40, 60)); + }); + + testWidgets('TextFormFields set the value of the slider on tap outside', (tester) async { + RangeValues values = const RangeValues(20, 80); + await tester.pumpWidget( + TestApp( + home: Padding( + padding: const EdgeInsets.all(32), + child: ZetaRangeSelector( + label: 'Range Selector', + initialValues: values, + onChange: (newValues) { + values = newValues; + }, + ), + ), + ), + ); + + final firstTextFinder = find.ancestor(of: find.text('20'), matching: find.byType(TextFormField)); + final secondTextFinder = find.ancestor(of: find.text('80'), matching: find.byType(TextFormField)); + + await tester.pumpAndSettle(); + await tester.enterText(firstTextFinder, '30'); + await tester.pump(); + await tester.tapAt(Offset.zero); + await tester.pump(); + + await tester.pumpAndSettle(); + await tester.enterText(secondTextFinder, '70'); + await tester.pump(); + await tester.tapAt(Offset.zero); + await tester.pump(); + + expect(values, const RangeValues(30, 70)); + }); + }); + + group('Golden Tests', () { + goldenTest( + goldenFile, + ZetaRangeSelector( + onChange: (value) {}, + initialValues: const RangeValues(20, 80), + showValues: false, + ), + 'range_slider_rounded_continuous', + ); + goldenTest( + goldenFile, + ZetaRangeSelector( + onChange: (value) {}, + initialValues: const RangeValues(20, 80), + showValues: false, + divisions: 10, + ), + 'range_slider_rounded_stepped', + ); + goldenTest( + goldenFile, + ZetaRangeSelector( + initialValues: const RangeValues(20, 80), + showValues: false, + ), + 'range_slider_rounded_disabled', + ); + + goldenTest( + goldenFile, + ZetaRangeSelector( + onChange: (value) {}, + initialValues: const RangeValues(20, 80), + showValues: false, + rounded: false, + ), + 'range_slider_sharp_continuous', + ); + goldenTest( + goldenFile, + ZetaRangeSelector( + onChange: (value) {}, + initialValues: const RangeValues(20, 80), + showValues: false, + divisions: 10, + rounded: false, + ), + 'range_slider_sharp_stepped', + ); + goldenTest( + goldenFile, + ZetaRangeSelector( + initialValues: const RangeValues(20, 80), + showValues: false, + rounded: false, + ), + 'range_slider_sharp_disabled', + ); + + goldenTest( + goldenFile, + ZetaRangeSelector( + onChange: (value) {}, + initialValues: const RangeValues(20, 80), + label: 'Label', + ), + 'range_selector_rounded_continuous', + ); + goldenTest( + goldenFile, + ZetaRangeSelector( + onChange: (value) {}, + initialValues: const RangeValues(20, 80), + label: 'Label', + divisions: 10, + ), + 'range_selector_rounded_stepped', + ); + goldenTest( + goldenFile, + ZetaRangeSelector( + initialValues: const RangeValues(20, 80), + label: 'Label', + divisions: 10, + ), + 'range_selector_rounded_disabled', + ); + + goldenTest( + goldenFile, + ZetaRangeSelector( + onChange: (value) {}, + initialValues: const RangeValues(20, 80), + label: 'Label', + rounded: false, + ), + 'range_selector_sharp_continuous', + ); + goldenTest( + goldenFile, + ZetaRangeSelector( + onChange: (value) {}, + initialValues: const RangeValues(20, 80), + label: 'Label', + rounded: false, + divisions: 10, + ), + 'range_selector_sharp_stepped', + ); + goldenTest( + goldenFile, + ZetaRangeSelector( + initialValues: const RangeValues(20, 80), + label: 'Label', + rounded: false, + divisions: 10, + ), + 'range_selector_sharp_disabled', + ); + }); + + group('Performance Tests', () {}); +} diff --git a/test/test_utils/utils.dart b/test/test_utils/utils.dart index 406d598e..bb35d331 100644 --- a/test/test_utils/utils.dart +++ b/test/test_utils/utils.dart @@ -88,3 +88,39 @@ void debugFillPropertiesTest(Widget widget, Map properties) { }); }); } + +void meetsAccessbilityGuidelinesTest( + Widget widget, { + ThemeMode themeMode = ThemeMode.system, + Size? screenSize, + bool? rounded, + Future Function(WidgetTester)? setUp, + Future Function(WidgetTester)? beforeTest, +}) { + testWidgets('meets accessibility requirements', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + if (setUp != null) { + await setUp(tester); + } + + await tester.pumpWidget( + TestApp( + screenSize: screenSize, + themeMode: themeMode, + rounded: rounded, + home: widget, + ), + ); + + if (beforeTest != null) { + await beforeTest(tester); + } + + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(iOSTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + handle.dispose(); + }); +}