stripe_native_card_field/lib/stripe_native_card_field.dart

1158 lines
49 KiB
Dart
Raw Normal View History

library stripe_native_card_field;
2023-11-14 09:58:49 -07:00
import 'dart:async';
import 'dart:convert';
2023-11-21 09:45:25 -07:00
import 'dart:io';
import 'package:flutter/foundation.dart';
2023-11-14 09:58:49 -07:00
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
2023-12-01 16:12:42 -07:00
import 'package:stripe_native_card_field/card_text_field_error.dart';
import 'card_details.dart';
import 'card_provider_icon.dart';
2023-11-14 09:58:49 -07:00
/// Enum to track each step of the card detail
/// entry process.
2023-11-14 09:58:49 -07:00
enum CardEntryStep { number, exp, cvc, postal }
2023-12-01 16:12:42 -07:00
enum LoadingLocation { above, below }
/// A uniform text field for entering card details, based
/// on the behavior of Stripe's various html elements.
///
/// Required `width`.
///
2023-12-01 16:12:42 -07:00
/// 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.
2023-11-14 09:58:49 -07:00
class CardTextField extends StatefulWidget {
CardTextField({
Key? key,
2023-11-21 09:45:25 -07:00
required this.width,
this.onStripeResponse,
2023-12-01 16:12:42 -07:00
this.onValidCardDetails,
this.stripePublishableKey,
this.height,
this.textStyle,
this.hintTextStyle,
this.errorTextStyle,
this.boxDecoration,
this.errorBoxDecoration,
this.loadingWidget,
2023-12-01 16:12:42 -07:00
this.loadingWidgetLocation = LoadingLocation.below,
2023-11-21 09:45:25 -07:00
this.showInternalLoadingWidget = true,
2023-12-01 16:12:42 -07:00
this.delayToShowLoading = const Duration(milliseconds: 0),
2023-11-21 09:45:25 -07:00
this.onCallToStripe,
this.overrideValidState,
this.errorText,
2023-11-21 09:45:25 -07:00
this.cardFieldWidth,
this.expFieldWidth,
this.securityFieldWidth,
this.postalFieldWidth,
this.iconSize,
2023-11-21 13:38:02 -07:00
this.cardIconColor,
this.cardIconErrorColor,
}) : super(key: key) {
2023-12-01 16:12:42 -07:00
// Setup logic for the CardTextField
// Will assert in debug mode, otherwise will throw `CardTextFieldError` in profile or release
if (stripePublishableKey != null) {
2023-12-01 16:12:42 -07:00
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);
}
}
}
}
/// Width of the entire CardTextField
2023-11-14 09:58:49 -07:00
final double width;
2023-11-14 17:34:11 -07:00
2023-11-21 09:45:25 -07:00
/// Height of the entire CardTextField, defaults to 60.0
2023-11-14 09:58:49 -07:00
final double? height;
2023-11-21 09:45:25 -07:00
/// Width of card number field, only override if changing the default `textStyle.fontSize`, defaults to 180.0
final double? cardFieldWidth;
/// Width of expiration date field, only override if changing the default `textStyle.fontSize`, defaults to 70.0
final double? expFieldWidth;
/// Width of security number field, only override if changing the default `textStyle.fontSize`, defaults to 40.0
final double? securityFieldWidth;
/// Width of postal code field, only override if changing the default `textStyle.fontSize`, defaults to 95.0
final double? postalFieldWidth;
/// Overrides the default box decoration of the text field
final BoxDecoration? boxDecoration;
/// Overrides the default box decoration of the text field when there is a validation error
final BoxDecoration? errorBoxDecoration;
2023-12-01 16:12:42 -07:00
/// 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;
2023-11-21 09:45:25 -07:00
/// Overrides default icon size of the card provider, defaults to `Size(30.0, 20.0)`
final Size? iconSize;
2023-11-21 13:38:02 -07:00
/// CSS string name of color or hex code for the card SVG icon to render
final String? cardIconColor;
/// CSS string name of color or hex code for the error card SVG icon to render
final String? cardIconErrorColor;
2023-11-21 09:45:25 -07:00
/// Determines where the loading indicator appears when contacting stripe
2023-12-01 16:12:42 -07:00
final LoadingLocation loadingWidgetLocation;
2023-11-21 09:45:25 -07:00
/// Default TextStyle
final TextStyle? textStyle;
2023-11-21 09:45:25 -07:00
/// Default TextStyle for the hint text in each TextFormField.
/// If null, inherits from the `textStyle`.
final TextStyle? hintTextStyle;
/// TextStyle used when any TextFormField's have a validation error
2023-11-21 09:45:25 -07:00
/// If null, inherits from the `textStyle`.
final TextStyle? errorTextStyle;
2023-12-01 16:12:42 -07:00
/// Time to wait until showing the loading indicator when retrieving Stripe token, defaults to 0 milliseconds.
final Duration delayToShowLoading;
2023-11-21 09:45:25 -07:00
/// Whether to show the internal loading widget on calls to Stripe
final bool showInternalLoadingWidget;
/// Stripe publishable key, starts with 'pk_'
final String? stripePublishableKey;
/// Callback when the http request is made to Stripe
final void Function()? onCallToStripe;
/// Callback that returns the stripe token for the card
final void Function(Map<String, dynamic>)? onStripeResponse;
/// Callback that returns the completed CardDetails object
2023-12-01 16:12:42 -07:00
final void Function(CardDetails)? onValidCardDetails;
/// Can manually override the ValidState to surface errors returned from Stripe
2023-11-21 09:45:25 -07:00
final CardDetailsValidState? overrideValidState;
/// Can manually override the errorText displayed to surface errors returned from Stripe
final String? errorText;
2023-12-01 16:12:42 -07:00
/// 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();
// }
2023-11-14 09:58:49 -07:00
@override
State<CardTextField> createState() => CardTextFieldState();
2023-12-01 16:12:42 -07:00
// {
// _state = CardTextFieldState();
// return _state;
// }
2023-11-14 09:58:49 -07:00
}
/// State Widget for CardTextField
2023-12-01 16:12:42 -07:00
/// Should not be used directly, except to
/// create a GlobalKey for directly accessing
/// the `getStripeResponse` function
class CardTextFieldState extends State<CardTextField> {
2023-11-14 09:58:49 -07:00
late TextEditingController _cardNumberController;
late TextEditingController _expirationController;
late TextEditingController _securityCodeController;
late TextEditingController _postalCodeController;
// Not made private for access in widget tests
late FocusNode cardNumberFocusNode;
late FocusNode expirationFocusNode;
late FocusNode securityCodeFocusNode;
late FocusNode postalCodeFocusNode;
2023-11-14 09:58:49 -07:00
// Not made private for access in widget tests
late final bool isWideFormat;
// Widget configurable styles
late final BoxDecoration _normalBoxDecoration;
late final BoxDecoration _errorBoxDecoration;
late final TextStyle _errorTextStyle;
late final TextStyle _normalTextStyle;
late final TextStyle _hintTextSyle;
2023-11-21 09:45:25 -07:00
/// Width of the card number text field
late final double _cardFieldWidth;
/// Width of the expiration text field
late final double _expirationFieldWidth;
/// Width of the security code text field
late final double _securityFieldWidth;
/// Width of the postal code text field
late final double _postalFieldWidth;
/// Width of the internal scrollable field, is potentially larger than the provided `widget.width`
2023-11-14 09:58:49 -07:00
late final double _internalFieldWidth;
2023-11-21 09:45:25 -07:00
/// Width of the gap between card number and expiration text fields when expanded
late final double _expanderWidthExpanded;
2023-11-21 09:45:25 -07:00
/// Width of the gap between card number and expiration text fields when collapsed
late final double _expanderWidthCollapsed;
2023-11-14 09:58:49 -07:00
String? _validationErrorText;
bool _showBorderError = false;
final _isMobile = kIsWeb ? false : Platform.isAndroid || Platform.isIOS;
2023-11-21 09:45:25 -07:00
/// If a request to Stripe is being made
bool _loading = false;
final CardDetails _cardDetails = CardDetails.blank();
int _prevErrorOverrideHash = 0;
2023-11-14 09:58:49 -07:00
final _currentCardEntryStepController = StreamController<CardEntryStep>();
final _horizontalScrollController = ScrollController();
CardEntryStep _currentStep = CardEntryStep.number;
final _formFieldKey = GlobalKey<FormState>();
@override
void initState() {
2023-11-21 09:45:25 -07:00
_cardFieldWidth = widget.cardFieldWidth ?? 180.0;
_expirationFieldWidth = widget.expFieldWidth ?? 70.0;
_securityFieldWidth = widget.securityFieldWidth ?? 40.0;
_postalFieldWidth = widget.postalFieldWidth ?? 95.0;
// No way to get backspace events on soft keyboards, so add invisible character to detect delete
2023-11-14 09:58:49 -07:00
_cardNumberController = TextEditingController();
_expirationController =
TextEditingController(text: _isMobile ? '\u200b' : '');
_securityCodeController =
TextEditingController(text: _isMobile ? '\u200b' : '');
_postalCodeController =
TextEditingController(text: _isMobile ? '\u200b' : '');
2023-11-21 09:45:25 -07:00
// Otherwise, use `RawKeyboard` listener
if (!_isMobile) {
RawKeyboard.instance.addListener(_backspaceTransitionListener);
}
2023-11-14 09:58:49 -07:00
cardNumberFocusNode = FocusNode();
expirationFocusNode = FocusNode();
securityCodeFocusNode = FocusNode();
postalCodeFocusNode = FocusNode();
2023-11-14 09:58:49 -07:00
_errorTextStyle =
const TextStyle(color: Colors.red, fontSize: 14, inherit: true)
.merge(widget.errorTextStyle ?? widget.textStyle);
_normalTextStyle =
const TextStyle(color: Colors.black87, fontSize: 14, inherit: true)
.merge(widget.textStyle);
_hintTextSyle =
const TextStyle(color: Colors.black54, fontSize: 14, inherit: true)
.merge(widget.hintTextStyle ?? widget.textStyle);
_normalBoxDecoration = BoxDecoration(
color: const Color(0xfff6f9fc),
border: Border.all(
color: const Color(0xffdde0e3),
width: 2.0,
),
borderRadius: BorderRadius.circular(8.0),
).copyWith(
backgroundBlendMode: widget.boxDecoration?.backgroundBlendMode,
border: widget.boxDecoration?.border,
borderRadius: widget.boxDecoration?.borderRadius,
boxShadow: widget.boxDecoration?.boxShadow,
color: widget.boxDecoration?.color,
gradient: widget.boxDecoration?.gradient,
image: widget.boxDecoration?.image,
shape: widget.boxDecoration?.shape,
);
_errorBoxDecoration = BoxDecoration(
color: const Color(0xfff6f9fc),
border: Border.all(
color: Colors.red,
width: 2.0,
),
borderRadius: BorderRadius.circular(8.0),
).copyWith(
backgroundBlendMode: widget.errorBoxDecoration?.backgroundBlendMode,
border: widget.errorBoxDecoration?.border,
borderRadius: widget.errorBoxDecoration?.borderRadius,
boxShadow: widget.errorBoxDecoration?.boxShadow,
color: widget.errorBoxDecoration?.color,
gradient: widget.errorBoxDecoration?.gradient,
image: widget.errorBoxDecoration?.image,
shape: widget.errorBoxDecoration?.shape,
);
2023-11-14 09:58:49 -07:00
_currentCardEntryStepController.stream.listen(
_onStepChange,
);
2023-11-21 09:45:25 -07:00
isWideFormat = widget.width >=
_cardFieldWidth +
_expirationFieldWidth +
_securityFieldWidth +
_postalFieldWidth +
60.0;
if (isWideFormat) {
_internalFieldWidth = widget.width + _postalFieldWidth + 35;
_expanderWidthExpanded = widget.width -
_cardFieldWidth -
_expirationFieldWidth -
_securityFieldWidth -
35;
_expanderWidthCollapsed = widget.width -
_cardFieldWidth -
_expirationFieldWidth -
_securityFieldWidth -
_postalFieldWidth -
70;
2023-11-14 09:58:49 -07:00
} else {
_internalFieldWidth = _cardFieldWidth +
_expirationFieldWidth +
_securityFieldWidth +
_postalFieldWidth +
80;
2023-11-14 09:58:49 -07:00
}
2023-11-14 09:58:49 -07:00
super.initState();
}
@override
void dispose() {
_cardNumberController.dispose();
_expirationController.dispose();
_securityCodeController.dispose();
cardNumberFocusNode.dispose();
expirationFocusNode.dispose();
securityCodeFocusNode.dispose();
2023-11-14 09:58:49 -07:00
2023-11-21 09:45:25 -07:00
if (!_isMobile) {
RawKeyboard.instance.removeListener(_backspaceTransitionListener);
}
2023-11-14 09:58:49 -07:00
super.dispose();
}
@override
Widget build(BuildContext context) {
if ((widget.errorText != null || widget.overrideValidState != null) &&
Object.hashAll([widget.errorText, widget.overrideValidState]) !=
_prevErrorOverrideHash) {
_prevErrorOverrideHash =
Object.hashAll([widget.errorText, widget.overrideValidState]);
_validateFields();
}
2023-11-14 09:58:49 -07:00
return Column(
mainAxisAlignment: MainAxisAlignment.center,
2023-11-14 09:58:49 -07:00
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Form(
key: _formFieldKey,
child: GestureDetector(
onTap: () {
// Focuses to the current field
_currentCardEntryStepController.add(_currentStep);
},
2023-11-21 09:45:25 -07:00
// Enable scrolling on mobile and if its narrow (not all fields visible)
onHorizontalDragUpdate: (details) {
const minOffset = 0.0;
final maxOffset =
_horizontalScrollController.position.maxScrollExtent;
2023-11-21 09:45:25 -07:00
if (!_isMobile || isWideFormat) return;
final newOffset =
_horizontalScrollController.offset - details.delta.dx;
2023-11-21 09:45:25 -07:00
if (newOffset < minOffset) {
_horizontalScrollController.jumpTo(minOffset);
} else if (newOffset > maxOffset) {
_horizontalScrollController.jumpTo(maxOffset);
2023-11-21 09:45:25 -07:00
} else {
_horizontalScrollController.jumpTo(newOffset);
}
},
onHorizontalDragEnd: (details) {
2023-12-01 16:12:42 -07:00
if (!_isMobile ||
isWideFormat ||
details.primaryVelocity == null) {
return;
}
2023-11-21 09:45:25 -07:00
const dur = Duration(milliseconds: 300);
const cur = Curves.ease;
// final max = _horizontalScrollController.position.maxScrollExtent;
final newOffset = _horizontalScrollController.offset -
details.primaryVelocity! * 0.15;
_horizontalScrollController.animateTo(newOffset,
curve: cur, duration: dur);
2023-11-21 09:45:25 -07:00
},
2023-11-14 09:58:49 -07:00
child: Container(
width: widget.width,
height: widget.height ?? 60.0,
decoration:
_showBorderError ? _errorBoxDecoration : _normalBoxDecoration,
2023-11-14 09:58:49 -07:00
child: ClipRect(
child: IgnorePointer(
child: SingleChildScrollView(
controller: _horizontalScrollController,
scrollDirection: Axis.horizontal,
child: SizedBox(
width: _internalFieldWidth,
height: widget.height ?? 60.0,
2023-12-01 16:12:42 -07:00
child: Column(
2023-11-14 09:58:49 -07:00
children: [
2023-12-01 16:12:42 -07:00
if (widget.loadingWidgetLocation ==
LoadingLocation.above)
AnimatedOpacity(
duration: const Duration(milliseconds: 300),
opacity:
_loading && widget.showInternalLoadingWidget
? 1.0
: 0.0,
child: widget.loadingWidget ??
const LinearProgressIndicator(),
2023-11-14 09:58:49 -07:00
),
2023-12-01 16:12:42 -07:00
Padding(
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,
2023-11-21 09:45:25 -07:00
children: [
2023-12-01 16:12:42 -07:00
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.');
}
2023-11-21 09:45:25 -07:00
return null;
2023-12-01 16:12:42 -07:00
},
onChanged: (str) {
final numbers = str.replaceAll(' ', '');
setState(() =>
2023-12-01 16:12:42 -07:00
_cardDetails.cardNumber = numbers);
if (str.length <=
_cardDetails.maxINNLength) {
_cardDetails.detectCardProvider();
2023-11-21 09:45:25 -07:00
}
2023-12-01 16:12:42 -07:00
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,
),
2023-11-21 09:45:25 -07:00
),
),
2023-12-01 16:12:42 -07:00
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),
),
),
2023-11-21 09:45:25 -07:00
),
2023-12-01 16:12:42 -07:00
// 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,
),
),
],
2023-11-21 09:45:25 -07:00
),
),
2023-12-01 16:12:42 -07:00
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,
),
),
],
2023-11-21 09:45:25 -07:00
),
2023-12-01 16:12:42 -07:00
),
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,
),
),
],
2023-11-21 09:45:25 -07:00
),
),
],
2023-11-14 09:58:49 -07:00
),
),
2023-12-01 16:12:42 -07:00
if (widget.loadingWidgetLocation ==
LoadingLocation.below)
AnimatedOpacity(
duration: const Duration(milliseconds: 300),
opacity:
_loading && widget.showInternalLoadingWidget
? 1.0
: 0.0,
child: widget.loadingWidget ??
const LinearProgressIndicator(),
),
2023-11-14 09:58:49 -07:00
],
),
),
),
),
),
),
),
),
AnimatedOpacity(
duration: const Duration(milliseconds: 525),
opacity: _validationErrorText == null ? 0.0 : 1.0,
child: Padding(
padding: const EdgeInsets.only(top: 8.0, left: 14.0),
child: Text(
2023-11-21 09:45:25 -07:00
// Spacing changes by like a pixel if its an empty string, slight jitter when error appears and disappears
_validationErrorText ?? ' ',
2023-11-21 13:38:02 -07:00
style: _errorTextStyle,
2023-11-14 09:58:49 -07:00
),
),
),
],
);
}
2023-12-01 16:12:42 -07:00
// 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;
}
2023-11-21 09:45:25 -07:00
Future<void> _postalFieldSubmitted() async {
_validateFields();
if (_cardDetails.isComplete) {
2023-12-01 16:12:42 -07:00
if (widget.onValidCardDetails != null) {
widget.onValidCardDetails!(_cardDetails);
2023-11-21 09:45:25 -07:00
} else if (widget.onStripeResponse != null) {
// Callback that stripe call is being made
if (widget.onCallToStripe != null) widget.onCallToStripe!();
2023-12-01 16:12:42 -07:00
final jsonBody = await getStripeResponse();
if (jsonBody != null) widget.onStripeResponse!(jsonBody);
2023-11-21 09:45:25 -07:00
if (_loading) setState(() => _loading = false);
}
}
}
/// Provided a list of `ValidState`, returns whether
/// make the text field red
2023-11-21 09:45:25 -07:00
bool _isRedText(List<CardDetailsValidState> args) {
2023-11-14 09:58:49 -07:00
return _showBorderError && args.contains(_cardDetails.validState);
}
/// Helper function to change the `_showBorderError` and
/// `_validationErrorText`.
2023-11-14 09:58:49 -07:00
void _setValidationState(String? text) {
setState(() {
_validationErrorText = text;
_showBorderError = text != null;
});
}
/// Calls `validate()` on the form state and resets
/// the validation state
2023-11-14 09:58:49 -07:00
void _validateFields() {
_validationErrorText = null;
if (widget.overrideValidState != null) {
_cardDetails.overrideValidState = widget.overrideValidState!;
_setValidationState(widget.errorText);
} else {
_formFieldKey.currentState!.validate();
// Clear up validation state if everything is valid
if (_validationErrorText == null) {
_setValidationState(null);
}
2023-11-14 09:58:49 -07:00
}
return;
}
/// Used when `_isWideFormat == false`, scrolls
2023-11-14 17:34:11 -07:00
/// the `_horizontalScrollController` to a given offset
2023-11-14 09:58:49 -07:00
void _scrollRow(CardEntryStep step) {
const dur = Duration(milliseconds: 150);
const cur = Curves.easeOut;
2023-11-14 09:58:49 -07:00
switch (step) {
case CardEntryStep.number:
_horizontalScrollController.animateTo(0.0, duration: dur, curve: cur);
break;
case CardEntryStep.exp:
_horizontalScrollController.animateTo(_cardFieldWidth / 2,
duration: dur, curve: cur);
2023-11-14 09:58:49 -07:00
break;
case CardEntryStep.cvc:
_horizontalScrollController.animateTo(
_cardFieldWidth / 2 + _expirationFieldWidth,
duration: dur,
curve: cur);
2023-11-14 09:58:49 -07:00
break;
case CardEntryStep.postal:
_horizontalScrollController.animateTo(
_cardFieldWidth / 2 + _expirationFieldWidth + _securityFieldWidth,
duration: dur,
curve: cur);
2023-11-14 09:58:49 -07:00
break;
}
}
/// Function that is listening to the `_currentCardEntryStepController`
/// StreamController. Manages validation and tracking of the current step
/// as well as scrolling the text fields.
2023-11-14 09:58:49 -07:00
void _onStepChange(CardEntryStep step) {
if (_currentStep.index < step.index) {
_validateFields();
} else if (_currentStep != step) {
_setValidationState(null);
}
// If field tapped, and has focus, dismiss focus
if (_currentStep == step && _hasFocus()) {
FocusManager.instance.primaryFocus?.unfocus();
return;
}
2023-11-14 09:58:49 -07:00
setState(() {
_currentStep = step;
});
switch (step) {
case CardEntryStep.number:
cardNumberFocusNode.requestFocus();
2023-11-14 09:58:49 -07:00
break;
case CardEntryStep.exp:
expirationFocusNode.requestFocus();
2023-11-14 09:58:49 -07:00
break;
case CardEntryStep.cvc:
securityCodeFocusNode.requestFocus();
2023-11-14 09:58:49 -07:00
break;
case CardEntryStep.postal:
postalCodeFocusNode.requestFocus();
2023-11-14 09:58:49 -07:00
break;
}
if (!isWideFormat) {
2023-11-14 09:58:49 -07:00
_scrollRow(step);
}
2023-11-21 09:45:25 -07:00
// If mobile, and keyboard is closed, unfocus, to allow refocus
// print(MediaQuery.of(context).viewInsets.bottom);
// if (_isMobile && _hasFocus() && MediaQuery.of(context).viewInsets.bottom == 0.0) {
// cardNumberFocusNode.unfocus();
// expirationFocusNode.unfocus();
// securityCodeFocusNode.unfocus();
// postalCodeFocusNode.unfocus();
// }
}
/// Returns true if any field in the `CardTextField` has focus.
// ignore: unused_element
bool _hasFocus() {
return cardNumberFocusNode.hasFocus ||
expirationFocusNode.hasFocus ||
securityCodeFocusNode.hasFocus ||
postalCodeFocusNode.hasFocus;
2023-11-14 09:58:49 -07:00
}
/// Function that is listening to the keyboard events.
///
/// This provides the functionality of hitting backspace
/// and the focus changing between fields when the current
/// entry step is empty.
2023-11-14 09:58:49 -07:00
void _backspaceTransitionListener(RawKeyEvent value) {
if (!value.isKeyPressed(LogicalKeyboardKey.backspace)) {
return;
}
switch (_currentStep) {
case CardEntryStep.number:
break;
case CardEntryStep.exp:
if (_expirationController.text.isNotEmpty) return;
2023-11-21 09:45:25 -07:00
case CardEntryStep.cvc:
if (_securityCodeController.text.isNotEmpty) return;
2023-11-21 09:45:25 -07:00
case CardEntryStep.postal:
if (_postalCodeController.text.isNotEmpty) return;
2023-11-21 09:45:25 -07:00
}
_transitionStepFocus();
}
void _backspacePressed() {
// Put the empty char back into the controller
switch (_currentStep) {
case CardEntryStep.number:
break;
case CardEntryStep.exp:
_expirationController.text = '\u200b';
case CardEntryStep.cvc:
_securityCodeController.text = '\u200b';
case CardEntryStep.postal:
_postalCodeController.text = '\u200b';
}
_transitionStepFocus();
}
void _transitionStepFocus() {
switch (_currentStep) {
case CardEntryStep.number:
break;
case CardEntryStep.exp:
2023-11-14 09:58:49 -07:00
_currentCardEntryStepController.add(CardEntryStep.number);
String numStr = _cardNumberController.text;
_cardNumberController.text = numStr.substring(0, numStr.length - 1);
break;
case CardEntryStep.cvc:
_currentCardEntryStepController.add(CardEntryStep.exp);
final expStr = _expirationController.text;
_expirationController.text = expStr.substring(0, expStr.length - 1);
2023-11-21 09:45:25 -07:00
break;
2023-11-14 09:58:49 -07:00
case CardEntryStep.postal:
_currentCardEntryStepController.add(CardEntryStep.cvc);
final String cvcStr = _securityCodeController.text;
_securityCodeController.text = cvcStr.substring(0, cvcStr.length - 1);
2023-11-21 09:45:25 -07:00
break;
2023-11-14 09:58:49 -07:00
}
}
}
/// Formatter that adds the appropriate space ' ' characters
/// to make the card number display cleanly.
2023-11-14 09:58:49 -07:00
class CardNumberInputFormatter implements TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue, TextEditingValue newValue) {
2023-11-14 09:58:49 -07:00
String cardNum = newValue.text;
if (cardNum.length <= 4) return newValue;
cardNum = cardNum.replaceAll(' ', '');
StringBuffer buffer = StringBuffer();
for (int i = 0; i < cardNum.length; i++) {
buffer.write(cardNum[i]);
int nonZeroIndex = i + 1;
if (nonZeroIndex % 4 == 0 && nonZeroIndex != cardNum.length) {
buffer.write(' ');
}
}
return newValue.copyWith(
text: buffer.toString(),
selection: TextSelection.collapsed(offset: buffer.length));
2023-11-14 09:58:49 -07:00
}
}
/// Formatter that adds a backslash '/' character in between
2023-11-14 17:34:11 -07:00
/// the month and the year for the expiration date.
2023-11-14 09:58:49 -07:00
class CardExpirationFormatter implements TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue, TextEditingValue newValue) {
2023-11-14 09:58:49 -07:00
String cardExp = newValue.text;
if (cardExp.length == 1) {
if (cardExp[0] == '0' || cardExp[0] == '1') {
return newValue;
} else {
cardExp = '0$cardExp';
}
}
if (cardExp.length == 2 && oldValue.text.length == 3) return newValue;
cardExp = cardExp.replaceAll('/', '');
StringBuffer buffer = StringBuffer();
for (int i = 0; i < cardExp.length; i++) {
buffer.write(cardExp[i]);
int nonZeroIndex = i + 1;
if (nonZeroIndex == 2) {
buffer.write('/');
}
}
return newValue.copyWith(
text: buffer.toString(),
selection: TextSelection.collapsed(offset: buffer.length));
2023-11-14 09:58:49 -07:00
}
}