diff --git a/example/lib/pages/components/phone_input_example.dart b/example/lib/pages/components/phone_input_example.dart index 337b94fe..469aff7b 100644 --- a/example/lib/pages/components/phone_input_example.dart +++ b/example/lib/pages/components/phone_input_example.dart @@ -25,10 +25,26 @@ class _PhoneInputExampleState extends State { padding: const EdgeInsets.all(20), child: ZetaPhoneInput( label: 'Phone number', - hint: 'Enter your phone number', + hintText: 'Enter your phone number', hasError: _errorText != null, errorText: _errorText, - onChanged: (value) { + onChange: (value) { + if (value?.isEmpty ?? true) setState(() => _errorText = null); + print(value); + }, + initialCountry: 'GB', + countries: ['US', 'GB', 'DE', 'AT', 'FR', 'IT', 'BG'], + ), + ), + Padding( + padding: const EdgeInsets.all(20), + child: ZetaPhoneInput( + label: 'Phone number', + hintText: 'Enter your phone number', + hasError: _errorText != null, + size: ZetaWidgetSize.large, + errorText: 'Error', + onChange: (value) { if (value?.isEmpty ?? true) setState(() => _errorText = null); print(value); }, @@ -45,8 +61,15 @@ class _PhoneInputExampleState extends State { padding: const EdgeInsets.all(20), child: ZetaPhoneInput( label: 'Phone number', - hint: 'Enter your phone number', - enabled: false, + hintText: 'Enter your phone number', + disabled: true, + ), + ), + Padding( + padding: const EdgeInsets.all(20), + child: ZetaPhoneInput( + label: 'Phone number', + hintText: 'Enter your phone number', ), ), ], diff --git a/example/widgetbook/pages/components/phone_input_widgetbook.dart b/example/widgetbook/pages/components/phone_input_widgetbook.dart index 0800b0fe..bfef1ba7 100644 --- a/example/widgetbook/pages/components/phone_input_widgetbook.dart +++ b/example/widgetbook/pages/components/phone_input_widgetbook.dart @@ -16,11 +16,10 @@ Widget phoneInputUseCase(BuildContext context) { return Padding( padding: const EdgeInsets.all(ZetaSpacing.xl_1), child: ZetaPhoneInput( - enabled: enabled, + disabled: !enabled, label: 'Phone number', - hint: 'Enter your phone number', + hintText: 'Enter your phone number', countries: countries.isEmpty ? null : countries.toUpperCase().split(','), - useRootNavigator: false, ), ); }, diff --git a/lib/src/components/dropdown/dropdown.dart b/lib/src/components/dropdown/dropdown.dart index 6785428b..e62baf50 100644 --- a/lib/src/components/dropdown/dropdown.dart +++ b/lib/src/components/dropdown/dropdown.dart @@ -477,17 +477,19 @@ class _DropdownItemState extends State<_DropdownItem> { statesController: controller, style: _getStyle(colors), child: Padding( - padding: widget.menuType == ZetaDropdownMenuType.radio - ? const EdgeInsets.only(right: ZetaSpacing.medium) - : const EdgeInsets.symmetric( - vertical: ZetaSpacingBase.x2_5, - horizontal: ZetaSpacing.medium, - ), + padding: const EdgeInsets.symmetric( + vertical: ZetaSpacingBase.x2_5, + horizontal: ZetaSpacing.medium, + ), child: Row( mainAxisSize: MainAxisSize.min, children: [ if (leading != null) leading, - Expanded(child: Text(widget.value.label)), + Expanded( + child: Text( + widget.value.label, + ), + ), ], ), ), @@ -639,21 +641,23 @@ class _ZetaDropDownMenuState extends State<_ZetaDropDownMenu> { ) : null, child: IntrinsicWidth( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: widget.items - .map((item) { - return _DropdownItem( - value: item, - onPress: () => widget.onSelected(item), - selected: item.value == widget.selected, - allocateLeadingSpace: widget.allocateLeadingSpace, - menuType: widget.menuType, - ); - }) - .divide(const SizedBox(height: ZetaSpacing.minimum)) - .toList(), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: widget.items + .map((item) { + return _DropdownItem( + value: item, + onPress: () => widget.onSelected(item), + selected: item.value == widget.selected, + allocateLeadingSpace: widget.allocateLeadingSpace, + menuType: widget.menuType, + ); + }) + .divide(const SizedBox(height: ZetaSpacing.minimum)) + .toList(), + ), ), ), ); diff --git a/lib/src/components/phone_input/countries_dialog.dart b/lib/src/components/phone_input/countries_dialog.dart deleted file mode 100644 index 173aaaa7..00000000 --- a/lib/src/components/phone_input/countries_dialog.dart +++ /dev/null @@ -1,217 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import '../../../zeta_flutter.dart'; -import 'countries.dart'; - -/// Class for [CountriesDialog] -class CountriesDialog extends ZetaStatefulWidget { - ///Constructor of [CountriesDialog] - const CountriesDialog({ - super.key, - super.rounded, - this.zeta, - required this.button, - required this.items, - required this.onChanged, - this.enabled = true, - this.searchHint, - this.useRootNavigator = true, - }); - - /// Sometimes it is needed to pass an instance of [Zeta] from outside. - final Zeta? zeta; - - /// The button, which opens the dialog. - final Widget button; - - /// List of [CountriesMenuItem] - final List items; - - /// Called when an item is selected. - final ValueSetter onChanged; - - /// Determines if the button should be enabled (default) or disabled. - final bool enabled; - - /// The hint to be shown inside the country search input field. - /// Default is `Search by name or dial code`. - final String? searchHint; - - /// Determines if the root navigator should be used. - final bool useRootNavigator; - - @override - State createState() => _CountriesDialogState(); - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(IterableProperty('items', items)) - ..add(ObjectFlagProperty>.has('onChanged', onChanged)) - ..add(DiagnosticsProperty('enabled', enabled)) - ..add(DiagnosticsProperty('useRootNavigator', useRootNavigator)) - ..add(StringProperty('searchHint', searchHint)); - } -} - -class _CountriesDialogState extends State { - Future _showCountriesDialog( - BuildContext context, { - Zeta? zeta, - required List items, - bool barrierDismissible = true, - bool useRootNavigator = true, - }) => - showDialog( - context: context, - barrierDismissible: barrierDismissible, - useRootNavigator: useRootNavigator, - builder: (_) => _CountriesList( - items: items, - searchHint: widget.searchHint, - zeta: zeta, - ), - ); - - @override - Widget build(BuildContext context) { - return ZetaRoundedScope( - rounded: context.rounded, - child: InkWell( - onTap: widget.items.isEmpty || !widget.enabled - ? null - : () async { - final item = await _showCountriesDialog( - context, - zeta: widget.zeta, - items: widget.items, - useRootNavigator: widget.useRootNavigator, - ); - widget.onChanged(item); - }, - child: widget.button, - ), - ); - } -} - -class _CountriesList extends StatefulWidget { - const _CountriesList({ - required this.items, - this.searchHint, - this.zeta, - }); - - final Zeta? zeta; - final List items; - final String? searchHint; - - @override - State<_CountriesList> createState() => _CountriesListState(); - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(IterableProperty('items', items)) - ..add(StringProperty('searchHint', searchHint)); - } -} - -class _CountriesListState extends State<_CountriesList> { - late final bool _enableSearch = widget.items.length > 20; - List _items = []; - - @override - void initState() { - super.initState(); - _items = List.from(widget.items); - } - - void _search(String? text) { - final value = text ?? ''; - setState(() { - _items = widget.items.where((item) { - return item.value.name.toLowerCase().contains(value.toLowerCase()) || - (RegExp(r'^\d+$').hasMatch(value) && item.value.dialCode.indexOf('+$value') == 0); - }).toList(); - }); - } - - @override - Widget build(BuildContext context) { - final zeta = widget.zeta ?? Zeta.of(context); - - return AlertDialog( - surfaceTintColor: zeta.colors.surfacePrimary, - shape: const RoundedRectangleBorder(borderRadius: ZetaRadius.large), - content: SizedBox( - width: double.maxFinite, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (_enableSearch) - Padding( - padding: const EdgeInsets.only(bottom: ZetaSpacing.large), - child: ZetaSearchBar( - onChanged: _search, - hint: widget.searchHint ?? 'Country or dial code', - shape: ZetaWidgetBorder.full, - showSpeechToText: false, - ), - ), - if (_enableSearch) - Expanded( - child: _listView(context), - ) - else - _listView(context), - Padding( - padding: const EdgeInsets.only(top: ZetaSpacing.large), - child: TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Close'), // TODO(UX-1003): Needs localization - ), - ), - ], - ), - ), - ); - } - - Widget _listView(BuildContext context) => ListView.builder( - shrinkWrap: true, - itemCount: _items.length, - itemBuilder: (_, index) => Semantics( - button: true, - child: InkWell( - onTap: () { - Navigator.of(context).pop(_items[index].value); - }, - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: ZetaSpacing.small, - ), - child: _items[index].child, - ), - ), - ), - ); -} - -/// [CountriesMenuItem] -/// Item for the country selection dialog. -class CountriesMenuItem { - /// Constructor for [CountriesMenuItem]. - const CountriesMenuItem({ - required this.value, - required this.child, - }); - - /// The selected value from the list. - final Country value; - - /// The widget which will represent each item in the list. - final Widget child; -} diff --git a/lib/src/components/phone_input/phone_input.dart b/lib/src/components/phone_input/phone_input.dart index d9a75c13..4d183000 100644 --- a/lib/src/components/phone_input/phone_input.dart +++ b/lib/src/components/phone_input/phone_input.dart @@ -2,28 +2,32 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; + import '../../../zeta_flutter.dart'; + +import '../../interfaces/form_field.dart'; +import '../text_input/hint_text.dart'; +import '../text_input/input_label.dart'; import 'countries.dart'; -import 'countries_dialog.dart'; /// ZetaPhoneInput allows entering phone numbers. /// {@category Components} -class ZetaPhoneInput extends ZetaStatefulWidget { +class ZetaPhoneInput extends ZetaFormField { /// Constructor for [ZetaPhoneInput]. const ZetaPhoneInput({ super.key, super.rounded, + super.initialValue, + super.onChange, + super.requirementLevel = ZetaFormFieldRequirement.none, this.label, - this.hint, - this.enabled = true, + this.hintText, + super.disabled = false, this.hasError = false, this.errorText, - this.onChanged, - this.countryDialCode, - this.phoneNumber, + this.initialCountry, this.countries, - this.countrySearchHint, - this.useRootNavigator = true, + this.size = ZetaWidgetSize.medium, this.selectCountrySemanticLabel, }); @@ -31,10 +35,7 @@ class ZetaPhoneInput extends ZetaStatefulWidget { final String? label; /// If provided, displays a hint below the input field. - final String? hint; - - /// Determines if the inputs should be enabled (default) or disabled. - final bool enabled; + final String? hintText; /// Determines if the input field should be displayed in error style. /// Default is `false`. @@ -45,24 +46,16 @@ class ZetaPhoneInput extends ZetaStatefulWidget { /// to be displayed below the input field. final String? errorText; - /// A callback, which provides the entered phone number. - final void Function(Map?)? onChanged; - - /// The initial value for the country dial code including leading + - final String? countryDialCode; - - /// The initial value for the phone number - final String? phoneNumber; + /// The initial value for the selected country. + final String? initialCountry; /// List of countries ISO 3166-1 alpha-2 codes final List? countries; - /// The hint to be shown inside the country search input field. - /// Default is `Search by name or dial code`. - final String? countrySearchHint; - - /// Determines if the root navigator should be used in the [CountriesDialog]. - final bool useRootNavigator; + /// The size of the input. + /// + /// Setting this to small will have no effect. + final ZetaWidgetSize size; /// The semantic label for the country selection button. /// @@ -76,30 +69,49 @@ class ZetaPhoneInput extends ZetaStatefulWidget { super.debugFillProperties(properties); properties ..add(StringProperty('label', label)) - ..add(StringProperty('hint', hint)) - ..add(DiagnosticsProperty('enabled', enabled)) + ..add(StringProperty('hint', hintText)) + ..add(DiagnosticsProperty('enabled', disabled)) ..add(DiagnosticsProperty('rounded', rounded)) ..add(DiagnosticsProperty('hasError', hasError)) ..add(StringProperty('errorText', errorText)) - ..add(ObjectFlagProperty? p1)?>.has('onChanged', onChanged)) - ..add(StringProperty('countryDialCode', countryDialCode)) - ..add(StringProperty('phoneNumber', phoneNumber)) + ..add(StringProperty('countryDialCode', initialCountry)) ..add(IterableProperty('countries', countries)) - ..add(DiagnosticsProperty('useRootNavigator', useRootNavigator)) - ..add(StringProperty('countrySearchHint', countrySearchHint)) + ..add(EnumProperty('size', size)) ..add(StringProperty('selectCountrySemanticLabel', selectCountrySemanticLabel)); } } class _ZetaPhoneInputState extends State { - bool _hasError = false; late List _countries; + late List> _dropdownItems; late Country _selectedCountry; late String _phoneNumber; + final FocusNode _inputFocusNode = FocusNode(); + + ZetaWidgetSize get _size => widget.size == ZetaWidgetSize.small ? ZetaWidgetSize.medium : widget.size; + @override void initState() { super.initState(); + _setCountries(); + _setInitialCountry(); + _setDropdownItems(); + } + + @override + void didUpdateWidget(ZetaPhoneInput oldWidget) { + if (oldWidget.countries != widget.countries) { + _setCountries(); + setState(_setDropdownItems); + } + if (oldWidget.initialCountry != widget.initialCountry) { + setState(_setInitialCountry); + } + super.didUpdateWidget(oldWidget); + } + + void _setCountries() { _countries = widget.countries?.isEmpty ?? true ? Countries.list : Countries.list.where((country) => widget.countries!.contains(country.isoCode)).toList(); @@ -111,18 +123,32 @@ class _ZetaPhoneInputState extends State { ); } if (_countries.isEmpty) _countries = Countries.list; + } + + void _setInitialCountry() { _selectedCountry = _countries.firstWhereOrNull( - (country) => country.dialCode == widget.countryDialCode, + (country) => country.isoCode == widget.initialCountry, ) ?? _countries.first; - _phoneNumber = widget.phoneNumber ?? ''; - _hasError = widget.hasError; + _phoneNumber = widget.initialValue ?? ''; } - @override - void didUpdateWidget(ZetaPhoneInput oldWidget) { - super.didUpdateWidget(oldWidget); - _hasError = widget.hasError; + void _setDropdownItems() { + _dropdownItems = _countries + .map( + (country) => ZetaDropdownItem( + value: country.dialCode, + icon: Image.asset( + country.flagUri, + package: 'zeta_flutter', + width: 26, + height: 18, + fit: BoxFit.fitHeight, + ), + label: '${country.name} (${country.dialCode})', + ), + ) + .toList(); } void _onChanged({Country? selectedCountry, String? phoneNumber}) { @@ -130,248 +156,122 @@ class _ZetaPhoneInputState extends State { if (selectedCountry != null) _selectedCountry = selectedCountry; if (phoneNumber != null) _phoneNumber = phoneNumber; }); - widget.onChanged?.call( - _phoneNumber.isEmpty - ? {} - : { - 'countryDialCode': _selectedCountry.dialCode, - 'phoneNumber': _phoneNumber, - }, - ); + widget.onChange?.call('${_selectedCountry.dialCode}$_phoneNumber'); } @override Widget build(BuildContext context) { final zeta = Zeta.of(context); - final showError = _hasError && widget.errorText != null; final rounded = context.rounded; - final hintErrorColor = widget.enabled - ? showError - ? zeta.colors.red - : zeta.colors.cool.shade70 - : zeta.colors.cool.shade50; return Semantics( - enabled: widget.enabled, - excludeSemantics: !widget.enabled, + enabled: !widget.disabled, + excludeSemantics: widget.disabled, child: 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, - ), - ), + if (widget.label != null) ...[ + ZetaInputLabel( + label: widget.label!, + requirementLevel: widget.requirementLevel, + disabled: widget.disabled, ), - SizedBox( - width: double.infinity, - child: Row( - children: [ - Semantics( - button: true, - excludeSemantics: true, - enabled: widget.enabled, - label: widget.selectCountrySemanticLabel, - child: SizedBox( - width: ZetaSpacing.xl_9, - height: ZetaSpacing.xl_8, - child: DecoratedBox( + const SizedBox(height: ZetaSpacing.minimum), + ], + Row( + children: [ + ZetaDropdown( + offset: const Offset(0, ZetaSpacing.medium), + onChange: !widget.disabled + ? (value) { + setState(() { + _selectedCountry = _countries.firstWhere((country) => country.dialCode == value.value); + }); + _inputFocusNode.requestFocus(); + } + : null, + value: _selectedCountry.dialCode, + onDismissed: () => setState(() {}), + items: _dropdownItems, + builder: (context, selectedItem, controller) { + final borderSide = BorderSide( + color: widget.disabled ? zeta.colors.borderDefault : zeta.colors.borderSubtle, + ); + + return GestureDetector( + onTap: !widget.disabled ? controller.toggle : null, + child: Container( + constraints: BoxConstraints( + maxHeight: widget.size == ZetaWidgetSize.large ? ZetaSpacing.xl_8 : ZetaSpacing.xl_6, + ), decoration: BoxDecoration( - color: widget.enabled ? zeta.colors.surfacePrimary : zeta.colors.cool.shade30, - borderRadius: rounded - ? const BorderRadius.only( - topLeft: Radius.circular(ZetaSpacing.minimum), - bottomLeft: Radius.circular(ZetaSpacing.minimum), - ) - : ZetaRadius.none, - border: Border( - top: BorderSide(color: zeta.colors.cool.shade40), - bottom: BorderSide(color: zeta.colors.cool.shade40), - left: BorderSide(color: zeta.colors.cool.shade40), + borderRadius: BorderRadius.only( + topLeft: rounded ? const Radius.circular(ZetaSpacing.minimum) : Radius.zero, + bottomLeft: rounded ? const Radius.circular(ZetaSpacing.minimum) : Radius.zero, ), - ), - child: CountriesDialog( - zeta: zeta, - useRootNavigator: widget.useRootNavigator, - enabled: widget.enabled, - searchHint: widget.countrySearchHint, - button: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only( - left: ZetaSpacingBase.x2_5, - ), - child: Image.asset( - _selectedCountry.flagUri, - package: 'zeta_flutter', - width: 26, - height: 18, - fit: BoxFit.fitHeight, - ), - ), - ZetaIcon( - ZetaIcons.expand_more, - color: widget.enabled ? zeta.colors.textDefault : zeta.colors.cool.shade50, - size: ZetaSpacing.xl_1, - ), - ], + border: Border( + left: borderSide, + top: borderSide, + bottom: borderSide, ), - items: _countries - .map( - (country) => CountriesMenuItem( - value: country, - child: Row( - children: [ - SizedBox( - width: 60, - child: Text(country.dialCode), - ), - Expanded( - child: Text(country.name), - ), - ], - ), - ), - ) - .toList(), - onChanged: (value) => _onChanged(selectedCountry: value), - ), - ), - ), - ), - Expanded( - child: TextFormField( - maxLength: 20, - initialValue: widget.phoneNumber, - enabled: widget.enabled, - inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[\d\s\-]'))], - keyboardType: TextInputType.phone, - onChanged: (value) => _onChanged(phoneNumber: value), - style: ZetaTextStyles.bodyMedium.copyWith( - color: widget.enabled ? zeta.colors.textDefault : zeta.colors.cool.shade50, - ), - decoration: InputDecoration( - counterText: '', - isDense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 10, + color: widget.disabled ? zeta.colors.surfaceDisabled : zeta.colors.surfaceDefault, ), - hintStyle: ZetaTextStyles.bodyMedium.copyWith( - color: widget.enabled ? zeta.colors.textDefault : zeta.colors.cool.shade50, - ), - prefixIcon: Row( - mainAxisSize: MainAxisSize.min, + child: Column( children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: ZetaSpacing.small), - child: Text( - _selectedCountry.dialCode, - style: ZetaTextStyles.bodyMedium.copyWith( - color: widget.enabled ? zeta.colors.textDefault : zeta.colors.cool.shade50, - ), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only( + left: ZetaSpacing.medium, + right: ZetaSpacing.small, + ), + child: selectedItem?.icon, + ), + ZetaIcon( + !controller.isOpen ? ZetaIcons.expand_more : ZetaIcons.expand_less, + color: !widget.disabled ? zeta.colors.iconDefault : zeta.colors.iconDisabled, + size: ZetaSpacing.xl_1, + ), + ], ), ), ], ), - prefixIconConstraints: const BoxConstraints( - minHeight: ZetaSpacing.xl_8, - minWidth: ZetaSpacing.xl_6, - ), - filled: true, - fillColor: widget.enabled - ? _hasError - ? zeta.colors.red.shade10 - : zeta.colors.surfacePrimary - : zeta.colors.cool.shade30, - enabledBorder: _hasError - ? _errorInputBorder(zeta, rounded: rounded) - : _defaultInputBorder(zeta, rounded: rounded), - focusedBorder: _hasError - ? _errorInputBorder(zeta, rounded: rounded) - : _focusedInputBorder(zeta, rounded: rounded), - disabledBorder: _defaultInputBorder(zeta, rounded: rounded), - errorBorder: _errorInputBorder(zeta, rounded: rounded), - focusedErrorBorder: _errorInputBorder(zeta, rounded: rounded), ), + ); + }, + ), + Expanded( + child: textInputWithBorder( + initialValue: widget.initialValue, + disabled: widget.disabled, + size: _size, + requirementLevel: widget.requirementLevel, + rounded: rounded, + focusNode: _inputFocusNode, + errorText: widget.errorText != null ? '' : null, + inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[\d\s\-]'))], + keyboardType: TextInputType.phone, + onChange: (value) => _onChanged(phoneNumber: value), + prefixText: _selectedCountry.dialCode, + borderRadius: BorderRadius.only( + topRight: rounded ? const Radius.circular(ZetaSpacing.minimum) : Radius.zero, + bottomRight: rounded ? const Radius.circular(ZetaSpacing.minimum) : Radius.zero, ), ), - ], - ), - ), - if (widget.hint != null || showError) - Padding( - padding: const EdgeInsets.only(top: 5), - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only(right: ZetaSpacing.small), - child: ZetaIcon( - (showError && widget.enabled) ? ZetaIcons.error : ZetaIcons.info, - size: ZetaSpacing.large, - color: hintErrorColor, - ), - ), - Expanded( - child: Text( - showError && widget.enabled ? widget.errorText! : widget.hint!, - style: ZetaTextStyles.bodyXSmall.copyWith( - color: hintErrorColor, - ), - ), - ), - ], ), - ), + ], + ), + ZetaHintText( + disabled: widget.disabled, + rounded: rounded, + hintText: widget.hintText, + errorText: widget.errorText, + ), ], ), ); } - - OutlineInputBorder _defaultInputBorder( - Zeta zeta, { - required bool rounded, - }) => - OutlineInputBorder( - borderRadius: rounded - ? const BorderRadius.only( - topRight: Radius.circular(ZetaSpacing.minimum), - bottomRight: Radius.circular(ZetaSpacing.minimum), - ) - : ZetaRadius.none, - borderSide: BorderSide(color: zeta.colors.cool.shade40), - ); - - OutlineInputBorder _focusedInputBorder( - Zeta zeta, { - required bool rounded, - }) => - OutlineInputBorder( - borderRadius: rounded - ? const BorderRadius.only( - topRight: Radius.circular(ZetaSpacing.minimum), - bottomRight: Radius.circular(ZetaSpacing.minimum), - ) - : ZetaRadius.none, - borderSide: BorderSide(color: zeta.colors.blue.shade50), - ); - - OutlineInputBorder _errorInputBorder( - Zeta zeta, { - required bool rounded, - }) => - OutlineInputBorder( - borderRadius: rounded - ? const BorderRadius.only( - topRight: Radius.circular(ZetaSpacing.minimum), - bottomRight: Radius.circular(ZetaSpacing.minimum), - ) - : ZetaRadius.none, - borderSide: BorderSide(color: zeta.colors.red.shade50), - ); } diff --git a/lib/src/components/text_input/hint_text.dart b/lib/src/components/text_input/hint_text.dart new file mode 100644 index 00000000..d3fbec76 --- /dev/null +++ b/lib/src/components/text_input/hint_text.dart @@ -0,0 +1,75 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import '../../../zeta_flutter.dart'; + +/// A widget that displays hint or error text below a form field. +class ZetaHintText extends ZetaStatelessWidget { + /// Creates a new [ZetaHintText] + const ZetaHintText({ + required this.disabled, + required this.hintText, + required this.errorText, + super.rounded, + super.key, + }); + + /// If true, the hint text will be disabled. + final bool disabled; + + /// The hint text. + final String? hintText; + + /// The error text. If defined, it will be shown instead of the hint text. + 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 Nothing(); + } + + return Row( + children: [ + ZetaIcon( + errorText != null ? ZetaIcons.error : ZetaIcons.info, + size: ZetaSpacing.large, + color: elementColor, + ), + const SizedBox( + width: ZetaSpacing.minimum, + ), + Expanded( + child: Text( + text, + style: ZetaTextStyles.bodyXSmall.copyWith(color: elementColor), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ).paddingTop(ZetaSpacing.small); + } + + @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/text_input/input_label.dart b/lib/src/components/text_input/input_label.dart new file mode 100644 index 00000000..d65f0a5d --- /dev/null +++ b/lib/src/components/text_input/input_label.dart @@ -0,0 +1,70 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import '../../../zeta_flutter.dart'; + +/// A widget that displays a label above a form field. +class ZetaInputLabel extends ZetaStatelessWidget { + /// Creates a new [ZetaInputLabel] + const ZetaInputLabel({ + required this.label, + required this.requirementLevel, + required this.disabled, + super.key, + }); + + /// The label text. + final String label; + + /// The requirement level of the field. + /// + /// If set to [ZetaFormFieldRequirement.optional], the label will display '(optional)'. + /// If set to [ZetaFormFieldRequirement.mandatory], the label will have an asterix next to it. + final ZetaFormFieldRequirement requirementLevel; + + /// If true, the label will be disabled. + 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.minimum), + ], + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(StringProperty('label', label)) + ..add(EnumProperty('requirementLevel', requirementLevel)) + ..add(DiagnosticsProperty('disabled', disabled)); + } +} diff --git a/lib/src/components/text_input/text_input.dart b/lib/src/components/text_input/text_input.dart index 94bcb1e4..6f64798f 100644 --- a/lib/src/components/text_input/text_input.dart +++ b/lib/src/components/text_input/text_input.dart @@ -4,6 +4,66 @@ import 'package:flutter/services.dart'; import '../../../zeta_flutter.dart'; import '../../interfaces/form_field.dart'; +import 'hint_text.dart'; +import 'input_label.dart'; + +/// Helper function to create a text input with a custom border. +/// Not intended for external use. +Widget textInputWithBorder({ + Key? key, + ValueChanged? onChange, + bool disabled = false, + ZetaFormFieldRequirement requirementLevel = ZetaFormFieldRequirement.none, + String? initialValue, + bool rounded = false, + String? label, + String? hintText, + String? placeholder, + String? errorText, + TextEditingController? controller, + String? Function(String?)? validator, + Widget? suffix, + Widget? prefix, + ZetaWidgetSize size = ZetaWidgetSize.medium, + List? inputFormatters, + String? prefixText, + TextStyle? prefixTextStyle, + String? suffixText, + TextStyle? suffixTextStyle, + void Function(String? val)? onSubmit, + bool obscureText = false, + TextInputType? keyboardType, + FocusNode? focusNode, + BorderRadius? borderRadius, +}) { + return ZetaTextInput._border( + key: key, + onChange: onChange, + disabled: disabled, + requirementLevel: requirementLevel, + initialValue: initialValue, + rounded: rounded, + label: label, + hintText: hintText, + placeholder: placeholder, + errorText: errorText, + controller: controller, + validator: validator, + suffix: suffix, + prefix: prefix, + size: size, + inputFormatters: inputFormatters, + prefixText: prefixText, + prefixTextStyle: prefixTextStyle, + suffixText: suffixText, + suffixTextStyle: suffixTextStyle, + onSubmit: onSubmit, + obscureText: obscureText, + keyboardType: keyboardType, + focusNode: focusNode, + borderRadius: borderRadius, + ); +} /// Text inputs allow the user to enter text. /// @@ -38,11 +98,42 @@ class ZetaTextInput extends ZetaFormField { this.suffixTextStyle, this.onSubmit, this.obscureText = false, + this.keyboardType, + this.focusNode, this.semanticLabel, - }) : assert(initialValue == null || controller == null, 'Only one of initial value and controller can be accepted.'), + }) : borderRadius = null, + 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.'); + const ZetaTextInput._border({ + super.key, + super.onChange, + super.disabled = false, + super.requirementLevel = ZetaFormFieldRequirement.none, + super.initialValue, + super.rounded, + this.label, + this.hintText, + this.placeholder, + this.errorText, + this.controller, + this.validator, + this.suffix, + this.prefix, + this.size = ZetaWidgetSize.medium, + this.inputFormatters, + this.prefixText, + this.prefixTextStyle, + this.suffixText, + this.suffixTextStyle, + this.onSubmit, + this.obscureText = false, + this.keyboardType, + this.focusNode, + this.borderRadius, + }) : semanticLabel = null; + /// {@template text-input-label} /// The label displayed above the input. /// {@endtemplate} @@ -110,6 +201,15 @@ class ZetaTextInput extends ZetaFormField { /// {@endtemplate} final bool obscureText; + /// The keyboard type of the input. + final TextInputType? keyboardType; + + /// The focus node of the input. + final FocusNode? focusNode; + + /// The border radius of the input. + final BorderRadius? borderRadius; + /// Value passed to the wrapping [Semantics] widget. /// /// If null, the label will be used. @@ -142,6 +242,9 @@ class ZetaTextInput extends ZetaFormField { ..add(EnumProperty('requirementLevel', requirementLevel)) ..add(ObjectFlagProperty.has('onSubmit', onSubmit)) ..add(DiagnosticsProperty('obscureText', obscureText)) + ..add(DiagnosticsProperty('keyboardType', keyboardType)) + ..add(DiagnosticsProperty('focusNode', focusNode)) + ..add(DiagnosticsProperty('borderRadius', borderRadius)) ..add(StringProperty('semanticLabel', semanticLabel)); } } @@ -178,10 +281,16 @@ class ZetaTextInputState extends State implements ZetaFormFieldSt EdgeInsets get _contentPadding { switch (widget.size) { case ZetaWidgetSize.large: - return const EdgeInsets.symmetric(horizontal: ZetaSpacing.medium, vertical: ZetaSpacing.large); + return const EdgeInsets.symmetric( + horizontal: ZetaSpacing.medium, + vertical: ZetaSpacing.large, + ); case ZetaWidgetSize.small: case ZetaWidgetSize.medium: - return const EdgeInsets.symmetric(horizontal: ZetaSpacing.medium, vertical: ZetaSpacing.medium); + return const EdgeInsets.symmetric( + horizontal: ZetaSpacing.medium, + vertical: ZetaSpacing.small, + ); } } @@ -194,55 +303,69 @@ class ZetaTextInputState extends State implements ZetaFormFieldSt } BoxConstraints get _affixConstraints { - late final double size; + late final double width; + late final double height; switch (widget.size) { case ZetaWidgetSize.large: - size = ZetaSpacing.xl_6; + width = ZetaSpacing.xl_6; + height = ZetaSpacing.xl_8; case ZetaWidgetSize.medium: - size = ZetaSpacing.xl_4; + width = ZetaSpacing.xl_6; + height = ZetaSpacing.xl_6; case ZetaWidgetSize.small: - size = ZetaSpacing.xl_2; + width = ZetaSpacing.xl_6; + height = ZetaSpacing.xl_4; } return BoxConstraints( - minWidth: size, + minWidth: width, + maxHeight: height, ); } - 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.small); - } + Widget? get _prefix => _getAffix( + widget: widget.prefix, + text: widget.prefixText, + textStyle: widget.prefixTextStyle, + ); - return null; - } + Widget? get _suffix => _getAffix( + widget: widget.suffix, + text: widget.suffixText, + textStyle: widget.suffixTextStyle, + ); - Widget? get _suffix { - if (widget.suffix != null) return widget.suffix; - if (widget.suffixText != null) { - final style = widget.suffixTextStyle ?? _affixStyle; - return Center( - widthFactor: 0, + Widget? _getAffix({ + required Widget? widget, + required String? text, + required TextStyle? textStyle, + }) { + if (widget == null && text == null) return null; + + late final Widget child; + if (widget != null) child = widget; + if (text != null) { + child = Center( + widthFactor: 1, child: Text( - widget.suffixText!, - style: style, + text, ), - ).paddingEnd(ZetaSpacing.small); + ).paddingHorizontal(ZetaSpacing.small); } - - return null; + final style = textStyle ?? _affixStyle; + return DefaultTextStyle( + style: style.copyWith(height: 1.5), + textHeightBehavior: const TextHeightBehavior( + applyHeightToFirstAscent: false, + ), + child: child, + ); } OutlineInputBorder _baseBorder(bool rounded) => OutlineInputBorder( - borderRadius: rounded ? ZetaRadius.minimal : ZetaRadius.none, - borderSide: BorderSide(color: _hovered ? _colors.borderSelected : _colors.borderSubtle), + borderRadius: widget.borderRadius ?? (rounded ? ZetaRadius.minimal : ZetaRadius.none), + borderSide: BorderSide( + color: !widget.disabled ? (_hovered ? _colors.borderSelected : _colors.borderSubtle) : _colors.borderDefault, + ), ); OutlineInputBorder _focusedBorder(bool rounded) => _baseBorder(rounded).copyWith( @@ -301,7 +424,7 @@ class ZetaTextInputState extends State implements ZetaFormFieldSt crossAxisAlignment: CrossAxisAlignment.start, children: [ if (widget.label != null) ...[ - _Label( + ZetaInputLabel( label: widget.label!, requirementLevel: widget.requirementLevel, disabled: widget.disabled, @@ -309,12 +432,21 @@ class ZetaTextInputState extends State implements ZetaFormFieldSt const SizedBox(height: ZetaSpacing.minimum), ], MouseRegion( - onEnter: !widget.disabled ? (_) => setState(() => _hovered = true) : null, - onExit: !widget.disabled ? (_) => setState(() => _hovered = false) : null, + onEnter: !widget.disabled + ? (_) => setState(() { + _hovered = true; + }) + : null, + onExit: !widget.disabled + ? (_) => setState(() { + _hovered = false; + }) + : null, child: TextFormField( enabled: !widget.disabled, key: _key, controller: _controller, + keyboardType: widget.keyboardType, inputFormatters: widget.inputFormatters, validator: (val) { setState(() { @@ -328,6 +460,7 @@ class ZetaTextInputState extends State implements ZetaFormFieldSt style: _baseTextStyle, cursorErrorColor: _colors.error, obscureText: widget.obscureText, + focusNode: widget.focusNode, decoration: InputDecoration( isDense: true, contentPadding: _contentPadding, @@ -335,7 +468,7 @@ class ZetaTextInputState extends State implements ZetaFormFieldSt prefixIcon: _prefix, prefixIconConstraints: widget.prefixText != null ? _affixConstraints : null, suffixIcon: _suffix, - suffixIconConstraints: _affixConstraints, + suffixIconConstraints: widget.suffixText != null ? _affixConstraints : null, focusColor: _backgroundColor, hoverColor: _backgroundColor, fillColor: _backgroundColor, @@ -351,7 +484,7 @@ class ZetaTextInputState extends State implements ZetaFormFieldSt ), ), ), - _HintText( + ZetaHintText( disabled: widget.disabled, rounded: rounded, hintText: widget.hintText, @@ -363,123 +496,3 @@ class ZetaTextInputState extends State implements ZetaFormFieldSt ); } } - -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.minimum), - ], - ); - } - - @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 Nothing(); - } - - return ExcludeSemantics( - child: Row( - children: [ - ZetaIcon( - errorText != null ? ZetaIcons.error : ZetaIcons.info, - size: ZetaSpacing.large, - color: elementColor, - ), - const SizedBox( - width: ZetaSpacing.minimum, - ), - Expanded( - child: Text( - text, - style: ZetaTextStyles.bodyXSmall.copyWith(color: elementColor), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ).paddingTop(ZetaSpacing.small), - ); - } - - @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)); - } -}