diff --git a/example/lib/home.dart b/example/lib/home.dart index a3e4f41e..63c48d63 100644 --- a/example/lib/home.dart +++ b/example/lib/home.dart @@ -27,6 +27,7 @@ import 'package:zeta_example/pages/components/select_input_example.dart'; import 'package:zeta_example/pages/components/search_bar_example.dart'; import 'package:zeta_example/pages/components/segmented_control_example.dart'; import 'package:zeta_example/pages/components/stepper_example.dart'; +import 'package:zeta_example/pages/components/stepper_input_example.dart'; import 'package:zeta_example/pages/components/switch_example.dart'; import 'package:zeta_example/pages/components/snackbar_example.dart'; import 'package:zeta_example/pages/components/tabs_example.dart'; @@ -84,6 +85,7 @@ final List components = [ Component(SelectInputExample.name, (context) => const SelectInputExample()), Component(ScreenHeaderBarExample.name, (context) => const ScreenHeaderBarExample()), Component(FilterSelectionExample.name, (context) => const FilterSelectionExample()), + Component(StepperInputExample.name, (context) => const StepperInputExample()), ]; final List theme = [ diff --git a/example/lib/pages/components/stepper_input_example.dart b/example/lib/pages/components/stepper_input_example.dart new file mode 100644 index 00000000..f03fa351 --- /dev/null +++ b/example/lib/pages/components/stepper_input_example.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:zeta_example/widgets.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +class StepperInputExample extends StatefulWidget { + static const name = 'StepperInput'; + + const StepperInputExample({super.key}); + + @override + State createState() => _StepperInputExampleState(); +} + +class _StepperInputExampleState extends State { + @override + Widget build(BuildContext context) { + return ExampleScaffold( + name: StepperInputExample.name, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ZetaStepperInput( + min: 0, + max: 10, + initialValue: 5, + onChange: (_) {}, + ), + ZetaStepperInput(rounded: false), + ZetaStepperInput( + size: ZetaStepperInputSize.large, + onChange: (_) {}, + ), + ].divide(const SizedBox(height: 16)).toList(), + ), + ), + ); + } +} diff --git a/example/widgetbook/main.dart b/example/widgetbook/main.dart index 1f9b73d1..477af5ce 100644 --- a/example/widgetbook/main.dart +++ b/example/widgetbook/main.dart @@ -33,6 +33,7 @@ import 'pages/components/screen_header_bar_widgetbook.dart'; import 'pages/components/search_bar_widgetbook.dart'; import 'pages/components/segmented_control_widgetbook.dart'; import 'pages/components/select_input_widgetbook.dart'; +import 'pages/components/stepper_input_widgetbook.dart'; import 'pages/components/stepper_widgetbook.dart'; import 'pages/components/switch_widgetbook.dart'; import 'pages/components/snack_bar_widgetbook.dart'; @@ -141,6 +142,10 @@ class HotReload extends StatelessWidget { name: 'Stepper', builder: (context) => stepperUseCase(context), ), + WidgetbookUseCase( + name: 'Stepper Input', + builder: (context) => stepperInputUseCase(context), + ), WidgetbookUseCase(name: 'Dialog', builder: (context) => dialogUseCase(context)), WidgetbookUseCase(name: 'Search Bar', builder: (context) => searchBarUseCase(context)), WidgetbookUseCase(name: 'Navigation Rail', builder: (context) => navigationRailUseCase(context)), diff --git a/example/widgetbook/pages/components/stepper_input_widgetbook.dart b/example/widgetbook/pages/components/stepper_input_widgetbook.dart new file mode 100644 index 00000000..e57fb11f --- /dev/null +++ b/example/widgetbook/pages/components/stepper_input_widgetbook.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +import '../../test/test_components.dart'; +import '../../utils/utils.dart'; + +Widget stepperInputUseCase(BuildContext context) { + return WidgetbookTestWidget( + widget: ZetaStepperInput( + initialValue: context.knobs.int.input(label: 'Initial value'), + min: context.knobs.int.input(label: 'Minimum value'), + max: context.knobs.int.input(label: 'Maximum value'), + size: context.knobs.list( + label: 'Size', + options: ZetaStepperInputSize.values, + labelBuilder: enumLabelBuilder, + ), + rounded: context.knobs.boolean(label: 'Rounded', initialValue: true), + onChange: context.knobs.boolean(label: 'Disabled', initialValue: false) ? null : (_) {}, + ), + ); +} diff --git a/lib/src/components/buttons/button_style.dart b/lib/src/components/buttons/button_style.dart index 8a3ed9fb..b37967bb 100644 --- a/lib/src/components/buttons/button_style.dart +++ b/lib/src/components/buttons/button_style.dart @@ -134,7 +134,7 @@ ButtonStyle buttonStyle( }), side: MaterialStateProperty.resolveWith((Set states) { if (type.border && states.contains(MaterialState.disabled)) { - return BorderSide(color: colors.cool.shade40); + return BorderSide(color: colors.borderDisabled); } // TODO(thelukewalton): This removes a defualt border when focused, rather than adding a second border when focused. if (states.contains(MaterialState.focused)) { @@ -142,7 +142,7 @@ ButtonStyle buttonStyle( } if (type.border) { return BorderSide( - color: type == ZetaButtonType.outline ? colors.primary.border : colors.borderDefault, + color: type == ZetaButtonType.outline ? colors.primary.border : colors.borderSubtle, ); } diff --git a/lib/src/components/stepper_input/stepper_input.dart b/lib/src/components/stepper_input/stepper_input.dart new file mode 100644 index 00000000..417cc1ff --- /dev/null +++ b/lib/src/components/stepper_input/stepper_input.dart @@ -0,0 +1,191 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../zeta_flutter.dart'; + +/// Sizes for [ZetaStepperInput] +enum ZetaStepperInputSize { + /// Medium + medium, + + /// Large + large, +} + +/// A stepper input, also called numeric stepper, is a common UI element that allows uers to input a number or value simply by clicking the plus and minus buttons. +class ZetaStepperInput extends StatefulWidget { + /// Creates a new [ZetaStepperInput] + const ZetaStepperInput({ + this.rounded = true, + this.size = ZetaStepperInputSize.medium, + this.initialValue, + this.min, + this.max, + this.onChange, + super.key, + }) : assert( + (min == null || (initialValue ?? 0) >= min) && (max == null || (initialValue ?? 0) <= max), + 'Initial value must be inside given min and max values', + ); + + /// {@macro zeta-component-rounded} + final bool rounded; + + /// The size of the stepper input. + final ZetaStepperInputSize size; + + /// The initial value of the stepper input. + /// + /// Must be in the bounds of [min] and [max] (if given). + final int? initialValue; + + /// The minimum value of the stepper input. + final int? min; + + /// The maximum value of the stepper input. + final int? max; + + /// Called with the value of the stepper whenever it is changed. + final ValueChanged? onChange; + + @override + State createState() => _ZetaStepperInputState(); + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('rounded', rounded)) + ..add(EnumProperty('size', size)) + ..add(IntProperty('initialValue', initialValue)) + ..add(IntProperty('min', min)) + ..add(IntProperty('max', max)) + ..add(ObjectFlagProperty?>.has('onChange', onChange)); + } +} + +class _ZetaStepperInputState extends State { + final TextEditingController _controller = TextEditingController(); + int _value = 0; + late final bool _disabled; + + @override + void initState() { + super.initState(); + _disabled = widget.onChange == null; + if (widget.initialValue != null) { + _value = widget.initialValue!; + } + _controller.text = _value.toString(); + } + + @override + void dispose() { + super.dispose(); + _controller.dispose(); + } + + InputBorder get _border { + final colors = Zeta.of(context).colors; + + return OutlineInputBorder( + borderSide: BorderSide( + color: !_disabled ? colors.borderSubtle : colors.borderDisabled, + ), + borderRadius: widget.rounded ? ZetaRadius.minimal : ZetaRadius.none, + ); + } + + double get _height { + if (widget.size != ZetaStepperInputSize.large) { + return ZetaSpacing.x10; + } else { + return ZetaSpacing.x12; + } + } + + void _onTextChange(String value) { + int? val = int.tryParse(value); + if (val != null) { + if (widget.max != null && val > widget.max!) { + val = widget.max; + } + if (widget.min != null && val! < widget.min!) { + val = widget.min; + } + _onChange(val!); + } + } + + void _onChange(int value) { + if (!(widget.max != null && value > widget.max! || widget.min != null && value < widget.min!)) { + setState(() { + _value = value; + }); + _controller.text = value.toString(); + widget.onChange?.call(value); + } + } + + ZetaIconButton _getButton({bool increase = false}) { + return ZetaIconButton( + icon: increase + ? widget.rounded + ? ZetaIcons.add_round + : ZetaIcons.add_sharp + : widget.rounded + ? ZetaIcons.remove_round + : ZetaIcons.remove_sharp, + type: ZetaButtonType.outlineSubtle, + size: widget.size == ZetaStepperInputSize.medium ? ZetaWidgetSize.medium : ZetaWidgetSize.large, + borderType: widget.rounded ? ZetaWidgetBorder.rounded : ZetaWidgetBorder.sharp, + onPressed: !_disabled + ? () => _onChange( + _value + (increase ? 1 : -1), + ) + : null, + ); + } + + @override + Widget build(BuildContext context) { + final colors = Zeta.of(context).colors; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + _getButton(), + SizedBox( + width: ZetaSpacing.xl, + child: TextFormField( + keyboardType: TextInputType.number, + enabled: !_disabled, + controller: _controller, + onChanged: _onTextChange, + textAlign: TextAlign.center, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: _disabled ? colors.textDisabled : null, + ), + onTapOutside: (_) { + if (_controller.text.isEmpty) { + _controller.text = _value.toString(); + } + }, + decoration: InputDecoration( + filled: true, + fillColor: _disabled ? colors.surfaceDisabled : null, + contentPadding: EdgeInsets.zero, + constraints: BoxConstraints(maxHeight: _height), + border: _border, + focusedBorder: _border, + enabledBorder: _border, + disabledBorder: _border, + ), + ), + ), + _getButton(increase: true), + ].divide(const SizedBox(width: ZetaSpacing.x2)).toList(), + ); + } +} diff --git a/lib/zeta_flutter.dart b/lib/zeta_flutter.dart index 612034ee..93af5218 100644 --- a/lib/zeta_flutter.dart +++ b/lib/zeta_flutter.dart @@ -44,6 +44,7 @@ export 'src/components/segmented_control/segmented_control.dart'; export 'src/components/select_input/select_input.dart'; export 'src/components/snack_bar/snack_bar.dart'; export 'src/components/stepper/stepper.dart'; +export 'src/components/stepper_input/stepper_input.dart'; export 'src/components/switch/zeta_switch.dart'; export 'src/components/tabs/tab.dart'; export 'src/components/tabs/tab_bar.dart';