Compare commits
3
Commits
kyc-67
..
c106706615
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c106706615 | ||
|
|
915b8e8123 | ||
|
|
464bd2d565 |
No files matched your search
@@ -211,51 +211,3 @@ smoke testing on the local demo opened the selector, searched for Poland and
|
|||||||
confirmed that the profile displays Poland after selection. Focused and full
|
confirmed that the profile displays Poland after selection. Focused and full
|
||||||
journey tests verify the stored `PL` code. Generated localization/build outputs
|
journey tests verify the stored `PL` code. Generated localization/build outputs
|
||||||
remain ignored and uncommitted.
|
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,80 @@
|
|||||||
|
# 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.
|
||||||
@@ -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,3 +1,5 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
@@ -15,8 +17,12 @@ class PhotoPreview extends StatefulWidget {
|
|||||||
|
|
||||||
class _PhotoPreviewState extends State<PhotoPreview> {
|
class _PhotoPreviewState extends State<PhotoPreview> {
|
||||||
late ImageProvider _image = _provider();
|
late ImageProvider _image = _provider();
|
||||||
ImageProvider _provider() =>
|
ImageProvider _provider() => ResizeImage.resizeIfNeeded(
|
||||||
ResizeImage.resizeIfNeeded(1000, null, MemoryImage(widget.photo.bytes));
|
1000,
|
||||||
|
null,
|
||||||
|
// Web decoders require a native typed array, not the model's read-only view.
|
||||||
|
MemoryImage(Uint8List.fromList(widget.photo.bytes)),
|
||||||
|
);
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant PhotoPreview oldWidget) {
|
void didUpdateWidget(covariant PhotoPreview oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
@@ -38,7 +44,15 @@ class _PhotoPreviewState extends State<PhotoPreview> {
|
|||||||
height: 220,
|
height: 220,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
semanticLabel: widget.label,
|
semanticLabel: widget.label,
|
||||||
errorBuilder: (_, _, _) =>
|
errorBuilder: (_, _, _) => ConstrainedBox(
|
||||||
Text(AppLocalizations.of(context)!.kycErrorUnreadable),
|
constraints: const BoxConstraints(minHeight: 220),
|
||||||
|
child: Center(
|
||||||
|
child: Semantics(
|
||||||
|
container: true,
|
||||||
|
liveRegion: true,
|
||||||
|
child: Text(AppLocalizations.of(context)!.kycErrorPreview),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,7 +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 'country.dart';
|
||||||
|
|
||||||
import '../generated/i18n/app_localizations.dart';
|
import '../generated/i18n/app_localizations.dart';
|
||||||
@@ -38,11 +37,17 @@ 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(
|
CountryField(
|
||||||
|
|||||||
@@ -128,6 +128,7 @@
|
|||||||
"kycErrorFormat": "Choose a JPEG or PNG image. Other formats are not supported.",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"kycErrorSession": "Your session has expired. Return to login, then check your verification status.",
|
||||||
@@ -269,11 +270,7 @@
|
|||||||
"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",
|
"kycCountrySelect": "Select a country",
|
||||||
"kycCountrySearch": "Search countries",
|
"kycCountrySearch": "Search countries",
|
||||||
|
|||||||
@@ -128,6 +128,7 @@
|
|||||||
"kycErrorFormat": "Выберите изображение JPEG или PNG. Другие форматы не поддерживаются.",
|
"kycErrorFormat": "Выберите изображение JPEG или PNG. Другие форматы не поддерживаются.",
|
||||||
"kycErrorLarge": "Изображение слишком большое. Выберите файл до 10 МБ и 40 мегапикселей.",
|
"kycErrorLarge": "Изображение слишком большое. Выберите файл до 10 МБ и 40 мегапикселей.",
|
||||||
"kycErrorUnreadable": "Изображение повреждено или не читается. Выберите другое фото.",
|
"kycErrorUnreadable": "Изображение повреждено или не читается. Выберите другое фото.",
|
||||||
|
"kycErrorPreview": "Не удалось показать фото. Попробуйте выбрать его ещё раз.",
|
||||||
"kycErrorSmall": "Фото слишком маленькое. Ширина и высота должны быть не менее 600 пикселей.",
|
"kycErrorSmall": "Фото слишком маленькое. Ширина и высота должны быть не менее 600 пикселей.",
|
||||||
"kycErrorIncomplete": "Выберите оба фото и подтвердите, что на них вы и ваш документ.",
|
"kycErrorIncomplete": "Выберите оба фото и подтвердите, что на них вы и ваш документ.",
|
||||||
"kycErrorSession": "Сессия истекла. Войдите снова и проверьте статус верификации.",
|
"kycErrorSession": "Сессия истекла. Войдите снова и проверьте статус верификации.",
|
||||||
@@ -269,11 +270,7 @@
|
|||||||
"kycProfileHelp": "Укажите данные точно как в документе, удостоверяющем личность.",
|
"kycProfileHelp": "Укажите данные точно как в документе, удостоверяющем личность.",
|
||||||
"kycFullName": "Полное имя по документу",
|
"kycFullName": "Полное имя по документу",
|
||||||
"kycBirth": "Дата рождения",
|
"kycBirth": "Дата рождения",
|
||||||
"kycBirthDay": "День",
|
"kycBirthHelp": "ГГГГ-ММ-ДД, например 1990-04-23",
|
||||||
"kycBirthMonth": "Месяц",
|
|
||||||
"kycBirthYear": "Год",
|
|
||||||
"kycBirthInvalid": "Укажите корректную дату рождения.",
|
|
||||||
"kycBirthFuture": "Дата рождения не может быть в будущем.",
|
|
||||||
"kycCountry": "Страна проживания",
|
"kycCountry": "Страна проживания",
|
||||||
"kycCountrySelect": "Выберите страну",
|
"kycCountrySelect": "Выберите страну",
|
||||||
"kycCountrySearch": "Поиск страны",
|
"kycCountrySearch": "Поиск страны",
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
image:
|
image:
|
||||||
dependency: transitive
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: image
|
name: image
|
||||||
sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e"
|
sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e"
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ dependencies:
|
|||||||
go_router: ^18.0.1
|
go_router: ^18.0.1
|
||||||
country_picker: ^2.0.28
|
country_picker: ^2.0.28
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
image: ^4.9.0
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
|
|||||||
@@ -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();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -11,7 +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/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,16 +66,10 @@ 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>));
|
|
||||||
// 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(
|
await tap(
|
||||||
t,
|
t,
|
||||||
find.descendant(
|
find.descendant(
|
||||||
@@ -92,10 +85,6 @@ void main() {
|
|||||||
'PL',
|
'PL',
|
||||||
);
|
);
|
||||||
await t.pumpAndSettle();
|
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 +93,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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user