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 00000000..536ef9a2 Binary files /dev/null and b/test/src/components/range_selector/golden/range_selector_rounded_continuous.png differ diff --git a/test/src/components/range_selector/golden/range_selector_rounded_disabled.png b/test/src/components/range_selector/golden/range_selector_rounded_disabled.png new file mode 100644 index 00000000..921e12ed Binary files /dev/null and b/test/src/components/range_selector/golden/range_selector_rounded_disabled.png differ 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 00000000..f4a64e7b Binary files /dev/null and b/test/src/components/range_selector/golden/range_selector_rounded_stepped.png differ 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 00000000..e263b955 Binary files /dev/null and b/test/src/components/range_selector/golden/range_selector_sharp_continuous.png differ diff --git a/test/src/components/range_selector/golden/range_selector_sharp_disabled.png b/test/src/components/range_selector/golden/range_selector_sharp_disabled.png new file mode 100644 index 00000000..79fd83d6 Binary files /dev/null and b/test/src/components/range_selector/golden/range_selector_sharp_disabled.png differ diff --git a/test/src/components/range_selector/golden/range_selector_sharp_stepped.png b/test/src/components/range_selector/golden/range_selector_sharp_stepped.png new file mode 100644 index 00000000..4a91d75e Binary files /dev/null and b/test/src/components/range_selector/golden/range_selector_sharp_stepped.png differ 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 00000000..3ec1c020 Binary files /dev/null and b/test/src/components/range_selector/golden/range_slider_rounded_continuous.png differ 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 00000000..4e0ab618 Binary files /dev/null and b/test/src/components/range_selector/golden/range_slider_rounded_disabled.png differ diff --git a/test/src/components/range_selector/golden/range_slider_rounded_stepped.png b/test/src/components/range_selector/golden/range_slider_rounded_stepped.png new file mode 100644 index 00000000..e7b1bd39 Binary files /dev/null and b/test/src/components/range_selector/golden/range_slider_rounded_stepped.png differ diff --git a/test/src/components/range_selector/golden/range_slider_sharp_continuous.png b/test/src/components/range_selector/golden/range_slider_sharp_continuous.png new file mode 100644 index 00000000..db3e30df Binary files /dev/null and b/test/src/components/range_selector/golden/range_slider_sharp_continuous.png differ 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 00000000..0c511caf Binary files /dev/null and b/test/src/components/range_selector/golden/range_slider_sharp_disabled.png differ 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 00000000..40c223c9 Binary files /dev/null and b/test/src/components/range_selector/golden/range_slider_sharp_stepped.png differ 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(); + }); +}