joanpablo / reactive_forms

This is a model-driven approach to handling form inputs and validations, heavily inspired in Angular's Reactive Forms
MIT License
470 stars 86 forks source link

DefaultValueAccessor() used for ReactiveTextField<double> #141

Closed BenjiFarquhar closed 3 years ago

BenjiFarquhar commented 3 years ago

version: reactive_forms: ^10.2.0

I have a number text field which has a model value of type double, but somehow the defaultValueAccessor ends up being used, even when I explicitly pass in a DoubleValueAccessor(). This causes this error to occur:

Unhandled Exception: type 'double' is not a subtype of type 'String?' in type cast

Is there anything wrong with my code or is this a bug?

import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:reactive_forms/reactive_forms.dart';
import 'package:flutter/material.dart';

class VpNumberField extends ReactiveFormField<double, String> {
  VpNumberField(
      {required String formControlName,
      required this.hintText,
      required this.context,
      this.labelText,
      this.textInputType = TextInputType.number,
      this.textColor,
      this.maxLength,
      bool hasFocus = false,
      this.textAlign = TextAlign.left,
      this.form,
      this.onSubmitted,
      Key? key,
      this.textInputAction = TextInputAction.done,
      this.nextField,
      this.inputFormatters,
      Map<String, String> Function(AbstractControl<double>)? validationMessages,
      this.onFocusChange})
      : super(
            key: key,
            formControlName: formControlName,
            builder: (ReactiveFormFieldState<double, String> field) {
              return FocusScope(
                  child: Focus(
                onFocusChange: (isFocused) {
                  hasFocus = isFocused;
                  if (onFocusChange != null) {
                    onFocusChange(isFocused);
                  }
                  // ignore: invalid_use_of_protected_member
                  field.setState(() {});
                },
                child: ReactiveTextField<double>(
                  valueAccessor: DoubleValueAccessor(),
                  keyboardType: textInputType,
                  textInputAction: textInputAction,
                  onSubmitted: onSubmitted ??
                      (() {
                        if (form != null && nextField != null) {
                          form.focus(nextField);
                        }
                      }),
                  inputFormatters: inputFormatters,
                  formControlName: formControlName,
                  validationMessages: validationMessages,
                  maxLength: maxLength,
                  decoration: InputDecoration(
                      prefixIconConstraints: const BoxConstraints(
                        minWidth: 2,
                        minHeight: 2,
                      ),
                      prefixIcon: Container(
                          padding: const EdgeInsets.fromLTRB(20, 0, 10, 4),
                          child: FaIcon(FontAwesomeIcons.dollarSign,
                              size: 20,
                              color: Theme.of(context!)
                                  .textTheme
                                  .subtitle1!
                                  .color!)),
                      errorMaxLines: 3,
                      suffixIconConstraints: const BoxConstraints(
                        minWidth: 2,
                        minHeight: 2,
                      ),
                      alignLabelWithHint: true,
                      labelStyle: TextStyle(
                          height: 0,
                          fontSize: hasFocus ? 24 : 18.0,
                          color: Theme.of(context).primaryColor),
                      hintText: hintText,
                      labelText: labelText,
                      counterText: ''),
                  textAlign: textAlign,
                ),
              ));
            });

  final BuildContext? context;
  final TextAlign textAlign;
  final String hintText;
  final String? labelText;
  final int? maxLength;
  final void Function(bool)? onFocusChange;
  final void Function()? onSubmitted;
  final Color? textColor;
  bool? hasFocus;
  final TextInputType textInputType;

  /// This is usually used to transform the text to lower case for emails ect.
  final List<TextInputFormatter>? inputFormatters;

  /// This is only required when setting the next field to focus on.
  final FormGroup? form;

  /// This is only required when setting the next field to focus on.
  final String? nextField;

  /// This is to be 'next' if not the last field, and 'done' when is the last field.
  final TextInputAction textInputAction;

  @override
  ReactiveFormFieldState<double, String> createState() =>
      ReactiveFormFieldState<double, String>();
}

This is my usage of it:

  Widget priceTextField({BuildContext? context}) {
    return Container(
        padding:
            const EdgeInsets.fromLTRB(PAD_3_HALF, PAD_0, PAD_3_HALF, PAD_4),
        child: VpNumberField(
            key: Key(MenuItemFieldKeys.menuItemPrice),
            textInputType: const TextInputType.numberWithOptions(decimal: true),
            context: context,
            labelText: 'Price',
            formControlName: MenuItemFieldNames.price,
            hintText: 'Price...',
            validationMessages: (control) => {
                  ValidationMessage.number: 'Product Price must be a number',
                  ValidationMessage.required: 'Please enter the Product Price',
                }));
  }

The validation messages are also mixed up. When I enter a number it says "Product Price must be a number" and when I enter a word it says: "Please enter the Product Price".

This is the form used:

  FormGroup createItemLocationForm() {
    return FormGroup({
      GroceryItemFieldNames.price: FormControl<double>(
          validators: [Validators.required, Validators.number]),
      GroceryItemFieldNames.groceryStore: FormControl<GroceryStoreDomainEntity>(
        validators: [Validators.required],
        asyncValidatorsDebounceTime: 300,
      ),
    });
  }
kuhnroyal commented 3 years ago

You are extending ReactiveFormField and wrapping a ReactiveTextField, both are bound to the same control. Only the "inner" ReactiveTextField uses the custom value accessor. It seems you don't need the outer ReactiveFormField, in your case this can just be a StatelessWidget that wraps the ReactiveTextField.

BenjiFarquhar commented 3 years ago

@kuhnroyal Thanks, I will take that on board to make a change. For the short term, I added the DoubleValueAccessor to the outer element like this:

: super(
            valueAccessor: DoubleValueAccessor(),

Which solved the value accessor issue. The validation messages are still very confused as mentioned in the original issue.

kuhnroyal commented 3 years ago

The validation messages are still very confused as mentioned in the original issue.

Likely when you enter a word, the DoubleValueAccessor evaluates the input to null and this the required message shows up. I am not sure what the number validator actually checks. This could be due to int/double, locale problems. You can try to add an input formatter on the field that only allows valid number characters to be entered.

BenjiFarquhar commented 3 years ago

@kuhnroyal Thank you, after extending StatelessWidget the value accessor issue is resolved. It was getting confused between the two reactive form controls as you suggested. When control.value is 88.00 it still displays "Product Price must be a number". I think the number validator is not good for double number types. So I don't think an input formatter will work because you can't convert a double to an int with precision. I would have to change the control to a String control, which maybe I will need to do, not sure, but it seems like more work than is necessary.

kuhnroyal commented 3 years ago

Yea the number validator uses this regex:

static final RegExp numberRegex = RegExp(r'^-?[0-9]+$');

You probably have to add a custom decimal validator but be aware of locale specific decimal separators. You can add a FilteringTextInputFormatter.allow with a regex that fits your double requirements. Like this (not tested): '^\\d{0,4}(\\${format.symbols.DECIMAL_SEP}{0,2})?)'.

An another hint from my personal experience, TextInputType.numberWithOptions doesn't work with Samsung keyboards.

joanpablo commented 3 years ago

Hi guys,

Thanks to @BenjaminFarquhar for the issue and thanks once again to @kuhnroyal for such a great help solving issues, I really appreciate it.

Yeap, the Validators.number is actually just validating integers (maybe we will need to create two differents validators one for integers and another for floating points that let you specify the precision). And @BenjaminFarquhar you can of course for the moment create your own validator that validates floating points and restrict the input with a Formatter.

BenjiFarquhar commented 3 years ago

Thanks heaps guys. I'm nearly there. I have used an input formatter:

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';

class CurrencyFormatter extends TextInputFormatter {
  CurrencyFormatter({this.maxDigits, required this.context});
  final int? maxDigits;
  final BuildContext context;

  TextEditingValue formatEditUpdate(
      TextEditingValue oldValue, TextEditingValue newValue) {
    if (newValue.selection.baseOffset == 0) {
      return newValue;
    }

    if (maxDigits != null && newValue.selection.baseOffset > maxDigits!) {
      return oldValue;
    }

    final double value = double.parse(newValue.text);
    final Locale locale = Localizations.localeOf(context);
    final formatter = NumberFormat.simpleCurrency(locale: locale.toString());
    final String newText = formatter.format(value / 100);
    return newValue.copyWith(
        text: newText,
        selection: TextSelection.collapsed(offset: newText.length));
  }
}

and pass into the ReactiveTextField:

        inputFormatters: [
          FilteringTextInputFormatter.digitsOnly,
          CurrencyFormatter(maxDigits: 8, context: context),
        ],

However, inside the package method: Map<String, String> _getValidationMessages(FormControl<dynamic> control) {, control.value is still always null, even if the textField has 8.00 entered. Any ideas why that is? Thank you so much.

BenjiFarquhar commented 3 years ago

Ah, I forgot to mention I have a very basic double validator:

import 'package:reactive_forms/reactive_forms.dart';

Map<String, bool>? floatValidator(AbstractControl<dynamic> control) {
  if (control.isNotNull && control.value is double) {
    return null;
  } else {
    return <String, bool>{'float': true};
  }
}

class VpValidators {
  static ValidatorFunction get optionSelected => optionSelectedValidator
      as Map<String, Object>? Function(AbstractControl<dynamic>);
  static ValidatorFunction get float =>
      floatValidator as Map<String, Object>? Function(AbstractControl<dynamic>);
}

  FormGroup createItemLocationForm() {
    return FormGroup({
      GroceryItemFieldNames.price: FormControl<double>(
          validators: [Validators.required, VpValidators.float]),
      GroceryItemFieldNames.groceryStore: FormControl<GroceryStoreDomainEntity>(
        validators: [Validators.required],
      ),
    });
  }
BenjiFarquhar commented 3 years ago

After I added the CurrencyFormatter, it added currency characters such as '$' and ',' and then the standard DoubleValueAccessor can't cope with that.

I needed to create a CurrencyValueAccessor to convert the string created by the formatter back to a double:

import 'package:flutter/material.dart';
import 'package:reactive_forms/reactive_forms.dart';
import 'package:intl/intl.dart';

class CurrencyValueAccessor extends ControlValueAccessor<double, String> {
  CurrencyValueAccessor({this.fractionDigits = 2, required this.context});

  final int fractionDigits;
  final BuildContext context;

  @override
  String modelToViewValue(double? modelValue) {
    return modelValue == null ? '' : modelValue.toStringAsFixed(fractionDigits);
  }

  @override
  double? viewToModelValue(String? viewValue) {
    if (viewValue == '' || viewValue == null) {
      return null;
    }

    final Locale locale = Localizations.localeOf(context);
    final formatter = NumberFormat.simpleCurrency(locale: locale.toString());
    return formatter.parse(viewValue).toDouble();
  }
}

Thanks @kuhnroyal for mentioning that

"the DoubleValueAccessor evaluates the input to null".

Thanks @joanpablo for also explaining the issue and the options I can take :)

joanpablo commented 3 years ago

It could be interesting to have a CurrencyValueAccessor built-in in the package.

BenjiFarquhar commented 3 years ago

Yep that could be very handy

escamoteur commented 3 years ago

Actually a CurrencyValueAccessor that automatically converts an int (in cents) to a double for display and entering would be pretty cool.

joanpablo commented 3 years ago

Actually a CurrencyValueAccessor that automatically converts an int (in cents) to a double for display and entering would be pretty cool.

I'm agree with you guys. Please create an issue for this new feature (and any Pull Request will be also nicely received :) )

joanpablo commented 3 years ago

In this thread I see two possible improvement: 1-) Create a CurrencyValueAccessor 2-) Create a Validators.floatingNumber (or something like that)