diff --git a/.gitignore b/.gitignore index 7165536..0d12ff1 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,5 @@ migrate_working_dir/ .packages build/ credentials.json +.flutter-plugins +.flutter-plugins-dependencies \ No newline at end of file diff --git a/README.md b/README.md index aadf226..45369f1 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,37 @@ # Flutter Credit/Debit Card Form -### Preview +## Features + +- [x] Scan card with camera +- [x] Set value programmatically +- [x] Create a custom theme +- [x] Listen for input value changes +- [x] Show card type icon + +## Preview -### Usage +## Usage ```dart +import 'package:credit_card_form/credit_card_form.dart'; + +... + +CardDataInputController controller = CardDataInputController(); + CreditCardForm( theme: CreditCardLightTheme(), - onChanged: (CreditCardResult result) { - print(result.cardNumber); - print(result.cardHolderName); - print(result.expirationMonth); - print(result.expirationYear); - print(result.cardType); - print(result.cvc); + controller: controller, + onChanged: (CardData data) { + print(data.cardNumber); + print(data.cardHolderName); + print(data.expiredDate); + print(data.expiredMonth); + print(data.expiredYear); + print(data.cardType); + print(data.cvc); }, ), ``` @@ -23,7 +39,7 @@ CreditCardForm( | Param | Description | | -------------------- | ------------------------------------------------------------ | | `theme` | card theme `CreditCardLightTheme()` or `CreditCardDarkTheme` | -| `onChanged` required | listen for input values changed | +| `onChanged`(required)| listen for input values changed | | `cardNumberLabel` | label for card number input | | `cardHolderLabel` | label for card holder name input | | `hideCardHolder` | default (false) | @@ -33,28 +49,26 @@ CreditCardForm( | `cvcLength` | length for security code. default (4) | | `cvcIcon` | Icon widget for security code. | | `fontSize` | font size for all inputs and labels. default (16) | -| `controller` | `CreditCardController()` to set initial value to inputs | - -### Set Credit Card Value Initially +| `controller` | `CardDataInputController()` | +| `enableScanner` | default (false), If set to true, please ensure you have granted camera permission in android and ios| +| `scannerIcon` | Icon widget for scanner button. | -```dart -CreditCardController controller = CreditCardController(); +Note: For more information about enabling scanner, please refer to the [Card Scanner Package](https://pub.dev/packages/card_scanner) -CreditCardForm( - controller: controller, - onChanged: (CreditCardResult result) { - }, -), +## Set Credit Card Value Programmatically -controller.setValue(CreditCardValue( - cardNumber: '4242 4242 4242 4242', - cardHolderName: 'John Wick', - expiryDate: '08/25', -)); +```dart +CardDataInputController controller = CardDataInputController(); +controller.value = CardData( + cardNumber: '4242424242424242', + cardHolderName: 'Zin Kyaw Kyaw', + expiredDate: '11/23', + cvc: '123', +); ``` -### How to create custom theme +## How to create custom theme ```dart class CustomCardTheme implements CreditCardTheme { @@ -70,10 +84,11 @@ class CustomCardTheme implements CreditCardTheme { CreditCardForm( theme: CustomCardTheme(), - onChanged: (CreditCardResult result) { + onChanged: (CardData data) { }, ), ``` -### Development +## Development + Want to contribute? Great! Fork the repo and create PR to us. diff --git a/images/scanner.png b/images/scanner.png new file mode 100644 index 0000000..e499cab Binary files /dev/null and b/images/scanner.png differ diff --git a/images/unionpay.png b/images/unionpay.png new file mode 100644 index 0000000..966a498 Binary files /dev/null and b/images/unionpay.png differ diff --git a/lib/card_types.dart b/lib/card_types.dart new file mode 100644 index 0000000..3c87bd8 --- /dev/null +++ b/lib/card_types.dart @@ -0,0 +1,15 @@ +part of credit_card_form; + +enum CardType { + master, + visa, + verve, + discover, + americanExpress, + dinersClub, + jcb, + others, + unionPay, + mir, + invalid +} diff --git a/lib/component.dart b/lib/component.dart deleted file mode 100644 index 4ce9eb2..0000000 --- a/lib/component.dart +++ /dev/null @@ -1,240 +0,0 @@ -part of credit_card_form; - -class CreditCardForm extends StatefulWidget { - final String? cardNumberLabel; - final String? cardHolderLabel; - final bool? hideCardHolder; - final String? expiredDateLabel; - final String? cvcLabel; - final Widget? cvcIcon; - final int? cardNumberLength; - final int? cvcLength; - final double fontSize; - final CreditCardTheme? theme; - final Function(CreditCardResult) onChanged; - final CreditCardController? controller; - const CreditCardForm({ - super.key, - this.theme, - required this.onChanged, - this.cardNumberLabel, - this.cardHolderLabel, - this.hideCardHolder = false, - this.expiredDateLabel, - this.cvcLabel, - this.cvcIcon, - this.cardNumberLength = 16, - this.cvcLength = 4, - this.fontSize = 16, - this.controller, - }); - - @override - State createState() => _CreditCardFormState(); -} - -class _CreditCardFormState extends State { - Map params = { - "card": '', - "expired_date": '', - "card_holder_name": '', - "cvc": '', - }; - - Map cardImg = { - "img": 'credit_card.png', - "width": 30.0, - }; - - Map controllers = { - "card": TextEditingController(), - "expired_date": TextEditingController(), - "card_holder_name": TextEditingController(), - "cvc": TextEditingController(), - }; - - String error = ''; - - CardType? cardType; - - @override - void dispose() { - controllers.forEach((key, value) => value.dispose()); - super.dispose(); - } - - @override - void initState() { - handleController(); - super.initState(); - } - - @override - Widget build(BuildContext context) { - CreditCardTheme theme = widget.theme ?? CreditCardLightTheme(); - return Container( - decoration: BoxDecoration( - color: theme.backgroundColor, - border: Border.all(color: theme.borderColor, width: 1), - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(10), - topRight: Radius.circular(10), - bottomLeft: Radius.circular(10), - bottomRight: Radius.circular(10), - ), - ), - child: Column( - children: [ - TextInputWidget( - theme: theme, - fontSize: widget.fontSize, - controller: controllers['card'], - label: widget.cardNumberLabel ?? 'Card number', - bottom: 1, - formatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(widget.cardNumberLength), - CardNumberInputFormatter(), - ], - onChanged: (val) { - Map img = CardUtils.getCardIcon(val); - CardType type = - CardUtils.getCardTypeFrmNumber(val.replaceAll(' ', '')); - setState(() { - cardImg = img; - cardType = type; - params['card'] = val; - }); - emitResult(); - }, - suffixIcon: Padding( - padding: const EdgeInsets.all(8), - child: Image.asset( - 'images/${cardImg['img']}', - package: 'credit_card_form', - width: cardImg['width'] as double?, - ), - ), - ), - if (widget.hideCardHolder == false) - TextInputWidget( - theme: theme, - fontSize: widget.fontSize, - label: widget.cardHolderLabel ?? 'Card holder name', - controller: controllers['card_holder_name'], - bottom: 1, - onChanged: (val) { - setState(() { - params['card_holder_name'] = val; - }); - emitResult(); - }, - keyboardType: TextInputType.name, - ), - Row( - children: [ - Expanded( - child: TextInputWidget( - theme: theme, - fontSize: widget.fontSize, - label: widget.expiredDateLabel ?? 'MM/YY', - right: 1, - onChanged: (val) { - setState(() { - params['expired_date'] = val; - }); - emitResult(); - }, - controller: controllers['expired_date'], - formatters: [ - CardExpirationFormatter(), - LengthLimitingTextInputFormatter(5) - ], - ), - ), - Expanded( - child: TextInputWidget( - theme: theme, - fontSize: widget.fontSize, - label: widget.cvcLabel ?? 'CVC', - controller: controllers['cvc'], - password: true, - onChanged: (val) { - setState(() { - params['cvc'] = val; - }); - emitResult(); - }, - formatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(widget.cvcLength) - ], - suffixIcon: Padding( - padding: const EdgeInsets.all(8), - child: widget.cvcIcon ?? - Image.asset( - 'images/cvc.png', - package: 'credit_card_form', - height: 25, - ), - ), - ), - ) - ], - ) - ], - ), - ); - } - - emitResult() { - List res = params['expired_date'].split('/'); - CreditCardResult result = CreditCardResult( - cardNumber: params['card'].replaceAll(' ', ''), - cvc: params['cvc'], - cardHolderName: params['card_holder_name'], - expirationMonth: res[0] ?? '', - expirationYear: res.asMap().containsKey(1) ? res[1] : '', - cardType: cardType, - ); - widget.onChanged(result); - } - - handleController() { - if (widget.controller != null) { - widget.controller?.addListener(() { - CreditCardValue? initialValue = widget.controller?.value; - if (initialValue?.cardNumber != null) { - TextEditingValue cardNumber = - FilteringTextInputFormatter.digitsOnly.formatEditUpdate( - const TextEditingValue(text: ''), - TextEditingValue(text: initialValue!.cardNumber.toString()), - ); - - cardNumber = LengthLimitingTextInputFormatter(19).formatEditUpdate( - const TextEditingValue(text: ''), - TextEditingValue(text: cardNumber.text), - ); - - cardNumber = CardNumberInputFormatter().formatEditUpdate( - const TextEditingValue(text: ''), - TextEditingValue(text: cardNumber.text), - ); - - controllers['card']?.value = cardNumber; - } - if (initialValue?.cardHolderName != null) { - controllers['card_holder_name']?.text = - initialValue!.cardHolderName.toString(); - } - if (initialValue?.expiryDate != null) { - controllers['expired_date']?.value = - CardExpirationFormatter().formatEditUpdate( - const TextEditingValue(text: ''), - TextEditingValue(text: initialValue!.expiryDate.toString()), - ); - } - }); - } - } -} diff --git a/lib/credit_card_form.dart b/lib/credit_card_form.dart index c050779..a27acd0 100644 --- a/lib/credit_card_form.dart +++ b/lib/credit_card_form.dart @@ -1,59 +1,13 @@ library credit_card_form; +import 'package:card_scanner/card_scanner.dart'; import 'package:credit_card_form/text_input_widget.dart'; import 'package:credit_card_form/utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -part 'component.dart'; +part 'card_types.dart'; +part 'credit_card_form_widget.dart'; +part 'scanner_button.dart'; part 'theme.dart'; - -enum CardType { - master, - visa, - verve, - discover, - americanExpress, - dinersClub, - jcb, - others, - mir, - invalid -} - -class CreditCardController extends ChangeNotifier { - CreditCardValue value = CreditCardValue(); - - void setValue(CreditCardValue initialValue) { - value = initialValue; - notifyListeners(); - } -} - -class CreditCardResult { - final String cardNumber; - final String cvc; - final String cardHolderName; - final String expirationMonth; - final String expirationYear; - final CardType? cardType; - const CreditCardResult({ - required this.cardNumber, - required this.cvc, - required this.cardHolderName, - required this.expirationMonth, - required this.expirationYear, - this.cardType, - }); -} - -class CreditCardValue { - String? cardNumber; - String? cardHolderName; - String? expiryDate; - CreditCardValue({ - this.cardNumber, - this.cardHolderName, - this.expiryDate, - }); -} +part 'types.dart'; diff --git a/lib/credit_card_form_widget.dart b/lib/credit_card_form_widget.dart new file mode 100644 index 0000000..3706049 --- /dev/null +++ b/lib/credit_card_form_widget.dart @@ -0,0 +1,177 @@ +part of credit_card_form; + +class CreditCardForm extends StatefulWidget { + final String? cardNumberLabel; + final String? cardHolderLabel; + final bool? hideCardHolder; + final bool? enableScanner; + final Widget? scannerIcon; + final CardScanOptions? scanOptions; + final String? expiredDateLabel; + final String? cvcLabel; + final Widget? cvcIcon; + final int? cardNumberLength; + final int? cvcLength; + final double fontSize; + final CreditCardTheme? theme; + final Function(CardData) onChanged; + final CardDataInputController controller; + const CreditCardForm({ + super.key, + this.theme, + required this.onChanged, + this.cardNumberLabel, + this.cardHolderLabel, + this.hideCardHolder = false, + this.enableScanner = false, + this.scannerIcon, + this.scanOptions, + this.expiredDateLabel, + this.cvcLabel, + this.cvcIcon, + this.cardNumberLength = 16, + this.cvcLength = 4, + this.fontSize = 16, + required this.controller, + }); + + @override + State createState() => _CreditCardFormState(); +} + +class _CreditCardFormState extends State { + // card image + CardImage cardImage = const CardImage(); + + // card type + CardType? cardType; + + @override + void dispose() { + widget.controller.dispose(); + super.dispose(); + } + + @override + void initState() { + super.initState(); + widget.controller.cardNumber.addListener(() { + setState(() { + cardImage = CardUtils.getCardIcon(widget.controller.cardNumber.text); + cardType = widget.controller.value.cardType; + emitResult(); + }); + }); + widget.controller.cardHolderName.addListener(() { + emitResult(); + }); + widget.controller.expiredDate.addListener(() { + emitResult(); + }); + widget.controller.cvc.addListener(() { + emitResult(); + }); + } + + @override + Widget build(BuildContext context) { + CreditCardTheme theme = widget.theme ?? CreditCardLightTheme(); + return Container( + decoration: BoxDecoration( + color: theme.backgroundColor, + border: Border.all(color: theme.borderColor, width: 1), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(10), + topRight: Radius.circular(10), + bottomLeft: Radius.circular(10), + bottomRight: Radius.circular(10), + ), + ), + child: Column(children: [ + // [card number] + TextInputWidget( + theme: theme, + fontSize: widget.fontSize, + controller: widget.controller.cardNumber, + label: widget.cardNumberLabel ?? 'Card number', + bottom: 1, + formatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(widget.cardNumberLength), + CardNumberInputFormatter(), + ], + onChanged: noop, + suffixIcon: Padding( + padding: const EdgeInsets.all(8), + child: widget.enableScanner == true && cardType == null + ? ScannerButton( + controller: widget.controller, + onChanged: widget.onChanged, + scannerIcon: widget.scannerIcon, + scanOptions: widget.scanOptions, + ) + : cardImage.render(), + ), + ), + // [card holder name] + if (widget.hideCardHolder == false) + TextInputWidget( + theme: theme, + fontSize: widget.fontSize, + label: widget.cardHolderLabel ?? 'Card holder name', + controller: widget.controller.cardHolderName, + bottom: 1, + onChanged: noop, + keyboardType: TextInputType.name, + ), + // [expired date] + Row( + children: [ + Expanded( + child: TextInputWidget( + theme: theme, + fontSize: widget.fontSize, + label: widget.expiredDateLabel ?? 'MM/YY', + right: 1, + onChanged: noop, + controller: widget.controller.expiredDate, + formatters: [ + CardExpirationFormatter(), + LengthLimitingTextInputFormatter(5) + ], + ), + ), + // [cvc] + Expanded( + child: TextInputWidget( + theme: theme, + fontSize: widget.fontSize, + label: widget.cvcLabel ?? 'CVC', + controller: widget.controller.cvc, + password: true, + onChanged: noop, + formatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(widget.cvcLength) + ], + suffixIcon: Padding( + padding: const EdgeInsets.all(8), + child: widget.cvcIcon ?? + Image.asset( + 'images/cvc.png', + package: 'credit_card_form', + height: 25, + ), + ), + ), + ) + ], + ), + ]), + ); + } + + emitResult() { + widget.onChanged(widget.controller.value); + } +} diff --git a/lib/scanner_button.dart b/lib/scanner_button.dart new file mode 100644 index 0000000..bf9175e --- /dev/null +++ b/lib/scanner_button.dart @@ -0,0 +1,52 @@ +part of credit_card_form; + +class ScannerButton extends StatelessWidget { + final CardDataInputController controller; + + final CardScanOptions? scanOptions; + + final Widget? scannerIcon; + + final Function(CardData) onChanged; + + const ScannerButton({ + super.key, + required this.controller, + required this.onChanged, + this.scanOptions, + this.scannerIcon, + }); + + void onTap() async { + CardDetails? cardDetails = await CardScanner.scanCard( + scanOptions: scanOptions ?? + const CardScanOptions( + scanExpiryDate: true, + cardScannerTimeOut: 3000, + enableLuhnCheck: false, + considerPastDatesInExpiryDateScan: true, + ), + ); + if (cardDetails != null) { + controller.value = CardData( + cardNumber: cardDetails.cardNumber, + cardHolderName: cardDetails.cardHolderName, + expiredDate: cardDetails.expiryDate, + ); + onChanged(controller.value); + } + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: scannerIcon ?? + Image.asset( + 'images/scanner.png', + package: 'credit_card_form', + width: 30.0, + ), + ); + } +} diff --git a/lib/types.dart b/lib/types.dart new file mode 100644 index 0000000..4ae5c90 --- /dev/null +++ b/lib/types.dart @@ -0,0 +1,134 @@ +part of credit_card_form; + +class CreditCardController extends ChangeNotifier { + CardData value = CardData(); + + void setValue(CardData initialValue) { + value = initialValue; + notifyListeners(); + } +} + +class CardData { + String cardNumber; + String expiredDate; + String cardHolderName; + String cvc; + CardType? cardType; + + CardData({ + this.cardNumber = '', + this.expiredDate = '', + this.cardHolderName = '', + this.cvc = '', + this.cardType, + }) { + // remove all spaces + cardNumber = cardNumber.replaceAll(' ', ''); + } + + // get expired month + String get expiredMonth { + if (expiredDate.isEmpty) return ''; + List year = expiredDate.split('/'); + if (year.isNotEmpty) return year[0]; + return ''; + } + + // get expired year + String get expiredYear { + if (expiredDate.isEmpty) return ''; + List year = expiredDate.split('/'); + if (year.length > 1) return year[1]; + return ''; + } +} + +class CardDataInputController extends ChangeNotifier { + TextEditingController cardNumber; + TextEditingController expiredDate; + TextEditingController cardHolderName; + TextEditingController cvc; + + CardDataInputController({ + TextEditingController? cardNumber, + TextEditingController? expiredDate, + TextEditingController? cardHolderName, + TextEditingController? cvc, + }) : cardNumber = cardNumber ?? TextEditingController(), + expiredDate = expiredDate ?? TextEditingController(), + cardHolderName = cardHolderName ?? TextEditingController(), + cvc = cvc ?? TextEditingController(); + + @override + void dispose() { + super.dispose(); + cardNumber.dispose(); + expiredDate.dispose(); + cardHolderName.dispose(); + cvc.dispose(); + } + + set value(CardData data) { + // format to text only + TextEditingValue formattedCardNumber = + FilteringTextInputFormatter.digitsOnly.formatEditUpdate( + const TextEditingValue(text: ''), + TextEditingValue(text: data.cardNumber.toString()), + ); + + // limit to 19 digits + formattedCardNumber = LengthLimitingTextInputFormatter(19).formatEditUpdate( + const TextEditingValue(text: ''), + TextEditingValue(text: formattedCardNumber.text), + ); + + // format to card number + formattedCardNumber = CardNumberInputFormatter().formatEditUpdate( + const TextEditingValue(text: ''), + TextEditingValue(text: formattedCardNumber.text), + ); + + cardNumber.text = formattedCardNumber.text; + + // expired date + expiredDate.value = CardExpirationFormatter().formatEditUpdate( + const TextEditingValue(text: ''), + TextEditingValue(text: data.expiredDate.toString()), + ); + + // card holder name + cardHolderName.text = data.cardHolderName; + + // cvc + cvc.text = data.cvc; + } + + CardData get value { + return CardData( + cardNumber: cardNumber.text, + expiredDate: expiredDate.text, + cardHolderName: cardHolderName.text, + cvc: cvc.text, + cardType: CardUtils.getCardTypeFrmNumber(cardNumber.text), + ); + } +} + +class CardImage { + final String img; + final double width; + + const CardImage({ + this.img = 'credit_card.png', + this.width = 30.0, + }); + + Widget render() { + return Image.asset( + 'images/$img', + package: 'credit_card_form', + width: width, + ); + } +} diff --git a/lib/utils.dart b/lib/utils.dart index a6b4731..c4e3212 100644 --- a/lib/utils.dart +++ b/lib/utils.dart @@ -51,8 +51,8 @@ class CardNumberInputFormatter extends TextInputFormatter { } class CardUtils { - static Map getCardIcon(input) { - CardType cardType = + static CardImage getCardIcon(String input) { + CardType? cardType = CardUtils.getCardTypeFrmNumber(input.replaceAll(' ', '')); String img = ""; double imgWidth = 30.0; @@ -85,6 +85,10 @@ class CardUtils { img = 'brand_mir.png'; imgWidth = 50.0; break; + case CardType.unionPay: + img = 'unionpay.png'; + imgWidth = 50.0; + break; case CardType.others: img = 'credit_card.png'; break; @@ -92,11 +96,14 @@ class CardUtils { img = 'credit_card.png'; break; } - return {"img": img, "width": imgWidth}; + return CardImage(img: img, width: imgWidth); } - static CardType getCardTypeFrmNumber(String input) { - CardType cardType; + static CardType? getCardTypeFrmNumber(String input) { + if (input.isEmpty) { + return null; + } + 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; @@ -112,6 +119,8 @@ class CardUtils { cardType = CardType.jcb; } else if (input.startsWith(RegExp(r'(220[0-4])'))) { cardType = CardType.mir; + } else if (input.startsWith(RegExp(r'(62|81)'))) { + cardType = CardType.unionPay; } else if (input.length <= 8) { cardType = CardType.others; } else { @@ -132,3 +141,5 @@ class HexColor extends Color { return int.parse(hexColor, radix: 16); } } + +void noop(_) => {}; diff --git a/pubspec.yaml b/pubspec.yaml index 371d725..2d4e685 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,15 +1,16 @@ name: credit_card_form description: Flutter Credit/Debit Card Form. -version: 0.0.7 +version: 1.0.0 homepage: https://github.com/necessarylion/flutter-credit-card-form environment: - sdk: '>=2.18.2 <3.0.0' + sdk: '>=2.18.2 <4.0.0' flutter: ">=1.17.0" dependencies: flutter: sdk: flutter + card_scanner: dev_dependencies: flutter_test: