From 88020f4d150ae0c67ecf26296d8048efce5e1b25 Mon Sep 17 00:00:00 2001 From: Nathan Anderson Date: Tue, 14 Nov 2023 09:58:49 -0700 Subject: [PATCH] WIP --- lib/card_details.dart | 295 ++++++++++++++ lib/card_provider_icon.dart | 84 ++++ lib/stripe_native_card_field.dart | 500 +++++++++++++++++++++++- pubspec.yaml | 1 + test/stripe_native_card_field_test.dart | 9 +- 5 files changed, 879 insertions(+), 10 deletions(-) create mode 100644 lib/card_details.dart create mode 100644 lib/card_provider_icon.dart diff --git a/lib/card_details.dart b/lib/card_details.dart new file mode 100644 index 0000000..bedf4c9 --- /dev/null +++ b/lib/card_details.dart @@ -0,0 +1,295 @@ + +import 'package:flutter/foundation.dart'; + +class CardDetails { + CardDetails({required this.cardNumber, required String? securityCode, required this.expirationDate}) { + this.securityCode = int.tryParse(securityCode ?? ''); + checkIsValid(); + } + + factory CardDetails.blank() { + return CardDetails(cardNumber: null, securityCode: null, expirationDate: null); + } + + String? cardNumber; + int? securityCode; + String? postalCode; + String? expirationDate; + bool _complete = false; + ValidState _validState = ValidState.blank; + int _lastCheckHash = 0; + CardProvider? provider; + + ValidState get validState { + checkIsValid(); + return _validState; + } + + bool get cardNumberFilled => provider == null || provider?.cardLength == cardNumber?.replaceAll(' ', '').length; + + bool get isComplete { + checkIsValid(); + return _complete; + } + + int get minInnLength => 1; + int get maxINNLength => 4; + + void checkIsValid() { + try { + int currentHash = hash; + if (currentHash == _lastCheckHash) { + return; + } + + _lastCheckHash = currentHash; + if (cardNumber == null && expirationDate == null && securityCode == null && postalCode == null) { + _complete = false; + _validState = ValidState.blank; + return; + } + final nums = cardNumber! + .replaceAll(' ', '') + .split('') + .map( + (i) => int.parse(i), + ) + .toList(); + if (!luhnAlgorithmCheck(nums)) { + _complete = false; + _validState = ValidState.invalidCard; + return; + } + if (cardNumber == null || !cardNumberFilled) { + _complete = false; + _validState = ValidState.missingCard; + return; + } + if (expirationDate == null) { + _complete = false; + _validState = ValidState.missingDate; + return; + } + final expSplits = expirationDate!.split('/'); + if (expSplits.length != 2 || expSplits.last == '') { + _complete = false; + _validState = ValidState.missingDate; + return; + } + final date = DateTime(2000 + int.parse(expSplits.last), + int.parse(expSplits.first[0] == '0' ? expSplits.first[1] : expSplits.first)); + if (date.isBefore(DateTime.now())) { + _complete = false; + _validState = ValidState.dateTooEarly; + return; + } else if (date.isAfter(DateTime.now().add(const Duration(days: 365 * 50)))) { + _complete = false; + _validState = ValidState.dateTooLate; + return; + } + if (securityCode == null) { + _complete = false; + _validState = ValidState.missingCVC; + return; + } + if (postalCode == null) { + _complete = false; + _validState = ValidState.missingZip; + return; + } + if (!RegExp(r'^\d{5}(-\d{4})?$').hasMatch(postalCode!)) { + _complete = false; + _validState = ValidState.invalidZip; + return; + } + _complete = true; + _validState = ValidState.ok; + } catch (err, st) { + if (kDebugMode) { + print('Error while validating CardDetails: $err\n$st'); + } + _complete = false; + _validState = ValidState.error; + } + } + + int get hash { + return Object.hash(cardNumber, expirationDate, securityCode, postalCode); + } + + void detectCardProvider() { + bool found = false; + if (cardNumber == null) { + return; + } + for (var cardPvd in providers) { + if (cardPvd.INN_VALID_NUMS != null) { + // trim card number to correct length + String trimmedNum = cardNumber!; + String innNumStr = '${cardPvd.INN_VALID_NUMS!.first}'; + if (trimmedNum.length > innNumStr.length) { + trimmedNum = trimmedNum.substring(0, innNumStr.length); + } + final num = int.tryParse(trimmedNum); + if (num == null) continue; + + if (cardPvd.INN_VALID_NUMS!.contains(num)) { + provider = cardPvd; + found = true; + break; + } + } + if (cardPvd.INN_VALID_RANGES != null) { + // trim card number to correct length + String trimmedNum = cardNumber!; + String innNumStr = '${cardPvd.INN_VALID_RANGES!.first.low}'; + if (trimmedNum.length > innNumStr.length) { + trimmedNum = trimmedNum.substring(0, innNumStr.length); + } + final num = int.tryParse(trimmedNum); + if (num == null) continue; + + if (cardPvd.INN_VALID_RANGES!.any((range) => range.isWithin(num))) { + provider = cardPvd; + found = true; + break; + } + } + } + if (!found) provider = null; + // print('Got provider $provider'); + } + + @override + String toString() { + return 'Number: "$cardNumber" - Exp: "$expirationDate" CVC: $securityCode Zip: "$postalCode"'; + } +} + +enum ValidState { + ok, + error, + blank, + missingCard, + invalidCard, + missingDate, + dateTooEarly, + dateTooLate, + missingCVC, + invalidCVC, + missingZip, + invalidZip, +} + +enum CardProviderID { + AmericanExpress, + DinersClub, + DiscoverCard, + Mastercard, + JCB, + Visa, +} + +class CardProvider { + CardProviderID id; + List? INN_VALID_NUMS; + List? INN_VALID_RANGES; + int cardLength; + int cvcLength; + + CardProvider( + {required this.id, + required this.cardLength, + required this.cvcLength, + this.INN_VALID_NUMS, + this.INN_VALID_RANGES}) { + // Must provide one or the other + assert(INN_VALID_NUMS != null || INN_VALID_RANGES != null); + // Do not provide empty list of valid nums + assert(INN_VALID_NUMS == null || INN_VALID_NUMS!.isNotEmpty); + } + + @override + String toString() { + return id.toString(); + } +} + +class Range { + int high; + int low; + + Range({required this.low, required this.high}) { + assert(low <= high); + } + + bool isWithin(int val) { + return low <= val && val <= high; + } +} + +List providers = [ + CardProvider( + id: CardProviderID.AmericanExpress, + cardLength: 15, + cvcLength: 4, + INN_VALID_NUMS: [34, 37], + ), + CardProvider( + id: CardProviderID.DinersClub, + cardLength: 16, + cvcLength: 3, + INN_VALID_NUMS: [30, 36, 38, 39], + ), + CardProvider( + id: CardProviderID.DiscoverCard, + cardLength: 16, + cvcLength: 3, + INN_VALID_NUMS: [60, 65], + INN_VALID_RANGES: [Range(low: 644, high: 649)], + ), + CardProvider( + id: CardProviderID.JCB, + cardLength: 16, + cvcLength: 3, + INN_VALID_NUMS: [35], + ), + CardProvider( + id: CardProviderID.Mastercard, + cardLength: 16, + cvcLength: 3, + INN_VALID_RANGES: [Range(low: 22, high: 27), Range(low: 51, high: 55)], + ), + CardProvider( + id: CardProviderID.Visa, + cardLength: 16, + cvcLength: 3, + INN_VALID_NUMS: [4], + ) +]; + +// https://en.wikipedia.org/wiki/Luhn_algorithm +// The Luhn algorithm is used in industry to check +// for valid credit / debit card numbers +// +// The algorithm adds together all the numbers, every +// other number is doubled, then the sum is checked to +// see if it is a multiple of 10. +bool luhnAlgorithmCheck(List digits) { + int sum = 0; + bool isSecond = false; + for (int i = digits.length - 1; i >= 0; i--) { + int d = digits[i]; + if (isSecond) { + d *= 2; + + if (d > 9) { + d -= 9; + } + } + + sum += d; + isSecond = !isSecond; + } + return (sum % 10) == 0; +} diff --git a/lib/card_provider_icon.dart b/lib/card_provider_icon.dart new file mode 100644 index 0000000..6eddb29 --- /dev/null +++ b/lib/card_provider_icon.dart @@ -0,0 +1,84 @@ +import 'card_details.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +class CardProviderIcon extends StatefulWidget { + const CardProviderIcon({required this.cardDetails, super.key}); + + final CardDetails? cardDetails; + + @override + State createState() => _CardProviderIconState(); +} + +class _CardProviderIconState extends State { + final Map cardProviderSvg = { + 'credit-card': + '', + 'error': + '', + '${CardProviderID.DiscoverCard.name}': + '', + '${CardProviderID.AmericanExpress.name}': + '', + '${CardProviderID.Mastercard.name}': + '', + '${CardProviderID.Visa.name}': + '', + '${CardProviderID.DinersClub.name}': + '', + '${CardProviderID.JCB.name}': + '', + }; + final double height = 20; + final double width = 30; + + @override + Widget build(BuildContext context) { + late Widget child; + if (widget.cardDetails?.cardNumber != null && + widget.cardDetails!.cardNumberFilled && + widget.cardDetails!.validState == ValidState.invalidCard) { + child = SvgPicture.string( + key: const Key('invalid-card'), + cardProviderSvg['error']!, + color: Colors.red, + height: height, + width: width, + ); + } else { + if (widget.cardDetails?.provider?.id == null) { + child = SvgPicture.string( + key: const Key('credit_card'), + cardProviderSvg['credit-card']!, + color: Colors.black, + height: height, + width: width, + ); + } else { + child = createCardSvg(widget.cardDetails!.provider!.id); + } + } + return AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + transitionBuilder: (child, animation) { + return ScaleTransition( + scale: animation, + child: FadeTransition( + opacity: animation, + child: child, + )); + }, + child: child, + ); + } + + Widget createCardSvg(CardProviderID id) { + return SvgPicture.string( + key: Key('${id.name}-card'), + cardProviderSvg[id.name]!, + height: height, + width: width, + ); + } +} diff --git a/lib/stripe_native_card_field.dart b/lib/stripe_native_card_field.dart index a43c1fc..069f615 100644 --- a/lib/stripe_native_card_field.dart +++ b/lib/stripe_native_card_field.dart @@ -1,7 +1,499 @@ library stripe_native_card_field; -/// A Calculator. -class Calculator { - /// Returns [value] plus 1. - int addOne(int value) => value + 1; +import 'dart:async'; +import 'card_details.dart'; +import 'card_provider_icon.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +enum CardEntryStep { number, exp, cvc, postal } + +class CardTextField extends StatefulWidget { + const CardTextField({Key? key, required this.width, this.height, this.inputDecoration}) : super(key: key); + + final InputDecoration? inputDecoration; + final double width; + final double? height; + + @override + State createState() => _CardTextFieldState(); +} + +class _CardTextFieldState extends State { + late TextEditingController _cardNumberController; + late TextEditingController _expirationController; + late TextEditingController _securityCodeController; + late TextEditingController _postalCodeController; + + late FocusNode _cardNumberFocusNode; + late FocusNode _expirationFocusNode; + late FocusNode _securityCodeFocusNode; + late FocusNode _postalCodeFocusNode; + + final double _cardFieldWidth = 180.0; + final double _expirationFieldWidth = 70.0; + final double _securityFieldWidth = 40.0; + final double _postalFieldWidth = 100.0; + late final double _internalFieldWidth; + late final bool _isWideFormat; + + bool _showBorderError = false; + String? _validationErrorText; + + final _currentCardEntryStepController = StreamController(); + final _horizontalScrollController = ScrollController(); + CardEntryStep _currentStep = CardEntryStep.number; + + final _formFieldKey = GlobalKey(); + + final CardDetails _cardDetails = CardDetails.blank(); + + final normalBoxDecoration = BoxDecoration( + color: Color(0xfff6f9fc), + border: Border.all( + color: Color(0xffdde0e3), + width: 2.0, + ), + borderRadius: BorderRadius.circular(8.0), + ); + + final errorBoxDecoration = BoxDecoration( + color: Color(0xfff6f9fc), + border: Border.all( + color: Colors.red, + width: 2.0, + ), + borderRadius: BorderRadius.circular(8.0), + ); + + final TextStyle _errorTextStyle = TextStyle(color: Colors.red, fontSize: 14); + final TextStyle _normalTextStyle = TextStyle(color: Colors.black87, fontSize: 14); + + @override + void initState() { + _cardNumberController = TextEditingController(); + _expirationController = TextEditingController(); + _securityCodeController = TextEditingController(); + _postalCodeController = TextEditingController(); + + _cardNumberFocusNode = FocusNode(); + _expirationFocusNode = FocusNode(); + _securityCodeFocusNode = FocusNode(); + _postalCodeFocusNode = FocusNode(); + + _currentCardEntryStepController.stream.listen( + _onStepChange, + ); + RawKeyboard.instance.addListener(_backspaceTransitionListener); + _isWideFormat = widget.width >= 450; + if (_isWideFormat) { + _internalFieldWidth = widget.width + 80; + } else { + _internalFieldWidth = _cardFieldWidth + _expirationFieldWidth + _securityFieldWidth + _postalFieldWidth + 80; + } + super.initState(); + } + + @override + void dispose() { + _cardNumberController.dispose(); + _expirationController.dispose(); + _securityCodeController.dispose(); + + _cardNumberFocusNode.dispose(); + _expirationFocusNode.dispose(); + _securityCodeFocusNode.dispose(); + + RawKeyboard.instance.removeListener(_backspaceTransitionListener); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Form( + key: _formFieldKey, + child: GestureDetector( + onTap: () { + // Focuses to the current field + _currentCardEntryStepController.add(_currentStep); + }, + child: Container( + width: widget.width, + height: widget.height ?? 60.0, + decoration: _showBorderError ? errorBoxDecoration : normalBoxDecoration, + child: ClipRect( + child: IgnorePointer( + child: SingleChildScrollView( + controller: _horizontalScrollController, + scrollDirection: Axis.horizontal, + child: SizedBox( + width: _internalFieldWidth, + height: widget.height ?? 60.0, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6.0), + child: CardProviderIcon( + cardDetails: _cardDetails, + ), + ), + Container( + width: _cardFieldWidth, + child: TextFormField( + focusNode: _cardNumberFocusNode, + controller: _cardNumberController, + keyboardType: TextInputType.number, + style: _isRedText([ValidState.invalidCard, ValidState.missingCard, ValidState.blank]) + ? _errorTextStyle + : _normalTextStyle, + validator: (content) { + if (content == null || content.isEmpty) { + return null; + } + _cardDetails.cardNumber = content; + if (_cardDetails.validState == ValidState.invalidCard) { + _setValidationState('You card number is invalid.'); + } else if (_cardDetails.validState == ValidState.missingCard) { + _setValidationState('Your card number is incomplete.'); + } + return null; + }, + onChanged: (str) { + final numbers = str.replaceAll(' ', ''); + setState(() => _cardDetails.cardNumber = numbers); + if (str.length <= _cardDetails.maxINNLength) { + _cardDetails.detectCardProvider(); + } + if (numbers.length == 16) { + _currentCardEntryStepController.add(CardEntryStep.exp); + } + }, + inputFormatters: [ + LengthLimitingTextInputFormatter(19), + FilteringTextInputFormatter.allow(RegExp('[0-9 ]')), + CardNumberInputFormatter(), + ], + decoration: InputDecoration( + hintText: 'Card number', + fillColor: Colors.transparent, + border: InputBorder.none, + ), + ), + ), + if (_isWideFormat) + Flexible( + fit: FlexFit.loose, + // fit: _currentStep == CardEntryStep.number ? FlexFit.loose : FlexFit.tight, + child: AnimatedContainer( + curve: Curves.easeOut, + duration: const Duration(milliseconds: 400), + constraints: _currentStep == CardEntryStep.number + ? BoxConstraints.loose(Size(400.0, 1.0)) + : BoxConstraints.tight(Size(0, 0)))), + + // Spacer(flex: _currentStep == CardEntryStep.number ? 100 : 1), + AnimatedContainer( + duration: const Duration(milliseconds: 125), + width: _expirationFieldWidth, + child: TextFormField( + focusNode: _expirationFocusNode, + controller: _expirationController, + style: + _isRedText([ValidState.dateTooLate, ValidState.dateTooEarly, ValidState.missingDate]) + ? _errorTextStyle + : _normalTextStyle, + validator: (content) { + if (content == null || content.isEmpty) { + return null; + } + setState(() => _cardDetails.expirationDate = content); + if (_cardDetails.validState == ValidState.dateTooEarly) { + _setValidationState('Your card\'s expiration date is in the past.'); + } else if (_cardDetails.validState == ValidState.dateTooLate) { + _setValidationState('Your card\'s expiration year is invalid.'); + } else if (_cardDetails.validState == ValidState.missingDate) { + _setValidationState('You must include your card\'s expiration date.'); + } + return null; + }, + onChanged: (str) { + setState(() => _cardDetails.expirationDate = str); + if (str.length == 5) { + _currentCardEntryStepController.add(CardEntryStep.cvc); + } + }, + inputFormatters: [ + LengthLimitingTextInputFormatter(5), + FilteringTextInputFormatter.allow(RegExp('[0-9/]')), + CardExpirationFormatter(), + ], + decoration: InputDecoration( + hintText: 'MM/YY', + fillColor: Colors.transparent, + border: InputBorder.none, + ), + ), + ), + AnimatedContainer( + duration: const Duration(milliseconds: 250), + width: _securityFieldWidth, + child: TextFormField( + focusNode: _securityCodeFocusNode, + controller: _securityCodeController, + style: _isRedText([ValidState.invalidCVC, ValidState.missingCVC]) + ? _errorTextStyle + : _normalTextStyle, + validator: (content) { + if (content == null || content.isEmpty) { + return null; + } + setState(() => _cardDetails.securityCode = int.tryParse(content)); + if (_cardDetails.validState == ValidState.invalidCVC) { + _setValidationState('Your card\'s security code is invalid.'); + } else if (_cardDetails.validState == ValidState.missingCVC) { + _setValidationState('You card\'s security code is incomplete.'); + } + return null; + }, + onChanged: (str) { + setState(() => _cardDetails.expirationDate = str); + if (str.length == _cardDetails.provider?.cvcLength) { + _currentCardEntryStepController.add(CardEntryStep.postal); + } + }, + inputFormatters: [ + LengthLimitingTextInputFormatter( + _cardDetails.provider == null ? 4 : _cardDetails.provider!.cvcLength), + FilteringTextInputFormatter.allow(RegExp('[0-9]')), + ], + decoration: const InputDecoration( + hintText: 'CVC', + fillColor: Colors.transparent, + border: InputBorder.none, + ), + ), + ), + AnimatedContainer( + duration: const Duration(milliseconds: 250), + width: _postalFieldWidth, + child: TextFormField( + focusNode: _postalCodeFocusNode, + controller: _postalCodeController, + style: _isRedText([ValidState.invalidZip, ValidState.missingZip]) + ? _errorTextStyle + : _normalTextStyle, + validator: (content) { + print('validate zipcode'); + if (content == null || content.isEmpty) { + return null; + } + setState(() => _cardDetails.postalCode = content); + +print('checking here\n$_cardDetails'); + if (_cardDetails.validState == ValidState.invalidZip) { + _setValidationState('The postal code you entered is not correct.'); + } else if (_cardDetails.validState == ValidState.missingZip) { + _setValidationState('You must enter your card\'s postal code.'); + } + return null; + }, + onChanged: (str) { + print('here'); + setState(() => _cardDetails.postalCode = str); + print(_cardDetails.toString()); + }, + onFieldSubmitted: (_) { + print('finished'); + _validateFields(); + }, + decoration: InputDecoration( + hintText: _currentStep == CardEntryStep.number ? '' : 'Postal Code', + fillColor: Colors.transparent, + border: InputBorder.none, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + AnimatedOpacity( + duration: const Duration(milliseconds: 525), + opacity: _validationErrorText == null ? 0.0 : 1.0, + child: Padding( + padding: const EdgeInsets.only(top: 8.0, left: 14.0), + child: Text( + _validationErrorText ?? '', + style: const TextStyle(color: Colors.red), + ), + ), + ), + ], + ); + } + + bool _isRedText(List args) { + return _showBorderError && args.contains(_cardDetails.validState); + } + + void _setValidationState(String? text) { + setState(() { + _validationErrorText = text; + _showBorderError = text != null; + }); + } + + void _validateFields() { + _validationErrorText = null; + _formFieldKey.currentState!.validate(); + // Clear up validation state if everything is valid + if (_validationErrorText == null) { + _setValidationState(null); + } + return; + } + + void _scrollRow(CardEntryStep step) { + final dur = Duration(milliseconds: 150); + final cur = Curves.easeOut; + final fieldLen = widget.width; + switch (step) { + case CardEntryStep.number: + _horizontalScrollController.animateTo(0.0, duration: dur, curve: cur); + break; + case CardEntryStep.exp: + _horizontalScrollController.animateTo(_cardFieldWidth / 2, duration: dur, curve: cur); + break; + case CardEntryStep.cvc: + _horizontalScrollController.animateTo(_cardFieldWidth / 2 + _expirationFieldWidth, duration: dur, curve: cur); + break; + case CardEntryStep.postal: + _horizontalScrollController.animateTo(_cardFieldWidth / 2 + _expirationFieldWidth + _securityFieldWidth, + duration: dur, curve: cur); + break; + } + } + + void _onStepChange(CardEntryStep step) { + if (_currentStep.index < step.index) { + _validateFields(); + } else if (_currentStep != step) { + _setValidationState(null); + } + + setState(() { + _currentStep = step; + }); + switch (step) { + case CardEntryStep.number: + _cardNumberFocusNode.requestFocus(); + break; + case CardEntryStep.exp: + _expirationFocusNode.requestFocus(); + break; + case CardEntryStep.cvc: + _securityCodeFocusNode.requestFocus(); + break; + case CardEntryStep.postal: + _postalCodeFocusNode.requestFocus(); + break; + } + if (!_isWideFormat) { + _scrollRow(step); + } + } + + void _backspaceTransitionListener(RawKeyEvent value) { + if (!value.isKeyPressed(LogicalKeyboardKey.backspace)) { + return; + } + switch (_currentStep) { + case CardEntryStep.number: + break; + case CardEntryStep.exp: + final expStr = _expirationController.text; + if (expStr.isNotEmpty) break; + _currentCardEntryStepController.add(CardEntryStep.number); + String numStr = _cardNumberController.text; + _cardNumberController.text = numStr.substring(0, numStr.length - 1); + break; + case CardEntryStep.cvc: + final cvcStr = _securityCodeController.text; + if (cvcStr.isNotEmpty) break; + _currentCardEntryStepController.add(CardEntryStep.exp); + final expStr = _expirationController.text; + _expirationController.text = expStr.substring(0, expStr.length - 1); + case CardEntryStep.postal: + final String postalStr = _postalCodeController.text; + if (postalStr.isNotEmpty) break; + _currentCardEntryStepController.add(CardEntryStep.cvc); + final String cvcStr = _securityCodeController.text; + _securityCodeController.text = cvcStr.substring(0, cvcStr.length - 1); + } + } +} + +class CardNumberInputFormatter implements TextInputFormatter { + @override + TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { + String cardNum = newValue.text; + if (cardNum.length <= 4) return newValue; + + cardNum = cardNum.replaceAll(' ', ''); + StringBuffer buffer = StringBuffer(); + + for (int i = 0; i < cardNum.length; i++) { + buffer.write(cardNum[i]); + int nonZeroIndex = i + 1; + if (nonZeroIndex % 4 == 0 && nonZeroIndex != cardNum.length) { + buffer.write(' '); + } + } + + return newValue.copyWith(text: buffer.toString(), selection: TextSelection.collapsed(offset: buffer.length)); + } +} + +class CardExpirationFormatter implements TextInputFormatter { + @override + TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { + String cardExp = newValue.text; + if (cardExp.length == 1) { + if (cardExp[0] == '0' || cardExp[0] == '1') { + return newValue; + } else { + cardExp = '0$cardExp'; + } + } + if (cardExp.length == 2 && oldValue.text.length == 3) return newValue; + // Auto delete the slash on backspace + // if (cardExp.length == 3 && oldValue.text.length == 4 && cardExp[2] == '/') { + // return newValue.copyWith( + // text: cardExp.substring(0, 2), selection: TextSelection.collapsed(offset: cardExp.length - 1)); + // } + + cardExp = cardExp.replaceAll('/', ''); + StringBuffer buffer = StringBuffer(); + + for (int i = 0; i < cardExp.length; i++) { + buffer.write(cardExp[i]); + int nonZeroIndex = i + 1; + if (nonZeroIndex == 2) { + buffer.write('/'); + } + } + return newValue.copyWith(text: buffer.toString(), selection: TextSelection.collapsed(offset: buffer.length)); + } } diff --git a/pubspec.yaml b/pubspec.yaml index e7ac478..cb7bddf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,6 +10,7 @@ environment: dependencies: flutter: sdk: flutter + flutter_svg: ^2.0.9 dev_dependencies: flutter_test: diff --git a/test/stripe_native_card_field_test.dart b/test/stripe_native_card_field_test.dart index 9fbe29c..2fe6e88 100644 --- a/test/stripe_native_card_field_test.dart +++ b/test/stripe_native_card_field_test.dart @@ -3,10 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:stripe_native_card_field/stripe_native_card_field.dart'; void main() { - test('adds one to input values', () { - final calculator = Calculator(); - expect(calculator.addOne(2), 3); - expect(calculator.addOne(-7), -6); - expect(calculator.addOne(0), 1); - }); + test('CardDetails:', () { + + }); }