From 3397f04ab773bb43cf55b62e5b74d26e6fff0ecc Mon Sep 17 00:00:00 2001 From: Stephan D Date: Sat, 12 Sep 2026 09:28:24 +0200 Subject: [PATCH] Add optional KYC flow and service-backed account limits --- Makefile | 1 + README.md | 15 + docs/implementation.md | 26 + docs/ux/kyc.md | 149 ++++++ frontend/app/lib/auth/form.dart | 4 + .../lib/generated/i18n/app_localizations.dart | 456 ++++++++++++++++++ .../generated/i18n/app_localizations_en.dart | 260 ++++++++++ .../generated/i18n/app_localizations_ru.dart | 260 ++++++++++ frontend/app/lib/kyc/copy.dart | 37 ++ frontend/app/lib/kyc/form.dart | 87 ++++ frontend/app/lib/kyc/limits.dart | 111 +++++ frontend/app/lib/kyc/page.dart | 240 +++++++++ frontend/app/lib/kyc/photo.dart | 78 +++ frontend/app/lib/kyc/preview.dart | 44 ++ frontend/app/lib/kyc/status.dart | 158 ++++++ frontend/app/lib/l10n/en.arb | 78 ++- frontend/app/lib/l10n/ru.arb | 78 ++- frontend/app/lib/main.dart | 19 +- frontend/app/lib/routing/router.dart | 63 ++- frontend/app/lib/routing/routes.dart | 1 + frontend/app/lib/verification/page.dart | 7 + frontend/app/pubspec.lock | 127 +++++ frontend/app/pubspec.yaml | 2 + frontend/app/test/kyc_test.dart | 199 ++++++++ .../lib/sxchnge_analytics.dart | 32 +- frontend/packages/sxchnge_kyc/.gitignore | 3 + frontend/packages/sxchnge_kyc/README.md | 38 ++ .../sxchnge_kyc/lib/src/models/identity.dart | 54 +++ .../sxchnge_kyc/lib/src/models/limits.dart | 31 ++ .../lib/src/providers/identity.dart | 235 +++++++++ .../sxchnge_kyc/lib/src/providers/limits.dart | 37 ++ .../lib/src/services/contracts.dart | 24 + .../sxchnge_kyc/lib/src/services/dummy.dart | 93 ++++ .../sxchnge_kyc/lib/src/services/photos.dart | 81 ++++ .../lib/src/transport/contracts.dart | 64 +++ .../packages/sxchnge_kyc/lib/sxchnge_kyc.dart | 7 + frontend/packages/sxchnge_kyc/pubspec.yaml | 17 + .../packages/sxchnge_kyc/test/kyc_test.dart | 263 ++++++++++ 38 files changed, 3443 insertions(+), 36 deletions(-) create mode 100644 docs/ux/kyc.md create mode 100644 frontend/app/lib/kyc/copy.dart create mode 100644 frontend/app/lib/kyc/form.dart create mode 100644 frontend/app/lib/kyc/limits.dart create mode 100644 frontend/app/lib/kyc/page.dart create mode 100644 frontend/app/lib/kyc/photo.dart create mode 100644 frontend/app/lib/kyc/preview.dart create mode 100644 frontend/app/lib/kyc/status.dart create mode 100644 frontend/app/test/kyc_test.dart create mode 100644 frontend/packages/sxchnge_kyc/.gitignore create mode 100644 frontend/packages/sxchnge_kyc/README.md create mode 100644 frontend/packages/sxchnge_kyc/lib/src/models/identity.dart create mode 100644 frontend/packages/sxchnge_kyc/lib/src/models/limits.dart create mode 100644 frontend/packages/sxchnge_kyc/lib/src/providers/identity.dart create mode 100644 frontend/packages/sxchnge_kyc/lib/src/providers/limits.dart create mode 100644 frontend/packages/sxchnge_kyc/lib/src/services/contracts.dart create mode 100644 frontend/packages/sxchnge_kyc/lib/src/services/dummy.dart create mode 100644 frontend/packages/sxchnge_kyc/lib/src/services/photos.dart create mode 100644 frontend/packages/sxchnge_kyc/lib/src/transport/contracts.dart create mode 100644 frontend/packages/sxchnge_kyc/lib/sxchnge_kyc.dart create mode 100644 frontend/packages/sxchnge_kyc/pubspec.yaml create mode 100644 frontend/packages/sxchnge_kyc/test/kyc_test.dart diff --git a/Makefile b/Makefile index f1970ca..79b83f4 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ .PHONY: test-frontend test-frontend: + cd frontend/packages/sxchnge_kyc && flutter test cd frontend/packages/sxchnge_analytics && flutter test cd frontend/packages/sxchnge_auth && flutter test cd frontend/packages/sxchnge_theme && flutter test diff --git a/README.md b/README.md index 398e9c5..e0083c6 100644 --- a/README.md +++ b/README.md @@ -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, a push and a PR. Merge only when authorized. Record scope, validation and known 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. diff --git a/docs/implementation.md b/docs/implementation.md index ed6b82f..0c3fe0f 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -141,3 +141,29 @@ current packages, app/website commands, verification previews, analytics limits, skill location and contribution workflow. Local links and diff checks pass; commands and prerequisites were checked against repository configuration. 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. diff --git a/docs/ux/kyc.md b/docs/ux/kyc.md new file mode 100644 index 0000000..b5518d4 --- /dev/null +++ b/docs/ux/kyc.md @@ -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. diff --git a/frontend/app/lib/auth/form.dart b/frontend/app/lib/auth/form.dart index d0781cb..e97b243 100644 --- a/frontend/app/lib/auth/form.dart +++ b/frontend/app/lib/auth/form.dart @@ -310,6 +310,10 @@ class _AuthFormState extends State { AuthFeedback(state: state, signUp: _signUp), ], const SizedBox(height: 20), + TextButton( + onPressed: () => context.goNamed(AppRoutes.identity), + child: Text(l.kycPreview), + ), Wrap( alignment: WrapAlignment.center, crossAxisAlignment: WrapCrossAlignment.center, diff --git a/frontend/app/lib/generated/i18n/app_localizations.dart b/frontend/app/lib/generated/i18n/app_localizations.dart index 69df6ec..7188c75 100644 --- a/frontend/app/lib/generated/i18n/app_localizations.dart +++ b/frontend/app/lib/generated/i18n/app_localizations.dart @@ -535,6 +535,462 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Prepare demo email again'** String get demoResend; + + /// No description provided for @kycTitle. + /// + /// In en, this message translates to: + /// **'Verification & limits'** + String get kycTitle; + + /// No description provided for @kycIntro. + /// + /// In en, this message translates to: + /// **'Your account, at your pace. Verify your identity when you need higher limits.'** + String get kycIntro; + + /// No description provided for @kycDemo. + /// + /// In en, this message translates to: + /// **'DEMO · Use sample images only. Photos stay in memory, are never uploaded, and disappear when you leave or reload. Review decisions are simulated.'** + String get kycDemo; + + /// No description provided for @kycUnverified. + /// + /// In en, this message translates to: + /// **'Not verified'** + String get kycUnverified; + + /// No description provided for @kycPending. + /// + /// In en, this message translates to: + /// **'In review'** + String get kycPending; + + /// No description provided for @kycApproved. + /// + /// In en, this message translates to: + /// **'Identity verified'** + String get kycApproved; + + /// No description provided for @kycChanges. + /// + /// In en, this message translates to: + /// **'New photos needed'** + String get kycChanges; + + /// No description provided for @kycDenied. + /// + /// In en, this message translates to: + /// **'Verification declined'** + String get kycDenied; + + /// No description provided for @kycUnverifiedBody. + /// + /// In en, this message translates to: + /// **'You can keep using your account with basic limits. Submit a passport photo and a selfie holding it to request an upgrade.'** + String get kycUnverifiedBody; + + /// No description provided for @kycPendingBody. + /// + /// In en, this message translates to: + /// **'Your submission is awaiting a human review. Basic limits remain active until approval. You do not need to submit again.'** + String get kycPendingBody; + + /// No description provided for @kycApprovedBody. + /// + /// In en, this message translates to: + /// **'Your review is complete. Check your current limits below.'** + String get kycApprovedBody; + + /// No description provided for @kycChangesBody. + /// + /// In en, this message translates to: + /// **'Your account remains available with basic limits. Replace the photos and submit a new review request.'** + String get kycChangesBody; + + /// No description provided for @kycDeniedBody. + /// + /// In en, this message translates to: + /// **'Basic limits remain available. If you believe this decision is incorrect, contact support for a review.'** + String get kycDeniedBody; + + /// No description provided for @kycPassportReason. + /// + /// In en, this message translates to: + /// **'The passport details could not be read. Retake the photo in good light, with every edge visible.'** + String get kycPassportReason; + + /// No description provided for @kycSelfieReason. + /// + /// In en, this message translates to: + /// **'Your face or passport was not clear in the selfie. Keep both visible and in focus.'** + String get kycSelfieReason; + + /// No description provided for @kycExpiredReason. + /// + /// In en, this message translates to: + /// **'The passport has expired. Use a valid passport and take both photos again.'** + String get kycExpiredReason; + + /// No description provided for @kycDeniedReason. + /// + /// In en, this message translates to: + /// **'We could not verify your identity from this submission.'** + String get kycDeniedReason; + + /// No description provided for @kycStart. + /// + /// In en, this message translates to: + /// **'Verify identity'** + String get kycStart; + + /// No description provided for @kycResubmit. + /// + /// In en, this message translates to: + /// **'Replace photos'** + String get kycResubmit; + + /// No description provided for @kycRefresh. + /// + /// In en, this message translates to: + /// **'Refresh status & limits'** + String get kycRefresh; + + /// No description provided for @kycSupport. + /// + /// In en, this message translates to: + /// **'Contact support'** + String get kycSupport; + + /// No description provided for @kycSupportBody. + /// + /// In en, this message translates to: + /// **'Support is not connected in this preview. In the live app, you will be able to request a review here without submitting documents again.'** + String get kycSupportBody; + + /// No description provided for @kycBasic. + /// + /// In en, this message translates to: + /// **'Basic'** + String get kycBasic; + + /// No description provided for @kycVerifiedTier. + /// + /// In en, this message translates to: + /// **'Verified'** + String get kycVerifiedTier; + + /// No description provided for @kycLimits. + /// + /// In en, this message translates to: + /// **'Your limits'** + String get kycLimits; + + /// No description provided for @kycLimitsDemo. + /// + /// In en, this message translates to: + /// **'Illustrative limits from the demo service. These are not a live offer.'** + String get kycLimitsDemo; + + /// No description provided for @kycDay. + /// + /// In en, this message translates to: + /// **'Daily volume'** + String get kycDay; + + /// No description provided for @kycMonth. + /// + /// In en, this message translates to: + /// **'Monthly volume'** + String get kycMonth; + + /// No description provided for @kycCurrent. + /// + /// In en, this message translates to: + /// **'Current cap'** + String get kycCurrent; + + /// No description provided for @kycRemaining. + /// + /// In en, this message translates to: + /// **'Available'** + String get kycRemaining; + + /// No description provided for @kycAfter. + /// + /// In en, this message translates to: + /// **'After approval'** + String get kycAfter; + + /// No description provided for @kycLimitsError. + /// + /// In en, this message translates to: + /// **'Limits could not be refreshed. Any values below are out of date. Retry to see your current access.'** + String get kycLimitsError; + + /// No description provided for @kycLimitsEmpty. + /// + /// In en, this message translates to: + /// **'No limits were returned. Refresh or check with support before making a transaction.'** + String get kycLimitsEmpty; + + /// No description provided for @kycLoading. + /// + /// In en, this message translates to: + /// **'Loading your status…'** + String get kycLoading; + + /// No description provided for @kycPassport. + /// + /// In en, this message translates to: + /// **'Passport photo'** + String get kycPassport; + + /// No description provided for @kycSelfie. + /// + /// In en, this message translates to: + /// **'Selfie with passport'** + String get kycSelfie; + + /// No description provided for @kycReview. + /// + /// In en, this message translates to: + /// **'Review your photos'** + String get kycReview; + + /// No description provided for @kycPassportHelp. + /// + /// In en, this message translates to: + /// **'Open your valid passport to the photo page. Show all four edges and keep text sharp. Avoid glare, blur and covered details.'** + String get kycPassportHelp; + + /// No description provided for @kycSelfieHelp. + /// + /// In en, this message translates to: + /// **'Hold the open passport beside your face. Show your whole face and the photo page in the same image. Keep both sharp and uncovered.'** + String get kycSelfieHelp; + + /// No description provided for @kycFileRules. + /// + /// In en, this message translates to: + /// **'JPEG or PNG · up to 10 MB · at least 600 × 600 pixels. Take a photo with your camera, then choose it here.'** + String get kycFileRules; + + /// No description provided for @kycChoose. + /// + /// In en, this message translates to: + /// **'Choose photo'** + String get kycChoose; + + /// No description provided for @kycReplace. + /// + /// In en, this message translates to: + /// **'Choose a different photo'** + String get kycReplace; + + /// No description provided for @kycRemove. + /// + /// In en, this message translates to: + /// **'Remove photo'** + String get kycRemove; + + /// No description provided for @kycNext. + /// + /// In en, this message translates to: + /// **'Continue'** + String get kycNext; + + /// No description provided for @kycBack. + /// + /// In en, this message translates to: + /// **'Back'** + String get kycBack; + + /// No description provided for @kycSkip. + /// + /// In en, this message translates to: + /// **'Keep basic limits'** + String get kycSkip; + + /// No description provided for @kycClose. + /// + /// In en, this message translates to: + /// **'Back to account preview'** + String get kycClose; + + /// No description provided for @kycReviewHelp. + /// + /// In en, this message translates to: + /// **'Check that both photos are readable and show the same passport. A staff member will review them; submission does not immediately raise your limits.'** + String get kycReviewHelp; + + /// No description provided for @kycConfirm. + /// + /// In en, this message translates to: + /// **'These photos show me and my own valid passport.'** + String get kycConfirm; + + /// No description provided for @kycSubmit. + /// + /// In en, this message translates to: + /// **'Submit for review'** + String get kycSubmit; + + /// No description provided for @kycSubmitDemo. + /// + /// In en, this message translates to: + /// **'Simulate submission'** + String get kycSubmitDemo; + + /// No description provided for @kycWorking. + /// + /// In en, this message translates to: + /// **'Processing… Please keep this page open.'** + String get kycWorking; + + /// No description provided for @kycDiscardTitle. + /// + /// In en, this message translates to: + /// **'Discard these photos?'** + String get kycDiscardTitle; + + /// No description provided for @kycDiscardBody. + /// + /// In en, this message translates to: + /// **'Your draft is only in memory. Leaving will remove the selected photos; you can start again later.'** + String get kycDiscardBody; + + /// No description provided for @kycStay. + /// + /// In en, this message translates to: + /// **'Keep editing'** + String get kycStay; + + /// No description provided for @kycDiscard. + /// + /// In en, this message translates to: + /// **'Discard & leave'** + String get kycDiscard; + + /// No description provided for @kycErrorUnavailable. + /// + /// In en, this message translates to: + /// **'The service is unavailable. Your selected photos are still here. Try again.'** + String get kycErrorUnavailable; + + /// No description provided for @kycErrorInternal. + /// + /// In en, this message translates to: + /// **'Something went wrong. Try again; your selected photos have been kept.'** + String get kycErrorInternal; + + /// No description provided for @kycErrorPermission. + /// + /// In en, this message translates to: + /// **'We could not open that photo. Check file access or choose another image.'** + String get kycErrorPermission; + + /// No description provided for @kycErrorFormat. + /// + /// In en, this message translates to: + /// **'Choose a JPEG or PNG image. Other formats are not supported.'** + String get kycErrorFormat; + + /// No description provided for @kycErrorLarge. + /// + /// In en, this message translates to: + /// **'This image is too large. Choose one under 10 MB and 40 megapixels.'** + String get kycErrorLarge; + + /// No description provided for @kycErrorUnreadable. + /// + /// In en, this message translates to: + /// **'This image is damaged or cannot be read. Choose another photo.'** + String get kycErrorUnreadable; + + /// No description provided for @kycErrorSmall. + /// + /// In en, this message translates to: + /// **'The photo is too small. Use an image at least 600 pixels wide and high.'** + String get kycErrorSmall; + + /// No description provided for @kycErrorIncomplete. + /// + /// In en, this message translates to: + /// **'Choose both photos and confirm they belong to you before submitting.'** + String get kycErrorIncomplete; + + /// No description provided for @kycErrorSession. + /// + /// In en, this message translates to: + /// **'Your session has expired. Return to login, then check your verification status.'** + String get kycErrorSession; + + /// No description provided for @kycErrorRate. + /// + /// In en, this message translates to: + /// **'Too many requests. Wait a moment before trying again.'** + String get kycErrorRate; + + /// No description provided for @kycErrorConflict. + /// + /// In en, this message translates to: + /// **'Your review status has changed. Refresh it before continuing.'** + String get kycErrorConflict; + + /// No description provided for @kycDemoControls. + /// + /// In en, this message translates to: + /// **'Demo: simulate a backoffice decision'** + String get kycDemoControls; + + /// No description provided for @kycDemoApprove. + /// + /// In en, this message translates to: + /// **'Approve'** + String get kycDemoApprove; + + /// No description provided for @kycDemoCorrect. + /// + /// In en, this message translates to: + /// **'Request clearer passport'** + String get kycDemoCorrect; + + /// No description provided for @kycDemoSelfie. + /// + /// In en, this message translates to: + /// **'Request clearer selfie'** + String get kycDemoSelfie; + + /// No description provided for @kycDemoExpired. + /// + /// In en, this message translates to: + /// **'Passport expired'** + String get kycDemoExpired; + + /// No description provided for @kycDemoDeny. + /// + /// In en, this message translates to: + /// **'Decline'** + String get kycDemoDeny; + + /// No description provided for @kycPreview. + /// + /// In en, this message translates to: + /// **'Explore verification & limits'** + String get kycPreview; + + /// No description provided for @kycChooseFirst. + /// + /// In en, this message translates to: + /// **'Choose a photo to continue.'** + String get kycChooseFirst; + + /// No description provided for @kycStatusStale. + /// + /// In en, this message translates to: + /// **'Status could not be refreshed. The status shown is the last known result.'** + String get kycStatusStale; } class _AppLocalizationsDelegate diff --git a/frontend/app/lib/generated/i18n/app_localizations_en.dart b/frontend/app/lib/generated/i18n/app_localizations_en.dart index 8972729..626a86b 100644 --- a/frontend/app/lib/generated/i18n/app_localizations_en.dart +++ b/frontend/app/lib/generated/i18n/app_localizations_en.dart @@ -242,4 +242,264 @@ class AppLocalizationsEn extends AppLocalizations { @override String get demoResend => 'Prepare demo email again'; + + @override + String get kycTitle => 'Verification & limits'; + + @override + String get kycIntro => + 'Your account, at your pace. Verify your identity when you need higher limits.'; + + @override + String get kycDemo => + 'DEMO · Use sample images only. Photos stay in memory, are never uploaded, and disappear when you leave or reload. Review decisions are simulated.'; + + @override + String get kycUnverified => 'Not verified'; + + @override + String get kycPending => 'In review'; + + @override + String get kycApproved => 'Identity verified'; + + @override + String get kycChanges => 'New photos needed'; + + @override + String get kycDenied => 'Verification declined'; + + @override + String get kycUnverifiedBody => + 'You can keep using your account with basic limits. Submit a passport photo and a selfie holding it to request an upgrade.'; + + @override + String get kycPendingBody => + 'Your submission is awaiting a human review. Basic limits remain active until approval. You do not need to submit again.'; + + @override + String get kycApprovedBody => + 'Your review is complete. Check your current limits below.'; + + @override + String get kycChangesBody => + 'Your account remains available with basic limits. Replace the photos and submit a new review request.'; + + @override + String get kycDeniedBody => + 'Basic limits remain available. If you believe this decision is incorrect, contact support for a review.'; + + @override + String get kycPassportReason => + 'The passport details could not be read. Retake the photo in good light, with every edge visible.'; + + @override + String get kycSelfieReason => + 'Your face or passport was not clear in the selfie. Keep both visible and in focus.'; + + @override + String get kycExpiredReason => + 'The passport has expired. Use a valid passport and take both photos again.'; + + @override + String get kycDeniedReason => + 'We could not verify your identity from this submission.'; + + @override + String get kycStart => 'Verify identity'; + + @override + String get kycResubmit => 'Replace photos'; + + @override + String get kycRefresh => 'Refresh status & limits'; + + @override + String get kycSupport => 'Contact support'; + + @override + String get kycSupportBody => + 'Support is not connected in this preview. In the live app, you will be able to request a review here without submitting documents again.'; + + @override + String get kycBasic => 'Basic'; + + @override + String get kycVerifiedTier => 'Verified'; + + @override + String get kycLimits => 'Your limits'; + + @override + String get kycLimitsDemo => + 'Illustrative limits from the demo service. These are not a live offer.'; + + @override + String get kycDay => 'Daily volume'; + + @override + String get kycMonth => 'Monthly volume'; + + @override + String get kycCurrent => 'Current cap'; + + @override + String get kycRemaining => 'Available'; + + @override + String get kycAfter => 'After approval'; + + @override + String get kycLimitsError => + 'Limits could not be refreshed. Any values below are out of date. Retry to see your current access.'; + + @override + String get kycLimitsEmpty => + 'No limits were returned. Refresh or check with support before making a transaction.'; + + @override + String get kycLoading => 'Loading your status…'; + + @override + String get kycPassport => 'Passport photo'; + + @override + String get kycSelfie => 'Selfie with passport'; + + @override + String get kycReview => 'Review your photos'; + + @override + String get kycPassportHelp => + 'Open your valid passport to the photo page. Show all four edges and keep text sharp. Avoid glare, blur and covered details.'; + + @override + String get kycSelfieHelp => + 'Hold the open passport beside your face. Show your whole face and the photo page in the same image. Keep both sharp and uncovered.'; + + @override + String get kycFileRules => + 'JPEG or PNG · up to 10 MB · at least 600 × 600 pixels. Take a photo with your camera, then choose it here.'; + + @override + String get kycChoose => 'Choose photo'; + + @override + String get kycReplace => 'Choose a different photo'; + + @override + String get kycRemove => 'Remove photo'; + + @override + String get kycNext => 'Continue'; + + @override + String get kycBack => 'Back'; + + @override + String get kycSkip => 'Keep basic limits'; + + @override + String get kycClose => 'Back to account preview'; + + @override + String get kycReviewHelp => + 'Check that both photos are readable and show the same passport. A staff member will review them; submission does not immediately raise your limits.'; + + @override + String get kycConfirm => 'These photos show me and my own valid passport.'; + + @override + String get kycSubmit => 'Submit for review'; + + @override + String get kycSubmitDemo => 'Simulate submission'; + + @override + String get kycWorking => 'Processing… Please keep this page open.'; + + @override + String get kycDiscardTitle => 'Discard these photos?'; + + @override + String get kycDiscardBody => + 'Your draft is only in memory. Leaving will remove the selected photos; you can start again later.'; + + @override + String get kycStay => 'Keep editing'; + + @override + String get kycDiscard => 'Discard & leave'; + + @override + String get kycErrorUnavailable => + 'The service is unavailable. Your selected photos are still here. Try again.'; + + @override + String get kycErrorInternal => + 'Something went wrong. Try again; your selected photos have been kept.'; + + @override + String get kycErrorPermission => + 'We could not open that photo. Check file access or choose another image.'; + + @override + String get kycErrorFormat => + 'Choose a JPEG or PNG image. Other formats are not supported.'; + + @override + String get kycErrorLarge => + 'This image is too large. Choose one under 10 MB and 40 megapixels.'; + + @override + String get kycErrorUnreadable => + 'This image is damaged or cannot be read. Choose another photo.'; + + @override + String get kycErrorSmall => + 'The photo is too small. Use an image at least 600 pixels wide and high.'; + + @override + String get kycErrorIncomplete => + 'Choose both photos and confirm they belong to you before submitting.'; + + @override + String get kycErrorSession => + 'Your session has expired. Return to login, then check your verification status.'; + + @override + String get kycErrorRate => + 'Too many requests. Wait a moment before trying again.'; + + @override + String get kycErrorConflict => + 'Your review status has changed. Refresh it before continuing.'; + + @override + String get kycDemoControls => 'Demo: simulate a backoffice decision'; + + @override + String get kycDemoApprove => 'Approve'; + + @override + String get kycDemoCorrect => 'Request clearer passport'; + + @override + String get kycDemoSelfie => 'Request clearer selfie'; + + @override + String get kycDemoExpired => 'Passport expired'; + + @override + String get kycDemoDeny => 'Decline'; + + @override + String get kycPreview => 'Explore verification & limits'; + + @override + String get kycChooseFirst => 'Choose a photo to continue.'; + + @override + String get kycStatusStale => + 'Status could not be refreshed. The status shown is the last known result.'; } diff --git a/frontend/app/lib/generated/i18n/app_localizations_ru.dart b/frontend/app/lib/generated/i18n/app_localizations_ru.dart index 9c32b10..9e856f0 100644 --- a/frontend/app/lib/generated/i18n/app_localizations_ru.dart +++ b/frontend/app/lib/generated/i18n/app_localizations_ru.dart @@ -243,4 +243,264 @@ class AppLocalizationsRu extends AppLocalizations { @override String get demoResend => 'Подготовить демописьмо ещё раз'; + + @override + String get kycTitle => 'Верификация и лимиты'; + + @override + String get kycIntro => + 'Ваш аккаунт — ваш темп. Подтвердите личность, когда понадобятся более высокие лимиты.'; + + @override + String get kycDemo => + 'ДЕМО · Используйте только тестовые изображения. Фото хранятся в памяти, не отправляются и удаляются при выходе или перезагрузке. Решения проверки имитируются.'; + + @override + String get kycUnverified => 'Личность не подтверждена'; + + @override + String get kycPending => 'На проверке'; + + @override + String get kycApproved => 'Личность подтверждена'; + + @override + String get kycChanges => 'Нужны новые фото'; + + @override + String get kycDenied => 'Верификация отклонена'; + + @override + String get kycUnverifiedBody => + 'Вы можете пользоваться аккаунтом с базовыми лимитами. Для повышения отправьте фото паспорта и селфи с ним.'; + + @override + String get kycPendingBody => + 'Заявка ожидает проверки сотрудником. До одобрения действуют базовые лимиты. Повторная отправка не нужна.'; + + @override + String get kycApprovedBody => + 'Проверка завершена. Актуальные лимиты указаны ниже.'; + + @override + String get kycChangesBody => + 'Аккаунт доступен с базовыми лимитами. Замените фото и отправьте новую заявку.'; + + @override + String get kycDeniedBody => + 'Базовые лимиты остаются доступными. Если вы не согласны с решением, обратитесь в поддержку для пересмотра.'; + + @override + String get kycPassportReason => + 'Данные паспорта не удалось прочитать. Переснимите при хорошем освещении, чтобы все края были видны.'; + + @override + String get kycSelfieReason => + 'На селфи плохо видно лицо или паспорт. Оба должны быть в кадре и в фокусе.'; + + @override + String get kycExpiredReason => + 'Срок действия паспорта истёк. Используйте действующий паспорт и сделайте оба фото заново.'; + + @override + String get kycDeniedReason => + 'По этой заявке не удалось подтвердить вашу личность.'; + + @override + String get kycStart => 'Подтвердить личность'; + + @override + String get kycResubmit => 'Заменить фото'; + + @override + String get kycRefresh => 'Обновить статус и лимиты'; + + @override + String get kycSupport => 'Обратиться в поддержку'; + + @override + String get kycSupportBody => + 'В демоверсии поддержка не подключена. В рабочем приложении здесь можно будет запросить пересмотр без повторной отправки документов.'; + + @override + String get kycBasic => 'Базовый'; + + @override + String get kycVerifiedTier => 'Подтверждённый'; + + @override + String get kycLimits => 'Ваши лимиты'; + + @override + String get kycLimitsDemo => + 'Пример лимитов из демосервиса. Это не действующее предложение.'; + + @override + String get kycDay => 'Объём за день'; + + @override + String get kycMonth => 'Объём за месяц'; + + @override + String get kycCurrent => 'Текущий лимит'; + + @override + String get kycRemaining => 'Доступно'; + + @override + String get kycAfter => 'После одобрения'; + + @override + String get kycLimitsError => + 'Не удалось обновить лимиты. Значения ниже могут быть устаревшими. Повторите запрос.'; + + @override + String get kycLimitsEmpty => + 'Сервис не вернул лимиты. Обновите данные или уточните их в поддержке перед операцией.'; + + @override + String get kycLoading => 'Загружаем ваш статус…'; + + @override + String get kycPassport => 'Фото паспорта'; + + @override + String get kycSelfie => 'Селфи с паспортом'; + + @override + String get kycReview => 'Проверьте фото'; + + @override + String get kycPassportHelp => + 'Откройте действующий паспорт на странице с фото. Все четыре края и текст должны быть чётко видны. Избегайте бликов и размытия, не закрывайте данные.'; + + @override + String get kycSelfieHelp => + 'Держите открытый паспорт рядом с лицом. Лицо целиком и страница с фото должны быть в одном кадре, чёткими и ничем не закрытыми.'; + + @override + String get kycFileRules => + 'JPEG или PNG · до 10 МБ · не менее 600 × 600 пикселей. Сделайте фото камерой, затем выберите его здесь.'; + + @override + String get kycChoose => 'Выбрать фото'; + + @override + String get kycReplace => 'Выбрать другое фото'; + + @override + String get kycRemove => 'Удалить фото'; + + @override + String get kycNext => 'Продолжить'; + + @override + String get kycBack => 'Назад'; + + @override + String get kycSkip => 'Оставить базовые лимиты'; + + @override + String get kycClose => 'К демоаккаунту'; + + @override + String get kycReviewHelp => + 'Убедитесь, что оба фото читаемы и на них один паспорт. Их проверит сотрудник. Отправка не повышает лимиты сразу.'; + + @override + String get kycConfirm => 'На фото я и мой действующий паспорт.'; + + @override + String get kycSubmit => 'Отправить на проверку'; + + @override + String get kycSubmitDemo => 'Имитировать отправку'; + + @override + String get kycWorking => 'Обработка… Не закрывайте страницу.'; + + @override + String get kycDiscardTitle => 'Удалить выбранные фото?'; + + @override + String get kycDiscardBody => + 'Черновик хранится только в памяти. При выходе выбранные фото будут удалены. Позже можно начать заново.'; + + @override + String get kycStay => 'Продолжить'; + + @override + String get kycDiscard => 'Удалить и выйти'; + + @override + String get kycErrorUnavailable => + 'Сервис недоступен. Выбранные фото сохранены в памяти. Повторите попытку.'; + + @override + String get kycErrorInternal => + 'Произошла ошибка. Повторите попытку; выбранные фото сохранены.'; + + @override + String get kycErrorPermission => + 'Не удалось открыть фото. Проверьте доступ к файлам или выберите другое изображение.'; + + @override + String get kycErrorFormat => + 'Выберите изображение JPEG или PNG. Другие форматы не поддерживаются.'; + + @override + String get kycErrorLarge => + 'Изображение слишком большое. Выберите файл до 10 МБ и 40 мегапикселей.'; + + @override + String get kycErrorUnreadable => + 'Изображение повреждено или не читается. Выберите другое фото.'; + + @override + String get kycErrorSmall => + 'Фото слишком маленькое. Ширина и высота должны быть не менее 600 пикселей.'; + + @override + String get kycErrorIncomplete => + 'Выберите оба фото и подтвердите, что на них вы и ваш паспорт.'; + + @override + String get kycErrorSession => + 'Сессия истекла. Войдите снова и проверьте статус верификации.'; + + @override + String get kycErrorRate => + 'Слишком много запросов. Немного подождите и повторите попытку.'; + + @override + String get kycErrorConflict => + 'Статус проверки изменился. Обновите его, прежде чем продолжить.'; + + @override + String get kycDemoControls => 'Демо: имитация решения сотрудника'; + + @override + String get kycDemoApprove => 'Одобрить'; + + @override + String get kycDemoCorrect => 'Запросить чёткое фото паспорта'; + + @override + String get kycDemoSelfie => 'Запросить чёткое селфи'; + + @override + String get kycDemoExpired => 'Паспорт просрочен'; + + @override + String get kycDemoDeny => 'Отклонить'; + + @override + String get kycPreview => 'Посмотреть верификацию и лимиты'; + + @override + String get kycChooseFirst => 'Выберите фото для продолжения.'; + + @override + String get kycStatusStale => + 'Не удалось обновить статус. Показан последний известный результат.'; } diff --git a/frontend/app/lib/kyc/copy.dart b/frontend/app/lib/kyc/copy.dart new file mode 100644 index 0000000..6cf18a9 --- /dev/null +++ b/frontend/app/lib/kyc/copy.dart @@ -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, +}; diff --git a/frontend/app/lib/kyc/form.dart b/frontend/app/lib/kyc/form.dart new file mode 100644 index 0000000..f22a25e --- /dev/null +++ b/frontend/app/lib/kyc/form.dart @@ -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)), + ], + ); + } +} diff --git a/frontend/app/lib/kyc/limits.dart b/frontend/app/lib/kyc/limits.dart new file mode 100644 index 0000000..724ba85 --- /dev/null +++ b/frontend/app/lib/kyc/limits.dart @@ -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(); + 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, + ), + ], + ); +} diff --git a/frontend/app/lib/kyc/page.dart b/frontend/app/lib/kyc/page.dart new file mode 100644 index 0000000..549b763 --- /dev/null +++ b/frontend/app/lib/kyc/page.dart @@ -0,0 +1,240 @@ +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; + +import 'package:provider/provider.dart'; + +import 'package:sxchnge_analytics/sxchnge_analytics.dart'; + +import 'package:sxchnge_kyc/sxchnge_kyc.dart'; + +import '../auth/brand_panel.dart'; +import '../routing/routes.dart'; +import '../widgets/locale.dart'; +import 'copy.dart'; +import 'form.dart'; +import 'limits.dart'; +import 'status.dart'; + +import '../generated/i18n/app_localizations.dart'; + +class IdentityPage extends StatefulWidget { + const IdentityPage({super.key}); + @override + State createState() => IdentityPageState(); +} + +class IdentityPageState extends State { + late final _limits = LimitsProvider(context.read()); + late final _identity = IdentityProvider( + context.read(), + context.read(), + JourneyRecorder(context.read()), + 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 allowExit() async { + if (_identity.busy) return false; + if (!_identity.hasDraft) return true; + final l = AppLocalizations.of(context)!; + return await showDialog( + 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 _leave({bool account = false}) async { + if (!await allowExit() || !mounted) return; + _identity.exit(); + if (account) context.goNamed(AppRoutes.login); + } + + @override + void dispose() { + _identity.removeListener(_transition); + _identity.dispose(); + _focus.dispose(); + _limits.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => ChangeNotifierProvider.value( + value: _limits, + child: ListenableBuilder( + listenable: _identity, + builder: (context, _) { + final p = _identity; + final l = AppLocalizations.of(context)!; + final t = Theme.of(context); + return PopScope( + canPop: !p.hasDraft && !p.busy, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _leave(account: true); + }, + child: Scaffold( + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 16, + children: [BrandWordmark(), LocaleSelector()], + ), + const SizedBox(height: 36), + Semantics( + header: true, + child: Text( + l.kycTitle, + style: t.textTheme.displaySmall, + ), + ), + const SizedBox(height: 12), + Text(l.kycIntro, style: t.textTheme.bodyLarge), + const SizedBox(height: 20), + if (p.isDemo) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: t.colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(16), + ), + child: Text( + l.kycDemo, + style: TextStyle( + color: t.colorScheme.onTertiaryContainer, + ), + ), + ), + const SizedBox(height: 24), + if (p.busy) ...[ + const LinearProgressIndicator(), + const SizedBox(height: 12), + Semantics( + liveRegion: true, + child: Text( + p.record == null ? l.kycLoading : l.kycWorking, + ), + ), + const SizedBox(height: 16), + ], + if (p.error != null) ...[ + Semantics( + key: _feedbackKey, + liveRegion: true, + child: Text( + errorText(l, p.error!), + key: const ValueKey('kyc-error'), + style: TextStyle(color: t.colorScheme.error), + ), + ), + const SizedBox(height: 16), + ], + if (p.stale && p.record != null) + Text( + l.kycStatusStale, + style: TextStyle(color: t.colorScheme.error), + ), + Focus( + key: _contentKey, + focusNode: _focus, + child: p.editing + ? IdentityForm(provider: p) + : p.record != null + ? StatusPanel(provider: p) + : const SizedBox.shrink(), + ), + if (!p.editing || p.stale) + TextButton( + onPressed: p.busy ? null : p.refresh, + child: Text(l.kycRefresh), + ), + if (!p.editing) ...[ + const SizedBox(height: 16), + const LimitsPanel(), + ], + const SizedBox(height: 16), + if (p.editing) + TextButton( + onPressed: p.busy ? null : _leave, + child: Text(l.kycSkip), + ), + TextButton( + onPressed: p.busy + ? null + : () => _leave(account: true), + child: Text(l.kycClose), + ), + ], + ), + ), + ), + ), + ), + ), + ); + }, + ), + ); +} diff --git a/frontend/app/lib/kyc/photo.dart b/frontend/app/lib/kyc/photo.dart new file mode 100644 index 0000000..04f764c --- /dev/null +++ b/frontend/app/lib/kyc/photo.dart @@ -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)), + ], + ], + ), + ), + ); + } +} diff --git a/frontend/app/lib/kyc/preview.dart b/frontend/app/lib/kyc/preview.dart new file mode 100644 index 0000000..89f527f --- /dev/null +++ b/frontend/app/lib/kyc/preview.dart @@ -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 createState() => _PhotoPreviewState(); +} + +class _PhotoPreviewState extends State { + 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), + ); +} diff --git a/frontend/app/lib/kyc/status.dart b/frontend/app/lib/kyc/status.dart new file mode 100644 index 0000000..09d3703 --- /dev/null +++ b/frontend/app/lib/kyc/status.dart @@ -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( + 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), + ), + ], + ), + ], + ), + ], + ); + } +} diff --git a/frontend/app/lib/l10n/en.arb b/frontend/app/lib/l10n/en.arb index 56fa545..af54b29 100644 --- a/frontend/app/lib/l10n/en.arb +++ b/frontend/app/lib/l10n/en.arb @@ -72,5 +72,81 @@ "demoVerifiedBody": "You’ve previewed email confirmation. No account was verified. Return to login to explore the demo.", "verificationRecipient": "Recipient", "verificationChangeEmail": "Change email", - "demoResend": "Prepare demo email again" + "demoResend": "Prepare demo email again", + "kycTitle": "Verification & limits", + "kycIntro": "Your account, at your pace. Verify your identity when you need higher limits.", + "kycDemo": "DEMO · Use sample images only. Photos stay in memory, are never uploaded, and disappear when you leave or reload. Review decisions are simulated.", + "kycUnverified": "Not verified", + "kycPending": "In review", + "kycApproved": "Identity verified", + "kycChanges": "New photos needed", + "kycDenied": "Verification declined", + "kycUnverifiedBody": "You can keep using your account with basic limits. Submit a passport photo and a selfie holding it to request an upgrade.", + "kycPendingBody": "Your submission is awaiting a human review. Basic limits remain active until approval. You do not need to submit again.", + "kycApprovedBody": "Your review is complete. Check your current limits below.", + "kycChangesBody": "Your account remains available with basic limits. Replace the photos and submit a new review request.", + "kycDeniedBody": "Basic limits remain available. If you believe this decision is incorrect, contact support for a review.", + "kycPassportReason": "The passport details could not be read. Retake the photo in good light, with every edge visible.", + "kycSelfieReason": "Your face or passport was not clear in the selfie. Keep both visible and in focus.", + "kycExpiredReason": "The passport has expired. Use a valid passport and take both photos again.", + "kycDeniedReason": "We could not verify your identity from this submission.", + "kycStart": "Verify identity", + "kycResubmit": "Replace photos", + "kycRefresh": "Refresh status & limits", + "kycSupport": "Contact support", + "kycSupportBody": "Support is not connected in this preview. In the live app, you will be able to request a review here without submitting documents again.", + "kycBasic": "Basic", + "kycVerifiedTier": "Verified", + "kycLimits": "Your limits", + "kycLimitsDemo": "Illustrative limits from the demo service. These are not a live offer.", + "kycDay": "Daily volume", + "kycMonth": "Monthly volume", + "kycCurrent": "Current cap", + "kycRemaining": "Available", + "kycAfter": "After approval", + "kycLimitsError": "Limits could not be refreshed. Any values below are out of date. Retry to see your current access.", + "kycLimitsEmpty": "No limits were returned. Refresh or check with support before making a transaction.", + "kycLoading": "Loading your status…", + "kycPassport": "Passport photo", + "kycSelfie": "Selfie with passport", + "kycReview": "Review your photos", + "kycPassportHelp": "Open your valid passport to the photo page. Show all four edges and keep text sharp. Avoid glare, blur and covered details.", + "kycSelfieHelp": "Hold the open passport beside your face. Show your whole face and the photo page in the same image. Keep both sharp and uncovered.", + "kycFileRules": "JPEG or PNG · up to 10 MB · at least 600 × 600 pixels. Take a photo with your camera, then choose it here.", + "kycChoose": "Choose photo", + "kycReplace": "Choose a different photo", + "kycRemove": "Remove photo", + "kycNext": "Continue", + "kycBack": "Back", + "kycSkip": "Keep basic limits", + "kycClose": "Back to account preview", + "kycReviewHelp": "Check that both photos are readable and show the same passport. A staff member will review them; submission does not immediately raise your limits.", + "kycConfirm": "These photos show me and my own valid passport.", + "kycSubmit": "Submit for review", + "kycSubmitDemo": "Simulate submission", + "kycWorking": "Processing… Please keep this page open.", + "kycDiscardTitle": "Discard these photos?", + "kycDiscardBody": "Your draft is only in memory. Leaving will remove the selected photos; you can start again later.", + "kycStay": "Keep editing", + "kycDiscard": "Discard & leave", + "kycErrorUnavailable": "The service is unavailable. Your selected photos are still here. Try again.", + "kycErrorInternal": "Something went wrong. Try again; your selected photos have been kept.", + "kycErrorPermission": "We could not open that photo. Check file access or choose another image.", + "kycErrorFormat": "Choose a JPEG or PNG image. Other formats are not supported.", + "kycErrorLarge": "This image is too large. Choose one under 10 MB and 40 megapixels.", + "kycErrorUnreadable": "This image is damaged or cannot be read. Choose another photo.", + "kycErrorSmall": "The photo is too small. Use an image at least 600 pixels wide and high.", + "kycErrorIncomplete": "Choose both photos and confirm they belong to you before submitting.", + "kycErrorSession": "Your session has expired. Return to login, then check your verification status.", + "kycErrorRate": "Too many requests. Wait a moment before trying again.", + "kycErrorConflict": "Your review status has changed. Refresh it before continuing.", + "kycDemoControls": "Demo: simulate a backoffice decision", + "kycDemoApprove": "Approve", + "kycDemoCorrect": "Request clearer passport", + "kycDemoSelfie": "Request clearer selfie", + "kycDemoExpired": "Passport expired", + "kycDemoDeny": "Decline", + "kycPreview": "Explore verification & limits", + "kycChooseFirst": "Choose a photo to continue.", + "kycStatusStale": "Status could not be refreshed. The status shown is the last known result." } diff --git a/frontend/app/lib/l10n/ru.arb b/frontend/app/lib/l10n/ru.arb index 02bf9e9..21eb357 100644 --- a/frontend/app/lib/l10n/ru.arb +++ b/frontend/app/lib/l10n/ru.arb @@ -72,5 +72,81 @@ "demoVerifiedBody": "Вы посмотрели подтверждение почты. Реальный аккаунт не подтверждён. Вернитесь ко входу, чтобы продолжить знакомство с демоверсией.", "verificationRecipient": "Получатель", "verificationChangeEmail": "Изменить почту", - "demoResend": "Подготовить демописьмо ещё раз" + "demoResend": "Подготовить демописьмо ещё раз", + "kycTitle": "Верификация и лимиты", + "kycIntro": "Ваш аккаунт — ваш темп. Подтвердите личность, когда понадобятся более высокие лимиты.", + "kycDemo": "ДЕМО · Используйте только тестовые изображения. Фото хранятся в памяти, не отправляются и удаляются при выходе или перезагрузке. Решения проверки имитируются.", + "kycUnverified": "Личность не подтверждена", + "kycPending": "На проверке", + "kycApproved": "Личность подтверждена", + "kycChanges": "Нужны новые фото", + "kycDenied": "Верификация отклонена", + "kycUnverifiedBody": "Вы можете пользоваться аккаунтом с базовыми лимитами. Для повышения отправьте фото паспорта и селфи с ним.", + "kycPendingBody": "Заявка ожидает проверки сотрудником. До одобрения действуют базовые лимиты. Повторная отправка не нужна.", + "kycApprovedBody": "Проверка завершена. Актуальные лимиты указаны ниже.", + "kycChangesBody": "Аккаунт доступен с базовыми лимитами. Замените фото и отправьте новую заявку.", + "kycDeniedBody": "Базовые лимиты остаются доступными. Если вы не согласны с решением, обратитесь в поддержку для пересмотра.", + "kycPassportReason": "Данные паспорта не удалось прочитать. Переснимите при хорошем освещении, чтобы все края были видны.", + "kycSelfieReason": "На селфи плохо видно лицо или паспорт. Оба должны быть в кадре и в фокусе.", + "kycExpiredReason": "Срок действия паспорта истёк. Используйте действующий паспорт и сделайте оба фото заново.", + "kycDeniedReason": "По этой заявке не удалось подтвердить вашу личность.", + "kycStart": "Подтвердить личность", + "kycResubmit": "Заменить фото", + "kycRefresh": "Обновить статус и лимиты", + "kycSupport": "Обратиться в поддержку", + "kycSupportBody": "В демоверсии поддержка не подключена. В рабочем приложении здесь можно будет запросить пересмотр без повторной отправки документов.", + "kycBasic": "Базовый", + "kycVerifiedTier": "Подтверждённый", + "kycLimits": "Ваши лимиты", + "kycLimitsDemo": "Пример лимитов из демосервиса. Это не действующее предложение.", + "kycDay": "Объём за день", + "kycMonth": "Объём за месяц", + "kycCurrent": "Текущий лимит", + "kycRemaining": "Доступно", + "kycAfter": "После одобрения", + "kycLimitsError": "Не удалось обновить лимиты. Значения ниже могут быть устаревшими. Повторите запрос.", + "kycLimitsEmpty": "Сервис не вернул лимиты. Обновите данные или уточните их в поддержке перед операцией.", + "kycLoading": "Загружаем ваш статус…", + "kycPassport": "Фото паспорта", + "kycSelfie": "Селфи с паспортом", + "kycReview": "Проверьте фото", + "kycPassportHelp": "Откройте действующий паспорт на странице с фото. Все четыре края и текст должны быть чётко видны. Избегайте бликов и размытия, не закрывайте данные.", + "kycSelfieHelp": "Держите открытый паспорт рядом с лицом. Лицо целиком и страница с фото должны быть в одном кадре, чёткими и ничем не закрытыми.", + "kycFileRules": "JPEG или PNG · до 10 МБ · не менее 600 × 600 пикселей. Сделайте фото камерой, затем выберите его здесь.", + "kycChoose": "Выбрать фото", + "kycReplace": "Выбрать другое фото", + "kycRemove": "Удалить фото", + "kycNext": "Продолжить", + "kycBack": "Назад", + "kycSkip": "Оставить базовые лимиты", + "kycClose": "К демоаккаунту", + "kycReviewHelp": "Убедитесь, что оба фото читаемы и на них один паспорт. Их проверит сотрудник. Отправка не повышает лимиты сразу.", + "kycConfirm": "На фото я и мой действующий паспорт.", + "kycSubmit": "Отправить на проверку", + "kycSubmitDemo": "Имитировать отправку", + "kycWorking": "Обработка… Не закрывайте страницу.", + "kycDiscardTitle": "Удалить выбранные фото?", + "kycDiscardBody": "Черновик хранится только в памяти. При выходе выбранные фото будут удалены. Позже можно начать заново.", + "kycStay": "Продолжить", + "kycDiscard": "Удалить и выйти", + "kycErrorUnavailable": "Сервис недоступен. Выбранные фото сохранены в памяти. Повторите попытку.", + "kycErrorInternal": "Произошла ошибка. Повторите попытку; выбранные фото сохранены.", + "kycErrorPermission": "Не удалось открыть фото. Проверьте доступ к файлам или выберите другое изображение.", + "kycErrorFormat": "Выберите изображение JPEG или PNG. Другие форматы не поддерживаются.", + "kycErrorLarge": "Изображение слишком большое. Выберите файл до 10 МБ и 40 мегапикселей.", + "kycErrorUnreadable": "Изображение повреждено или не читается. Выберите другое фото.", + "kycErrorSmall": "Фото слишком маленькое. Ширина и высота должны быть не менее 600 пикселей.", + "kycErrorIncomplete": "Выберите оба фото и подтвердите, что на них вы и ваш паспорт.", + "kycErrorSession": "Сессия истекла. Войдите снова и проверьте статус верификации.", + "kycErrorRate": "Слишком много запросов. Немного подождите и повторите попытку.", + "kycErrorConflict": "Статус проверки изменился. Обновите его, прежде чем продолжить.", + "kycDemoControls": "Демо: имитация решения сотрудника", + "kycDemoApprove": "Одобрить", + "kycDemoCorrect": "Запросить чёткое фото паспорта", + "kycDemoSelfie": "Запросить чёткое селфи", + "kycDemoExpired": "Паспорт просрочен", + "kycDemoDeny": "Отклонить", + "kycPreview": "Посмотреть верификацию и лимиты", + "kycChooseFirst": "Выберите фото для продолжения.", + "kycStatusStale": "Не удалось обновить статус. Показан последний известный результат." } diff --git a/frontend/app/lib/main.dart b/frontend/app/lib/main.dart index 032cb6f..8986a0e 100644 --- a/frontend/app/lib/main.dart +++ b/frontend/app/lib/main.dart @@ -6,6 +6,8 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart'; import 'package:sxchnge_auth/sxchnge_auth.dart'; +import 'package:sxchnge_kyc/sxchnge_kyc.dart'; + import 'package:sxchnge_theme/sxchnge_theme.dart'; import 'providers/locale.dart'; @@ -24,12 +26,18 @@ class SxchngeApp extends StatefulWidget { super.key, this.localeProvider, this.analyticsService, + this.identityService, + this.limitsService, + this.photoService = const FilePhotoService(), this.loginService = const DummyLoginService(), this.signupService = const DummySignupService(), this.verificationService = const DummyVerificationService(), }); final LocaleProvider? localeProvider; final AnalyticsService? analyticsService; + final IdentityService? identityService; + final LimitsService? limitsService; + final PhotoService photoService; final LoginService loginService; final SignupService signupService; final VerificationService verificationService; @@ -41,9 +49,10 @@ class SxchngeApp extends StatefulWidget { class _SxchngeAppState extends State { late final _locale = widget.localeProvider ?? LocaleProvider(); - late final _journeys = JourneyRecorder( - widget.analyticsService ?? DummyAnalyticsService(), - ); + late final _analytics = widget.analyticsService ?? DummyAnalyticsService(); + late final _identity = widget.identityService ?? DummyIdentityService(); + late final _limits = widget.limitsService ?? DummyLimitsService(_identity); + late final _journeys = JourneyRecorder(_analytics); late final _services = InstrumentedAuthServices( recorder: _journeys, loginService: widget.loginService, @@ -81,6 +90,10 @@ class _SxchngeAppState extends State { Widget build(BuildContext context) => MultiProvider( providers: [ Provider.value(value: _journeys), + Provider.value(value: _analytics), + Provider.value(value: _identity), + Provider.value(value: _limits), + Provider.value(value: widget.photoService), ChangeNotifierProvider.value(value: _locale), Provider.value(value: _services), ChangeNotifierProvider(create: (_) => LoginProvider(_services)), diff --git a/frontend/app/lib/routing/router.dart b/frontend/app/lib/routing/router.dart index 0cfd021..f7c5da1 100644 --- a/frontend/app/lib/routing/router.dart +++ b/frontend/app/lib/routing/router.dart @@ -1,37 +1,50 @@ import 'dart:convert'; +import 'package:flutter/widgets.dart'; + import 'package:go_router/go_router.dart'; import '../auth/page.dart'; +import '../kyc/page.dart'; import '../verification/page.dart'; import 'routes.dart'; -GoRouter createAppRouter() => GoRouter( - // Signup email is an in-memory navigation extra, never browser-history data. - extraCodec: const _TransientExtras(), - routes: [ - GoRoute( - path: '/', - name: AppRoutes.login, - builder: (context, state) => const AuthPage(), - ), - GoRoute( - path: '/verification-sent', - name: AppRoutes.verificationSent, - builder: (context, state) => VerificationPage( - email: state.extra is String ? state.extra as String : null, +GoRouter createAppRouter() { + final identityKey = GlobalKey(); + return GoRouter( + // Signup email is an in-memory navigation extra, never browser-history data. + extraCodec: const _TransientExtras(), + routes: [ + GoRoute( + path: '/identity', + name: AppRoutes.identity, + builder: (context, state) => IdentityPage(key: identityKey), + onExit: (context, state) async => + await identityKey.currentState?.allowExit() ?? true, ), - ), - GoRoute( - path: '/verify-email', - name: AppRoutes.verifyEmail, - builder: (context, state) => - VerificationPage(token: state.uri.queryParameters['token'] ?? ''), - ), - ], - // Preserve the existing fallback, and keep the visible URL canonical. - onException: (context, state, router) => router.goNamed(AppRoutes.login), -); + GoRoute( + path: '/', + name: AppRoutes.login, + builder: (context, state) => const AuthPage(), + ), + GoRoute( + path: '/verification-sent', + name: AppRoutes.verificationSent, + builder: (context, state) => VerificationPage( + email: state.extra is String ? state.extra as String : null, + ), + ), + GoRoute( + path: '/verify-email', + name: AppRoutes.verifyEmail, + builder: (context, state) => + VerificationPage(token: state.uri.queryParameters['token'] ?? ''), + ), + ], + // Preserve the existing fallback, and keep the visible URL canonical. + onException: (context, state, router) => router.goNamed(AppRoutes.login), + ); +} class _TransientExtras extends Codec { const _TransientExtras(); diff --git a/frontend/app/lib/routing/routes.dart b/frontend/app/lib/routing/routes.dart index c97a4d4..ed32787 100644 --- a/frontend/app/lib/routing/routes.dart +++ b/frontend/app/lib/routing/routes.dart @@ -1,4 +1,5 @@ abstract final class AppRoutes { + static const identity = 'identity'; static const login = 'login'; static const verificationSent = 'verification-sent'; static const verifyEmail = 'verify-email'; diff --git a/frontend/app/lib/verification/page.dart b/frontend/app/lib/verification/page.dart index 718f936..90cc69f 100644 --- a/frontend/app/lib/verification/page.dart +++ b/frontend/app/lib/verification/page.dart @@ -325,6 +325,13 @@ class _VerificationPageState extends State { child: Text(l.verificationRequest), ), ], + if (verified) ...[ + const SizedBox(height: 16), + FilledButton( + onPressed: () => context.goNamed(AppRoutes.identity), + child: Text(l.kycPreview), + ), + ], const SizedBox(height: 16), TextButton( onPressed: () => context.goNamed(AppRoutes.login), diff --git a/frontend/app/pubspec.lock b/frontend/app/pubspec.lock index 859f421..81e429f 100644 --- a/frontend/app/pubspec.lock +++ b/frontend/app/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + archive: + dependency: transitive + description: + name: archive + sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19 + url: "https://pub.dev" + source: hosted + version: "4.2.0" async: dependency: transitive description: @@ -41,6 +49,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 + url: "https://pub.dev" + source: hosted + version: "0.3.5+5" cupertino_icons: dependency: "direct main" description: @@ -81,6 +97,70 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector: + dependency: transitive + description: + name: file_selector + sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a + url: "https://pub.dev" + source: hosted + version: "1.1.0" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: d670cd0ce77a2e785b18d8b4d0a8d6a222d6a813ec9b7ddf2790a1b4fb6fa92c + url: "https://pub.dev" + source: hosted + version: "0.5.2+11" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: "97269e5307a0ab813b1fa2430bada0a96e0afb74848417f8676f64ba5de0051c" + url: "https://pub.dev" + source: hosted + version: "0.5.3+6" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab + url: "https://pub.dev" + source: hosted + version: "0.9.4+1" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 + url: "https://pub.dev" + source: hosted + version: "0.9.5+1" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec + url: "https://pub.dev" + source: hosted + version: "0.9.3+6" flutter: dependency: "direct main" description: flutter @@ -117,6 +197,30 @@ packages: url: "https://pub.dev" source: hosted version: "18.0.1" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e" + url: "https://pub.dev" + source: hosted + version: "4.9.2" intl: dependency: "direct main" description: @@ -253,6 +357,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" provider: dependency: "direct main" description: @@ -368,6 +480,13 @@ packages: relative: true source: path version: "0.1.0" + sxchnge_kyc: + dependency: "direct main" + description: + path: "../packages/sxchnge_kyc" + relative: true + source: path + version: "0.1.0" sxchnge_theme: dependency: "direct main" description: @@ -391,6 +510,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.12" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: diff --git a/frontend/app/pubspec.yaml b/frontend/app/pubspec.yaml index d02e0ea..c89dde1 100644 --- a/frontend/app/pubspec.yaml +++ b/frontend/app/pubspec.yaml @@ -6,6 +6,8 @@ environment: sdk: '>=3.13.0 <4.0.0' flutter: '>=3.47.0' dependencies: + sxchnge_kyc: + path: ../packages/sxchnge_kyc sxchnge_analytics: path: ../packages/sxchnge_analytics flutter: diff --git a/frontend/app/test/kyc_test.dart b/frontend/app/test/kyc_test.dart new file mode 100644 index 0000000..f088859 --- /dev/null +++ b/frontend/app/test/kyc_test.dart @@ -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 pick() async { + if (error != null) throw KycException(error!); + return Photo(bytes); + } +} + +Future 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 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 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(); + }); +} diff --git a/frontend/packages/sxchnge_analytics/lib/sxchnge_analytics.dart b/frontend/packages/sxchnge_analytics/lib/sxchnge_analytics.dart index b638a2e..6ff550c 100644 --- a/frontend/packages/sxchnge_analytics/lib/sxchnge_analytics.dart +++ b/frontend/packages/sxchnge_analytics/lib/sxchnge_analytics.dart @@ -2,11 +2,22 @@ import 'dart:async'; import 'dart:collection'; 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 { + cancelled, started, requested, succeeded, @@ -16,6 +27,7 @@ enum JourneySignal { } enum JourneyFailure { + document, invalidCredentials, emailInUse, unavailable, @@ -66,8 +78,13 @@ class AnalyticsEvent { final bool terminal; final bool simulated; final DateTime occurredAt; - String get name => - 'auth_${step == null ? 'journey' : _snake(step!.name)}_${signal.name}'; + String get 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 get properties => Map.unmodifiable({ 'schema_version': 1, 'event_id': eventId, @@ -165,16 +182,19 @@ class JourneyRecorder { bool simulated = false, JourneyFailure? failure, bool terminal = false, + bool cancelled = false, }) { final journey = operation._journey; if (_disposed || operation._finished || journey.closed) return; operation._finished = true; journey.pending--; journey.simulated |= simulated; - final complete = failure == null && terminal; + final complete = failure == null && !cancelled && terminal; _emit( journey, - failure != null + cancelled + ? JourneySignal.cancelled + : failure != null ? JourneySignal.failed : (simulated || (complete && journey.simulated)) ? JourneySignal.simulated diff --git a/frontend/packages/sxchnge_kyc/.gitignore b/frontend/packages/sxchnge_kyc/.gitignore new file mode 100644 index 0000000..61608ef --- /dev/null +++ b/frontend/packages/sxchnge_kyc/.gitignore @@ -0,0 +1,3 @@ +.dart_tool/ +build/ +pubspec.lock diff --git a/frontend/packages/sxchnge_kyc/README.md b/frontend/packages/sxchnge_kyc/README.md new file mode 100644 index 0000000..18181be --- /dev/null +++ b/frontend/packages/sxchnge_kyc/README.md @@ -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. diff --git a/frontend/packages/sxchnge_kyc/lib/src/models/identity.dart b/frontend/packages/sxchnge_kyc/lib/src/models/identity.dart new file mode 100644 index 0000000..2c90d64 --- /dev/null +++ b/frontend/packages/sxchnge_kyc/lib/src/models/identity.dart @@ -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; +} diff --git a/frontend/packages/sxchnge_kyc/lib/src/models/limits.dart b/frontend/packages/sxchnge_kyc/lib/src/models/limits.dart new file mode 100644 index 0000000..6e58621 --- /dev/null +++ b/frontend/packages/sxchnge_kyc/lib/src/models/limits.dart @@ -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 rows, + required this.simulated, + }) : rows = List.unmodifiable(rows); + final AccountTier tier; + final List rows; + final bool simulated; +} diff --git a/frontend/packages/sxchnge_kyc/lib/src/providers/identity.dart b/frontend/packages/sxchnge_kyc/lib/src/providers/identity.dart new file mode 100644 index 0000000..6aa1508 --- /dev/null +++ b/frontend/packages/sxchnge_kyc/lib/src/providers/identity.dart @@ -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 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 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 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 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 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(); + } +} diff --git a/frontend/packages/sxchnge_kyc/lib/src/providers/limits.dart b/frontend/packages/sxchnge_kyc/lib/src/providers/limits.dart new file mode 100644 index 0000000..6fa7e26 --- /dev/null +++ b/frontend/packages/sxchnge_kyc/lib/src/providers/limits.dart @@ -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 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(); + } +} diff --git a/frontend/packages/sxchnge_kyc/lib/src/services/contracts.dart b/frontend/packages/sxchnge_kyc/lib/src/services/contracts.dart new file mode 100644 index 0000000..2a8b782 --- /dev/null +++ b/frontend/packages/sxchnge_kyc/lib/src/services/contracts.dart @@ -0,0 +1,24 @@ +import '../models/identity.dart'; +import '../models/limits.dart'; + +abstract interface class IdentityService { + bool get isSimulated; + Future fetch(); + + /// Backend adapters must scope the key to the authenticated account and reject + /// stale/duplicate review transitions. Retrying the same key is idempotent. + Future 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 fetch(); +} + +abstract interface class PhotoService { + Future pick(); +} diff --git a/frontend/packages/sxchnge_kyc/lib/src/services/dummy.dart b/frontend/packages/sxchnge_kyc/lib/src/services/dummy.dart new file mode 100644 index 0000000..9dfef29 --- /dev/null +++ b/frontend/packages/sxchnge_kyc/lib/src/services/dummy.dart @@ -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 _wait() async { + await Future.delayed(delay); + final error = nextError; + nextError = null; + if (error != null) throw KycException(error); + } + + @override + Future fetch() async { + await _wait(); + return IdentityMapper.fromDTO(_record); + } + + @override + Future 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 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, + ), + ], + ), + ); + } +} diff --git a/frontend/packages/sxchnge_kyc/lib/src/services/photos.dart b/frontend/packages/sxchnge_kyc/lib/src/services/photos.dart new file mode 100644 index 0000000..cc6f2f9 --- /dev/null +++ b/frontend/packages/sxchnge_kyc/lib/src/services/photos.dart @@ -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 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 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); + } + } +} diff --git a/frontend/packages/sxchnge_kyc/lib/src/transport/contracts.dart b/frontend/packages/sxchnge_kyc/lib/src/transport/contracts.dart new file mode 100644 index 0000000..b15337b --- /dev/null +++ b/frontend/packages/sxchnge_kyc/lib/src/transport/contracts.dart @@ -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 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(), + ); +} diff --git a/frontend/packages/sxchnge_kyc/lib/sxchnge_kyc.dart b/frontend/packages/sxchnge_kyc/lib/sxchnge_kyc.dart new file mode 100644 index 0000000..dc1e5e8 --- /dev/null +++ b/frontend/packages/sxchnge_kyc/lib/sxchnge_kyc.dart @@ -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'; diff --git a/frontend/packages/sxchnge_kyc/pubspec.yaml b/frontend/packages/sxchnge_kyc/pubspec.yaml new file mode 100644 index 0000000..76cdf68 --- /dev/null +++ b/frontend/packages/sxchnge_kyc/pubspec.yaml @@ -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 diff --git a/frontend/packages/sxchnge_kyc/test/kyc_test.dart b/frontend/packages/sxchnge_kyc/test/kyc_test.dart new file mode 100644 index 0000000..5af1ea8 --- /dev/null +++ b/frontend/packages/sxchnge_kyc/test/kyc_test.dart @@ -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? pending; + @override + Future pick() async { + if (error != null) throw KycException(error!); + return pending == null ? result : pending!.future; + } +} + +class LimitStub implements LimitsService { + final requests = >[]; + @override + Future fetch() { + final c = Completer(); + requests.add(c); + return c.future; + } +} + +class BrokenAnalytics implements AnalyticsService { + @override + Future capture(AnalyticsEvent event) => throw StateError('offline'); +} + +Future 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.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.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.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.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(); + 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 rejects(Uint8List bytes, KycError code) async => expectLater( + FilePhotoService.validate(bytes), + throwsA(isA().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); + }); +}