Compare commits
2 Commits
f9e758fda5
...
1668ab8947
Author | SHA1 | Date | |
---|---|---|---|
|
1668ab8947 | ||
|
f23805a3a8 |
2
LICENSE
2
LICENSE
|
@ -1,3 +1,5 @@
|
|||
Copyright 2023 Nathan Anderson
|
||||
|
||||
BSD 3-Clause License
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
|
|
@ -68,7 +68,7 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
const Text(
|
||||
'Enter your card details below:',
|
||||
),
|
||||
CardTextField(
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Class encapsulating the card's data
|
||||
/// as well as validation of the data.
|
||||
///
|
||||
/// `CardDetails.validState == ValidState.ok`
|
||||
/// when fields are filled and validated as correct.
|
||||
class CardDetails {
|
||||
CardDetails({
|
||||
required dynamic cardNumber,
|
||||
|
@ -11,10 +16,13 @@ class CardDetails {
|
|||
checkIsValid();
|
||||
}
|
||||
|
||||
/// Sets every field to null, a default
|
||||
/// `CardDetails` when nothing has been entered.
|
||||
factory CardDetails.blank() {
|
||||
return CardDetails(cardNumber: null, securityCode: null, expirationString: null, postalCode: null);
|
||||
}
|
||||
|
||||
/// Returns the CardNumber as a `String` with the spaces removed.
|
||||
String? get cardNumber => _cardNumber?.replaceAll(' ', '');
|
||||
|
||||
set cardNumber(String? num) => _cardNumber = num;
|
||||
|
@ -29,22 +37,35 @@ class CardDetails {
|
|||
int _lastCheckHash = 0;
|
||||
CardProvider? provider;
|
||||
|
||||
/// Checks the validity of the `CardDetails` and returns the result.
|
||||
ValidState get validState {
|
||||
checkIsValid();
|
||||
return _validState;
|
||||
}
|
||||
|
||||
// TODO rename to be more clear
|
||||
/// Returns true if `_cardNumber` is null, or
|
||||
/// if the _cardNumber matches the detected `provider`'s
|
||||
/// card lenght, defaulting to 16.
|
||||
bool get cardNumberFilled =>
|
||||
_cardNumber == null ? false : (provider?.cardLength ?? 16) == _cardNumber!.replaceAll(' ', '').length;
|
||||
|
||||
/// Returns true if all details are complete and valid
|
||||
/// otherwise, return false.
|
||||
bool get isComplete {
|
||||
checkIsValid();
|
||||
return _complete;
|
||||
}
|
||||
|
||||
int get minInnLength => 1;
|
||||
/// The maximum length of the INN (identifier)
|
||||
/// of a card provider.
|
||||
int get maxINNLength => 4;
|
||||
|
||||
/// Validates each field of the `CardDetails` object in entry order,
|
||||
/// namely _cardNumber -> expirationString -> securityCode -> postalCode
|
||||
///
|
||||
/// If all fields are filled out and valid, `CardDetails.isComplete == true`
|
||||
/// and `CardDetails.validState == ValidState.ok`.
|
||||
void checkIsValid() {
|
||||
try {
|
||||
int currentHash = hash;
|
||||
|
@ -65,7 +86,7 @@ class CardDetails {
|
|||
(i) => int.parse(i),
|
||||
)
|
||||
.toList();
|
||||
if (!luhnAlgorithmCheck(nums)) {
|
||||
if (!_luhnAlgorithmCheck(nums)) {
|
||||
_complete = false;
|
||||
_validState = ValidState.invalidCard;
|
||||
return;
|
||||
|
@ -130,16 +151,21 @@ class CardDetails {
|
|||
}
|
||||
}
|
||||
|
||||
/// Provides a hash of the CardDetails object
|
||||
/// Hashes `_cardNumber`, `expirationString`,
|
||||
/// `securityCode`, and `postalCode`.
|
||||
int get hash {
|
||||
return Object.hash(_cardNumber, expirationString, securityCode, postalCode);
|
||||
}
|
||||
|
||||
/// Iterates over the list `_providers`, detecting which
|
||||
/// provider the current `_cardNumber` falls under.
|
||||
void detectCardProvider() {
|
||||
bool found = false;
|
||||
if (_cardNumber == null) {
|
||||
return;
|
||||
}
|
||||
for (var cardPvd in providers) {
|
||||
for (var cardPvd in _providers) {
|
||||
if (cardPvd.innValidNums != null) {
|
||||
// trim card number to correct length
|
||||
String trimmedNum = _cardNumber!;
|
||||
|
@ -180,8 +206,36 @@ class CardDetails {
|
|||
String toString() {
|
||||
return 'Number: "$_cardNumber" - Exp: "$expirationString" CVC: $securityCode Zip: "$postalCode"';
|
||||
}
|
||||
|
||||
/// https://en.wikipedia.org/wiki/Luhn_algorithm
|
||||
/// The Luhn algorithm is used in industry to check
|
||||
/// for valid credit / debit card numbers
|
||||
///
|
||||
/// The algorithm adds together all the numbers, every
|
||||
/// other number is doubled, then the sum is checked to
|
||||
/// see if it is a multiple of 10.
|
||||
/// https://en.wikipedia.org/wiki/Luhn_algorithm
|
||||
bool _luhnAlgorithmCheck(List<int> digits) {
|
||||
int sum = 0;
|
||||
bool isSecond = false;
|
||||
for (int i = digits.length - 1; i >= 0; i--) {
|
||||
int d = digits[i];
|
||||
if (isSecond) {
|
||||
d *= 2;
|
||||
|
||||
if (d > 9) {
|
||||
d -= 9;
|
||||
}
|
||||
}
|
||||
|
||||
sum += d;
|
||||
isSecond = !isSecond;
|
||||
}
|
||||
return (sum % 10) == 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Enum of validation states a `CardDetails` object can have.
|
||||
enum ValidState {
|
||||
ok,
|
||||
error,
|
||||
|
@ -198,6 +252,7 @@ enum ValidState {
|
|||
invalidZip,
|
||||
}
|
||||
|
||||
/// Enum of supported U.S. Card Providers
|
||||
enum CardProviderID {
|
||||
americanExpress,
|
||||
dinersClub,
|
||||
|
@ -207,6 +262,9 @@ enum CardProviderID {
|
|||
visa,
|
||||
}
|
||||
|
||||
/// Encapsulates criteria for Card Providers in the U.S.
|
||||
/// Used by `CardDetails.detectCardProvider()` to determine
|
||||
/// a card's Provider.
|
||||
class CardProvider {
|
||||
CardProviderID id;
|
||||
List<int>? innValidNums;
|
||||
|
@ -228,6 +286,9 @@ class CardProvider {
|
|||
}
|
||||
}
|
||||
|
||||
/// Object for `CardProvider` to determine valid number ranges.
|
||||
/// A loose wrapper on a tuple, that provides assertion of
|
||||
/// valid inputs and the `isWithin()` helper function.
|
||||
class Range {
|
||||
int high;
|
||||
int low;
|
||||
|
@ -236,12 +297,19 @@ class Range {
|
|||
assert(low <= high);
|
||||
}
|
||||
|
||||
/// Returns bool whether or not `val` is between `low` and `high`.
|
||||
/// The range includes the `val`, so
|
||||
/// ```dart
|
||||
/// Range(low: 1, high: 3).isWithin(3);
|
||||
/// ```
|
||||
/// would return true.
|
||||
bool isWithin(int val) {
|
||||
return low <= val && val <= high;
|
||||
}
|
||||
}
|
||||
|
||||
List<CardProvider> providers = [
|
||||
/// List of CardProviders for US-based Credit / Debit Cards.
|
||||
List<CardProvider> _providers = [
|
||||
CardProvider(
|
||||
id: CardProviderID.americanExpress,
|
||||
cardLength: 15,
|
||||
|
@ -280,29 +348,3 @@ List<CardProvider> providers = [
|
|||
innValidNums: [4],
|
||||
)
|
||||
];
|
||||
|
||||
// https://en.wikipedia.org/wiki/Luhn_algorithm
|
||||
// The Luhn algorithm is used in industry to check
|
||||
// for valid credit / debit card numbers
|
||||
//
|
||||
// The algorithm adds together all the numbers, every
|
||||
// other number is doubled, then the sum is checked to
|
||||
// see if it is a multiple of 10.
|
||||
bool luhnAlgorithmCheck(List<int> digits) {
|
||||
int sum = 0;
|
||||
bool isSecond = false;
|
||||
for (int i = digits.length - 1; i >= 0; i--) {
|
||||
int d = digits[i];
|
||||
if (isSecond) {
|
||||
d *= 2;
|
||||
|
||||
if (d > 9) {
|
||||
d -= 9;
|
||||
}
|
||||
}
|
||||
|
||||
sum += d;
|
||||
isSecond = !isSecond;
|
||||
}
|
||||
return (sum % 10) == 0;
|
||||
}
|
||||
|
|
|
@ -2,6 +2,10 @@ import 'card_details.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
/// Widget that provides the various supported card provider's
|
||||
/// icons, as well as a default and error card icon.
|
||||
///
|
||||
/// To see a list of supported card providers, see `CardDetails.provider`.
|
||||
class CardProviderIcon extends StatefulWidget {
|
||||
const CardProviderIcon({required this.cardDetails, super.key});
|
||||
|
||||
|
@ -77,6 +81,7 @@ class _CardProviderIconState extends State<CardProviderIcon> {
|
|||
);
|
||||
}
|
||||
|
||||
/// Helper function to create the SVG icons provided a `CardProviderID`.
|
||||
Widget createCardSvg(CardProviderID id) {
|
||||
return SvgPicture.string(
|
||||
key: Key('${id.name}-card'),
|
||||
|
|
|
@ -6,8 +6,18 @@ import 'card_provider_icon.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Enum to track each step of the card detail
|
||||
/// entry process.
|
||||
enum CardEntryStep { number, exp, cvc, postal }
|
||||
|
||||
/// A uniform text field for entering card details, based
|
||||
/// on the behavior of Stripe's various html elements.
|
||||
///
|
||||
/// Required `width` and `onCardDetailsComplete`.
|
||||
///
|
||||
/// If the provided `width < 450.0`, the `CardTextField`
|
||||
/// will scroll its content horizontally with the cursor
|
||||
/// to compensate.
|
||||
class CardTextField extends StatefulWidget {
|
||||
const CardTextField(
|
||||
{Key? key,
|
||||
|
@ -23,6 +33,8 @@ class CardTextField extends StatefulWidget {
|
|||
final BoxDecoration? boxDecoration; // TODO unapplied style
|
||||
final BoxDecoration? errorBoxDecoration; // TODO unapplied style
|
||||
final double width;
|
||||
|
||||
/// Callback that returns the completed CardDetails object
|
||||
final void Function(CardDetails) onCardDetailsComplete;
|
||||
final double? height;
|
||||
|
||||
|
@ -30,6 +42,9 @@ class CardTextField extends StatefulWidget {
|
|||
State<CardTextField> createState() => CardTextFieldState();
|
||||
}
|
||||
|
||||
/// State Widget for CardTextField
|
||||
/// Should not be used directly, create a
|
||||
/// `CardTextField()` instead.
|
||||
@visibleForTesting
|
||||
class CardTextFieldState extends State<CardTextField> {
|
||||
late TextEditingController _cardNumberController;
|
||||
|
@ -361,10 +376,14 @@ class CardTextFieldState extends State<CardTextField> {
|
|||
);
|
||||
}
|
||||
|
||||
/// Provided a list of `ValidState`, returns whether
|
||||
/// make the text field red
|
||||
bool _isRedText(List<ValidState> args) {
|
||||
return _showBorderError && args.contains(_cardDetails.validState);
|
||||
}
|
||||
|
||||
/// Helper function to change the `_showBorderError` and
|
||||
/// `_validationErrorText`.
|
||||
void _setValidationState(String? text) {
|
||||
setState(() {
|
||||
_validationErrorText = text;
|
||||
|
@ -372,6 +391,8 @@ class CardTextFieldState extends State<CardTextField> {
|
|||
});
|
||||
}
|
||||
|
||||
/// Calls `validate()` on the form state and resets
|
||||
/// the validation state
|
||||
void _validateFields() {
|
||||
_validationErrorText = null;
|
||||
_formFieldKey.currentState!.validate();
|
||||
|
@ -382,6 +403,8 @@ class CardTextFieldState extends State<CardTextField> {
|
|||
return;
|
||||
}
|
||||
|
||||
/// Used when `_isWideFormat == false`, scrolls
|
||||
/// the `_horizontalScrollController` to a given offset
|
||||
void _scrollRow(CardEntryStep step) {
|
||||
const dur = Duration(milliseconds: 150);
|
||||
const cur = Curves.easeOut;
|
||||
|
@ -402,6 +425,9 @@ class CardTextFieldState extends State<CardTextField> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Function that is listening to the `_currentCardEntryStepController`
|
||||
/// StreamController. Manages validation and tracking of the current step
|
||||
/// as well as scrolling the text fields.
|
||||
void _onStepChange(CardEntryStep step) {
|
||||
if (_currentStep.index < step.index) {
|
||||
_validateFields();
|
||||
|
@ -431,6 +457,11 @@ class CardTextFieldState extends State<CardTextField> {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
void _backspaceTransitionListener(RawKeyEvent value) {
|
||||
if (!value.isKeyPressed(LogicalKeyboardKey.backspace)) {
|
||||
return;
|
||||
|
@ -461,6 +492,8 @@ class CardTextFieldState extends State<CardTextField> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Formatter that adds the appropriate space ' ' characters
|
||||
/// to make the card number display cleanly.
|
||||
class CardNumberInputFormatter implements TextInputFormatter {
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
|
||||
|
@ -482,6 +515,8 @@ class CardNumberInputFormatter implements TextInputFormatter {
|
|||
}
|
||||
}
|
||||
|
||||
/// Formatter that adds a backslash '/' character in between
|
||||
/// the month and the year for the expiration date.
|
||||
class CardExpirationFormatter implements TextInputFormatter {
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
|
||||
|
@ -494,11 +529,6 @@ class CardExpirationFormatter implements TextInputFormatter {
|
|||
}
|
||||
}
|
||||
if (cardExp.length == 2 && oldValue.text.length == 3) return newValue;
|
||||
// Auto delete the slash on backspace
|
||||
// if (cardExp.length == 3 && oldValue.text.length == 4 && cardExp[2] == '/') {
|
||||
// return newValue.copyWith(
|
||||
// text: cardExp.substring(0, 2), selection: TextSelection.collapsed(offset: cardExp.length - 1));
|
||||
// }
|
||||
|
||||
cardExp = cardExp.replaceAll('/', '');
|
||||
StringBuffer buffer = StringBuffer();
|
||||
|
|
Loading…
Reference in New Issue
Block a user