0.0.3 released, see CHANGELOG.md

This commit is contained in:
Nathan Anderson
2023-11-21 09:45:25 -07:00
parent d4c697dde8
commit dd52ffb398
11 changed files with 535 additions and 267 deletions
+18 -18
View File
@@ -32,14 +32,14 @@ class CardDetails {
String? expirationString;
DateTime? expirationDate;
bool _complete = false;
ValidState _validState = ValidState.blank;
CardDetailsValidState _validState = CardDetailsValidState.blank;
int _lastCheckHash = 0;
CardProvider? provider;
set overrideValidState(ValidState state) => _validState = state;
set overrideValidState(CardDetailsValidState state) => _validState = state;
/// Checks the validity of the `CardDetails` and returns the result.
ValidState get validState {
CardDetailsValidState get validState {
checkIsValid();
return _validState;
}
@@ -80,7 +80,7 @@ class CardDetails {
_lastCheckHash = currentHash;
if (_cardNumber == null && expirationString == null && securityCode == null && postalCode == null) {
_complete = false;
_validState = ValidState.blank;
_validState = CardDetailsValidState.blank;
return;
}
final nums = _cardNumber!
@@ -92,71 +92,71 @@ class CardDetails {
.toList();
if (!_luhnAlgorithmCheck(nums)) {
_complete = false;
_validState = ValidState.invalidCard;
_validState = CardDetailsValidState.invalidCard;
return;
}
if (_cardNumber == null || !cardNumberFilled) {
_complete = false;
_validState = ValidState.missingCard;
_validState = CardDetailsValidState.missingCard;
return;
}
if (expirationString == null) {
_complete = false;
_validState = ValidState.missingDate;
_validState = CardDetailsValidState.missingDate;
return;
}
final expSplits = expirationString!.split('/');
if (expSplits.length != 2 || expSplits.last == '') {
_complete = false;
_validState = ValidState.missingDate;
_validState = CardDetailsValidState.missingDate;
return;
}
final month = int.parse(expSplits.first[0] == '0' ? expSplits.first[1] : expSplits.first);
if (month < 1 || month > 12) {
_complete = false;
_validState = ValidState.invalidMonth;
_validState = CardDetailsValidState.invalidMonth;
return;
}
final year = 2000 + int.parse(expSplits.last);
final date = DateTime(year, month);
if (date.isBefore(DateTime.now())) {
_complete = false;
_validState = ValidState.dateTooEarly;
_validState = CardDetailsValidState.dateTooEarly;
return;
} else if (date.isAfter(DateTime.now().add(const Duration(days: 365 * 50)))) {
_complete = false;
_validState = ValidState.dateTooLate;
_validState = CardDetailsValidState.dateTooLate;
return;
}
expirationDate = date;
if (securityCode == null) {
_complete = false;
_validState = ValidState.missingCVC;
_validState = CardDetailsValidState.missingCVC;
return;
}
if (provider != null && securityCode!.length != provider!.cvcLength) {
_complete = false;
_validState = ValidState.invalidCVC;
_validState = CardDetailsValidState.invalidCVC;
return;
}
if (postalCode == null) {
_complete = false;
_validState = ValidState.missingZip;
_validState = CardDetailsValidState.missingZip;
return;
}
if (!RegExp(r'^\d{5}(-\d{4})?$').hasMatch(postalCode!)) {
_complete = false;
_validState = ValidState.invalidZip;
_validState = CardDetailsValidState.invalidZip;
return;
}
_complete = true;
_validState = ValidState.ok;
_validState = CardDetailsValidState.ok;
} catch (err, st) {
if (kDebugMode) {
print('Error while validating CardDetails: $err\n$st');
}
_complete = false;
_validState = ValidState.error;
_validState = CardDetailsValidState.error;
}
}
@@ -245,7 +245,7 @@ class CardDetails {
}
/// Enum of validation states a `CardDetails` object can have.
enum ValidState {
enum CardDetailsValidState {
ok,
error,
blank,
+18 -10
View File
@@ -7,9 +7,10 @@ import 'package:flutter_svg/flutter_svg.dart';
///
/// To see a list of supported card providers, see `CardDetails.provider`.
class CardProviderIcon extends StatefulWidget {
const CardProviderIcon({required this.cardDetails, super.key});
const CardProviderIcon({required this.cardDetails, this.size, super.key});
final CardDetails? cardDetails;
final Size? size;
@override
State<CardProviderIcon> createState() => _CardProviderIconState();
@@ -34,22 +35,29 @@ class _CardProviderIconState extends State<CardProviderIcon> {
CardProviderID.jcb.name:
'<svg enable-background="new 0 0 780 500" height="500" viewBox="0 0 780 500" width="780" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a" gradientTransform="matrix(132.87 0 0 323.02 -120270 -100930)" gradientUnits="userSpaceOnUse" x1="908.72" x2="909.72" y1="313.21" y2="313.21"><stop offset="0" stop-color="#007b40"/><stop offset="1" stop-color="#55b330"/></linearGradient><linearGradient id="b" gradientTransform="matrix(133.43 0 0 323.02 -121080 -100920)" gradientUnits="userSpaceOnUse" x1="908.73" x2="909.73" y1="313.21" y2="313.21"><stop offset="0" stop-color="#1d2970"/><stop offset="1" stop-color="#006dba"/></linearGradient><linearGradient id="c" gradientTransform="matrix(132.96 0 0 323.03 -120500 -100930)" gradientUnits="userSpaceOnUse" x1="908.72" x2="909.72" y1="313.21" y2="313.21"><stop offset="0" stop-color="#6e2b2f"/><stop offset="1" stop-color="#e30138"/></linearGradient><path d="m632.24 361.27c0 41.615-33.729 75.36-75.357 75.36h-409.13v-297.88c0-41.626 33.73-75.371 75.364-75.371h409.12l-.001 297.89z" fill="#fff"/><path d="m498.86 256.54c11.686.254 23.438-.516 35.077.4 11.787 2.199 14.628 20.043 4.156 25.887-7.145 3.85-15.633 1.434-23.379 2.113h-15.854zm41.834-32.145c2.596 9.164-6.238 17.392-15.064 16.13h-26.77c.188-8.642-.367-18.022.272-26.209 10.724.302 21.547-.616 32.209.48 4.581 1.151 8.415 4.917 9.353 9.599zm64.425-135.9c.498 17.501.072 35.927.215 53.783-.033 72.596.07 145.19-.057 217.79-.47 27.207-24.582 50.848-51.601 51.391-27.045.11-54.094.017-81.143.047v-109.75c29.471-.152 58.957.309 88.416-.23 13.666-.858 28.635-9.875 29.271-24.914 1.609-15.104-12.631-25.551-26.151-27.201-5.197-.135-5.045-1.515 0-2.117 12.895-2.787 23.021-16.133 19.227-29.499-3.233-14.058-18.771-19.499-31.695-19.472-26.352-.179-52.709-.025-79.062-.077.17-20.489-.355-41 .283-61.474 2.088-26.716 26.807-48.748 53.446-48.27 26.287-.004 52.57-.004 78.851-.005z" fill="url(#a)"/><path d="m174.74 139.54c.673-27.164 24.888-50.611 51.872-51.008 26.945-.083 53.894-.012 80.839-.036-.074 90.885.146 181.78-.111 272.66-1.038 26.834-24.989 49.834-51.679 50.309-26.996.098-53.995.014-80.992.041v-113.45c26.223 6.195 53.722 8.832 80.474 4.723 15.991-2.573 33.487-10.426 38.901-27.016 3.984-14.191 1.741-29.126 2.334-43.691v-33.825h-46.297c-.208 22.371.426 44.781-.335 67.125-1.248 13.734-14.849 22.46-27.802 21.994-16.064.17-47.897-11.642-47.897-11.642-.08-41.914.466-94.405.693-136.18z" fill="url(#b)"/><path d="m324.72 211.89c-2.437.517-.49-8.301-1.113-11.646.166-21.15-.347-42.323.283-63.458 2.082-26.829 26.991-48.916 53.738-48.288h78.768c-.074 90.885.145 181.78-.111 272.66-1.039 26.834-24.992 49.833-51.683 50.309-26.997.102-53.997.016-80.996.042v-124.3c18.439 15.129 43.5 17.484 66.472 17.525 17.318-.006 34.535-2.676 51.353-6.67v-22.772c-18.953 9.446-41.233 15.446-62.243 10.019-14.656-3.648-25.295-17.812-25.058-32.937-1.698-15.729 7.522-32.335 22.979-37.011 19.191-6.008 40.107-1.413 58.096 6.398 3.854 2.018 7.766 4.521 6.225-1.921v-17.899c-30.086-7.158-62.104-9.792-92.33-2.005-8.749 2.468-17.273 6.211-24.38 11.956z" fill="url(#c)"/></svg>',
};
final double height = 20;
final double width = 30;
late final Size _size;
@override
initState() {
super.initState();
_size = widget.size ?? const Size(30.0, 20.0);
}
@override
Widget build(BuildContext context) {
late Widget child;
if (widget.cardDetails?.cardNumber != null &&
widget.cardDetails!.cardNumberFilled &&
widget.cardDetails!.validState == ValidState.invalidCard) {
widget.cardDetails!.validState == CardDetailsValidState.invalidCard) {
child = Padding(
padding: const EdgeInsets.symmetric(horizontal: 5.0),
child: SvgPicture.string(
key: const Key('invalid-card'),
cardProviderSvg['error']!,
height: height,
width: width,
height: _size.height,
width: _size.width,
),
);
} else {
@@ -59,8 +67,8 @@ class _CardProviderIconState extends State<CardProviderIcon> {
child: SvgPicture.string(
key: const Key('credit_card'),
cardProviderSvg['credit-card']!,
height: height,
width: width,
height: _size.height,
width: _size.width,
),
);
} else {
@@ -86,8 +94,8 @@ class _CardProviderIconState extends State<CardProviderIcon> {
return SvgPicture.string(
key: Key('${id.name}-card'),
cardProviderSvg[id.name]!,
height: height,
width: width,
height: _size.height,
width: _size.width,
);
}
}
+406 -194
View File
@@ -2,6 +2,8 @@ library stripe_native_card_field;
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@@ -28,9 +30,9 @@ enum CardEntryStep { number, exp, cvc, postal }
class CardTextField extends StatefulWidget {
CardTextField({
Key? key,
required this.width,
this.onStripeResponse,
this.onCardDetailsComplete,
required this.width,
this.stripePublishableKey,
this.height,
this.textStyle,
@@ -39,61 +41,89 @@ class CardTextField extends StatefulWidget {
this.boxDecoration,
this.errorBoxDecoration,
this.loadingWidget,
this.showInternalLoadingWidget = true,
this.delayToShowLoading = const Duration(milliseconds: 750),
this.onCallToStripe,
this.overrideValidState,
this.errorText,
this.cardFieldWidth,
this.expFieldWidth,
this.securityFieldWidth,
this.postalFieldWidth,
this.iconSize,
// this.loadingWidgetLocation = LoadingLocation.rightInside,
}) : super(key: key) {
if (stripePublishableKey != null) {
assert(stripePublishableKey!.startsWith('pk_'));
if (kReleaseMode && !stripePublishableKey!.startsWith('pk_live_')) {
print('StripeNativeCardField: *WARN* You are not using a live publishableKey in production.');
log('StripeNativeCardField: *WARN* You are not using a live publishableKey in production.');
} else if ((kDebugMode || kProfileMode) && stripePublishableKey!.startsWith('pk_live_')) {
print(
'StripeNativeCardField: *WARN* You are using a live stripe key in a debug environment, proceed with caution!');
print('StripeNativeCardField: *WARN* Ideally you should be using your test keys whenever not in production.');
log('StripeNativeCardField: *WARN* You are using a live stripe key in a debug environment, proceed with caution!');
log('StripeNativeCardField: *WARN* Ideally you should be using your test keys whenever not in production.');
}
} else {
if (onStripeResponse != null) {
print(
'StripeNativeCardField: *ERROR* You provided the onTokenReceived callback, but did not provide a stripePublishableKey.');
log('StripeNativeCardField: *ERROR* You provided the onTokenReceived callback, but did not provide a stripePublishableKey.');
assert(false);
}
}
}
/// Width of the entire CardTextField
final double width;
/// Height of the entire CardTextField, defaults to 60.0
final double? height;
/// Width of card number field, only override if changing the default `textStyle.fontSize`, defaults to 180.0
final double? cardFieldWidth;
/// Width of expiration date field, only override if changing the default `textStyle.fontSize`, defaults to 70.0
final double? expFieldWidth;
/// Width of security number field, only override if changing the default `textStyle.fontSize`, defaults to 40.0
final double? securityFieldWidth;
/// Width of postal code field, only override if changing the default `textStyle.fontSize`, defaults to 95.0
final double? postalFieldWidth;
/// Overrides the default box decoration of the text field
final BoxDecoration? boxDecoration;
/// Overrides the default box decoration of the text field when there is a validation error
final BoxDecoration? errorBoxDecoration;
/// Width of the entire CardTextField
final double width;
/// Height of the entire CardTextField
final double? height;
/// Stripe publishable key, starts with 'pk_'
final String? stripePublishableKey;
/// Shown and overrides CircularProgressIndicator() if the request to stripe takes longer than `delayToShowLoading`
final Widget? loadingWidget;
/// Overrides default icon size of the card provider, defaults to `Size(30.0, 20.0)`
final Size? iconSize;
/// Determines where the loading indicator appears when contacting stripe
// final LoadingLocation loadingWidgetLocation;
/// Default TextStyle
final TextStyle? textStyle;
/// Default TextStyle for the hint text in each TextFormField
/// Default TextStyle for the hint text in each TextFormField.
/// If null, inherits from the `textStyle`.
final TextStyle? hintTextStyle;
/// TextStyle used when any TextFormField's have a validation error
/// If null, inherits from the `textStyle`.
final TextStyle? errorTextStyle;
/// Time to wait until showing the loading indicator when retrieving Stripe token
final Duration delayToShowLoading;
/// Determines where the loading indicator appears when contacting stripe
// final LoadingLocation loadingWidgetLocation;
/// Whether to show the internal loading widget on calls to Stripe
final bool showInternalLoadingWidget;
/// Stripe publishable key, starts with 'pk_'
final String? stripePublishableKey;
/// Callback when the http request is made to Stripe
final void Function()? onCallToStripe;
/// Callback that returns the stripe token for the card
final void Function(Map<String, dynamic>)? onStripeResponse;
@@ -102,7 +132,7 @@ class CardTextField extends StatefulWidget {
final void Function(CardDetails)? onCardDetailsComplete;
/// Can manually override the ValidState to surface errors returned from Stripe
final ValidState? overrideValidState;
final CardDetailsValidState? overrideValidState;
/// Can manually override the errorText displayed to surface errors returned from Stripe
final String? errorText;
@@ -137,16 +167,32 @@ class CardTextFieldState extends State<CardTextField> {
late final TextStyle _normalTextStyle;
late final TextStyle _hintTextSyle;
final double _cardFieldWidth = 180.0;
final double _expirationFieldWidth = 70.0;
final double _securityFieldWidth = 40.0;
final double _postalFieldWidth = 95.0;
/// Width of the card number text field
late final double _cardFieldWidth;
/// Width of the expiration text field
late final double _expirationFieldWidth;
/// Width of the security code text field
late final double _securityFieldWidth;
/// Width of the postal code text field
late final double _postalFieldWidth;
/// Width of the internal scrollable field, is potentially larger than the provided `widget.width`
late final double _internalFieldWidth;
/// Width of the gap between card number and expiration text fields when expanded
late final double _expanderWidthExpanded;
late final double _expanderWidthContracted;
/// Width of the gap between card number and expiration text fields when collapsed
late final double _expanderWidthCollapsed;
String? _validationErrorText;
bool _showBorderError = false;
final _isMobile = Platform.isAndroid || Platform.isIOS;
/// If a request to Stripe is being made
bool _loading = false;
final CardDetails _cardDetails = CardDetails.blank();
int _prevErrorOverrideHash = 0;
@@ -159,19 +205,32 @@ class CardTextFieldState extends State<CardTextField> {
@override
void initState() {
_cardFieldWidth = widget.cardFieldWidth ?? 180.0;
_expirationFieldWidth = widget.expFieldWidth ?? 70.0;
_securityFieldWidth = widget.securityFieldWidth ?? 40.0;
_postalFieldWidth = widget.postalFieldWidth ?? 95.0;
// No way to get backspace events on soft keyboards, so add invisible character to detect delete
_cardNumberController = TextEditingController();
_expirationController = TextEditingController();
_securityCodeController = TextEditingController();
_postalCodeController = TextEditingController();
_expirationController = TextEditingController(text: _isMobile ? '\u200b' : '');
_securityCodeController = TextEditingController(text: _isMobile ? '\u200b' : '');
_postalCodeController = TextEditingController(text: _isMobile ? '\u200b' : '');
// Otherwise, use `RawKeyboard` listener
if (!_isMobile) {
RawKeyboard.instance.addListener(_backspaceTransitionListener);
}
cardNumberFocusNode = FocusNode();
expirationFocusNode = FocusNode();
securityCodeFocusNode = FocusNode();
postalCodeFocusNode = FocusNode();
_errorTextStyle = const TextStyle(color: Colors.red, fontSize: 14, inherit: true).merge(widget.errorTextStyle);
_errorTextStyle = const TextStyle(color: Colors.red, fontSize: 14, inherit: true)
.merge(widget.errorTextStyle ?? widget.textStyle);
_normalTextStyle = const TextStyle(color: Colors.black87, fontSize: 14, inherit: true).merge(widget.textStyle);
_hintTextSyle = const TextStyle(color: Colors.black54, fontSize: 14, inherit: true).merge(widget.hintTextStyle);
_hintTextSyle = const TextStyle(color: Colors.black54, fontSize: 14, inherit: true)
.merge(widget.hintTextStyle ?? widget.textStyle);
_normalBoxDecoration = BoxDecoration(
color: const Color(0xfff6f9fc),
@@ -212,12 +271,13 @@ class CardTextFieldState extends State<CardTextField> {
_currentCardEntryStepController.stream.listen(
_onStepChange,
);
RawKeyboard.instance.addListener(_backspaceTransitionListener);
isWideFormat = widget.width >= 450;
isWideFormat =
widget.width >= _cardFieldWidth + _expirationFieldWidth + _securityFieldWidth + _postalFieldWidth + 60.0;
if (isWideFormat) {
_internalFieldWidth = widget.width + _postalFieldWidth + 35;
_expanderWidthExpanded = widget.width - _cardFieldWidth - _expirationFieldWidth - _securityFieldWidth - 35;
_expanderWidthContracted =
_expanderWidthCollapsed =
widget.width - _cardFieldWidth - _expirationFieldWidth - _securityFieldWidth - _postalFieldWidth - 70;
} else {
_internalFieldWidth = _cardFieldWidth + _expirationFieldWidth + _securityFieldWidth + _postalFieldWidth + 80;
@@ -236,7 +296,9 @@ class CardTextFieldState extends State<CardTextField> {
expirationFocusNode.dispose();
securityCodeFocusNode.dispose();
RawKeyboard.instance.removeListener(_backspaceTransitionListener);
if (!_isMobile) {
RawKeyboard.instance.removeListener(_backspaceTransitionListener);
}
super.dispose();
}
@@ -259,6 +321,30 @@ class CardTextFieldState extends State<CardTextField> {
// Focuses to the current field
_currentCardEntryStepController.add(_currentStep);
},
// Enable scrolling on mobile and if its narrow (not all fields visible)
onHorizontalDragUpdate: (details) {
if (!_isMobile || isWideFormat) return;
final newOffset = _horizontalScrollController.offset - details.delta.dx;
final max = _horizontalScrollController.position.maxScrollExtent;
if (newOffset < -30.0) {
_horizontalScrollController.jumpTo(-30.0);
} else if (newOffset > max + 30.0) {
_horizontalScrollController.jumpTo(max + 30.0);
} else {
_horizontalScrollController.jumpTo(newOffset);
}
},
onHorizontalDragEnd: (details) {
if (!_isMobile || isWideFormat || details.primaryVelocity == null) return;
const dur = Duration(milliseconds: 300);
const cur = Curves.ease;
// final max = _horizontalScrollController.position.maxScrollExtent;
final newOffset = _horizontalScrollController.offset - details.primaryVelocity! * 0.15;
_horizontalScrollController.animateTo(newOffset, curve: cur, duration: dur);
},
child: Container(
width: widget.width,
height: widget.height ?? 60.0,
@@ -279,6 +365,7 @@ class CardTextFieldState extends State<CardTextField> {
padding: const EdgeInsets.symmetric(horizontal: 6.0),
child: CardProviderIcon(
cardDetails: _cardDetails,
size: widget.iconSize,
),
),
SizedBox(
@@ -288,7 +375,11 @@ class CardTextFieldState extends State<CardTextField> {
focusNode: cardNumberFocusNode,
controller: _cardNumberController,
keyboardType: TextInputType.number,
style: _isRedText([ValidState.invalidCard, ValidState.missingCard, ValidState.blank])
style: _isRedText([
CardDetailsValidState.invalidCard,
CardDetailsValidState.missingCard,
CardDetailsValidState.blank
])
? _errorTextStyle
: _normalTextStyle,
validator: (content) {
@@ -296,9 +387,9 @@ class CardTextFieldState extends State<CardTextField> {
return null;
}
_cardDetails.cardNumber = content;
if (_cardDetails.validState == ValidState.invalidCard) {
if (_cardDetails.validState == CardDetailsValidState.invalidCard) {
_setValidationState('Your card number is invalid.');
} else if (_cardDetails.validState == ValidState.missingCard) {
} else if (_cardDetails.validState == CardDetailsValidState.missingCard) {
_setValidationState('Your card number is incomplete.');
}
return null;
@@ -340,7 +431,7 @@ class CardTextFieldState extends State<CardTextField> {
Size(_expanderWidthExpanded, 0.0),
)
: BoxConstraints.tight(
Size(_expanderWidthContracted, 0.0),
Size(_expanderWidthCollapsed, 0.0),
),
),
),
@@ -348,170 +439,210 @@ class CardTextFieldState extends State<CardTextField> {
// Spacer(flex: _currentStep == CardEntryStep.number ? 100 : 1),
SizedBox(
width: _expirationFieldWidth,
child: TextFormField(
key: const Key('expiration_field'),
focusNode: expirationFocusNode,
controller: _expirationController,
style: _isRedText([
ValidState.dateTooLate,
ValidState.dateTooEarly,
ValidState.missingDate,
ValidState.invalidMonth
])
? _errorTextStyle
: _normalTextStyle,
validator: (content) {
if (content == null || content.isEmpty) {
return null;
}
setState(() => _cardDetails.expirationString = 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.');
} else if (_cardDetails.validState == ValidState.invalidMonth) {
_setValidationState('Your card\'s expiration month is invalid.');
}
return null;
},
onChanged: (str) {
setState(() => _cardDetails.expirationString = str);
if (str.length == 5) {
_currentCardEntryStepController.add(CardEntryStep.cvc);
}
},
onFieldSubmitted: (_) => _currentCardEntryStepController.add(CardEntryStep.cvc),
inputFormatters: [
LengthLimitingTextInputFormatter(5),
FilteringTextInputFormatter.allow(RegExp('[0-9/]')),
CardExpirationFormatter(),
child: Stack(
alignment: Alignment.centerLeft,
children: [
// Must manually add hint label because they wont show on mobile with backspace hack
if (_isMobile && _expirationController.text == '\u200b')
Text('MM/YY', style: _hintTextSyle),
TextFormField(
key: const Key('expiration_field'),
focusNode: expirationFocusNode,
controller: _expirationController,
keyboardType: TextInputType.number,
style: _isRedText([
CardDetailsValidState.dateTooLate,
CardDetailsValidState.dateTooEarly,
CardDetailsValidState.missingDate,
CardDetailsValidState.invalidMonth
])
? _errorTextStyle
: _normalTextStyle,
validator: (content) {
if (content == null || content.isEmpty || _isMobile && content == '\u200b') {
return null;
}
if (_isMobile) {
setState(() => _cardDetails.expirationString = content.replaceAll('\u200b', ''));
} else {
setState(() => _cardDetails.expirationString = content);
}
if (_cardDetails.validState == CardDetailsValidState.dateTooEarly) {
_setValidationState('Your card\'s expiration date is in the past.');
} else if (_cardDetails.validState == CardDetailsValidState.dateTooLate) {
_setValidationState('Your card\'s expiration year is invalid.');
} else if (_cardDetails.validState == CardDetailsValidState.missingDate) {
_setValidationState('You must include your card\'s expiration date.');
} else if (_cardDetails.validState == CardDetailsValidState.invalidMonth) {
_setValidationState('Your card\'s expiration month is invalid.');
}
return null;
},
onChanged: (str) {
if (_isMobile) {
if (str.isEmpty) {
_backspacePressed();
}
setState(() => _cardDetails.expirationString = str.replaceAll('\u200b', ''));
} else {
setState(() => _cardDetails.expirationString = str);
}
if (str.length == 5) {
_currentCardEntryStepController.add(CardEntryStep.cvc);
}
},
onFieldSubmitted: (_) => _currentCardEntryStepController.add(CardEntryStep.cvc),
inputFormatters: [
LengthLimitingTextInputFormatter(5),
FilteringTextInputFormatter.allow(RegExp('[0-9/]')),
CardExpirationFormatter(),
],
decoration: InputDecoration(
contentPadding: EdgeInsets.zero,
hintText: _isMobile ? '' : 'MM/YY',
hintStyle: _hintTextSyle,
fillColor: Colors.transparent,
border: InputBorder.none,
),
),
],
decoration: InputDecoration(
contentPadding: EdgeInsets.zero,
hintText: 'MM/YY',
hintStyle: _hintTextSyle,
fillColor: Colors.transparent,
border: InputBorder.none,
),
),
),
SizedBox(
width: _securityFieldWidth,
child: TextFormField(
key: const Key('security_field'),
focusNode: securityCodeFocusNode,
controller: _securityCodeController,
style: _isRedText([ValidState.invalidCVC, ValidState.missingCVC])
? _errorTextStyle
: _normalTextStyle,
validator: (content) {
if (content == null || content.isEmpty) {
return null;
}
setState(() => _cardDetails.securityCode = content);
if (_cardDetails.validState == ValidState.invalidCVC) {
_setValidationState('Your card\'s security code is invalid.');
} else if (_cardDetails.validState == ValidState.missingCVC) {
_setValidationState('Your card\'s security code is incomplete.');
}
return null;
},
onFieldSubmitted: (_) => _currentCardEntryStepController.add(CardEntryStep.postal),
onChanged: (str) {
setState(() => _cardDetails.expirationString = 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]')),
child: Stack(
alignment: Alignment.centerLeft,
children: [
if (_isMobile && _securityCodeController.text == '\u200b')
Text(
'CVC',
style: _hintTextSyle,
),
TextFormField(
key: const Key('security_field'),
focusNode: securityCodeFocusNode,
controller: _securityCodeController,
keyboardType: TextInputType.number,
style:
_isRedText([CardDetailsValidState.invalidCVC, CardDetailsValidState.missingCVC])
? _errorTextStyle
: _normalTextStyle,
validator: (content) {
if (content == null || content.isEmpty || _isMobile && content == '\u200b') {
return null;
}
if (_isMobile) {
setState(() => _cardDetails.securityCode = content.replaceAll('\u200b', ''));
} else {
setState(() => _cardDetails.securityCode = content);
}
if (_cardDetails.validState == CardDetailsValidState.invalidCVC) {
_setValidationState('Your card\'s security code is invalid.');
} else if (_cardDetails.validState == CardDetailsValidState.missingCVC) {
_setValidationState('Your card\'s security code is incomplete.');
}
return null;
},
onFieldSubmitted: (_) => _currentCardEntryStepController.add(CardEntryStep.postal),
onChanged: (str) {
if (_isMobile) {
if (str.isEmpty) {
_backspacePressed();
}
setState(() => _cardDetails.expirationString = str.replaceAll('\u200b', ''));
} else {
setState(() => _cardDetails.expirationString = 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: InputDecoration(
contentPadding: EdgeInsets.zero,
hintText: _isMobile ? '' : 'CVC',
hintStyle: _hintTextSyle,
fillColor: Colors.transparent,
border: InputBorder.none,
),
),
],
decoration: InputDecoration(
contentPadding: EdgeInsets.zero,
hintText: 'CVC',
hintStyle: _hintTextSyle,
fillColor: Colors.transparent,
border: InputBorder.none,
),
),
),
SizedBox(
width: _postalFieldWidth,
child: TextFormField(
key: const Key('postal_field'),
focusNode: postalCodeFocusNode,
controller: _postalCodeController,
style: _isRedText([ValidState.invalidZip, ValidState.missingZip])
? _errorTextStyle
: _normalTextStyle,
validator: (content) {
if (content == null || content.isEmpty) {
return null;
}
setState(() => _cardDetails.postalCode = content);
child: Stack(
alignment: Alignment.centerLeft,
children: [
if (_isMobile && _postalCodeController.text == '\u200b')
Text(
'Postal Code',
style: _hintTextSyle,
),
TextFormField(
key: const Key('postal_field'),
focusNode: postalCodeFocusNode,
controller: _postalCodeController,
keyboardType: TextInputType.number,
style:
_isRedText([CardDetailsValidState.invalidZip, CardDetailsValidState.missingZip])
? _errorTextStyle
: _normalTextStyle,
validator: (content) {
if (content == null || content.isEmpty || _isMobile && content == '\u200b') {
return null;
}
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) {
setState(() => _cardDetails.postalCode = str);
},
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) async {
_validateFields();
if (_cardDetails.isComplete) {
if (widget.onCardDetailsComplete != null) {
widget.onCardDetailsComplete!(_cardDetails);
} else if (widget.onStripeResponse != null) {
bool returned = false;
if (_isMobile) {
setState(() => _cardDetails.postalCode = content.replaceAll('\u200b', ''));
} else {
setState(() => _cardDetails.postalCode = content);
}
Future.delayed(
const Duration(milliseconds: 750),
() => returned ? null : setState(() => _loading = true),
);
const stripeCardUrl = 'https://api.stripe.com/v1/tokens';
final response = await http.post(
Uri.parse(stripeCardUrl),
body: {
'card[number]': _cardDetails.cardNumber,
'card[cvc]': _cardDetails.securityCode,
'card[exp_month]': _cardDetails.expMonth,
'card[exp_year]': _cardDetails.expYear,
'card[address_zip]': _cardDetails.postalCode,
'key': widget.stripePublishableKey,
},
headers: {"Content-Type": "application/x-www-form-urlencoded"},
);
returned = true;
final jsonBody = jsonDecode(response.body);
widget.onStripeResponse!(jsonBody);
if (_loading) setState(() => _loading = false);
}
}
},
decoration: InputDecoration(
contentPadding: EdgeInsets.zero,
hintText: 'Postal Code',
hintStyle: _hintTextSyle,
fillColor: Colors.transparent,
border: InputBorder.none,
),
if (_cardDetails.validState == CardDetailsValidState.invalidZip) {
_setValidationState('The postal code you entered is not correct.');
} else if (_cardDetails.validState == CardDetailsValidState.missingZip) {
_setValidationState('You must enter your card\'s postal code.');
}
return null;
},
onChanged: (str) {
if (_isMobile) {
if (str.isEmpty) {
_backspacePressed();
}
setState(() => _cardDetails.postalCode = str.replaceAll('\u200b', ''));
} else {
setState(() => _cardDetails.postalCode = str);
}
},
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) {
_postalFieldSubmitted();
},
decoration: InputDecoration(
contentPadding: EdgeInsets.zero,
hintText: _isMobile ? '' : 'Postal Code',
hintStyle: _hintTextSyle,
fillColor: Colors.transparent,
border: InputBorder.none,
),
),
],
),
),
AnimatedOpacity(
duration: const Duration(milliseconds: 300),
opacity: _loading ? 1.0 : 0.0,
opacity: _loading && widget.showInternalLoadingWidget ? 1.0 : 0.0,
child: widget.loadingWidget ?? const CircularProgressIndicator(),
),
],
@@ -529,7 +660,8 @@ class CardTextFieldState extends State<CardTextField> {
child: Padding(
padding: const EdgeInsets.only(top: 8.0, left: 14.0),
child: Text(
_validationErrorText ?? '',
// Spacing changes by like a pixel if its an empty string, slight jitter when error appears and disappears
_validationErrorText ?? ' ',
style: const TextStyle(color: Colors.red),
),
),
@@ -538,9 +670,47 @@ class CardTextFieldState extends State<CardTextField> {
);
}
Future<void> _postalFieldSubmitted() async {
_validateFields();
if (_cardDetails.isComplete) {
if (widget.onCardDetailsComplete != null) {
widget.onCardDetailsComplete!(_cardDetails);
} else if (widget.onStripeResponse != null) {
bool returned = false;
Future.delayed(
const Duration(milliseconds: 750),
() => returned ? null : setState(() => _loading = true),
);
const stripeCardUrl = 'https://api.stripe.com/v1/tokens';
// Callback that stripe call is being made
if (widget.onCallToStripe != null) widget.onCallToStripe!();
final response = await http.post(
Uri.parse(stripeCardUrl),
body: {
'card[number]': _cardDetails.cardNumber,
'card[cvc]': _cardDetails.securityCode,
'card[exp_month]': _cardDetails.expMonth,
'card[exp_year]': _cardDetails.expYear,
'card[address_zip]': _cardDetails.postalCode,
'key': widget.stripePublishableKey,
},
headers: {"Content-Type": "application/x-www-form-urlencoded"},
);
returned = true;
final jsonBody = jsonDecode(response.body);
widget.onStripeResponse!(jsonBody);
if (_loading) setState(() => _loading = false);
}
}
}
/// Provided a list of `ValidState`, returns whether
/// make the text field red
bool _isRedText(List<ValidState> args) {
bool _isRedText(List<CardDetailsValidState> args) {
return _showBorderError && args.contains(_cardDetails.validState);
}
@@ -623,6 +793,23 @@ class CardTextFieldState extends State<CardTextField> {
if (!isWideFormat) {
_scrollRow(step);
}
// If mobile, and keyboard is closed, unfocus, to allow refocus
// print(MediaQuery.of(context).viewInsets.bottom);
// if (_isMobile && _hasFocus() && MediaQuery.of(context).viewInsets.bottom == 0.0) {
// cardNumberFocusNode.unfocus();
// expirationFocusNode.unfocus();
// securityCodeFocusNode.unfocus();
// postalCodeFocusNode.unfocus();
// }
}
/// Returns true if any field in the `CardTextField` has focus.
// ignore: unused_element
bool _hasFocus() {
return cardNumberFocusNode.hasFocus ||
expirationFocusNode.hasFocus ||
securityCodeFocusNode.hasFocus ||
postalCodeFocusNode.hasFocus;
}
/// Function that is listening to the keyboard events.
@@ -638,24 +825,49 @@ class CardTextFieldState extends State<CardTextField> {
case CardEntryStep.number:
break;
case CardEntryStep.exp:
final expStr = _expirationController.text;
if (expStr.isNotEmpty) break;
if (_expirationController.text.isNotEmpty) break;
case CardEntryStep.cvc:
if (_securityCodeController.text.isNotEmpty) break;
case CardEntryStep.postal:
if (_postalCodeController.text.isNotEmpty) break;
}
_transitionStepFocus();
}
void _backspacePressed() {
// Put the empty char back into the controller
switch (_currentStep) {
case CardEntryStep.number:
break;
case CardEntryStep.exp:
_expirationController.text = '\u200b';
case CardEntryStep.cvc:
_securityCodeController.text = '\u200b';
case CardEntryStep.postal:
_postalCodeController.text = '\u200b';
}
_transitionStepFocus();
}
void _transitionStepFocus() {
switch (_currentStep) {
case CardEntryStep.number:
break;
case CardEntryStep.exp:
_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);
break;
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);
break;
}
}
}