From 462e40308fd823b8cda88b5ec7e80066f4c5e6ac Mon Sep 17 00:00:00 2001 From: Nathan Anderson Date: Fri, 1 Dec 2023 16:12:42 -0700 Subject: [PATCH] 0.0.6 release --- CHANGELOG.md | 10 + README.md | 21 +- example/lib/dark_customization.dart | 5 +- example/lib/main.dart | 39 +- example/pubspec.lock | 2 +- lib/card_text_field_error.dart | 19 + lib/stripe_native_card_field.dart | 900 +++++++++++++----------- pubspec.yaml | 2 +- test/stripe_native_card_field_test.dart | 4 +- 9 files changed, 581 insertions(+), 421 deletions(-) create mode 100644 lib/card_text_field_error.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index dc7b17d..0425b9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## 0.0.6 + +- Improved assertion and error messaging when missing stripe implements +- Added better doc comments +- Fixed `CardTextField.delayToShowLoading`, now it uses it +- Fixed bad assertion logic when providing stripe keys +- Added ability to make Stripe call with `GlobalKey` +- Refactored method `onTokenReceived` to `onStripeResponse` to be clearer +- Refactored method `onCardDetailsComplete` to `onValidCardDetails` to be clearer + ## 0.0.5 - Fix Web, invalid call to `Platform.isAndroid` diff --git a/README.md b/README.md index 7103cbb..ee3431e 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Got to use emojis and taglines for attention grabbing and algorithm hacking: - Native Implementation: compiles and loads like the rest of your app, unlike embeded html - Automatic validation: no `inputFormatters` or `RegExp` needed on your side -The card data can either be retrieved with the `onCardDetailsComplete` callback, or +The card data can either be retrieved with the `onValidCardDetails` callback, or you can have the element automatically create a Stripe card token when the fields are filled out, and return the token with the `onTokenReceived` callback. @@ -62,12 +62,13 @@ Include the package in a file: import 'package:stripe_native_card_field/stripe_native_card_field.dart'; ``` -### For just Card Data +### For Raw Card Data +Provide a callback for the `CardTextField` to return you the data when its complete. ```dart CardTextField( width: 500, - onCardDetailsComplete: (details) { + onValidCardDetails: (details) { // Save the card details to use with your call to Stripe, or whoever setState(() => _cardDetails = details); }, @@ -76,17 +77,27 @@ CardTextField( ### For Stripe Token +Simply provide a function for the `onStripeResponse` callback! + ```dart CardTextField( width: 500, stripePublishableKey: 'pk_test_abc123', // Your stripe key here - onTokenReceived: (token) { + onStripeResponse: (Map data) { // Save the stripe token to send to your backend - setState(() => _token = token); + setState(() => _tokenData = data); }, ); ``` +If you want more fine-grained control of when the stripe call is made, you +can create a `GlobalKey` and access the `CardTextFieldState`, calling the +`getStripeResponse()` function yourself. See the provided [example](https://pub.dev/packages/stripe_native_card_field/example) +for details. If you choose this route, do not provide an `onStripeResponse` callback, or you will end up +making two calls to stripe! + # Additional information Repository located [here](https://git.fosscat.com/n8r/stripe_native_card_field) + +Please email me at n8r@fosscat.com for any issues or PRs. diff --git a/example/lib/dark_customization.dart b/example/lib/dark_customization.dart index b59975d..30c5fb8 100644 --- a/example/lib/dark_customization.dart +++ b/example/lib/dark_customization.dart @@ -61,14 +61,15 @@ class _MyHomePageState extends State { ), CardTextField( width: 300, - onCardDetailsComplete: (details) { + onValidCardDetails: (details) { if (kDebugMode) { print(details); } }, textStyle: const TextStyle(fontFamily: 'Lato', color: Colors.tealAccent), - hintTextStyle: const TextStyle(fontFamily: 'Lato', color: Colors.teal), + hintTextStyle: + const TextStyle(fontFamily: 'Lato', color: Colors.teal), errorTextStyle: const TextStyle(color: Colors.purpleAccent), boxDecoration: BoxDecoration( color: Colors.black54, diff --git a/example/lib/main.dart b/example/lib/main.dart index 20b0c67..40f5a5e 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -38,8 +38,26 @@ class _MyHomePageState extends State { CardDetailsValidState? state; String? errorText; + // Creating a global key here allows us to call the `getStripeResponse()` + // inside the CardTextFieldState widget in our build method. See below + final _key = GlobalKey(); + @override Widget build(BuildContext context) { + final cardField = CardTextField( + key: _key, + loadingWidgetLocation: LoadingLocation.above, + stripePublishableKey: 'pk_test_abc123testmykey', + width: 600, + onValidCardDetails: (details) { + if (kDebugMode) { + print(details); + } + }, + overrideValidState: state, + errorText: errorText, + ); + return Scaffold( body: Center( child: Column( @@ -51,22 +69,23 @@ class _MyHomePageState extends State { 'Enter your card details below:', ), ), - CardTextField( - width: 300, - onCardDetailsComplete: (details) { - if (kDebugMode) { - print(details); - } - }, - overrideValidState: state, - errorText: errorText, - ), + cardField, ElevatedButton( child: const Text('Set manual error'), onPressed: () => setState(() { errorText = 'There is a problem'; state = CardDetailsValidState.invalidCard; }), + ), + const SizedBox(height: 12), + ElevatedButton( + child: const Text('Get Stripe token'), + onPressed: () async { + // Here we use the global key to get the stripe data, rather than + // using the `onStripeResponse` callback in the widget + final tok = await _key.currentState?.getStripeResponse(); + if (kDebugMode) print(tok); + }, ) ], ), diff --git a/example/pubspec.lock b/example/pubspec.lock index a26d925..ea0a19c 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -206,7 +206,7 @@ packages: path: ".." relative: true source: path - version: "0.0.3" + version: "0.0.5" term_glyph: dependency: transitive description: diff --git a/lib/card_text_field_error.dart b/lib/card_text_field_error.dart new file mode 100644 index 0000000..19e7a23 --- /dev/null +++ b/lib/card_text_field_error.dart @@ -0,0 +1,19 @@ +/// Error class that `CardTextField` throws if any errors are encountered +class CardTextFieldError extends Error { + /// Details provided for the error + String? details; + CardTextFieldErrorType type; + + CardTextFieldError(this.type, {this.details}); + + @override + String toString() { + return 'CardTextFieldError-${type.name}: $details'; + } +} + +/// Enum to add typing to the `CardTextFieldErrorType` +enum CardTextFieldErrorType { + stripeImplementation, + unknown, +} diff --git a/lib/stripe_native_card_field.dart b/lib/stripe_native_card_field.dart index 551e4a3..58343af 100644 --- a/lib/stripe_native_card_field.dart +++ b/lib/stripe_native_card_field.dart @@ -2,13 +2,13 @@ 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'; import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; +import 'package:stripe_native_card_field/card_text_field_error.dart'; import 'card_details.dart'; import 'card_provider_icon.dart'; @@ -17,13 +17,23 @@ import 'card_provider_icon.dart'; /// entry process. enum CardEntryStep { number, exp, cvc, postal } -// enum LoadingLocation { ontop, rightInside } +enum LoadingLocation { above, below } /// A uniform text field for entering card details, based /// on the behavior of Stripe's various html elements. /// /// Required `width`. /// +/// To get the card data or stripe token, provide callbacks +/// for either `onValidCardDetails`, which will return a +/// `CardDetails` object, or `onStripeResponse`, which will +/// return a Map response from the Stripe Api. +/// +/// If stripe integration is desired, you must provide both +/// `stripePublishableKey` and `onStripeResponse`, otherwise +/// `CardTextField` will `assert(false)` in debug mode or +/// throw a `CardTextFieldError` in profile or release mode +/// /// If the provided `width < 450.0`, the `CardTextField` /// will scroll its content horizontally with the cursor /// to compensate. @@ -32,7 +42,7 @@ class CardTextField extends StatefulWidget { Key? key, required this.width, this.onStripeResponse, - this.onCardDetailsComplete, + this.onValidCardDetails, this.stripePublishableKey, this.height, this.textStyle, @@ -41,8 +51,9 @@ class CardTextField extends StatefulWidget { this.boxDecoration, this.errorBoxDecoration, this.loadingWidget, + this.loadingWidgetLocation = LoadingLocation.below, this.showInternalLoadingWidget = true, - this.delayToShowLoading = const Duration(milliseconds: 750), + this.delayToShowLoading = const Duration(milliseconds: 0), this.onCallToStripe, this.overrideValidState, this.errorText, @@ -53,21 +64,17 @@ class CardTextField extends StatefulWidget { this.iconSize, this.cardIconColor, this.cardIconErrorColor, - // this.loadingWidgetLocation = LoadingLocation.rightInside, }) : super(key: key) { + // Setup logic for the CardTextField + // Will assert in debug mode, otherwise will throw `CardTextFieldError` in profile or release if (stripePublishableKey != null) { - assert(stripePublishableKey!.startsWith('pk_')); - if (kReleaseMode && !stripePublishableKey!.startsWith('pk_live_')) { - log('StripeNativeCardField: *WARN* You are not using a live publishableKey in production.'); - } else if ((kDebugMode || kProfileMode) && - stripePublishableKey!.startsWith('pk_live_')) { - 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) { - log('StripeNativeCardField: *ERROR* You provided the onTokenReceived callback, but did not provide a stripePublishableKey.'); - assert(false); + if (!stripePublishableKey!.startsWith('pk_')) { + const msg = 'Invalid stripe key, doesn\'t start with "pk_"'; + if (kDebugMode) assert(false, msg); + if (kReleaseMode || kProfileMode) { + throw CardTextFieldError(CardTextFieldErrorType.stripeImplementation, + details: msg); + } } } } @@ -96,7 +103,8 @@ class CardTextField extends StatefulWidget { /// Overrides the default box decoration of the text field when there is a validation error final BoxDecoration? errorBoxDecoration; - /// Shown and overrides CircularProgressIndicator() if the request to stripe takes longer than `delayToShowLoading` + /// Shown and overrides `LinearProgressIndicator` if the request to stripe takes longer than `delayToShowLoading` + /// Recommended to only override with a `LinearProgressIndicator` or similar widget, or spacing will be messed up final Widget? loadingWidget; /// Overrides default icon size of the card provider, defaults to `Size(30.0, 20.0)` @@ -109,7 +117,7 @@ class CardTextField extends StatefulWidget { final String? cardIconErrorColor; /// Determines where the loading indicator appears when contacting stripe - // final LoadingLocation loadingWidgetLocation; + final LoadingLocation loadingWidgetLocation; /// Default TextStyle final TextStyle? textStyle; @@ -122,7 +130,7 @@ class CardTextField extends StatefulWidget { /// If null, inherits from the `textStyle`. final TextStyle? errorTextStyle; - /// Time to wait until showing the loading indicator when retrieving Stripe token + /// Time to wait until showing the loading indicator when retrieving Stripe token, defaults to 0 milliseconds. final Duration delayToShowLoading; /// Whether to show the internal loading widget on calls to Stripe @@ -138,7 +146,7 @@ class CardTextField extends StatefulWidget { final void Function(Map)? onStripeResponse; /// Callback that returns the completed CardDetails object - final void Function(CardDetails)? onCardDetailsComplete; + final void Function(CardDetails)? onValidCardDetails; /// Can manually override the ValidState to surface errors returned from Stripe final CardDetailsValidState? overrideValidState; @@ -146,14 +154,31 @@ class CardTextField extends StatefulWidget { /// Can manually override the errorText displayed to surface errors returned from Stripe final String? errorText; + /// GlobalKey used for calling `getStripeToken` in the `CardTextFieldState` + // final GlobalKey _key = GlobalKey(); + + // 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?> fetchStripeResponse() async { + // if (kDebugMode && _key.currentState == null) print('Could not fetch Stripe Response, currentState == null'); + // return _key.currentState?.getStripeResponse(); + // } + @override State createState() => CardTextFieldState(); + // { + // _state = CardTextFieldState(); + // return _state; + // } } /// State Widget for CardTextField -/// Should not be used directly, create a -/// `CardTextField()` instead. -@visibleForTesting +/// Should not be used directly, except to +/// create a GlobalKey for directly accessing +/// the `getStripeResponse` function class CardTextFieldState extends State { late TextEditingController _cardNumberController; late TextEditingController _expirationController; @@ -373,7 +398,9 @@ class CardTextFieldState extends State { } }, onHorizontalDragEnd: (details) { - if (!_isMobile || isWideFormat || details.primaryVelocity == null) { + if (!_isMobile || + isWideFormat || + details.primaryVelocity == null) { return; } @@ -399,366 +426,417 @@ class CardTextFieldState extends State { child: SizedBox( width: _internalFieldWidth, height: widget.height ?? 60.0, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.start, + child: Column( children: [ + if (widget.loadingWidgetLocation == + LoadingLocation.above) + AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: + _loading && widget.showInternalLoadingWidget + ? 1.0 + : 0.0, + child: widget.loadingWidget ?? + const LinearProgressIndicator(), + ), Padding( - padding: - const EdgeInsets.symmetric(horizontal: 6.0), - child: CardProviderIcon( - cardDetails: _cardDetails, - size: widget.iconSize, - defaultCardColor: widget.cardIconColor, - errorCardColor: widget.cardIconErrorColor, - ), - ), - SizedBox( - width: _cardFieldWidth, - child: TextFormField( - key: const Key('card_field'), - focusNode: cardNumberFocusNode, - controller: _cardNumberController, - keyboardType: TextInputType.number, - style: _isRedText([ - CardDetailsValidState.invalidCard, - CardDetailsValidState.missingCard, - CardDetailsValidState.blank - ]) - ? _errorTextStyle - : _normalTextStyle, - validator: (content) { - if (content == null || content.isEmpty) { - return null; - } - _cardDetails.cardNumber = content; - if (_cardDetails.validState == - CardDetailsValidState.invalidCard) { - _setValidationState( - 'Your card number is invalid.'); - } else if (_cardDetails.validState == - CardDetailsValidState.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); - } - }, - onFieldSubmitted: (_) => - _currentCardEntryStepController - .add(CardEntryStep.exp), - inputFormatters: [ - LengthLimitingTextInputFormatter(19), - FilteringTextInputFormatter.allow( - RegExp('[0-9 ]')), - CardNumberInputFormatter(), - ], - decoration: InputDecoration( - hintText: 'Card number', - contentPadding: EdgeInsets.zero, - hintStyle: _hintTextSyle, - fillColor: Colors.transparent, - border: InputBorder.none, - ), - ), - ), - if (isWideFormat) - Flexible( - fit: FlexFit.loose, - // fit: _currentStep == CardEntryStep.number ? FlexFit.loose : FlexFit.tight, - child: AnimatedContainer( - curve: Curves.easeInOut, - duration: const Duration(milliseconds: 400), - constraints: - _currentStep == CardEntryStep.number - ? BoxConstraints.loose( - Size(_expanderWidthExpanded, 0.0), - ) - : BoxConstraints.tight( - Size(_expanderWidthCollapsed, 0.0), - ), - ), - ), - - // Spacer(flex: _currentStep == CardEntryStep.number ? 100 : 1), - SizedBox( - width: _expirationFieldWidth, - child: Stack( - alignment: Alignment.centerLeft, + padding: switch (widget.loadingWidgetLocation) { + LoadingLocation.above => + const EdgeInsets.only(top: 0, bottom: 4.0), + LoadingLocation.below => + const EdgeInsets.only(top: 4.0, bottom: 0), + }, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.start, 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(); + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 6.0), + child: CardProviderIcon( + cardDetails: _cardDetails, + size: widget.iconSize, + defaultCardColor: widget.cardIconColor, + errorCardColor: widget.cardIconErrorColor, + ), + ), + SizedBox( + width: _cardFieldWidth, + child: TextFormField( + key: const Key('card_field'), + focusNode: cardNumberFocusNode, + controller: _cardNumberController, + keyboardType: TextInputType.number, + style: _isRedText([ + CardDetailsValidState.invalidCard, + CardDetailsValidState.missingCard, + CardDetailsValidState.blank + ]) + ? _errorTextStyle + : _normalTextStyle, + validator: (content) { + if (content == null || content.isEmpty) { + return null; } + _cardDetails.cardNumber = content; + if (_cardDetails.validState == + CardDetailsValidState.invalidCard) { + _setValidationState( + 'Your card number is invalid.'); + } else if (_cardDetails.validState == + CardDetailsValidState.missingCard) { + _setValidationState( + 'Your card number is incomplete.'); + } + return null; + }, + onChanged: (str) { + final numbers = str.replaceAll(' ', ''); 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, + _cardDetails.cardNumber = numbers); + if (str.length <= + _cardDetails.maxINNLength) { + _cardDetails.detectCardProvider(); + } + if (numbers.length == 16) { + _currentCardEntryStepController + .add(CardEntryStep.exp); + } + }, + onFieldSubmitted: (_) => + _currentCardEntryStepController + .add(CardEntryStep.exp), + inputFormatters: [ + LengthLimitingTextInputFormatter(19), + FilteringTextInputFormatter.allow( + RegExp('[0-9 ]')), + CardNumberInputFormatter(), + ], + decoration: InputDecoration( + hintText: 'Card number', + contentPadding: EdgeInsets.zero, + hintStyle: _hintTextSyle, + fillColor: Colors.transparent, + border: InputBorder.none, + ), + ), + ), + if (isWideFormat) + Flexible( + fit: FlexFit.loose, + // fit: _currentStep == CardEntryStep.number ? FlexFit.loose : FlexFit.tight, + child: AnimatedContainer( + curve: Curves.easeInOut, + duration: + const Duration(milliseconds: 400), + constraints: _currentStep == + CardEntryStep.number + ? BoxConstraints.loose( + Size(_expanderWidthExpanded, 0.0), + ) + : BoxConstraints.tight( + Size( + _expanderWidthCollapsed, 0.0), + ), + ), + ), + + // Spacer(flex: _currentStep == CardEntryStep.number ? 100 : 1), + SizedBox( + width: _expirationFieldWidth, + 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, + ), + ), + ], + ), + ), + SizedBox( + width: _securityFieldWidth, + 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, + ), + ), + ], + ), + ), + SizedBox( + width: _postalFieldWidth, + 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 (_isMobile) { + setState(() => + _cardDetails.postalCode = + content.replaceAll( + '\u200b', '')); + } else { + setState(() => _cardDetails + .postalCode = content); + } + + 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, + ), + ), + ], ), ), ], ), ), - SizedBox( - width: _securityFieldWidth, - 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, - ), - ), - ], + if (widget.loadingWidgetLocation == + LoadingLocation.below) + AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: + _loading && widget.showInternalLoadingWidget + ? 1.0 + : 0.0, + child: widget.loadingWidget ?? + const LinearProgressIndicator(), ), - ), - SizedBox( - width: _postalFieldWidth, - 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 (_isMobile) { - setState(() => _cardDetails.postalCode = - content.replaceAll('\u200b', '')); - } else { - setState(() => - _cardDetails.postalCode = content); - } - - 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 && widget.showInternalLoadingWidget - ? 1.0 - : 0.0, - child: widget.loadingWidget ?? - const CircularProgressIndicator(), - ), ], ), ), @@ -784,39 +862,61 @@ class CardTextFieldState extends State { ); } + // Makes an http call to stripe API with provided card credentials and returns the result + Future?> 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 (kDebugMode) + print( + '***ERROR tried calling `getStripeToken()` but no stripe key provided'); + return null; + } + + bool returned = false; + Future.delayed( + widget.delayToShowLoading, + () => 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 Map jsonBody = jsonDecode(response.body); + if (_loading) setState(() => _loading = false); + return jsonBody; + } + Future _postalFieldSubmitted() async { _validateFields(); if (_cardDetails.isComplete) { - if (widget.onCardDetailsComplete != null) { - widget.onCardDetailsComplete!(_cardDetails); + if (widget.onValidCardDetails != null) { + widget.onValidCardDetails!(_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"}, - ); + final jsonBody = await getStripeResponse(); - returned = true; - final jsonBody = jsonDecode(response.body); - - widget.onStripeResponse!(jsonBody); + if (jsonBody != null) widget.onStripeResponse!(jsonBody); if (_loading) setState(() => _loading = false); } } diff --git a/pubspec.yaml b/pubspec.yaml index 9387f2b..254f760 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: stripe_native_card_field description: A native flutter implementation of the elegant Stripe Card Field. -version: 0.0.5 +version: 0.0.6 repository: https://git.fosscat.com/n8r/stripe_native_card_field environment: diff --git a/test/stripe_native_card_field_test.dart b/test/stripe_native_card_field_test.dart index 0e451dc..8324261 100644 --- a/test/stripe_native_card_field_test.dart +++ b/test/stripe_native_card_field_test.dart @@ -18,7 +18,7 @@ void main() { CardDetails? details; final cardField = CardTextField( width: width, - onCardDetailsComplete: (cd) => details = cd, + onValidCardDetails: (cd) => details = cd, ); await tester.pumpWidget(baseCardFieldWidget(cardField)); @@ -110,7 +110,7 @@ void main() { final cardField = CardTextField( width: width, - onCardDetailsComplete: (cd) => details = cd, + onValidCardDetails: (cd) => details = cd, ); await tester.pumpWidget(baseCardFieldWidget(cardField));