diff --git a/docs/ux/kyc.md b/docs/ux/kyc.md index 2677666..ef23382 100644 --- a/docs/ux/kyc.md +++ b/docs/ux/kyc.md @@ -211,3 +211,51 @@ smoke testing on the local demo opened the selector, searched for Poland and confirmed that the profile displays Poland after selection. Focused and full journey tests verify the stored `PL` code. Generated localization/build outputs remain ignored and uncommitted. + +## Birthday entry — task #67 + +Task [#67](https://git.sendico.io/tech/sxchnge/issues/67), branch `kyc-67`. +Delivery: [PR #70](https://git.sendico.io/tech/sxchnge/pulls/70). +This change is stacked on the residence-country selector in PR #65; merge #65 +before the birthday PR. + +The profile uses labeled Day, Month and Year controls. Day and year accept numeric +entry with birthday autofill hints; month selection uses localized month names +and standard Material keyboard/touch interaction. Customers can enter their birth +year directly. No calendar paging or transport-format punctuation is required. +The controls share a row when space allows and stack at narrow widths or enlarged +text sizes. English and Russian labels, month names and errors follow the app locale. + +Valid calendar dates are normalized to the existing `IdentityProfile.dateOfBirth` +`YYYY-MM-DD` value through `IdentityProvider.updateProfile`. Partial, impossible +and future dates clear that profile value so Continue cannot retain an earlier +valid birthday. Local input components remain visible while correcting a date; +complete invalid dates receive a specific live-region error below the group. +Leap years, zero day/year and calendar rollover are checked. Today's date follows +the existing model policy; no minimum age or arbitrary oldest birth year is added. +Prefilled dates, locale changes and external profile replacement synchronize the +controls. Back navigation restores the valid saved date. Busy state disables all +three controls. Controllers are disposed with the widget. + +The existing DTO, mapper, service, domain model and provider boundaries are retained. +No dependency, transport or backend change is needed. This remains part of the KYC +preparation-to-submission journey defined above: existing acceptance, failure, +retry and abandonment signals apply, without new field events or date collection. +The existing server-side review measurement gaps remain unchanged. + +UX review and automated checks cover entry/correction, leap dates, invalid and +future-date recovery, numeric restrictions, prefill/replacement/clearing, locale +switching, disabled state, Tab/Enter/arrow-key month selection, Escape cancellation, +and Russian controls/errors at 320px with 1.6 text scale. Labeled and minimum-size +tap-target guidelines pass. Integration checks verify that invalid dates block +Continue, Back preserves the birthday, and EN/RU flows still reach submission +acceptance. Native screen-reader and mobile keyboard/autofill testing remain +release checks; widget semantics are not a claim of device-level validation. + +Validation: all four changed Dart files pass the format check; `flutter analyze` +in `frontend/app` reports no issues; `make test-frontend` passes all 230 tests, +including the seven new birthday widget tests and the new navigation/gating test. +`flutter build web` passes, including its Wasm compatibility dry run. Browser +smoke testing of the release build entered 23 April 1990, verified the inline +error for 31 April and restored 23 April without losing month or year. +Generated localization and build outputs remain ignored and uncommitted. diff --git a/frontend/app/lib/kyc/birth.dart b/frontend/app/lib/kyc/birth.dart new file mode 100644 index 0000000..c918804 --- /dev/null +++ b/frontend/app/lib/kyc/birth.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:intl/intl.dart'; + +import '../generated/i18n/app_localizations.dart'; + +/// Birthday entry with a canonical date string at the profile boundary. +class BirthField extends StatefulWidget { + const BirthField({ + super.key, + required this.value, + required this.onChanged, + this.enabled = true, + }); + + final String value; + final ValueChanged onChanged; + final bool enabled; + + @override + State createState() => _BirthFieldState(); +} + +class _BirthFieldState extends State { + final _day = TextEditingController(); + final _year = TextEditingController(); + final _monthField = GlobalKey>(); + int? _month; + late String _emitted; + + @override + void initState() { + super.initState(); + _readValue(); + } + + @override + void didUpdateWidget(BirthField oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.value != _emitted) { + _readValue(); + _monthField.currentState?.didChange(_month); + } + } + + void _readValue() { + _emitted = widget.value; + final parts = RegExp(r'^(\d{4})-(\d{2})-(\d{2})$').firstMatch(widget.value); + _year.text = parts?.group(1) ?? ''; + final month = int.tryParse(parts?.group(2) ?? ''); + _month = month != null && month >= 1 && month <= 12 ? month : null; + _day.text = parts?.group(3) ?? ''; + } + + DateTime? get _date { + final day = int.tryParse(_day.text); + final year = int.tryParse(_year.text); + if (day == null || + year == null || + year < 1 || + _year.text.length != 4 || + _month == null) { + return null; + } + final date = DateTime(year, _month!, day); + return date.year == year && date.month == _month && date.day == day + ? date + : null; + } + + bool _future(DateTime date) => + date.isAfter(DateUtils.dateOnly(DateTime.now())); + + void _change() { + setState(() {}); + final date = _date; + _emitted = date == null || _future(date) + ? '' + : '${_year.text}-${_month.toString().padLeft(2, '0')}-' + '${_day.text.padLeft(2, '0')}'; + widget.onChanged(_emitted); + } + + @override + void dispose() { + _day.dispose(); + _year.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + final date = _date; + final complete = + _day.text.isNotEmpty && _month != null && _year.text.length == 4; + final error = !complete + ? null + : date == null + ? l.kycBirthInvalid + : _future(date) + ? l.kycBirthFuture + : null; + final months = DateFormat.MMMM(Localizations.localeOf(context).toString()); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l.kycBirth, style: Theme.of(context).textTheme.labelLarge), + const SizedBox(height: 8), + LayoutBuilder( + builder: (context, constraints) { + final day = _number( + key: 'birth-day', + controller: _day, + label: l.kycBirthDay, + length: 2, + autofill: AutofillHints.birthdayDay, + ); + final month = DropdownButtonFormField( + key: _monthField, + initialValue: _month, + isExpanded: true, + decoration: InputDecoration(labelText: l.kycBirthMonth), + items: [ + for (var month = 1; month <= 12; month++) + DropdownMenuItem( + value: month, + child: Text(months.format(DateTime(2000, month))), + ), + ], + onChanged: !widget.enabled + ? null + : (value) { + _month = value; + _change(); + }, + ); + final year = _number( + key: 'birth-year', + controller: _year, + label: l.kycBirthYear, + length: 4, + autofill: AutofillHints.birthdayYear, + ); + final scale = MediaQuery.textScalerOf(context).scale(16) / 16; + if (constraints.maxWidth < 400 * scale) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + day, + const SizedBox(height: 16), + month, + const SizedBox(height: 16), + year, + ], + ); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(flex: 2, child: day), + const SizedBox(width: 12), + Expanded(flex: 4, child: month), + const SizedBox(width: 12), + Expanded(flex: 3, child: year), + ], + ); + }, + ), + if (error != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Semantics( + liveRegion: true, + child: Text( + error, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ), + ], + ); + } + + Widget _number({ + required String key, + required TextEditingController controller, + required String label, + required int length, + required String autofill, + }) => TextFormField( + key: ValueKey(key), + controller: controller, + enabled: widget.enabled, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.next, + autofillHints: [autofill], + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(length), + ], + decoration: InputDecoration(labelText: label), + onChanged: (_) => _change(), + ); +} diff --git a/frontend/app/lib/kyc/profile.dart b/frontend/app/lib/kyc/profile.dart index fc28939..557addb 100644 --- a/frontend/app/lib/kyc/profile.dart +++ b/frontend/app/lib/kyc/profile.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:sxchnge_kyc/sxchnge_kyc.dart'; +import 'birth.dart'; import 'country.dart'; import '../generated/i18n/app_localizations.dart'; @@ -37,17 +38,11 @@ class ProfileForm extends StatelessWidget { onChanged: (value) => update(name: value), ), const SizedBox(height: 16), - TextFormField( + BirthField( key: const ValueKey('kyc-birth'), - initialValue: p.profile.dateOfBirth, + value: p.profile.dateOfBirth, enabled: !p.busy, - keyboardType: TextInputType.datetime, - autofillHints: const [AutofillHints.birthday], - decoration: InputDecoration( - labelText: l.kycBirth, - helperText: l.kycBirthHelp, - ), - onChanged: (value) => update(birth: value.trim()), + onChanged: (value) => update(birth: value), ), const SizedBox(height: 16), CountryField( diff --git a/frontend/app/lib/l10n/en.arb b/frontend/app/lib/l10n/en.arb index edfccf6..4c775ab 100644 --- a/frontend/app/lib/l10n/en.arb +++ b/frontend/app/lib/l10n/en.arb @@ -269,7 +269,11 @@ "kycProfileHelp": "Enter your details exactly as shown on your identity document.", "kycFullName": "Full legal name", "kycBirth": "Date of birth", - "kycBirthHelp": "YYYY-MM-DD, for example 1990-04-23", + "kycBirthDay": "Day", + "kycBirthMonth": "Month", + "kycBirthYear": "Year", + "kycBirthInvalid": "Enter a valid date of birth.", + "kycBirthFuture": "Date of birth cannot be in the future.", "kycCountry": "Country of residence", "kycCountrySelect": "Select a country", "kycCountrySearch": "Search countries", diff --git a/frontend/app/lib/l10n/ru.arb b/frontend/app/lib/l10n/ru.arb index 0ec126f..c2c465c 100644 --- a/frontend/app/lib/l10n/ru.arb +++ b/frontend/app/lib/l10n/ru.arb @@ -269,7 +269,11 @@ "kycProfileHelp": "Укажите данные точно как в документе, удостоверяющем личность.", "kycFullName": "Полное имя по документу", "kycBirth": "Дата рождения", - "kycBirthHelp": "ГГГГ-ММ-ДД, например 1990-04-23", + "kycBirthDay": "День", + "kycBirthMonth": "Месяц", + "kycBirthYear": "Год", + "kycBirthInvalid": "Укажите корректную дату рождения.", + "kycBirthFuture": "Дата рождения не может быть в будущем.", "kycCountry": "Страна проживания", "kycCountrySelect": "Выберите страну", "kycCountrySearch": "Поиск страны", diff --git a/frontend/app/test/birth_test.dart b/frontend/app/test/birth_test.dart new file mode 100644 index 0000000..00f4041 --- /dev/null +++ b/frontend/app/test/birth_test.dart @@ -0,0 +1,236 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:sxchnge_theme/sxchnge_theme.dart'; + +import 'package:sxchnge_app/kyc/birth.dart'; + +import 'package:sxchnge_app/generated/i18n/app_localizations.dart'; + +void main() { + final day = find.byKey(const ValueKey('birth-day')); + final year = find.byKey(const ValueKey('birth-year')); + final month = find.byType(DropdownButtonFormField); + + Future mount( + WidgetTester t, + ValueNotifier value, { + Locale locale = const Locale('en'), + bool enabled = true, + }) async { + await t.pumpWidget( + MaterialApp( + theme: SxchngeTheme.light(), + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ValueListenableBuilder( + valueListenable: value, + builder: (_, date, _) => BirthField( + value: date, + enabled: enabled, + onChanged: (date) => value.value = date, + ), + ), + ), + ), + ), + ); + await t.pumpAndSettle(); + } + + Future select(WidgetTester t, String name) async { + await t.ensureVisible(month); + await t.tap(month); + await t.pumpAndSettle(); + await t.tap(find.text(name).last); + await t.pumpAndSettle(); + } + + testWidgets('enters and corrects a birthday without ISO syntax', (t) async { + final value = ValueNotifier(''); + addTearDown(value.dispose); + await mount(t, value); + expect(find.text('Date of birth'), findsOneWidget); + await t.enterText(day, '23'); + await select(t, 'April'); + expect(value.value, ''); + await t.enterText(year, '1990'); + await t.pumpAndSettle(); + expect(value.value, '1990-04-23'); + await t.enterText(day, '9'); + await t.pumpAndSettle(); + expect(value.value, '1990-04-09'); + await t.enterText(year, '19'); + await t.pumpAndSettle(); + expect(value.value, ''); + // A provider rebuild must retain the other components of an incomplete date. + expect(t.widget(day).controller!.text, '9'); + expect(find.text('April'), findsOneWidget); + await t.enterText(year, '1985'); + await t.pumpAndSettle(); + expect(value.value, '1985-04-09'); + }); + + testWidgets('leap dates, impossible days and future dates recover inline', ( + t, + ) async { + final value = ValueNotifier('2000-02-29'); + addTearDown(value.dispose); + await mount(t, value); + expect(find.text('February'), findsOneWidget); + await t.enterText(year, '2001'); + await t.pumpAndSettle(); + expect(value.value, ''); + expect(find.text('Enter a valid date of birth.'), findsOneWidget); + expect(t.widget(day).controller!.text, '29'); + await t.enterText(year, '2004'); + await t.pumpAndSettle(); + expect(value.value, '2004-02-29'); + expect(find.text('Enter a valid date of birth.'), findsNothing); + await t.enterText(day, '31'); + await select(t, 'April'); + expect(value.value, ''); + expect(find.text('Enter a valid date of birth.'), findsOneWidget); + await t.enterText(day, '30'); + await t.enterText(year, '${DateTime.now().year + 1}'); + await t.pumpAndSettle(); + expect(value.value, ''); + expect(find.text('Date of birth cannot be in the future.'), findsOneWidget); + await t.enterText(year, '1990'); + await t.pumpAndSettle(); + expect(value.value, '1990-04-30'); + await t.enterText(day, '0'); + await t.pumpAndSettle(); + expect(value.value, ''); + expect(find.text('Enter a valid date of birth.'), findsOneWidget); + await t.enterText(day, '1'); + await t.enterText(year, '0000'); + await t.pumpAndSettle(); + expect(value.value, ''); + expect(find.text('Enter a valid date of birth.'), findsOneWidget); + }); + + testWidgets( + 'prefill, external replacement, locale change and clear synchronize', + (t) async { + final value = ValueNotifier('1990-04-23'); + addTearDown(value.dispose); + await mount(t, value); + expect(t.widget(year).controller!.text, '1990'); + value.value = '1985-12-09'; + await t.pumpAndSettle(); + expect(t.widget(day).controller!.text, '09'); + expect(t.widget(year).controller!.text, '1985'); + expect(find.text('December'), findsOneWidget); + await mount(t, value, locale: const Locale('ru')); + expect(find.text('Дата рождения'), findsOneWidget); + expect(find.text('декабрь'), findsOneWidget); + expect(value.value, '1985-12-09'); + await select(t, 'апрель'); + expect(value.value, '1985-04-09'); + value.value = ''; + await t.pumpAndSettle(); + expect(t.widget(day).controller!.text, ''); + expect(t.widget(year).controller!.text, ''); + expect(find.text('апрель'), findsNothing); + }, + ); + + testWidgets('digits and length limits keep each component usable', (t) async { + final value = ValueNotifier('1990-04-23'); + addTearDown(value.dispose); + await mount(t, value); + await t.enterText(day, ''); + await t.enterText(day, 'a123'); + await t.enterText(year, ''); + await t.enterText(year, 'b19856'); + await t.pumpAndSettle(); + expect(t.widget(day).controller!.text, '12'); + expect(t.widget(year).controller!.text, '1985'); + expect(value.value, '1985-04-12'); + }); + + testWidgets('busy state disables every birthday control', (t) async { + final value = ValueNotifier('1990-04-23'); + addTearDown(value.dispose); + await mount(t, value, enabled: false); + expect(t.widget(day).enabled, isFalse); + expect(t.widget(year).enabled, isFalse); + expect(t.widget>(month).onChanged, isNull); + expect(value.value, '1990-04-23'); + }); + + testWidgets('keyboard can traverse, choose month, cancel and edit year', ( + t, + ) async { + final value = ValueNotifier('1990-04-23'); + addTearDown(value.dispose); + await mount(t, value); + await t.sendKeyEvent(LogicalKeyboardKey.tab); + await t.pump(); + expect( + t + .widget( + find.descendant(of: day, matching: find.byType(EditableText)), + ) + .focusNode + .hasFocus, + isTrue, + ); + await t.sendKeyEvent(LogicalKeyboardKey.tab); + await t.sendKeyEvent(LogicalKeyboardKey.enter); + await t.pumpAndSettle(); + await t.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await t.sendKeyEvent(LogicalKeyboardKey.enter); + await t.pumpAndSettle(); + expect(value.value, '1990-05-23'); + await t.sendKeyEvent(LogicalKeyboardKey.enter); + await t.pumpAndSettle(); + await t.sendKeyEvent(LogicalKeyboardKey.escape); + await t.pumpAndSettle(); + expect(value.value, '1990-05-23'); + await t.sendKeyEvent(LogicalKeyboardKey.tab); + await t.pump(); + expect( + t + .widget( + find.descendant(of: year, matching: find.byType(EditableText)), + ) + .focusNode + .hasFocus, + isTrue, + ); + }); + + testWidgets( + 'Russian controls and errors fit 320px with large text and labeled targets', + (t) async { + t.view.physicalSize = const Size(320, 900); + t.view.devicePixelRatio = 1; + t.platformDispatcher.textScaleFactorTestValue = 1.6; + addTearDown(t.view.resetPhysicalSize); + addTearDown(t.view.resetDevicePixelRatio); + addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue); + final semantics = t.ensureSemantics(); + final value = ValueNotifier('1990-09-23'); + addTearDown(value.dispose); + await mount(t, value, locale: const Locale('ru')); + expect(find.text('сентябрь'), findsOneWidget); + expect(t, meetsGuideline(labeledTapTargetGuideline)); + expect(t, meetsGuideline(androidTapTargetGuideline)); + await t.enterText(day, '31'); + await t.pumpAndSettle(); + expect(find.text('Укажите корректную дату рождения.'), findsOneWidget); + await select(t, 'март'); + expect(value.value, '1990-03-31'); + expect(t.takeException(), isNull); + semantics.dispose(); + }, + ); +} diff --git a/frontend/app/test/kyc_test.dart b/frontend/app/test/kyc_test.dart index e59275c..172b6ef 100644 --- a/frontend/app/test/kyc_test.dart +++ b/frontend/app/test/kyc_test.dart @@ -11,6 +11,7 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart'; import 'package:sxchnge_kyc/sxchnge_kyc.dart'; +import 'package:sxchnge_app/kyc/birth.dart'; import 'package:sxchnge_app/kyc/country.dart'; import 'package:sxchnge_app/main.dart'; import 'package:sxchnge_app/providers/locale.dart'; @@ -66,10 +67,16 @@ void main() { return service; } - Future submit(WidgetTester t) async { + Future profile(WidgetTester t) async { await tap(t, find.byKey(const ValueKey('kyc-start'))); await t.enterText(find.byKey(const ValueKey('kyc-name')), 'Example Person'); - await t.enterText(find.byKey(const ValueKey('kyc-birth')), '1990-04-23'); + await t.enterText(find.byKey(const ValueKey('birth-day')), '23'); + await tap(t, find.byType(DropdownButtonFormField)); + // The month label follows the selected app locale. + await tap(t, find.textContaining(RegExp(r'^(April|апрель)$')).last); + await t.enterText(find.byKey(const ValueKey('birth-year')), '1990'); + await t.pumpAndSettle(); + expect(t.widget(find.byType(BirthField)).value, '1990-04-23'); await tap( t, find.descendant( @@ -85,6 +92,10 @@ void main() { 'PL', ); await t.pumpAndSettle(); + } + + Future submit(WidgetTester t) async { + await profile(t); await tap(t, find.byKey(const ValueKey('pick-passport'))); await tap(t, find.byKey(const ValueKey('kyc-next'))); await tap(t, find.byKey(const ValueKey('pick-selfie'))); @@ -93,6 +104,41 @@ void main() { await tap(t, find.byKey(const ValueKey('kyc-submit'))); } + testWidgets( + 'invalid birthday blocks advancing and valid birthday survives Back', + (t) async { + await open(t); + await profile(t); + await tap(t, find.byKey(const ValueKey('pick-passport'))); + final next = find.byKey(const ValueKey('kyc-next')); + await t.enterText(find.byKey(const ValueKey('birth-day')), '31'); + await t.pumpAndSettle(); + expect(t.widget(next).onPressed, isNull); + expect(find.text('Enter a valid date of birth.'), findsOneWidget); + await t.enterText(find.byKey(const ValueKey('birth-day')), '23'); + await t.pumpAndSettle(); + expect(t.widget(next).onPressed, isNotNull); + await tap(t, next); + await tap(t, find.text('Back')); + expect(t.widget(find.byType(BirthField)).value, '1990-04-23'); + expect( + t + .widget(find.byKey(const ValueKey('birth-day'))) + .controller! + .text, + '23', + ); + expect(find.text('April'), findsOneWidget); + expect( + t + .widget(find.byKey(const ValueKey('birth-year'))) + .controller! + .text, + '1990', + ); + }, + ); + testWidgets('two photos, review, pending and approval with service limits', ( t, ) async {