diff --git a/lib/features/cards/presentation/validators/card_form_validators.dart b/lib/features/cards/presentation/validators/card_form_validators.dart index 7c28f78..aed9458 100644 --- a/lib/features/cards/presentation/validators/card_form_validators.dart +++ b/lib/features/cards/presentation/validators/card_form_validators.dart @@ -46,7 +46,7 @@ abstract final class CardFormValidators { return AppStrings.subscriptionsPaymentExpiryMonthHint; } - final year = int.tryParse(_trimmed(yearValue)); + final year = _parseFullYear(yearValue); if (year != null) { final currentDate = now ?? DateTime.now(); if (_isExpired(month: month, year: year, now: currentDate) || @@ -67,11 +67,11 @@ abstract final class CardFormValidators { if (trimmed.isEmpty) { return AppStrings.subscriptionsPaymentExpiryYearHint; } - if (trimmed.length != 4) { + if (trimmed.length != 2) { return AppStrings.subscriptionsPaymentExpiryYearHint; } - final year = int.tryParse(trimmed); + final year = _parseFullYear(trimmed); if (year == null) { return AppStrings.subscriptionsPaymentExpiryYearHint; } @@ -107,6 +107,16 @@ abstract final class CardFormValidators { static String _digitsOnly(String? value) => (value ?? '').replaceAll(RegExp(r'\D'), ''); + static int? _parseFullYear(String? value) { + final trimmed = _trimmed(value); + if (trimmed.length != 2) return null; + + final shortYear = int.tryParse(trimmed); + if (shortYear == null) return null; + + return 2000 + shortYear; + } + static bool _isExpired({ required int month, required int year, diff --git a/lib/features/cards/presentation/widgets/save_card_dialog.dart b/lib/features/cards/presentation/widgets/save_card_dialog.dart index 42aa83b..87f1aa1 100644 --- a/lib/features/cards/presentation/widgets/save_card_dialog.dart +++ b/lib/features/cards/presentation/widgets/save_card_dialog.dart @@ -99,12 +99,14 @@ class _SaveCardDialogState extends State { final form = _formKey.currentState; if (form == null || !form.validate()) return; + final expiryYear = _expiryYearController.text.trim(); + context.read().saveCard( payload: SaveCardPayload( cardNumber: _cardNumberController.text.replaceAll(RegExp(r'\D'), ''), cardHolder: _normalizedCardHolder, expiryMonth: _expiryMonthController.text.trim(), - expiryYear: _expiryYearController.text.trim(), + expiryYear: _buildBackendExpiryYear(expiryYear), ), ); } @@ -220,7 +222,7 @@ class _SaveCardDialogState extends State { showLabel: false, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(4), + const _ExpiryYearTextInputFormatter(), ], validator: (value) => CardFormValidators.expiryYear( value, @@ -289,6 +291,8 @@ class _SaveCardDialogState extends State { } } +String _buildBackendExpiryYear(String value) => value.isEmpty ? value : '20$value'; + final class _CardPreview extends StatelessWidget { final TextEditingController previewCardNumberController; final String cardHolderValue; @@ -488,3 +492,32 @@ final class _CardHolderTextInputFormatter extends TextInputFormatter { extension on String { String ifEmpty(String fallback) => isEmpty ? fallback : this; } + +final class _ExpiryYearTextInputFormatter extends TextInputFormatter { + const _ExpiryYearTextInputFormatter(); + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + final digits = newValue.text.replaceAll(RegExp(r'\D'), ''); + + if (digits.length <= 2) { + return TextEditingValue( + text: digits, + selection: TextSelection.collapsed(offset: digits.length), + ); + } + + if (digits.length == 4 && digits.startsWith('20')) { + final shortYear = digits.substring(2); + return TextEditingValue( + text: shortYear, + selection: TextSelection.collapsed(offset: shortYear.length), + ); + } + + return oldValue; + } +} diff --git a/lib/features/subscriptions/presentation/validators/subscription_payment_validators.dart b/lib/features/subscriptions/presentation/validators/subscription_payment_validators.dart index 286d6c3..98d1eb4 100644 --- a/lib/features/subscriptions/presentation/validators/subscription_payment_validators.dart +++ b/lib/features/subscriptions/presentation/validators/subscription_payment_validators.dart @@ -46,7 +46,7 @@ abstract final class SubscriptionPaymentValidators { return AppStrings.subscriptionsPaymentExpiryMonthHint; } - final year = int.tryParse(_trimmed(yearValue)); + final year = _parseFullYear(yearValue); if (year != null) { final currentDate = now ?? DateTime.now(); if (_isExpired(month: month, year: year, now: currentDate) || @@ -67,11 +67,11 @@ abstract final class SubscriptionPaymentValidators { if (trimmed.isEmpty) { return AppStrings.subscriptionsPaymentExpiryYearHint; } - if (trimmed.length != 4) { + if (trimmed.length != 2) { return AppStrings.subscriptionsPaymentExpiryYearHint; } - final year = int.tryParse(trimmed); + final year = _parseFullYear(trimmed); if (year == null) { return AppStrings.subscriptionsPaymentExpiryYearHint; } @@ -107,6 +107,16 @@ abstract final class SubscriptionPaymentValidators { static String _digitsOnly(String? value) => (value ?? '').replaceAll(RegExp(r'\D'), ''); + static int? _parseFullYear(String? value) { + final trimmed = _trimmed(value); + if (trimmed.length != 2) return null; + + final shortYear = int.tryParse(trimmed); + if (shortYear == null) return null; + + return 2000 + shortYear; + } + static bool _isExpired({ required int month, required int year, diff --git a/lib/features/subscriptions/presentation/widgets/subscription_payment_dialog.dart b/lib/features/subscriptions/presentation/widgets/subscription_payment_dialog.dart index 7f57a81..49a33af 100644 --- a/lib/features/subscriptions/presentation/widgets/subscription_payment_dialog.dart +++ b/lib/features/subscriptions/presentation/widgets/subscription_payment_dialog.dart @@ -108,6 +108,8 @@ class _SubscriptionPaymentDialogState extends State { final form = _formKey.currentState; if (form == null || !form.validate()) return; + final expiryYear = _expiryYearController.text.trim(); + context.read().pay( payload: SubscriptionPaymentPayload( subscriptionId: widget.item.id, @@ -115,7 +117,7 @@ class _SubscriptionPaymentDialogState extends State { cardNumber: _cardNumberController.text.replaceAll(RegExp(r'\D'), ''), cardHolder: _cardHolderController.text.trim(), expiryMonth: _expiryMonthController.text.trim(), - expiryYear: _expiryYearController.text.trim(), + expiryYear: _buildBackendExpiryYear(expiryYear), cvv: _cvvController.text.trim(), ), ); @@ -232,7 +234,7 @@ class _SubscriptionPaymentDialogState extends State { showLabel: false, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(4), + const _ExpiryYearTextInputFormatter(), ], validator: (value) => SubscriptionPaymentValidators.expiryYear( @@ -310,6 +312,8 @@ class _SubscriptionPaymentDialogState extends State { } } +String _buildBackendExpiryYear(String value) => value.isEmpty ? value : '20$value'; + final class _PaymentPreviewCard extends StatelessWidget { final String previewCardNumber; final String cardHolder; @@ -535,3 +539,32 @@ final class _CardNumberTextInputFormatter extends TextInputFormatter { ); } } + +final class _ExpiryYearTextInputFormatter extends TextInputFormatter { + const _ExpiryYearTextInputFormatter(); + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + final digits = newValue.text.replaceAll(RegExp(r'\D'), ''); + + if (digits.length <= 2) { + return TextEditingValue( + text: digits, + selection: TextSelection.collapsed(offset: digits.length), + ); + } + + if (digits.length == 4 && digits.startsWith('20')) { + final shortYear = digits.substring(2); + return TextEditingValue( + text: shortYear, + selection: TextSelection.collapsed(offset: shortYear.length), + ); + } + + return oldValue; + } +} diff --git a/test/features/cards/presentation/validators/card_form_validators_test.dart b/test/features/cards/presentation/validators/card_form_validators_test.dart index 7590889..521db65 100644 --- a/test/features/cards/presentation/validators/card_form_validators_test.dart +++ b/test/features/cards/presentation/validators/card_form_validators_test.dart @@ -73,7 +73,7 @@ void main() { expect( CardFormValidators.expiryMonth( '3', - yearValue: '2026', + yearValue: '26', now: fixedNow, ), isNotNull, @@ -84,7 +84,7 @@ void main() { expect( CardFormValidators.expiryMonth( '4', - yearValue: '2026', + yearValue: '26', now: fixedNow, ), isNull, @@ -92,7 +92,7 @@ void main() { expect( CardFormValidators.expiryMonth( '5', - yearValue: '2026', + yearValue: '26', now: fixedNow, ), isNull, @@ -103,7 +103,7 @@ void main() { expect( CardFormValidators.expiryMonth( '5', - yearValue: '9999', + yearValue: '99', now: fixedNow, ), isNotNull, @@ -121,21 +121,21 @@ void main() { expect(CardFormValidators.expiryYear(' '), invalidMessage); }); - test('returns invalid error when year length is not 4', () { - expect(CardFormValidators.expiryYear('24'), invalidMessage); + test('returns invalid error when year length is not 2', () { + expect(CardFormValidators.expiryYear('2'), invalidMessage); expect(CardFormValidators.expiryYear('202'), invalidMessage); - expect(CardFormValidators.expiryYear('20245'), invalidMessage); + expect(CardFormValidators.expiryYear('2026'), invalidMessage); }); - test('returns null when year length is 4', () { - expect(CardFormValidators.expiryYear('2026'), isNull); - expect(CardFormValidators.expiryYear(' 2026 '), isNull); + test('returns null when year length is 2', () { + expect(CardFormValidators.expiryYear('26'), isNull); + expect(CardFormValidators.expiryYear(' 26 '), isNull); }); test('returns hidden error when year is before current year', () { expect( CardFormValidators.expiryYear( - '2025', + '25', now: fixedNow, ), isNotNull, @@ -145,7 +145,7 @@ void main() { test('returns hidden error when month is already in the past for current year', () { expect( CardFormValidators.expiryYear( - '2026', + '26', monthValue: '3', now: fixedNow, ), @@ -156,7 +156,7 @@ void main() { test('returns null when current year is paired with current or future month', () { expect( CardFormValidators.expiryYear( - '2026', + '26', monthValue: '4', now: fixedNow, ), @@ -164,7 +164,7 @@ void main() { ); expect( CardFormValidators.expiryYear( - '2026', + '26', monthValue: '12', now: fixedNow, ), @@ -175,7 +175,7 @@ void main() { test('returns hidden error when year is unrealistically far in the future', () { expect( CardFormValidators.expiryYear( - '9999', + '99', now: fixedNow, ), isNotNull, diff --git a/test/features/subscriptions/presentation/validators/subscription_payment_validators_test.dart b/test/features/subscriptions/presentation/validators/subscription_payment_validators_test.dart index f483c88..05ebc2d 100644 --- a/test/features/subscriptions/presentation/validators/subscription_payment_validators_test.dart +++ b/test/features/subscriptions/presentation/validators/subscription_payment_validators_test.dart @@ -79,7 +79,7 @@ void main() { expect( SubscriptionPaymentValidators.expiryMonth( '3', - yearValue: '2026', + yearValue: '26', now: fixedNow, ), isNotNull, @@ -90,7 +90,7 @@ void main() { expect( SubscriptionPaymentValidators.expiryMonth( '4', - yearValue: '2026', + yearValue: '26', now: fixedNow, ), isNull, @@ -98,7 +98,7 @@ void main() { expect( SubscriptionPaymentValidators.expiryMonth( '5', - yearValue: '2026', + yearValue: '26', now: fixedNow, ), isNull, @@ -109,7 +109,7 @@ void main() { expect( SubscriptionPaymentValidators.expiryMonth( '5', - yearValue: '9999', + yearValue: '99', now: fixedNow, ), isNotNull, @@ -127,21 +127,21 @@ void main() { expect(SubscriptionPaymentValidators.expiryYear(' '), invalidMessage); }); - test('returns invalid error when year length is not 4', () { - expect(SubscriptionPaymentValidators.expiryYear('24'), invalidMessage); + test('returns invalid error when year length is not 2', () { + expect(SubscriptionPaymentValidators.expiryYear('2'), invalidMessage); expect(SubscriptionPaymentValidators.expiryYear('202'), invalidMessage); - expect(SubscriptionPaymentValidators.expiryYear('20245'), invalidMessage); + expect(SubscriptionPaymentValidators.expiryYear('2026'), invalidMessage); }); - test('returns null when year length is 4', () { - expect(SubscriptionPaymentValidators.expiryYear('2026'), isNull); - expect(SubscriptionPaymentValidators.expiryYear(' 2026 '), isNull); + test('returns null when year length is 2', () { + expect(SubscriptionPaymentValidators.expiryYear('26'), isNull); + expect(SubscriptionPaymentValidators.expiryYear(' 26 '), isNull); }); test('returns expired error when year is before current year', () { expect( SubscriptionPaymentValidators.expiryYear( - '2025', + '25', now: fixedNow, ), isNotNull, @@ -151,7 +151,7 @@ void main() { test('returns expired error when month is already in the past for current year', () { expect( SubscriptionPaymentValidators.expiryYear( - '2026', + '26', monthValue: '3', now: fixedNow, ), @@ -162,7 +162,7 @@ void main() { test('returns null when current year is paired with current or future month', () { expect( SubscriptionPaymentValidators.expiryYear( - '2026', + '26', monthValue: '4', now: fixedNow, ), @@ -170,7 +170,7 @@ void main() { ); expect( SubscriptionPaymentValidators.expiryYear( - '2026', + '26', monthValue: '12', now: fixedNow, ), @@ -181,7 +181,7 @@ void main() { test('returns invalid when year is unrealistically far in the future', () { expect( SubscriptionPaymentValidators.expiryYear( - '9999', + '99', now: fixedNow, ), isNotNull,