Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added assets/brand_amex.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/brand_diners.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/brand_discover.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/brand_jcb.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/brand_maestro.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/brand_mastercard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/brand_unionpay.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/brand_visa.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
56 changes: 56 additions & 0 deletions lib/src/models/card_brand.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
enum CardBrand {
amex('^3[47]', 15, 15, 'assets/brand_amex.png'),
diners('^3(0[0-5]|6)', 14, 14, 'assets/brand_diners.png'),
jcb('^35(2[89]|[3-8])', 16, 16, 'assets/brand_jcb.png'),
visa('^4', 16, 16, 'assets/brand_visa.png'),
mastercard('^5[1-5]', 16, 16, 'assets/brand_mastercard.png'),
maestro('^(5018|5020|5038|6304|6759|676[1-3])', 12, 19,
'assets/brand_maestro.png'),
discover(
'^(6011|622(12[6-9]|1[3-9][0-9]|[2-8][0-9]{2}|9[0-1][0-9]|92[0-5]|64[4-9])|65)',
16,
16,
'assets/brand_discover.png'),
unionpay('^(62|81)', 16, 19, 'assets/brand_unionpay.png');

final String patternStr;
final int minLength;
final int maxLength;
final String logoAssetPath;

const CardBrand(
this.patternStr,
this.minLength,
this.maxLength,
this.logoAssetPath,
);

bool match(String? pan) {
if (pan == null || pan.isEmpty) return false;
// Remove spaces before matching
final sanitizedPan = pan.replaceAll(RegExp(r'\s+'), '');
// Construct regex to match the pattern at start, followed by optional digits to end
final pattern = RegExp(patternStr + r'[0-9]*$');
return pattern.hasMatch(sanitizedPan);
}

bool valid(String pan) {
if (pan.isEmpty) return false;
final sanitizedPan = pan.replaceAll(RegExp(r'\s+'), '');
return match(sanitizedPan) &&
minLength <= sanitizedPan.length &&
sanitizedPan.length <= maxLength;
}

static CardBrand? getActiveBrand(String? pan) {
if (pan == null || pan.isEmpty) return null;
final sanitizedPan = pan.replaceAll(RegExp(r'\s+'), '');
// Enums automatically provide a `.values` list!
for (var brand in CardBrand.values) {
if (brand.match(sanitizedPan)) {
return brand;
}
}
return null;
}
}
77 changes: 50 additions & 27 deletions lib/src/pages/paymentMethods/credit_card_page.dart
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import 'package:fl_country_code_picker/fl_country_code_picker.dart';
import 'package:flutter/material.dart';
import 'package:omise_dart/omise_dart.dart';
import 'package:omise_dart/omise_dart.dart' hide CardBrand;
import 'package:omise_flutter/src/controllers/credit_card_controller.dart';
import 'package:omise_flutter/src/enums/enums.dart';
import 'package:omise_flutter/src/models/omise_payment_result.dart';
import 'package:omise_flutter/src/services/method_channel_service.dart';
import 'package:omise_flutter/src/services/omise_api_service.dart';
import 'package:omise_flutter/src/translations/translations.dart';
import 'package:omise_flutter/src/utils/card_number_formatter.dart';
import 'package:omise_flutter/src/utils/expiry_date_formatter.dart';
import 'package:omise_flutter/src/utils/message_display_utils.dart';
import 'package:omise_flutter/src/utils/package_info.dart';
import 'package:omise_flutter/src/widgets/rounded_text_field.dart';
import 'package:omise_flutter/src/models/card_brand.dart';

/// A page that allows users to enter their credit card payment information.
///
Expand Down Expand Up @@ -176,32 +179,52 @@ class _CreditCardPageState extends State<CreditCardPage> {
// Card Number Input
Padding(
padding: const EdgeInsets.only(bottom: 20.0, top: 20),
child: RoundedTextField(
title: Translations.get('cardNumber', widget.locale, context),
validationType: ValidationType.cardNumber,
enabled: isFormEnabled,
keyboardType: TextInputType.number,
useValidationTypeAsKey: true,
onChange: (cardNumber) {
var newState = state.copyWith();
newState.createTokenRequest.number = cardNumber;
if (state.isLoanCard) {
expiryDateTextController.text = '';
securityCodeTextController.text = '';
newState = state.copyWith();
newState.createTokenRequest.expirationMonth = null;
newState.createTokenRequest.expirationYear = null;
newState.createTokenRequest.securityCode = null;
newState.textFieldValidityStatuses
.remove(ValidationType.cvv.name);
newState.textFieldValidityStatuses
.remove(ValidationType.expiryDate.name);
}
creditCardController.updateState(newState);
},
updateValidationList: (fieldKey, isValid) {
creditCardController.setTextFieldValidityStatuses(
fieldKey, isValid);
child: Builder(
builder: (context) {
final activeBrand = CardBrand.getActiveBrand(
state.createTokenRequest.number);
return RoundedTextField(
title: Translations.get(
'cardNumber', widget.locale, context),
validationType: ValidationType.cardNumber,
enabled: isFormEnabled,
keyboardType: TextInputType.number,
useValidationTypeAsKey: true,
inputFormatters: [CardNumberFormatter()],
suffixIcon: activeBrand != null
? Padding(
padding: const EdgeInsets.all(8.0),
child: Image.asset(
activeBrand.logoAssetPath,
package: PackageInfo.packageName,
width: 32,
height: 20,
),
)
: null,
onChange: (cardNumber) {
var newState = state.copyWith();
newState.createTokenRequest.number =
cardNumber.replaceAll(RegExp(r'\s+'), '');
if (state.isLoanCard) {
expiryDateTextController.text = '';
securityCodeTextController.text = '';
newState = state.copyWith();
newState.createTokenRequest.expirationMonth = null;
newState.createTokenRequest.expirationYear = null;
newState.createTokenRequest.securityCode = null;
newState.textFieldValidityStatuses
.remove(ValidationType.cvv.name);
newState.textFieldValidityStatuses
.remove(ValidationType.expiryDate.name);
}
creditCardController.updateState(newState);
},
updateValidationList: (fieldKey, isValid) {
creditCardController.setTextFieldValidityStatuses(
fieldKey, isValid);
},
);
},
),
),
Expand Down
80 changes: 80 additions & 0 deletions lib/src/utils/card_number_formatter.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import 'package:flutter/services.dart';
import 'package:omise_flutter/src/models/card_brand.dart';

class CardNumberFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(

Check failure on line 6 in lib/src/utils/card_number_formatter.dart

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 22 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=omise_omise_flutter&issues=AZzY_o9RSyOhfY7ERZaP&open=AZzY_o9RSyOhfY7ERZaP&pullRequest=62
TextEditingValue oldValue,
TextEditingValue newValue,
) {
if (newValue.text.isEmpty) {
return newValue.copyWith(text: '');
}

// Retain only numeric characters
String numericText = newValue.text.replaceAll(RegExp(r'[^0-9]'), '');

// Attempt to identify the active card brand
final brand = CardBrand.getActiveBrand(numericText);

// Determine the grouping pattern and max length based on the card brand
List<int> groupLengths = [4, 4, 4, 4, 4]; // Default: 4-4-4-4-X grouping
int maxLength = 19; // Safe default upper limit

if (brand == CardBrand.amex) {
groupLengths = [4, 6, 5];
maxLength = 15;
} else if (brand == CardBrand.diners) {
groupLengths = [4, 6, 4];
maxLength = 14;
} else if (brand != null) {
maxLength = brand.maxLength;
}

if (numericText.length > maxLength) {
numericText = numericText.substring(0, maxLength);
}

String formattedText = '';
int currentIdx = 0;
for (int length in groupLengths) {
if (currentIdx >= numericText.length) break;
int endIdx = currentIdx + length;
if (endIdx > numericText.length) endIdx = numericText.length;

formattedText += numericText.substring(currentIdx, endIdx);
currentIdx = endIdx;

if (currentIdx < numericText.length) {
formattedText += ' ';
}
}

// Calculate cursor position after formatting
int nonSpaceCount = 0;
int cursorPosition = newValue.selection.end;
if (cursorPosition < 0) {
cursorPosition = newValue.text.length;
}

for (int i = 0; i < newValue.text.length && i < cursorPosition; i++) {
if (newValue.text[i] != ' ') {
nonSpaceCount++;
}
}

int finalCursorPosition = 0;
for (int i = 0; i < formattedText.length; i++) {
if (nonSpaceCount == 0) break;
if (formattedText[i] != ' ') {
nonSpaceCount--;
}
finalCursorPosition++;
}

return TextEditingValue(
text: formattedText,
selection: TextSelection.collapsed(offset: finalCursorPosition),
);
}
}
5 changes: 5 additions & 0 deletions lib/src/widgets/rounded_text_field.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class RoundedTextField extends StatefulWidget {
this.updateValidationList,
this.useValidationTypeAsKey = false,
this.isOptional = false,
this.suffixIcon,
});

/// An optional controller for controlling the text being edited.
Expand Down Expand Up @@ -66,6 +67,9 @@ class RoundedTextField extends StatefulWidget {
/// Is the field optional
final bool? isOptional;

/// An optional suffix icon widget
final Widget? suffixIcon;

@override
State<RoundedTextField> createState() => _RoundedTextFieldState();
}
Expand Down Expand Up @@ -101,6 +105,7 @@ class _RoundedTextFieldState extends State<RoundedTextField> {
inputFormatters: widget.inputFormatters,
decoration: InputDecoration(
hintText: widget.hintText,
suffixIcon: widget.suffixIcon,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: const BorderSide(
Expand Down
35 changes: 35 additions & 0 deletions test/models/card_brand_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:omise_flutter/src/models/card_brand.dart';

void main() {
group('CardBrand enum tests', () {
test('getActiveBrand recognizes valid brands correctly', () {
// Amex
expect(CardBrand.getActiveBrand('341234567890123'), CardBrand.amex);

// Visa
expect(CardBrand.getActiveBrand('4123456789012345'), CardBrand.visa);

// Mastercard
expect(
CardBrand.getActiveBrand('5512345678901234'), CardBrand.mastercard);

// Unknown
expect(CardBrand.getActiveBrand('111111111'), isNull);
});

test('valid validates correctness and lengths', () {
expect(CardBrand.visa.valid('4123456789012345'), isTrue);
// Visa length must be exactly 16 based on our config
expect(CardBrand.visa.valid('412345678901234'), isFalse);

expect(CardBrand.amex.valid('341234567890123'), isTrue);
expect(CardBrand.amex.valid('3412345678901234'), isFalse);
});

test('valid accepts formatted text with spaces', () {
expect(CardBrand.visa.valid('4123 4567 8901 2345'), isTrue);
expect(CardBrand.amex.valid('3412 345678 90123'), isTrue);
});
});
}
4 changes: 2 additions & 2 deletions test/pages/credit_card_page_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,7 @@ void main() {
await tester.pump();

// Check that the fields contain the entered text
expect(find.text('4242424242424242'), findsOneWidget);
expect(find.text('4242 4242 4242 4242'), findsOneWidget);
expect(find.text('12/25'), findsOneWidget);
expect(find.text('123'), findsOneWidget);
expect(find.text('John Doe'), findsOneWidget);
Expand Down Expand Up @@ -508,7 +508,7 @@ void main() {
await tester.pump();

// Check that the fields contain the entered text
expect(find.text('4784451119188786'), findsOneWidget);
expect(find.text('4784 4511 1918 8786'), findsOneWidget);

expect(tester.widget<TextField>(expiryDateField).enabled,
isFalse); // TextField should be disabled
Expand Down
52 changes: 52 additions & 0 deletions test/utils/card_number_formatter_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/services.dart';
import 'package:omise_flutter/src/utils/card_number_formatter.dart';

void main() {
group('CardNumberFormatter', () {
final formatter = CardNumberFormatter();

TextEditingValue format(String text) {
return formatter.formatEditUpdate(
const TextEditingValue(),
TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length)),
);
}

test('formats a standard 16 digit card as 4-4-4-4', () {
final val = format('4242424242424242');
expect(val.text, '4242 4242 4242 4242');
});

test('strips non-numeric characters before formatting', () {
final val = format('abc4242xyz4242_4242-4242&');
expect(val.text, '4242 4242 4242 4242');
});

test('formats Amex differently (4-6-5)', () {
// Amex starts with 34 or 37
final val = format('341234567890123'); // 15 digits
expect(val.text, '3412 345678 90123');
});

test('caps length properly based on brand limits', () {
// Amex is 15 max
final val = format('341234567890123999');
// Should stop at 15 chars
expect(val.text, '3412 345678 90123');
});

test('formats Diners Club as 4-6-4', () {
// Diners starts with 30, 36
final val = format('36123456789012'); // 14 digits
expect(val.text, '3612 345678 9012');
});

test('caps standard generic / unknown card at 19 digits', () {
final val = format('111122223333444455556666'); // More than 19
expect(val.text, '1111 2222 3333 4444 555');
});
});
}