Add optional identity verification and service-backed limits #21

Merged
tech merged 3 commits from kyc-20 into main 2026-09-12 10:12:01 +00:00
38 changed files with 3421 additions and 13 deletions

No files matched your search

+1
View File
@@ -1,6 +1,7 @@
.PHONY: test-frontend .PHONY: test-frontend
test-frontend: test-frontend:
cd frontend/packages/sxchnge_kyc && flutter test
cd frontend/packages/sxchnge_analytics && flutter test cd frontend/packages/sxchnge_analytics && flutter test
cd frontend/packages/sxchnge_auth && flutter test cd frontend/packages/sxchnge_auth && flutter test
cd frontend/packages/sxchnge_theme && flutter test cd frontend/packages/sxchnge_theme && flutter test
+15
View File
@@ -169,3 +169,18 @@ Follow [`AGENTS.md`](AGENTS.md) and the applicable scoped guidelines, including
Implementation work uses a Gitea task and linked branch, validation, commits, Implementation work uses a Gitea task and linked branch, validation, commits,
a push and a PR. Merge only when authorized. Record scope, validation and known a push and a PR. Merge only when authorized. Record scope, validation and known
limitations in the task and [implementation record](docs/implementation.md). limitations in the task and [implementation record](docs/implementation.md).
## Identity verification preview
Open `/#/identity` or select **Explore verification & limits** from login or email
confirmation. The optional flow covers passport and selfie selection, review,
submission, pending review, approval, correction requests and denial in EN/RU.
Use sample images only: photos are never uploaded and drafts are not persisted.
The shared [sxchnge_kyc package](frontend/packages/sxchnge_kyc/README.md) provides
identity and limits services/providers for web and future mobile clients. Limits
are fetched through `LimitsProvider → LimitsService`; fixture amounts and simulated
review decisions demonstrate the future backend contract. Higher limits apply
only when returned by that service. Production review/storage/support and financial
authorization are not connected. See the [UX plan](docs/ux/kyc.md) for state, error,
privacy and measurement details.
+26
View File
@@ -152,6 +152,32 @@ skill location and contribution workflow. Local links and diff checks pass;
commands and prerequisites were checked against repository configuration. commands and prerequisites were checked against repository configuration.
Documentation only; no runtime tests were run. Documentation only; no runtime tests were run.
## Optional identity verification and backend-owned limits — task #20
- Branch: `kyc-20`, based on `61ccfc1` (main after README PR #19).
- Planned the UX before implementation in [docs/ux/kyc.md](ux/kyc.md), including
optional basic access, two-photo preparation/review, pending and decision states,
errors, recovery, privacy and measurement definitions.
- Added the mobile-reusable `sxchnge_kyc` package, injectable identity/photo/limits
services, transport mappers and providers. Followed Sendico's visible-limits
ownership boundary; current tier/caps/remaining amounts come from a service.
- Added themed EN/RU `/identity` screens, login/email-confirmation entry points,
route exit guards, review/replace/discard controls, safe error messages and
demo-only backoffice decision controls. No real documents are transmitted.
- Reviewed full frontend scope and corrected web image-metadata validation,
stale-state messaging, cancelled-picker analytics, step focus/scroll behavior,
duplicate submissions and sensitive image cache cleanup.
- Validation: format check for all 27 changed/new Dart files; analyzer passes for
app, sxchnge_kyc and sxchnge_analytics; `make test-frontend` passes **86 tests**
(10 KYC, 7 analytics, 20 auth, 3 theme, 3 consumer example, 43 app). Web build
passes. Actual browser selection of generated PNG samples reached pending
review with unchanged basic limits. EN/RU, 320px/1.6 text scale, accessibility
labels/tap targets, route guards and all decision paths have widget coverage.
- Limits are illustrative backend-stub fixtures. Real authenticated uploads,
private storage, staff review, support, authoritative limits enforcement and
server-side review analytics remain integration work. Native mobile and
VoiceOver/TalkBack checks remain release QA. Security scans were not requested.
- Delivery: [PR #21](https://git.sendico.io/tech/sxchnge/pulls/21), pushed on `kyc-20`; not merged. Final browser review also preserved heading semantics when moving keyboard focus.
## UX simplicity preference ## UX simplicity preference
Task [#26](https://git.sendico.io/tech/sxchnge/issues/26), delivered in Task [#26](https://git.sendico.io/tech/sxchnge/issues/26), delivered in
+149
View File
@@ -0,0 +1,149 @@
# Identity verification UX plan
Task [#20](https://git.sendico.io/tech/sxchnge/issues/20). Plan recorded before implementation.
## User outcome and scope
Understand current access, decide whether higher limits are useful, and submit two
readable photos for a human review. No KYC is a valid account state. Email
confirmation and identity verification remain separate. The existing app is a demo:
this release simulates review, holds photos only in memory and never uploads them.
It must say so before selection and submission; use sample images only.
## Journey
1. **Verification & limits**: status, active tier, basic versus verified daily and
monthly limits, and one relevant next action. Preview policy is explicitly
labeled as fixtures in the stub service. A draft, pending review,
correction request or denial keeps basic limits; only approval enables the
verified tier. This is presentation, never financial authorization.
2. **Passport**: photograph the open identity/photo page of a valid passport;
all edges visible, readable text, no glare or fingers covering details.
3. **Selfie with passport**: face and open passport visible in the same photo,
unobstructed and in focus. Explain the difference with an icon and guidance.
Each slot supports JPEG/PNG up to 10 MiB, preview, replacement and removal.
Validate content/decodability, size and dimensions, not just extensions.
4. **Review**: both previews, ability to return and replace, explicit confirmation
that photos belong to the user, then submit for human review. No automatic
approval, invented review SLA or instant increase in limits.
5. **Review status**: pending message, unchanged limits and manual refresh. A
correctable rejection has a specific safe reason and a fresh submission path;
final denial offers support guidance and no futile resubmit control. Approval
clearly shows the active higher tier. No forced upgrade or dead-end gate.
Back preserves the in-memory draft; leaving the flow asks whether to discard
selected photos. Refresh/closing clears a demo session. Successful submission
releases photo bytes. Never put images, paths or filenames in routes, analytics,
logs, preferences or task records. Demo decision controls are explicitly separate
from customer actions and operate only with the dummy service.
## State and recovery contract
| State | Tier | Action |
|---|---|---|
| Not verified | Basic | Start optional verification |
| Local draft | Basic | Continue / discard |
| Loading status | Unknown | Wait; don't assert basic/verified before response |
| Status unavailable | Last known, marked stale | Retry; block new submission until refreshed |
| In review | Basic | Refresh; continue account use |
| Changes requested | Basic | Reason: unreadable passport, unreadable selfie, expired passport; replace and resubmit |
| Denied | Basic | Explain decision category; contact support for review |
| Verified | Verified | See increased limits |
Picker cancellation is neutral. Invalid type, oversized/corrupt/too-small image,
permission/read failure and unavailable submission have distinct recoverable
messages. Keep valid photos on failed replacement/submission. Serialize operations,
prevent duplicate submits, ignore results after disposal. Server adapters must
provide idempotent submissions and current status/revision, enforce account and
email access, handle session expiry and rate limits, and validate images again.
Do not infer identity validity from client image checks.
## Measurement
Use existing typed AnalyticsService/JourneyRecorder. A KYC submission journey
starts when the user starts/restarts verification. Semantic stages: preparation,
passport selection, selfie selection, submission. Replacements/retries are
operations in the same journey. Confirmed service acceptance is the terminal
submission outcome, **not identity approval**; demo outcomes are simulated.
Explicit draft discard/navigation or 30 minutes of settled inactivity marks
abandonment. Do not count time waiting for human review as abandonment. Review
status refresh is a separate terminal observation, not a new approval conversion.
Measure start-to-submission conversion, per-stage failures and recovery, elapsed
submission time and explicit/inactivity abandonment. Only enum reason categories,
random journey IDs, operation sequence and elapsed time are collected. No photo
content/metadata, names, emails, document numbers, URLs or exception strings.
Disable third-party session replay/autocapture on identity routes when PostHog is
added. Production time-to-decision and approval rate require server-side review
events correlated by an opaque submission ID; frontend refresh cannot establish
those metrics. Cross-session resume requires the future backend.
## Visual and accessibility plan
Reuse theme colors/typography, rounded panels, locale selector and GoRouter.
Single scrollable mobile column; constrained desktop reading width. Text labels
and icons accompany status colors. Visible numbered steps, back/skip controls,
minimum touch targets, keyboard-accessible buttons, live-region operation/error
feedback, image semantic labels without filenames. Check EN/RU, 320px viewport,
large text, success/failure paths and analytics isolation before delivery.
## Production integration prerequisites
This frontend scope does not implement a staff backoffice, document storage or a
real KYC policy. An authenticated backend must own entitlements, limits and review
transitions. Provide private upload/download authorization, human-review roles,
audit trail, safe rejection categories, retention/deletion policy, user privacy
notice/support destination, idempotency and malware/content checks before enabling
real documents. No production claims, deadlines or statutory requirements are
invented here. Native camera capture can later complement the shared file picker.
## Sendico limits alignment
Inspected Sendico ADR 0001 and `api/proto/limits/v1/limits.proto`
(`ListVisibleLimits`, `VisibleLimitSlot`). No matching Flutter limits provider was
found in that checkout. Follow its ownership boundary: LimitsService.fetch()
returns backend-evaluated tier, visible caps, remaining capacity and upgrade
comparison. LimitsProvider loads/refreshes those models; widgets never derive
active limits from KYC. The dummy service performs the backend stand-in evaluation.
The visible projection does not reserve capacity or authorize payments.
## Implementation and review evidence
- Optional `/identity` demo entry from login and confirmed-email screens. No live
session is implied. GoRouter exit guard and discard confirmation protect drafts;
reload/closing the browser remains destructive to this explicitly transient demo.
- Shared package uses transport DTO → mapper → service → model → provider. Limits
are independently fetched and expose current/remaining/upgrade values.
- Typed analytics separates submission from status observation; picker cancellation
has a neutral cancelled signal. Simulated submission is not real identity approval.
- Review fixed a Flutter Web incompatibility: encoded ImageDescriptor dimensions
are unsupported. Image metadata is now read before bounded decoding using the
cross-platform image package. Actual browser file selection with a generated
640×640 PNG succeeds for both slots and reaches pending review with basic limits.
- EN/RU widget journeys cover approval, correction/resubmission, denial/support,
errors, discard, and 320px width at 1.6 text scale. Controls use theme colors and
labels; headings/errors expose live-region semantics, step changes move focus
and scroll context. Native VoiceOver/TalkBack and device camera QA remain release
checks for future mobile consumers; no camera capture is implemented here.
- Photo previews evict their image-cache entry on replacement/disposal. Providers
release photo references on exit/submission/disposal. No secure-memory-erasure
guarantee or browser-termination telemetry is claimed.
## Measurement definitions for dashboards
The denominator for submission conversion is journeys containing a requested
`kycPreparation` step; status-only refresh journeys are excluded. Numerator is a
terminal `kycSubmission` service outcome, counted once per journey. Exclude
`simulated=true` from production dashboards. Stage failure rate divides failed
operations by requested operations of that stage; cancelled pickers are reported
separately. Compare recovered submissions within the same journey, and calculate
duration from the first preparation request to acceptance. Explicit exit and
settled 30-minute inactivity are separate abandonment categories. Refresh after an
uncertain submission reconciles server status but cannot prove an end-to-end
submission conversion; production correlation needs the backend submission ID.
Final validation: format check (27 Dart files), analyzer in all three affected
packages, `make test-frontend` (86 tests) and release web build passed. The UX
checklist was reviewed against the implemented demo scope; production/backend
and native assistive-technology gaps are recorded above rather than represented
as complete.
+4
View File
@@ -310,6 +310,10 @@ class _AuthFormState extends State<AuthForm> {
AuthFeedback(state: state, signUp: _signUp), AuthFeedback(state: state, signUp: _signUp),
], ],
const SizedBox(height: 20), const SizedBox(height: 20),
TextButton(
onPressed: () => context.goNamed(AppRoutes.identity),
child: Text(l.kycPreview),
),
Wrap( Wrap(
alignment: WrapAlignment.center, alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
@@ -535,6 +535,462 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Prepare demo email again'** /// **'Prepare demo email again'**
String get demoResend; 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 class _AppLocalizationsDelegate
@@ -242,4 +242,264 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get demoResend => 'Prepare demo email again'; 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 @override
String get demoResend => 'Подготовить демописьмо ещё раз'; 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,
),
],
);
}
+241
View File
@@ -0,0 +1,241 @@
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(
includeSemantics: false,
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.", "demoVerifiedBody": "You’ve previewed email confirmation. No account was verified. Return to login to explore the demo.",
"verificationRecipient": "Recipient", "verificationRecipient": "Recipient",
"verificationChangeEmail": "Change email", "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": "Вы посмотрели подтверждение почты. Реальный аккаунт не подтверждён. Вернитесь ко входу, чтобы продолжить знакомство с демоверсией.", "demoVerifiedBody": "Вы посмотрели подтверждение почты. Реальный аккаунт не подтверждён. Вернитесь ко входу, чтобы продолжить знакомство с демоверсией.",
"verificationRecipient": "Получатель", "verificationRecipient": "Получатель",
"verificationChangeEmail": "Изменить почту", "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_auth/sxchnge_auth.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'package:sxchnge_theme/sxchnge_theme.dart'; import 'package:sxchnge_theme/sxchnge_theme.dart';
import 'providers/locale.dart'; import 'providers/locale.dart';
@@ -24,12 +26,18 @@ class SxchngeApp extends StatefulWidget {
super.key, super.key,
this.localeProvider, this.localeProvider,
this.analyticsService, this.analyticsService,
this.identityService,
this.limitsService,
this.photoService = const FilePhotoService(),
this.loginService = const DummyLoginService(), this.loginService = const DummyLoginService(),
this.signupService = const DummySignupService(), this.signupService = const DummySignupService(),
this.verificationService = const DummyVerificationService(), this.verificationService = const DummyVerificationService(),
}); });
final LocaleProvider? localeProvider; final LocaleProvider? localeProvider;
final AnalyticsService? analyticsService; final AnalyticsService? analyticsService;
final IdentityService? identityService;
final LimitsService? limitsService;
final PhotoService photoService;
final LoginService loginService; final LoginService loginService;
final SignupService signupService; final SignupService signupService;
final VerificationService verificationService; final VerificationService verificationService;
@@ -41,9 +49,10 @@ class SxchngeApp extends StatefulWidget {
class _SxchngeAppState extends State<SxchngeApp> { class _SxchngeAppState extends State<SxchngeApp> {
late final _locale = widget.localeProvider ?? LocaleProvider(); late final _locale = widget.localeProvider ?? LocaleProvider();
late final _journeys = JourneyRecorder( late final _analytics = widget.analyticsService ?? DummyAnalyticsService();
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( late final _services = InstrumentedAuthServices(
recorder: _journeys, recorder: _journeys,
loginService: widget.loginService, loginService: widget.loginService,
@@ -81,6 +90,10 @@ class _SxchngeAppState extends State<SxchngeApp> {
Widget build(BuildContext context) => MultiProvider( Widget build(BuildContext context) => MultiProvider(
providers: [ providers: [
Provider<JourneyRecorder>.value(value: _journeys), 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), ChangeNotifierProvider.value(value: _locale),
Provider<VerificationService>.value(value: _services), Provider<VerificationService>.value(value: _services),
ChangeNotifierProvider(create: (_) => LoginProvider(_services)), ChangeNotifierProvider(create: (_) => LoginProvider(_services)),
+15 -2
View File
@@ -1,15 +1,27 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../auth/page.dart'; import '../auth/page.dart';
import '../kyc/page.dart';
import '../verification/page.dart'; import '../verification/page.dart';
import 'routes.dart'; import 'routes.dart';
GoRouter createAppRouter() => GoRouter( GoRouter createAppRouter() {
final identityKey = GlobalKey<IdentityPageState>();
return GoRouter(
// Signup email is an in-memory navigation extra, never browser-history data. // Signup email is an in-memory navigation extra, never browser-history data.
extraCodec: const _TransientExtras(), extraCodec: const _TransientExtras(),
routes: [ routes: [
GoRoute(
path: '/identity',
name: AppRoutes.identity,
builder: (context, state) => IdentityPage(key: identityKey),
onExit: (context, state) async =>
await identityKey.currentState?.allowExit() ?? true,
),
GoRoute( GoRoute(
path: '/', path: '/',
name: AppRoutes.login, name: AppRoutes.login,
@@ -31,7 +43,8 @@ GoRouter createAppRouter() => GoRouter(
], ],
// Preserve the existing fallback, and keep the visible URL canonical. // Preserve the existing fallback, and keep the visible URL canonical.
onException: (context, state, router) => router.goNamed(AppRoutes.login), onException: (context, state, router) => router.goNamed(AppRoutes.login),
); );
}
class _TransientExtras extends Codec<Object?, Object?> { class _TransientExtras extends Codec<Object?, Object?> {
const _TransientExtras(); const _TransientExtras();
+1
View File
@@ -1,4 +1,5 @@
abstract final class AppRoutes { abstract final class AppRoutes {
static const identity = 'identity';
static const login = 'login'; static const login = 'login';
static const verificationSent = 'verification-sent'; static const verificationSent = 'verification-sent';
static const verifyEmail = 'verify-email'; static const verifyEmail = 'verify-email';
+7
View File
@@ -325,6 +325,13 @@ class _VerificationPageState extends State<VerificationPage> {
child: Text(l.verificationRequest), child: Text(l.verificationRequest),
), ),
], ],
if (verified) ...[
const SizedBox(height: 16),
FilledButton(
onPressed: () => context.goNamed(AppRoutes.identity),
child: Text(l.kycPreview),
),
],
const SizedBox(height: 16), const SizedBox(height: 16),
TextButton( TextButton(
onPressed: () => context.goNamed(AppRoutes.login), onPressed: () => context.goNamed(AppRoutes.login),
+127
View File
@@ -1,6 +1,14 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
archive:
dependency: transitive
description:
name: archive
sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19
url: "https://pub.dev"
source: hosted
version: "4.2.0"
async: async:
dependency: transitive dependency: transitive
description: description:
@@ -41,6 +49,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" 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: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -81,6 +97,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.1" 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: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -117,6 +197,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "18.0.1" 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: intl:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -253,6 +357,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
posix:
dependency: transitive
description:
name: posix
sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e
url: "https://pub.dev"
source: hosted
version: "6.5.2"
provider: provider:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -368,6 +480,13 @@ packages:
relative: true relative: true
source: path source: path
version: "0.1.0" version: "0.1.0"
sxchnge_kyc:
dependency: "direct main"
description:
path: "../packages/sxchnge_kyc"
relative: true
source: path
version: "0.1.0"
sxchnge_theme: sxchnge_theme:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -391,6 +510,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.12" 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: vector_math:
dependency: transitive dependency: transitive
description: description:
+2
View File
@@ -6,6 +6,8 @@ environment:
sdk: '>=3.13.0 <4.0.0' sdk: '>=3.13.0 <4.0.0'
flutter: '>=3.47.0' flutter: '>=3.47.0'
dependencies: dependencies:
sxchnge_kyc:
path: ../packages/sxchnge_kyc
sxchnge_analytics: sxchnge_analytics:
path: ../packages/sxchnge_analytics path: ../packages/sxchnge_analytics
flutter: 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();
});
}
@@ -2,11 +2,22 @@ import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'dart:math'; import 'dart:math';
enum JourneyKind { login, signup, verification } enum JourneyKind { login, signup, verification, kyc }
enum JourneyStep { login, signup, emailDelivery, emailConfirmation } enum JourneyStep {
login,
signup,
emailDelivery,
emailConfirmation,
kycPreparation,
kycPassport,
kycSelfie,
kycSubmission,
kycStatus,
}
enum JourneySignal { enum JourneySignal {
cancelled,
started, started,
requested, requested,
succeeded, succeeded,
@@ -16,6 +27,7 @@ enum JourneySignal {
} }
enum JourneyFailure { enum JourneyFailure {
document,
invalidCredentials, invalidCredentials,
emailInUse, emailInUse,
unavailable, unavailable,
@@ -66,8 +78,13 @@ class AnalyticsEvent {
final bool terminal; final bool terminal;
final bool simulated; final bool simulated;
final DateTime occurredAt; final DateTime occurredAt;
String get name => String get name {
'auth_${step == null ? 'journey' : _snake(step!.name)}_${signal.name}'; final area = kind == JourneyKind.kyc ? 'kyc' : 'auth';
var stage = step == null ? 'journey' : _snake(step!.name);
if (kind == JourneyKind.kyc) stage = stage.replaceFirst('kyc_', '');
return '${area}_${stage}_${signal.name}';
}
Map<String, Object> get properties => Map.unmodifiable({ Map<String, Object> get properties => Map.unmodifiable({
'schema_version': 1, 'schema_version': 1,
'event_id': eventId, 'event_id': eventId,
@@ -165,16 +182,19 @@ class JourneyRecorder {
bool simulated = false, bool simulated = false,
JourneyFailure? failure, JourneyFailure? failure,
bool terminal = false, bool terminal = false,
bool cancelled = false,
}) { }) {
final journey = operation._journey; final journey = operation._journey;
if (_disposed || operation._finished || journey.closed) return; if (_disposed || operation._finished || journey.closed) return;
operation._finished = true; operation._finished = true;
journey.pending--; journey.pending--;
journey.simulated |= simulated; journey.simulated |= simulated;
final complete = failure == null && terminal; final complete = failure == null && !cancelled && terminal;
_emit( _emit(
journey, journey,
failure != null cancelled
? JourneySignal.cancelled
: failure != null
? JourneySignal.failed ? JourneySignal.failed
: (simulated || (complete && journey.simulated)) : (simulated || (complete && journey.simulated))
? JourneySignal.simulated ? JourneySignal.simulated
+3
View File
@@ -0,0 +1,3 @@
.dart_tool/
build/
pubspec.lock
+38
View File
@@ -0,0 +1,38 @@
# sxchnge_kyc
Shared Flutter identity verification and backend-owned visible limits. Both web
and future mobile clients consume the same models, providers and service contracts.
- `IdentityProvider` coordinates draft photos, validation errors, submission,
refresh and semantic analytics. Inject `IdentityService` and `PhotoService`.
- `LimitsProvider` fetches `LimitsService` models; it does not calculate caps from
a client-selected status. Refresh after status updates. Existing values are
marked stale after an error; out-of-order responses cannot replace newer data.
- `DummyIdentityService` keeps only review metadata in memory. `DummyLimitsService`
mimics backend policy with explicitly illustrative EUR fixtures. Neither is
production authorization. `review` exists only on the dummy service.
- `FilePhotoService` opens the platform picker and validates JPEG/PNG content,
10 MiB size, minimum 600px per dimension, maximum 40 megapixels and decodability.
It performs no face matching, OCR or identity approval. Camera capture is not
implemented; users can take a photo separately and select it.
- Photo models are transient; clients must dispose providers and evict sensitive
image cache entries. No disk, browser storage, network or document telemetry is
used by these stubs. No guarantee of cryptographic memory erasure is made.
Replace both identity and limits services when connecting a backend. A submit
adapter must reuse the supplied idempotency key across retries, scope requests to
the authenticated account, map safe errors and return server-confirmed status.
Numeric values use minor currency units (currently a two-decimal currency fixture);
extend the transport with currency precision before supporting other units.
The app's `/identity` route is an explicitly public demo, not an authenticated
account endpoint. Production requires session/email guards, server authorization,
private document storage, staff review and support integration. See
[the UX/measurement contract](../../../docs/ux/kyc.md).
Validation: `flutter pub get`, `flutter analyze`, `flutter test`.
Photo selection uses Flutter's [file_selector](https://pub.dev/packages/file_selector)
and [image metadata decoding](https://pub.dev/documentation/image/latest/image/Decoder-class.html).
For native macOS consumers enable the user-selected read-only file entitlement;
iOS/Android camera permissions are not required for this file-picker-only flow.
@@ -0,0 +1,54 @@
import 'dart:typed_data';
enum IdentityStatus { unverified, pending, approved, changesRequested, denied }
enum ReviewReason {
passportUnreadable,
selfieUnreadable,
passportExpired,
unableToVerify,
}
enum PhotoKind { passport, selfie }
enum KycError {
unavailable,
internal,
permission,
format,
tooLarge,
unreadable,
tooSmall,
incomplete,
sessionExpired,
rateLimited,
conflict,
}
class KycException implements Exception {
const KycException(this.code);
final KycError code;
}
class Identity {
const Identity({
required this.status,
this.reason,
this.submissionId,
this.simulated = false,
});
final IdentityStatus status;
final ReviewReason? reason;
final String? submissionId;
final bool simulated;
bool get canSubmit =>
status == IdentityStatus.unverified ||
status == IdentityStatus.changesRequested;
}
/// Ephemeral image data only. Never serialize this model into storage or telemetry.
class Photo {
Photo(Uint8List bytes)
: bytes = Uint8List.fromList(bytes).asUnmodifiableView();
final Uint8List bytes;
}
@@ -0,0 +1,31 @@
enum AccountTier { basic, verified }
enum LimitPeriod { day, month }
class VisibleLimit {
const VisibleLimit({
required this.period,
required this.currency,
required this.basicMinor,
required this.verifiedMinor,
required this.currentMinor,
required this.remainingMinor,
});
final LimitPeriod period;
final String currency;
final int basicMinor;
final int verifiedMinor;
final int currentMinor;
final int remainingMinor;
}
class Limits {
Limits({
required this.tier,
required List<VisibleLimit> rows,
required this.simulated,
}) : rows = List.unmodifiable(rows);
final AccountTier tier;
final List<VisibleLimit> rows;
final bool simulated;
}
@@ -0,0 +1,235 @@
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import '../models/identity.dart';
import '../services/contracts.dart';
import '../services/dummy.dart';
class IdentityProvider extends ChangeNotifier {
IdentityProvider(
this.service,
this.photos,
this.journeys, {
this.onStatusChanged,
});
final Future<void> Function()? onStatusChanged;
final IdentityService service;
final PhotoService photos;
final JourneyRecorder journeys;
Identity? record;
Photo? passport;
Photo? selfie;
KycError? error;
PhotoKind? photoError;
bool busy = false;
bool stale = false;
bool editing = false;
bool confirmed = false;
int step = 0;
bool _disposed = false;
String? _key;
bool get hasDraft => passport != null || selfie != null;
bool get canSubmit =>
!busy &&
!stale &&
confirmed &&
passport != null &&
selfie != null &&
(record?.canSubmit ?? false);
bool get isDemo => service.isSimulated;
void _notify() {
if (!_disposed) notifyListeners();
}
JourneyOperation _begin(JourneyStep step) =>
journeys.begin(JourneyKind.kyc, step, simulated: isDemo);
Future<void> refresh() async {
if (busy) return;
busy = true;
error = null;
photoError = null;
_notify();
final op = _begin(JourneyStep.kycStatus);
try {
final result = await service.fetch();
if (_disposed) return;
record = result;
stale = false;
await onStatusChanged?.call();
if (_disposed) return;
if (!result.canSubmit) {
_clear();
editing = false;
}
journeys.finish(op, simulated: isDemo, terminal: true);
} catch (e) {
if (_disposed) return;
stale = true;
error = _error(e);
journeys.finish(op, failure: JourneyFailure.unavailable, terminal: false);
}
busy = false;
_notify();
}
void start() {
if (busy || stale || !(record?.canSubmit ?? false)) return;
editing = true;
error = null;
if (!hasDraft) {
step = 0;
confirmed = false;
}
final op = _begin(JourneyStep.kycPreparation);
journeys.finish(op, simulated: isDemo);
_notify();
}
Future<void> pick(PhotoKind kind) async {
if (busy || !editing) return;
busy = true;
error = null;
photoError = null;
_notify();
final op = _begin(
kind == PhotoKind.passport
? JourneyStep.kycPassport
: JourneyStep.kycSelfie,
);
try {
final photo = await photos.pick();
if (_disposed) return;
if (photo != null) {
if (kind == PhotoKind.passport) {
passport = photo;
} else {
selfie = photo;
}
confirmed = false;
_key = null;
}
journeys.finish(op, simulated: isDemo, cancelled: photo == null);
} catch (e) {
if (_disposed) return;
error = _error(e);
photoError = kind;
journeys.finish(op, failure: JourneyFailure.document);
}
busy = false;
_notify();
}
void remove(PhotoKind kind) {
if (busy) return;
if (kind == PhotoKind.passport) {
passport = null;
} else {
selfie = null;
}
confirmed = false;
_key = null;
_notify();
}
void back() {
if (!busy && step > 0) {
step--;
error = null;
_notify();
}
}
void next() {
if (busy ||
(step == 0 && passport == null) ||
(step == 1 && selfie == null))
return;
if (step < 2) step++;
error = null;
_notify();
}
void confirm(bool value) {
if (!busy) {
confirmed = value;
_notify();
}
}
Future<void> submit() async {
if (!canSubmit) return;
busy = true;
error = null;
photoError = null;
_notify();
_key ??= List.generate(
16,
(_) => Random.secure().nextInt(256).toRadixString(16).padLeft(2, '0'),
).join();
final op = _begin(JourneyStep.kycSubmission);
try {
final result = await service.submit(
passport: passport!,
selfie: selfie!,
idempotencyKey: _key!,
);
if (_disposed) return;
if (result.canSubmit) throw const KycException(KycError.conflict);
record = result;
editing = false;
_clear();
await onStatusChanged?.call();
if (_disposed) return;
journeys.finish(op, simulated: isDemo, terminal: true);
} catch (e) {
if (_disposed) return;
error = _error(e);
if (error == KycError.conflict || error == KycError.sessionExpired)
stale = true;
journeys.finish(op, failure: JourneyFailure.unavailable);
}
busy = false;
_notify();
}
Future<void> demoReview(IdentityStatus status, {ReviewReason? reason}) async {
if (busy || service is! DummyIdentityService) return;
try {
(service as DummyIdentityService).review(status, reason: reason);
} on KycException catch (e) {
error = e.code;
_notify();
return;
}
await refresh();
}
void exit() {
if (busy) return;
journeys.abandon(JourneyExit.navigation);
_clear();
editing = false;
error = null;
_notify();
}
void _clear() {
passport = null;
selfie = null;
confirmed = false;
_key = null;
step = 0;
}
KycError _error(Object e) => e is KycException ? e.code : KycError.internal;
@override
void dispose() {
_disposed = true;
_clear();
journeys.dispose();
super.dispose();
}
}
@@ -0,0 +1,37 @@
import 'package:flutter/foundation.dart';
import '../models/limits.dart';
import '../services/contracts.dart';
class LimitsProvider extends ChangeNotifier {
LimitsProvider(this.service);
final LimitsService service;
Limits? data;
bool loading = false;
bool failed = false;
int _revision = 0;
bool _disposed = false;
Future<void> refresh() async {
final revision = ++_revision;
loading = true;
failed = false;
notifyListeners();
try {
final result = await service.fetch();
if (_disposed || revision != _revision) return;
data = result;
} catch (_) {
if (_disposed || revision != _revision) return;
failed = true;
}
if (_disposed || revision != _revision) return;
loading = false;
notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
@@ -0,0 +1,24 @@
import '../models/identity.dart';
import '../models/limits.dart';
abstract interface class IdentityService {
bool get isSimulated;
Future<Identity> fetch();
/// Backend adapters must scope the key to the authenticated account and reject
/// stale/duplicate review transitions. Retrying the same key is idempotent.
Future<Identity> submit({
required Photo passport,
required Photo selfie,
required String idempotencyKey,
});
}
abstract interface class LimitsService {
/// Fetch server-owned visible limits. No client-selected KYC status or tier.
Future<Limits> fetch();
}
abstract interface class PhotoService {
Future<Photo?> pick();
}
@@ -0,0 +1,93 @@
import '../models/identity.dart';
import '../models/limits.dart';
import '../transport/contracts.dart';
import 'contracts.dart';
/// Process-local backend stand-in. It never stores or sends document bytes.
class DummyIdentityService implements IdentityService {
DummyIdentityService({this.delay = const Duration(milliseconds: 450)});
final Duration delay;
IdentityDTO _record = const IdentityDTO('unverified');
String? _lastKey;
KycError? nextError;
@override
bool get isSimulated => true;
Future<void> _wait() async {
await Future<void>.delayed(delay);
final error = nextError;
nextError = null;
if (error != null) throw KycException(error);
}
@override
Future<Identity> fetch() async {
await _wait();
return IdentityMapper.fromDTO(_record);
}
@override
Future<Identity> submit({
required Photo passport,
required Photo selfie,
required String idempotencyKey,
}) async {
await _wait();
if (_lastKey == idempotencyKey) return IdentityMapper.fromDTO(_record);
if (!IdentityMapper.fromDTO(_record).canSubmit)
throw const KycException(KycError.conflict);
if (passport.bytes.isEmpty || selfie.bytes.isEmpty)
throw const KycException(KycError.incomplete);
_lastKey = idempotencyKey;
_record = IdentityDTO('pending', id: idempotencyKey);
return IdentityMapper.fromDTO(_record);
}
/// Explicit demo-only human decision stand-in; unavailable in real adapters.
void review(IdentityStatus status, {ReviewReason? reason}) {
if (_record.status != 'pending')
throw const KycException(KycError.conflict);
if (![
IdentityStatus.approved,
IdentityStatus.changesRequested,
IdentityStatus.denied,
].contains(status))
throw const KycException(KycError.conflict);
_record = IdentityDTO(status.name, id: _record.id, reason: reason?.name);
}
}
/// Mimics backend policy evaluation. Widgets/providers never compute active caps.
/// Values are illustrative fixtures, not the product's real limits policy.
class DummyLimitsService implements LimitsService {
DummyLimitsService(this.identity);
final IdentityService identity;
@override
Future<Limits> fetch() async {
final record = await identity.fetch();
final approved = record.status == IdentityStatus.approved;
return LimitsMapper.fromDTO(
LimitsDTO(
tier: approved ? 'verified' : 'basic',
simulated: true,
rows: [
LimitDTO(
'day',
'EUR',
10000,
250000,
approved ? 250000 : 10000,
approved ? 250000 : 10000,
),
LimitDTO(
'month',
'EUR',
50000,
1000000,
approved ? 1000000 : 50000,
approved ? 1000000 : 50000,
),
],
),
);
}
}
@@ -0,0 +1,81 @@
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:file_selector/file_selector.dart';
import 'package:image/image.dart' as metadata;
import '../models/identity.dart';
import 'contracts.dart';
class FilePhotoService implements PhotoService {
const FilePhotoService();
static const maxBytes = 10 * 1024 * 1024;
@override
Future<Photo?> pick() async {
try {
final file = await openFile(
acceptedTypeGroups: const [
XTypeGroup(
label: 'JPEG / PNG',
extensions: ['jpg', 'jpeg', 'png'],
mimeTypes: ['image/jpeg', 'image/png'],
uniformTypeIdentifiers: ['public.jpeg', 'public.png'],
),
],
);
if (file == null) return null;
if (await file.length() > maxBytes)
throw const KycException(KycError.tooLarge);
final bytes = await file.readAsBytes();
return await validate(bytes);
} on KycException {
rethrow;
} catch (_) {
throw const KycException(KycError.permission);
}
}
static Future<Photo> validate(Uint8List bytes) async {
try {
if (bytes.length > maxBytes) throw const KycException(KycError.tooLarge);
final png =
bytes.length > 8 &&
bytes[0] == 137 &&
bytes[1] == 80 &&
bytes[2] == 78 &&
bytes[3] == 71;
final jpg =
bytes.length > 3 &&
bytes[0] == 255 &&
bytes[1] == 216 &&
bytes[2] == 255;
if (!png && !jpg) throw const KycException(KycError.format);
final metadata.Decoder decoder = png
? metadata.PngDecoder()
: metadata.JpegDecoder();
final info = decoder.startDecode(bytes);
if (info == null) throw const KycException(KycError.unreadable);
if (info.width < 600 || info.height < 600)
throw const KycException(KycError.tooSmall);
if (info.width * info.height > 40000000)
throw const KycException(KycError.tooLarge);
final codec = await ui.instantiateImageCodec(
bytes,
targetWidth: 1000,
allowUpscaling: false,
);
try {
final frame = await codec.getNextFrame();
frame.image.dispose();
} finally {
codec.dispose();
}
return Photo(bytes);
} on KycException {
rethrow;
} catch (_) {
throw const KycException(KycError.unreadable);
}
}
}
@@ -0,0 +1,64 @@
import '../models/identity.dart';
import '../models/limits.dart';
// Transport contracts for the stub. Replace with generated backend DTOs later.
class IdentityDTO {
const IdentityDTO(this.status, {this.reason, this.id, this.simulated = true});
final String status;
final String? reason;
final String? id;
final bool simulated;
}
class IdentityMapper {
static Identity fromDTO(IdentityDTO dto) => Identity(
status: IdentityStatus.values.byName(dto.status),
reason: dto.reason == null ? null : ReviewReason.values.byName(dto.reason!),
submissionId: dto.id,
simulated: dto.simulated,
);
}
class LimitsDTO {
const LimitsDTO({
required this.tier,
required this.rows,
required this.simulated,
});
final String tier;
final List<LimitDTO> rows;
final bool simulated;
}
class LimitDTO {
const LimitDTO(
this.period,
this.currency,
this.basic,
this.verified,
this.current,
this.remaining,
);
final String period;
final String currency;
final int basic, verified, current, remaining;
}
class LimitsMapper {
static Limits fromDTO(LimitsDTO dto) => Limits(
tier: AccountTier.values.byName(dto.tier),
simulated: dto.simulated,
rows: dto.rows
.map(
(r) => VisibleLimit(
period: LimitPeriod.values.byName(r.period),
currency: r.currency,
basicMinor: r.basic,
verifiedMinor: r.verified,
currentMinor: r.current,
remainingMinor: r.remaining,
),
)
.toList(),
);
}
@@ -0,0 +1,7 @@
export 'src/models/identity.dart';
export 'src/models/limits.dart';
export 'src/providers/identity.dart';
export 'src/providers/limits.dart';
export 'src/services/contracts.dart';
export 'src/services/dummy.dart';
export 'src/services/photos.dart';
@@ -0,0 +1,17 @@
name: sxchnge_kyc
description: Shared identity verification and visible limits for SXCHNGE clients.
version: 0.1.0
publish_to: none
environment:
sdk: '>=3.13.0 <4.0.0'
flutter: '>=3.47.0'
dependencies:
flutter:
sdk: flutter
image: ^4.9.0
file_selector: ^1.1.0
sxchnge_analytics:
path: ../sxchnge_analytics
dev_dependencies:
flutter_test:
sdk: flutter
@@ -0,0 +1,263 @@
import 'dart:async';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter_test/flutter_test.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
class Photos implements PhotoService {
Photo? result = Photo(Uint8List.fromList([1, 2, 3]));
KycError? error;
Completer<Photo?>? pending;
@override
Future<Photo?> pick() async {
if (error != null) throw KycException(error!);
return pending == null ? result : pending!.future;
}
}
class LimitStub implements LimitsService {
final requests = <Completer<Limits>>[];
@override
Future<Limits> fetch() {
final c = Completer<Limits>();
requests.add(c);
return c.future;
}
}
class BrokenAnalytics implements AnalyticsService {
@override
Future<void> capture(AnalyticsEvent event) => throw StateError('offline');
}
Future<void> draft(IdentityProvider p) async {
await p.refresh();
p.start();
await p.pick(PhotoKind.passport);
p.next();
await p.pick(PhotoKind.selfie);
p.next();
p.confirm(true);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late DummyIdentityService service;
late Photos photos;
late DummyAnalyticsService sink;
late IdentityProvider p;
setUp(() {
service = DummyIdentityService(delay: Duration.zero);
photos = Photos();
sink = DummyAnalyticsService();
p = IdentityProvider(service, photos, JourneyRecorder(sink));
});
tearDown(() => p.dispose());
test(
'unverified, pending, correction and denial retain basic service limits',
() async {
final limits = LimitsProvider(DummyLimitsService(service));
addTearDown(limits.dispose);
await limits.refresh();
expect(limits.data!.tier, AccountTier.basic);
await draft(p);
await p.submit();
expect(p.record!.status, IdentityStatus.pending);
expect(p.hasDraft, false);
await limits.refresh();
expect(limits.data!.tier, AccountTier.basic);
await p.demoReview(
IdentityStatus.changesRequested,
reason: ReviewReason.passportExpired,
);
await limits.refresh();
expect(limits.data!.tier, AccountTier.basic);
await draft(p);
await p.submit();
await p.demoReview(
IdentityStatus.denied,
reason: ReviewReason.unableToVerify,
);
p.start();
expect(p.editing, false);
await limits.refresh();
expect(limits.data!.tier, AccountTier.basic);
},
);
test('approval refreshes backend limits through callback', () async {
final limits = LimitsProvider(DummyLimitsService(service));
addTearDown(limits.dispose);
final subject = IdentityProvider(
service,
photos,
JourneyRecorder(sink),
onStatusChanged: limits.refresh,
);
addTearDown(subject.dispose);
await draft(subject);
final before = limits.data!.rows.first.currentMinor;
await subject.submit();
await subject.demoReview(IdentityStatus.approved);
expect(limits.data!.tier, AccountTier.verified);
expect(limits.data!.rows.first.currentMinor, greaterThan(before));
});
test('cancel and invalid replacement preserve photo', () async {
await draft(p);
p.back();
final original = p.selfie;
photos.result = null;
await p.pick(PhotoKind.selfie);
expect(p.selfie, same(original));
expect(p.error, null);
photos.error = KycError.tooLarge;
await p.pick(PhotoKind.selfie);
expect(p.selfie, same(original));
expect(p.error, KycError.tooLarge);
expect(p.step, 1);
await Future<void>.delayed(Duration.zero);
expect(sink.events.any((e) => e.signal == JourneySignal.cancelled), true);
});
test(
'submit retry preserves photos and counts one terminal simulated outcome',
() async {
await draft(p);
service.nextError = KycError.unavailable;
await p.submit();
expect(p.hasDraft, true);
expect(p.canSubmit, true);
await p.submit();
expect(p.record!.status, IdentityStatus.pending);
expect(p.hasDraft, false);
await Future<void>.delayed(Duration.zero);
final events = sink.events.where(
(e) => e.step == JourneyStep.kycSubmission,
);
expect(events.where((e) => e.terminal).length, 1);
expect(events.map((e) => e.journeyId).toSet().length, 1);
expect(events.every((e) => e.simulated), true);
expect(
sink.events
.expand((e) => e.properties.keys)
.any(
(k) => [
'email',
'filename',
'bytes',
'passport',
'token',
].contains(k),
),
false,
);
},
);
test('duplicate submit suppressed and service idempotency replays', () async {
await draft(p);
await Future.wait([p.submit(), p.submit()]);
await Future<void>.delayed(Duration.zero);
expect(
sink.events
.where(
(e) =>
e.step == JourneyStep.kycSubmission &&
e.signal == JourneySignal.requested,
)
.length,
1,
);
final replay = await service.submit(
passport: photos.result!,
selfie: photos.result!,
idempotencyKey: p.record!.submissionId!,
);
expect(replay.submissionId, p.record!.submissionId);
});
test('status error does not invent a tier or allow submission', () async {
service.nextError = KycError.unavailable;
await p.refresh();
expect(p.record, null);
expect(p.stale, true);
p.start();
expect(p.editing, false);
await p.refresh();
p.start();
expect(p.editing, true);
});
test(
'limits ignore late response and mark retained values stale on error',
() async {
final s = LimitStub();
final limits = LimitsProvider(s);
addTearDown(limits.dispose);
final old = limits.refresh();
final recent = limits.refresh();
s.requests[1].complete(
Limits(tier: AccountTier.verified, rows: [], simulated: true),
);
await recent;
s.requests[0].complete(
Limits(tier: AccountTier.basic, rows: [], simulated: true),
);
await old;
expect(limits.data!.tier, AccountTier.verified);
final failure = limits.refresh();
s.requests[2].completeError(StateError('offline'));
await failure;
expect(limits.failed, true);
expect(limits.data!.tier, AccountTier.verified);
},
);
test(
'exit discards images; analytics errors cannot block submission',
() async {
await draft(p);
p.exit();
expect(p.hasDraft, false);
await Future<void>.delayed(Duration.zero);
expect(sink.events.any((e) => e.signal == JourneySignal.abandoned), true);
final subject = IdentityProvider(
service,
photos,
JourneyRecorder(BrokenAnalytics()),
);
addTearDown(subject.dispose);
await draft(subject);
await subject.submit();
expect(subject.record!.status, IdentityStatus.pending);
},
);
test('late picker does not repopulate disposed state', () async {
final subject = IdentityProvider(service, photos, JourneyRecorder(sink));
await subject.refresh();
subject.start();
photos.pending = Completer<Photo?>();
final pending = subject.pick(PhotoKind.passport);
subject.dispose();
photos.pending!.complete(photos.result);
await pending;
expect(subject.hasDraft, false);
});
test('validates bytes, signature, corrupt encoding and resolution', () async {
Future<void> rejects(Uint8List bytes, KycError code) async => expectLater(
FilePhotoService.validate(bytes),
throwsA(isA<KycException>().having((e) => e.code, 'code', code)),
);
await rejects(Uint8List(FilePhotoService.maxBytes + 1), KycError.tooLarge);
await rejects(Uint8List.fromList([1, 2, 3]), KycError.format);
await rejects(Uint8List.fromList([255, 216, 255, 0]), KycError.unreadable);
final r = ui.PictureRecorder();
ui.Canvas(r);
final pic = r.endRecording();
final image = await pic.toImage(20, 20);
final bytes = (await image.toByteData(format: ui.ImageByteFormat.png))!
.buffer
.asUint8List();
image.dispose();
pic.dispose();
await rejects(bytes, KycError.tooSmall);
});
}