diff --git a/LICENSE b/LICENSE index 0baa90c..d1876df 100644 --- a/LICENSE +++ b/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: diff --git a/lib/card_details.dart b/lib/card_details.dart index 22c54ab..1553c5e 100644 --- a/lib/card_details.dart +++ b/lib/card_details.dart @@ -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,36 @@ 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 +87,7 @@ class CardDetails { (i) => int.parse(i), ) .toList(); - if (!luhnAlgorithmCheck(nums)) { + if (!_luhnAlgorithmCheck(nums)) { _complete = false; _validState = ValidState.invalidCard; return; @@ -130,16 +152,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 +207,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 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 +253,7 @@ enum ValidState { invalidZip, } +/// Enum of supported U.S. Card Providers enum CardProviderID { americanExpress, dinersClub, @@ -207,10 +263,13 @@ 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? innValidNums; - List? innValidRanges; + List<_Range>? innValidRanges; int cardLength; int cvcLength; @@ -228,20 +287,30 @@ class CardProvider { } } -class Range { +/// 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; - Range({required this.low, required this.high}) { + _Range({required this.low, required this.high}) { 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 providers = [ +/// List of CardProviders for US-based Credit / Debit Cards. +List _providers = [ CardProvider( id: CardProviderID.americanExpress, cardLength: 15, @@ -259,7 +328,7 @@ List providers = [ cardLength: 16, cvcLength: 3, innValidNums: [60, 65], - innValidRanges: [Range(low: 644, high: 649)], + innValidRanges: [_Range(low: 644, high: 649)], ), CardProvider( id: CardProviderID.jcb, @@ -271,7 +340,7 @@ List providers = [ id: CardProviderID.mastercard, cardLength: 16, cvcLength: 3, - innValidRanges: [Range(low: 22, high: 27), Range(low: 51, high: 55)], + innValidRanges: [_Range(low: 22, high: 27), _Range(low: 51, high: 55)], ), CardProvider( id: CardProviderID.visa, @@ -280,29 +349,3 @@ List 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 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; -} diff --git a/lib/card_provider_icon.dart b/lib/card_provider_icon.dart index b1edde6..ce10680 100644 --- a/lib/card_provider_icon.dart +++ b/lib/card_provider_icon.dart @@ -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 { ); } + /// Helper function to create the SVG icons provided a `CardProviderID`. Widget createCardSvg(CardProviderID id) { return SvgPicture.string( key: Key('${id.name}-card'), diff --git a/lib/stripe_native_card_field.dart b/lib/stripe_native_card_field.dart index 6979fa9..78befe7 100644 --- a/lib/stripe_native_card_field.dart +++ b/lib/stripe_native_card_field.dart @@ -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, @@ -21,8 +31,9 @@ class CardTextField extends StatefulWidget { final InputDecoration? inputDecoration; // TODO unapplied style final BoxDecoration? boxDecoration; // TODO unapplied style - final BoxDecoration? errorBoxDecoration; // 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 +41,9 @@ class CardTextField extends StatefulWidget { State createState() => CardTextFieldState(); } +/// State Widget for CardTextField +/// Should not be used directly, create a +/// `CardTextField()` instead. @visibleForTesting class CardTextFieldState extends State { late TextEditingController _cardNumberController; @@ -361,10 +375,14 @@ class CardTextFieldState extends State { ); } + /// Provided a list of `ValidState`, returns whether + /// make the text field red bool _isRedText(List args) { return _showBorderError && args.contains(_cardDetails.validState); } + /// Helper function to change the `_showBorderError` and + /// `_validationErrorText`. void _setValidationState(String? text) { setState(() { _validationErrorText = text; @@ -372,6 +390,8 @@ class CardTextFieldState extends State { }); } + /// Calls `validate()` on the form state and resets + /// the validation state void _validateFields() { _validationErrorText = null; _formFieldKey.currentState!.validate(); @@ -382,6 +402,8 @@ class CardTextFieldState extends State { 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 +424,9 @@ class CardTextFieldState extends State { } } + /// 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 +456,11 @@ class CardTextFieldState extends State { } } + /// 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 +491,8 @@ class CardTextFieldState extends State { } } +/// 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 +514,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 +528,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();