Add optional KYC flow and service-backed account limits
This commit is contained in:
1 parent
61ccfc1844
commit
3397f04ab7
38 files changed
+3443
-36
No files matched your search
@@ -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 =>
|
||||
'Не удалось обновить статус. Показан последний известный результат.';
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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": "Не удалось обновить статус. Показан последний известный результат."
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user