From 417e6d96b66fa870ec50f6571335f43bf3fb8063 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Fri, 23 Oct 2026 11:47:59 +0200 Subject: [PATCH] Select residence country by searchable name (#64) --- docs/ux/kyc.md | 43 +++++++ frontend/app/lib/kyc/country.dart | 163 ++++++++++++++++++++++++++ frontend/app/lib/kyc/profile.dart | 14 +-- frontend/app/lib/l10n/en.arb | 7 +- frontend/app/lib/l10n/ru.arb | 7 +- frontend/app/lib/main.dart | 7 +- frontend/app/pubspec.lock | 16 +++ frontend/app/pubspec.yaml | 1 + frontend/app/test/country_test.dart | 176 ++++++++++++++++++++++++++++ frontend/app/test/kyc_test.dart | 16 ++- 10 files changed, 435 insertions(+), 15 deletions(-) create mode 100644 frontend/app/lib/kyc/country.dart create mode 100644 frontend/app/test/country_test.dart diff --git a/docs/ux/kyc.md b/docs/ux/kyc.md index 5242a70..c3100c5 100644 --- a/docs/ux/kyc.md +++ b/docs/ux/kyc.md @@ -167,3 +167,46 @@ This is a display correction within the existing KYC journey, so no additional analytics events or financial data collection are introduced. Tests cover mapping without inferring remaining, unused/exhausted/over-cap/zero-cap ratios and EN/RU usage display at 320px with 1.6 text scaling. + +## Residence country selection — task #64 + +Task [#64](https://git.sendico.io/tech/sxchnge/issues/64), branch `kyc-64`. +The profile now shows a country name and opens a searchable country list instead +of asking customers to type a two-letter code. Names follow the English/Russian +app locale. Search accepts localized or English names and two-letter codes, +ignoring case and surrounding whitespace. Selection updates only +`IdentityProfile.residenceCountry` with the country code; the existing provider, +service, mapper and transport contracts remain unchanged. + +The `country_picker` dependency supplies bundled country data, code lookup and +translations through `CountryService` and `CountryLocalizations`. Flutter Material +controls provide the search interface, explicit no-results recovery, minimum +touch targets, keyboard selection, a selected-state checkmark, Back and Escape. +The package's built-in list omits explicit no-results feedback and uses shorter +row targets, so its data/localizations are reused with standard Material controls. +There is no remote lookup, copied country table or extra application provider. +Cancelling preserves the current profile, prefilled codes display names, busy +state disables opening the selector, and the search controller is disposed with +its dialog. The list is a data catalogue; backend eligibility remains authoritative. + +This change stays within the existing KYC preparation/submission journey and its +measurement definitions above. No selector events, queries, names or country +codes are added to telemetry. Existing acceptance/failure/retry/abandonment +signals and documented server-side review measurement gaps still apply. + +Focused widget checks cover name/code search, code-only updates, reselection, +cancel, no-results recovery, clearing search, saved values, locale changes, +Russian names, disabled state, keyboard activation/selection/Escape and labeled +touch targets. A long country name fits at 320px with 1.6 text scale. Existing +EN/RU verification journeys now select a country and assert the profile code +before continuing through document submission. Native screen-reader and device +keyboard testing remain release checks; widget semantics checks do not establish +those device outcomes. + +Validation: five changed Dart files passed the format check; `flutter analyze` +in `frontend/app` reported no issues; `make test-frontend` passed all 222 tests; +and `flutter build web` passed, including its Wasm compatibility dry run. Browser +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. diff --git a/frontend/app/lib/kyc/country.dart b/frontend/app/lib/kyc/country.dart new file mode 100644 index 0000000..1fc5ca3 --- /dev/null +++ b/frontend/app/lib/kyc/country.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:country_picker/country_picker.dart'; + +import '../generated/i18n/app_localizations.dart'; + +class CountryField extends StatelessWidget { + const CountryField({ + super.key, + required this.value, + required this.onChanged, + this.enabled = true, + }); + + final String value; + final ValueChanged onChanged; + final bool enabled; + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + final country = CountryService().findByCode(value); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l.kycCountry, style: Theme.of(context).textTheme.labelLarge), + const SizedBox(height: 8), + Semantics( + label: l.kycCountry, + child: OutlinedButton( + onPressed: !enabled + ? null + : () async { + final code = await showDialog( + context: context, + builder: (_) => _CountrySearch(selected: value), + ); + if (context.mounted && code != null) onChanged(code); + }, + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: Row( + children: [ + Expanded( + child: Text( + country == null + ? l.kycCountrySelect + : _name(context, country), + ), + ), + const SizedBox(width: 8), + const Icon(Icons.expand_more), + ], + ), + ), + ), + ], + ); + } +} + +String _name(BuildContext context, Country country) => + country.getTranslatedName(context) ?? country.name; + +class _CountrySearch extends StatefulWidget { + const _CountrySearch({required this.selected}); + + final String selected; + + @override + State<_CountrySearch> createState() => _CountrySearchState(); +} + +class _CountrySearchState extends State<_CountrySearch> { + final _query = TextEditingController(); + final _countries = { + for (final country in CountryService().getAll()) + country.countryCode: country, + }.values.toList(); + + @override + void dispose() { + _query.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + return Focus( + autofocus: true, + onKeyEvent: (_, event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.escape) { + Navigator.of(context).pop(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: Dialog.fullscreen( + child: Scaffold( + appBar: AppBar( + leading: BackButton(onPressed: () => Navigator.of(context).pop()), + title: TextField( + key: const ValueKey('country-search'), + controller: _query, + autofocus: true, + decoration: InputDecoration( + labelText: l.kycCountrySearch, + prefixIcon: const Icon(Icons.search), + ), + onChanged: (_) => setState(() {}), + ), + toolbarHeight: 96, + actions: [ + if (_query.text.isNotEmpty) + IconButton( + tooltip: l.kycCountryClearSearch, + icon: const Icon(Icons.clear), + onPressed: () => setState(_query.clear), + ), + ], + ), + body: _list(context), + ), + ), + ); + } + + Widget _list(BuildContext context) { + final search = _query.text.trim().toLowerCase(); + final countries = _countries.where((country) { + return _name(context, country).toLowerCase().contains(search) || + country.name.toLowerCase().contains(search) || + country.countryCode.toLowerCase() == search; + }).toList()..sort((a, b) => _name(context, a).compareTo(_name(context, b))); + if (countries.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text(AppLocalizations.of(context)!.kycCountryEmpty), + ), + ); + } + return ListView.builder( + itemCount: countries.length, + itemBuilder: (context, index) { + final country = countries[index]; + final isSelected = country.countryCode == widget.selected; + return ListTile( + title: Text(_name(context, country)), + selected: isSelected, + trailing: isSelected ? const Icon(Icons.check) : null, + onTap: () => Navigator.of(context).pop(country.countryCode), + ); + }, + ); + } +} diff --git a/frontend/app/lib/kyc/profile.dart b/frontend/app/lib/kyc/profile.dart index bc0d659..fc28939 100644 --- a/frontend/app/lib/kyc/profile.dart +++ b/frontend/app/lib/kyc/profile.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:sxchnge_kyc/sxchnge_kyc.dart'; +import 'country.dart'; + import '../generated/i18n/app_localizations.dart'; class ProfileForm extends StatelessWidget { @@ -48,17 +50,11 @@ class ProfileForm extends StatelessWidget { onChanged: (value) => update(birth: value.trim()), ), const SizedBox(height: 16), - TextFormField( + CountryField( key: const ValueKey('kyc-country'), - initialValue: p.profile.residenceCountry, + value: p.profile.residenceCountry, enabled: !p.busy, - maxLength: 2, - textCapitalization: TextCapitalization.characters, - decoration: InputDecoration( - labelText: l.kycCountry, - helperText: l.kycCountryHelp, - ), - onChanged: (value) => update(country: value.trim().toUpperCase()), + onChanged: (value) => update(country: value), ), if (!p.profile.isComplete) Text(l.kycProfileRequired), ], diff --git a/frontend/app/lib/l10n/en.arb b/frontend/app/lib/l10n/en.arb index 11fac1d..edfccf6 100644 --- a/frontend/app/lib/l10n/en.arb +++ b/frontend/app/lib/l10n/en.arb @@ -271,8 +271,11 @@ "kycBirth": "Date of birth", "kycBirthHelp": "YYYY-MM-DD, for example 1990-04-23", "kycCountry": "Country of residence", - "kycCountryHelp": "Two-letter country code, for example PL or DE", - "kycProfileRequired": "Enter your full name, a valid date of birth and a two-letter country code to continue.", + "kycCountrySelect": "Select a country", + "kycCountrySearch": "Search countries", + "kycCountryEmpty": "No countries found. Try another name or country code.", + "kycCountryClearSearch": "Clear search", + "kycProfileRequired": "Enter your full name, a valid date of birth and select your country of residence to continue.", "kycContinue": "Continue to my account", "kycOperationMinimum": "Per operation minimum", "kycOperationMaximum": "Per operation maximum", diff --git a/frontend/app/lib/l10n/ru.arb b/frontend/app/lib/l10n/ru.arb index 2535b62..0ec126f 100644 --- a/frontend/app/lib/l10n/ru.arb +++ b/frontend/app/lib/l10n/ru.arb @@ -271,8 +271,11 @@ "kycBirth": "Дата рождения", "kycBirthHelp": "ГГГГ-ММ-ДД, например 1990-04-23", "kycCountry": "Страна проживания", - "kycCountryHelp": "Код страны из двух букв, например PL или DE", - "kycProfileRequired": "Укажите полное имя, корректную дату рождения и код страны из двух букв.", + "kycCountrySelect": "Выберите страну", + "kycCountrySearch": "Поиск страны", + "kycCountryEmpty": "Страны не найдены. Попробуйте другое название или код страны.", + "kycCountryClearSearch": "Очистить поиск", + "kycProfileRequired": "Укажите полное имя, корректную дату рождения и выберите страну проживания.", "kycContinue": "Перейти в личный кабинет", "kycOperationMinimum": "Минимум на операцию", "kycOperationMaximum": "Максимум на операцию", diff --git a/frontend/app/lib/main.dart b/frontend/app/lib/main.dart index 517d50f..2e75147 100644 --- a/frontend/app/lib/main.dart +++ b/frontend/app/lib/main.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_web_plugins/url_strategy.dart'; +import 'package:country_picker/country_picker.dart'; + import 'package:provider/provider.dart'; import 'package:sxchnge_analytics/sxchnge_analytics.dart'; @@ -195,7 +197,10 @@ class _SxchngeAppState extends State { debugShowCheckedModeBanner: false, theme: SxchngeTheme.light(), locale: locale.locale, - localizationsDelegates: AppLocalizations.localizationsDelegates, + localizationsDelegates: const [ + ...AppLocalizations.localizationsDelegates, + CountryLocalizations.delegate, + ], supportedLocales: AppLocalizations.supportedLocales, routerConfig: _router, ), diff --git a/frontend/app/pubspec.lock b/frontend/app/pubspec.lock index 302e955..629743b 100644 --- a/frontend/app/pubspec.lock +++ b/frontend/app/pubspec.lock @@ -49,6 +49,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + country_picker: + dependency: "direct main" + description: + name: country_picker + sha256: "8ef721183bd7e0a6017c413693cdc68051daceef5790f5420c3b4378b5c69115" + url: "https://pub.dev" + source: hosted + version: "2.0.28" cross_file: dependency: transitive description: @@ -525,6 +533,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 + url: "https://pub.dev" + source: hosted + version: "2.3.1" vector_math: dependency: transitive description: diff --git a/frontend/app/pubspec.yaml b/frontend/app/pubspec.yaml index 9a0ccba..592bd5e 100644 --- a/frontend/app/pubspec.yaml +++ b/frontend/app/pubspec.yaml @@ -27,6 +27,7 @@ dependencies: shared_preferences: ^2.5.3 cupertino_icons: ^1.0.8 go_router: ^18.0.1 + country_picker: ^2.0.28 dev_dependencies: flutter_test: sdk: flutter diff --git a/frontend/app/test/country_test.dart b/frontend/app/test/country_test.dart new file mode 100644 index 0000000..aa34b65 --- /dev/null +++ b/frontend/app/test/country_test.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:country_picker/country_picker.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:sxchnge_theme/sxchnge_theme.dart'; + +import 'package:sxchnge_app/kyc/country.dart'; + +import 'package:sxchnge_app/generated/i18n/app_localizations.dart'; + +void main() { + final search = find.byKey(const ValueKey('country-search')); + final button = find.byType(OutlinedButton); + + Future mount( + WidgetTester tester, + ValueNotifier value, { + Locale locale = const Locale('en'), + bool enabled = true, + }) async { + await tester.pumpWidget( + MaterialApp( + theme: SxchngeTheme.light(), + locale: locale, + localizationsDelegates: const [ + ...AppLocalizations.localizationsDelegates, + CountryLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Padding( + padding: const EdgeInsets.all(24), + child: ValueListenableBuilder( + valueListenable: value, + builder: (_, code, _) => CountryField( + value: code, + enabled: enabled, + onChanged: (code) => value.value = code, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + Future open(WidgetTester tester) async { + await tester.tap(button); + await tester.pumpAndSettle(); + } + + testWidgets( + 'name search stores code, reselection and cancel preserve value', + (tester) async { + final value = ValueNotifier(''); + addTearDown(value.dispose); + await mount(tester, value); + expect(find.text('Select a country'), findsOneWidget); + await open(tester); + await tester.enterText(search, 'polAND'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Poland')); + await tester.pumpAndSettle(); + expect(value.value, 'PL'); + expect(find.text('Poland'), findsOneWidget); + expect(find.text('0/2'), findsNothing); + + await open(tester); + await tester.enterText(search, ' de '); + await tester.pumpAndSettle(); + await tester.tap(find.text('Germany')); + await tester.pumpAndSettle(); + expect(value.value, 'DE'); + expect(find.text('Germany'), findsOneWidget); + + await open(tester); + await tester.enterText(search, 'poland'); + await tester.pumpAndSettle(); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(value.value, 'DE'); + expect(find.text('Germany'), findsOneWidget); + }, + ); + + testWidgets('no-results state recovers through clear and Escape cancels', ( + tester, + ) async { + final value = ValueNotifier('PL'); + addTearDown(value.dispose); + await mount(tester, value); + await open(tester); + await tester.enterText(search, 'no-such-country'); + await tester.pumpAndSettle(); + expect(find.textContaining('No countries found'), findsOneWidget); + expect(find.byType(ListTile), findsNothing); + expect(value.value, 'PL'); + await tester.tap(find.byTooltip('Clear search')); + await tester.pumpAndSettle(); + expect(find.textContaining('No countries found'), findsNothing); + expect(find.byType(ListTile), findsWidgets); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(search, findsNothing); + expect(value.value, 'PL'); + }); + + testWidgets('saved code follows locale and Russian names are searchable', ( + tester, + ) async { + final value = ValueNotifier('PL'); + addTearDown(value.dispose); + await mount(tester, value); + expect(find.text('Poland'), findsOneWidget); + await mount(tester, value, locale: const Locale('ru')); + expect(find.text('Польша'), findsOneWidget); + expect(value.value, 'PL'); + await open(tester); + await tester.enterText(search, 'Германия'); + await tester.pumpAndSettle(); + await tester.tap(find.byType(ListTile)); + await tester.pumpAndSettle(); + expect(value.value, 'DE'); + expect(find.text('Германия'), findsOneWidget); + }); + + testWidgets('busy field cannot open or change selection', (tester) async { + final value = ValueNotifier('PL'); + addTearDown(value.dispose); + await mount(tester, value, enabled: false); + expect(tester.widget(button).onPressed, isNull); + await tester.tap(button); + await tester.pumpAndSettle(); + expect(search, findsNothing); + expect(value.value, 'PL'); + }); + + testWidgets( + 'keyboard and labeled touch targets work at 320px with large text', + (tester) async { + tester.view.physicalSize = const Size(320, 900); + tester.view.devicePixelRatio = 1; + tester.platformDispatcher.textScaleFactorTestValue = 1.6; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + final semantics = tester.ensureSemantics(); + final value = ValueNotifier('GS'); + addTearDown(value.dispose); + await mount(tester, value); + expect(tester, meetsGuideline(labeledTapTargetGuideline)); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(search, findsOneWidget); + await tester.enterText(search, 'GS'); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(ListTile)).selected, isTrue); + expect(tester, meetsGuideline(labeledTapTargetGuideline)); + expect(tester, meetsGuideline(androidTapTargetGuideline)); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(search, findsNothing); + expect(value.value, 'GS'); + expect(tester.takeException(), isNull); + semantics.dispose(); + }, + ); +} diff --git a/frontend/app/test/kyc_test.dart b/frontend/app/test/kyc_test.dart index 88dbb56..e59275c 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/country.dart'; import 'package:sxchnge_app/main.dart'; import 'package:sxchnge_app/providers/locale.dart'; @@ -69,7 +70,20 @@ void main() { 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('kyc-country')), 'PL'); + await tap( + t, + find.descendant( + of: find.byKey(const ValueKey('kyc-country')), + matching: find.byType(OutlinedButton), + ), + ); + await t.enterText(find.byKey(const ValueKey('country-search')), 'PL'); + await t.pumpAndSettle(); + await tap(t, find.byType(ListTile)); + expect( + t.widget(find.byKey(const ValueKey('kyc-country'))).value, + 'PL', + ); await t.pumpAndSettle(); await tap(t, find.byKey(const ValueKey('pick-passport'))); await tap(t, find.byKey(const ValueKey('kyc-next')));