Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b22f6ce12 | ||
|
|
b5c4772168 |
No files matched your search
@@ -302,6 +302,33 @@ that contradicts displayed balances or usage without explaining the fixtures.
|
|||||||
- Keep unrelated accounts/history accessible during a pending operation. Do not
|
- Keep unrelated accounts/history accessible during a pending operation. Do not
|
||||||
block the whole product behind an exchange spinner.
|
block the whole product behind an exchange spinner.
|
||||||
|
|
||||||
|
## Empty currency accounts (task #66)
|
||||||
|
|
||||||
|
The shared empty-account message used on Accounts and transfer screens explains
|
||||||
|
that no currency accounts have been set up and directs the customer to support
|
||||||
|
to request setup. English and Russian no longer suggest that refreshing creates
|
||||||
|
accounts or imply that automatic provisioning is underway. This follows the
|
||||||
|
existing support guidance for missing funding and payout setup. The application
|
||||||
|
has no configured support contact channel; the message therefore provides text
|
||||||
|
guidance without a nonfunctional contact button or invented contact details.
|
||||||
|
|
||||||
|
UX review: the copy names the missing resource and the next step, retains the
|
||||||
|
existing wrapping text and accessible reading order, and does not alter loading,
|
||||||
|
error or populated-state behavior. Existing manual refresh remains available for
|
||||||
|
accounts provisioned externally. This copy-only change adds no controls or
|
||||||
|
journey outcomes and requires no new analytics events. Existing transaction
|
||||||
|
journey signals and their production measurement limitations below still apply;
|
||||||
|
support resolution and external account provisioning are not measured in-app.
|
||||||
|
|
||||||
|
Validation: `flutter gen-l10n`, `flutter analyze` in `frontend/app`,
|
||||||
|
`make test-frontend` (217 tests), and `git diff --check` passed. No Dart source
|
||||||
|
changed, so Dart formatting was not applicable. Existing responsive workspace
|
||||||
|
tests cover Russian at 320px with enlarged text; this copy-only change does not
|
||||||
|
add a dedicated empty-state rendering or native screen-reader test. No security
|
||||||
|
scan was requested. Delivery: [task #66](https://git.sendico.io/tech/sxchnge/issues/66),
|
||||||
|
branch `wallet-66`, [PR #68](https://git.sendico.io/tech/sxchnge/pulls/68).
|
||||||
|
Merge and deployment remain separate.
|
||||||
|
|
||||||
## Journey measurement plan
|
## Journey measurement plan
|
||||||
|
|
||||||
Reuse the typed analytics abstraction and maintain a separate journey per active
|
Reuse the typed analytics abstraction and maintain a separate journey per active
|
||||||
|
|||||||
@@ -167,95 +167,3 @@ This is a display correction within the existing KYC journey, so no additional
|
|||||||
analytics events or financial data collection are introduced. Tests cover mapping
|
analytics events or financial data collection are introduced. Tests cover mapping
|
||||||
without inferring remaining, unused/exhausted/over-cap/zero-cap ratios and EN/RU
|
without inferring remaining, unused/exhausted/over-cap/zero-cap ratios and EN/RU
|
||||||
usage display at 320px with 1.6 text scaling.
|
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`.
|
|
||||||
Delivery: [PR #65](https://git.sendico.io/tech/sxchnge/pulls/65).
|
|
||||||
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.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
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<String> onChanged;
|
|
||||||
final bool enabled;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<BirthField> createState() => _BirthFieldState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _BirthFieldState extends State<BirthField> {
|
|
||||||
final _day = TextEditingController();
|
|
||||||
final _year = TextEditingController();
|
|
||||||
final _monthField = GlobalKey<FormFieldState<int>>();
|
|
||||||
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<int>(
|
|
||||||
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(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
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<String> 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<String>(
|
|
||||||
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),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,9 +2,6 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
import 'birth.dart';
|
|
||||||
import 'country.dart';
|
|
||||||
|
|
||||||
import '../generated/i18n/app_localizations.dart';
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
class ProfileForm extends StatelessWidget {
|
class ProfileForm extends StatelessWidget {
|
||||||
@@ -38,18 +35,30 @@ class ProfileForm extends StatelessWidget {
|
|||||||
onChanged: (value) => update(name: value),
|
onChanged: (value) => update(name: value),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
BirthField(
|
TextFormField(
|
||||||
key: const ValueKey('kyc-birth'),
|
key: const ValueKey('kyc-birth'),
|
||||||
value: p.profile.dateOfBirth,
|
initialValue: p.profile.dateOfBirth,
|
||||||
enabled: !p.busy,
|
enabled: !p.busy,
|
||||||
onChanged: (value) => update(birth: value),
|
keyboardType: TextInputType.datetime,
|
||||||
|
autofillHints: const [AutofillHints.birthday],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l.kycBirth,
|
||||||
|
helperText: l.kycBirthHelp,
|
||||||
|
),
|
||||||
|
onChanged: (value) => update(birth: value.trim()),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
CountryField(
|
TextFormField(
|
||||||
key: const ValueKey('kyc-country'),
|
key: const ValueKey('kyc-country'),
|
||||||
value: p.profile.residenceCountry,
|
initialValue: p.profile.residenceCountry,
|
||||||
enabled: !p.busy,
|
enabled: !p.busy,
|
||||||
onChanged: (value) => update(country: value),
|
maxLength: 2,
|
||||||
|
textCapitalization: TextCapitalization.characters,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l.kycCountry,
|
||||||
|
helperText: l.kycCountryHelp,
|
||||||
|
),
|
||||||
|
onChanged: (value) => update(country: value.trim().toUpperCase()),
|
||||||
),
|
),
|
||||||
if (!p.profile.isComplete) Text(l.kycProfileRequired),
|
if (!p.profile.isComplete) Text(l.kycProfileRequired),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -160,7 +160,7 @@
|
|||||||
"wRecent": "Recent activity",
|
"wRecent": "Recent activity",
|
||||||
"wSeeAll": "See all",
|
"wSeeAll": "See all",
|
||||||
"wEmptyActivity": "Your next move starts here. Your transactions will appear below.",
|
"wEmptyActivity": "Your next move starts here. Your transactions will appear below.",
|
||||||
"wEmptyAccounts": "Your accounts are not ready yet. Try refreshing.",
|
"wEmptyAccounts": "No currency accounts have been set up for you yet. Contact support to request account setup.",
|
||||||
"wRefresh": "Refresh",
|
"wRefresh": "Refresh",
|
||||||
"wSource": "From account",
|
"wSource": "From account",
|
||||||
"wDestination": "To account",
|
"wDestination": "To account",
|
||||||
@@ -269,17 +269,10 @@
|
|||||||
"kycProfileHelp": "Enter your details exactly as shown on your identity document.",
|
"kycProfileHelp": "Enter your details exactly as shown on your identity document.",
|
||||||
"kycFullName": "Full legal name",
|
"kycFullName": "Full legal name",
|
||||||
"kycBirth": "Date of birth",
|
"kycBirth": "Date of birth",
|
||||||
"kycBirthDay": "Day",
|
"kycBirthHelp": "YYYY-MM-DD, for example 1990-04-23",
|
||||||
"kycBirthMonth": "Month",
|
|
||||||
"kycBirthYear": "Year",
|
|
||||||
"kycBirthInvalid": "Enter a valid date of birth.",
|
|
||||||
"kycBirthFuture": "Date of birth cannot be in the future.",
|
|
||||||
"kycCountry": "Country of residence",
|
"kycCountry": "Country of residence",
|
||||||
"kycCountrySelect": "Select a country",
|
"kycCountryHelp": "Two-letter country code, for example PL or DE",
|
||||||
"kycCountrySearch": "Search countries",
|
"kycProfileRequired": "Enter your full name, a valid date of birth and a two-letter country code to continue.",
|
||||||
"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",
|
"kycContinue": "Continue to my account",
|
||||||
"kycOperationMinimum": "Per operation minimum",
|
"kycOperationMinimum": "Per operation minimum",
|
||||||
"kycOperationMaximum": "Per operation maximum",
|
"kycOperationMaximum": "Per operation maximum",
|
||||||
|
|||||||
@@ -160,7 +160,7 @@
|
|||||||
"wRecent": "Последние операции",
|
"wRecent": "Последние операции",
|
||||||
"wSeeAll": "Смотреть все",
|
"wSeeAll": "Смотреть все",
|
||||||
"wEmptyActivity": "Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.",
|
"wEmptyActivity": "Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.",
|
||||||
"wEmptyAccounts": "Ваши счета ещё не готовы. Попробуйте обновить страницу.",
|
"wEmptyAccounts": "У вас ещё нет валютных счетов. Обратитесь в поддержку за помощью с их открытием.",
|
||||||
"wRefresh": "Обновить",
|
"wRefresh": "Обновить",
|
||||||
"wSource": "Со счёта",
|
"wSource": "Со счёта",
|
||||||
"wDestination": "На счёт",
|
"wDestination": "На счёт",
|
||||||
@@ -269,17 +269,10 @@
|
|||||||
"kycProfileHelp": "Укажите данные точно как в документе, удостоверяющем личность.",
|
"kycProfileHelp": "Укажите данные точно как в документе, удостоверяющем личность.",
|
||||||
"kycFullName": "Полное имя по документу",
|
"kycFullName": "Полное имя по документу",
|
||||||
"kycBirth": "Дата рождения",
|
"kycBirth": "Дата рождения",
|
||||||
"kycBirthDay": "День",
|
"kycBirthHelp": "ГГГГ-ММ-ДД, например 1990-04-23",
|
||||||
"kycBirthMonth": "Месяц",
|
|
||||||
"kycBirthYear": "Год",
|
|
||||||
"kycBirthInvalid": "Укажите корректную дату рождения.",
|
|
||||||
"kycBirthFuture": "Дата рождения не может быть в будущем.",
|
|
||||||
"kycCountry": "Страна проживания",
|
"kycCountry": "Страна проживания",
|
||||||
"kycCountrySelect": "Выберите страну",
|
"kycCountryHelp": "Код страны из двух букв, например PL или DE",
|
||||||
"kycCountrySearch": "Поиск страны",
|
"kycProfileRequired": "Укажите полное имя, корректную дату рождения и код страны из двух букв.",
|
||||||
"kycCountryEmpty": "Страны не найдены. Попробуйте другое название или код страны.",
|
|
||||||
"kycCountryClearSearch": "Очистить поиск",
|
|
||||||
"kycProfileRequired": "Укажите полное имя, корректную дату рождения и выберите страну проживания.",
|
|
||||||
"kycContinue": "Перейти в личный кабинет",
|
"kycContinue": "Перейти в личный кабинет",
|
||||||
"kycOperationMinimum": "Минимум на операцию",
|
"kycOperationMinimum": "Минимум на операцию",
|
||||||
"kycOperationMaximum": "Максимум на операцию",
|
"kycOperationMaximum": "Максимум на операцию",
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'package:flutter_web_plugins/url_strategy.dart';
|
import 'package:flutter_web_plugins/url_strategy.dart';
|
||||||
|
|
||||||
import 'package:country_picker/country_picker.dart';
|
|
||||||
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||||
@@ -197,10 +195,7 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
|||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: SxchngeTheme.light(),
|
theme: SxchngeTheme.light(),
|
||||||
locale: locale.locale,
|
locale: locale.locale,
|
||||||
localizationsDelegates: const [
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
...AppLocalizations.localizationsDelegates,
|
|
||||||
CountryLocalizations.delegate,
|
|
||||||
],
|
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
routerConfig: _router,
|
routerConfig: _router,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -49,14 +49,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
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:
|
cross_file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -533,14 +525,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
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:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ dependencies:
|
|||||||
shared_preferences: ^2.5.3
|
shared_preferences: ^2.5.3
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
go_router: ^18.0.1
|
go_router: ^18.0.1
|
||||||
country_picker: ^2.0.28
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|||||||
@@ -1,236 +0,0 @@
|
|||||||
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<int>);
|
|
||||||
|
|
||||||
Future<void> mount(
|
|
||||||
WidgetTester t,
|
|
||||||
ValueNotifier<String> 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<String>(
|
|
||||||
valueListenable: value,
|
|
||||||
builder: (_, date, _) => BirthField(
|
|
||||||
value: date,
|
|
||||||
enabled: enabled,
|
|
||||||
onChanged: (date) => value.value = date,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await t.pumpAndSettle();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> 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<TextFormField>(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<TextFormField>(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<TextFormField>(year).controller!.text, '1990');
|
|
||||||
value.value = '1985-12-09';
|
|
||||||
await t.pumpAndSettle();
|
|
||||||
expect(t.widget<TextFormField>(day).controller!.text, '09');
|
|
||||||
expect(t.widget<TextFormField>(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<TextFormField>(day).controller!.text, '');
|
|
||||||
expect(t.widget<TextFormField>(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<TextFormField>(day).controller!.text, '12');
|
|
||||||
expect(t.widget<TextFormField>(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<TextFormField>(day).enabled, isFalse);
|
|
||||||
expect(t.widget<TextFormField>(year).enabled, isFalse);
|
|
||||||
expect(t.widget<DropdownButtonFormField<int>>(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<EditableText>(
|
|
||||||
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<EditableText>(
|
|
||||||
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();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
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<void> mount(
|
|
||||||
WidgetTester tester,
|
|
||||||
ValueNotifier<String> 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<String>(
|
|
||||||
valueListenable: value,
|
|
||||||
builder: (_, code, _) => CountryField(
|
|
||||||
value: code,
|
|
||||||
enabled: enabled,
|
|
||||||
onChanged: (code) => value.value = code,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> 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<OutlinedButton>(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<ListTile>(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();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -11,8 +11,6 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
|||||||
|
|
||||||
import 'package:sxchnge_kyc/sxchnge_kyc.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/main.dart';
|
||||||
import 'package:sxchnge_app/providers/locale.dart';
|
import 'package:sxchnge_app/providers/locale.dart';
|
||||||
|
|
||||||
@@ -67,35 +65,12 @@ void main() {
|
|||||||
return service;
|
return service;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> profile(WidgetTester t) async {
|
Future<void> submit(WidgetTester t) async {
|
||||||
await tap(t, find.byKey(const ValueKey('kyc-start')));
|
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-name')), 'Example Person');
|
||||||
await t.enterText(find.byKey(const ValueKey('birth-day')), '23');
|
await t.enterText(find.byKey(const ValueKey('kyc-birth')), '1990-04-23');
|
||||||
await tap(t, find.byType(DropdownButtonFormField<int>));
|
await t.enterText(find.byKey(const ValueKey('kyc-country')), 'PL');
|
||||||
// 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();
|
await t.pumpAndSettle();
|
||||||
expect(t.widget<BirthField>(find.byType(BirthField)).value, '1990-04-23');
|
|
||||||
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<CountryField>(find.byKey(const ValueKey('kyc-country'))).value,
|
|
||||||
'PL',
|
|
||||||
);
|
|
||||||
await t.pumpAndSettle();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> submit(WidgetTester t) async {
|
|
||||||
await profile(t);
|
|
||||||
await tap(t, find.byKey(const ValueKey('pick-passport')));
|
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('kyc-next')));
|
||||||
await tap(t, find.byKey(const ValueKey('pick-selfie')));
|
await tap(t, find.byKey(const ValueKey('pick-selfie')));
|
||||||
@@ -104,41 +79,6 @@ void main() {
|
|||||||
await tap(t, find.byKey(const ValueKey('kyc-submit')));
|
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<FilledButton>(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<FilledButton>(next).onPressed, isNotNull);
|
|
||||||
await tap(t, next);
|
|
||||||
await tap(t, find.text('Back'));
|
|
||||||
expect(t.widget<BirthField>(find.byType(BirthField)).value, '1990-04-23');
|
|
||||||
expect(
|
|
||||||
t
|
|
||||||
.widget<TextFormField>(find.byKey(const ValueKey('birth-day')))
|
|
||||||
.controller!
|
|
||||||
.text,
|
|
||||||
'23',
|
|
||||||
);
|
|
||||||
expect(find.text('April'), findsOneWidget);
|
|
||||||
expect(
|
|
||||||
t
|
|
||||||
.widget<TextFormField>(find.byKey(const ValueKey('birth-year')))
|
|
||||||
.controller!
|
|
||||||
.text,
|
|
||||||
'1990',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
testWidgets('two photos, review, pending and approval with service limits', (
|
testWidgets('two photos, review, pending and approval with service limits', (
|
||||||
t,
|
t,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
Reference in new issue
Block a user