From 462e40308fd823b8cda88b5ec7e80066f4c5e6ac Mon Sep 17 00:00:00 2001
From: Nathan Anderson <nathananderson98@gmail.com>
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<String, dynamic> 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<MyHomePage> {
             ),
             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<MyHomePage> {
   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<CardTextFieldState>();
+
   @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<MyHomePage> {
                 '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<String, dynamic> 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<String, dynamic>)? 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<CardTextFieldState> _key = GlobalKey<CardTextFieldState>();
+
+  // 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
   State<CardTextField> 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<CardTextField> {
   late TextEditingController _cardNumberController;
   late TextEditingController _expirationController;
@@ -373,7 +398,9 @@ class CardTextFieldState extends State<CardTextField> {
               }
             },
             onHorizontalDragEnd: (details) {
-              if (!_isMobile || isWideFormat || details.primaryVelocity == null) {
+              if (!_isMobile ||
+                  isWideFormat ||
+                  details.primaryVelocity == null) {
                 return;
               }
 
@@ -399,366 +426,417 @@ class CardTextFieldState extends State<CardTextField> {
                     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<CardTextField> {
     );
   }
 
+  // Makes an http call to stripe API with provided card credentials and returns the result
+  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 (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<String, dynamic> jsonBody = jsonDecode(response.body);
+    if (_loading) setState(() => _loading = false);
+    return jsonBody;
+  }
+
   Future<void> _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));