Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe60b7ac1a | ||
|
|
b644a10663 | ||
|
|
c106706615 | ||
|
|
915b8e8123 | ||
|
|
ec4115d083 | ||
|
|
f3ff7fc016 | ||
|
|
658d715c8a | ||
|
|
288a2f0c02 | ||
|
|
4605a197ad | ||
|
|
464bd2d565 | ||
|
|
f1ff40d072 | ||
|
|
417e6d96b6 | ||
|
|
c8ef9c3d14 | ||
|
|
3af60f38b1 |
No files matched your search
@@ -296,3 +296,66 @@ real token was checked read-only and remains unused and unexpired, with its
|
||||
account pending verification, ready for the user to reopen the link. PR #61 has
|
||||
not been merged into remote main; this local deployment does not imply a main
|
||||
branch merge.
|
||||
|
||||
### Accepted signup delivery (#62)
|
||||
|
||||
Task [#62](https://git.sendico.io/tech/sxchnge/issues/62), branch
|
||||
`email-verification-62`, delivered in
|
||||
[PR #63](https://git.sendico.io/tech/sxchnge/pulls/63). On October 23, signup
|
||||
returned HTTP 202 and the local
|
||||
SMTP provider accepted its message with status 250, while the user reported an
|
||||
internal-error screen immediately after Create account. No subsequent
|
||||
verification request from that attempt reached the BFF. The precise original
|
||||
client exception was not captured; an ordinary isolated release reproduction
|
||||
succeeded. The regression below deliberately injects an unavailable follow-up
|
||||
send service to cover the post-acceptance failure path without claiming that
|
||||
injected exception was the original cause.
|
||||
|
||||
Backend and preview signup mappers now return a `VerificationDelivery` domain
|
||||
receipt with the recipient and simulation status. It means that signup accepted
|
||||
the email-queue operation, not inbox delivery, account activation or a session.
|
||||
The signup provider exposes it through its operation result. The form captures
|
||||
the receipt before clearing password controllers, whose change listeners reset
|
||||
auth state, and passes it as transient router extra. The verification provider
|
||||
initializes directly in the sent state; mounting the page performs no send.
|
||||
This replaces the backend adapter's hidden one-use signup cache. Every explicit
|
||||
resend now invokes its service, including the first one after signup.
|
||||
|
||||
Routes without a receipt retain their existing behavior: unverified login may
|
||||
request an email, a fresh verification page asks for an address, and emailed
|
||||
links run confirmation. Recipient changes invalidate the displayed claim until
|
||||
another send succeeds. New receipts replace stale recipients. The route codec
|
||||
discards the receipt when serializing history, so reloads do not invent a queued
|
||||
result or retain the address. No token or password is included in the receipt.
|
||||
|
||||
The existing email-delivery requested/succeeded signals now follow the accepted
|
||||
signup receipt inside the instrumented service instead of a synthetic send on
|
||||
page mount. They stay in the signup journey, occur once for the accepted
|
||||
operation, and remain intermediate queue-acceptance signals. Resends retain
|
||||
separate attempts and failures; confirmation remains the terminal outcome.
|
||||
Simulation and privacy rules are unchanged, including the in-memory analytics
|
||||
and cross-device correlation limitations above.
|
||||
|
||||
Frontend review covered the complete app/auth contribution and preserved the
|
||||
DTO, mapper, service, domain model, provider and UI boundaries. Regressions cover
|
||||
accepted signup followed by unavailable resend, explicit retry, normalized
|
||||
receipt mapping, provider initialization/reset, recipient replacement, transient
|
||||
history, event correlation and exclusion of form values from analytics. EN/RU
|
||||
flow tests run at 390px with 1.5x text scaling; existing keyboard, autofill and
|
||||
accessibility suites also pass. Changed-file Dart format checks, analysis in
|
||||
`frontend/app` and `frontend/packages/sxchnge_auth`, and `make test-frontend`
|
||||
(221 tests) pass. The new injected-failure regression failed on the previous
|
||||
implementation and passes with the receipt handoff. A release-browser fixture
|
||||
with the same backend adapter and a failing resend service shows Check your
|
||||
inbox after signup with no send call, then the expected error only after an
|
||||
explicit resend. It sends no email. Native Safari was not automated.
|
||||
|
||||
The frontend image was rebuilt with freshly resolved stable digest pins and
|
||||
recreated without restarting BFF. The served JavaScript matches the new image
|
||||
at SHA-256 `0a45ce6f0b4658b1f03d8e39b9e39fd3fbfcf6f1626cb3f17705dce2da130a6c`.
|
||||
Normal browser-trust HTTPS serves the frontend and API with HTTP 200.
|
||||
`sh infra/local/tools.sh verify` passes all seven services, Vault/database
|
||||
checks and authenticated Sendico currency/ledger reads. No new customer email,
|
||||
activation or payment was submitted for this change, and no security scan was
|
||||
requested or run. The user's pre-existing browser tab must reload to use the
|
||||
new bundle; their original Safari exception remains unobserved.
|
||||
@@ -167,3 +167,95 @@ 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`.
|
||||
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.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Identity photo preview correction
|
||||
|
||||
Task [#69](https://git.sendico.io/tech/sxchnge/issues/69), branch `kyc-69`.
|
||||
Delivery: [PR #71](https://git.sendico.io/tech/sxchnge/pulls/71).
|
||||
This frontend change builds on the [identity verification journey](kyc.md)
|
||||
and is independent of the birthday controls in task #67.
|
||||
|
||||
## Cause and correction
|
||||
|
||||
A synthetic valid PNG reproduced this Flutter 3.47.7 browser error:
|
||||
|
||||
```text
|
||||
TypeError: Instance of '_UnmodifiableUint8ListView': type
|
||||
'_UnmodifiableUint8ListView' is not a subtype of type 'NativeUint8List'
|
||||
```
|
||||
|
||||
The stack reached `Uint8ListToJSUint8Array.toJS` from CanvasKit's browser
|
||||
image decoder through `MemoryImage` and `ResizeImage`. File selection had
|
||||
successfully decoded the original typed bytes. The `Photo` constructor then
|
||||
copied them into an unmodifiable view, which was passed directly into the
|
||||
preview's web decoder. Native decoding accepted that view, hiding the bug in
|
||||
native-only tests. The generic image error builder presented the failure as a
|
||||
damaged document even though the selected bytes were valid.
|
||||
|
||||
`PhotoPreview` now creates a native `Uint8List` copy for `MemoryImage` once per
|
||||
selected photo. The shared domain model stays immutable, and validation,
|
||||
providers, services and transport contracts remain unchanged. The preview still
|
||||
requests a decode width of at most 1000 pixels and evicts its decoded cache entry
|
||||
on replacement or disposal. The extra encoded-byte copy belongs to the preview
|
||||
and is released with its provider/cache references; no secure-erasure guarantee
|
||||
is made.
|
||||
|
||||
An actual preview failure now says that the preview could not be displayed and
|
||||
suggests selecting the photo again. The English/Russian fallback keeps at least
|
||||
the preview's usual height, can grow for long text, and has a separate live-region
|
||||
semantic node. Existing replacement/removal controls stay available and selected
|
||||
input is retained. This is distinct from validation's corrupt-file message.
|
||||
|
||||
## UX and measurement review
|
||||
|
||||
The outcome remains selecting readable ID and selfie photos, checking both on
|
||||
review, and submitting for confirmed service acceptance. Existing KYC journey
|
||||
attempts, retries, failure, submission and abandonment signals are reused. No
|
||||
control-level events, image bytes, filenames, form values or raw exceptions are
|
||||
added to telemetry. A selection success measures validation; it does not establish
|
||||
that pixels were painted. Preview-only rendering failures are not separately
|
||||
counted by existing telemetry. Server review/approval measurement and native
|
||||
screen-reader QA retain the limitations documented in the original journey.
|
||||
|
||||
Regression tests use synthetic JPEG/PNG content only. They start and await real
|
||||
image decoding outside the widget test's fake async zone, inspect the actual
|
||||
rendered images, and surface stream errors so an error fallback cannot conceal a
|
||||
failure. Checks cover portrait/landscape decoding, model immutability, bounded
|
||||
decode width, stable provider reuse, replacement/removal cache eviction, both
|
||||
review slots, returning to review, localized fallback/replacement, a 320px view
|
||||
at 1.6 text scale, and error announcements isolated from surrounding instructions.
|
||||
The `image` package is already a KYC runtime dependency; its direct app test
|
||||
dependency is used only to create synthetic fixtures in memory.
|
||||
|
||||
## Validation and delivery
|
||||
|
||||
The original browser regression failed before the fix while its native equivalent
|
||||
passed. The complete task diff was reviewed against frontend architecture, UX,
|
||||
measurement, lifecycle and privacy requirements. Final validation passed:
|
||||
|
||||
- In `frontend/app`, `dart format --output=none --set-exit-if-changed
|
||||
lib/kyc/preview.dart test/preview_test.dart`: two files, no changes.
|
||||
- In `frontend/app`, `flutter analyze --no-pub`: no issues.
|
||||
- In `frontend/app`, `flutter test --platform chrome test/preview_test.dart
|
||||
--reporter expanded --no-pub --verbose`: all six regression tests passed.
|
||||
- At the repository root, `make test-frontend`: all 228 tests passed across
|
||||
the shared packages, theme example and app, including the six native preview
|
||||
regressions and existing KYC journey/analytics tests.
|
||||
- In `frontend/app`, `flutter build web --no-pub`: release build and Wasm
|
||||
compatibility dry run succeeded.
|
||||
|
||||
Browser regression coverage uses Chrome; Safari and native assistive-technology
|
||||
testing remain release checks. Generated localization and build code stays
|
||||
ignored; the tracked app lockfile changes only `image` from transitive to direct
|
||||
test dependency. The separate birthday worktree was preserved.
|
||||
|
||||
## Follow-up contribution review
|
||||
|
||||
The complete seven-file contribution was reviewed again before the authorized
|
||||
merge and frontend rollout. No additional defects were found in the byte-copy
|
||||
boundary, provider reuse, cache disposal, error semantics/localization, privacy,
|
||||
architecture or regression coverage. No source changes were needed.
|
||||
|
||||
Current `main` (`ec4115d`, including the birthday, signup-delivery and account
|
||||
setup changes) was merged into `kyc-69` before final verification. The two-file
|
||||
format check and app analyzer passed; all six Chrome preview regressions and
|
||||
all 240 tests in `make test-frontend` passed on the combined code. The original
|
||||
228-test result above describes the earlier isolated branch.
|
||||
|
||||
The authorized rollout rebuilds only `frontend` from clean, pulled `main`, using
|
||||
the repository's fresh stable-release resolver and digest-pinned local inputs.
|
||||
Runtime image, HTTPS and served-bundle verification are recorded in task #69
|
||||
after the rollout; generated local pins remain ignored.
|
||||
@@ -99,10 +99,11 @@ class _AuthFormState extends State<AuthForm> {
|
||||
return;
|
||||
}
|
||||
final email = _email.text.trim();
|
||||
final delivery = signup.state.result!.delivery;
|
||||
TextInput.finishAutofillContext();
|
||||
_password.clear();
|
||||
_confirmation.clear();
|
||||
context.goNamed(AppRoutes.verificationSent, extra: email);
|
||||
context.goNamed(AppRoutes.verificationSent, extra: delivery ?? email);
|
||||
} else {
|
||||
final login = context.read<LoginProvider>();
|
||||
await login.login(
|
||||
|
||||
@@ -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<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(),
|
||||
);
|
||||
}
|
||||
@@ -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<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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
@@ -15,8 +17,12 @@ class PhotoPreview extends StatefulWidget {
|
||||
|
||||
class _PhotoPreviewState extends State<PhotoPreview> {
|
||||
late ImageProvider _image = _provider();
|
||||
ImageProvider _provider() =>
|
||||
ResizeImage.resizeIfNeeded(1000, null, MemoryImage(widget.photo.bytes));
|
||||
ImageProvider _provider() => ResizeImage.resizeIfNeeded(
|
||||
1000,
|
||||
null,
|
||||
// Web decoders require a native typed array, not the model's read-only view.
|
||||
MemoryImage(Uint8List.fromList(widget.photo.bytes)),
|
||||
);
|
||||
@override
|
||||
void didUpdateWidget(covariant PhotoPreview oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
@@ -38,7 +44,15 @@ class _PhotoPreviewState extends State<PhotoPreview> {
|
||||
height: 220,
|
||||
fit: BoxFit.contain,
|
||||
semanticLabel: widget.label,
|
||||
errorBuilder: (_, _, _) =>
|
||||
Text(AppLocalizations.of(context)!.kycErrorUnreadable),
|
||||
errorBuilder: (_, _, _) => ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 220),
|
||||
child: Center(
|
||||
child: Semantics(
|
||||
container: true,
|
||||
liveRegion: true,
|
||||
child: Text(AppLocalizations.of(context)!.kycErrorPreview),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
import 'birth.dart';
|
||||
import 'country.dart';
|
||||
|
||||
import '../generated/i18n/app_localizations.dart';
|
||||
|
||||
class ProfileForm extends StatelessWidget {
|
||||
@@ -35,30 +38,18 @@ 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),
|
||||
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),
|
||||
],
|
||||
|
||||
@@ -128,6 +128,7 @@
|
||||
"kycErrorFormat": "Choose a JPEG or PNG image. Other formats are not supported.",
|
||||
"kycErrorLarge": "This image is too large. Choose one under 10 MB and 40 megapixels.",
|
||||
"kycErrorUnreadable": "This image is damaged or cannot be read. Choose another photo.",
|
||||
"kycErrorPreview": "The photo preview could not be displayed. Try choosing the photo again.",
|
||||
"kycErrorSmall": "The photo is too small. Use an image at least 600 pixels wide and high.",
|
||||
"kycErrorIncomplete": "Choose both photos and confirm they belong to you before submitting.",
|
||||
"kycErrorSession": "Your session has expired. Return to login, then check your verification status.",
|
||||
@@ -269,10 +270,17 @@
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -128,6 +128,7 @@
|
||||
"kycErrorFormat": "Выберите изображение JPEG или PNG. Другие форматы не поддерживаются.",
|
||||
"kycErrorLarge": "Изображение слишком большое. Выберите файл до 10 МБ и 40 мегапикселей.",
|
||||
"kycErrorUnreadable": "Изображение повреждено или не читается. Выберите другое фото.",
|
||||
"kycErrorPreview": "Не удалось показать фото. Попробуйте выбрать его ещё раз.",
|
||||
"kycErrorSmall": "Фото слишком маленькое. Ширина и высота должны быть не менее 600 пикселей.",
|
||||
"kycErrorIncomplete": "Выберите оба фото и подтвердите, что на них вы и ваш документ.",
|
||||
"kycErrorSession": "Сессия истекла. Войдите снова и проверьте статус верификации.",
|
||||
@@ -269,10 +270,17 @@
|
||||
"kycProfileHelp": "Укажите данные точно как в документе, удостоверяющем личность.",
|
||||
"kycFullName": "Полное имя по документу",
|
||||
"kycBirth": "Дата рождения",
|
||||
"kycBirthHelp": "ГГГГ-ММ-ДД, например 1990-04-23",
|
||||
"kycBirthDay": "День",
|
||||
"kycBirthMonth": "Месяц",
|
||||
"kycBirthYear": "Год",
|
||||
"kycBirthInvalid": "Укажите корректную дату рождения.",
|
||||
"kycBirthFuture": "Дата рождения не может быть в будущем.",
|
||||
"kycCountry": "Страна проживания",
|
||||
"kycCountryHelp": "Код страны из двух букв, например PL или DE",
|
||||
"kycProfileRequired": "Укажите полное имя, корректную дату рождения и код страны из двух букв.",
|
||||
"kycCountrySelect": "Выберите страну",
|
||||
"kycCountrySearch": "Поиск страны",
|
||||
"kycCountryEmpty": "Страны не найдены. Попробуйте другое название или код страны.",
|
||||
"kycCountryClearSearch": "Очистить поиск",
|
||||
"kycProfileRequired": "Укажите полное имя, корректную дату рождения и выберите страну проживания.",
|
||||
"kycContinue": "Перейти в личный кабинет",
|
||||
"kycOperationMinimum": "Минимум на операцию",
|
||||
"kycOperationMaximum": "Максимум на операцию",
|
||||
|
||||
@@ -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<SxchngeApp> {
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: SxchngeTheme.light(),
|
||||
locale: locale.locale,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
localizationsDelegates: const [
|
||||
...AppLocalizations.localizationsDelegates,
|
||||
CountryLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
routerConfig: _router,
|
||||
),
|
||||
|
||||
@@ -4,6 +4,8 @@ import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
|
||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
|
||||
import '../auth/page.dart';
|
||||
@@ -144,6 +146,9 @@ GoRouter createAppRouter({bool Function()? authenticated}) {
|
||||
name: AppRoutes.verificationSent,
|
||||
builder: (context, state) => VerificationPage(
|
||||
email: state.extra is String ? state.extra as String : null,
|
||||
delivery: state.extra is VerificationDelivery
|
||||
? state.extra as VerificationDelivery
|
||||
: null,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
|
||||
@@ -15,9 +15,10 @@ import '../routing/routes.dart';
|
||||
import '../generated/i18n/app_localizations.dart';
|
||||
|
||||
class VerificationPage extends StatefulWidget {
|
||||
const VerificationPage({super.key, this.email, this.token});
|
||||
const VerificationPage({super.key, this.email, this.token, this.delivery});
|
||||
final String? email;
|
||||
final String? token;
|
||||
final VerificationDelivery? delivery;
|
||||
|
||||
@override
|
||||
State<VerificationPage> createState() => _VerificationPageState();
|
||||
@@ -25,9 +26,12 @@ class VerificationPage extends StatefulWidget {
|
||||
|
||||
class _VerificationPageState extends State<VerificationPage> {
|
||||
final _form = GlobalKey<FormState>();
|
||||
late final _email = TextEditingController(text: widget.email);
|
||||
late final _email = TextEditingController(
|
||||
text: widget.delivery?.email ?? widget.email,
|
||||
);
|
||||
late final _provider = VerificationProvider(
|
||||
context.read<VerificationService>(),
|
||||
delivery: widget.delivery,
|
||||
);
|
||||
final _emailFocus = FocusNode();
|
||||
String? _recipient;
|
||||
@@ -44,7 +48,7 @@ class _VerificationPageState extends State<VerificationPage> {
|
||||
if (!mounted) return;
|
||||
if (widget.token != null) {
|
||||
_provider.confirm(widget.token!);
|
||||
} else if (_email.text.isNotEmpty) {
|
||||
} else if (widget.delivery == null && _email.text.isNotEmpty) {
|
||||
_send();
|
||||
}
|
||||
});
|
||||
@@ -58,14 +62,26 @@ class _VerificationPageState extends State<VerificationPage> {
|
||||
@override
|
||||
void didUpdateWidget(covariant VerificationPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.token == widget.token) return;
|
||||
if (oldWidget.token == widget.token &&
|
||||
oldWidget.email == widget.email &&
|
||||
identical(oldWidget.delivery, widget.delivery)) {
|
||||
return;
|
||||
}
|
||||
if (oldWidget.token != widget.token) {
|
||||
context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
|
||||
_provider.reset();
|
||||
}
|
||||
_provider.reset(delivery: widget.delivery);
|
||||
_lastWasSend = false;
|
||||
_validated = false;
|
||||
_email.clear();
|
||||
_recipient = null;
|
||||
_email.text = widget.delivery?.email ?? widget.email ?? '';
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && widget.token != null) _provider.confirm(widget.token!);
|
||||
if (!mounted) return;
|
||||
if (widget.token != null) {
|
||||
_provider.confirm(widget.token!);
|
||||
} else if (widget.delivery == null && _email.text.isNotEmpty) {
|
||||
_send();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -198,7 +206,7 @@ packages:
|
||||
source: hosted
|
||||
version: "18.0.1"
|
||||
http:
|
||||
dependency: transitive
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
@@ -214,7 +222,7 @@ packages:
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
image:
|
||||
dependency: transitive
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: image
|
||||
sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e"
|
||||
@@ -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:
|
||||
|
||||
@@ -27,10 +27,13 @@ dependencies:
|
||||
shared_preferences: ^2.5.3
|
||||
cupertino_icons: ^1.0.8
|
||||
go_router: ^18.0.1
|
||||
country_picker: ^2.0.28
|
||||
dev_dependencies:
|
||||
image: ^4.9.0
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
http: ^1.6.0
|
||||
flutter:
|
||||
generate: true
|
||||
uses-material-design: true
|
||||
@@ -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<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();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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<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,6 +11,8 @@ 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';
|
||||
|
||||
@@ -65,12 +67,35 @@ void main() {
|
||||
return service;
|
||||
}
|
||||
|
||||
Future<void> submit(WidgetTester t) async {
|
||||
Future<void> 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('kyc-country')), 'PL');
|
||||
await t.enterText(find.byKey(const ValueKey('birth-day')), '23');
|
||||
await tap(t, find.byType(DropdownButtonFormField<int>));
|
||||
// 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<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('kyc-next')));
|
||||
await tap(t, find.byKey(const ValueKey('pick-selfie')));
|
||||
@@ -79,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<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', (
|
||||
t,
|
||||
) async {
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:image/image.dart' as images;
|
||||
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
import 'package:sxchnge_app/kyc/photo.dart';
|
||||
import 'package:sxchnge_app/kyc/preview.dart';
|
||||
|
||||
import 'package:sxchnge_app/generated/i18n/app_localizations.dart';
|
||||
|
||||
Widget host(Widget child, {Locale locale = const Locale('en')}) => MaterialApp(
|
||||
locale: locale,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(body: SingleChildScrollView(child: child)),
|
||||
);
|
||||
|
||||
// Start decoding outside FakeAsync as well as waiting outside it, including
|
||||
// browser codec callbacks that otherwise remain pending in the test zone.
|
||||
Future<void> show(WidgetTester tester, Widget widget) async {
|
||||
await tester.runAsync(() => tester.pumpWidget(widget));
|
||||
}
|
||||
|
||||
// pumpAndSettle alone does not wait for image codecs. Listen to the actual
|
||||
// preview streams so a hidden errorBuilder cannot make this regression pass.
|
||||
Future<void> decoded(WidgetTester tester, {bool succeeds = true}) async {
|
||||
final providers = tester
|
||||
.widgetList<Image>(find.byType(Image))
|
||||
.map((image) => image.image)
|
||||
.toList();
|
||||
expect(providers, isNotEmpty);
|
||||
for (final provider in providers) {
|
||||
Object? renderError;
|
||||
StackTrace? renderStack;
|
||||
await tester.runAsync(() async {
|
||||
final stream = provider.resolve(ImageConfiguration.empty);
|
||||
final ready = Completer<void>();
|
||||
final listener = ImageStreamListener(
|
||||
(info, _) {
|
||||
info.dispose();
|
||||
if (!ready.isCompleted) ready.complete();
|
||||
},
|
||||
onError: (Object error, StackTrace? stack) {
|
||||
renderError = error;
|
||||
renderStack = stack;
|
||||
if (!ready.isCompleted) ready.complete();
|
||||
},
|
||||
);
|
||||
stream.addListener(listener);
|
||||
try {
|
||||
await ready.future.timeout(const Duration(seconds: 15));
|
||||
} finally {
|
||||
stream.removeListener(listener);
|
||||
}
|
||||
});
|
||||
expect(
|
||||
renderError,
|
||||
succeeds ? isNull : isNotNull,
|
||||
reason: succeeds ? '$renderError\n$renderStack' : null,
|
||||
);
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late Uint8List png;
|
||||
late Uint8List jpeg;
|
||||
setUpAll(() {
|
||||
// Synthetic, non-sensitive portrait and landscape photos in both formats.
|
||||
png = images.encodePng(images.Image(width: 1200, height: 1600));
|
||||
jpeg = images.encodeJpg(images.Image(width: 1600, height: 1200));
|
||||
});
|
||||
|
||||
for (final format in ['PNG', 'JPEG']) {
|
||||
testWidgets('accepted $format renders from an immutable photo model', (
|
||||
tester,
|
||||
) async {
|
||||
final source = Uint8List.fromList(format == 'PNG' ? png : jpeg);
|
||||
final photo = (await tester.runAsync(
|
||||
() => FilePhotoService.validate(source),
|
||||
))!;
|
||||
final originalFirstByte = photo.bytes.first;
|
||||
source[0] = 0;
|
||||
expect(photo.bytes.first, originalFirstByte);
|
||||
expect(() => photo.bytes[0] = 0, throwsUnsupportedError);
|
||||
await show(
|
||||
tester,
|
||||
host(PhotoPreview(photo: photo, label: 'ID document photo')),
|
||||
);
|
||||
await decoded(tester);
|
||||
final raw = tester.widget<RawImage>(find.byType(RawImage));
|
||||
expect(raw.image, isNotNull);
|
||||
expect(raw.image!.width, lessThanOrEqualTo(1000));
|
||||
expect(
|
||||
tester.widget<Image>(find.byType(Image)).semanticLabel,
|
||||
'ID document photo',
|
||||
);
|
||||
expect(find.textContaining('damaged'), findsNothing);
|
||||
expect(find.textContaining('could not be displayed'), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('replacement and removal evict sensitive preview cache entries', (
|
||||
tester,
|
||||
) async {
|
||||
final first = Photo(png);
|
||||
await show(tester, host(PhotoPreview(photo: first, label: 'ID')));
|
||||
await decoded(tester);
|
||||
final initialProvider = tester.widget<Image>(find.byType(Image)).image;
|
||||
final initialKey = await initialProvider.obtainKey(
|
||||
ImageConfiguration.empty,
|
||||
);
|
||||
final cache = PaintingBinding.instance.imageCache;
|
||||
expect(cache.statusForKey(initialKey).keepAlive, isTrue);
|
||||
await show(tester, host(PhotoPreview(photo: first, label: 'Document')));
|
||||
await decoded(tester);
|
||||
expect(
|
||||
identical(
|
||||
tester.widget<Image>(find.byType(Image)).image,
|
||||
initialProvider,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
await show(tester, host(PhotoPreview(photo: Photo(jpeg), label: 'ID')));
|
||||
await decoded(tester);
|
||||
expect(cache.statusForKey(initialKey).untracked, isTrue);
|
||||
final replacement = tester.widget<Image>(find.byType(Image)).image;
|
||||
final replacementKey = await replacement.obtainKey(
|
||||
ImageConfiguration.empty,
|
||||
);
|
||||
expect(cache.statusForKey(replacementKey).keepAlive, isTrue);
|
||||
expect(tester.widget<RawImage>(find.byType(RawImage)).image!.width, 1000);
|
||||
await show(tester, host(const SizedBox()));
|
||||
await tester.pumpAndSettle();
|
||||
expect(cache.statusForKey(replacementKey).untracked, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('ID and selfie both render on review and after returning', (
|
||||
tester,
|
||||
) async {
|
||||
final passport = Photo(png);
|
||||
final selfie = Photo(jpeg);
|
||||
for (var visit = 0; visit < 2; visit++) {
|
||||
await show(
|
||||
tester,
|
||||
host(
|
||||
Column(
|
||||
children: [
|
||||
PhotoPanel(
|
||||
kind: PhotoKind.passport,
|
||||
photo: passport,
|
||||
review: true,
|
||||
),
|
||||
PhotoPanel(kind: PhotoKind.selfie, photo: selfie, review: true),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
await decoded(tester);
|
||||
final previews = tester.widgetList<RawImage>(find.byType(RawImage));
|
||||
expect(previews.length, 2);
|
||||
expect(previews.every((image) => image.image != null), isTrue);
|
||||
expect(find.text('Choose a different photo'), findsNothing);
|
||||
await show(tester, host(const SizedBox()));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
});
|
||||
|
||||
for (final locale in ['en', 'ru']) {
|
||||
testWidgets(
|
||||
'$locale preview failure explains recovery and can be replaced',
|
||||
(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);
|
||||
var replacements = 0;
|
||||
var removals = 0;
|
||||
await show(
|
||||
tester,
|
||||
host(
|
||||
PhotoPanel(
|
||||
kind: PhotoKind.passport,
|
||||
photo: Photo(Uint8List.fromList([1, 2, 3])),
|
||||
onPick: () => replacements++,
|
||||
onRemove: () => removals++,
|
||||
),
|
||||
locale: Locale(locale),
|
||||
),
|
||||
);
|
||||
await decoded(tester, succeeds: false);
|
||||
final l = AppLocalizations.of(tester.element(find.byType(PhotoPanel)))!;
|
||||
expect(find.text(l.kycErrorPreview), findsOneWidget);
|
||||
expect(find.text(l.kycErrorUnreadable), findsNothing);
|
||||
expect(
|
||||
tester.getSemantics(find.text(l.kycErrorPreview)),
|
||||
matchesSemantics(isLiveRegion: true, label: l.kycErrorPreview),
|
||||
);
|
||||
final replace = find.byKey(const ValueKey('pick-passport'));
|
||||
await tester.ensureVisible(replace);
|
||||
await tester.tap(replace);
|
||||
final remove = find.text(l.kycRemove);
|
||||
await tester.ensureVisible(remove);
|
||||
await tester.tap(remove);
|
||||
expect(replacements, 1);
|
||||
expect(removals, 1);
|
||||
await show(
|
||||
tester,
|
||||
host(
|
||||
PhotoPanel(kind: PhotoKind.passport, photo: Photo(jpeg)),
|
||||
locale: Locale(locale),
|
||||
),
|
||||
);
|
||||
await decoded(tester);
|
||||
expect(find.text(l.kycErrorPreview), findsNothing);
|
||||
expect(tester.widget<RawImage>(find.byType(RawImage)).image, isNotNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,44 @@ Future<GoRouter> openRoute(
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('queued signup receipt is transient and updates its recipient', (
|
||||
tester,
|
||||
) async {
|
||||
final router = await openRoute(tester, '/');
|
||||
await tester.pumpAndSettle();
|
||||
router.goNamed(
|
||||
AppRoutes.verificationSent,
|
||||
extra: const VerificationDelivery(
|
||||
email: 'first@example.com',
|
||||
isSimulated: false,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
|
||||
expect(find.text('first@example.com'), findsOneWidget);
|
||||
router.goNamed(
|
||||
AppRoutes.verificationSent,
|
||||
extra: const VerificationDelivery(
|
||||
email: 'second@example.com',
|
||||
isSimulated: false,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('first@example.com'), findsNothing);
|
||||
expect(find.text('second@example.com'), findsOneWidget);
|
||||
final restored = router.routeInformationParser.restoreRouteInformation(
|
||||
router.routerDelegate.currentConfiguration,
|
||||
)!;
|
||||
expect(restored.state.toString(), isNot(contains('second@example.com')));
|
||||
expect(restored.uri.toString(), '/verification-sent');
|
||||
router.goNamed(AppRoutes.login);
|
||||
await tester.pumpAndSettle();
|
||||
router.goNamed(AppRoutes.verificationSent);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('verification-idle')), findsOneWidget);
|
||||
expect(find.text('second@example.com'), findsNothing);
|
||||
});
|
||||
|
||||
for (final language in ['en', 'ru']) {
|
||||
testWidgets(
|
||||
'verified link keeps success visible and prefills login ($language)',
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
|
||||
import 'package:sxchnge_app/main.dart';
|
||||
import 'package:sxchnge_app/providers/locale.dart';
|
||||
|
||||
class DeliveryBackend extends BackendAuthServices {
|
||||
DeliveryBackend(List<String> requests)
|
||||
: super(
|
||||
baseUri: Uri.parse('https://api.example/api/v1'),
|
||||
clientId: 'web',
|
||||
client: MockClient((request) async {
|
||||
requests.add(request.url.path);
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'data': request.url.path.endsWith('/signup')
|
||||
? {'userId': 'customer'}
|
||||
: {'accepted': true},
|
||||
}),
|
||||
202,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
int sends = 0;
|
||||
bool failResend = true;
|
||||
|
||||
@override
|
||||
Future<VerificationResult> send({
|
||||
required String email,
|
||||
required String locale,
|
||||
}) async {
|
||||
sends++;
|
||||
if (failResend) {
|
||||
throw const VerificationException(VerificationError.internal);
|
||||
}
|
||||
return super.send(email: email, locale: locale);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
for (final language in ['en', 'ru']) {
|
||||
testWidgets(
|
||||
'accepted signup survives an unavailable resend service ($language)',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.platformDispatcher.textScaleFactorTestValue = 1.5;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||
final requests = <String>[];
|
||||
final backend = DeliveryBackend(requests);
|
||||
final analytics = DummyAnalyticsService();
|
||||
await tester.pumpWidget(
|
||||
SxchngeApp(
|
||||
backend: backend,
|
||||
analyticsService: analytics,
|
||||
localeProvider: LocaleProvider(initialLocale: Locale(language)),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final signup = find.text(language == 'ru' ? 'Регистрация' : 'Sign up');
|
||||
await tester.ensureVisible(signup);
|
||||
await tester.tap(signup);
|
||||
await tester.pumpAndSettle();
|
||||
for (final entry in {
|
||||
'name': 'Ada Lovelace',
|
||||
'email': 'ada@example.com',
|
||||
'password': 'password preserved',
|
||||
'confirmation': 'password preserved',
|
||||
}.entries) {
|
||||
await tester.enterText(find.byKey(ValueKey(entry.key)), entry.value);
|
||||
}
|
||||
final submit = find.byKey(const ValueKey('submit'));
|
||||
await tester.ensureVisible(submit);
|
||||
await tester.tap(submit);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
|
||||
expect(find.text('ada@example.com'), findsOneWidget);
|
||||
expect(backend.sends, 0);
|
||||
expect(requests, ['/api/v1/accounts/signup']);
|
||||
final router = GoRouter.of(tester.element(find.byType(Scaffold)));
|
||||
final restored = router.routeInformationParser.restoreRouteInformation(
|
||||
router.routerDelegate.currentConfiguration,
|
||||
)!;
|
||||
expect(restored.uri.toString(), '/verification-sent');
|
||||
expect(restored.state.toString(), isNot(contains('ada@example.com')));
|
||||
expect(
|
||||
analytics.events.where(
|
||||
(event) => event.name == 'auth_signup_succeeded',
|
||||
),
|
||||
hasLength(1),
|
||||
);
|
||||
expect(
|
||||
analytics.events.where(
|
||||
(event) => event.name == 'auth_email_delivery_succeeded',
|
||||
),
|
||||
hasLength(1),
|
||||
);
|
||||
|
||||
final resend = find.byKey(const ValueKey('verification-resend'));
|
||||
await tester.ensureVisible(resend);
|
||||
await tester.tap(resend);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.byKey(const ValueKey('verification-error')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(backend.sends, 1);
|
||||
expect(
|
||||
tester
|
||||
.widget<TextFormField>(
|
||||
find.byKey(const ValueKey('verification-email')),
|
||||
)
|
||||
.controller!
|
||||
.text,
|
||||
'ada@example.com',
|
||||
);
|
||||
backend.failResend = false;
|
||||
await tester.ensureVisible(resend);
|
||||
await tester.tap(resend);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
|
||||
expect(backend.sends, 2);
|
||||
expect(requests.last, '/api/v1/accounts/verification/send');
|
||||
expect(requests, hasLength(2));
|
||||
expect(
|
||||
analytics.events.map((event) => event.journeyId).toSet(),
|
||||
hasLength(1),
|
||||
);
|
||||
expect(
|
||||
analytics.events.map((event) => event.properties).toString(),
|
||||
isNot(contains('ada@example.com')),
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,13 @@ abstract final class BackendMapper {
|
||||
return VerificationResult(isSimulated: false, verifiedEmail: dto.email);
|
||||
}
|
||||
|
||||
static AuthResult signup(BackendDTO dto) {
|
||||
static AuthResult signup(BackendDTO dto, {required String email}) {
|
||||
if (dto.userId.isEmpty) throw const FormatException('Missing user');
|
||||
return AuthResult(userId: dto.userId, isSimulated: false);
|
||||
return AuthResult(
|
||||
userId: dto.userId,
|
||||
isSimulated: false,
|
||||
delivery: VerificationDelivery(email: email, isSimulated: false),
|
||||
);
|
||||
}
|
||||
|
||||
static FactorChallenge challenge(BackendDTO dto, String id) {
|
||||
|
||||
@@ -5,6 +5,12 @@ import '../models/verification.dart';
|
||||
abstract final class AuthMapper {
|
||||
static AuthResult fromDTO(AuthDTO dto) =>
|
||||
AuthResult(userId: dto.userId, isSimulated: dto.simulated);
|
||||
|
||||
static AuthResult signup(AuthDTO dto, {required String email}) => AuthResult(
|
||||
userId: dto.userId,
|
||||
isSimulated: dto.simulated,
|
||||
delivery: VerificationDelivery(email: email, isSimulated: dto.simulated),
|
||||
);
|
||||
}
|
||||
|
||||
abstract final class VerificationMapper {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'two_factor.dart';
|
||||
import 'verification.dart';
|
||||
|
||||
/// Ephemeral input: never persist, log, or include passwords in provider state.
|
||||
class LoginRequest {
|
||||
@@ -31,9 +32,11 @@ class AuthResult {
|
||||
required this.userId,
|
||||
required this.isSimulated,
|
||||
this.challenge,
|
||||
this.delivery,
|
||||
});
|
||||
final String userId;
|
||||
final FactorChallenge? challenge;
|
||||
final VerificationDelivery? delivery;
|
||||
final bool isSimulated;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,13 @@ enum VerificationStatus {
|
||||
|
||||
enum VerificationError { expired, invalid, internal, needsAssistance }
|
||||
|
||||
/// An accepted email-queue operation, not proof of inbox delivery or activation.
|
||||
class VerificationDelivery {
|
||||
const VerificationDelivery({required this.email, required this.isSimulated});
|
||||
final String email;
|
||||
final bool isSimulated;
|
||||
}
|
||||
|
||||
class VerificationException implements Exception {
|
||||
const VerificationException(this.code);
|
||||
final VerificationError code;
|
||||
|
||||
@@ -4,13 +4,22 @@ import '../models/verification.dart';
|
||||
import '../services/verification.dart';
|
||||
|
||||
class VerificationProvider extends ChangeNotifier {
|
||||
VerificationProvider(this.service);
|
||||
VerificationProvider(this.service, {VerificationDelivery? delivery})
|
||||
: _state = _initialState(delivery);
|
||||
final VerificationService service;
|
||||
VerificationState _state = const VerificationState(VerificationStatus.idle);
|
||||
VerificationState _state;
|
||||
VerificationState get state => _state;
|
||||
bool _disposed = false;
|
||||
int _generation = 0;
|
||||
|
||||
static VerificationState _initialState(VerificationDelivery? delivery) =>
|
||||
delivery == null
|
||||
? const VerificationState(VerificationStatus.idle)
|
||||
: VerificationState(
|
||||
VerificationStatus.sent,
|
||||
isSimulated: delivery.isSimulated,
|
||||
);
|
||||
|
||||
Future<void> send({required String email, required String locale}) => _run(
|
||||
() => service.send(email: email.trim(), locale: locale),
|
||||
VerificationStatus.sending,
|
||||
@@ -61,10 +70,10 @@ class VerificationProvider extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void reset() {
|
||||
void reset({VerificationDelivery? delivery}) {
|
||||
if (_disposed) return;
|
||||
++_generation;
|
||||
_state = const VerificationState(VerificationStatus.idle);
|
||||
_state = _initialState(delivery);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ class BackendAuthServices
|
||||
final _pending = <String, ({String token, String challengeId})>{};
|
||||
final _grants = <String, AuthSession>{};
|
||||
final _retryKeys = <String, String>{};
|
||||
String? _queuedSignup;
|
||||
AuthSession? _session;
|
||||
AuthSession? get session => _session;
|
||||
@override
|
||||
@@ -113,9 +112,8 @@ class BackendAuthServices
|
||||
'locale': r.locale,
|
||||
'idempotencyKey': key,
|
||||
});
|
||||
final result = BackendMapper.signup(dto);
|
||||
final result = BackendMapper.signup(dto, email: email);
|
||||
_retryKeys.remove('signup:$email');
|
||||
_queuedSignup = email;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -141,14 +139,6 @@ class BackendAuthServices
|
||||
required String locale,
|
||||
}) async {
|
||||
final normalized = email.trim().toLowerCase();
|
||||
// Signup already queued this email. The existing confirmation page requests
|
||||
// its initial delivery on mount; consume that receipt once.
|
||||
if (_queuedSignup == normalized) {
|
||||
_queuedSignup = null;
|
||||
return VerificationMapper.fromDTO(
|
||||
const VerificationDTO(simulated: false),
|
||||
);
|
||||
}
|
||||
final key = _retryKeys.putIfAbsent('activation:$normalized', _key);
|
||||
try {
|
||||
await _post('verification/send', {
|
||||
|
||||
@@ -40,8 +40,9 @@ class DummySignupService implements SignupService, SimulationInfo {
|
||||
Future<AuthResult> signup(SignupRequest request) async {
|
||||
await Future<void>.delayed(delay);
|
||||
if (failure case final error?) throw AuthException(error);
|
||||
return AuthMapper.fromDTO(
|
||||
return AuthMapper.signup(
|
||||
const AuthDTO(userId: 'demo-user', simulated: true),
|
||||
email: request.email.trim(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -111,13 +111,27 @@ class InstrumentedAuthServices
|
||||
simulation: _simulation(loginService),
|
||||
);
|
||||
@override
|
||||
Future<AuthResult> signup(SignupRequest request) => _run(
|
||||
Future<AuthResult> signup(SignupRequest request) async {
|
||||
final result = await _run(
|
||||
JourneyKind.signup,
|
||||
JourneyStep.signup,
|
||||
() => signupService.signup(request),
|
||||
(r) => r.isSimulated,
|
||||
simulation: _simulation(signupService),
|
||||
);
|
||||
final delivery = result.delivery;
|
||||
if (delivery != null) {
|
||||
final operation = recorder.begin(
|
||||
JourneyKind.verification,
|
||||
JourneyStep.emailDelivery,
|
||||
onboarding: true,
|
||||
simulated: delivery.isSimulated,
|
||||
);
|
||||
recorder.finish(operation, simulated: delivery.isSimulated);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<VerificationResult> send({
|
||||
required String email,
|
||||
|
||||
@@ -142,11 +142,11 @@ void main() {
|
||||
.isSimulated,
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
(await const DummySignupService(delay: Duration.zero).signup(signup))
|
||||
.isSimulated,
|
||||
isTrue,
|
||||
);
|
||||
final accepted = await const DummySignupService(delay: Duration.zero)
|
||||
.signup(signup);
|
||||
expect(accepted.isSimulated, isTrue);
|
||||
expect(accepted.delivery?.email, 'a@example.com');
|
||||
expect(accepted.delivery?.isSimulated, isTrue);
|
||||
await expectLater(
|
||||
const DummyLoginService(
|
||||
delay: Duration.zero,
|
||||
|
||||
@@ -89,7 +89,9 @@ void main() {
|
||||
expect(keys[0], keys[1]);
|
||||
},
|
||||
);
|
||||
test('signup receipt avoids duplicate delivery and subsequent resend reaches API', () async {
|
||||
test(
|
||||
'signup returns its receipt and every explicit resend reaches API',
|
||||
() async {
|
||||
final paths = <String>[];
|
||||
final service = BackendAuthServices(
|
||||
baseUri: Uri.parse('https://api.example/api/v1'),
|
||||
@@ -97,7 +99,10 @@ void main() {
|
||||
client: MockClient((request) async {
|
||||
paths.add(request.url.path);
|
||||
if (request.url.path.endsWith('/signup')) {
|
||||
expect(jsonDecode(request.body)['password'], ' password preserved ');
|
||||
expect(
|
||||
jsonDecode(request.body)['password'],
|
||||
' password preserved ',
|
||||
);
|
||||
return response({'userId': 'user'});
|
||||
}
|
||||
return response({'accepted': true});
|
||||
@@ -113,11 +118,19 @@ void main() {
|
||||
),
|
||||
);
|
||||
expect(result.isSimulated, isFalse);
|
||||
await service.send(email: 'user@example.com', locale: 'en');
|
||||
expect(result.delivery?.email, 'user@example.com');
|
||||
expect(result.delivery?.isSimulated, isFalse);
|
||||
expect(paths, ['/api/v1/accounts/signup']);
|
||||
await service.send(email: 'user@example.com', locale: 'en');
|
||||
expect(paths, [
|
||||
'/api/v1/accounts/signup',
|
||||
'/api/v1/accounts/verification/send',
|
||||
]);
|
||||
await service.send(email: 'user@example.com', locale: 'en');
|
||||
expect(paths.last, '/api/v1/accounts/verification/send');
|
||||
});
|
||||
expect(paths, hasLength(3));
|
||||
},
|
||||
);
|
||||
test('login confirms backend session only after factor and consumes local grant once', () async {
|
||||
final now = DateTime.now();
|
||||
var verified = false;
|
||||
|
||||
@@ -28,6 +28,36 @@ class PendingService implements VerificationService {
|
||||
}
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'accepted delivery initializes sent state without calling the service',
|
||||
() async {
|
||||
final service = PendingService();
|
||||
final provider = VerificationProvider(
|
||||
service,
|
||||
delivery: const VerificationDelivery(
|
||||
email: 'ada@example.com',
|
||||
isSimulated: false,
|
||||
),
|
||||
);
|
||||
addTearDown(provider.dispose);
|
||||
expect(provider.state.status, VerificationStatus.sent);
|
||||
expect(provider.state.isSimulated, isFalse);
|
||||
expect(provider.state.result, isNull);
|
||||
expect(service.calls, 0);
|
||||
final resend = provider.send(email: 'ada@example.com', locale: 'en');
|
||||
expect(provider.state.status, VerificationStatus.sending);
|
||||
expect(service.calls, 1);
|
||||
service.pending.completeError(
|
||||
const VerificationException(VerificationError.internal),
|
||||
);
|
||||
await resend;
|
||||
expect(provider.state.status, VerificationStatus.error);
|
||||
expect(provider.state.result, isNull);
|
||||
provider.reset();
|
||||
expect(provider.state.status, VerificationStatus.idle);
|
||||
},
|
||||
);
|
||||
|
||||
for (final scenario in {
|
||||
'demo-valid': VerificationStatus.verified,
|
||||
'demo-expired': VerificationStatus.expired,
|
||||
|
||||
Reference in new issue
Block a user