From 96e5d42b69b3e872717bb6d3ea78b5623eb933ee Mon Sep 17 00:00:00 2001 From: atanasyordanov21 <63714308+atanasyordanov21@users.noreply.github.com> Date: Thu, 4 Apr 2024 10:51:39 +0300 Subject: [PATCH] Component date input (#12) * create ZetaDateInput * create different ZetaDateInput variants * fix show error style * date validation and input mask; documentation for ZetaDateInput properties * create widgetbook * changes according to comments --- example/lib/home.dart | 2 + .../pages/components/date_input_example.dart | 107 ++++++ example/widgetbook/main.dart | 2 + .../components/date_input_widgetbook.dart | 55 +++ lib/src/components/date_input/date_input.dart | 328 ++++++++++++++++++ lib/zeta_flutter.dart | 1 + pubspec.yaml | 2 + 7 files changed, 497 insertions(+) create mode 100644 example/lib/pages/components/date_input_example.dart create mode 100644 example/widgetbook/pages/components/date_input_widgetbook.dart create mode 100644 lib/src/components/date_input/date_input.dart diff --git a/example/lib/home.dart b/example/lib/home.dart index f59ec323..329e411f 100644 --- a/example/lib/home.dart +++ b/example/lib/home.dart @@ -9,6 +9,7 @@ import 'package:zeta_example/pages/components/breadcrumbs_example.dart'; import 'package:zeta_example/pages/components/button_example.dart'; import 'package:zeta_example/pages/components/checkbox_example.dart'; import 'package:zeta_example/pages/components/chip_example.dart'; +import 'package:zeta_example/pages/components/date_input_example.dart'; import 'package:zeta_example/pages/components/dialpad_example.dart'; import 'package:zeta_example/pages/components/dropdown_example.dart'; import 'package:zeta_example/pages/components/list_item_example.dart'; @@ -54,6 +55,7 @@ final List components = [ Component(DialPadExample.name, (context) => const DialPadExample()), Component(RadioButtonExample.name, (context) => const RadioButtonExample()), Component(SwitchExample.name, (context) => const SwitchExample()), + Component(DateInputExample.name, (context) => const DateInputExample()), ]; final List theme = [ diff --git a/example/lib/pages/components/date_input_example.dart b/example/lib/pages/components/date_input_example.dart new file mode 100644 index 00000000..2f8c9470 --- /dev/null +++ b/example/lib/pages/components/date_input_example.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:zeta_example/widgets.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +class DateInputExample extends StatefulWidget { + static const String name = 'DateInput'; + + const DateInputExample({Key? key}) : super(key: key); + + @override + State createState() => _DateInputExampleState(); +} + +class _DateInputExampleState extends State { + String? _errorText; + + @override + Widget build(BuildContext context) { + return ExampleScaffold( + name: 'Date Input', + child: SingleChildScrollView( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(top: 20), + child: Text('Rounded', style: ZetaTextStyles.titleSmall), + ), + Padding( + padding: const EdgeInsets.all(20), + child: ZetaDateInput( + label: 'Birthdate', + hint: 'Enter birthdate', + hasError: _errorText != null, + errorText: _errorText ?? 'Invalid date', + onChanged: (value) { + if (value == null) return setState(() => _errorText = null); + final now = DateTime.now(); + setState( + () => _errorText = value.difference(DateTime(now.year, now.month, now.day)).inDays > 0 + ? 'Birthdate cannot be in the future' + : null, + ); + }, + ), + ), + Divider(color: Colors.grey[200]), + Padding( + padding: const EdgeInsets.only(top: 20), + child: Text('Sharp', style: ZetaTextStyles.titleSmall), + ), + Padding( + padding: const EdgeInsets.all(20), + child: ZetaDateInput( + label: 'Label', + hint: 'Default hint text', + errorText: 'Oops! Error hint text', + rounded: false, + datePattern: 'yyyy-MM-dd', + ), + ), + Divider(color: Colors.grey[200]), + Padding( + padding: const EdgeInsets.only(top: 20), + child: Text('Disabled', style: ZetaTextStyles.titleSmall), + ), + Padding( + padding: const EdgeInsets.all(20), + child: ZetaDateInput( + label: 'Label', + hint: 'Default hint text', + enabled: false, + ), + ), + Divider(color: Colors.grey[200]), + Padding( + padding: const EdgeInsets.only(top: 20), + child: Text('Medium', style: ZetaTextStyles.titleSmall), + ), + Padding( + padding: const EdgeInsets.all(20), + child: ZetaDateInput( + label: 'Label', + hint: 'Default hint text', + errorText: 'Oops! Error hint text', + size: ZetaDateInputSize.medium, + ), + ), + Divider(color: Colors.grey[200]), + Padding( + padding: const EdgeInsets.only(top: 20), + child: Text('Small', style: ZetaTextStyles.titleSmall), + ), + Padding( + padding: const EdgeInsets.all(20), + child: ZetaDateInput( + label: 'Label', + hint: 'Default hint text', + errorText: 'Oops! Error hint text', + size: ZetaDateInputSize.small, + ), + ), + ], + ), + ), + ); + } +} diff --git a/example/widgetbook/main.dart b/example/widgetbook/main.dart index f88d48a5..e75ae793 100644 --- a/example/widgetbook/main.dart +++ b/example/widgetbook/main.dart @@ -12,6 +12,7 @@ import 'pages/components/breadcrumbs_widgetbook.dart'; import 'pages/components/button_widgetbook.dart'; import 'pages/components/checkbox_widgetbook.dart'; import 'pages/components/chip_widgetbook.dart'; +import 'pages/components/date_input_widgetbook.dart'; import 'pages/components/dial_pad_widgetbook.dart'; import 'pages/components/dropdown_widgetbook.dart'; import 'pages/components/in_page_banner_widgetbook.dart'; @@ -98,6 +99,7 @@ class HotReload extends StatelessWidget { name: 'Snack Bar', builder: (context) => snackBarUseCase(context), ), + WidgetbookUseCase(name: 'Date Input', builder: (context) => dateInputUseCase(context)), WidgetbookUseCase(name: 'Tabs', builder: (context) => tabsUseCase(context)), ]..sort((a, b) => a.name.compareTo(b.name)), ), diff --git a/example/widgetbook/pages/components/date_input_widgetbook.dart b/example/widgetbook/pages/components/date_input_widgetbook.dart new file mode 100644 index 00000000..eb3d7cf1 --- /dev/null +++ b/example/widgetbook/pages/components/date_input_widgetbook.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +import '../../test/test_components.dart'; + +Widget dateInputUseCase(BuildContext context) { + String? _errorText; + + return WidgetbookTestWidget( + widget: StatefulBuilder( + builder: (context, setState) { + final errorText = context.knobs.string( + label: 'Error message for invalid date', + initialValue: 'Invalid date', + ); + final rounded = context.knobs.boolean(label: 'Rounded', initialValue: true); + final enabled = context.knobs.boolean(label: 'Enabled', initialValue: true); + final size = context.knobs.list( + label: 'Size', + options: ZetaDateInputSize.values, + labelBuilder: (size) => size.name, + ); + final datePattern = context.knobs.list( + label: 'Date pattern', + options: ['MM/dd/yyyy', 'dd/MM/yyyy', 'dd.MM.yyyy', 'yyyy-MM-dd'], + labelBuilder: (pattern) => pattern, + ); + + return Padding( + padding: const EdgeInsets.all(ZetaSpacing.x5), + child: ZetaDateInput( + size: size, + rounded: rounded, + enabled: enabled, + label: 'Birthdate', + hint: 'Enter birthdate', + datePattern: datePattern, + hasError: _errorText != null, + errorText: _errorText ?? errorText, + onChanged: (value) { + if (value == null) return setState(() => _errorText = null); + final now = DateTime.now(); + setState( + () => _errorText = value.difference(DateTime(now.year, now.month, now.day)).inDays > 0 + ? 'Birthdate cannot be in the future' + : null, + ); + }, + ), + ); + }, + ), + ); +} diff --git a/lib/src/components/date_input/date_input.dart b/lib/src/components/date_input/date_input.dart new file mode 100644 index 00000000..1de3d6f6 --- /dev/null +++ b/lib/src/components/date_input/date_input.dart @@ -0,0 +1,328 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:mask_text_input_formatter/mask_text_input_formatter.dart'; +import '../../../zeta_flutter.dart'; + +/// [ZetaDateInput] size +enum ZetaDateInputSize { + /// [large] 48 pixels height of the input field. + large, + + /// [medium] 40 pixels height of the input field. + medium, + + /// [small] 32 pixels height of the input field. + small, +} + +/// ZetaDateInput allows entering date in a pre-defined format. +/// Validation is performed to make sure the date is valid +/// and is in the proper format. +class ZetaDateInput extends StatefulWidget { + /// Constructor for [ZetaDateInput]. + /// + /// Example usage how to provide custom validanions + /// via `onChanged`, `hasError` and `errorText`: + /// ```dart + /// ZetaDateInput( + /// label: 'Birthdate', + /// hint: 'Enter birthdate', + /// hasError: _errorText != null, + /// errorText: _errorText ?? 'Invalid date', + /// onChanged: (value) { + /// if (value == null) return setState(() => _errorText = null); + /// final now = DateTime.now(); + /// setState( + /// () => _errorText = value.difference( + /// DateTime(now.year, now.month, now.day)).inDays > 0 + /// ? 'Birthdate cannot be in the future' + /// : null, + /// ); + /// }, + /// ) + /// ``` + const ZetaDateInput({ + super.key, + this.size, + this.label, + this.hint, + this.enabled = true, + this.rounded = true, + this.hasError = false, + this.errorText, + this.onChanged, + this.datePattern = 'MM/dd/yyyy', + }); + + /// Determines the size of the input field. + /// Default is `ZetaDateInputSize.large` + final ZetaDateInputSize? size; + + /// If provided, displays a label above the input field. + final String? label; + + /// If provided, displays a hint below the input field. + final String? hint; + + /// 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. + final bool rounded; + + /// Determines if the input field should be displayed in error style. + /// Default is `false`. + /// If `enabled` is `false`, this has no effect. + final bool hasError; + + /// In combination with `hasError: true`, provides the error message + /// to be displayed below the input field. + /// + /// If `hasError` is false, then `errorText` should provide + /// date validation error message. + /// + /// See the example in the [ZetaDateInput] documentation. + final String? errorText; + + /// A callback, which provides the entered date, or `null`, if invalid. + /// + /// See the example in the [ZetaDateInput] documentation + /// how to provide custom validations + /// in combination with `hasError` and `errorText`. + final void Function(DateTime?)? onChanged; + + /// `datePattern` is needed for the date format validation as described here: + /// https://pub.dev/documentation/intl/latest/intl/DateFormat-class.html + final String datePattern; + + @override + State createState() => _ZetaDateInputState(); + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(EnumProperty('size', size)) + ..add(StringProperty('label', label)) + ..add(StringProperty('hint', hint)) + ..add(DiagnosticsProperty('enabled', enabled)) + ..add(DiagnosticsProperty('rounded', rounded)) + ..add(DiagnosticsProperty('hasError', hasError)) + ..add(StringProperty('errorText', errorText)) + ..add(ObjectFlagProperty.has('onChanged', onChanged)) + ..add(StringProperty('datePattern', datePattern)); + } +} + +class _ZetaDateInputState extends State { + final _controller = TextEditingController(); + late ZetaDateInputSize _size; + late final String _hintText; + late final MaskTextInputFormatter _dateFormatter; + bool _invalidDate = false; + bool _hasError = false; + + @override + void initState() { + super.initState(); + _hintText = widget.datePattern.toLowerCase(); + _dateFormatter = MaskTextInputFormatter( + mask: _hintText.replaceAll(RegExp('[a-z]'), '#'), + filter: {'#': RegExp('[0-9]')}, + type: MaskAutoCompletionType.eager, + ); + _setParams(); + } + + @override + void didUpdateWidget(ZetaDateInput oldWidget) { + super.didUpdateWidget(oldWidget); + _setParams(); + } + + void _setParams() { + _size = widget.size ?? ZetaDateInputSize.large; + _hasError = widget.hasError; + } + + void _onChanged() { + final value = _dateFormatter.getMaskedText().trim(); + final date = DateFormat(widget.datePattern).tryParseStrict(value); + _invalidDate = value.isNotEmpty && date == null; + widget.onChanged?.call(date); + setState(() {}); + } + + void _clear() { + _controller.clear(); + setState(() { + _invalidDate = false; + _hasError = false; + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final zeta = Zeta.of(context); + final hasError = _invalidDate || _hasError; + final showError = hasError && widget.errorText != null; + final hintErrorColor = widget.enabled + ? showError + ? zeta.colors.red + : zeta.colors.cool.shade70 + : zeta.colors.cool.shade50; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.label != null) + Padding( + padding: const EdgeInsets.only(bottom: 5), + child: Text( + widget.label!, + style: ZetaTextStyles.bodyLarge.copyWith( + height: 1.33, + color: widget.enabled ? zeta.colors.textDefault : zeta.colors.cool.shade50, + ), + ), + ), + TextFormField( + enabled: widget.enabled, + controller: _controller, + inputFormatters: [_dateFormatter], + keyboardType: TextInputType.number, + onChanged: (_) => _onChanged(), + style: ZetaTextStyles.bodyLarge.copyWith(height: 1.5), + decoration: InputDecoration( + isDense: true, + contentPadding: EdgeInsets.symmetric( + horizontal: 10, + vertical: _inputVerticalPadding(_size), + ), + hintText: _hintText, + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_controller.text.isNotEmpty) + IconButton( + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, + ), + onPressed: _clear, + icon: Icon( + widget.rounded ? ZetaIcons.cancel_round : ZetaIcons.cancel_sharp, + color: zeta.colors.cool.shade70, + size: _iconSize(_size), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 6, right: 10), + child: Icon( + widget.rounded ? ZetaIcons.calendar_3_day_round : ZetaIcons.calendar_3_day_sharp, + color: widget.enabled ? zeta.colors.textDefault : zeta.colors.cool.shade50, + size: _iconSize(_size), + ), + ), + ], + ), + suffixIconConstraints: const BoxConstraints( + minHeight: ZetaSpacing.m, + minWidth: ZetaSpacing.m, + ), + hintStyle: ZetaTextStyles.bodyLarge.copyWith( + color: widget.enabled ? zeta.colors.textDefault : zeta.colors.cool.shade50, + height: 1.5, + ), + filled: !widget.enabled || hasError ? true : null, + fillColor: widget.enabled + ? hasError + ? zeta.colors.red.shade10 + : null + : zeta.colors.cool.shade30, + enabledBorder: hasError + ? _errorInputBorder(zeta, rounded: widget.rounded) + : _defaultInputBorder(zeta, rounded: widget.rounded), + focusedBorder: hasError + ? _errorInputBorder(zeta, rounded: widget.rounded) + : _focusedInputBorder(zeta, rounded: widget.rounded), + disabledBorder: _defaultInputBorder(zeta, rounded: widget.rounded), + errorBorder: _errorInputBorder(zeta, rounded: widget.rounded), + focusedErrorBorder: _errorInputBorder(zeta, rounded: widget.rounded), + ), + ), + if (widget.hint != null || showError) + Padding( + padding: const EdgeInsets.only(top: 5), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 8), + child: Icon( + showError && widget.enabled + ? (widget.rounded ? ZetaIcons.error_round : ZetaIcons.error_sharp) + : (widget.rounded ? ZetaIcons.info_round : ZetaIcons.info_sharp), + size: ZetaSpacing.b, + color: hintErrorColor, + ), + ), + Expanded( + child: Text( + showError && widget.enabled ? widget.errorText! : widget.hint!, + style: ZetaTextStyles.bodySmall.copyWith( + color: hintErrorColor, + ), + ), + ), + ], + ), + ), + ], + ); + } + + double _inputVerticalPadding(ZetaDateInputSize size) => switch (size) { + ZetaDateInputSize.large => ZetaSpacing.x3, + ZetaDateInputSize.medium => ZetaSpacing.x2, + ZetaDateInputSize.small => ZetaSpacing.x1, + }; + + double _iconSize(ZetaDateInputSize size) => switch (size) { + ZetaDateInputSize.large => ZetaSpacing.x6, + ZetaDateInputSize.medium => ZetaSpacing.x5, + ZetaDateInputSize.small => ZetaSpacing.x4, + }; + + OutlineInputBorder _defaultInputBorder( + Zeta zeta, { + required bool rounded, + }) => + OutlineInputBorder( + borderRadius: rounded ? ZetaRadius.minimal : ZetaRadius.none, + borderSide: BorderSide(color: zeta.colors.cool.shade40), + ); + + OutlineInputBorder _focusedInputBorder( + Zeta zeta, { + required bool rounded, + }) => + OutlineInputBorder( + borderRadius: rounded ? ZetaRadius.minimal : ZetaRadius.none, + borderSide: BorderSide(color: zeta.colors.blue.shade50), + ); + + OutlineInputBorder _errorInputBorder( + Zeta zeta, { + required bool rounded, + }) => + OutlineInputBorder( + borderRadius: rounded ? ZetaRadius.minimal : ZetaRadius.none, + borderSide: BorderSide(color: zeta.colors.red.shade50), + ); +} diff --git a/lib/zeta_flutter.dart b/lib/zeta_flutter.dart index 7bcbbd40..6ee239cc 100644 --- a/lib/zeta_flutter.dart +++ b/lib/zeta_flutter.dart @@ -22,6 +22,7 @@ export 'src/components/buttons/fab.dart'; export 'src/components/buttons/icon_button.dart'; export 'src/components/checkbox/checkbox.dart'; export 'src/components/chips/chip.dart'; +export 'src/components/date_input/date_input.dart'; export 'src/components/dial_pad/dial_pad.dart'; export 'src/components/dropdown/dropdown.dart'; export 'src/components/list_item/list_item.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 7f88b8e6..302b2225 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,6 +23,8 @@ environment: dependencies: flutter: sdk: flutter + intl: ^0.19.0 + mask_text_input_formatter: ^2.9.0 dev_dependencies: zds_analysis: ^1.0.0