Compare commits

..

4 Commits

Author SHA1 Message Date
Nathan Anderson
713f35c692 dart formatting 2023-12-07 15:38:08 -07:00
Nathan Anderson
abcbf96d08 0.0.9 2023-12-07 15:37:29 -07:00
Nathan Anderson
f4b05dbccf Fix versioning 2023-12-05 17:45:59 -07:00
Nathan Anderson
ef189517c1 Changed lower sdk constraint 2023-12-05 11:08:39 -07:00
6 changed files with 353 additions and 253 deletions

View File

@ -1,3 +1,18 @@
## 0.0.9
- Drastically improved usability and performance with flutter web and canvaskit renderer, especially on mobile
- Using streams to more accurately call `widget.onValidCardDetails` when the card details are valid and completed
- Added `cursorColor` customization
- Reworked widget life cycle so that hot reloads work as expected (resizing, focus, etc.).
## 0.0.8
- Updated dart sdk constraints again... oops (>=3.0.0)
## 0.0.7
- Changed pubspec versioning to allow lower SDK constraints (>=2.12.0)
## 0.0.6 ## 0.0.6
- Improved assertion and error messaging when missing stripe implements - Improved assertion and error messaging when missing stripe implements

View File

@ -45,10 +45,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: collection name: collection
sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687 sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.2" version: "1.18.0"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@ -135,10 +135,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" version: "1.10.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@ -180,18 +180,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: stack_trace name: stack_trace
sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.11.0" version: "1.11.1"
stream_channel: stream_channel:
dependency: transitive dependency: transitive
description: description:
name: stream_channel name: stream_channel
sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8" sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.1" version: "2.1.2"
string_scanner: string_scanner:
dependency: transitive dependency: transitive
description: description:
@ -206,7 +206,7 @@ packages:
path: ".." path: ".."
relative: true relative: true
source: path source: path
version: "0.0.5" version: "0.0.9"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:
@ -219,10 +219,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8" sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.0" version: "0.6.1"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@ -267,10 +267,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: web name: web
sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10 sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.4-beta" version: "0.3.0"
xml: xml:
dependency: transitive dependency: transitive
description: description:
@ -280,5 +280,5 @@ packages:
source: hosted source: hosted
version: "6.3.0" version: "6.3.0"
sdks: sdks:
dart: ">=3.1.3 <4.0.0" dart: ">=3.2.0-194.0.dev <4.0.0"
flutter: ">=3.7.0-0" flutter: ">=3.7.0-0"

View File

@ -4,7 +4,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 1.0.0+1 version: 1.0.0+1
environment: environment:
sdk: '>=3.1.3 <4.0.0' sdk: '>=3.0.0 <4.0.0'
dependencies: dependencies:
flutter: flutter:

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
/// Class encapsulating the card's data /// Class encapsulating the card's data
@ -39,6 +41,7 @@ class CardDetails {
CardDetailsValidState _validState = CardDetailsValidState.blank; CardDetailsValidState _validState = CardDetailsValidState.blank;
int _lastCheckHash = 0; int _lastCheckHash = 0;
CardProvider? provider; CardProvider? provider;
StreamController<CardDetails> onCompleteController = StreamController();
set overrideValidState(CardDetailsValidState state) => _validState = state; set overrideValidState(CardDetailsValidState state) => _validState = state;
@ -51,7 +54,6 @@ class CardDetails {
String get expMonth => isComplete ? expirationString!.split('/').first : ''; String get expMonth => isComplete ? expirationString!.split('/').first : '';
String get expYear => isComplete ? expirationString!.split('/').last : ''; String get expYear => isComplete ? expirationString!.split('/').last : '';
// TODO rename to be more clear
/// Returns true if `_cardNumber` is null, or /// Returns true if `_cardNumber` is null, or
/// if the _cardNumber matches the detected `provider`'s /// if the _cardNumber matches the detected `provider`'s
/// card lenght, defaulting to 16. /// card lenght, defaulting to 16.
@ -66,6 +68,14 @@ class CardDetails {
return _complete; return _complete;
} }
/// Detects if the card is complete, then broadcasts
/// card details to `onCompleteController`
void broadcastStatus() {
if (isComplete) {
onCompleteController.add(this);
}
}
/// The maximum length of the INN (identifier) /// The maximum length of the INN (identifier)
/// of a card provider. /// of a card provider.
int get maxINNLength => 4; int get maxINNLength => 4;
@ -82,12 +92,12 @@ class CardDetails {
return; return;
} }
_complete = false;
_lastCheckHash = currentHash; _lastCheckHash = currentHash;
if (_cardNumber == null && if (_cardNumber == null &&
expirationString == null && expirationString == null &&
securityCode == null && securityCode == null &&
postalCode == null) { postalCode == null) {
_complete = false;
_validState = CardDetailsValidState.blank; _validState = CardDetailsValidState.blank;
return; return;
} }
@ -99,63 +109,52 @@ class CardDetails {
) )
.toList(); .toList();
if (!_luhnAlgorithmCheck(nums)) { if (!_luhnAlgorithmCheck(nums)) {
_complete = false;
_validState = CardDetailsValidState.invalidCard; _validState = CardDetailsValidState.invalidCard;
return; return;
} }
if (_cardNumber == null || !cardNumberFilled) { if (_cardNumber == null || !cardNumberFilled) {
_complete = false;
_validState = CardDetailsValidState.missingCard; _validState = CardDetailsValidState.missingCard;
return; return;
} }
if (expirationString == null) { if (expirationString == null) {
_complete = false;
_validState = CardDetailsValidState.missingDate; _validState = CardDetailsValidState.missingDate;
return; return;
} }
final expSplits = expirationString!.split('/'); final expSplits = expirationString!.split('/');
if (expSplits.length != 2 || expSplits.last == '') { if (expSplits.length != 2 || expSplits.last == '') {
_complete = false;
_validState = CardDetailsValidState.missingDate; _validState = CardDetailsValidState.missingDate;
return; return;
} }
final month = int.parse( final month = int.parse(
expSplits.first[0] == '0' ? expSplits.first[1] : expSplits.first); expSplits.first[0] == '0' ? expSplits.first[1] : expSplits.first);
if (month < 1 || month > 12) { if (month < 1 || month > 12) {
_complete = false;
_validState = CardDetailsValidState.invalidMonth; _validState = CardDetailsValidState.invalidMonth;
return; return;
} }
final year = 2000 + int.parse(expSplits.last); final year = 2000 + int.parse(expSplits.last);
final date = DateTime(year, month); final date = DateTime(year, month);
if (date.isBefore(DateTime.now())) { if (date.isBefore(DateTime.now())) {
_complete = false;
_validState = CardDetailsValidState.dateTooEarly; _validState = CardDetailsValidState.dateTooEarly;
return; return;
} else if (date } else if (date
.isAfter(DateTime.now().add(const Duration(days: 365 * 50)))) { .isAfter(DateTime.now().add(const Duration(days: 365 * 50)))) {
_complete = false;
_validState = CardDetailsValidState.dateTooLate; _validState = CardDetailsValidState.dateTooLate;
return; return;
} }
expirationDate = date; expirationDate = date;
if (securityCode == null) { if (securityCode == null) {
_complete = false;
_validState = CardDetailsValidState.missingCVC; _validState = CardDetailsValidState.missingCVC;
return; return;
} }
if (provider != null && securityCode!.length != provider!.cvcLength) { if (provider != null && securityCode!.length != provider!.cvcLength) {
_complete = false;
_validState = CardDetailsValidState.invalidCVC; _validState = CardDetailsValidState.invalidCVC;
return; return;
} }
if (postalCode == null) { if (postalCode == null) {
_complete = false;
_validState = CardDetailsValidState.missingZip; _validState = CardDetailsValidState.missingZip;
return; return;
} }
if (!RegExp(r'^\d{5}(-\d{4})?$').hasMatch(postalCode!)) { if (!RegExp(r'^\d{5}(-\d{4})?$').hasMatch(postalCode!)) {
_complete = false;
_validState = CardDetailsValidState.invalidZip; _validState = CardDetailsValidState.invalidZip;
return; return;
} }

View File

@ -42,19 +42,22 @@ class CardTextField extends StatefulWidget {
Key? key, Key? key,
required this.width, required this.width,
this.onStripeResponse, this.onStripeResponse,
this.onCallToStripe,
this.onValidCardDetails, this.onValidCardDetails,
this.onSubmitted,
this.stripePublishableKey, this.stripePublishableKey,
this.height, this.height,
this.textStyle, this.textStyle,
this.hintTextStyle, this.hintTextStyle,
this.errorTextStyle, this.errorTextStyle,
this.cursorColor,
this.boxDecoration, this.boxDecoration,
this.errorBoxDecoration, this.errorBoxDecoration,
this.loadingWidget, this.loadingWidget,
this.loadingWidgetLocation = LoadingLocation.below, this.loadingWidgetLocation = LoadingLocation.below,
this.autoFetchStripektoken = true,
this.showInternalLoadingWidget = true, this.showInternalLoadingWidget = true,
this.delayToShowLoading = const Duration(milliseconds: 0), this.delayToShowLoading = const Duration(milliseconds: 0),
this.onCallToStripe,
this.overrideValidState, this.overrideValidState,
this.errorText, this.errorText,
this.cardFieldWidth, this.cardFieldWidth,
@ -130,12 +133,18 @@ class CardTextField extends StatefulWidget {
/// If null, inherits from the `textStyle`. /// If null, inherits from the `textStyle`.
final TextStyle? errorTextStyle; final TextStyle? errorTextStyle;
/// Color used for the cursor, if null, inherits the primary color of the Theme
final Color? cursorColor;
/// Time to wait until showing the loading indicator when retrieving Stripe token, defaults to 0 milliseconds. /// Time to wait until showing the loading indicator when retrieving Stripe token, defaults to 0 milliseconds.
final Duration delayToShowLoading; final Duration delayToShowLoading;
/// Whether to show the internal loading widget on calls to Stripe /// Whether to show the internal loading widget on calls to Stripe
final bool showInternalLoadingWidget; final bool showInternalLoadingWidget;
/// Whether to automatically call `getStripeResponse` when the `_cardDetails` are valid.
final bool autoFetchStripektoken;
/// Stripe publishable key, starts with 'pk_' /// Stripe publishable key, starts with 'pk_'
final String? stripePublishableKey; final String? stripePublishableKey;
@ -143,11 +152,15 @@ class CardTextField extends StatefulWidget {
final void Function()? onCallToStripe; final void Function()? onCallToStripe;
/// Callback that returns the stripe token for the card /// Callback that returns the stripe token for the card
final void Function(Map<String, dynamic>)? onStripeResponse; final void Function(Map<String, dynamic>?)? onStripeResponse;
/// Callback that returns the completed CardDetails object /// Callback that returns the completed CardDetails object
final void Function(CardDetails)? onValidCardDetails; final void Function(CardDetails)? onValidCardDetails;
/// Callback when the user hits enter or done in the postal code field
/// Optionally returns the `CardDetails` object if it is valid
final void Function(CardDetails?)? onSubmitted;
/// Can manually override the ValidState to surface errors returned from Stripe /// Can manually override the ValidState to surface errors returned from Stripe
final CardDetailsValidState? overrideValidState; final CardDetailsValidState? overrideValidState;
@ -159,20 +172,8 @@ class CardTextField extends StatefulWidget {
// CardTextFieldState? get state => _key.currentState; // CardTextFieldState? get state => _key.currentState;
/// Validates the current fields and makes an http request to get the stripe
/// token for the `CardDetails` provided. Will return null if the data is not
/// complete or does not validate properly.
// Future<Map<String, dynamic>?> fetchStripeResponse() async {
// if (kDebugMode && _key.currentState == null) print('Could not fetch Stripe Response, currentState == null');
// return _key.currentState?.getStripeResponse();
// }
@override @override
State<CardTextField> createState() => CardTextFieldState(); State<CardTextField> createState() => CardTextFieldState();
// {
// _state = CardTextFieldState();
// return _state;
// }
} }
/// State Widget for CardTextField /// State Widget for CardTextField
@ -180,51 +181,53 @@ class CardTextField extends StatefulWidget {
/// create a GlobalKey for directly accessing /// create a GlobalKey for directly accessing
/// the `getStripeResponse` function /// the `getStripeResponse` function
class CardTextFieldState extends State<CardTextField> { class CardTextFieldState extends State<CardTextField> {
late TextEditingController _cardNumberController; late final TextEditingController _cardNumberController;
late TextEditingController _expirationController; late final TextEditingController _expirationController;
late TextEditingController _securityCodeController; late final TextEditingController _securityCodeController;
late TextEditingController _postalCodeController; late final TextEditingController _postalCodeController;
final List<TextEditingController> _controllers = [];
// Not made private for access in widget tests // Not made private for access in widget tests
late FocusNode cardNumberFocusNode; late final FocusNode cardNumberFocusNode;
late FocusNode expirationFocusNode; late final FocusNode expirationFocusNode;
late FocusNode securityCodeFocusNode; late final FocusNode securityCodeFocusNode;
late FocusNode postalCodeFocusNode; late final FocusNode postalCodeFocusNode;
// Not made private for access in widget tests // Not made private for access in widget tests
late final bool isWideFormat; late bool isWideFormat;
// Widget configurable styles // Widget configurable styles
late final BoxDecoration _normalBoxDecoration; late BoxDecoration _normalBoxDecoration;
late final BoxDecoration _errorBoxDecoration; late BoxDecoration _errorBoxDecoration;
late final TextStyle _errorTextStyle; late TextStyle _errorTextStyle;
late final TextStyle _normalTextStyle; late TextStyle _normalTextStyle;
late final TextStyle _hintTextSyle; late TextStyle _hintTextSyle;
late Color _cursorColor;
/// Width of the card number text field /// Width of the card number text field
late final double _cardFieldWidth; late double _cardFieldWidth;
/// Width of the expiration text field /// Width of the expiration text field
late final double _expirationFieldWidth; late double _expirationFieldWidth;
/// Width of the security code text field /// Width of the security code text field
late final double _securityFieldWidth; late double _securityFieldWidth;
/// Width of the postal code text field /// Width of the postal code text field
late final double _postalFieldWidth; late double _postalFieldWidth;
/// Width of the internal scrollable field, is potentially larger than the provided `widget.width` /// Width of the internal scrollable field, is potentially larger than the provided `widget.width`
late final double _internalFieldWidth; late double _internalFieldWidth;
/// Width of the gap between card number and expiration text fields when expanded /// Width of the gap between card number and expiration text fields when expanded
late final double _expanderWidthExpanded; late double _expanderWidthExpanded;
/// Width of the gap between card number and expiration text fields when collapsed /// Width of the gap between card number and expiration text fields when collapsed
late final double _expanderWidthCollapsed; late double _expanderWidthCollapsed;
String? _validationErrorText; String? _validationErrorText;
bool _showBorderError = false; bool _showBorderError = false;
final _isMobile = kIsWeb ? false : Platform.isAndroid || Platform.isIOS; late bool _isMobile;
/// If a request to Stripe is being made /// If a request to Stripe is being made
bool _loading = false; bool _loading = false;
@ -239,10 +242,7 @@ class CardTextFieldState extends State<CardTextField> {
@override @override
void initState() { void initState() {
_cardFieldWidth = widget.cardFieldWidth ?? 180.0; _calculateProperties();
_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 // No way to get backspace events on soft keyboards, so add invisible character to detect delete
_cardNumberController = TextEditingController(); _cardNumberController = TextEditingController();
@ -253,92 +253,38 @@ class CardTextFieldState extends State<CardTextField> {
_postalCodeController = _postalCodeController =
TextEditingController(text: _isMobile ? '\u200b' : ''); TextEditingController(text: _isMobile ? '\u200b' : '');
// Otherwise, use `RawKeyboard` listener _controllers.addAll([
if (!_isMobile) { _cardNumberController,
RawKeyboard.instance.addListener(_backspaceTransitionListener); _expirationController,
} _securityCodeController,
_postalCodeController,
]);
cardNumberFocusNode = FocusNode(); cardNumberFocusNode = FocusNode();
expirationFocusNode = FocusNode(); expirationFocusNode = FocusNode();
securityCodeFocusNode = FocusNode(); securityCodeFocusNode = FocusNode();
postalCodeFocusNode = FocusNode(); postalCodeFocusNode = FocusNode();
_errorTextStyle = // Add backspace transition listener for non mobile clients
const TextStyle(color: Colors.red, fontSize: 14, inherit: true) if (!_isMobile) {
.merge(widget.errorTextStyle ?? widget.textStyle); RawKeyboard.instance.addListener(_backspaceTransitionListener);
_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 ?? widget.textStyle);
_normalBoxDecoration = BoxDecoration(
color: const Color(0xfff6f9fc),
border: Border.all(
color: const Color(0xffdde0e3),
width: 2.0,
),
borderRadius: BorderRadius.circular(8.0),
).copyWith(
backgroundBlendMode: widget.boxDecoration?.backgroundBlendMode,
border: widget.boxDecoration?.border,
borderRadius: widget.boxDecoration?.borderRadius,
boxShadow: widget.boxDecoration?.boxShadow,
color: widget.boxDecoration?.color,
gradient: widget.boxDecoration?.gradient,
image: widget.boxDecoration?.image,
shape: widget.boxDecoration?.shape,
);
_errorBoxDecoration = BoxDecoration(
color: const Color(0xfff6f9fc),
border: Border.all(
color: Colors.red,
width: 2.0,
),
borderRadius: BorderRadius.circular(8.0),
).copyWith(
backgroundBlendMode: widget.errorBoxDecoration?.backgroundBlendMode,
border: widget.errorBoxDecoration?.border,
borderRadius: widget.errorBoxDecoration?.borderRadius,
boxShadow: widget.errorBoxDecoration?.boxShadow,
color: widget.errorBoxDecoration?.color,
gradient: widget.errorBoxDecoration?.gradient,
image: widget.errorBoxDecoration?.image,
shape: widget.errorBoxDecoration?.shape,
);
// Add listener to change focus and whatnot between fields
_currentCardEntryStepController.stream.listen( _currentCardEntryStepController.stream.listen(
_onStepChange, _onStepChange,
); );
isWideFormat = widget.width >= // Add listeners to know when card details are completed
_cardFieldWidth + _cardDetails.onCompleteController.stream.listen((card) async {
_expirationFieldWidth + if (widget.stripePublishableKey != null &&
_securityFieldWidth + widget.onStripeResponse != null &&
_postalFieldWidth + widget.autoFetchStripektoken) {
60.0; final res = await getStripeResponse();
if (isWideFormat) { widget.onStripeResponse!(res);
_internalFieldWidth = widget.width + _postalFieldWidth + 35; }
_expanderWidthExpanded = widget.width - if (widget.onValidCardDetails != null) widget.onValidCardDetails!(card);
_cardFieldWidth - });
_expirationFieldWidth -
_securityFieldWidth -
35;
_expanderWidthCollapsed = widget.width -
_cardFieldWidth -
_expirationFieldWidth -
_securityFieldWidth -
_postalFieldWidth -
70;
} else {
_internalFieldWidth = _cardFieldWidth +
_expirationFieldWidth +
_securityFieldWidth +
_postalFieldWidth +
80;
}
super.initState(); super.initState();
} }
@ -348,10 +294,12 @@ class CardTextFieldState extends State<CardTextField> {
_cardNumberController.dispose(); _cardNumberController.dispose();
_expirationController.dispose(); _expirationController.dispose();
_securityCodeController.dispose(); _securityCodeController.dispose();
_postalCodeController.dispose();
cardNumberFocusNode.dispose(); cardNumberFocusNode.dispose();
expirationFocusNode.dispose(); expirationFocusNode.dispose();
securityCodeFocusNode.dispose(); securityCodeFocusNode.dispose();
postalCodeFocusNode.dispose();
if (!_isMobile) { if (!_isMobile) {
RawKeyboard.instance.removeListener(_backspaceTransitionListener); RawKeyboard.instance.removeListener(_backspaceTransitionListener);
@ -362,13 +310,10 @@ class CardTextFieldState extends State<CardTextField> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if ((widget.errorText != null || widget.overrideValidState != null) && _calculateProperties();
Object.hashAll([widget.errorText, widget.overrideValidState]) != _initStyles();
_prevErrorOverrideHash) { _checkErrorOverride();
_prevErrorOverrideHash =
Object.hashAll([widget.errorText, widget.overrideValidState]);
_validateFields();
}
return Column( return Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -478,7 +423,8 @@ class CardTextFieldState extends State<CardTextField> {
if (content == null || content.isEmpty) { if (content == null || content.isEmpty) {
return null; return null;
} }
_cardDetails.cardNumber = content; // setState(() => _cardDetails.cardNumber = content);
if (_cardDetails.validState == if (_cardDetails.validState ==
CardDetailsValidState.invalidCard) { CardDetailsValidState.invalidCard) {
_setValidationState( _setValidationState(
@ -491,9 +437,9 @@ class CardTextFieldState extends State<CardTextField> {
return null; return null;
}, },
onChanged: (str) { onChanged: (str) {
_onTextFieldChanged(
str, CardEntryStep.number);
final numbers = str.replaceAll(' ', ''); final numbers = str.replaceAll(' ', '');
setState(() =>
_cardDetails.cardNumber = numbers);
if (str.length <= if (str.length <=
_cardDetails.maxINNLength) { _cardDetails.maxINNLength) {
_cardDetails.detectCardProvider(); _cardDetails.detectCardProvider();
@ -512,6 +458,7 @@ class CardTextFieldState extends State<CardTextField> {
RegExp('[0-9 ]')), RegExp('[0-9 ]')),
CardNumberInputFormatter(), CardNumberInputFormatter(),
], ],
cursorColor: _cursorColor,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Card number', hintText: 'Card number',
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
@ -551,7 +498,7 @@ class CardTextFieldState extends State<CardTextField> {
if (_isMobile && if (_isMobile &&
_expirationController.text == _expirationController.text ==
'\u200b') '\u200b')
Text('MM/YY', style: _hintTextSyle), Text('MM/YYY', style: _hintTextSyle),
TextFormField( TextFormField(
key: const Key('expiration_field'), key: const Key('expiration_field'),
focusNode: expirationFocusNode, focusNode: expirationFocusNode,
@ -573,15 +520,12 @@ class CardTextFieldState extends State<CardTextField> {
return null; return null;
} }
if (_isMobile) { // if (_isMobile) {
setState(() => // setState(
_cardDetails.expirationString = // () => _cardDetails.expirationString = content.replaceAll('\u200b', ''));
content.replaceAll( // } else {
'\u200b', '')); // setState(() => _cardDetails.expirationString = content);
} else { // }
setState(() => _cardDetails
.expirationString = content);
}
if (_cardDetails.validState == if (_cardDetails.validState ==
CardDetailsValidState CardDetailsValidState
@ -607,17 +551,8 @@ class CardTextFieldState extends State<CardTextField> {
return null; return null;
}, },
onChanged: (str) { onChanged: (str) {
if (_isMobile) { _onTextFieldChanged(
if (str.isEmpty) { str, CardEntryStep.exp);
_backspacePressed();
}
setState(() => _cardDetails
.expirationString =
str.replaceAll('\u200b', ''));
} else {
setState(() => _cardDetails
.expirationString = str);
}
if (str.length == 5) { if (str.length == 5) {
_currentCardEntryStepController _currentCardEntryStepController
.add(CardEntryStep.cvc); .add(CardEntryStep.cvc);
@ -632,6 +567,7 @@ class CardTextFieldState extends State<CardTextField> {
RegExp('[0-9/]')), RegExp('[0-9/]')),
CardExpirationFormatter(), CardExpirationFormatter(),
], ],
cursorColor: _cursorColor,
decoration: InputDecoration( decoration: InputDecoration(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
hintText: _isMobile ? '' : 'MM/YY', hintText: _isMobile ? '' : 'MM/YY',
@ -674,15 +610,12 @@ class CardTextFieldState extends State<CardTextField> {
return null; return null;
} }
if (_isMobile) { // if (_isMobile) {
setState(() => // setState(
_cardDetails.securityCode = // () => _cardDetails.securityCode = content.replaceAll('\u200b', ''));
content.replaceAll( // } else {
'\u200b', '')); // setState(() => _cardDetails.securityCode = content);
} else { // }
setState(() => _cardDetails
.securityCode = content);
}
if (_cardDetails.validState == if (_cardDetails.validState ==
CardDetailsValidState CardDetailsValidState
@ -701,17 +634,8 @@ class CardTextFieldState extends State<CardTextField> {
_currentCardEntryStepController _currentCardEntryStepController
.add(CardEntryStep.postal), .add(CardEntryStep.postal),
onChanged: (str) { onChanged: (str) {
if (_isMobile) { _onTextFieldChanged(
if (str.isEmpty) { str, CardEntryStep.cvc);
_backspacePressed();
}
setState(() => _cardDetails
.expirationString =
str.replaceAll('\u200b', ''));
} else {
setState(() => _cardDetails
.expirationString = str);
}
if (str.length == if (str.length ==
_cardDetails _cardDetails
@ -729,6 +653,7 @@ class CardTextFieldState extends State<CardTextField> {
FilteringTextInputFormatter.allow( FilteringTextInputFormatter.allow(
RegExp('[0-9]')), RegExp('[0-9]')),
], ],
cursorColor: _cursorColor,
decoration: InputDecoration( decoration: InputDecoration(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
hintText: _isMobile ? '' : 'CVC', hintText: _isMobile ? '' : 'CVC',
@ -771,15 +696,11 @@ class CardTextFieldState extends State<CardTextField> {
return null; return null;
} }
if (_isMobile) { // if (_isMobile) {
setState(() => // setState(() => _cardDetails.postalCode = content.replaceAll('\u200b', ''));
_cardDetails.postalCode = // } else {
content.replaceAll( // setState(() => _cardDetails.postalCode = content);
'\u200b', '')); // }
} else {
setState(() => _cardDetails
.postalCode = content);
}
if (_cardDetails.validState == if (_cardDetails.validState ==
CardDetailsValidState CardDetailsValidState
@ -795,22 +716,14 @@ class CardTextFieldState extends State<CardTextField> {
return null; return null;
}, },
onChanged: (str) { onChanged: (str) {
if (_isMobile) { _onTextFieldChanged(
if (str.isEmpty) { str, CardEntryStep.postal);
_backspacePressed();
}
setState(() => _cardDetails
.postalCode =
str.replaceAll('\u200b', ''));
} else {
setState(() =>
_cardDetails.postalCode = str);
}
}, },
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
onFieldSubmitted: (_) { onFieldSubmitted: (_) {
_postalFieldSubmitted(); _postalFieldSubmitted();
}, },
cursorColor: _cursorColor,
decoration: InputDecoration( decoration: InputDecoration(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
hintText: hintText:
@ -862,24 +775,163 @@ class CardTextFieldState extends State<CardTextField> {
); );
} }
void _onTextFieldChanged(String str, CardEntryStep step) {
String cleanedStr;
if (_isMobile) {
cleanedStr = str.replaceAll('\u200b', '');
} else {
cleanedStr = str;
}
switch (step) {
case CardEntryStep.number:
setState(
() => _cardDetails.cardNumber = cleanedStr.replaceAll(' ', ''));
break;
case CardEntryStep.exp:
setState(() => _cardDetails.expirationString = cleanedStr);
break;
case CardEntryStep.cvc:
setState(() => _cardDetails.securityCode = cleanedStr);
break;
case CardEntryStep.postal:
setState(() => _cardDetails.postalCode = cleanedStr);
break;
}
if (_isMobile && str.isEmpty) {
_mobileBackspaceDetected();
}
// Check if card is complete and broadcast
_cardDetails.broadcastStatus();
}
/// Called in `initState()` as well as `build()`, determines form factor and target device
void _calculateProperties() {
// TODO skip if not needing to recalc
_cardFieldWidth = widget.cardFieldWidth ?? 180.0;
_expirationFieldWidth = widget.expFieldWidth ?? 70.0;
_securityFieldWidth = widget.securityFieldWidth ?? 40.0;
_postalFieldWidth = widget.postalFieldWidth ?? 95.0;
isWideFormat = widget.width >=
_cardFieldWidth +
_expirationFieldWidth +
_securityFieldWidth +
_postalFieldWidth +
60.0;
if (isWideFormat) {
_internalFieldWidth = widget.width + _postalFieldWidth + 35;
_expanderWidthExpanded = widget.width -
_cardFieldWidth -
_expirationFieldWidth -
_securityFieldWidth -
35;
_expanderWidthCollapsed = widget.width -
_cardFieldWidth -
_expirationFieldWidth -
_securityFieldWidth -
_postalFieldWidth -
70;
} else {
_internalFieldWidth = _cardFieldWidth +
_expirationFieldWidth +
_securityFieldWidth +
_postalFieldWidth +
80;
}
_isMobile = kIsWeb ? !isWideFormat : Platform.isAndroid || Platform.isIOS;
// int index = 0;
// for (final controller in _controllers) {
// if (controller.text.isNotEmpty || index == 0) continue;
// controller.text = '\u200b';
// index += 1;
// }
}
/// Called every `build()` invocation, combines passed in styles with the defaults
void _initStyles() {
_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 ?? widget.textStyle);
_normalBoxDecoration = BoxDecoration(
color: const Color(0xfff6f9fc),
border: Border.all(
color: const Color(0xffdde0e3),
width: 2.0,
),
borderRadius: BorderRadius.circular(8.0),
).copyWith(
backgroundBlendMode: widget.boxDecoration?.backgroundBlendMode,
border: widget.boxDecoration?.border,
borderRadius: widget.boxDecoration?.borderRadius,
boxShadow: widget.boxDecoration?.boxShadow,
color: widget.boxDecoration?.color,
gradient: widget.boxDecoration?.gradient,
image: widget.boxDecoration?.image,
shape: widget.boxDecoration?.shape,
);
_errorBoxDecoration = BoxDecoration(
color: const Color(0xfff6f9fc),
border: Border.all(
color: Colors.red,
width: 2.0,
),
borderRadius: BorderRadius.circular(8.0),
).copyWith(
backgroundBlendMode: widget.errorBoxDecoration?.backgroundBlendMode,
border: widget.errorBoxDecoration?.border,
borderRadius: widget.errorBoxDecoration?.borderRadius,
boxShadow: widget.errorBoxDecoration?.boxShadow,
color: widget.errorBoxDecoration?.color,
gradient: widget.errorBoxDecoration?.gradient,
image: widget.errorBoxDecoration?.image,
shape: widget.errorBoxDecoration?.shape,
);
_cursorColor = widget.cursorColor ?? Theme.of(context).primaryColor;
}
void _checkErrorOverride() {
if ((widget.errorText != null || widget.overrideValidState != null) &&
Object.hashAll([widget.errorText, widget.overrideValidState]) !=
_prevErrorOverrideHash) {
_prevErrorOverrideHash =
Object.hashAll([widget.errorText, widget.overrideValidState]);
_validateFields();
}
}
// Makes an http call to stripe API with provided card credentials and returns the result // Makes an http call to stripe API with provided card credentials and returns the result
Future<Map<String, dynamic>?> getStripeResponse() async { Future<Map<String, dynamic>?> getStripeResponse() async {
_validateFields();
if (!_cardDetails.isComplete) {
if (kDebugMode)
print(
'Could not get stripe response, card details not complete: $_cardDetails');
return null;
}
if (widget.onCallToStripe != null) widget.onCallToStripe!();
if (widget.stripePublishableKey == null) { if (widget.stripePublishableKey == null) {
if (kDebugMode) if (kDebugMode)
print( print(
'***ERROR tried calling `getStripeToken()` but no stripe key provided'); '***ERROR tried calling `getStripeResponse()` but no stripe key provided');
return null; return null;
} }
_validateFields();
if (!_cardDetails.isComplete) {
if (kDebugMode) {
print(
'***ERROR Could not get stripe response, card details not complete: ${_cardDetails.validState}');
}
return null;
}
if (widget.onCallToStripe != null) widget.onCallToStripe!();
bool returned = false; bool returned = false;
Future.delayed( Future.delayed(
widget.delayToShowLoading, widget.delayToShowLoading,
@ -908,16 +960,19 @@ class CardTextFieldState extends State<CardTextField> {
Future<void> _postalFieldSubmitted() async { Future<void> _postalFieldSubmitted() async {
_validateFields(); _validateFields();
if (widget.onSubmitted != null) {
widget.onSubmitted!(_cardDetails.isComplete ? _cardDetails : null);
}
if (_cardDetails.isComplete) { if (_cardDetails.isComplete) {
if (widget.onValidCardDetails != null) { if (widget.onValidCardDetails != null) {
widget.onValidCardDetails!(_cardDetails); widget.onValidCardDetails!(_cardDetails);
} else if (widget.onStripeResponse != null) { } else if (widget.onStripeResponse != null &&
!widget.autoFetchStripektoken) {
// Callback that stripe call is being made // Callback that stripe call is being made
if (widget.onCallToStripe != null) widget.onCallToStripe!(); if (widget.onCallToStripe != null) widget.onCallToStripe!();
final jsonBody = await getStripeResponse(); final jsonBody = await getStripeResponse();
if (jsonBody != null) widget.onStripeResponse!(jsonBody); widget.onStripeResponse!(jsonBody);
if (_loading) setState(() => _loading = false);
} }
} }
} }
@ -957,12 +1012,13 @@ class CardTextFieldState extends State<CardTextField> {
/// Used when `_isWideFormat == false`, scrolls /// Used when `_isWideFormat == false`, scrolls
/// the `_horizontalScrollController` to a given offset /// the `_horizontalScrollController` to a given offset
void _scrollRow(CardEntryStep step) { void _scrollRow(CardEntryStep step) async {
await Future.delayed(const Duration(milliseconds: 25));
const dur = Duration(milliseconds: 150); const dur = Duration(milliseconds: 150);
const cur = Curves.easeOut; const cur = Curves.easeOut;
switch (step) { switch (step) {
case CardEntryStep.number: case CardEntryStep.number:
_horizontalScrollController.animateTo(0.0, duration: dur, curve: cur); _horizontalScrollController.animateTo(-20.0, duration: dur, curve: cur);
break; break;
case CardEntryStep.exp: case CardEntryStep.exp:
_horizontalScrollController.animateTo(_cardFieldWidth / 2, _horizontalScrollController.animateTo(_cardFieldWidth / 2,
@ -987,14 +1043,14 @@ class CardTextFieldState extends State<CardTextField> {
/// StreamController. Manages validation and tracking of the current step /// StreamController. Manages validation and tracking of the current step
/// as well as scrolling the text fields. /// as well as scrolling the text fields.
void _onStepChange(CardEntryStep step) { void _onStepChange(CardEntryStep step) {
// Validated fields only when progressing, not when regressing in step
if (_currentStep.index < step.index) { if (_currentStep.index < step.index) {
_validateFields(); _validateFields();
} else if (_currentStep != step) { } else if (_currentStep != step) {
_setValidationState(null); _setValidationState(null);
} }
// If field tapped, and has focus, dismiss focus // If field tapped, and has focus, dismiss focus
if (_currentStep == step && _hasFocus()) { if (_currentStep == step && _anyHaveFocus()) {
FocusManager.instance.primaryFocus?.unfocus(); FocusManager.instance.primaryFocus?.unfocus();
return; return;
} }
@ -1002,7 +1058,7 @@ class CardTextFieldState extends State<CardTextField> {
setState(() { setState(() {
_currentStep = step; _currentStep = step;
}); });
switch (step) { switch (_currentStep) {
case CardEntryStep.number: case CardEntryStep.number:
cardNumberFocusNode.requestFocus(); cardNumberFocusNode.requestFocus();
break; break;
@ -1016,28 +1072,54 @@ class CardTextFieldState extends State<CardTextField> {
postalCodeFocusNode.requestFocus(); postalCodeFocusNode.requestFocus();
break; break;
} }
/// Make the selection adjustment after first frame builds
if (kIsWeb)
WidgetsBinding.instance.addPostFrameCallback((_) => _adjustSelection());
if (!isWideFormat) { if (!isWideFormat) {
_scrollRow(step); _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. /// Returns true if any field in the `CardTextField` has focus.
// ignore: unused_element bool _anyHaveFocus() {
bool _hasFocus() {
return cardNumberFocusNode.hasFocus || return cardNumberFocusNode.hasFocus ||
expirationFocusNode.hasFocus || expirationFocusNode.hasFocus ||
securityCodeFocusNode.hasFocus || securityCodeFocusNode.hasFocus ||
postalCodeFocusNode.hasFocus; postalCodeFocusNode.hasFocus;
} }
/// On web, selection gets screwy when changing focus, workaround for placing cursor at the end of the text content only
void _adjustSelection() {
switch (_currentStep) {
case CardEntryStep.number:
final len = _cardNumberController.text.length;
final offset = len == 0 ? 1 : len;
_cardNumberController.value = _cardNumberController.value.copyWith(
selection: TextSelection(baseOffset: offset, extentOffset: offset));
break;
case CardEntryStep.exp:
final len = _expirationController.text.length;
final offset = len == 0 ? 0 : len;
_expirationController.value = _expirationController.value.copyWith(
selection: TextSelection(baseOffset: offset, extentOffset: offset));
break;
case CardEntryStep.cvc:
final len = _securityCodeController.text.length;
final offset = len == 0 ? 0 : len;
_securityCodeController.value = _securityCodeController.value.copyWith(
selection: TextSelection(baseOffset: offset, extentOffset: offset));
break;
case CardEntryStep.postal:
final len = _postalCodeController.text.length;
final offset = len == 0 ? 0 : len;
_postalCodeController.value = _postalCodeController.value.copyWith(
selection: TextSelection(baseOffset: offset, extentOffset: offset));
break;
}
}
/// Function that is listening to the keyboard events. /// Function that is listening to the keyboard events.
/// ///
/// This provides the functionality of hitting backspace /// This provides the functionality of hitting backspace
@ -1049,7 +1131,7 @@ class CardTextFieldState extends State<CardTextField> {
} }
switch (_currentStep) { switch (_currentStep) {
case CardEntryStep.number: case CardEntryStep.number:
break; return;
case CardEntryStep.exp: case CardEntryStep.exp:
if (_expirationController.text.isNotEmpty) return; if (_expirationController.text.isNotEmpty) return;
case CardEntryStep.cvc: case CardEntryStep.cvc:
@ -1060,8 +1142,9 @@ class CardTextFieldState extends State<CardTextField> {
_transitionStepFocus(); _transitionStepFocus();
} }
void _backspacePressed() { /// Called whenever a text field is emptied and the mobile flag is set
// Put the empty char back into the controller void _mobileBackspaceDetected() {
// Put the empty char back into the controller to detect backspace on mobile
switch (_currentStep) { switch (_currentStep) {
case CardEntryStep.number: case CardEntryStep.number:
break; break;
@ -1081,18 +1164,22 @@ class CardTextFieldState extends State<CardTextField> {
break; break;
case CardEntryStep.exp: case CardEntryStep.exp:
_currentCardEntryStepController.add(CardEntryStep.number); _currentCardEntryStepController.add(CardEntryStep.number);
String numStr = _cardNumberController.text;
_cardNumberController.text = numStr.substring(0, numStr.length - 1); final String numStr = _cardNumberController.text;
final endIndex = numStr.isEmpty ? 0 : numStr.length - 1;
_cardNumberController.text = numStr.substring(0, endIndex);
break; break;
case CardEntryStep.cvc: case CardEntryStep.cvc:
_currentCardEntryStepController.add(CardEntryStep.exp); _currentCardEntryStepController.add(CardEntryStep.exp);
final expStr = _expirationController.text; final String expStr = _expirationController.text;
_expirationController.text = expStr.substring(0, expStr.length - 1); final endIndex = expStr.isEmpty ? 0 : expStr.length - 1;
_expirationController.text = expStr.substring(0, endIndex);
break; break;
case CardEntryStep.postal: case CardEntryStep.postal:
_currentCardEntryStepController.add(CardEntryStep.cvc); _currentCardEntryStepController.add(CardEntryStep.cvc);
final String cvcStr = _securityCodeController.text; final String cvcStr = _securityCodeController.text;
_securityCodeController.text = cvcStr.substring(0, cvcStr.length - 1); final endIndex = cvcStr.isEmpty ? 0 : cvcStr.length - 1;
_securityCodeController.text = cvcStr.substring(0, endIndex);
break; break;
} }
} }

View File

@ -1,11 +1,10 @@
name: stripe_native_card_field name: stripe_native_card_field
description: A native flutter implementation of the elegant Stripe Card Field. description: A native flutter implementation of the elegant Stripe Card Field.
version: 0.0.6 version: 0.0.9
repository: https://git.fosscat.com/n8r/stripe_native_card_field repository: https://git.fosscat.com/n8r/stripe_native_card_field
environment: environment:
sdk: '>=3.1.3 <4.0.0' sdk: '>=3.0.0 <4.0.0'
flutter: ">=1.17.0"
dependencies: dependencies:
flutter: flutter: