I am building a Flutter Web application and would like for the user to be prompted to save the inputted credit card information to his browser. I have set up the AutoFillGroup
with their respectives autoFillHints
.
I have created a accessory widget to handle my TextFormField
widgets. Much of it may not be relevant, but I will share it for context. It is as follows:
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Manages focus traversal for a custom text field.
/// This class holds a FocusNode and optionally links to the next and previous focus nodes.
class TextFieldFocusManager {
FocusNode focusNode = FocusNode();
TextFieldFocusManager? _next;
TextFieldFocusManager? _previous;
TextFieldFocusManager({TextFieldFocusManager? next, TextFieldFocusManager? previous}) {
_next = next;
_previous = previous;
}
/// Moves focus to the next field, if set.
focusNext() {
if (_next != null) {
FocusScope.of(focusNode.context!).requestFocus(_next!.focusNode);
}
}
/// Moves focus to the previous field, if set.
focusPrevious() {
if (_previous != null) {
FocusScope.of(focusNode.context!).requestFocus(_previous!.focusNode);
}
}
/// Sets focus on this field.
focusSelf() {
FocusScope.of(focusNode.context!).requestFocus(focusNode);
}
/// Sets the next focus manager. Throws if already set.
setNext(TextFieldFocusManager next) {
if (_next == null) {
_next = next;
} else {
throw Exception('Next focus node already set');
}
}
/// Sets the previous focus manager. Throws if already set.
setPrevious(TextFieldFocusManager previous) {
if (_previous == null) {
_previous = previous;
} else {
throw Exception('Previous focus node already set');
}
}
}
/// A custom text field widget with validation, theming, and autofill support.
class WhiteTextField extends StatefulWidget {
final TextEditingController controller;
final String? hintText;
final String? labelText;
/// Validator function: returns an error message if the input is invalid, or null if valid.
final String? Function(String?)? validator;
final Widget? suffixIcon;
final List<TextInputFormatter>? inputFormatters;
/// The minimum number of characters required for the field to be considered complete.
final int? minValidationLength;
/// Whether the field is interactive.
final bool enabled;
final TextFieldFocusManager focusManager;
final TextInputAction textInputAction;
final TextInputType? keyboardType;
/// Autofill hints for the field.
final Iterable<String>? autofillHints;
const WhiteTextField({
super.key,
required this.controller,
required this.focusManager,
required this.textInputAction,
this.keyboardType = TextInputType.text,
this.hintText,
this.labelText,
this.validator,
this.suffixIcon,
this.inputFormatters,
this.minValidationLength,
this.enabled = true,
this.autofillHints,
});
@override
State<WhiteTextField> createState() => _WhiteTextFieldState();
}
class _WhiteTextFieldState extends State<WhiteTextField> {
late final FocusNode _focusNode;
Color _borderColor = Colors.grey;
String? _errorText;
@override
void initState() {
super.initState();
// Use the focus node provided by the focus manager.
_focusNode = widget.focusManager.focusNode;
// Listen to changes in focus and text to update validation.
_focusNode.addListener(_updateValidationState);
widget.controller.addListener(_updateValidationState);
_updateValidationState();
}
/// Updates the border color and error text based on the current input and focus.
void _updateValidationState() {
// If the field is disabled, use a grey border and show no error.
if (!widget.enabled) {
setState(() {
_borderColor = Colors.grey;
_errorText = null;
});
return;
}
final text = widget.controller.text;
// Check if the field meets the minimum length requirement.
final isComplete = widget.minValidationLength == null
? true
: text.length >= widget.minValidationLength!;
if (!isComplete) {
// If the field is not complete, skip validation.
_errorText = null;
// Use blue border when focused, grey otherwise.
_borderColor = _focusNode.hasFocus ? Colors.blue : Colors.grey;
} else {
// Field is complete; run the validator if provided.
if (widget.validator != null) {
final error = widget.validator!(text);
if (error == null) {
_borderColor = Colors.green;
_errorText = null;
} else {
_borderColor = Colors.red;
_errorText = error;
}
} else {
// No validator provided; assume the input is valid.
_borderColor = Colors.green;
_errorText = null;
}
}
if (mounted) setState(() {});
}
@override
void dispose() {
_focusNode.removeListener(_updateValidationState);
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Determine text and background colors based on field state.
final textColor = widget.enabled ? Colors.black : Colors.grey.shade700;
final fillColor = widget.enabled ? Colors.white : Colors.grey.shade100;
// If a suffix icon is provided and the field is valid, tint it green.
Widget? suffix = widget.suffixIcon;
if (widget.enabled && suffix != null && _errorText == null && _borderColor == Colors.green) {
suffix = IconTheme(
data: const IconThemeData(color: Colors.green),
child: widget.suffixIcon!,
);
}
return TextFormField(
controller: widget.controller,
onFieldSubmitted: (value) {
// If the action is "done", dismiss the keyboard; otherwise, move focus to the next field.
if (widget.textInputAction == TextInputAction.done) {
FocusScope.of(context).unfocus();
} else {
widget.focusManager.focusNext();
}
},
onEditingComplete: () => widget.focusManager.focusNext(),
focusNode: _focusNode,
enabled: widget.enabled,
validator: widget.validator,
inputFormatters: widget.inputFormatters,
style: TextStyle(color: textColor),
// Pass autofill hints to the underlying input.
autofillHints: widget.autofillHints,
decoration: InputDecoration(
hintText: widget.hintText,
labelText: widget.labelText,
errorText: _errorText,
errorMaxLines: 2,
suffixIcon: suffix,
filled: true,
fillColor: fillColor,
hintStyle: TextStyle(color: Colors.grey[500]),
labelStyle: const TextStyle(color: Colors.black),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: BorderSide(color: _borderColor),
),
disabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: const BorderSide(color: Colors.grey),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: BorderSide(color: _borderColor),
),
),
textInputAction: widget.textInputAction,
keyboardType: widget.keyboardType,
);
}
}
And, when and how I create the Form
is rather complicated, but here is the snippet where I would like for the AutoFillGroup
to work:
Widget _buildCheckoutForm(BuildContext context) {
final keyboardType = defaultTargetPlatform == TargetPlatform.iOS
? TextInputType.text
: TextInputType.number;
final textTheme = Theme.of(context).textTheme;
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Form(
key: _formKey,
child: AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Card Information header
Text('Card Information', style: textTheme.bodyMedium),
const SizedBox(height: 8),
WhiteTextField(
focusManager: _cardNumberFocusManager,
textInputAction: TextInputAction.next,
controller: _cardNumberController,
enabled: !_isProcessing,
hintText: '1234 1234 1234 1234',
suffixIcon: getCardNetworkIcon(_currentCardNetwork),
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
CreditCardNumberInputFormatter(),
],
minValidationLength: 19,
validator: Validators.validateCardNumber,
keyboardType: keyboardType,
autofillHints: const [AutofillHints.creditCardNumber],
),
const SizedBox(height: 8),
// Row for Expiration Date and CVV
Row(
children: [
Expanded(
child: WhiteTextField(
focusManager: _expiryDateFocusManager,
textInputAction: TextInputAction.next,
controller: _expiryDateController,
enabled: !_isProcessing,
hintText: 'MM/YY',
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
CreditCardExpirationDateInputFormatter(),
],
minValidationLength: 5,
validator: Validators.validateExpiryDate,
keyboardType: keyboardType,
autofillHints: const [AutofillHints.creditCardExpirationDate],
),
),
const SizedBox(width: 8),
Expanded(
child: WhiteTextField(
focusManager: _cvvFocusManager,
textInputAction: TextInputAction.next,
controller: _cvvController,
enabled: !_isProcessing,
hintText: 'CVV',
suffixIcon: const Icon(Icons.lock),
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(3),
],
minValidationLength: 3,
validator: Validators.validateCVV,
keyboardType: keyboardType,
autofillHints: const [AutofillHints.creditCardSecurityCode],
),
),
],
),
const SizedBox(height: 16),
// Cardholder Name
Text('Name on Card', style: textTheme.bodyMedium),
const SizedBox(height: 8),
WhiteTextField(
focusManager: _cardHolderFocusManager,
textInputAction: TextInputAction.next,
controller: _cardHolderController,
enabled: !_isProcessing,
hintText: 'Name printed on card',
inputFormatters: [
LengthLimitingTextInputFormatter(50),
],
minValidationLength: 5,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter the name printed on the card';
}
return null;
},
keyboardType: TextInputType.text,
autofillHints: const [AutofillHints.creditCardName],
),
const SizedBox(height: 16),
// ID
Text('ID Number', style: textTheme.bodyMedium),
const SizedBox(height: 8),
WhiteTextField(
focusManager: _documentNumberFocusManager,
textInputAction: TextInputAction.done,
controller: _documentNumberController,
enabled: !_isProcessing,
hintText: 'Just numbers',
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
minValidationLength: 14,
keyboardType: keyboardType,
),
],
),
),
),
),
const SizedBox(height: 16),
// Checkbox to save card data.
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Checkbox(
value: _saveCardData,
onChanged: _isProcessing
? null
: (bool? value) {
setState(() {
_saveCardData = value ?? false;
});
},
),
const Text('Save card data'),
],
),
),
const SizedBox(height: 4),
// Pay button.
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: _isSuccess ? Colors.green : Colors.black,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
),
onPressed: _processCreditCardPayment,
child: const Text(
'Pay',
style: TextStyle(color: Colors.white, fontSize: 16),
),
),
),
),
const SizedBox(height: 24),
const PaymentFooter(),
],
);
}
I have tested it on Chrome desktop and mobile (Android) with no success. I tried running it locally and deployed in a https
secure website. In the generated HTML, the autocomplete tags are correct for every field.
I also saw that the keyboardType
could have an influence on the autoFillHints
working. However, on Android where keyboardType = TextInputType.number
, it also did not work.