Add optional KYC flow and service-backed account limits

This commit is contained in:
Stephan D committed 2026-09-12 09:28:24 +02:00
1 parent 61ccfc1844
commit 3397f04ab7
38 files changed
+3443 -36

No files matched your search

+4
View File
@@ -310,6 +310,10 @@ class _AuthFormState extends State<AuthForm> {
AuthFeedback(state: state, signUp: _signUp),
],
const SizedBox(height: 20),
TextButton(
onPressed: () => context.goNamed(AppRoutes.identity),
child: Text(l.kycPreview),
),
Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
@@ -535,6 +535,462 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Prepare demo email again'**
String get demoResend;
/// No description provided for @kycTitle.
///
/// In en, this message translates to:
/// **'Verification & limits'**
String get kycTitle;
/// No description provided for @kycIntro.
///
/// In en, this message translates to:
/// **'Your account, at your pace. Verify your identity when you need higher limits.'**
String get kycIntro;
/// No description provided for @kycDemo.
///
/// In en, this message translates to:
/// **'DEMO · Use sample images only. Photos stay in memory, are never uploaded, and disappear when you leave or reload. Review decisions are simulated.'**
String get kycDemo;
/// No description provided for @kycUnverified.
///
/// In en, this message translates to:
/// **'Not verified'**
String get kycUnverified;
/// No description provided for @kycPending.
///
/// In en, this message translates to:
/// **'In review'**
String get kycPending;
/// No description provided for @kycApproved.
///
/// In en, this message translates to:
/// **'Identity verified'**
String get kycApproved;
/// No description provided for @kycChanges.
///
/// In en, this message translates to:
/// **'New photos needed'**
String get kycChanges;
/// No description provided for @kycDenied.
///
/// In en, this message translates to:
/// **'Verification declined'**
String get kycDenied;
/// No description provided for @kycUnverifiedBody.
///
/// In en, this message translates to:
/// **'You can keep using your account with basic limits. Submit a passport photo and a selfie holding it to request an upgrade.'**
String get kycUnverifiedBody;
/// No description provided for @kycPendingBody.
///
/// In en, this message translates to:
/// **'Your submission is awaiting a human review. Basic limits remain active until approval. You do not need to submit again.'**
String get kycPendingBody;
/// No description provided for @kycApprovedBody.
///
/// In en, this message translates to:
/// **'Your review is complete. Check your current limits below.'**
String get kycApprovedBody;
/// No description provided for @kycChangesBody.
///
/// In en, this message translates to:
/// **'Your account remains available with basic limits. Replace the photos and submit a new review request.'**
String get kycChangesBody;
/// No description provided for @kycDeniedBody.
///
/// In en, this message translates to:
/// **'Basic limits remain available. If you believe this decision is incorrect, contact support for a review.'**
String get kycDeniedBody;
/// No description provided for @kycPassportReason.
///
/// In en, this message translates to:
/// **'The passport details could not be read. Retake the photo in good light, with every edge visible.'**
String get kycPassportReason;
/// No description provided for @kycSelfieReason.
///
/// In en, this message translates to:
/// **'Your face or passport was not clear in the selfie. Keep both visible and in focus.'**
String get kycSelfieReason;
/// No description provided for @kycExpiredReason.
///
/// In en, this message translates to:
/// **'The passport has expired. Use a valid passport and take both photos again.'**
String get kycExpiredReason;
/// No description provided for @kycDeniedReason.
///
/// In en, this message translates to:
/// **'We could not verify your identity from this submission.'**
String get kycDeniedReason;
/// No description provided for @kycStart.
///
/// In en, this message translates to:
/// **'Verify identity'**
String get kycStart;
/// No description provided for @kycResubmit.
///
/// In en, this message translates to:
/// **'Replace photos'**
String get kycResubmit;
/// No description provided for @kycRefresh.
///
/// In en, this message translates to:
/// **'Refresh status & limits'**
String get kycRefresh;
/// No description provided for @kycSupport.
///
/// In en, this message translates to:
/// **'Contact support'**
String get kycSupport;
/// No description provided for @kycSupportBody.
///
/// In en, this message translates to:
/// **'Support is not connected in this preview. In the live app, you will be able to request a review here without submitting documents again.'**
String get kycSupportBody;
/// No description provided for @kycBasic.
///
/// In en, this message translates to:
/// **'Basic'**
String get kycBasic;
/// No description provided for @kycVerifiedTier.
///
/// In en, this message translates to:
/// **'Verified'**
String get kycVerifiedTier;
/// No description provided for @kycLimits.
///
/// In en, this message translates to:
/// **'Your limits'**
String get kycLimits;
/// No description provided for @kycLimitsDemo.
///
/// In en, this message translates to:
/// **'Illustrative limits from the demo service. These are not a live offer.'**
String get kycLimitsDemo;
/// No description provided for @kycDay.
///
/// In en, this message translates to:
/// **'Daily volume'**
String get kycDay;
/// No description provided for @kycMonth.
///
/// In en, this message translates to:
/// **'Monthly volume'**
String get kycMonth;
/// No description provided for @kycCurrent.
///
/// In en, this message translates to:
/// **'Current cap'**
String get kycCurrent;
/// No description provided for @kycRemaining.
///
/// In en, this message translates to:
/// **'Available'**
String get kycRemaining;
/// No description provided for @kycAfter.
///
/// In en, this message translates to:
/// **'After approval'**
String get kycAfter;
/// No description provided for @kycLimitsError.
///
/// In en, this message translates to:
/// **'Limits could not be refreshed. Any values below are out of date. Retry to see your current access.'**
String get kycLimitsError;
/// No description provided for @kycLimitsEmpty.
///
/// In en, this message translates to:
/// **'No limits were returned. Refresh or check with support before making a transaction.'**
String get kycLimitsEmpty;
/// No description provided for @kycLoading.
///
/// In en, this message translates to:
/// **'Loading your status…'**
String get kycLoading;
/// No description provided for @kycPassport.
///
/// In en, this message translates to:
/// **'Passport photo'**
String get kycPassport;
/// No description provided for @kycSelfie.
///
/// In en, this message translates to:
/// **'Selfie with passport'**
String get kycSelfie;
/// No description provided for @kycReview.
///
/// In en, this message translates to:
/// **'Review your photos'**
String get kycReview;
/// No description provided for @kycPassportHelp.
///
/// In en, this message translates to:
/// **'Open your valid passport to the photo page. Show all four edges and keep text sharp. Avoid glare, blur and covered details.'**
String get kycPassportHelp;
/// No description provided for @kycSelfieHelp.
///
/// In en, this message translates to:
/// **'Hold the open passport beside your face. Show your whole face and the photo page in the same image. Keep both sharp and uncovered.'**
String get kycSelfieHelp;
/// No description provided for @kycFileRules.
///
/// In en, this message translates to:
/// **'JPEG or PNG · up to 10 MB · at least 600 × 600 pixels. Take a photo with your camera, then choose it here.'**
String get kycFileRules;
/// No description provided for @kycChoose.
///
/// In en, this message translates to:
/// **'Choose photo'**
String get kycChoose;
/// No description provided for @kycReplace.
///
/// In en, this message translates to:
/// **'Choose a different photo'**
String get kycReplace;
/// No description provided for @kycRemove.
///
/// In en, this message translates to:
/// **'Remove photo'**
String get kycRemove;
/// No description provided for @kycNext.
///
/// In en, this message translates to:
/// **'Continue'**
String get kycNext;
/// No description provided for @kycBack.
///
/// In en, this message translates to:
/// **'Back'**
String get kycBack;
/// No description provided for @kycSkip.
///
/// In en, this message translates to:
/// **'Keep basic limits'**
String get kycSkip;
/// No description provided for @kycClose.
///
/// In en, this message translates to:
/// **'Back to account preview'**
String get kycClose;
/// No description provided for @kycReviewHelp.
///
/// In en, this message translates to:
/// **'Check that both photos are readable and show the same passport. A staff member will review them; submission does not immediately raise your limits.'**
String get kycReviewHelp;
/// No description provided for @kycConfirm.
///
/// In en, this message translates to:
/// **'These photos show me and my own valid passport.'**
String get kycConfirm;
/// No description provided for @kycSubmit.
///
/// In en, this message translates to:
/// **'Submit for review'**
String get kycSubmit;
/// No description provided for @kycSubmitDemo.
///
/// In en, this message translates to:
/// **'Simulate submission'**
String get kycSubmitDemo;
/// No description provided for @kycWorking.
///
/// In en, this message translates to:
/// **'Processing… Please keep this page open.'**
String get kycWorking;
/// No description provided for @kycDiscardTitle.
///
/// In en, this message translates to:
/// **'Discard these photos?'**
String get kycDiscardTitle;
/// No description provided for @kycDiscardBody.
///
/// In en, this message translates to:
/// **'Your draft is only in memory. Leaving will remove the selected photos; you can start again later.'**
String get kycDiscardBody;
/// No description provided for @kycStay.
///
/// In en, this message translates to:
/// **'Keep editing'**
String get kycStay;
/// No description provided for @kycDiscard.
///
/// In en, this message translates to:
/// **'Discard & leave'**
String get kycDiscard;
/// No description provided for @kycErrorUnavailable.
///
/// In en, this message translates to:
/// **'The service is unavailable. Your selected photos are still here. Try again.'**
String get kycErrorUnavailable;
/// No description provided for @kycErrorInternal.
///
/// In en, this message translates to:
/// **'Something went wrong. Try again; your selected photos have been kept.'**
String get kycErrorInternal;
/// No description provided for @kycErrorPermission.
///
/// In en, this message translates to:
/// **'We could not open that photo. Check file access or choose another image.'**
String get kycErrorPermission;
/// No description provided for @kycErrorFormat.
///
/// In en, this message translates to:
/// **'Choose a JPEG or PNG image. Other formats are not supported.'**
String get kycErrorFormat;
/// No description provided for @kycErrorLarge.
///
/// In en, this message translates to:
/// **'This image is too large. Choose one under 10 MB and 40 megapixels.'**
String get kycErrorLarge;
/// No description provided for @kycErrorUnreadable.
///
/// In en, this message translates to:
/// **'This image is damaged or cannot be read. Choose another photo.'**
String get kycErrorUnreadable;
/// No description provided for @kycErrorSmall.
///
/// In en, this message translates to:
/// **'The photo is too small. Use an image at least 600 pixels wide and high.'**
String get kycErrorSmall;
/// No description provided for @kycErrorIncomplete.
///
/// In en, this message translates to:
/// **'Choose both photos and confirm they belong to you before submitting.'**
String get kycErrorIncomplete;
/// No description provided for @kycErrorSession.
///
/// In en, this message translates to:
/// **'Your session has expired. Return to login, then check your verification status.'**
String get kycErrorSession;
/// No description provided for @kycErrorRate.
///
/// In en, this message translates to:
/// **'Too many requests. Wait a moment before trying again.'**
String get kycErrorRate;
/// No description provided for @kycErrorConflict.
///
/// In en, this message translates to:
/// **'Your review status has changed. Refresh it before continuing.'**
String get kycErrorConflict;
/// No description provided for @kycDemoControls.
///
/// In en, this message translates to:
/// **'Demo: simulate a backoffice decision'**
String get kycDemoControls;
/// No description provided for @kycDemoApprove.
///
/// In en, this message translates to:
/// **'Approve'**
String get kycDemoApprove;
/// No description provided for @kycDemoCorrect.
///
/// In en, this message translates to:
/// **'Request clearer passport'**
String get kycDemoCorrect;
/// No description provided for @kycDemoSelfie.
///
/// In en, this message translates to:
/// **'Request clearer selfie'**
String get kycDemoSelfie;
/// No description provided for @kycDemoExpired.
///
/// In en, this message translates to:
/// **'Passport expired'**
String get kycDemoExpired;
/// No description provided for @kycDemoDeny.
///
/// In en, this message translates to:
/// **'Decline'**
String get kycDemoDeny;
/// No description provided for @kycPreview.
///
/// In en, this message translates to:
/// **'Explore verification & limits'**
String get kycPreview;
/// No description provided for @kycChooseFirst.
///
/// In en, this message translates to:
/// **'Choose a photo to continue.'**
String get kycChooseFirst;
/// No description provided for @kycStatusStale.
///
/// In en, this message translates to:
/// **'Status could not be refreshed. The status shown is the last known result.'**
String get kycStatusStale;
}
class _AppLocalizationsDelegate
@@ -242,4 +242,264 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get demoResend => 'Prepare demo email again';
@override
String get kycTitle => 'Verification & limits';
@override
String get kycIntro =>
'Your account, at your pace. Verify your identity when you need higher limits.';
@override
String get kycDemo =>
'DEMO · Use sample images only. Photos stay in memory, are never uploaded, and disappear when you leave or reload. Review decisions are simulated.';
@override
String get kycUnverified => 'Not verified';
@override
String get kycPending => 'In review';
@override
String get kycApproved => 'Identity verified';
@override
String get kycChanges => 'New photos needed';
@override
String get kycDenied => 'Verification declined';
@override
String get kycUnverifiedBody =>
'You can keep using your account with basic limits. Submit a passport photo and a selfie holding it to request an upgrade.';
@override
String get kycPendingBody =>
'Your submission is awaiting a human review. Basic limits remain active until approval. You do not need to submit again.';
@override
String get kycApprovedBody =>
'Your review is complete. Check your current limits below.';
@override
String get kycChangesBody =>
'Your account remains available with basic limits. Replace the photos and submit a new review request.';
@override
String get kycDeniedBody =>
'Basic limits remain available. If you believe this decision is incorrect, contact support for a review.';
@override
String get kycPassportReason =>
'The passport details could not be read. Retake the photo in good light, with every edge visible.';
@override
String get kycSelfieReason =>
'Your face or passport was not clear in the selfie. Keep both visible and in focus.';
@override
String get kycExpiredReason =>
'The passport has expired. Use a valid passport and take both photos again.';
@override
String get kycDeniedReason =>
'We could not verify your identity from this submission.';
@override
String get kycStart => 'Verify identity';
@override
String get kycResubmit => 'Replace photos';
@override
String get kycRefresh => 'Refresh status & limits';
@override
String get kycSupport => 'Contact support';
@override
String get kycSupportBody =>
'Support is not connected in this preview. In the live app, you will be able to request a review here without submitting documents again.';
@override
String get kycBasic => 'Basic';
@override
String get kycVerifiedTier => 'Verified';
@override
String get kycLimits => 'Your limits';
@override
String get kycLimitsDemo =>
'Illustrative limits from the demo service. These are not a live offer.';
@override
String get kycDay => 'Daily volume';
@override
String get kycMonth => 'Monthly volume';
@override
String get kycCurrent => 'Current cap';
@override
String get kycRemaining => 'Available';
@override
String get kycAfter => 'After approval';
@override
String get kycLimitsError =>
'Limits could not be refreshed. Any values below are out of date. Retry to see your current access.';
@override
String get kycLimitsEmpty =>
'No limits were returned. Refresh or check with support before making a transaction.';
@override
String get kycLoading => 'Loading your status…';
@override
String get kycPassport => 'Passport photo';
@override
String get kycSelfie => 'Selfie with passport';
@override
String get kycReview => 'Review your photos';
@override
String get kycPassportHelp =>
'Open your valid passport to the photo page. Show all four edges and keep text sharp. Avoid glare, blur and covered details.';
@override
String get kycSelfieHelp =>
'Hold the open passport beside your face. Show your whole face and the photo page in the same image. Keep both sharp and uncovered.';
@override
String get kycFileRules =>
'JPEG or PNG · up to 10 MB · at least 600 × 600 pixels. Take a photo with your camera, then choose it here.';
@override
String get kycChoose => 'Choose photo';
@override
String get kycReplace => 'Choose a different photo';
@override
String get kycRemove => 'Remove photo';
@override
String get kycNext => 'Continue';
@override
String get kycBack => 'Back';
@override
String get kycSkip => 'Keep basic limits';
@override
String get kycClose => 'Back to account preview';
@override
String get kycReviewHelp =>
'Check that both photos are readable and show the same passport. A staff member will review them; submission does not immediately raise your limits.';
@override
String get kycConfirm => 'These photos show me and my own valid passport.';
@override
String get kycSubmit => 'Submit for review';
@override
String get kycSubmitDemo => 'Simulate submission';
@override
String get kycWorking => 'Processing… Please keep this page open.';
@override
String get kycDiscardTitle => 'Discard these photos?';
@override
String get kycDiscardBody =>
'Your draft is only in memory. Leaving will remove the selected photos; you can start again later.';
@override
String get kycStay => 'Keep editing';
@override
String get kycDiscard => 'Discard & leave';
@override
String get kycErrorUnavailable =>
'The service is unavailable. Your selected photos are still here. Try again.';
@override
String get kycErrorInternal =>
'Something went wrong. Try again; your selected photos have been kept.';
@override
String get kycErrorPermission =>
'We could not open that photo. Check file access or choose another image.';
@override
String get kycErrorFormat =>
'Choose a JPEG or PNG image. Other formats are not supported.';
@override
String get kycErrorLarge =>
'This image is too large. Choose one under 10 MB and 40 megapixels.';
@override
String get kycErrorUnreadable =>
'This image is damaged or cannot be read. Choose another photo.';
@override
String get kycErrorSmall =>
'The photo is too small. Use an image at least 600 pixels wide and high.';
@override
String get kycErrorIncomplete =>
'Choose both photos and confirm they belong to you before submitting.';
@override
String get kycErrorSession =>
'Your session has expired. Return to login, then check your verification status.';
@override
String get kycErrorRate =>
'Too many requests. Wait a moment before trying again.';
@override
String get kycErrorConflict =>
'Your review status has changed. Refresh it before continuing.';
@override
String get kycDemoControls => 'Demo: simulate a backoffice decision';
@override
String get kycDemoApprove => 'Approve';
@override
String get kycDemoCorrect => 'Request clearer passport';
@override
String get kycDemoSelfie => 'Request clearer selfie';
@override
String get kycDemoExpired => 'Passport expired';
@override
String get kycDemoDeny => 'Decline';
@override
String get kycPreview => 'Explore verification & limits';
@override
String get kycChooseFirst => 'Choose a photo to continue.';
@override
String get kycStatusStale =>
'Status could not be refreshed. The status shown is the last known result.';
}
@@ -243,4 +243,264 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get demoResend => 'Подготовить демописьмо ещё раз';
@override
String get kycTitle => 'Верификация и лимиты';
@override
String get kycIntro =>
'Ваш аккаунт — ваш темп. Подтвердите личность, когда понадобятся более высокие лимиты.';
@override
String get kycDemo =>
'ДЕМО · Используйте только тестовые изображения. Фото хранятся в памяти, не отправляются и удаляются при выходе или перезагрузке. Решения проверки имитируются.';
@override
String get kycUnverified => 'Личность не подтверждена';
@override
String get kycPending => 'На проверке';
@override
String get kycApproved => 'Личность подтверждена';
@override
String get kycChanges => 'Нужны новые фото';
@override
String get kycDenied => 'Верификация отклонена';
@override
String get kycUnverifiedBody =>
'Вы можете пользоваться аккаунтом с базовыми лимитами. Для повышения отправьте фото паспорта и селфи с ним.';
@override
String get kycPendingBody =>
'Заявка ожидает проверки сотрудником. До одобрения действуют базовые лимиты. Повторная отправка не нужна.';
@override
String get kycApprovedBody =>
'Проверка завершена. Актуальные лимиты указаны ниже.';
@override
String get kycChangesBody =>
'Аккаунт доступен с базовыми лимитами. Замените фото и отправьте новую заявку.';
@override
String get kycDeniedBody =>
'Базовые лимиты остаются доступными. Если вы не согласны с решением, обратитесь в поддержку для пересмотра.';
@override
String get kycPassportReason =>
'Данные паспорта не удалось прочитать. Переснимите при хорошем освещении, чтобы все края были видны.';
@override
String get kycSelfieReason =>
'На селфи плохо видно лицо или паспорт. Оба должны быть в кадре и в фокусе.';
@override
String get kycExpiredReason =>
'Срок действия паспорта истёк. Используйте действующий паспорт и сделайте оба фото заново.';
@override
String get kycDeniedReason =>
'По этой заявке не удалось подтвердить вашу личность.';
@override
String get kycStart => 'Подтвердить личность';
@override
String get kycResubmit => 'Заменить фото';
@override
String get kycRefresh => 'Обновить статус и лимиты';
@override
String get kycSupport => 'Обратиться в поддержку';
@override
String get kycSupportBody =>
'В демоверсии поддержка не подключена. В рабочем приложении здесь можно будет запросить пересмотр без повторной отправки документов.';
@override
String get kycBasic => 'Базовый';
@override
String get kycVerifiedTier => 'Подтверждённый';
@override
String get kycLimits => 'Ваши лимиты';
@override
String get kycLimitsDemo =>
'Пример лимитов из демосервиса. Это не действующее предложение.';
@override
String get kycDay => 'Объём за день';
@override
String get kycMonth => 'Объём за месяц';
@override
String get kycCurrent => 'Текущий лимит';
@override
String get kycRemaining => 'Доступно';
@override
String get kycAfter => 'После одобрения';
@override
String get kycLimitsError =>
'Не удалось обновить лимиты. Значения ниже могут быть устаревшими. Повторите запрос.';
@override
String get kycLimitsEmpty =>
'Сервис не вернул лимиты. Обновите данные или уточните их в поддержке перед операцией.';
@override
String get kycLoading => 'Загружаем ваш статус…';
@override
String get kycPassport => 'Фото паспорта';
@override
String get kycSelfie => 'Селфи с паспортом';
@override
String get kycReview => 'Проверьте фото';
@override
String get kycPassportHelp =>
'Откройте действующий паспорт на странице с фото. Все четыре края и текст должны быть чётко видны. Избегайте бликов и размытия, не закрывайте данные.';
@override
String get kycSelfieHelp =>
'Держите открытый паспорт рядом с лицом. Лицо целиком и страница с фото должны быть в одном кадре, чёткими и ничем не закрытыми.';
@override
String get kycFileRules =>
'JPEG или PNG · до 10 МБ · не менее 600 × 600 пикселей. Сделайте фото камерой, затем выберите его здесь.';
@override
String get kycChoose => 'Выбрать фото';
@override
String get kycReplace => 'Выбрать другое фото';
@override
String get kycRemove => 'Удалить фото';
@override
String get kycNext => 'Продолжить';
@override
String get kycBack => 'Назад';
@override
String get kycSkip => 'Оставить базовые лимиты';
@override
String get kycClose => 'К демоаккаунту';
@override
String get kycReviewHelp =>
'Убедитесь, что оба фото читаемы и на них один паспорт. Их проверит сотрудник. Отправка не повышает лимиты сразу.';
@override
String get kycConfirm => 'На фото я и мой действующий паспорт.';
@override
String get kycSubmit => 'Отправить на проверку';
@override
String get kycSubmitDemo => 'Имитировать отправку';
@override
String get kycWorking => 'Обработка… Не закрывайте страницу.';
@override
String get kycDiscardTitle => 'Удалить выбранные фото?';
@override
String get kycDiscardBody =>
'Черновик хранится только в памяти. При выходе выбранные фото будут удалены. Позже можно начать заново.';
@override
String get kycStay => 'Продолжить';
@override
String get kycDiscard => 'Удалить и выйти';
@override
String get kycErrorUnavailable =>
'Сервис недоступен. Выбранные фото сохранены в памяти. Повторите попытку.';
@override
String get kycErrorInternal =>
'Произошла ошибка. Повторите попытку; выбранные фото сохранены.';
@override
String get kycErrorPermission =>
'Не удалось открыть фото. Проверьте доступ к файлам или выберите другое изображение.';
@override
String get kycErrorFormat =>
'Выберите изображение JPEG или PNG. Другие форматы не поддерживаются.';
@override
String get kycErrorLarge =>
'Изображение слишком большое. Выберите файл до 10 МБ и 40 мегапикселей.';
@override
String get kycErrorUnreadable =>
'Изображение повреждено или не читается. Выберите другое фото.';
@override
String get kycErrorSmall =>
'Фото слишком маленькое. Ширина и высота должны быть не менее 600 пикселей.';
@override
String get kycErrorIncomplete =>
'Выберите оба фото и подтвердите, что на них вы и ваш паспорт.';
@override
String get kycErrorSession =>
'Сессия истекла. Войдите снова и проверьте статус верификации.';
@override
String get kycErrorRate =>
'Слишком много запросов. Немного подождите и повторите попытку.';
@override
String get kycErrorConflict =>
'Статус проверки изменился. Обновите его, прежде чем продолжить.';
@override
String get kycDemoControls => 'Демо: имитация решения сотрудника';
@override
String get kycDemoApprove => 'Одобрить';
@override
String get kycDemoCorrect => 'Запросить чёткое фото паспорта';
@override
String get kycDemoSelfie => 'Запросить чёткое селфи';
@override
String get kycDemoExpired => 'Паспорт просрочен';
@override
String get kycDemoDeny => 'Отклонить';
@override
String get kycPreview => 'Посмотреть верификацию и лимиты';
@override
String get kycChooseFirst => 'Выберите фото для продолжения.';
@override
String get kycStatusStale =>
'Не удалось обновить статус. Показан последний известный результат.';
}
+37
View File
@@ -0,0 +1,37 @@
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import '../generated/i18n/app_localizations.dart';
String statusTitle(AppLocalizations l, IdentityStatus s) => switch (s) {
IdentityStatus.unverified => l.kycUnverified,
IdentityStatus.pending => l.kycPending,
IdentityStatus.approved => l.kycApproved,
IdentityStatus.changesRequested => l.kycChanges,
IdentityStatus.denied => l.kycDenied,
};
String statusBody(AppLocalizations l, IdentityStatus s) => switch (s) {
IdentityStatus.unverified => l.kycUnverifiedBody,
IdentityStatus.pending => l.kycPendingBody,
IdentityStatus.approved => l.kycApprovedBody,
IdentityStatus.changesRequested => l.kycChangesBody,
IdentityStatus.denied => l.kycDeniedBody,
};
String reasonText(AppLocalizations l, ReviewReason r) => switch (r) {
ReviewReason.passportUnreadable => l.kycPassportReason,
ReviewReason.selfieUnreadable => l.kycSelfieReason,
ReviewReason.passportExpired => l.kycExpiredReason,
ReviewReason.unableToVerify => l.kycDeniedReason,
};
String errorText(AppLocalizations l, KycError e) => switch (e) {
KycError.unavailable => l.kycErrorUnavailable,
KycError.internal => l.kycErrorInternal,
KycError.permission => l.kycErrorPermission,
KycError.format => l.kycErrorFormat,
KycError.tooLarge => l.kycErrorLarge,
KycError.unreadable => l.kycErrorUnreadable,
KycError.tooSmall => l.kycErrorSmall,
KycError.incomplete => l.kycErrorIncomplete,
KycError.sessionExpired => l.kycErrorSession,
KycError.rateLimited => l.kycErrorRate,
KycError.conflict => l.kycErrorConflict,
};
+87
View File
@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'photo.dart';
import '../generated/i18n/app_localizations.dart';
class IdentityForm extends StatelessWidget {
const IdentityForm({super.key, required this.provider});
final IdentityProvider provider;
@override
Widget build(BuildContext context) {
final p = provider;
final l = AppLocalizations.of(context)!;
final titles = [l.kycPassport, l.kycSelfie, l.kycReview];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Semantics(
header: true,
liveRegion: true,
child: Text(
'${p.step + 1} / 3 · ${titles[p.step]}',
style: Theme.of(context).textTheme.headlineSmall,
),
),
const SizedBox(height: 16),
LinearProgressIndicator(
value: (p.step + 1) / 3,
semanticsLabel: titles[p.step],
),
const SizedBox(height: 16),
if (p.step < 2)
PhotoPanel(
kind: p.step == 0 ? PhotoKind.passport : PhotoKind.selfie,
photo: p.step == 0 ? p.passport : p.selfie,
onPick: p.busy
? null
: () => p.pick(
p.step == 0 ? PhotoKind.passport : PhotoKind.selfie,
),
onRemove: p.busy
? null
: () => p.remove(
p.step == 0 ? PhotoKind.passport : PhotoKind.selfie,
),
),
if (p.step == 2) ...[
Text(l.kycReviewHelp),
const SizedBox(height: 16),
PhotoPanel(kind: PhotoKind.passport, photo: p.passport, review: true),
PhotoPanel(kind: PhotoKind.selfie, photo: p.selfie, review: true),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
value: p.confirmed,
onChanged: p.busy ? null : (v) => p.confirm(v ?? false),
title: Text(l.kycConfirm),
),
],
const SizedBox(height: 16),
if (p.step < 2) ...[
if ((p.step == 0 ? p.passport : p.selfie) == null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(l.kycChooseFirst),
),
FilledButton(
key: const ValueKey('kyc-next'),
onPressed: p.busy || (p.step == 0 ? p.passport : p.selfie) == null
? null
: p.next,
child: Text(l.kycNext),
),
] else
FilledButton(
key: const ValueKey('kyc-submit'),
onPressed: p.canSubmit ? p.submit : null,
child: Text(p.isDemo ? l.kycSubmitDemo : l.kycSubmit),
),
if (p.step > 0)
TextButton(onPressed: p.busy ? null : p.back, child: Text(l.kycBack)),
],
);
}
}
+111
View File
@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import '../generated/i18n/app_localizations.dart';
class LimitsPanel extends StatelessWidget {
const LimitsPanel({super.key});
@override
Widget build(BuildContext context) {
final p = context.watch<LimitsProvider>();
final l = AppLocalizations.of(context)!;
final t = Theme.of(context);
final data = p.data;
return Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l.kycLimits, style: t.textTheme.headlineSmall),
if (p.loading) ...[
const SizedBox(height: 16),
const LinearProgressIndicator(),
],
if (p.failed) ...[
const SizedBox(height: 12),
Semantics(
liveRegion: true,
child: Text(
l.kycLimitsError,
style: TextStyle(color: t.colorScheme.error),
),
),
TextButton(
onPressed: p.loading ? null : p.refresh,
child: Text(l.kycRefresh),
),
],
if (data != null) ...[
const SizedBox(height: 12),
Text(
data.tier == AccountTier.basic ? l.kycBasic : l.kycVerifiedTier,
style: t.textTheme.titleLarge,
),
if (data.simulated)
Text(l.kycLimitsDemo, style: t.textTheme.bodySmall),
if (data.rows.isEmpty) Text(l.kycLimitsEmpty),
for (final row in data.rows) ...[
const Divider(height: 32),
Text(
row.period == LimitPeriod.day ? l.kycDay : l.kycMonth,
style: t.textTheme.titleMedium,
),
const SizedBox(height: 12),
Wrap(
spacing: 28,
runSpacing: 16,
children: [
_amount(
context,
l.kycCurrent,
row.currentMinor,
row.currency,
),
_amount(
context,
l.kycRemaining,
row.remainingMinor,
row.currency,
),
if (data.tier == AccountTier.basic)
_amount(
context,
l.kycAfter,
row.verifiedMinor,
row.currency,
),
],
),
],
],
],
),
),
);
}
Widget _amount(
BuildContext context,
String label,
int minor,
String currency,
) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label),
Text(
NumberFormat.simpleCurrency(
locale: Localizations.localeOf(context).toLanguageTag(),
name: currency,
).format(minor / 100),
style: Theme.of(context).textTheme.titleLarge,
),
],
);
}
+240
View File
@@ -0,0 +1,240 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import '../auth/brand_panel.dart';
import '../routing/routes.dart';
import '../widgets/locale.dart';
import 'copy.dart';
import 'form.dart';
import 'limits.dart';
import 'status.dart';
import '../generated/i18n/app_localizations.dart';
class IdentityPage extends StatefulWidget {
const IdentityPage({super.key});
@override
State<IdentityPage> createState() => IdentityPageState();
}
class IdentityPageState extends State<IdentityPage> {
late final _limits = LimitsProvider(context.read<LimitsService>());
late final _identity = IdentityProvider(
context.read<IdentityService>(),
context.read<PhotoService>(),
JourneyRecorder(context.read<AnalyticsService>()),
onStatusChanged: _limits.refresh,
);
final _contentKey = GlobalKey();
final _feedbackKey = GlobalKey();
final _focus = FocusNode(skipTraversal: true);
String? _lastView;
void _transition() {
final p = _identity;
if (p.busy) return;
final view = '${p.editing}:${p.step}:${p.record?.status}:${p.error}';
if (_lastView == null) {
_lastView = view;
return;
}
if (view == _lastView) return;
_lastView = view;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final target = p.error != null
? _feedbackKey.currentContext
: _contentKey.currentContext;
if (target != null) {
Scrollable.ensureVisible(
target,
alignment: 0.05,
duration: const Duration(milliseconds: 180),
);
}
if (p.error == null) _focus.requestFocus();
});
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_identity.addListener(_transition);
_identity.refresh();
}
});
}
Future<bool> allowExit() async {
if (_identity.busy) return false;
if (!_identity.hasDraft) return true;
final l = AppLocalizations.of(context)!;
return await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l.kycDiscardTitle),
content: Text(l.kycDiscardBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(l.kycStay),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(l.kycDiscard),
),
],
),
) ??
false;
}
Future<void> _leave({bool account = false}) async {
if (!await allowExit() || !mounted) return;
_identity.exit();
if (account) context.goNamed(AppRoutes.login);
}
@override
void dispose() {
_identity.removeListener(_transition);
_identity.dispose();
_focus.dispose();
_limits.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => ChangeNotifierProvider.value(
value: _limits,
child: ListenableBuilder(
listenable: _identity,
builder: (context, _) {
final p = _identity;
final l = AppLocalizations.of(context)!;
final t = Theme.of(context);
return PopScope(
canPop: !p.hasDraft && !p.busy,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _leave(account: true);
},
child: Scaffold(
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Wrap(
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 16,
children: [BrandWordmark(), LocaleSelector()],
),
const SizedBox(height: 36),
Semantics(
header: true,
child: Text(
l.kycTitle,
style: t.textTheme.displaySmall,
),
),
const SizedBox(height: 12),
Text(l.kycIntro, style: t.textTheme.bodyLarge),
const SizedBox(height: 20),
if (p.isDemo)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: t.colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Text(
l.kycDemo,
style: TextStyle(
color: t.colorScheme.onTertiaryContainer,
),
),
),
const SizedBox(height: 24),
if (p.busy) ...[
const LinearProgressIndicator(),
const SizedBox(height: 12),
Semantics(
liveRegion: true,
child: Text(
p.record == null ? l.kycLoading : l.kycWorking,
),
),
const SizedBox(height: 16),
],
if (p.error != null) ...[
Semantics(
key: _feedbackKey,
liveRegion: true,
child: Text(
errorText(l, p.error!),
key: const ValueKey('kyc-error'),
style: TextStyle(color: t.colorScheme.error),
),
),
const SizedBox(height: 16),
],
if (p.stale && p.record != null)
Text(
l.kycStatusStale,
style: TextStyle(color: t.colorScheme.error),
),
Focus(
key: _contentKey,
focusNode: _focus,
child: p.editing
? IdentityForm(provider: p)
: p.record != null
? StatusPanel(provider: p)
: const SizedBox.shrink(),
),
if (!p.editing || p.stale)
TextButton(
onPressed: p.busy ? null : p.refresh,
child: Text(l.kycRefresh),
),
if (!p.editing) ...[
const SizedBox(height: 16),
const LimitsPanel(),
],
const SizedBox(height: 16),
if (p.editing)
TextButton(
onPressed: p.busy ? null : _leave,
child: Text(l.kycSkip),
),
TextButton(
onPressed: p.busy
? null
: () => _leave(account: true),
child: Text(l.kycClose),
),
],
),
),
),
),
),
),
);
},
),
);
}
+78
View File
@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'preview.dart';
import '../generated/i18n/app_localizations.dart';
class PhotoPanel extends StatelessWidget {
const PhotoPanel({
super.key,
required this.kind,
required this.photo,
this.onPick,
this.onRemove,
this.review = false,
});
final PhotoKind kind;
final Photo? photo;
final VoidCallback? onPick;
final VoidCallback? onRemove;
final bool review;
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final t = Theme.of(context);
final passport = kind == PhotoKind.passport;
final title = passport ? l.kycPassport : l.kycSelfie;
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(title, style: t.textTheme.titleLarge),
const SizedBox(height: 16),
if (photo != null)
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: PhotoPreview(photo: photo!, label: title),
)
else
Container(
height: 140,
decoration: BoxDecoration(
color: t.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
),
child: Icon(
passport ? Icons.badge_outlined : Icons.portrait_outlined,
size: 64,
color: t.colorScheme.primary,
),
),
if (!review) ...[
const SizedBox(height: 16),
Text(passport ? l.kycPassportHelp : l.kycSelfieHelp),
const SizedBox(height: 12),
Text(l.kycFileRules, style: t.textTheme.bodySmall),
const SizedBox(height: 16),
OutlinedButton.icon(
key: ValueKey('pick-${kind.name}'),
onPressed: onPick,
icon: const Icon(Icons.add_photo_alternate_outlined),
label: Text(
photo == null ? l.kycChoose : l.kycReplace,
textAlign: TextAlign.center,
),
),
if (photo != null)
TextButton(onPressed: onRemove, child: Text(l.kycRemove)),
],
],
),
),
);
}
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import '../generated/i18n/app_localizations.dart';
/// Evict this sensitive preview's decoded cache entry on replacement or exit.
class PhotoPreview extends StatefulWidget {
const PhotoPreview({super.key, required this.photo, required this.label});
final Photo photo;
final String label;
@override
State<PhotoPreview> createState() => _PhotoPreviewState();
}
class _PhotoPreviewState extends State<PhotoPreview> {
late ImageProvider _image = _provider();
ImageProvider _provider() =>
ResizeImage.resizeIfNeeded(1000, null, MemoryImage(widget.photo.bytes));
@override
void didUpdateWidget(covariant PhotoPreview oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.photo, widget.photo)) {
_image.evict();
_image = _provider();
}
}
@override
void dispose() {
_image.evict();
super.dispose();
}
@override
Widget build(BuildContext context) => Image(
image: _image,
height: 220,
fit: BoxFit.contain,
semanticLabel: widget.label,
errorBuilder: (_, _, _) =>
Text(AppLocalizations.of(context)!.kycErrorUnreadable),
);
}
+158
View File
@@ -0,0 +1,158 @@
import 'package:flutter/material.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'copy.dart';
import '../generated/i18n/app_localizations.dart';
class StatusPanel extends StatelessWidget {
const StatusPanel({super.key, required this.provider});
final IdentityProvider provider;
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final t = Theme.of(context);
final p = provider;
final record = p.record!;
final status = record.status;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: t.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(24),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
switch (status) {
IdentityStatus.approved => Icons.verified_outlined,
IdentityStatus.pending => Icons.hourglass_top,
IdentityStatus.changesRequested ||
IdentityStatus.denied => Icons.info_outline,
_ => Icons.shield_outlined,
},
color: t.colorScheme.onPrimaryContainer,
size: 36,
),
const SizedBox(height: 16),
Semantics(
header: true,
liveRegion: true,
child: Text(
statusTitle(l, status),
key: ValueKey('kyc-${status.name}'),
style: t.textTheme.headlineMedium?.copyWith(
color: t.colorScheme.onPrimaryContainer,
),
),
),
const SizedBox(height: 12),
Text(
statusBody(l, status),
style: TextStyle(color: t.colorScheme.onPrimaryContainer),
),
if (record.reason != null) ...[
const SizedBox(height: 12),
Text(
reasonText(l, record.reason!),
style: TextStyle(
color: t.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.bold,
),
),
],
],
),
),
const SizedBox(height: 20),
if (record.canSubmit)
FilledButton(
key: const ValueKey('kyc-start'),
onPressed: p.busy || p.stale ? null : p.start,
child: Text(
status == IdentityStatus.changesRequested
? l.kycResubmit
: l.kycStart,
),
),
if (status == IdentityStatus.denied)
OutlinedButton(
onPressed: () => showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: Text(l.kycSupport),
content: Text(l.kycSupportBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l.kycBack),
),
],
),
),
child: Text(l.kycSupport),
),
if (status == IdentityStatus.pending &&
p.service is DummyIdentityService)
ExpansionTile(
title: Text(l.kycDemoControls),
children: [
Wrap(
spacing: 8,
runSpacing: 8,
children: [
TextButton(
onPressed: p.busy
? null
: () => p.demoReview(IdentityStatus.approved),
child: Text(l.kycDemoApprove),
),
TextButton(
onPressed: p.busy
? null
: () => p.demoReview(
IdentityStatus.changesRequested,
reason: ReviewReason.passportUnreadable,
),
child: Text(l.kycDemoCorrect),
),
TextButton(
onPressed: p.busy
? null
: () => p.demoReview(
IdentityStatus.changesRequested,
reason: ReviewReason.selfieUnreadable,
),
child: Text(l.kycDemoSelfie),
),
TextButton(
onPressed: p.busy
? null
: () => p.demoReview(
IdentityStatus.changesRequested,
reason: ReviewReason.passportExpired,
),
child: Text(l.kycDemoExpired),
),
TextButton(
onPressed: p.busy
? null
: () => p.demoReview(
IdentityStatus.denied,
reason: ReviewReason.unableToVerify,
),
child: Text(l.kycDemoDeny),
),
],
),
],
),
],
);
}
}
+77 -1
View File
@@ -72,5 +72,81 @@
"demoVerifiedBody": "You’ve previewed email confirmation. No account was verified. Return to login to explore the demo.",
"verificationRecipient": "Recipient",
"verificationChangeEmail": "Change email",
"demoResend": "Prepare demo email again"
"demoResend": "Prepare demo email again",
"kycTitle": "Verification & limits",
"kycIntro": "Your account, at your pace. Verify your identity when you need higher limits.",
"kycDemo": "DEMO · Use sample images only. Photos stay in memory, are never uploaded, and disappear when you leave or reload. Review decisions are simulated.",
"kycUnverified": "Not verified",
"kycPending": "In review",
"kycApproved": "Identity verified",
"kycChanges": "New photos needed",
"kycDenied": "Verification declined",
"kycUnverifiedBody": "You can keep using your account with basic limits. Submit a passport photo and a selfie holding it to request an upgrade.",
"kycPendingBody": "Your submission is awaiting a human review. Basic limits remain active until approval. You do not need to submit again.",
"kycApprovedBody": "Your review is complete. Check your current limits below.",
"kycChangesBody": "Your account remains available with basic limits. Replace the photos and submit a new review request.",
"kycDeniedBody": "Basic limits remain available. If you believe this decision is incorrect, contact support for a review.",
"kycPassportReason": "The passport details could not be read. Retake the photo in good light, with every edge visible.",
"kycSelfieReason": "Your face or passport was not clear in the selfie. Keep both visible and in focus.",
"kycExpiredReason": "The passport has expired. Use a valid passport and take both photos again.",
"kycDeniedReason": "We could not verify your identity from this submission.",
"kycStart": "Verify identity",
"kycResubmit": "Replace photos",
"kycRefresh": "Refresh status & limits",
"kycSupport": "Contact support",
"kycSupportBody": "Support is not connected in this preview. In the live app, you will be able to request a review here without submitting documents again.",
"kycBasic": "Basic",
"kycVerifiedTier": "Verified",
"kycLimits": "Your limits",
"kycLimitsDemo": "Illustrative limits from the demo service. These are not a live offer.",
"kycDay": "Daily volume",
"kycMonth": "Monthly volume",
"kycCurrent": "Current cap",
"kycRemaining": "Available",
"kycAfter": "After approval",
"kycLimitsError": "Limits could not be refreshed. Any values below are out of date. Retry to see your current access.",
"kycLimitsEmpty": "No limits were returned. Refresh or check with support before making a transaction.",
"kycLoading": "Loading your status…",
"kycPassport": "Passport photo",
"kycSelfie": "Selfie with passport",
"kycReview": "Review your photos",
"kycPassportHelp": "Open your valid passport to the photo page. Show all four edges and keep text sharp. Avoid glare, blur and covered details.",
"kycSelfieHelp": "Hold the open passport beside your face. Show your whole face and the photo page in the same image. Keep both sharp and uncovered.",
"kycFileRules": "JPEG or PNG · up to 10 MB · at least 600 × 600 pixels. Take a photo with your camera, then choose it here.",
"kycChoose": "Choose photo",
"kycReplace": "Choose a different photo",
"kycRemove": "Remove photo",
"kycNext": "Continue",
"kycBack": "Back",
"kycSkip": "Keep basic limits",
"kycClose": "Back to account preview",
"kycReviewHelp": "Check that both photos are readable and show the same passport. A staff member will review them; submission does not immediately raise your limits.",
"kycConfirm": "These photos show me and my own valid passport.",
"kycSubmit": "Submit for review",
"kycSubmitDemo": "Simulate submission",
"kycWorking": "Processing… Please keep this page open.",
"kycDiscardTitle": "Discard these photos?",
"kycDiscardBody": "Your draft is only in memory. Leaving will remove the selected photos; you can start again later.",
"kycStay": "Keep editing",
"kycDiscard": "Discard & leave",
"kycErrorUnavailable": "The service is unavailable. Your selected photos are still here. Try again.",
"kycErrorInternal": "Something went wrong. Try again; your selected photos have been kept.",
"kycErrorPermission": "We could not open that photo. Check file access or choose another image.",
"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.",
"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.",
"kycErrorRate": "Too many requests. Wait a moment before trying again.",
"kycErrorConflict": "Your review status has changed. Refresh it before continuing.",
"kycDemoControls": "Demo: simulate a backoffice decision",
"kycDemoApprove": "Approve",
"kycDemoCorrect": "Request clearer passport",
"kycDemoSelfie": "Request clearer selfie",
"kycDemoExpired": "Passport expired",
"kycDemoDeny": "Decline",
"kycPreview": "Explore verification & limits",
"kycChooseFirst": "Choose a photo to continue.",
"kycStatusStale": "Status could not be refreshed. The status shown is the last known result."
}
+77 -1
View File
@@ -72,5 +72,81 @@
"demoVerifiedBody": "Вы посмотрели подтверждение почты. Реальный аккаунт не подтверждён. Вернитесь ко входу, чтобы продолжить знакомство с демоверсией.",
"verificationRecipient": "Получатель",
"verificationChangeEmail": "Изменить почту",
"demoResend": "Подготовить демописьмо ещё раз"
"demoResend": "Подготовить демописьмо ещё раз",
"kycTitle": "Верификация и лимиты",
"kycIntro": "Ваш аккаунт — ваш темп. Подтвердите личность, когда понадобятся более высокие лимиты.",
"kycDemo": "ДЕМО · Используйте только тестовые изображения. Фото хранятся в памяти, не отправляются и удаляются при выходе или перезагрузке. Решения проверки имитируются.",
"kycUnverified": "Личность не подтверждена",
"kycPending": "На проверке",
"kycApproved": "Личность подтверждена",
"kycChanges": "Нужны новые фото",
"kycDenied": "Верификация отклонена",
"kycUnverifiedBody": "Вы можете пользоваться аккаунтом с базовыми лимитами. Для повышения отправьте фото паспорта и селфи с ним.",
"kycPendingBody": "Заявка ожидает проверки сотрудником. До одобрения действуют базовые лимиты. Повторная отправка не нужна.",
"kycApprovedBody": "Проверка завершена. Актуальные лимиты указаны ниже.",
"kycChangesBody": "Аккаунт доступен с базовыми лимитами. Замените фото и отправьте новую заявку.",
"kycDeniedBody": "Базовые лимиты остаются доступными. Если вы не согласны с решением, обратитесь в поддержку для пересмотра.",
"kycPassportReason": "Данные паспорта не удалось прочитать. Переснимите при хорошем освещении, чтобы все края были видны.",
"kycSelfieReason": "На селфи плохо видно лицо или паспорт. Оба должны быть в кадре и в фокусе.",
"kycExpiredReason": "Срок действия паспорта истёк. Используйте действующий паспорт и сделайте оба фото заново.",
"kycDeniedReason": "По этой заявке не удалось подтвердить вашу личность.",
"kycStart": "Подтвердить личность",
"kycResubmit": "Заменить фото",
"kycRefresh": "Обновить статус и лимиты",
"kycSupport": "Обратиться в поддержку",
"kycSupportBody": "В демоверсии поддержка не подключена. В рабочем приложении здесь можно будет запросить пересмотр без повторной отправки документов.",
"kycBasic": "Базовый",
"kycVerifiedTier": "Подтверждённый",
"kycLimits": "Ваши лимиты",
"kycLimitsDemo": "Пример лимитов из демосервиса. Это не действующее предложение.",
"kycDay": "Объём за день",
"kycMonth": "Объём за месяц",
"kycCurrent": "Текущий лимит",
"kycRemaining": "Доступно",
"kycAfter": "После одобрения",
"kycLimitsError": "Не удалось обновить лимиты. Значения ниже могут быть устаревшими. Повторите запрос.",
"kycLimitsEmpty": "Сервис не вернул лимиты. Обновите данные или уточните их в поддержке перед операцией.",
"kycLoading": "Загружаем ваш статус…",
"kycPassport": "Фото паспорта",
"kycSelfie": "Селфи с паспортом",
"kycReview": "Проверьте фото",
"kycPassportHelp": "Откройте действующий паспорт на странице с фото. Все четыре края и текст должны быть чётко видны. Избегайте бликов и размытия, не закрывайте данные.",
"kycSelfieHelp": "Держите открытый паспорт рядом с лицом. Лицо целиком и страница с фото должны быть в одном кадре, чёткими и ничем не закрытыми.",
"kycFileRules": "JPEG или PNG · до 10 МБ · не менее 600 × 600 пикселей. Сделайте фото камерой, затем выберите его здесь.",
"kycChoose": "Выбрать фото",
"kycReplace": "Выбрать другое фото",
"kycRemove": "Удалить фото",
"kycNext": "Продолжить",
"kycBack": "Назад",
"kycSkip": "Оставить базовые лимиты",
"kycClose": "К демоаккаунту",
"kycReviewHelp": "Убедитесь, что оба фото читаемы и на них один паспорт. Их проверит сотрудник. Отправка не повышает лимиты сразу.",
"kycConfirm": "На фото я и мой действующий паспорт.",
"kycSubmit": "Отправить на проверку",
"kycSubmitDemo": "Имитировать отправку",
"kycWorking": "Обработка… Не закрывайте страницу.",
"kycDiscardTitle": "Удалить выбранные фото?",
"kycDiscardBody": "Черновик хранится только в памяти. При выходе выбранные фото будут удалены. Позже можно начать заново.",
"kycStay": "Продолжить",
"kycDiscard": "Удалить и выйти",
"kycErrorUnavailable": "Сервис недоступен. Выбранные фото сохранены в памяти. Повторите попытку.",
"kycErrorInternal": "Произошла ошибка. Повторите попытку; выбранные фото сохранены.",
"kycErrorPermission": "Не удалось открыть фото. Проверьте доступ к файлам или выберите другое изображение.",
"kycErrorFormat": "Выберите изображение JPEG или PNG. Другие форматы не поддерживаются.",
"kycErrorLarge": "Изображение слишком большое. Выберите файл до 10 МБ и 40 мегапикселей.",
"kycErrorUnreadable": "Изображение повреждено или не читается. Выберите другое фото.",
"kycErrorSmall": "Фото слишком маленькое. Ширина и высота должны быть не менее 600 пикселей.",
"kycErrorIncomplete": "Выберите оба фото и подтвердите, что на них вы и ваш паспорт.",
"kycErrorSession": "Сессия истекла. Войдите снова и проверьте статус верификации.",
"kycErrorRate": "Слишком много запросов. Немного подождите и повторите попытку.",
"kycErrorConflict": "Статус проверки изменился. Обновите его, прежде чем продолжить.",
"kycDemoControls": "Демо: имитация решения сотрудника",
"kycDemoApprove": "Одобрить",
"kycDemoCorrect": "Запросить чёткое фото паспорта",
"kycDemoSelfie": "Запросить чёткое селфи",
"kycDemoExpired": "Паспорт просрочен",
"kycDemoDeny": "Отклонить",
"kycPreview": "Посмотреть верификацию и лимиты",
"kycChooseFirst": "Выберите фото для продолжения.",
"kycStatusStale": "Не удалось обновить статус. Показан последний известный результат."
}
+16 -3
View File
@@ -6,6 +6,8 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'package:sxchnge_theme/sxchnge_theme.dart';
import 'providers/locale.dart';
@@ -24,12 +26,18 @@ class SxchngeApp extends StatefulWidget {
super.key,
this.localeProvider,
this.analyticsService,
this.identityService,
this.limitsService,
this.photoService = const FilePhotoService(),
this.loginService = const DummyLoginService(),
this.signupService = const DummySignupService(),
this.verificationService = const DummyVerificationService(),
});
final LocaleProvider? localeProvider;
final AnalyticsService? analyticsService;
final IdentityService? identityService;
final LimitsService? limitsService;
final PhotoService photoService;
final LoginService loginService;
final SignupService signupService;
final VerificationService verificationService;
@@ -41,9 +49,10 @@ class SxchngeApp extends StatefulWidget {
class _SxchngeAppState extends State<SxchngeApp> {
late final _locale = widget.localeProvider ?? LocaleProvider();
late final _journeys = JourneyRecorder(
widget.analyticsService ?? DummyAnalyticsService(),
);
late final _analytics = widget.analyticsService ?? DummyAnalyticsService();
late final _identity = widget.identityService ?? DummyIdentityService();
late final _limits = widget.limitsService ?? DummyLimitsService(_identity);
late final _journeys = JourneyRecorder(_analytics);
late final _services = InstrumentedAuthServices(
recorder: _journeys,
loginService: widget.loginService,
@@ -81,6 +90,10 @@ class _SxchngeAppState extends State<SxchngeApp> {
Widget build(BuildContext context) => MultiProvider(
providers: [
Provider<JourneyRecorder>.value(value: _journeys),
Provider<AnalyticsService>.value(value: _analytics),
Provider<IdentityService>.value(value: _identity),
Provider<LimitsService>.value(value: _limits),
Provider<PhotoService>.value(value: widget.photoService),
ChangeNotifierProvider.value(value: _locale),
Provider<VerificationService>.value(value: _services),
ChangeNotifierProvider(create: (_) => LoginProvider(_services)),
+38 -25
View File
@@ -1,37 +1,50 @@
import 'dart:convert';
import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart';
import '../auth/page.dart';
import '../kyc/page.dart';
import '../verification/page.dart';
import 'routes.dart';
GoRouter createAppRouter() => GoRouter(
// Signup email is an in-memory navigation extra, never browser-history data.
extraCodec: const _TransientExtras(),
routes: [
GoRoute(
path: '/',
name: AppRoutes.login,
builder: (context, state) => const AuthPage(),
),
GoRoute(
path: '/verification-sent',
name: AppRoutes.verificationSent,
builder: (context, state) => VerificationPage(
email: state.extra is String ? state.extra as String : null,
GoRouter createAppRouter() {
final identityKey = GlobalKey<IdentityPageState>();
return GoRouter(
// Signup email is an in-memory navigation extra, never browser-history data.
extraCodec: const _TransientExtras(),
routes: [
GoRoute(
path: '/identity',
name: AppRoutes.identity,
builder: (context, state) => IdentityPage(key: identityKey),
onExit: (context, state) async =>
await identityKey.currentState?.allowExit() ?? true,
),
),
GoRoute(
path: '/verify-email',
name: AppRoutes.verifyEmail,
builder: (context, state) =>
VerificationPage(token: state.uri.queryParameters['token'] ?? ''),
),
],
// Preserve the existing fallback, and keep the visible URL canonical.
onException: (context, state, router) => router.goNamed(AppRoutes.login),
);
GoRoute(
path: '/',
name: AppRoutes.login,
builder: (context, state) => const AuthPage(),
),
GoRoute(
path: '/verification-sent',
name: AppRoutes.verificationSent,
builder: (context, state) => VerificationPage(
email: state.extra is String ? state.extra as String : null,
),
),
GoRoute(
path: '/verify-email',
name: AppRoutes.verifyEmail,
builder: (context, state) =>
VerificationPage(token: state.uri.queryParameters['token'] ?? ''),
),
],
// Preserve the existing fallback, and keep the visible URL canonical.
onException: (context, state, router) => router.goNamed(AppRoutes.login),
);
}
class _TransientExtras extends Codec<Object?, Object?> {
const _TransientExtras();
+1
View File
@@ -1,4 +1,5 @@
abstract final class AppRoutes {
static const identity = 'identity';
static const login = 'login';
static const verificationSent = 'verification-sent';
static const verifyEmail = 'verify-email';
+7
View File
@@ -325,6 +325,13 @@ class _VerificationPageState extends State<VerificationPage> {
child: Text(l.verificationRequest),
),
],
if (verified) ...[
const SizedBox(height: 16),
FilledButton(
onPressed: () => context.goNamed(AppRoutes.identity),
child: Text(l.kycPreview),
),
],
const SizedBox(height: 16),
TextButton(
onPressed: () => context.goNamed(AppRoutes.login),
+127
View File
@@ -1,6 +1,14 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
archive:
dependency: transitive
description:
name: archive
sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19
url: "https://pub.dev"
source: hosted
version: "4.2.0"
async:
dependency: transitive
description:
@@ -41,6 +49,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6
url: "https://pub.dev"
source: hosted
version: "0.3.5+5"
cupertino_icons:
dependency: "direct main"
description:
@@ -81,6 +97,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_selector:
dependency: transitive
description:
name: file_selector
sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a
url: "https://pub.dev"
source: hosted
version: "1.1.0"
file_selector_android:
dependency: transitive
description:
name: file_selector_android
sha256: d670cd0ce77a2e785b18d8b4d0a8d6a222d6a813ec9b7ddf2790a1b4fb6fa92c
url: "https://pub.dev"
source: hosted
version: "0.5.2+11"
file_selector_ios:
dependency: transitive
description:
name: file_selector_ios
sha256: "97269e5307a0ab813b1fa2430bada0a96e0afb74848417f8676f64ba5de0051c"
url: "https://pub.dev"
source: hosted
version: "0.5.3+6"
file_selector_linux:
dependency: transitive
description:
name: file_selector_linux
sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab
url: "https://pub.dev"
source: hosted
version: "0.9.4+1"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4
url: "https://pub.dev"
source: hosted
version: "0.9.5+1"
file_selector_platform_interface:
dependency: transitive
description:
name: file_selector_platform_interface
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
file_selector_web:
dependency: transitive
description:
name: file_selector_web
sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3"
url: "https://pub.dev"
source: hosted
version: "0.9.5"
file_selector_windows:
dependency: transitive
description:
name: file_selector_windows
sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec
url: "https://pub.dev"
source: hosted
version: "0.9.3+6"
flutter:
dependency: "direct main"
description: flutter
@@ -117,6 +197,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "18.0.1"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e"
url: "https://pub.dev"
source: hosted
version: "4.9.2"
intl:
dependency: "direct main"
description:
@@ -253,6 +357,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
posix:
dependency: transitive
description:
name: posix
sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e
url: "https://pub.dev"
source: hosted
version: "6.5.2"
provider:
dependency: "direct main"
description:
@@ -368,6 +480,13 @@ packages:
relative: true
source: path
version: "0.1.0"
sxchnge_kyc:
dependency: "direct main"
description:
path: "../packages/sxchnge_kyc"
relative: true
source: path
version: "0.1.0"
sxchnge_theme:
dependency: "direct main"
description:
@@ -391,6 +510,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.12"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vector_math:
dependency: transitive
description:
+2
View File
@@ -6,6 +6,8 @@ environment:
sdk: '>=3.13.0 <4.0.0'
flutter: '>=3.47.0'
dependencies:
sxchnge_kyc:
path: ../packages/sxchnge_kyc
sxchnge_analytics:
path: ../packages/sxchnge_analytics
flutter:
+199
View File
@@ -0,0 +1,199 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'package:sxchnge_app/main.dart';
import 'package:sxchnge_app/providers/locale.dart';
class Photos implements PhotoService {
Photos(this.bytes);
final Uint8List bytes;
KycError? error;
@override
Future<Photo?> pick() async {
if (error != null) throw KycException(error!);
return Photo(bytes);
}
}
Future<void> tap(WidgetTester t, Finder finder) async {
await t.ensureVisible(finder);
await t.tap(finder);
await t.pumpAndSettle();
}
void main() {
late Uint8List bytes;
setUpAll(() async {
final r = ui.PictureRecorder();
final c = ui.Canvas(r);
c.drawRect(const Rect.fromLTWH(0, 0, 600, 600), Paint());
final pic = r.endRecording();
final image = await pic.toImage(600, 600);
bytes = (await image.toByteData(format: ui.ImageByteFormat.png))!.buffer
.asUint8List();
image.dispose();
pic.dispose();
});
Future<DummyIdentityService> open(
WidgetTester t, {
Photos? photos,
Locale? locale,
}) async {
t.platformDispatcher.defaultRouteNameTestValue = '/identity';
addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue);
final service = DummyIdentityService(delay: Duration.zero);
final provider = LocaleProvider();
if (locale != null) await provider.setLocale(locale);
await t.pumpWidget(
SxchngeApp(
identityService: service,
photoService: photos ?? Photos(bytes),
localeProvider: provider,
),
);
await t.pumpAndSettle();
return service;
}
Future<void> submit(WidgetTester t) async {
await tap(t, find.byKey(const ValueKey('kyc-start')));
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')));
await tap(t, find.byKey(const ValueKey('kyc-next')));
await tap(t, find.byType(CheckboxListTile));
await tap(t, find.byKey(const ValueKey('kyc-submit')));
}
testWidgets('two photos, review, pending and approval with service limits', (
t,
) async {
await open(t);
expect(find.byKey(const ValueKey('kyc-unverified')), findsOneWidget);
await submit(t);
expect(find.byKey(const ValueKey('kyc-pending')), findsOneWidget);
await tap(t, find.byType(ExpansionTile));
await tap(t, find.text('Approve'));
expect(find.byKey(const ValueKey('kyc-approved')), findsOneWidget);
expect(find.text('Verified'), findsOneWidget);
expect(t.takeException(), null);
});
testWidgets('denial provides explanation and support, no resubmit', (
t,
) async {
await open(t);
await submit(t);
await tap(t, find.byType(ExpansionTile));
await tap(t, find.text('Decline'));
expect(find.byKey(const ValueKey('kyc-denied')), findsOneWidget);
expect(find.byKey(const ValueKey('kyc-start')), findsNothing);
await tap(t, find.text('Contact support'));
expect(find.byType(AlertDialog), findsOneWidget);
});
testWidgets('correctable rejection can be resubmitted', (t) async {
await open(t);
await submit(t);
await tap(t, find.byType(ExpansionTile));
await tap(t, find.text('Request clearer passport'));
expect(find.byKey(const ValueKey('kyc-changesRequested')), findsOneWidget);
await submit(t);
expect(find.byKey(const ValueKey('kyc-pending')), findsOneWidget);
});
testWidgets('picker error recovery and discard confirmation', (t) async {
final photos = Photos(bytes)..error = KycError.permission;
await open(t, photos: photos);
await tap(t, find.byKey(const ValueKey('kyc-start')));
await tap(t, find.byKey(const ValueKey('pick-passport')));
expect(find.byKey(const ValueKey('kyc-error')), findsOneWidget);
photos.error = null;
await tap(t, find.byKey(const ValueKey('pick-passport')));
await tap(t, find.text('Keep basic limits'));
expect(find.byType(AlertDialog), findsOneWidget);
await tap(t, find.text('Keep editing'));
expect(find.byKey(const ValueKey('pick-passport')), findsOneWidget);
await tap(t, find.text('Keep basic limits'));
await tap(t, find.text('Discard & leave'));
expect(find.byKey(const ValueKey('kyc-unverified')), findsOneWidget);
});
testWidgets(
'320px Russian at large text works through submission and keyboard',
(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);
await open(t, locale: const Locale('ru'));
expect(find.text('Верификация и лимиты'), findsOneWidget);
await t.sendKeyEvent(LogicalKeyboardKey.tab);
await t.pump();
await submit(t);
expect(find.byKey(const ValueKey('kyc-pending')), findsOneWidget);
expect(t.takeException(), null);
},
);
testWidgets('status failure exposes retry without asserting basic state', (
t,
) async {
t.platformDispatcher.defaultRouteNameTestValue = '/identity';
addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue);
final service = DummyIdentityService(delay: Duration.zero)
..nextError = KycError.unavailable;
await t.pumpWidget(
SxchngeApp(
identityService: service,
photoService: Photos(bytes),
analyticsService: DummyAnalyticsService(),
),
);
await t.pumpAndSettle();
expect(find.byKey(const ValueKey('kyc-unverified')), findsNothing);
await tap(t, find.text('Refresh status & limits'));
expect(find.byKey(const ValueKey('kyc-unverified')), findsOneWidget);
});
testWidgets('route guard preserves draft when navigation is cancelled', (
t,
) async {
await open(t);
await tap(t, find.byKey(const ValueKey('kyc-start')));
await tap(t, find.byKey(const ValueKey('pick-passport')));
final router = GoRouter.of(t.element(find.text('Verification & limits')));
router.go('/');
await t.pumpAndSettle();
expect(find.byType(AlertDialog), findsOneWidget);
await tap(t, find.text('Keep editing'));
expect(find.byKey(const ValueKey('pick-passport')), findsOneWidget);
router.go('/');
await t.pumpAndSettle();
await tap(t, find.text('Discard & leave'));
expect(find.byKey(const ValueKey('kyc-start')), findsNothing);
expect(find.text('Explore verification & limits'), findsOneWidget);
});
testWidgets('overview controls have accessible labels and touch targets', (
t,
) async {
final semantics = t.ensureSemantics();
await open(t);
expect(t, meetsGuideline(labeledTapTargetGuideline));
expect(t, meetsGuideline(androidTapTargetGuideline));
final start = find.byKey(const ValueKey('kyc-start'));
await t.ensureVisible(start);
final buttonContext = t.element(start);
FocusScope.of(buttonContext).requestFocus(Focus.of(buttonContext));
await t.sendKeyEvent(LogicalKeyboardKey.tab);
await t.pump();
expect(t.takeException(), null);
semantics.dispose();
});
}