WIP
This commit is contained in:
parent
41da951be1
commit
88020f4d15
295
lib/card_details.dart
Normal file
295
lib/card_details.dart
Normal file
|
@ -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<int>? INN_VALID_NUMS;
|
||||||
|
List<Range>? 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<CardProvider> 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<int> 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;
|
||||||
|
}
|
84
lib/card_provider_icon.dart
Normal file
84
lib/card_provider_icon.dart
Normal file
File diff suppressed because one or more lines are too long
|
@ -1,7 +1,499 @@
|
||||||
library stripe_native_card_field;
|
library stripe_native_card_field;
|
||||||
|
|
||||||
/// A Calculator.
|
import 'dart:async';
|
||||||
class Calculator {
|
import 'card_details.dart';
|
||||||
/// Returns [value] plus 1.
|
import 'card_provider_icon.dart';
|
||||||
int addOne(int value) => value + 1;
|
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<CardTextField> createState() => _CardTextFieldState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CardTextFieldState extends State<CardTextField> {
|
||||||
|
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<CardEntryStep>();
|
||||||
|
final _horizontalScrollController = ScrollController();
|
||||||
|
CardEntryStep _currentStep = CardEntryStep.number;
|
||||||
|
|
||||||
|
final _formFieldKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
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<ValidState> 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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ environment:
|
||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
flutter_svg: ^2.0.9
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
@ -3,10 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:stripe_native_card_field/stripe_native_card_field.dart';
|
import 'package:stripe_native_card_field/stripe_native_card_field.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test('adds one to input values', () {
|
test('CardDetails:', () {
|
||||||
final calculator = Calculator();
|
|
||||||
expect(calculator.addOne(2), 3);
|
|
||||||
expect(calculator.addOne(-7), -6);
|
|
||||||
expect(calculator.addOne(0), 1);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user