Formulário de entrada de cartão de crédito do Flutter – No Packages

Tempo de leitura: 6 minutes

É comum usar pacotes de terceiros ao adicionar um formulário de cartão de crédito ao Flutter. Mostrarei a você uma maneira fácil de adicionar um formulário de cartão de crédito no Flutter sem usar nenhum pacote de terceiros.

Você aprenderá a identificar o tipo de cartão, se é um Visa ou um MasterCard, e como adicionar espaços após os quatro dígitos e adicionar uma barra após o mês. Você também aprenderá a validar o número do cartão.

Vamos começar, a tela é bastante simples, com alguns campos de texto e um botão.

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

class AddNewCardScreen extends StatefulWidget {
  const AddNewCardScreen({Key? key}) : super(key: key);

  @override
  State<AddNewCardScreen> createState() => _AddNewCardScreenState();
}

class _AddNewCardScreenState extends State<AddNewCardScreen> {
  TextEditingController cardNumberController = TextEditingController();

  CardType cardType = CardType.Invalid;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      appBar: AppBar(title: const Text("New card")),
      body: SafeArea(
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 16),
          child: Column(
            children: [
              const Spacer(),
              Form(
                child: Column(
                  children: [
                    TextFormField(
                      controller: cardNumberController,
                      keyboardType: TextInputType.number,
                      inputFormatters: [
                        FilteringTextInputFormatter.digitsOnly,
                        LengthLimitingTextInputFormatter(19),
                      ],
                      decoration: InputDecoration(hintText: "Card number"),
                    ),
                    Padding(
                      padding: const EdgeInsets.symmetric(vertical: 16),
                      child: TextFormField(
                        decoration:
                            const InputDecoration(hintText: "Full name"),
                      ),
                    ),
                    Row(
                      children: [
                        Expanded(
                          child: TextFormField(
                            keyboardType: TextInputType.number,
                            inputFormatters: [
                              FilteringTextInputFormatter.digitsOnly,
                              // Limit the input
                              LengthLimitingTextInputFormatter(4),
                            ],
                            decoration: const InputDecoration(hintText: "CVV"),
                          ),
                        ),
                        const SizedBox(width: 16),
                        Expanded(
                          child: TextFormField(
                            keyboardType: TextInputType.number,
                            inputFormatters: [
                              FilteringTextInputFormatter.digitsOnly,
                              LengthLimitingTextInputFormatter(5),
                            ],
                            decoration:
                                const InputDecoration(hintText: "MM/YY"),
                          ),
                        ),
                      ],
                    ),
                  ],
                ),
              ),
              const Spacer(flex: 2),
              Padding(
                padding: const EdgeInsets.only(top: 16),
                child: ElevatedButton(
                  child: const Text("Add card"),
                  onPressed: () {},
                ),
              ),
              const Spacer(),
            ],
          ),
        ),
      ),
    );
  }
}

Como o cartão ainda não foi criado, é exibido um erro no tipo de cartão.

enum CardType {
  Master,
  Visa,
  Verve,
  Discover,
  AmericanExpress,
  DinersClub,
  Jcb,
  Others,
  Invalid
}

Ao inserir o número do cartão de crédito, você perceberá que ele adiciona espaços após o quarto dígito. O TextInputFormatter nos permite fazer essas coisas no Flutter. Agora vamos criar nosso próprio CardNumberInputFormatter.

class CardNumberInputFormatter extends TextInputFormatter {
  @override
  TextEditingValue formatEditUpdate(
      TextEditingValue oldValue, TextEditingValue newValue) {
    var text = newValue.text;
if (newValue.selection.baseOffset == 0) {
      return newValue;
    }
var buffer = StringBuffer();
    for (int i = 0; i < text.length; i++) {
      buffer.write(text[i]);
      var nonZeroIndex = i + 1;
      if (nonZeroIndex % 4 == 0 && nonZeroIndex != text.length) {
        buffer.write('  '); // Add double spaces.
      }
    }
var string = buffer.toString();
    return newValue.copyWith(
        text: string,
        selection: TextSelection.collapsed(offset: string.length));
  }
}

Em seguida, volte ao TextFormField do cartão de crédito e adicione CardNumberInputFormatter a inputFormatters.

...
TextFormField(
  controller: creditCardController,
  keyboardType: TextInputType.number,
  validator: CardUtils.validateCardNum,
  inputFormatters: [
    FilteringTextInputFormatter.digitsOnly,
    LengthLimitingTextInputFormatter(19),
    CardNumberInputFormatter(),
  ],
  decoration: InputDecoration(hintText: "Card number"),
),

Vamos dar uma olhada na prévia👇

 

Precisamos fazer algo semelhante para a data de expiração, mas, em vez de espaços, precisamos de uma barra.

class CardMonthInputFormatter extends TextInputFormatter {
  @override
  TextEditingValue formatEditUpdate(
      TextEditingValue oldValue, TextEditingValue newValue) {
    var newText = newValue.text;
if (newValue.selection.baseOffset == 0) {
      return newValue;
    }
var buffer = StringBuffer();
    for (int i = 0; i < newText.length; i++) {
      buffer.write(newText[i]);
      var nonZeroIndex = i + 1;
      if (nonZeroIndex % 2 == 0 && nonZeroIndex != newText.length) {
        buffer.write('/');
      }
    }
var string = buffer.toString();
    return newValue.copyWith(
        text: string,
        selection: TextSelection.collapsed(offset: string.length));
  }
}

Em inputFormatters, defina CardMonthInputFormatter.

TextFormField(
  keyboardType: TextInputType.number,
  inputFormatters: [
    FilteringTextInputFormatter.digitsOnly,
    LengthLimitingTextInputFormatter(4),
    CardMonthInputFormatter(),
  ],
  decoration: const InputDecoration(hintText: "MM/YY"),
),

A próxima etapa é identificar o tipo de cartão, se é Visa, Mastercard ou outro. Gostaria muito de tê-lo no CardUtils.

class CardUtils {
static CardType getCardTypeFrmNumber(String input) {
    CardType cardType;
    if (input.startsWith(RegExp(
        r'((5[1-5])|(222[1-9]|22[3-9][0-9]|2[3-6][0-9]{2}|27[01][0-9]|2720))'))) {
      cardType = CardType.Master;
    } else if (input.startsWith(RegExp(r'[4]'))) {
      cardType = CardType.Visa;
    } else if (input.startsWith(RegExp(r'((506(0|1))|(507(8|9))|(6500))'))) {
      cardType = CardType.Verve;
    } else if (input.startsWith(RegExp(r'((34)|(37))'))) {
      cardType = CardType.AmericanExpress;
    } else if (input.startsWith(RegExp(r'((6[45])|(6011))'))) {
      cardType = CardType.Discover;
    } else if (input.startsWith(RegExp(r'((30[0-5])|(3[89])|(36)|(3095))'))) {
      cardType = CardType.DinersClub;
    } else if (input.startsWith(RegExp(r'(352[89]|35[3-8][0-9])'))) {
      cardType = CardType.Jcb;
    } else if (input.length <= 8) {
      cardType = CardType.Others;
    } else {
      cardType = CardType.Invalid;
    }
    return cardType;
  }
}

Sabendo o tipo de cartão de crédito, é muito fácil mostrar seu logotipo.

class CardUtils {
...
static Widget? getCardIcon(CardType? cardType) {
    String img = "";
    Icon? icon;
    switch (cardType) {
      case CardType.Master:
        img = 'mastercard.png';
        break;
      case CardType.Visa:
        img = 'visa.png';
        break;
      case CardType.Verve:
        img = 'verve.png';
        break;
      case CardType.AmericanExpress:
        img = 'american_express.png';
        break;
      case CardType.Discover:
        img = 'discover.png';
        break;
      case CardType.DinersClub:
        img = 'dinners_club.png';
        break;
      case CardType.Jcb:
        img = 'jcb.png';
        break;
      case CardType.Others:
        icon = const Icon(
          Icons.credit_card,
          size: 24.0,
          color: Color(0xFFB8B5C3),
        );
        break;
      default:
        icon = const Icon(
          Icons.warning,
          size: 24.0,
          color: Color(0xFFB8B5C3),
        );
        break;
    }
    Widget? widget;
    if (img.isNotEmpty) {
      widget = Image.asset(
        'assets/images/$img',
        width: 40.0,
      );
    } else {
      widget = icon;
    }
    return widget;
  }
}

Para melhorar a experiência do usuário, usamos espaços duplos após 4 dígitos no número do cartão. O número do cartão precisa estar sem esses espaços duplos, portanto, definiremos um método chamado getCleanedNumber.

class CardUtils {
...
static String getCleanedNumber(String text) {
    RegExp regExp = RegExp(r"[^0-9]");
    return text.replaceAll(regExp, '');
  }
}

Em AddNewCardScreen, defina um método que nos permita saber o tipo de cartão e atualizá-lo adequadamente.

void getCardTypeFrmNumber() {
    if (cardNumberController.text.length <= 6) {
      String input = CardUtils.getCleanedNumber(cardNumberController.text);
      CardType type = CardUtils.getCardTypeFrmNumber(input);
      if (type != cardType) {
        setState(() {
          cardType = type;
        });
      }
    }
  }

Sempre que o campo de texto do cartão de crédito for alterado, devemos executar o método getCardTypeFrmNumber. Você pode fazer isso adicionando um listener ao cardNumberController em initState.

@override
void initState() {
  cardNumberController.addListener(
    () {
      getCardTypeFrmNumber();
    },
  );
  super.initState();
}

Não se esqueça de descartá-lo.

  @override
  void dispose() {
    cardNumberController.dispose();
    super.dispose();
  }

De volta ao campo de texto do cartão de crédito, remova o const de InputDecoration e adicione o suffix que representa o logotipo do tipo de cartão.

TextFormField(
...
  
  decoration: InputDecoration(
    hintText: "Card number",
    suffix: CardUtils.getCardIcon(cardType),
  ),
),

Ainda não validamos o cartão de crédito, que é a etapa mais importante.

class CardUtils {
...
/// With the card number with Luhn Algorithm
/// https://en.wikipedia.org/wiki/Luhn_algorithm
  static String? validateCardNum(String? input) {
    if (input == null || input.isEmpty) {
      return "This field is required";
    }
input = getCleanedNumber(input);
if (input.length < 8) {
      return "Card is invalid";
    }
int sum = 0;
    int length = input.length;
    for (var i = 0; i < length; i++) {
      // get digits in reverse order
      int digit = int.parse(input[length - i - 1]);
// every 2nd number multiply with 2
      if (i % 2 == 1) {
        digit *= 2;
      }
      sum += digit > 9 ? (digit - 9) : digit;
    }
if (sum % 10 == 0) {
      return null;
    }
return "Card is invalid";
  }
}

Não é apenas o número do cartão que precisa ser validado, mas também o CVV e a data de validade.

class CardUtils {
...

  static String? validateCVV(String? value) {
    if (value == null || value.isEmpty) {
      return "This field is required";
    }
if (value.length < 3 || value.length > 4) {
      return "CVV is invalid";
    }
    return null;
  }
static String? validateDate(String? value) {
    if (value == null || value.isEmpty) {
      return "This field is required";
    }
int year;
int month;
if (value.contains(RegExp(r'(/)'))) {
      var split = value.split(RegExp(r'(/)'));
      
      month = int.parse(split[0]);
      year = int.parse(split[1]);
    } else {
      
      month = int.parse(value.substring(0, (value.length)));
      year = -1; // Lets use an invalid year intentionally
    }
if ((month < 1) || (month > 12)) {
      // A valid month is between 1 (January) and 12 (December)
      return 'Expiry month is invalid';
    }
var fourDigitsYear = convertYearTo4Digits(year);
    if ((fourDigitsYear < 1) || (fourDigitsYear > 2099)) {
      // We are assuming a valid should be between 1 and 2099.
      // Note that, it's valid doesn't mean that it has not expired.
      return 'Expiry year is invalid';
    }
if (!hasDateExpired(month, year)) {
      return "Card has expired";
    }
    return null;
  }
}

Basta adicionar esses validadores aos campos de texto e pronto. Aqui estão alguns métodos de bônus 🥳 que podem ser úteis para você.

/// Convert the two-digit year to four-digit year if necessary
static int convertYearTo4Digits(int year) {
  if (year < 100 && year >= 0) {
    var now = DateTime.now();
    String currentYear = now.year.toString();
    String prefix = currentYear.substring(0, currentYear.length - 2);
    year = int.parse('$prefix${year.toString().padLeft(2, '0')}');
  }
  return year;
}
static bool hasDateExpired(int month, int year) {
  return isNotExpired(year, month);
}
static bool isNotExpired(int year, int month) {
  // It has not expired if both the year and date has not passed
  return !hasYearPassed(year) && !hasMonthPassed(year, month);
}
static List<int> getExpiryDate(String value) {
  var split = value.split(RegExp(r'(/)'));
  return [int.parse(split[0]), int.parse(split[1])];
}
static bool hasMonthPassed(int year, int month) {
  var now = DateTime.now();
  // The month has passed if:
  // 1. The year is in the past. In that case, we just assume that the month
  // has passed
  // 2. Card's month (plus another month) is more than current month.
  return hasYearPassed(year) ||
      convertYearTo4Digits(year) == now.year && (month < now.month + 1);
}
static bool hasYearPassed(int year) {
  int fourDigitsYear = convertYearTo4Digits(year);
  var now = DateTime.now();
  // The year has passed if the year we are currently is more than card's
  // year
  return fourDigitsYear < now.year;
}

Aqui está o código-fonte, se quiser dar uma olhada nele.