diff --git a/example/lib/home.dart b/example/lib/home.dart index 7dd6bfa0..492c343b 100644 --- a/example/lib/home.dart +++ b/example/lib/home.dart @@ -32,6 +32,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/text_input_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/components/top_app_bar_example.dart'; @@ -90,6 +91,7 @@ final List components = [ Component(FilterSelectionExample.name, (context) => const FilterSelectionExample()), Component(StepperInputExample.name, (context) => const StepperInputExample()), Component(TimeInputExample.name, (context) => const TimeInputExample()), + Component(TextInputExample.name, (context) => const TextInputExample()), ]; final List theme = [ diff --git a/example/lib/pages/components/date_input_example.dart b/example/lib/pages/components/date_input_example.dart index d02d5d9e..89037f82 100644 --- a/example/lib/pages/components/date_input_example.dart +++ b/example/lib/pages/components/date_input_example.dart @@ -29,18 +29,9 @@ class _DateInputExampleState extends State { padding: const EdgeInsets.all(20), child: ZetaDateInput( label: 'Birthdate', - hint: 'Enter birthdate', - hasError: _errorText != null, + hintText: 'Enter birthdate', 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, - ); - }, + initialValue: DateTime.now(), ), ), Divider(color: Colors.grey[200]), @@ -52,10 +43,10 @@ class _DateInputExampleState extends State { padding: const EdgeInsets.all(20), child: ZetaDateInput( label: 'Label', - hint: 'Default hint text', + hintText: 'Default hint text', errorText: 'Oops! Error hint text', rounded: false, - datePattern: 'yyyy-MM-dd', + dateFormat: 'yyyy-MM-dd', ), ), Divider(color: Colors.grey[200]), @@ -67,8 +58,8 @@ class _DateInputExampleState extends State { padding: const EdgeInsets.all(20), child: ZetaDateInput( label: 'Label', - hint: 'Default hint text', - enabled: false, + hintText: 'Default hint text', + disabled: true, ), ), Divider(color: Colors.grey[200]), @@ -80,7 +71,7 @@ class _DateInputExampleState extends State { padding: const EdgeInsets.all(20), child: ZetaDateInput( label: 'Label', - hint: 'Default hint text', + hintText: 'Default hint text', errorText: 'Oops! Error hint text', size: ZetaWidgetSize.medium, ), @@ -94,7 +85,7 @@ class _DateInputExampleState extends State { padding: const EdgeInsets.all(20), child: ZetaDateInput( label: 'Label', - hint: 'Default hint text', + hintText: 'Default hint text', errorText: 'Oops! Error hint text', size: ZetaWidgetSize.small, ), diff --git a/example/lib/pages/components/text_input_example.dart b/example/lib/pages/components/text_input_example.dart new file mode 100644 index 00000000..0b293921 --- /dev/null +++ b/example/lib/pages/components/text_input_example.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:zeta_example/widgets.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +class TextInputExample extends StatelessWidget { + static const name = 'TextInput'; + + const TextInputExample({super.key}); + + @override + Widget build(BuildContext context) { + GlobalKey key = GlobalKey(); + return ExampleScaffold( + name: 'Text Input', + child: Center( + child: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.all(16), + child: Column( + children: [ + ZetaTextInput( + size: ZetaWidgetSize.large, + key: key, + placeholder: 'Placeholder', + prefixText: '£', + label: 'Label', + requirementLevel: ZetaFormFieldRequirement.mandatory, + errorText: 'Error text', + disabled: false, + hintText: 'hint text', + suffix: IconButton( + icon: Icon(ZetaIcons.add_alert_round), + onPressed: () {}, + ), + ), + ZetaButton( + label: 'Clear', + onPressed: () => key.currentState?.reset(), + ), + ZetaTextInput( + placeholder: 'Placeholder', + prefixText: '£', + ), + ZetaTextInput( + size: ZetaWidgetSize.small, + placeholder: 'Placeholder', + prefix: IconButton( + iconSize: 12, + icon: Icon( + ZetaIcons.add_alert_round, + ), + onPressed: () {}, + ), + ), + const SizedBox(height: 8), + ZetaTextInput( + placeholder: 'Placeholder', + prefix: Icon( + ZetaIcons.star_round, + size: 20, + ), + ), + ZetaTextInput( + size: ZetaWidgetSize.small, + placeholder: 'Placeholder', + suffixText: 'kg', + prefixText: '£', + ), + ].divide(const SizedBox(height: 12)).toList(), + ), + ), + ), + )); + } +} diff --git a/example/lib/pages/components/time_input_example.dart b/example/lib/pages/components/time_input_example.dart index 1c973e31..75b4b9fb 100644 --- a/example/lib/pages/components/time_input_example.dart +++ b/example/lib/pages/components/time_input_example.dart @@ -29,21 +29,23 @@ class TimeInputExample extends StatelessWidget { ), ZetaTimeInput( label: 'Large', - hint: 'Default hint text', + hintText: 'Default hint text', onChange: (value) => print(value), errorText: 'Oops! Error hint text', size: ZetaWidgetSize.large, + initialValue: TimeOfDay.now(), ), ZetaTimeInput( label: 'Medium', - hint: 'Default hint text', + hintText: 'Default hint text', + requirementLevel: ZetaFormFieldRequirement.optional, onChange: (value) => print(value), errorText: 'Oops! Error hint text', size: ZetaWidgetSize.medium, ), ZetaTimeInput( label: 'Small', - hint: 'Default hint text', + hintText: 'Default hint text', onChange: (value) => print(value), errorText: 'Oops! Error hint text', size: ZetaWidgetSize.small, @@ -55,7 +57,7 @@ class TimeInputExample extends StatelessWidget { height: 48, ), ZetaTimeInput(label: '12 Hr Time Picker', use12Hr: true), - ZetaTimeInput(label: 'Disabled Time Picker', disabled: true, hint: 'Disabled time picker'), + ZetaTimeInput(label: 'Disabled Time Picker', disabled: true, hintText: '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 6e074ccb..a9feb3b0 100644 --- a/example/widgetbook/main.dart +++ b/example/widgetbook/main.dart @@ -4,6 +4,7 @@ import 'package:zeta_flutter/zeta_flutter.dart'; import 'pages/assets/icon_widgetbook.dart'; import 'pages/components/accordion_widgetbook.dart'; +import 'pages/components/text_input_widgetbook.dart'; import 'pages/components/top_app_bar_widgetbook.dart'; import 'pages/components/avatar_widgetbook.dart'; import 'pages/components/badges_widgetbook.dart'; @@ -39,7 +40,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/time_input_widgetbook.dart'; import 'pages/components/tooltip_widgetbook.dart'; import 'pages/theme/color_widgetbook.dart'; import 'pages/theme/radius_widgetbook.dart'; @@ -140,6 +141,7 @@ class HotReload extends StatelessWidget { 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)), + WidgetbookUseCase(name: 'Text Input', builder: (context) => textInputUseCase(context)), ]..sort((a, b) => a.name.compareTo(b.name)), ), WidgetbookCategory( diff --git a/example/widgetbook/pages/components/date_input_widgetbook.dart b/example/widgetbook/pages/components/date_input_widgetbook.dart index 5e85be3a..6cfa77de 100644 --- a/example/widgetbook/pages/components/date_input_widgetbook.dart +++ b/example/widgetbook/pages/components/date_input_widgetbook.dart @@ -15,7 +15,7 @@ Widget dateInputUseCase(BuildContext context) { initialValue: 'Invalid date', ); final rounded = context.knobs.boolean(label: 'Rounded', initialValue: true); - final enabled = context.knobs.boolean(label: 'Enabled', initialValue: true); + final disabled = context.knobs.boolean(label: 'Disabled', initialValue: false); final size = context.knobs.list( label: 'Size', options: ZetaWidgetSize.values, @@ -32,13 +32,12 @@ Widget dateInputUseCase(BuildContext context) { child: ZetaDateInput( size: size, rounded: rounded, - enabled: enabled, + disabled: disabled, label: 'Birthdate', - hint: 'Enter birthdate', - datePattern: datePattern, - hasError: _errorText != null, + hintText: 'Enter birthdate', + dateFormat: datePattern, errorText: _errorText ?? errorText, - onChanged: (value) { + onChange: (value) { if (value == null) return setState(() => _errorText = null); final now = DateTime.now(); setState( diff --git a/example/widgetbook/pages/components/text_input_widgetbook.dart b/example/widgetbook/pages/components/text_input_widgetbook.dart new file mode 100644 index 00000000..bddb4ef6 --- /dev/null +++ b/example/widgetbook/pages/components/text_input_widgetbook.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +import '../../test/test_components.dart'; + +Widget textInputUseCase(BuildContext context) { + return WidgetbookTestWidget( + widget: StatefulBuilder( + builder: (context, setState) { + final label = context.knobs.string( + label: 'Label', + initialValue: 'Label', + ); + final errorText = context.knobs.stringOrNull( + 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: ZetaTextInput( + size: size, + rounded: rounded, + disabled: disabled, + label: label, + hintText: hintText, + errorText: errorText, + prefixText: '£', + suffix: IconButton( + icon: Icon(ZetaIcons.star_round), + onPressed: () {}, + ), + onChange: (value) {}, + ), + ); + }, + ), + ); +} diff --git a/example/widgetbook/pages/components/time_input.dart b/example/widgetbook/pages/components/time_input_widgetbook.dart similarity index 97% rename from example/widgetbook/pages/components/time_input.dart rename to example/widgetbook/pages/components/time_input_widgetbook.dart index 32b9c547..507c885a 100644 --- a/example/widgetbook/pages/components/time_input.dart +++ b/example/widgetbook/pages/components/time_input_widgetbook.dart @@ -37,7 +37,7 @@ Widget timeInputUseCase(BuildContext context) { rounded: rounded, disabled: disabled, label: label, - hint: hintText, + hintText: 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 3f1e85e0..349f531e 100644 --- a/lib/src/components/date_input/date_input.dart +++ b/lib/src/components/date_input/date_input.dart @@ -1,319 +1,326 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; import 'package:mask_text_input_formatter/mask_text_input_formatter.dart'; + import '../../../zeta_flutter.dart'; +import '../../interfaces/form_field.dart'; -/// 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 validations - /// via `onChanged`, `hasError` and `errorText`: - /// ```dart - /// ZetaDateInput( - /// label: 'Birthday', - /// hint: 'Enter birthday', - /// 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 - /// ? 'Birthday cannot be in the future' - /// : null, - /// ); - /// }, - /// ) - /// ``` - const ZetaDateInput({ +/// A form field used to input dates. +/// +/// Can be used and validated the same way as a [TextFormField] +class ZetaDateInput extends ZetaFormField { + /// Creates a new [ZetaDateInput] + ZetaDateInput({ super.key, - this.size, - this.label, - this.hint, - this.enabled = true, + super.disabled = false, + super.initialValue, + super.onChange, + super.requirementLevel = ZetaFormFieldRequirement.none, this.rounded = true, - this.hasError = false, + this.label, + this.hintText, this.errorText, - this.onChanged, - this.datePattern = 'MM/dd/yyyy', - }); + this.validator, + this.size = ZetaWidgetSize.medium, + this.dateFormat = 'MM/dd/yyyy', + this.minDate, + this.maxDate, + this.pickerInitialEntryMode, + }) : assert((minDate == null || maxDate == null) || minDate.isBefore(maxDate), 'minDate cannot be after maxDate'); - /// Determines the size of the input field. - /// - /// Default is `ZetaDateInputSize.large` - final ZetaWidgetSize? size; + /// {@macro zeta-component-rounded} + final bool rounded; - /// If provided, displays a label above the input field. + /// The label for the input. final String? label; - /// If provided, displays a hint below the input field. - final String? hint; + /// The hint displayed below the input. + final String? hintText; - /// Determines if the input field should be enabled (default) or disabled. - final bool enabled; + /// The error displayed below the input. + /// + /// If you want to have custom error messages for different types of error, use [validator]. + final String? errorText; - /// {@macro zeta-component-rounded} - final bool rounded; + /// The format the given date should be in + /// https://pub.dev/documentation/intl/latest/intl/DateFormat-class.html + final String dateFormat; - /// 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; + /// The size of the input. + final ZetaWidgetSize size; - /// 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; + /// The minimum date allowed by the date input. + final DateTime? minDate; - /// 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; + /// The maximum date allowed by the date input. + final DateTime? maxDate; - /// `datePattern` is needed for the date format validation as described here: - /// https://pub.dev/documentation/intl/latest/intl/DateFormat-class.html - final String datePattern; + /// The initial entry mode of the date picker. + final DatePickerEntryMode? pickerInitialEntryMode; + + /// The validator passed to the text input. + /// Returns a string containing an error message. + /// + /// By default, the form field checks for if the date is within [minDate] and [maxDate] (if given). + /// It also checks for null values unless [requirementLevel] is set to [ZetaFormFieldRequirement.optional] + /// + /// 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(DateTime? value)? validator; @override - State createState() => _ZetaDateInputState(); + 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(ObjectFlagProperty?>.has('onChange', onChange)) + ..add(DiagnosticsProperty('initialValue', initialValue)) + ..add(DiagnosticsProperty('disabled', disabled)) + ..add(StringProperty('label', label)) + ..add(StringProperty('hintText', hintText)) ..add(StringProperty('errorText', errorText)) - ..add(ObjectFlagProperty.has('onChanged', onChanged)) - ..add(StringProperty('datePattern', datePattern)); + ..add(EnumProperty('size', size)) + ..add(ObjectFlagProperty.has('validator', validator)) + ..add(StringProperty('dateFormat', dateFormat)) + ..add(DiagnosticsProperty('minDate', minDate)) + ..add(DiagnosticsProperty('maxDate', maxDate)) + ..add(EnumProperty('pickerInitialEntryMode', pickerInitialEntryMode)); } } -class _ZetaDateInputState extends State { - final _controller = TextEditingController(); - late ZetaWidgetSize _size; - late final String _hintText; +/// State for [ZetaDateInput] +class ZetaDateInputState extends State implements ZetaFormFieldState { + // TODO(mikecoomber): add AM/PM selector inline. + + ZetaColors get _colors => Zeta.of(context).colors; + late final MaskTextInputFormatter _dateFormatter; - bool _invalidDate = false; - bool _hasError = false; + + final _controller = TextEditingController(); + final GlobalKey _key = GlobalKey(); + + String? _errorText; + + bool get _showClearButton => _controller.text.isNotEmpty; + + double get _iconSize { + switch (widget.size) { + case ZetaWidgetSize.large: + return ZetaSpacing.x6; + case ZetaWidgetSize.medium: + return ZetaSpacing.x5; + case ZetaWidgetSize.small: + return ZetaSpacing.x4; + } + } + + DateTime? get _value { + final value = _dateFormatter.getMaskedText().trim(); + final date = DateFormat(widget.dateFormat).tryParseStrict(value); + + return date; + } @override void initState() { - super.initState(); - _hintText = widget.datePattern.toLowerCase(); _dateFormatter = MaskTextInputFormatter( - mask: _hintText.replaceAll(RegExp('[a-z]'), '#'), + mask: widget.dateFormat.replaceAll(RegExp('[A-Za-z]'), '#'), filter: {'#': RegExp('[0-9]')}, type: MaskAutoCompletionType.eager, ); - _setParams(); + + if (widget.initialValue != null) { + _setText(widget.initialValue!); + WidgetsBinding.instance.addPostFrameCallback((_) { + _key.currentState?.validate(); + }); + } + super.initState(); } @override - void didUpdateWidget(ZetaDateInput oldWidget) { - super.didUpdateWidget(oldWidget); - _setParams(); + void dispose() { + _controller.dispose(); + super.dispose(); } - void _setParams() { - _size = widget.size ?? ZetaWidgetSize.large; - _hasError = widget.hasError; + void _onChange() { + if (_dateFormatter.getMaskedText().length == widget.dateFormat.length && (_key.currentState?.validate() ?? false)) { + widget.onChange?.call(_value); + } + setState(() {}); } - 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 _setText(DateTime value) { + _controller.text = DateFormat(widget.dateFormat).format(value); + _dateFormatter.formatEditUpdate(TextEditingValue.empty, _controller.value); } - void _clear() { - _controller.clear(); + Future _pickDate() async { + final firstDate = widget.minDate ?? DateTime(0000); + final lastDate = widget.maxDate ?? DateTime(3000); + DateTime fallbackDate = DateTime.now(); + + if (fallbackDate.isBefore(firstDate) || fallbackDate.isAfter(lastDate)) { + fallbackDate = firstDate; + } + + late final DateTime initialDate; + + if (_value == null || (_value != null && _value!.isBefore(firstDate) || _value!.isAfter(lastDate))) { + initialDate = fallbackDate; + } else { + initialDate = _value!; + } + + final result = await showDatePicker( + context: context, + firstDate: firstDate, + lastDate: lastDate, + initialEntryMode: widget.pickerInitialEntryMode ?? DatePickerEntryMode.calendar, + initialDate: initialDate, + builder: (BuildContext context, Widget? child) { + return Theme( + data: Theme.of(context).copyWith( + dividerTheme: DividerThemeData(color: _colors.borderSubtle), + datePickerTheme: DatePickerThemeData( + shape: RoundedRectangleBorder( + borderRadius: widget.rounded ? ZetaRadius.rounded : ZetaRadius.none, + ), + headerHeadlineStyle: ZetaTextStyles.titleLarge, + headerHelpStyle: ZetaTextStyles.labelLarge, + dividerColor: _colors.borderSubtle, + dayStyle: ZetaTextStyles.bodyMedium, + ), + ), + child: child!, + ); + }, + ); + if (result != null) { + _setText(result); + } + } + + @override + void reset() { + _dateFormatter.clear(); + _key.currentState?.reset(); setState(() { - _invalidDate = false; - _hasError = false; + _errorText = null; }); + _controller.clear(); + widget.onChange?.call(null); } @override - void dispose() { - _controller.dispose(); - super.dispose(); - } + bool validate() => _key.currentState?.validate() ?? false; @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.bodyMedium.copyWith( - color: widget.enabled ? zeta.colors.textDefault : zeta.colors.cool.shade50, - ), - ), - ), - TextFormField( - enabled: widget.enabled, - autovalidateMode: AutovalidateMode.onUserInteraction, - controller: _controller, - inputFormatters: [_dateFormatter], - keyboardType: TextInputType.number, - onChanged: (_) => _onChanged(), - style: _size == ZetaWidgetSize.small ? ZetaTextStyles.bodyXSmall : ZetaTextStyles.bodyMedium, - 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: _size == ZetaWidgetSize.small - ? ZetaTextStyles.bodyXSmall.copyWith( - color: widget.enabled ? zeta.colors.textDefault : zeta.colors.cool.shade50, - ) - : ZetaTextStyles.bodyMedium.copyWith( - color: widget.enabled ? zeta.colors.textDefault : zeta.colors.cool.shade50, - ), - 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: ZetaSpacing.x2), - 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.bodyXSmall.copyWith( - color: hintErrorColor, - ), - ), - ), - ], + return ZetaTextInput( + disabled: widget.disabled, + key: _key, + rounded: widget.rounded, + size: widget.size, + errorText: _errorText, + label: widget.label, + hintText: widget.hintText, + placeholder: widget.dateFormat, + controller: _controller, + inputFormatters: [ + _dateFormatter, + ], + validator: (_) { + final customValidation = widget.validator?.call(_value); + if (_value == null || + (widget.minDate != null && _value!.isBefore(widget.minDate!)) || + (widget.maxDate != null && _value!.isAfter(widget.maxDate!)) || + customValidation != null) { + setState(() { + _errorText = customValidation ?? widget.errorText ?? ''; + }); + return ''; + } + + setState(() { + _errorText = null; + }); + return null; + }, + onChange: (_) => _onChange(), + requirementLevel: widget.requirementLevel, + suffix: Row( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + if (_showClearButton) + _IconButton( + icon: widget.rounded ? ZetaIcons.cancel_round : ZetaIcons.cancel_sharp, + onTap: reset, + disabled: widget.disabled, + size: _iconSize, + color: _colors.iconSubtle, ), + _IconButton( + icon: widget.rounded ? ZetaIcons.calendar_round : ZetaIcons.calendar_sharp, + onTap: _pickDate, + disabled: widget.disabled, + size: _iconSize, + color: _colors.iconDefault, ), - ], + ], + ), + ); + } +} + +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, + minHeight: size * 2, + minWidth: size * 2, + ), + color: !disabled ? color : colors.iconDisabled, + onPressed: disabled ? null : onTap, + iconSize: size, + icon: Icon(icon), ); } - double _inputVerticalPadding(ZetaWidgetSize size) => switch (size) { - ZetaWidgetSize.large => ZetaSpacing.x3, - ZetaWidgetSize.medium => ZetaSpacing.x2, - ZetaWidgetSize.small => ZetaSpacing.x2, - }; - - double _iconSize(ZetaWidgetSize size) => switch (size) { - ZetaWidgetSize.large => ZetaSpacing.x6, - ZetaWidgetSize.medium => ZetaSpacing.x5, - ZetaWidgetSize.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), - ); + @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)); + } } diff --git a/lib/src/components/text_input/text_input.dart b/lib/src/components/text_input/text_input.dart new file mode 100644 index 00000000..e1eb2db0 --- /dev/null +++ b/lib/src/components/text_input/text_input.dart @@ -0,0 +1,446 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../zeta_flutter.dart'; +import '../../interfaces/form_field.dart'; + +/// Text inputs allow the user to enter text. +/// +/// To show error messages on the text input, use the [validator]. The string returned from this function will be displayed as the error message. +/// Error messages can also be managed outside the text input by setting [errorText]. +/// +/// The input can be reset and validated by Creating a key of type [ZetaTextInputState] and calling either `reset` or `validate`. +/// However, it is recommended that the input is used and validated as part of a form. +class ZetaTextInput extends ZetaFormField { + /// Creates a new [ZetaTextInput] + const ZetaTextInput({ + super.key, + super.onChange, + super.disabled = false, + super.requirementLevel = ZetaFormFieldRequirement.none, + super.initialValue, + this.label, + this.hintText, + this.placeholder, + this.errorText, + this.controller, + this.validator, + this.suffix, + this.prefix, + this.size = ZetaWidgetSize.medium, + this.rounded = true, + this.inputFormatters, + this.prefixText, + this.prefixTextStyle, + this.suffixText, + this.suffixTextStyle, + }) : assert(initialValue == null || controller == null, 'Only one of initial value and controller can be accepted.'), + assert(prefix == null || prefixText == null, 'Only one of prefix or prefixText can be accepted.'), + assert(suffix == null || suffixText == null, 'Only one of suffix or suffixText can be accepted.'); + + /// The label displayed above the input. + final String? label; + + /// The hint text displayed below the input. + final String? hintText; + + /// The placeholder text displayed in the input. + final String? placeholder; + + /// The error text shown beneath the input. Replaces [hintText]. + final String? errorText; + + /// The controller given to the input. Cannot be given in addition to [initialValue]. + final TextEditingController? controller; + + /// The validator passed to the input. Should return the error message to be displayed below the input. + /// Should return null if there is no error. + final String? Function(String?)? validator; + + /// The widget displayed at the end of the input. Cannot be given in addition to [suffixText]. + final Widget? suffix; + + /// The text displayed at the end of the input. Cannot be given in addition to [suffix]. + final String? suffixText; + + /// The style applied to [suffixText]. + final TextStyle? suffixTextStyle; + + /// The widget displayed at the start of the input. Cannot be given in addition to [prefixText]. + final Widget? prefix; + + /// The text displayed at the end of the input. Cannot be given in addition to [prefix]. + final String? prefixText; + + /// The style applied to [prefixText]. + final TextStyle? prefixTextStyle; + + /// The size of the input. + final ZetaWidgetSize size; + + /// {@macro zeta-component-rounded} + final bool rounded; + + /// The input formatters given to the text input. + final List? inputFormatters; + + @override + State createState() => ZetaTextInputState(); + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(StringProperty('label', label)) + ..add(StringProperty('hintText', hintText)) + ..add(StringProperty('placeholder', placeholder)) + ..add(StringProperty('initialValue', initialValue)) + ..add(StringProperty('errorText', errorText)) + ..add(DiagnosticsProperty('controller', controller)) + ..add(ObjectFlagProperty?>.has('onChanged', onChange)) + ..add(ObjectFlagProperty.has('validator', validator)) + ..add(StringProperty('suffixText', suffixText)) + ..add(DiagnosticsProperty('suffixTextStyle', suffixTextStyle)) + ..add(StringProperty('prefixText', prefixText)) + ..add(DiagnosticsProperty('prefixTextStyle', prefixTextStyle)) + ..add(EnumProperty('size', size)) + ..add(DiagnosticsProperty('rounded', rounded)) + ..add(DiagnosticsProperty('disabled', disabled)) + ..add(IterableProperty('inputFormatters', inputFormatters)) + ..add(EnumProperty('requirementLevel', requirementLevel)); + } +} + +/// The current state of a [ZetaTextInput] +class ZetaTextInputState extends State implements ZetaFormFieldState { + late final TextEditingController _controller; + final GlobalKey> _key = GlobalKey(); + ZetaColors get _colors => Zeta.of(context).colors; + + bool _hovered = false; + + String? _errorText; + + Color get _backgroundColor { + if (widget.disabled) { + return _colors.surfaceDisabled; + } + if (_errorText != null) { + return _colors.error.shade10; + } + return _colors.surfacePrimary; + } + + TextStyle get _baseTextStyle { + TextStyle style = ZetaTextStyles.bodyMedium; + if (widget.size == ZetaWidgetSize.small) { + style = ZetaTextStyles.bodyXSmall; + } + return style; + } + + EdgeInsets get _contentPadding { + switch (widget.size) { + case ZetaWidgetSize.large: + return const EdgeInsets.symmetric(horizontal: ZetaSpacing.x3, vertical: ZetaSpacing.x4); + case ZetaWidgetSize.small: + case ZetaWidgetSize.medium: + return const EdgeInsets.symmetric(horizontal: ZetaSpacing.x3, vertical: ZetaSpacing.x3); + } + } + + TextStyle get _affixStyle { + Color color = _colors.textSubtle; + if (widget.disabled) { + color = _colors.textDisabled; + } + return _baseTextStyle.copyWith(color: color); + } + + BoxConstraints get _affixConstraints { + late final double size; + switch (widget.size) { + case ZetaWidgetSize.large: + size = ZetaSpacing.x10; + case ZetaWidgetSize.medium: + size = ZetaSpacing.x8; + case ZetaWidgetSize.small: + size = ZetaSpacing.x6; + } + return BoxConstraints( + minWidth: size, + ); + } + + Widget? get _prefix { + if (widget.prefix != null) return widget.prefix; + if (widget.prefixText != null) { + final style = widget.prefixTextStyle ?? _affixStyle; + return Center( + widthFactor: 0, + child: Text( + widget.prefixText!, + style: style, + ), + ).paddingStart(ZetaSpacing.x2); + } + + return null; + } + + Widget? get _suffix { + if (widget.suffix != null) return widget.suffix; + if (widget.suffixText != null) { + final style = widget.suffixTextStyle ?? _affixStyle; + return Center( + widthFactor: 0, + child: Text( + widget.suffixText!, + style: style, + ), + ).paddingEnd(ZetaSpacing.x2); + } + + 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() { + _controller = widget.controller ?? TextEditingController(); + _errorText = widget.errorText; + + if (widget.initialValue != null) { + _controller.text = widget.initialValue!; + } + super.initState(); + } + + @override + void didUpdateWidget(covariant ZetaTextInput oldWidget) { + if (oldWidget.errorText != widget.errorText) { + setState(() { + _errorText = widget.errorText; + }); + } + super.didUpdateWidget(oldWidget); + } + + @override + bool validate() => _key.currentState?.validate() ?? false; + + @override + void reset() { + _key.currentState?.reset(); + _controller.clear(); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.label != null) ...[ + _Label( + label: widget.label!, + requirementLevel: widget.requirementLevel, + disabled: widget.disabled, + ), + const SizedBox(height: ZetaSpacing.x1), + ], + 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: widget.inputFormatters, + validator: (val) { + setState(() { + _errorText = widget.validator?.call(val); + }); + return _errorText; + }, + textAlignVertical: TextAlignVertical.center, + onChanged: widget.onChange, + style: _baseTextStyle, + cursorErrorColor: _colors.error, + decoration: InputDecoration( + isDense: true, + contentPadding: _contentPadding, + filled: true, + prefixIcon: _prefix, + prefixIconConstraints: widget.prefixText != null ? _affixConstraints : null, + suffixIcon: _suffix, + suffixIconConstraints: _affixConstraints, + focusColor: _backgroundColor, + hoverColor: _backgroundColor, + fillColor: _backgroundColor, + enabledBorder: _baseBorder, + disabledBorder: _baseBorder, + focusedBorder: _focusedBorder, + focusedErrorBorder: _errorBorder, + errorBorder: widget.disabled ? _baseBorder : _errorBorder, + hintText: widget.placeholder, + errorText: _errorText, + hintStyle: _baseTextStyle, + errorStyle: const TextStyle(height: 0.001, color: Colors.transparent), + ), + ), + ), + _HintText( + disabled: widget.disabled, + rounded: widget.rounded, + hintText: widget.hintText, + errorText: _errorText, + ), + ], + ); + } +} + +class _Label extends StatelessWidget { + const _Label({ + required this.label, + required this.requirementLevel, + required this.disabled, + }); + + final String label; + final ZetaFormFieldRequirement requirementLevel; + final bool disabled; + + @override + Widget build(BuildContext context) { + final colors = Zeta.of(context).colors; + const textStyle = ZetaTextStyles.bodyMedium; + + Widget? requirementWidget; + + if (requirementLevel == ZetaFormFieldRequirement.optional) { + requirementWidget = Text( + '(optional)', // TODO(UX-1003): needs localizing. + style: textStyle.copyWith(color: disabled ? colors.textDisabled : colors.textSubtle), + ); + } else if (requirementLevel == ZetaFormFieldRequirement.mandatory) { + requirementWidget = Text( + '*', + style: ZetaTextStyles.labelIndicator.copyWith( + color: disabled ? colors.textDisabled : colors.error, // TODO(mikecoomber): change to textNegative when added + ), + ); + } + + return Row( + children: [ + Text( + label, + style: textStyle.copyWith( + color: disabled ? colors.textDisabled : colors.textDefault, + ), + ), + if (requirementWidget != null) requirementWidget.paddingStart(ZetaSpacing.x1), + ], + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(StringProperty('label', label)) + ..add(EnumProperty('requirementLevel', requirementLevel)) + ..add(DiagnosticsProperty('disabled', disabled)); + } +} + +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 && !disabled ? errorText : hintText; + + Color elementColor = colors.textSubtle; + + if (disabled) { + elementColor = colors.textDisabled; + } else if (error) { + elementColor = colors.error; + } + + if (text == null || text.isEmpty) { + 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/components/time_input/time_input.dart b/lib/src/components/time_input/time_input.dart index 3e6805a8..0d7ad7fd 100644 --- a/lib/src/components/time_input/time_input.dart +++ b/lib/src/components/time_input/time_input.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'package:mask_text_input_formatter/mask_text_input_formatter.dart'; import '../../../zeta_flutter.dart'; +import '../../interfaces/form_field.dart'; const _maxHrValue = 23; const _max12HrValue = 12; @@ -12,20 +13,22 @@ 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 { +class ZetaTimeInput extends ZetaFormField { /// Creates a new [ZetaTimeInput] const ZetaTimeInput({ super.key, + super.disabled = false, + super.initialValue, + super.onChange, + super.requirementLevel = ZetaFormFieldRequirement.none, this.rounded = true, this.use12Hr, - this.disabled = false, - this.initialValue, - this.onChange, this.label, - this.hint, + this.hintText, this.errorText, this.validator, this.size = ZetaWidgetSize.medium, + this.pickerInitialEntryMode, }); /// {@macro zeta-component-rounded} @@ -35,21 +38,11 @@ class ZetaTimeInput extends StatefulWidget { /// 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; + final String? hintText; /// The error displayed below the input. final String? errorText; @@ -57,10 +50,15 @@ class ZetaTimeInput extends StatefulWidget { /// The size of the input. final ZetaWidgetSize size; + /// The initial entry mode of the time picker. + final TimePickerEntryMode? pickerInitialEntryMode; + /// 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. + /// By default, the input checks for invalid hour or minute values. + /// It also checks for null values unless [requirementLevel] is set to [ZetaFormFieldRequirement.optional] + /// /// 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; @@ -77,15 +75,16 @@ class ZetaTimeInput extends StatefulWidget { ..add(DiagnosticsProperty('initialValue', initialValue)) ..add(DiagnosticsProperty('disabled', disabled)) ..add(StringProperty('label', label)) - ..add(StringProperty('hintText', hint)) + ..add(StringProperty('hintText', hintText)) ..add(StringProperty('errorText', errorText)) ..add(EnumProperty('size', size)) - ..add(ObjectFlagProperty.has('validator', validator)); + ..add(ObjectFlagProperty.has('validator', validator)) + ..add(EnumProperty('pickerInitialEntryMode', pickerInitialEntryMode)); } } /// State for [ZetaTimeInput] -class ZetaTimeInputState extends State { +class ZetaTimeInputState extends State implements ZetaFormFieldState { // TODO(mikecoomber): add AM/PM selector inline. ZetaColors get _colors => Zeta.of(context).colors; @@ -97,42 +96,13 @@ class ZetaTimeInputState extends State { bool get _use12Hr => widget.use12Hr ?? !MediaQuery.of(context).alwaysUse24HourFormat; final _controller = TextEditingController(); - final GlobalKey> _key = GlobalKey(); + 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, - ); - } + BorderRadius get _borderRadius => widget.rounded ? ZetaRadius.minimal : ZetaRadius.none; double get _iconSize { switch (widget.size) { @@ -160,19 +130,6 @@ class ZetaTimeInputState extends State { 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( @@ -197,10 +154,9 @@ class ZetaTimeInputState extends State { } void _onChange() { - if (_timeFormatter.getUnmaskedText().length > 3 && (_key.currentState?.validate() ?? false)) { + if (_timeFormatter.getUnmaskedText().length > (_timeFormat.length - 2) && + (_key.currentState?.validate() ?? false)) { widget.onChange?.call(_value); - } else { - widget.onChange?.call(null); } setState(() {}); } @@ -218,11 +174,31 @@ class ZetaTimeInputState extends State { Future _pickTime() async { final result = await showTimePicker( context: context, + initialEntryMode: widget.pickerInitialEntryMode ?? TimePickerEntryMode.dial, initialTime: _value ?? TimeOfDay.now(), builder: (BuildContext context, Widget? child) { - return MediaQuery( - data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: !_use12Hr), - child: child!, + return Theme( + data: Theme.of(context).copyWith( + timePickerTheme: TimePickerThemeData( + dialBackgroundColor: _colors.warm.shade30, + dayPeriodColor: _colors.primary, + shape: RoundedRectangleBorder( + borderRadius: widget.rounded ? ZetaRadius.rounded : ZetaRadius.none, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + isDense: true, + border: OutlineInputBorder( + borderRadius: _borderRadius, + borderSide: BorderSide.none, + ), + ), + ), + ), + child: MediaQuery( + data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: !_use12Hr), + child: child!, + ), ); }, ); @@ -231,7 +207,8 @@ class ZetaTimeInputState extends State { } } - void _clear() { + @override + void reset() { _timeFormatter.clear(); _key.currentState?.reset(); setState(() { @@ -241,120 +218,67 @@ class ZetaTimeInputState extends State { widget.onChange?.call(null); } + @override + bool validate() => _key.currentState?.validate() ?? false; + @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), + return ZetaTextInput( + disabled: widget.disabled, + key: _key, + size: widget.size, + errorText: _errorText, + rounded: widget.rounded, + label: widget.label, + hintText: widget.hintText, + placeholder: _timeFormat, + controller: _controller, + inputFormatters: [ + _timeFormatter, + ], + validator: (_) { + String? errorText; + final customValidation = widget.validator?.call(_value); + if ((_value == null && widget.requirementLevel != ZetaFormFieldRequirement.optional) || + (_value != null && (_value!.hour > _hrsLimit || _value!.minute > _minsLimit)) || + customValidation != null) { + errorText = customValidation ?? widget.errorText ?? ''; + } + + setState(() { + _errorText = errorText; + }); + return errorText; + }, + onChange: (_) => _onChange(), + requirementLevel: widget.requirementLevel, + suffix: Row( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + if (_showClearButton) + _IconButton( + icon: widget.rounded ? ZetaIcons.cancel_round : ZetaIcons.cancel_sharp, + onTap: reset, + 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, ), - ), - _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 { @@ -381,6 +305,8 @@ class _IconButton extends StatelessWidget { constraints: BoxConstraints( maxHeight: size * 2, maxWidth: size * 2, + minHeight: size * 2, + minWidth: size * 2, ), color: !disabled ? color : colors.iconDisabled, onPressed: disabled ? null : onTap, @@ -400,72 +326,3 @@ class _IconButton extends StatelessWidget { ..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/interfaces/form_field.dart b/lib/src/interfaces/form_field.dart new file mode 100644 index 00000000..ef302113 --- /dev/null +++ b/lib/src/interfaces/form_field.dart @@ -0,0 +1,46 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../utils/enums.dart'; + +/// An interface for all form fields used in Zeta +abstract class ZetaFormFieldState { + /// Validates the form field. Returns true if there are no errors. + bool validate(); + + /// Resets the form field to its initial state. + void reset(); +} + +/// A common interface shared with all Zeta form elements. +abstract class ZetaFormField extends StatefulWidget { + /// Creats a new [ZetaFormField] + const ZetaFormField({ + required this.disabled, + required this.initialValue, + required this.onChange, + required this.requirementLevel, + super.key, + }); + + /// Disables the form field. + final bool disabled; + + /// The initial value of the form field. + final T? initialValue; + + /// Called with the current value of the field whenever it is changed. + final ValueChanged? onChange; + + /// The requirement level of the form field, e.g. mandatory or optional. + final ZetaFormFieldRequirement requirementLevel; + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('disabled', disabled)) + ..add(DiagnosticsProperty('initialValue', initialValue)) + ..add(ObjectFlagProperty?>.has('onChange', onChange)) + ..add(EnumProperty('requirementLevel', requirementLevel)); + } +} diff --git a/lib/src/utils/enums.dart b/lib/src/utils/enums.dart index 1597c56f..f0441546 100644 --- a/lib/src/utils/enums.dart +++ b/lib/src/utils/enums.dart @@ -41,3 +41,15 @@ enum ZetaWidgetStatus { /// Neutral widget; defaults to grey color scheme. neutral, } + +/// The requirement options for a Form Field. +enum ZetaFormFieldRequirement { + /// The default form field requirement. + none, + + /// A mandatory form field. + mandatory, + + /// An optional form field. + optional, +} diff --git a/lib/zeta_flutter.dart b/lib/zeta_flutter.dart index 32706fbc..c77ecd2c 100644 --- a/lib/zeta_flutter.dart +++ b/lib/zeta_flutter.dart @@ -49,6 +49,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/text_input/text_input.dart'; export 'src/components/time_input/time_input.dart'; export 'src/components/tooltip/tooltip.dart'; export 'src/components/top_app_bar/top_app_bar.dart';