From fb7b6295dfc507a0a94de28cc7b0d11f338db13e Mon Sep 17 00:00:00 2001 From: mikecoomber <58986130+mikecoomber@users.noreply.github.com> Date: Thu, 16 May 2024 16:06:25 +0100 Subject: [PATCH] feat: Created Time Input (#75) * time input * sizes * refactor as form input * lint stuff * refactoring with input mask * docs, fixed sizes * widget book --- example/lib/home.dart | 2 + .../pages/components/time_input_example.dart | 67 +++ example/widgetbook/main.dart | 2 + .../pages/components/time_input.dart | 48 ++ lib/src/components/date_input/date_input.dart | 14 +- lib/src/components/stepper/stepper.dart | 2 +- lib/src/components/time_input/time_input.dart | 471 ++++++++++++++++++ lib/src/utils/extensions.dart | 14 + lib/zeta_flutter.dart | 1 + 9 files changed, 608 insertions(+), 13 deletions(-) create mode 100644 example/lib/pages/components/time_input_example.dart create mode 100644 example/widgetbook/pages/components/time_input.dart create mode 100644 lib/src/components/time_input/time_input.dart diff --git a/example/lib/home.dart b/example/lib/home.dart index 0ff969ac..5bf66363 100644 --- a/example/lib/home.dart +++ b/example/lib/home.dart @@ -33,6 +33,7 @@ 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'; import 'package:zeta_example/pages/components/pagination_example.dart'; +import 'package:zeta_example/pages/components/time_input_example.dart'; import 'package:zeta_example/pages/components/tooltip_example.dart'; import 'package:zeta_example/pages/theme/color_example.dart'; import 'package:zeta_example/pages/components/password_input_example.dart'; @@ -88,6 +89,7 @@ final List components = [ Component(ScreenHeaderBarExample.name, (context) => const ScreenHeaderBarExample()), Component(FilterSelectionExample.name, (context) => const FilterSelectionExample()), Component(StepperInputExample.name, (context) => const StepperInputExample()), + Component(TimeInputExample.name, (context) => const TimeInputExample()), ]; final List theme = [ diff --git a/example/lib/pages/components/time_input_example.dart b/example/lib/pages/components/time_input_example.dart new file mode 100644 index 00000000..1c973e31 --- /dev/null +++ b/example/lib/pages/components/time_input_example.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:zeta_example/widgets.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +class TimeInputExample extends StatelessWidget { + static const name = 'TimeInput'; + + const TimeInputExample({super.key}); + + @override + Widget build(BuildContext context) { + final formKey = GlobalKey(); + return ExampleScaffold( + name: 'Time Input', + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Form( + key: formKey, + child: Column( + children: [ + ZetaButton( + label: 'Validate inputs', + onPressed: () => print(formKey.currentState?.validate()), + ), + ZetaTimeInput( + label: 'Large', + hint: 'Default hint text', + onChange: (value) => print(value), + errorText: 'Oops! Error hint text', + size: ZetaWidgetSize.large, + ), + ZetaTimeInput( + label: 'Medium', + hint: 'Default hint text', + onChange: (value) => print(value), + errorText: 'Oops! Error hint text', + size: ZetaWidgetSize.medium, + ), + ZetaTimeInput( + label: 'Small', + hint: 'Default hint text', + onChange: (value) => print(value), + errorText: 'Oops! Error hint text', + size: ZetaWidgetSize.small, + ), + ].divide(const SizedBox(height: 12)).toList(), + ), + ), + const SizedBox( + height: 48, + ), + ZetaTimeInput(label: '12 Hr Time Picker', use12Hr: true), + ZetaTimeInput(label: 'Disabled Time Picker', disabled: true, hint: 'Disabled time picker'), + ZetaTimeInput(label: 'Sharp Time Picker', rounded: false), + ].divide(const SizedBox(height: 12)).toList(), + ), + ), + ), + ), + ); + } +} diff --git a/example/widgetbook/main.dart b/example/widgetbook/main.dart index db897fd3..f8cf98e3 100644 --- a/example/widgetbook/main.dart +++ b/example/widgetbook/main.dart @@ -39,6 +39,7 @@ import 'pages/components/stepper_widgetbook.dart'; import 'pages/components/switch_widgetbook.dart'; import 'pages/components/snack_bar_widgetbook.dart'; import 'pages/components/tabs_widgetbook.dart'; +import 'pages/components/time_input.dart'; import 'pages/components/tooltip_widgetbook.dart'; import 'pages/theme/color_widgetbook.dart'; import 'pages/theme/radius_widgetbook.dart'; @@ -137,6 +138,7 @@ class HotReload extends StatelessWidget { WidgetbookUseCase(name: 'Select Input', builder: (context) => selectInputUseCase(context)), WidgetbookUseCase(name: 'Screen Header Bar', builder: (context) => screenHeaderBarUseCase(context)), WidgetbookUseCase(name: 'Filter Selection', builder: (context) => filterSelectionUseCase(context)), + WidgetbookUseCase(name: 'Time Input', builder: (context) => timeInputUseCase(context)), ]..sort((a, b) => a.name.compareTo(b.name)), ), WidgetbookCategory( diff --git a/example/widgetbook/pages/components/time_input.dart b/example/widgetbook/pages/components/time_input.dart new file mode 100644 index 00000000..32b9c547 --- /dev/null +++ b/example/widgetbook/pages/components/time_input.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +import '../../test/test_components.dart'; + +Widget timeInputUseCase(BuildContext context) { + String? _errorText; + + return WidgetbookTestWidget( + widget: StatefulBuilder( + builder: (context, setState) { + final label = context.knobs.string( + label: 'Label', + initialValue: 'Label', + ); + final errorText = context.knobs.string( + label: 'Error message', + initialValue: 'Oops! Error hint text', + ); + final hintText = context.knobs.string( + label: 'Hint', + initialValue: 'Default hint text', + ); + final rounded = context.knobs.boolean(label: 'Rounded', initialValue: true); + final disabled = context.knobs.boolean(label: 'Disabled', initialValue: false); + final size = context.knobs.list( + label: 'Size', + options: ZetaWidgetSize.values, + labelBuilder: (size) => size.name, + ); + + return Padding( + padding: const EdgeInsets.all(ZetaSpacing.x5), + child: ZetaTimeInput( + size: size, + rounded: rounded, + disabled: disabled, + label: label, + hint: hintText, + errorText: _errorText ?? errorText, + onChange: (value) {}, + ), + ); + }, + ), + ); +} diff --git a/lib/src/components/date_input/date_input.dart b/lib/src/components/date_input/date_input.dart index a6eb440d..3f1e85e0 100644 --- a/lib/src/components/date_input/date_input.dart +++ b/lib/src/components/date_input/date_input.dart @@ -57,7 +57,7 @@ class ZetaDateInput extends StatefulWidget { /// Determines if the input field should be enabled (default) or disabled. final bool enabled; - /// Determines if the input field corners are rounded (default) or sharp. + /// {@macro zeta-component-rounded} final bool rounded; /// Determines if the input field should be displayed in error style. @@ -181,6 +181,7 @@ class _ZetaDateInputState extends State { ), TextFormField( enabled: widget.enabled, + autovalidateMode: AutovalidateMode.onUserInteraction, controller: _controller, inputFormatters: [_dateFormatter], keyboardType: TextInputType.number, @@ -316,14 +317,3 @@ class _ZetaDateInputState extends State { borderSide: BorderSide(color: zeta.colors.red.shade50), ); } - -extension on DateFormat { - //NOTE: this function is a part of intl 0.19.0. - DateTime? tryParseStrict(String inputString) { - try { - return parseStrict(inputString); - } on FormatException { - return null; - } - } -} diff --git a/lib/src/components/stepper/stepper.dart b/lib/src/components/stepper/stepper.dart index 25534fc9..0669592b 100644 --- a/lib/src/components/stepper/stepper.dart +++ b/lib/src/components/stepper/stepper.dart @@ -28,7 +28,7 @@ class ZetaStepper extends StatefulWidget { /// an argument. final ValueChanged? onStepTapped; - /// Whether the icons of the horizontal stepper to be rounded or square. + /// {@macro zeta-component-rounded} final bool rounded; /// The steps of the stepper whose titles, subtitles, icons always get shown. diff --git a/lib/src/components/time_input/time_input.dart b/lib/src/components/time_input/time_input.dart new file mode 100644 index 00000000..3e6805a8 --- /dev/null +++ b/lib/src/components/time_input/time_input.dart @@ -0,0 +1,471 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:mask_text_input_formatter/mask_text_input_formatter.dart'; + +import '../../../zeta_flutter.dart'; + +const _maxHrValue = 23; +const _max12HrValue = 12; +const _maxMinsValue = 59; + +/// A form field used to input time. +/// +/// Can be used and validated the same way as a [TextFormField] +class ZetaTimeInput extends StatefulWidget { + /// Creates a new [ZetaTimeInput] + const ZetaTimeInput({ + super.key, + this.rounded = true, + this.use12Hr, + this.disabled = false, + this.initialValue, + this.onChange, + this.label, + this.hint, + this.errorText, + this.validator, + this.size = ZetaWidgetSize.medium, + }); + + /// {@macro zeta-component-rounded} + final bool rounded; + + /// Changes the time input to 12 hour time. + /// Uses the device default if not set. + final bool? use12Hr; + + /// Called when the input changes. + /// Null is passed to this function if the current value is an invalid time. + final ValueChanged? onChange; + + /// The inital value of the input. + final TimeOfDay? initialValue; + + /// Disables the input. + final bool disabled; + + /// The label for the input. + final String? label; + + /// The hint displayed below the input. + final String? hint; + + /// The error displayed below the input. + final String? errorText; + + /// The size of the input. + final ZetaWidgetSize size; + + /// The validator passed to the text input. + /// Returns a string containing an error message. + /// + /// By default, the form field checks for null and invalid hour or minute values. + /// If the default validation fails, [errorText] will be shown. + /// However, if [validator] catches any of these conditions, the return value of [validator] will be shown. + final String? Function(TimeOfDay? value)? validator; + + @override + State createState() => ZetaTimeInputState(); + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('rounded', rounded)) + ..add(DiagnosticsProperty('use12Hr', use12Hr)) + ..add(ObjectFlagProperty?>.has('onChange', onChange)) + ..add(DiagnosticsProperty('initialValue', initialValue)) + ..add(DiagnosticsProperty('disabled', disabled)) + ..add(StringProperty('label', label)) + ..add(StringProperty('hintText', hint)) + ..add(StringProperty('errorText', errorText)) + ..add(EnumProperty('size', size)) + ..add(ObjectFlagProperty.has('validator', validator)); + } +} + +/// State for [ZetaTimeInput] +class ZetaTimeInputState extends State { + // TODO(mikecoomber): add AM/PM selector inline. + + ZetaColors get _colors => Zeta.of(context).colors; + + final _timeFormat = 'hh:mm'; // TODO(UX-1003): needs localizing. + late final MaskTextInputFormatter _timeFormatter; + + bool _firstBuildComplete = false; + bool get _use12Hr => widget.use12Hr ?? !MediaQuery.of(context).alwaysUse24HourFormat; + + final _controller = TextEditingController(); + final GlobalKey> _key = GlobalKey(); + + bool _hovered = false; + String? _errorText; + + /// Returns true if the input contains a valid [TimeOfDay] + bool get isValid => _errorText != null; + + bool get _showClearButton => _controller.text.isNotEmpty; + + Color get _backgroundColor { + if (widget.disabled) { + return _colors.surfaceDisabled; + } + if (_errorText != null) { + return _colors.error.shade10; + } + return _colors.surfacePrimary; + } + + Color get _textColor { + if (widget.disabled) { + return _colors.textDisabled; + } + return _colors.textSubtle; + } + + TextStyle get _textStyle { + TextStyle style = ZetaTextStyles.bodyMedium; + if (widget.size == ZetaWidgetSize.small) { + style = ZetaTextStyles.bodyXSmall; + } + return style.copyWith( + color: _textColor, + ); + } + + double get _iconSize { + switch (widget.size) { + case ZetaWidgetSize.large: + return ZetaSpacing.x6; + case ZetaWidgetSize.medium: + return ZetaSpacing.x5; + case ZetaWidgetSize.small: + return ZetaSpacing.x4; + } + } + + int get _hrsLimit => _use12Hr ? _max12HrValue : _maxHrValue; + final int _minsLimit = _maxMinsValue; + + TimeOfDay? get _value { + final splitValue = _timeFormatter.getMaskedText().trim().split(':'); + if (splitValue.length > 1) { + final hrsValue = int.tryParse(splitValue[0]); + final minsValue = int.tryParse(splitValue[1]); + if (hrsValue != null && minsValue != null) { + return TimeOfDay(hour: hrsValue, minute: minsValue); + } + } + return null; + } + + OutlineInputBorder get _baseBorder => OutlineInputBorder( + borderRadius: widget.rounded ? ZetaRadius.minimal : ZetaRadius.none, + borderSide: BorderSide(color: _hovered ? _colors.borderSelected : _colors.borderSubtle), + ); + + OutlineInputBorder get _focusedBorder => _baseBorder.copyWith( + borderSide: BorderSide(color: _colors.primary.shade50, width: ZetaSpacing.x0_5), + ); // TODO(mikecoomber): change to colors.borderPrimary when added + + OutlineInputBorder get _errorBorder => _baseBorder.copyWith( + borderSide: BorderSide(color: _colors.error, width: ZetaSpacing.x0_5), + ); + + @override + void initState() { + _timeFormatter = MaskTextInputFormatter( + mask: _timeFormat.replaceAll(RegExp('[a-z]'), '#'), + filter: {'#': RegExp('[0-9]')}, + type: MaskAutoCompletionType.eager, + ); + + if (widget.initialValue != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _key.currentState?.validate(); + }); + } + super.initState(); + } + + @override + void dispose() { + _controller.dispose(); + + super.dispose(); + } + + void _onChange() { + if (_timeFormatter.getUnmaskedText().length > 3 && (_key.currentState?.validate() ?? false)) { + widget.onChange?.call(_value); + } else { + widget.onChange?.call(null); + } + setState(() {}); + } + + void _setText(TimeOfDay value) { + final hrsValue = _use12Hr && value.hour > _hrsLimit ? value.hour - _hrsLimit : value.hour; + + final hrText = hrsValue.toString().padLeft(2, '0'); + final minText = value.minute.toString().padLeft(2, '0'); + + _controller.text = _timeFormatter.maskText(hrText + minText); + _timeFormatter.formatEditUpdate(TextEditingValue.empty, _controller.value); + } + + Future _pickTime() async { + final result = await showTimePicker( + context: context, + initialTime: _value ?? TimeOfDay.now(), + builder: (BuildContext context, Widget? child) { + return MediaQuery( + data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: !_use12Hr), + child: child!, + ); + }, + ); + if (result != null) { + _setText(result); + } + } + + void _clear() { + _timeFormatter.clear(); + _key.currentState?.reset(); + setState(() { + _errorText = null; + }); + _controller.clear(); + widget.onChange?.call(null); + } + + @override + Widget build(BuildContext context) { + if (!_firstBuildComplete && widget.initialValue != null) { + _setText(widget.initialValue!); + _firstBuildComplete = true; + } + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.label != null) ...[ + Text( + widget.label!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: _textColor), + ), + const SizedBox(height: ZetaSpacing.x2), + ], + MouseRegion( + onEnter: !widget.disabled + ? (_) => setState(() { + _hovered = true; + }) + : null, + onExit: !widget.disabled + ? (_) => setState(() { + _hovered = false; + }) + : null, + child: TextFormField( + enabled: !widget.disabled, + key: _key, + controller: _controller, + inputFormatters: [ + _timeFormatter, + ], + validator: (_) { + final customValidation = widget.validator?.call(_value); + if (_value == null || + _value!.hour > _hrsLimit || + _value!.minute > _minsLimit || + customValidation != null) { + setState(() { + _errorText = customValidation ?? widget.errorText ?? ''; + }); + return ''; + } + + setState(() { + _errorText = null; + }); + return null; + }, + textAlignVertical: TextAlignVertical.center, + onChanged: (_) => _onChange(), + style: _textStyle, + decoration: InputDecoration( + isDense: true, + contentPadding: const EdgeInsets.only(left: ZetaSpacing.x3), + filled: true, + suffixIconConstraints: BoxConstraints( + maxHeight: _iconSize * 2, + minWidth: _iconSize * 2, + ), + suffixIcon: Row( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + if (_showClearButton) + _IconButton( + icon: widget.rounded ? ZetaIcons.cancel_round : ZetaIcons.cancel_sharp, + onTap: _clear, + disabled: widget.disabled, + size: _iconSize, + color: _colors.iconSubtle, + ), + _IconButton( + icon: widget.rounded ? ZetaIcons.clock_outline_round : ZetaIcons.clock_outline_sharp, + onTap: _pickTime, + disabled: widget.disabled, + size: _iconSize, + color: _colors.iconDefault, + ), + ], + ), + focusColor: _backgroundColor, + hoverColor: _backgroundColor, + fillColor: _backgroundColor, + enabledBorder: _baseBorder, + disabledBorder: _baseBorder, + focusedBorder: _focusedBorder, + focusedErrorBorder: _errorBorder, + errorBorder: _errorBorder, + hintText: _timeFormat, + hintStyle: _textStyle, + errorStyle: const TextStyle(height: 0), + ), + ), + ), + _HintText( + disabled: widget.disabled, + rounded: widget.rounded, + hintText: widget.hint, + errorText: _errorText, + ), + ], + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('isValid', isValid)); + } +} + +class _IconButton extends StatelessWidget { + const _IconButton({ + required this.icon, + required this.onTap, + required this.disabled, + required this.size, + required this.color, + }); + + final IconData icon; + final VoidCallback onTap; + final bool disabled; + final double size; + final Color color; + + @override + Widget build(BuildContext context) { + final colors = Zeta.of(context).colors; + + return IconButton( + padding: EdgeInsets.all(size / 2), + constraints: BoxConstraints( + maxHeight: size * 2, + maxWidth: size * 2, + ), + color: !disabled ? color : colors.iconDisabled, + onPressed: disabled ? null : onTap, + iconSize: size, + icon: Icon(icon), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('icon', icon)) + ..add(ObjectFlagProperty.has('onTap', onTap)) + ..add(DiagnosticsProperty('disabled', disabled)) + ..add(DoubleProperty('size', size)) + ..add(ColorProperty('color', color)); + } +} + +class _HintText extends StatelessWidget { + const _HintText({ + required this.disabled, + required this.hintText, + required this.errorText, + required this.rounded, + }); + final bool disabled; + final bool rounded; + final String? hintText; + final String? errorText; + + @override + Widget build(BuildContext context) { + final colors = Zeta.of(context).colors; + final error = errorText != null && errorText!.isNotEmpty; + + final text = error ? errorText : hintText; + + Color elementColor = colors.textSubtle; + + if (disabled) { + elementColor = colors.textDisabled; + } else if (error) { + elementColor = colors.error; + } + + if (text == null) { + return const SizedBox(); + } + + return Row( + children: [ + Icon( + errorText != null + ? rounded + ? ZetaIcons.error_round + : ZetaIcons.error_sharp + : rounded + ? ZetaIcons.info_round + : ZetaIcons.info_sharp, + size: ZetaSpacing.x4, + color: elementColor, + ), + const SizedBox( + width: ZetaSpacing.x1, + ), + Expanded( + child: Text( + text, + style: ZetaTextStyles.bodyXSmall.copyWith(color: elementColor), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ).paddingTop(ZetaSpacing.x2); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('disabled', disabled)) + ..add(DiagnosticsProperty('rounded', rounded)) + ..add(StringProperty('hintText', hintText)) + ..add(StringProperty('errorText', errorText)); + } +} diff --git a/lib/src/utils/extensions.dart b/lib/src/utils/extensions.dart index 643ad0e7..b17ddbfb 100644 --- a/lib/src/utils/extensions.dart +++ b/lib/src/utils/extensions.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import '../../zeta_flutter.dart'; @@ -110,3 +111,16 @@ extension StringExtensions on String? { return '${this![0].toUpperCase()}${this!.substring(1).toLowerCase()}'; } } + +/// Extensions on [DateFormat] +extension DateFormatExtensions on DateFormat { + //NOTE: this function is a part of intl 0.19.0. + /// Parses a string to a DateTime + DateTime? tryParseStrict(String inputString) { + try { + return parseStrict(inputString); + } on FormatException { + return null; + } + } +} diff --git a/lib/zeta_flutter.dart b/lib/zeta_flutter.dart index ce453296..6917cd54 100644 --- a/lib/zeta_flutter.dart +++ b/lib/zeta_flutter.dart @@ -50,6 +50,7 @@ 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'; +export 'src/components/time_input/time_input.dart'; export 'src/components/tooltip/tooltip.dart'; export 'src/theme/color_extensions.dart'; export 'src/theme/color_scheme.dart';