Add optional identity verification and service-backed limits #21
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
.PHONY: test-frontend
|
.PHONY: test-frontend
|
||||||
|
|
||||||
test-frontend:
|
test-frontend:
|
||||||
|
cd frontend/packages/sxchnge_kyc && flutter test
|
||||||
cd frontend/packages/sxchnge_analytics && flutter test
|
cd frontend/packages/sxchnge_analytics && flutter test
|
||||||
cd frontend/packages/sxchnge_auth && flutter test
|
cd frontend/packages/sxchnge_auth && flutter test
|
||||||
cd frontend/packages/sxchnge_theme && flutter test
|
cd frontend/packages/sxchnge_theme && flutter test
|
||||||
|
|||||||
@@ -169,3 +169,18 @@ Follow [`AGENTS.md`](AGENTS.md) and the applicable scoped guidelines, including
|
|||||||
Implementation work uses a Gitea task and linked branch, validation, commits,
|
Implementation work uses a Gitea task and linked branch, validation, commits,
|
||||||
a push and a PR. Merge only when authorized. Record scope, validation and known
|
a push and a PR. Merge only when authorized. Record scope, validation and known
|
||||||
limitations in the task and [implementation record](docs/implementation.md).
|
limitations in the task and [implementation record](docs/implementation.md).
|
||||||
|
|
||||||
|
## Identity verification preview
|
||||||
|
|
||||||
|
Open `/#/identity` or select **Explore verification & limits** from login or email
|
||||||
|
confirmation. The optional flow covers passport and selfie selection, review,
|
||||||
|
submission, pending review, approval, correction requests and denial in EN/RU.
|
||||||
|
Use sample images only: photos are never uploaded and drafts are not persisted.
|
||||||
|
|
||||||
|
The shared [sxchnge_kyc package](frontend/packages/sxchnge_kyc/README.md) provides
|
||||||
|
identity and limits services/providers for web and future mobile clients. Limits
|
||||||
|
are fetched through `LimitsProvider → LimitsService`; fixture amounts and simulated
|
||||||
|
review decisions demonstrate the future backend contract. Higher limits apply
|
||||||
|
only when returned by that service. Production review/storage/support and financial
|
||||||
|
authorization are not connected. See the [UX plan](docs/ux/kyc.md) for state, error,
|
||||||
|
privacy and measurement details.
|
||||||
@@ -152,6 +152,32 @@ skill location and contribution workflow. Local links and diff checks pass;
|
|||||||
commands and prerequisites were checked against repository configuration.
|
commands and prerequisites were checked against repository configuration.
|
||||||
Documentation only; no runtime tests were run.
|
Documentation only; no runtime tests were run.
|
||||||
|
|
||||||
|
## Optional identity verification and backend-owned limits — task #20
|
||||||
|
|
||||||
|
- Branch: `kyc-20`, based on `61ccfc1` (main after README PR #19).
|
||||||
|
- Planned the UX before implementation in [docs/ux/kyc.md](ux/kyc.md), including
|
||||||
|
optional basic access, two-photo preparation/review, pending and decision states,
|
||||||
|
errors, recovery, privacy and measurement definitions.
|
||||||
|
- Added the mobile-reusable `sxchnge_kyc` package, injectable identity/photo/limits
|
||||||
|
services, transport mappers and providers. Followed Sendico's visible-limits
|
||||||
|
ownership boundary; current tier/caps/remaining amounts come from a service.
|
||||||
|
- Added themed EN/RU `/identity` screens, login/email-confirmation entry points,
|
||||||
|
route exit guards, review/replace/discard controls, safe error messages and
|
||||||
|
demo-only backoffice decision controls. No real documents are transmitted.
|
||||||
|
- Reviewed full frontend scope and corrected web image-metadata validation,
|
||||||
|
stale-state messaging, cancelled-picker analytics, step focus/scroll behavior,
|
||||||
|
duplicate submissions and sensitive image cache cleanup.
|
||||||
|
- Validation: format check for all 27 changed/new Dart files; analyzer passes for
|
||||||
|
app, sxchnge_kyc and sxchnge_analytics; `make test-frontend` passes **86 tests**
|
||||||
|
(10 KYC, 7 analytics, 20 auth, 3 theme, 3 consumer example, 43 app). Web build
|
||||||
|
passes. Actual browser selection of generated PNG samples reached pending
|
||||||
|
review with unchanged basic limits. EN/RU, 320px/1.6 text scale, accessibility
|
||||||
|
labels/tap targets, route guards and all decision paths have widget coverage.
|
||||||
|
- Limits are illustrative backend-stub fixtures. Real authenticated uploads,
|
||||||
|
private storage, staff review, support, authoritative limits enforcement and
|
||||||
|
server-side review analytics remain integration work. Native mobile and
|
||||||
|
VoiceOver/TalkBack checks remain release QA. Security scans were not requested.
|
||||||
|
- Delivery: [PR #21](https://git.sendico.io/tech/sxchnge/pulls/21), pushed on `kyc-20`; not merged. Final browser review also preserved heading semantics when moving keyboard focus.
|
||||||
## UX simplicity preference
|
## UX simplicity preference
|
||||||
|
|
||||||
Task [#26](https://git.sendico.io/tech/sxchnge/issues/26), delivered in
|
Task [#26](https://git.sendico.io/tech/sxchnge/issues/26), delivered in
|
||||||
|
|||||||
+149
@@ -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.
|
||||||
@@ -310,6 +310,10 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
AuthFeedback(state: state, signUp: _signUp),
|
AuthFeedback(state: state, signUp: _signUp),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => context.goNamed(AppRoutes.identity),
|
||||||
|
child: Text(l.kycPreview),
|
||||||
|
),
|
||||||
Wrap(
|
Wrap(
|
||||||
alignment: WrapAlignment.center,
|
alignment: WrapAlignment.center,
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
|||||||
@@ -535,6 +535,462 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Prepare demo email again'**
|
/// **'Prepare demo email again'**
|
||||||
String get demoResend;
|
String get demoResend;
|
||||||
|
|
||||||
|
/// No description provided for @kycTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Verification & limits'**
|
||||||
|
String get kycTitle;
|
||||||
|
|
||||||
|
/// No description provided for @kycIntro.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Your account, at your pace. Verify your identity when you need higher limits.'**
|
||||||
|
String get kycIntro;
|
||||||
|
|
||||||
|
/// No description provided for @kycDemo.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'DEMO · Use sample images only. Photos stay in memory, are never uploaded, and disappear when you leave or reload. Review decisions are simulated.'**
|
||||||
|
String get kycDemo;
|
||||||
|
|
||||||
|
/// No description provided for @kycUnverified.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Not verified'**
|
||||||
|
String get kycUnverified;
|
||||||
|
|
||||||
|
/// No description provided for @kycPending.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'In review'**
|
||||||
|
String get kycPending;
|
||||||
|
|
||||||
|
/// No description provided for @kycApproved.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Identity verified'**
|
||||||
|
String get kycApproved;
|
||||||
|
|
||||||
|
/// No description provided for @kycChanges.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'New photos needed'**
|
||||||
|
String get kycChanges;
|
||||||
|
|
||||||
|
/// No description provided for @kycDenied.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Verification declined'**
|
||||||
|
String get kycDenied;
|
||||||
|
|
||||||
|
/// No description provided for @kycUnverifiedBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'You can keep using your account with basic limits. Submit a passport photo and a selfie holding it to request an upgrade.'**
|
||||||
|
String get kycUnverifiedBody;
|
||||||
|
|
||||||
|
/// No description provided for @kycPendingBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Your submission is awaiting a human review. Basic limits remain active until approval. You do not need to submit again.'**
|
||||||
|
String get kycPendingBody;
|
||||||
|
|
||||||
|
/// No description provided for @kycApprovedBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Your review is complete. Check your current limits below.'**
|
||||||
|
String get kycApprovedBody;
|
||||||
|
|
||||||
|
/// No description provided for @kycChangesBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Your account remains available with basic limits. Replace the photos and submit a new review request.'**
|
||||||
|
String get kycChangesBody;
|
||||||
|
|
||||||
|
/// No description provided for @kycDeniedBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Basic limits remain available. If you believe this decision is incorrect, contact support for a review.'**
|
||||||
|
String get kycDeniedBody;
|
||||||
|
|
||||||
|
/// No description provided for @kycPassportReason.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The passport details could not be read. Retake the photo in good light, with every edge visible.'**
|
||||||
|
String get kycPassportReason;
|
||||||
|
|
||||||
|
/// No description provided for @kycSelfieReason.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Your face or passport was not clear in the selfie. Keep both visible and in focus.'**
|
||||||
|
String get kycSelfieReason;
|
||||||
|
|
||||||
|
/// No description provided for @kycExpiredReason.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The passport has expired. Use a valid passport and take both photos again.'**
|
||||||
|
String get kycExpiredReason;
|
||||||
|
|
||||||
|
/// No description provided for @kycDeniedReason.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'We could not verify your identity from this submission.'**
|
||||||
|
String get kycDeniedReason;
|
||||||
|
|
||||||
|
/// No description provided for @kycStart.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Verify identity'**
|
||||||
|
String get kycStart;
|
||||||
|
|
||||||
|
/// No description provided for @kycResubmit.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Replace photos'**
|
||||||
|
String get kycResubmit;
|
||||||
|
|
||||||
|
/// No description provided for @kycRefresh.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Refresh status & limits'**
|
||||||
|
String get kycRefresh;
|
||||||
|
|
||||||
|
/// No description provided for @kycSupport.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Contact support'**
|
||||||
|
String get kycSupport;
|
||||||
|
|
||||||
|
/// No description provided for @kycSupportBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Support is not connected in this preview. In the live app, you will be able to request a review here without submitting documents again.'**
|
||||||
|
String get kycSupportBody;
|
||||||
|
|
||||||
|
/// No description provided for @kycBasic.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Basic'**
|
||||||
|
String get kycBasic;
|
||||||
|
|
||||||
|
/// No description provided for @kycVerifiedTier.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Verified'**
|
||||||
|
String get kycVerifiedTier;
|
||||||
|
|
||||||
|
/// No description provided for @kycLimits.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Your limits'**
|
||||||
|
String get kycLimits;
|
||||||
|
|
||||||
|
/// No description provided for @kycLimitsDemo.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Illustrative limits from the demo service. These are not a live offer.'**
|
||||||
|
String get kycLimitsDemo;
|
||||||
|
|
||||||
|
/// No description provided for @kycDay.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Daily volume'**
|
||||||
|
String get kycDay;
|
||||||
|
|
||||||
|
/// No description provided for @kycMonth.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Monthly volume'**
|
||||||
|
String get kycMonth;
|
||||||
|
|
||||||
|
/// No description provided for @kycCurrent.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Current cap'**
|
||||||
|
String get kycCurrent;
|
||||||
|
|
||||||
|
/// No description provided for @kycRemaining.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Available'**
|
||||||
|
String get kycRemaining;
|
||||||
|
|
||||||
|
/// No description provided for @kycAfter.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'After approval'**
|
||||||
|
String get kycAfter;
|
||||||
|
|
||||||
|
/// No description provided for @kycLimitsError.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Limits could not be refreshed. Any values below are out of date. Retry to see your current access.'**
|
||||||
|
String get kycLimitsError;
|
||||||
|
|
||||||
|
/// No description provided for @kycLimitsEmpty.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No limits were returned. Refresh or check with support before making a transaction.'**
|
||||||
|
String get kycLimitsEmpty;
|
||||||
|
|
||||||
|
/// No description provided for @kycLoading.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Loading your status…'**
|
||||||
|
String get kycLoading;
|
||||||
|
|
||||||
|
/// No description provided for @kycPassport.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Passport photo'**
|
||||||
|
String get kycPassport;
|
||||||
|
|
||||||
|
/// No description provided for @kycSelfie.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Selfie with passport'**
|
||||||
|
String get kycSelfie;
|
||||||
|
|
||||||
|
/// No description provided for @kycReview.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Review your photos'**
|
||||||
|
String get kycReview;
|
||||||
|
|
||||||
|
/// No description provided for @kycPassportHelp.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Open your valid passport to the photo page. Show all four edges and keep text sharp. Avoid glare, blur and covered details.'**
|
||||||
|
String get kycPassportHelp;
|
||||||
|
|
||||||
|
/// No description provided for @kycSelfieHelp.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Hold the open passport beside your face. Show your whole face and the photo page in the same image. Keep both sharp and uncovered.'**
|
||||||
|
String get kycSelfieHelp;
|
||||||
|
|
||||||
|
/// No description provided for @kycFileRules.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'JPEG or PNG · up to 10 MB · at least 600 × 600 pixels. Take a photo with your camera, then choose it here.'**
|
||||||
|
String get kycFileRules;
|
||||||
|
|
||||||
|
/// No description provided for @kycChoose.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Choose photo'**
|
||||||
|
String get kycChoose;
|
||||||
|
|
||||||
|
/// No description provided for @kycReplace.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Choose a different photo'**
|
||||||
|
String get kycReplace;
|
||||||
|
|
||||||
|
/// No description provided for @kycRemove.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Remove photo'**
|
||||||
|
String get kycRemove;
|
||||||
|
|
||||||
|
/// No description provided for @kycNext.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Continue'**
|
||||||
|
String get kycNext;
|
||||||
|
|
||||||
|
/// No description provided for @kycBack.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Back'**
|
||||||
|
String get kycBack;
|
||||||
|
|
||||||
|
/// No description provided for @kycSkip.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Keep basic limits'**
|
||||||
|
String get kycSkip;
|
||||||
|
|
||||||
|
/// No description provided for @kycClose.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Back to account preview'**
|
||||||
|
String get kycClose;
|
||||||
|
|
||||||
|
/// No description provided for @kycReviewHelp.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Check that both photos are readable and show the same passport. A staff member will review them; submission does not immediately raise your limits.'**
|
||||||
|
String get kycReviewHelp;
|
||||||
|
|
||||||
|
/// No description provided for @kycConfirm.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'These photos show me and my own valid passport.'**
|
||||||
|
String get kycConfirm;
|
||||||
|
|
||||||
|
/// No description provided for @kycSubmit.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Submit for review'**
|
||||||
|
String get kycSubmit;
|
||||||
|
|
||||||
|
/// No description provided for @kycSubmitDemo.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Simulate submission'**
|
||||||
|
String get kycSubmitDemo;
|
||||||
|
|
||||||
|
/// No description provided for @kycWorking.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Processing… Please keep this page open.'**
|
||||||
|
String get kycWorking;
|
||||||
|
|
||||||
|
/// No description provided for @kycDiscardTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Discard these photos?'**
|
||||||
|
String get kycDiscardTitle;
|
||||||
|
|
||||||
|
/// No description provided for @kycDiscardBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Your draft is only in memory. Leaving will remove the selected photos; you can start again later.'**
|
||||||
|
String get kycDiscardBody;
|
||||||
|
|
||||||
|
/// No description provided for @kycStay.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Keep editing'**
|
||||||
|
String get kycStay;
|
||||||
|
|
||||||
|
/// No description provided for @kycDiscard.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Discard & leave'**
|
||||||
|
String get kycDiscard;
|
||||||
|
|
||||||
|
/// No description provided for @kycErrorUnavailable.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The service is unavailable. Your selected photos are still here. Try again.'**
|
||||||
|
String get kycErrorUnavailable;
|
||||||
|
|
||||||
|
/// No description provided for @kycErrorInternal.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Something went wrong. Try again; your selected photos have been kept.'**
|
||||||
|
String get kycErrorInternal;
|
||||||
|
|
||||||
|
/// No description provided for @kycErrorPermission.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'We could not open that photo. Check file access or choose another image.'**
|
||||||
|
String get kycErrorPermission;
|
||||||
|
|
||||||
|
/// No description provided for @kycErrorFormat.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Choose a JPEG or PNG image. Other formats are not supported.'**
|
||||||
|
String get kycErrorFormat;
|
||||||
|
|
||||||
|
/// No description provided for @kycErrorLarge.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This image is too large. Choose one under 10 MB and 40 megapixels.'**
|
||||||
|
String get kycErrorLarge;
|
||||||
|
|
||||||
|
/// No description provided for @kycErrorUnreadable.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This image is damaged or cannot be read. Choose another photo.'**
|
||||||
|
String get kycErrorUnreadable;
|
||||||
|
|
||||||
|
/// No description provided for @kycErrorSmall.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The photo is too small. Use an image at least 600 pixels wide and high.'**
|
||||||
|
String get kycErrorSmall;
|
||||||
|
|
||||||
|
/// No description provided for @kycErrorIncomplete.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Choose both photos and confirm they belong to you before submitting.'**
|
||||||
|
String get kycErrorIncomplete;
|
||||||
|
|
||||||
|
/// No description provided for @kycErrorSession.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Your session has expired. Return to login, then check your verification status.'**
|
||||||
|
String get kycErrorSession;
|
||||||
|
|
||||||
|
/// No description provided for @kycErrorRate.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Too many requests. Wait a moment before trying again.'**
|
||||||
|
String get kycErrorRate;
|
||||||
|
|
||||||
|
/// No description provided for @kycErrorConflict.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Your review status has changed. Refresh it before continuing.'**
|
||||||
|
String get kycErrorConflict;
|
||||||
|
|
||||||
|
/// No description provided for @kycDemoControls.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Demo: simulate a backoffice decision'**
|
||||||
|
String get kycDemoControls;
|
||||||
|
|
||||||
|
/// No description provided for @kycDemoApprove.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Approve'**
|
||||||
|
String get kycDemoApprove;
|
||||||
|
|
||||||
|
/// No description provided for @kycDemoCorrect.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Request clearer passport'**
|
||||||
|
String get kycDemoCorrect;
|
||||||
|
|
||||||
|
/// No description provided for @kycDemoSelfie.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Request clearer selfie'**
|
||||||
|
String get kycDemoSelfie;
|
||||||
|
|
||||||
|
/// No description provided for @kycDemoExpired.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Passport expired'**
|
||||||
|
String get kycDemoExpired;
|
||||||
|
|
||||||
|
/// No description provided for @kycDemoDeny.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Decline'**
|
||||||
|
String get kycDemoDeny;
|
||||||
|
|
||||||
|
/// No description provided for @kycPreview.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Explore verification & limits'**
|
||||||
|
String get kycPreview;
|
||||||
|
|
||||||
|
/// No description provided for @kycChooseFirst.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Choose a photo to continue.'**
|
||||||
|
String get kycChooseFirst;
|
||||||
|
|
||||||
|
/// No description provided for @kycStatusStale.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Status could not be refreshed. The status shown is the last known result.'**
|
||||||
|
String get kycStatusStale;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -242,4 +242,264 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get demoResend => 'Prepare demo email again';
|
String get demoResend => 'Prepare demo email again';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycTitle => 'Verification & limits';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycIntro =>
|
||||||
|
'Your account, at your pace. Verify your identity when you need higher limits.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycDemo =>
|
||||||
|
'DEMO · Use sample images only. Photos stay in memory, are never uploaded, and disappear when you leave or reload. Review decisions are simulated.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycUnverified => 'Not verified';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycPending => 'In review';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycApproved => 'Identity verified';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycChanges => 'New photos needed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycDenied => 'Verification declined';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycUnverifiedBody =>
|
||||||
|
'You can keep using your account with basic limits. Submit a passport photo and a selfie holding it to request an upgrade.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycPendingBody =>
|
||||||
|
'Your submission is awaiting a human review. Basic limits remain active until approval. You do not need to submit again.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycApprovedBody =>
|
||||||
|
'Your review is complete. Check your current limits below.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycChangesBody =>
|
||||||
|
'Your account remains available with basic limits. Replace the photos and submit a new review request.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycDeniedBody =>
|
||||||
|
'Basic limits remain available. If you believe this decision is incorrect, contact support for a review.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycPassportReason =>
|
||||||
|
'The passport details could not be read. Retake the photo in good light, with every edge visible.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycSelfieReason =>
|
||||||
|
'Your face or passport was not clear in the selfie. Keep both visible and in focus.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycExpiredReason =>
|
||||||
|
'The passport has expired. Use a valid passport and take both photos again.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycDeniedReason =>
|
||||||
|
'We could not verify your identity from this submission.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycStart => 'Verify identity';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycResubmit => 'Replace photos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycRefresh => 'Refresh status & limits';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycSupport => 'Contact support';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycSupportBody =>
|
||||||
|
'Support is not connected in this preview. In the live app, you will be able to request a review here without submitting documents again.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycBasic => 'Basic';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycVerifiedTier => 'Verified';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycLimits => 'Your limits';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycLimitsDemo =>
|
||||||
|
'Illustrative limits from the demo service. These are not a live offer.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycDay => 'Daily volume';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycMonth => 'Monthly volume';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycCurrent => 'Current cap';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycRemaining => 'Available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycAfter => 'After approval';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycLimitsError =>
|
||||||
|
'Limits could not be refreshed. Any values below are out of date. Retry to see your current access.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycLimitsEmpty =>
|
||||||
|
'No limits were returned. Refresh or check with support before making a transaction.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycLoading => 'Loading your status…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycPassport => 'Passport photo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycSelfie => 'Selfie with passport';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycReview => 'Review your photos';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycPassportHelp =>
|
||||||
|
'Open your valid passport to the photo page. Show all four edges and keep text sharp. Avoid glare, blur and covered details.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycSelfieHelp =>
|
||||||
|
'Hold the open passport beside your face. Show your whole face and the photo page in the same image. Keep both sharp and uncovered.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycFileRules =>
|
||||||
|
'JPEG or PNG · up to 10 MB · at least 600 × 600 pixels. Take a photo with your camera, then choose it here.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycChoose => 'Choose photo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycReplace => 'Choose a different photo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycRemove => 'Remove photo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycNext => 'Continue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycBack => 'Back';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycSkip => 'Keep basic limits';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycClose => 'Back to account preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycReviewHelp =>
|
||||||
|
'Check that both photos are readable and show the same passport. A staff member will review them; submission does not immediately raise your limits.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycConfirm => 'These photos show me and my own valid passport.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycSubmit => 'Submit for review';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycSubmitDemo => 'Simulate submission';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycWorking => 'Processing… Please keep this page open.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycDiscardTitle => 'Discard these photos?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycDiscardBody =>
|
||||||
|
'Your draft is only in memory. Leaving will remove the selected photos; you can start again later.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycStay => 'Keep editing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycDiscard => 'Discard & leave';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycErrorUnavailable =>
|
||||||
|
'The service is unavailable. Your selected photos are still here. Try again.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycErrorInternal =>
|
||||||
|
'Something went wrong. Try again; your selected photos have been kept.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycErrorPermission =>
|
||||||
|
'We could not open that photo. Check file access or choose another image.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycErrorFormat =>
|
||||||
|
'Choose a JPEG or PNG image. Other formats are not supported.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycErrorLarge =>
|
||||||
|
'This image is too large. Choose one under 10 MB and 40 megapixels.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycErrorUnreadable =>
|
||||||
|
'This image is damaged or cannot be read. Choose another photo.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycErrorSmall =>
|
||||||
|
'The photo is too small. Use an image at least 600 pixels wide and high.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycErrorIncomplete =>
|
||||||
|
'Choose both photos and confirm they belong to you before submitting.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycErrorSession =>
|
||||||
|
'Your session has expired. Return to login, then check your verification status.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycErrorRate =>
|
||||||
|
'Too many requests. Wait a moment before trying again.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycErrorConflict =>
|
||||||
|
'Your review status has changed. Refresh it before continuing.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycDemoControls => 'Demo: simulate a backoffice decision';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycDemoApprove => 'Approve';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycDemoCorrect => 'Request clearer passport';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycDemoSelfie => 'Request clearer selfie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycDemoExpired => 'Passport expired';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycDemoDeny => 'Decline';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycPreview => 'Explore verification & limits';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycChooseFirst => 'Choose a photo to continue.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycStatusStale =>
|
||||||
|
'Status could not be refreshed. The status shown is the last known result.';
|
||||||
}
|
}
|
||||||
@@ -243,4 +243,264 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get demoResend => 'Подготовить демописьмо ещё раз';
|
String get demoResend => 'Подготовить демописьмо ещё раз';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycTitle => 'Верификация и лимиты';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycIntro =>
|
||||||
|
'Ваш аккаунт — ваш темп. Подтвердите личность, когда понадобятся более высокие лимиты.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycDemo =>
|
||||||
|
'ДЕМО · Используйте только тестовые изображения. Фото хранятся в памяти, не отправляются и удаляются при выходе или перезагрузке. Решения проверки имитируются.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycUnverified => 'Личность не подтверждена';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycPending => 'На проверке';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycApproved => 'Личность подтверждена';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycChanges => 'Нужны новые фото';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycDenied => 'Верификация отклонена';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycUnverifiedBody =>
|
||||||
|
'Вы можете пользоваться аккаунтом с базовыми лимитами. Для повышения отправьте фото паспорта и селфи с ним.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycPendingBody =>
|
||||||
|
'Заявка ожидает проверки сотрудником. До одобрения действуют базовые лимиты. Повторная отправка не нужна.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycApprovedBody =>
|
||||||
|
'Проверка завершена. Актуальные лимиты указаны ниже.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycChangesBody =>
|
||||||
|
'Аккаунт доступен с базовыми лимитами. Замените фото и отправьте новую заявку.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycDeniedBody =>
|
||||||
|
'Базовые лимиты остаются доступными. Если вы не согласны с решением, обратитесь в поддержку для пересмотра.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycPassportReason =>
|
||||||
|
'Данные паспорта не удалось прочитать. Переснимите при хорошем освещении, чтобы все края были видны.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycSelfieReason =>
|
||||||
|
'На селфи плохо видно лицо или паспорт. Оба должны быть в кадре и в фокусе.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycExpiredReason =>
|
||||||
|
'Срок действия паспорта истёк. Используйте действующий паспорт и сделайте оба фото заново.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycDeniedReason =>
|
||||||
|
'По этой заявке не удалось подтвердить вашу личность.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycStart => 'Подтвердить личность';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycResubmit => 'Заменить фото';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycRefresh => 'Обновить статус и лимиты';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycSupport => 'Обратиться в поддержку';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycSupportBody =>
|
||||||
|
'В демоверсии поддержка не подключена. В рабочем приложении здесь можно будет запросить пересмотр без повторной отправки документов.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycBasic => 'Базовый';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycVerifiedTier => 'Подтверждённый';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycLimits => 'Ваши лимиты';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycLimitsDemo =>
|
||||||
|
'Пример лимитов из демосервиса. Это не действующее предложение.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycDay => 'Объём за день';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycMonth => 'Объём за месяц';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycCurrent => 'Текущий лимит';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycRemaining => 'Доступно';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycAfter => 'После одобрения';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycLimitsError =>
|
||||||
|
'Не удалось обновить лимиты. Значения ниже могут быть устаревшими. Повторите запрос.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycLimitsEmpty =>
|
||||||
|
'Сервис не вернул лимиты. Обновите данные или уточните их в поддержке перед операцией.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycLoading => 'Загружаем ваш статус…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycPassport => 'Фото паспорта';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycSelfie => 'Селфи с паспортом';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycReview => 'Проверьте фото';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycPassportHelp =>
|
||||||
|
'Откройте действующий паспорт на странице с фото. Все четыре края и текст должны быть чётко видны. Избегайте бликов и размытия, не закрывайте данные.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycSelfieHelp =>
|
||||||
|
'Держите открытый паспорт рядом с лицом. Лицо целиком и страница с фото должны быть в одном кадре, чёткими и ничем не закрытыми.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycFileRules =>
|
||||||
|
'JPEG или PNG · до 10 МБ · не менее 600 × 600 пикселей. Сделайте фото камерой, затем выберите его здесь.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycChoose => 'Выбрать фото';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycReplace => 'Выбрать другое фото';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycRemove => 'Удалить фото';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycNext => 'Продолжить';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycBack => 'Назад';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycSkip => 'Оставить базовые лимиты';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycClose => 'К демоаккаунту';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycReviewHelp =>
|
||||||
|
'Убедитесь, что оба фото читаемы и на них один паспорт. Их проверит сотрудник. Отправка не повышает лимиты сразу.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycConfirm => 'На фото я и мой действующий паспорт.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycSubmit => 'Отправить на проверку';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycSubmitDemo => 'Имитировать отправку';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycWorking => 'Обработка… Не закрывайте страницу.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycDiscardTitle => 'Удалить выбранные фото?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycDiscardBody =>
|
||||||
|
'Черновик хранится только в памяти. При выходе выбранные фото будут удалены. Позже можно начать заново.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycStay => 'Продолжить';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycDiscard => 'Удалить и выйти';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycErrorUnavailable =>
|
||||||
|
'Сервис недоступен. Выбранные фото сохранены в памяти. Повторите попытку.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycErrorInternal =>
|
||||||
|
'Произошла ошибка. Повторите попытку; выбранные фото сохранены.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycErrorPermission =>
|
||||||
|
'Не удалось открыть фото. Проверьте доступ к файлам или выберите другое изображение.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycErrorFormat =>
|
||||||
|
'Выберите изображение JPEG или PNG. Другие форматы не поддерживаются.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycErrorLarge =>
|
||||||
|
'Изображение слишком большое. Выберите файл до 10 МБ и 40 мегапикселей.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycErrorUnreadable =>
|
||||||
|
'Изображение повреждено или не читается. Выберите другое фото.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycErrorSmall =>
|
||||||
|
'Фото слишком маленькое. Ширина и высота должны быть не менее 600 пикселей.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycErrorIncomplete =>
|
||||||
|
'Выберите оба фото и подтвердите, что на них вы и ваш паспорт.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycErrorSession =>
|
||||||
|
'Сессия истекла. Войдите снова и проверьте статус верификации.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycErrorRate =>
|
||||||
|
'Слишком много запросов. Немного подождите и повторите попытку.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycErrorConflict =>
|
||||||
|
'Статус проверки изменился. Обновите его, прежде чем продолжить.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycDemoControls => 'Демо: имитация решения сотрудника';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycDemoApprove => 'Одобрить';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycDemoCorrect => 'Запросить чёткое фото паспорта';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycDemoSelfie => 'Запросить чёткое селфи';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycDemoExpired => 'Паспорт просрочен';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycDemoDeny => 'Отклонить';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycPreview => 'Посмотреть верификацию и лимиты';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycChooseFirst => 'Выберите фото для продолжения.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kycStatusStale =>
|
||||||
|
'Не удалось обновить статус. Показан последний известный результат.';
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
String statusTitle(AppLocalizations l, IdentityStatus s) => switch (s) {
|
||||||
|
IdentityStatus.unverified => l.kycUnverified,
|
||||||
|
IdentityStatus.pending => l.kycPending,
|
||||||
|
IdentityStatus.approved => l.kycApproved,
|
||||||
|
IdentityStatus.changesRequested => l.kycChanges,
|
||||||
|
IdentityStatus.denied => l.kycDenied,
|
||||||
|
};
|
||||||
|
String statusBody(AppLocalizations l, IdentityStatus s) => switch (s) {
|
||||||
|
IdentityStatus.unverified => l.kycUnverifiedBody,
|
||||||
|
IdentityStatus.pending => l.kycPendingBody,
|
||||||
|
IdentityStatus.approved => l.kycApprovedBody,
|
||||||
|
IdentityStatus.changesRequested => l.kycChangesBody,
|
||||||
|
IdentityStatus.denied => l.kycDeniedBody,
|
||||||
|
};
|
||||||
|
String reasonText(AppLocalizations l, ReviewReason r) => switch (r) {
|
||||||
|
ReviewReason.passportUnreadable => l.kycPassportReason,
|
||||||
|
ReviewReason.selfieUnreadable => l.kycSelfieReason,
|
||||||
|
ReviewReason.passportExpired => l.kycExpiredReason,
|
||||||
|
ReviewReason.unableToVerify => l.kycDeniedReason,
|
||||||
|
};
|
||||||
|
String errorText(AppLocalizations l, KycError e) => switch (e) {
|
||||||
|
KycError.unavailable => l.kycErrorUnavailable,
|
||||||
|
KycError.internal => l.kycErrorInternal,
|
||||||
|
KycError.permission => l.kycErrorPermission,
|
||||||
|
KycError.format => l.kycErrorFormat,
|
||||||
|
KycError.tooLarge => l.kycErrorLarge,
|
||||||
|
KycError.unreadable => l.kycErrorUnreadable,
|
||||||
|
KycError.tooSmall => l.kycErrorSmall,
|
||||||
|
KycError.incomplete => l.kycErrorIncomplete,
|
||||||
|
KycError.sessionExpired => l.kycErrorSession,
|
||||||
|
KycError.rateLimited => l.kycErrorRate,
|
||||||
|
KycError.conflict => l.kycErrorConflict,
|
||||||
|
};
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import 'photo.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
class IdentityForm extends StatelessWidget {
|
||||||
|
const IdentityForm({super.key, required this.provider});
|
||||||
|
final IdentityProvider provider;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final p = provider;
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final titles = [l.kycPassport, l.kycSelfie, l.kycReview];
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Semantics(
|
||||||
|
header: true,
|
||||||
|
liveRegion: true,
|
||||||
|
child: Text(
|
||||||
|
'${p.step + 1} / 3 · ${titles[p.step]}',
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: (p.step + 1) / 3,
|
||||||
|
semanticsLabel: titles[p.step],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (p.step < 2)
|
||||||
|
PhotoPanel(
|
||||||
|
kind: p.step == 0 ? PhotoKind.passport : PhotoKind.selfie,
|
||||||
|
photo: p.step == 0 ? p.passport : p.selfie,
|
||||||
|
onPick: p.busy
|
||||||
|
? null
|
||||||
|
: () => p.pick(
|
||||||
|
p.step == 0 ? PhotoKind.passport : PhotoKind.selfie,
|
||||||
|
),
|
||||||
|
onRemove: p.busy
|
||||||
|
? null
|
||||||
|
: () => p.remove(
|
||||||
|
p.step == 0 ? PhotoKind.passport : PhotoKind.selfie,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (p.step == 2) ...[
|
||||||
|
Text(l.kycReviewHelp),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
PhotoPanel(kind: PhotoKind.passport, photo: p.passport, review: true),
|
||||||
|
PhotoPanel(kind: PhotoKind.selfie, photo: p.selfie, review: true),
|
||||||
|
CheckboxListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
value: p.confirmed,
|
||||||
|
onChanged: p.busy ? null : (v) => p.confirm(v ?? false),
|
||||||
|
title: Text(l.kycConfirm),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (p.step < 2) ...[
|
||||||
|
if ((p.step == 0 ? p.passport : p.selfie) == null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Text(l.kycChooseFirst),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
key: const ValueKey('kyc-next'),
|
||||||
|
onPressed: p.busy || (p.step == 0 ? p.passport : p.selfie) == null
|
||||||
|
? null
|
||||||
|
: p.next,
|
||||||
|
child: Text(l.kycNext),
|
||||||
|
),
|
||||||
|
] else
|
||||||
|
FilledButton(
|
||||||
|
key: const ValueKey('kyc-submit'),
|
||||||
|
onPressed: p.canSubmit ? p.submit : null,
|
||||||
|
child: Text(p.isDemo ? l.kycSubmitDemo : l.kycSubmit),
|
||||||
|
),
|
||||||
|
if (p.step > 0)
|
||||||
|
TextButton(onPressed: p.busy ? null : p.back, child: Text(l.kycBack)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
class LimitsPanel extends StatelessWidget {
|
||||||
|
const LimitsPanel({super.key});
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final p = context.watch<LimitsProvider>();
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final t = Theme.of(context);
|
||||||
|
final data = p.data;
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(l.kycLimits, style: t.textTheme.headlineSmall),
|
||||||
|
if (p.loading) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const LinearProgressIndicator(),
|
||||||
|
],
|
||||||
|
if (p.failed) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Semantics(
|
||||||
|
liveRegion: true,
|
||||||
|
child: Text(
|
||||||
|
l.kycLimitsError,
|
||||||
|
style: TextStyle(color: t.colorScheme.error),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: p.loading ? null : p.refresh,
|
||||||
|
child: Text(l.kycRefresh),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (data != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
data.tier == AccountTier.basic ? l.kycBasic : l.kycVerifiedTier,
|
||||||
|
style: t.textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
if (data.simulated)
|
||||||
|
Text(l.kycLimitsDemo, style: t.textTheme.bodySmall),
|
||||||
|
if (data.rows.isEmpty) Text(l.kycLimitsEmpty),
|
||||||
|
for (final row in data.rows) ...[
|
||||||
|
const Divider(height: 32),
|
||||||
|
Text(
|
||||||
|
row.period == LimitPeriod.day ? l.kycDay : l.kycMonth,
|
||||||
|
style: t.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 28,
|
||||||
|
runSpacing: 16,
|
||||||
|
children: [
|
||||||
|
_amount(
|
||||||
|
context,
|
||||||
|
l.kycCurrent,
|
||||||
|
row.currentMinor,
|
||||||
|
row.currency,
|
||||||
|
),
|
||||||
|
_amount(
|
||||||
|
context,
|
||||||
|
l.kycRemaining,
|
||||||
|
row.remainingMinor,
|
||||||
|
row.currency,
|
||||||
|
),
|
||||||
|
if (data.tier == AccountTier.basic)
|
||||||
|
_amount(
|
||||||
|
context,
|
||||||
|
l.kycAfter,
|
||||||
|
row.verifiedMinor,
|
||||||
|
row.currency,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _amount(
|
||||||
|
BuildContext context,
|
||||||
|
String label,
|
||||||
|
int minor,
|
||||||
|
String currency,
|
||||||
|
) => Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label),
|
||||||
|
Text(
|
||||||
|
NumberFormat.simpleCurrency(
|
||||||
|
locale: Localizations.localeOf(context).toLanguageTag(),
|
||||||
|
name: currency,
|
||||||
|
).format(minor / 100),
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import '../auth/brand_panel.dart';
|
||||||
|
import '../routing/routes.dart';
|
||||||
|
import '../widgets/locale.dart';
|
||||||
|
import 'copy.dart';
|
||||||
|
import 'form.dart';
|
||||||
|
import 'limits.dart';
|
||||||
|
import 'status.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
class IdentityPage extends StatefulWidget {
|
||||||
|
const IdentityPage({super.key});
|
||||||
|
@override
|
||||||
|
State<IdentityPage> createState() => IdentityPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class IdentityPageState extends State<IdentityPage> {
|
||||||
|
late final _limits = LimitsProvider(context.read<LimitsService>());
|
||||||
|
late final _identity = IdentityProvider(
|
||||||
|
context.read<IdentityService>(),
|
||||||
|
context.read<PhotoService>(),
|
||||||
|
JourneyRecorder(context.read<AnalyticsService>()),
|
||||||
|
onStatusChanged: _limits.refresh,
|
||||||
|
);
|
||||||
|
final _contentKey = GlobalKey();
|
||||||
|
final _feedbackKey = GlobalKey();
|
||||||
|
final _focus = FocusNode(skipTraversal: true);
|
||||||
|
String? _lastView;
|
||||||
|
void _transition() {
|
||||||
|
final p = _identity;
|
||||||
|
if (p.busy) return;
|
||||||
|
final view = '${p.editing}:${p.step}:${p.record?.status}:${p.error}';
|
||||||
|
if (_lastView == null) {
|
||||||
|
_lastView = view;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (view == _lastView) return;
|
||||||
|
_lastView = view;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final target = p.error != null
|
||||||
|
? _feedbackKey.currentContext
|
||||||
|
: _contentKey.currentContext;
|
||||||
|
if (target != null) {
|
||||||
|
Scrollable.ensureVisible(
|
||||||
|
target,
|
||||||
|
alignment: 0.05,
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (p.error == null) _focus.requestFocus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
_identity.addListener(_transition);
|
||||||
|
_identity.refresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> allowExit() async {
|
||||||
|
if (_identity.busy) return false;
|
||||||
|
if (!_identity.hasDraft) return true;
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
return await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: Text(l.kycDiscardTitle),
|
||||||
|
content: Text(l.kycDiscardBody),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: Text(l.kycStay),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: Text(l.kycDiscard),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
) ??
|
||||||
|
false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _leave({bool account = false}) async {
|
||||||
|
if (!await allowExit() || !mounted) return;
|
||||||
|
_identity.exit();
|
||||||
|
if (account) context.goNamed(AppRoutes.login);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_identity.removeListener(_transition);
|
||||||
|
_identity.dispose();
|
||||||
|
_focus.dispose();
|
||||||
|
_limits.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => ChangeNotifierProvider.value(
|
||||||
|
value: _limits,
|
||||||
|
child: ListenableBuilder(
|
||||||
|
listenable: _identity,
|
||||||
|
builder: (context, _) {
|
||||||
|
final p = _identity;
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final t = Theme.of(context);
|
||||||
|
return PopScope(
|
||||||
|
canPop: !p.hasDraft && !p.busy,
|
||||||
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
|
if (!didPop) _leave(account: true);
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
|
body: SafeArea(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 720),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Wrap(
|
||||||
|
alignment: WrapAlignment.spaceBetween,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
spacing: 16,
|
||||||
|
children: [BrandWordmark(), LocaleSelector()],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 36),
|
||||||
|
Semantics(
|
||||||
|
header: true,
|
||||||
|
child: Text(
|
||||||
|
l.kycTitle,
|
||||||
|
style: t.textTheme.displaySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(l.kycIntro, style: t.textTheme.bodyLarge),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
if (p.isDemo)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: t.colorScheme.tertiaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
l.kycDemo,
|
||||||
|
style: TextStyle(
|
||||||
|
color: t.colorScheme.onTertiaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
if (p.busy) ...[
|
||||||
|
const LinearProgressIndicator(),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Semantics(
|
||||||
|
liveRegion: true,
|
||||||
|
child: Text(
|
||||||
|
p.record == null ? l.kycLoading : l.kycWorking,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
if (p.error != null) ...[
|
||||||
|
Semantics(
|
||||||
|
key: _feedbackKey,
|
||||||
|
liveRegion: true,
|
||||||
|
child: Text(
|
||||||
|
errorText(l, p.error!),
|
||||||
|
key: const ValueKey('kyc-error'),
|
||||||
|
style: TextStyle(color: t.colorScheme.error),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
if (p.stale && p.record != null)
|
||||||
|
Text(
|
||||||
|
l.kycStatusStale,
|
||||||
|
style: TextStyle(color: t.colorScheme.error),
|
||||||
|
),
|
||||||
|
Focus(
|
||||||
|
includeSemantics: false,
|
||||||
|
key: _contentKey,
|
||||||
|
focusNode: _focus,
|
||||||
|
child: p.editing
|
||||||
|
? IdentityForm(provider: p)
|
||||||
|
: p.record != null
|
||||||
|
? StatusPanel(provider: p)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
if (!p.editing || p.stale)
|
||||||
|
TextButton(
|
||||||
|
onPressed: p.busy ? null : p.refresh,
|
||||||
|
child: Text(l.kycRefresh),
|
||||||
|
),
|
||||||
|
if (!p.editing) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const LimitsPanel(),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (p.editing)
|
||||||
|
TextButton(
|
||||||
|
onPressed: p.busy ? null : _leave,
|
||||||
|
child: Text(l.kycSkip),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: p.busy
|
||||||
|
? null
|
||||||
|
: () => _leave(account: true),
|
||||||
|
child: Text(l.kycClose),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import 'preview.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
class PhotoPanel extends StatelessWidget {
|
||||||
|
const PhotoPanel({
|
||||||
|
super.key,
|
||||||
|
required this.kind,
|
||||||
|
required this.photo,
|
||||||
|
this.onPick,
|
||||||
|
this.onRemove,
|
||||||
|
this.review = false,
|
||||||
|
});
|
||||||
|
final PhotoKind kind;
|
||||||
|
final Photo? photo;
|
||||||
|
final VoidCallback? onPick;
|
||||||
|
final VoidCallback? onRemove;
|
||||||
|
final bool review;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final t = Theme.of(context);
|
||||||
|
final passport = kind == PhotoKind.passport;
|
||||||
|
final title = passport ? l.kycPassport : l.kycSelfie;
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(title, style: t.textTheme.titleLarge),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (photo != null)
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: PhotoPreview(photo: photo!, label: title),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Container(
|
||||||
|
height: 140,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: t.colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
passport ? Icons.badge_outlined : Icons.portrait_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: t.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!review) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(passport ? l.kycPassportHelp : l.kycSelfieHelp),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(l.kycFileRules, style: t.textTheme.bodySmall),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
key: ValueKey('pick-${kind.name}'),
|
||||||
|
onPressed: onPick,
|
||||||
|
icon: const Icon(Icons.add_photo_alternate_outlined),
|
||||||
|
label: Text(
|
||||||
|
photo == null ? l.kycChoose : l.kycReplace,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (photo != null)
|
||||||
|
TextButton(onPressed: onRemove, child: Text(l.kycRemove)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
/// Evict this sensitive preview's decoded cache entry on replacement or exit.
|
||||||
|
class PhotoPreview extends StatefulWidget {
|
||||||
|
const PhotoPreview({super.key, required this.photo, required this.label});
|
||||||
|
final Photo photo;
|
||||||
|
final String label;
|
||||||
|
@override
|
||||||
|
State<PhotoPreview> createState() => _PhotoPreviewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PhotoPreviewState extends State<PhotoPreview> {
|
||||||
|
late ImageProvider _image = _provider();
|
||||||
|
ImageProvider _provider() =>
|
||||||
|
ResizeImage.resizeIfNeeded(1000, null, MemoryImage(widget.photo.bytes));
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant PhotoPreview oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (!identical(oldWidget.photo, widget.photo)) {
|
||||||
|
_image.evict();
|
||||||
|
_image = _provider();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_image.evict();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Image(
|
||||||
|
image: _image,
|
||||||
|
height: 220,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
semanticLabel: widget.label,
|
||||||
|
errorBuilder: (_, _, _) =>
|
||||||
|
Text(AppLocalizations.of(context)!.kycErrorUnreadable),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import 'copy.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
class StatusPanel extends StatelessWidget {
|
||||||
|
const StatusPanel({super.key, required this.provider});
|
||||||
|
final IdentityProvider provider;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final t = Theme.of(context);
|
||||||
|
final p = provider;
|
||||||
|
final record = p.record!;
|
||||||
|
final status = record.status;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: t.colorScheme.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
switch (status) {
|
||||||
|
IdentityStatus.approved => Icons.verified_outlined,
|
||||||
|
IdentityStatus.pending => Icons.hourglass_top,
|
||||||
|
IdentityStatus.changesRequested ||
|
||||||
|
IdentityStatus.denied => Icons.info_outline,
|
||||||
|
_ => Icons.shield_outlined,
|
||||||
|
},
|
||||||
|
color: t.colorScheme.onPrimaryContainer,
|
||||||
|
size: 36,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Semantics(
|
||||||
|
header: true,
|
||||||
|
liveRegion: true,
|
||||||
|
child: Text(
|
||||||
|
statusTitle(l, status),
|
||||||
|
key: ValueKey('kyc-${status.name}'),
|
||||||
|
style: t.textTheme.headlineMedium?.copyWith(
|
||||||
|
color: t.colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
statusBody(l, status),
|
||||||
|
style: TextStyle(color: t.colorScheme.onPrimaryContainer),
|
||||||
|
),
|
||||||
|
if (record.reason != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
reasonText(l, record.reason!),
|
||||||
|
style: TextStyle(
|
||||||
|
color: t.colorScheme.onPrimaryContainer,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
if (record.canSubmit)
|
||||||
|
FilledButton(
|
||||||
|
key: const ValueKey('kyc-start'),
|
||||||
|
onPressed: p.busy || p.stale ? null : p.start,
|
||||||
|
child: Text(
|
||||||
|
status == IdentityStatus.changesRequested
|
||||||
|
? l.kycResubmit
|
||||||
|
: l.kycStart,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (status == IdentityStatus.denied)
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () => showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: Text(l.kycSupport),
|
||||||
|
content: Text(l.kycSupportBody),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(l.kycBack),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(l.kycSupport),
|
||||||
|
),
|
||||||
|
if (status == IdentityStatus.pending &&
|
||||||
|
p.service is DummyIdentityService)
|
||||||
|
ExpansionTile(
|
||||||
|
title: Text(l.kycDemoControls),
|
||||||
|
children: [
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: p.busy
|
||||||
|
? null
|
||||||
|
: () => p.demoReview(IdentityStatus.approved),
|
||||||
|
child: Text(l.kycDemoApprove),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: p.busy
|
||||||
|
? null
|
||||||
|
: () => p.demoReview(
|
||||||
|
IdentityStatus.changesRequested,
|
||||||
|
reason: ReviewReason.passportUnreadable,
|
||||||
|
),
|
||||||
|
child: Text(l.kycDemoCorrect),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: p.busy
|
||||||
|
? null
|
||||||
|
: () => p.demoReview(
|
||||||
|
IdentityStatus.changesRequested,
|
||||||
|
reason: ReviewReason.selfieUnreadable,
|
||||||
|
),
|
||||||
|
child: Text(l.kycDemoSelfie),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: p.busy
|
||||||
|
? null
|
||||||
|
: () => p.demoReview(
|
||||||
|
IdentityStatus.changesRequested,
|
||||||
|
reason: ReviewReason.passportExpired,
|
||||||
|
),
|
||||||
|
child: Text(l.kycDemoExpired),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: p.busy
|
||||||
|
? null
|
||||||
|
: () => p.demoReview(
|
||||||
|
IdentityStatus.denied,
|
||||||
|
reason: ReviewReason.unableToVerify,
|
||||||
|
),
|
||||||
|
child: Text(l.kycDemoDeny),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,5 +72,81 @@
|
|||||||
"demoVerifiedBody": "You’ve previewed email confirmation. No account was verified. Return to login to explore the demo.",
|
"demoVerifiedBody": "You’ve previewed email confirmation. No account was verified. Return to login to explore the demo.",
|
||||||
"verificationRecipient": "Recipient",
|
"verificationRecipient": "Recipient",
|
||||||
"verificationChangeEmail": "Change email",
|
"verificationChangeEmail": "Change email",
|
||||||
"demoResend": "Prepare demo email again"
|
"demoResend": "Prepare demo email again",
|
||||||
|
"kycTitle": "Verification & limits",
|
||||||
|
"kycIntro": "Your account, at your pace. Verify your identity when you need higher limits.",
|
||||||
|
"kycDemo": "DEMO · Use sample images only. Photos stay in memory, are never uploaded, and disappear when you leave or reload. Review decisions are simulated.",
|
||||||
|
"kycUnverified": "Not verified",
|
||||||
|
"kycPending": "In review",
|
||||||
|
"kycApproved": "Identity verified",
|
||||||
|
"kycChanges": "New photos needed",
|
||||||
|
"kycDenied": "Verification declined",
|
||||||
|
"kycUnverifiedBody": "You can keep using your account with basic limits. Submit a passport photo and a selfie holding it to request an upgrade.",
|
||||||
|
"kycPendingBody": "Your submission is awaiting a human review. Basic limits remain active until approval. You do not need to submit again.",
|
||||||
|
"kycApprovedBody": "Your review is complete. Check your current limits below.",
|
||||||
|
"kycChangesBody": "Your account remains available with basic limits. Replace the photos and submit a new review request.",
|
||||||
|
"kycDeniedBody": "Basic limits remain available. If you believe this decision is incorrect, contact support for a review.",
|
||||||
|
"kycPassportReason": "The passport details could not be read. Retake the photo in good light, with every edge visible.",
|
||||||
|
"kycSelfieReason": "Your face or passport was not clear in the selfie. Keep both visible and in focus.",
|
||||||
|
"kycExpiredReason": "The passport has expired. Use a valid passport and take both photos again.",
|
||||||
|
"kycDeniedReason": "We could not verify your identity from this submission.",
|
||||||
|
"kycStart": "Verify identity",
|
||||||
|
"kycResubmit": "Replace photos",
|
||||||
|
"kycRefresh": "Refresh status & limits",
|
||||||
|
"kycSupport": "Contact support",
|
||||||
|
"kycSupportBody": "Support is not connected in this preview. In the live app, you will be able to request a review here without submitting documents again.",
|
||||||
|
"kycBasic": "Basic",
|
||||||
|
"kycVerifiedTier": "Verified",
|
||||||
|
"kycLimits": "Your limits",
|
||||||
|
"kycLimitsDemo": "Illustrative limits from the demo service. These are not a live offer.",
|
||||||
|
"kycDay": "Daily volume",
|
||||||
|
"kycMonth": "Monthly volume",
|
||||||
|
"kycCurrent": "Current cap",
|
||||||
|
"kycRemaining": "Available",
|
||||||
|
"kycAfter": "After approval",
|
||||||
|
"kycLimitsError": "Limits could not be refreshed. Any values below are out of date. Retry to see your current access.",
|
||||||
|
"kycLimitsEmpty": "No limits were returned. Refresh or check with support before making a transaction.",
|
||||||
|
"kycLoading": "Loading your status…",
|
||||||
|
"kycPassport": "Passport photo",
|
||||||
|
"kycSelfie": "Selfie with passport",
|
||||||
|
"kycReview": "Review your photos",
|
||||||
|
"kycPassportHelp": "Open your valid passport to the photo page. Show all four edges and keep text sharp. Avoid glare, blur and covered details.",
|
||||||
|
"kycSelfieHelp": "Hold the open passport beside your face. Show your whole face and the photo page in the same image. Keep both sharp and uncovered.",
|
||||||
|
"kycFileRules": "JPEG or PNG · up to 10 MB · at least 600 × 600 pixels. Take a photo with your camera, then choose it here.",
|
||||||
|
"kycChoose": "Choose photo",
|
||||||
|
"kycReplace": "Choose a different photo",
|
||||||
|
"kycRemove": "Remove photo",
|
||||||
|
"kycNext": "Continue",
|
||||||
|
"kycBack": "Back",
|
||||||
|
"kycSkip": "Keep basic limits",
|
||||||
|
"kycClose": "Back to account preview",
|
||||||
|
"kycReviewHelp": "Check that both photos are readable and show the same passport. A staff member will review them; submission does not immediately raise your limits.",
|
||||||
|
"kycConfirm": "These photos show me and my own valid passport.",
|
||||||
|
"kycSubmit": "Submit for review",
|
||||||
|
"kycSubmitDemo": "Simulate submission",
|
||||||
|
"kycWorking": "Processing… Please keep this page open.",
|
||||||
|
"kycDiscardTitle": "Discard these photos?",
|
||||||
|
"kycDiscardBody": "Your draft is only in memory. Leaving will remove the selected photos; you can start again later.",
|
||||||
|
"kycStay": "Keep editing",
|
||||||
|
"kycDiscard": "Discard & leave",
|
||||||
|
"kycErrorUnavailable": "The service is unavailable. Your selected photos are still here. Try again.",
|
||||||
|
"kycErrorInternal": "Something went wrong. Try again; your selected photos have been kept.",
|
||||||
|
"kycErrorPermission": "We could not open that photo. Check file access or choose another image.",
|
||||||
|
"kycErrorFormat": "Choose a JPEG or PNG image. Other formats are not supported.",
|
||||||
|
"kycErrorLarge": "This image is too large. Choose one under 10 MB and 40 megapixels.",
|
||||||
|
"kycErrorUnreadable": "This image is damaged or cannot be read. Choose another photo.",
|
||||||
|
"kycErrorSmall": "The photo is too small. Use an image at least 600 pixels wide and high.",
|
||||||
|
"kycErrorIncomplete": "Choose both photos and confirm they belong to you before submitting.",
|
||||||
|
"kycErrorSession": "Your session has expired. Return to login, then check your verification status.",
|
||||||
|
"kycErrorRate": "Too many requests. Wait a moment before trying again.",
|
||||||
|
"kycErrorConflict": "Your review status has changed. Refresh it before continuing.",
|
||||||
|
"kycDemoControls": "Demo: simulate a backoffice decision",
|
||||||
|
"kycDemoApprove": "Approve",
|
||||||
|
"kycDemoCorrect": "Request clearer passport",
|
||||||
|
"kycDemoSelfie": "Request clearer selfie",
|
||||||
|
"kycDemoExpired": "Passport expired",
|
||||||
|
"kycDemoDeny": "Decline",
|
||||||
|
"kycPreview": "Explore verification & limits",
|
||||||
|
"kycChooseFirst": "Choose a photo to continue.",
|
||||||
|
"kycStatusStale": "Status could not be refreshed. The status shown is the last known result."
|
||||||
}
|
}
|
||||||
@@ -72,5 +72,81 @@
|
|||||||
"demoVerifiedBody": "Вы посмотрели подтверждение почты. Реальный аккаунт не подтверждён. Вернитесь ко входу, чтобы продолжить знакомство с демоверсией.",
|
"demoVerifiedBody": "Вы посмотрели подтверждение почты. Реальный аккаунт не подтверждён. Вернитесь ко входу, чтобы продолжить знакомство с демоверсией.",
|
||||||
"verificationRecipient": "Получатель",
|
"verificationRecipient": "Получатель",
|
||||||
"verificationChangeEmail": "Изменить почту",
|
"verificationChangeEmail": "Изменить почту",
|
||||||
"demoResend": "Подготовить демописьмо ещё раз"
|
"demoResend": "Подготовить демописьмо ещё раз",
|
||||||
|
"kycTitle": "Верификация и лимиты",
|
||||||
|
"kycIntro": "Ваш аккаунт — ваш темп. Подтвердите личность, когда понадобятся более высокие лимиты.",
|
||||||
|
"kycDemo": "ДЕМО · Используйте только тестовые изображения. Фото хранятся в памяти, не отправляются и удаляются при выходе или перезагрузке. Решения проверки имитируются.",
|
||||||
|
"kycUnverified": "Личность не подтверждена",
|
||||||
|
"kycPending": "На проверке",
|
||||||
|
"kycApproved": "Личность подтверждена",
|
||||||
|
"kycChanges": "Нужны новые фото",
|
||||||
|
"kycDenied": "Верификация отклонена",
|
||||||
|
"kycUnverifiedBody": "Вы можете пользоваться аккаунтом с базовыми лимитами. Для повышения отправьте фото паспорта и селфи с ним.",
|
||||||
|
"kycPendingBody": "Заявка ожидает проверки сотрудником. До одобрения действуют базовые лимиты. Повторная отправка не нужна.",
|
||||||
|
"kycApprovedBody": "Проверка завершена. Актуальные лимиты указаны ниже.",
|
||||||
|
"kycChangesBody": "Аккаунт доступен с базовыми лимитами. Замените фото и отправьте новую заявку.",
|
||||||
|
"kycDeniedBody": "Базовые лимиты остаются доступными. Если вы не согласны с решением, обратитесь в поддержку для пересмотра.",
|
||||||
|
"kycPassportReason": "Данные паспорта не удалось прочитать. Переснимите при хорошем освещении, чтобы все края были видны.",
|
||||||
|
"kycSelfieReason": "На селфи плохо видно лицо или паспорт. Оба должны быть в кадре и в фокусе.",
|
||||||
|
"kycExpiredReason": "Срок действия паспорта истёк. Используйте действующий паспорт и сделайте оба фото заново.",
|
||||||
|
"kycDeniedReason": "По этой заявке не удалось подтвердить вашу личность.",
|
||||||
|
"kycStart": "Подтвердить личность",
|
||||||
|
"kycResubmit": "Заменить фото",
|
||||||
|
"kycRefresh": "Обновить статус и лимиты",
|
||||||
|
"kycSupport": "Обратиться в поддержку",
|
||||||
|
"kycSupportBody": "В демоверсии поддержка не подключена. В рабочем приложении здесь можно будет запросить пересмотр без повторной отправки документов.",
|
||||||
|
"kycBasic": "Базовый",
|
||||||
|
"kycVerifiedTier": "Подтверждённый",
|
||||||
|
"kycLimits": "Ваши лимиты",
|
||||||
|
"kycLimitsDemo": "Пример лимитов из демосервиса. Это не действующее предложение.",
|
||||||
|
"kycDay": "Объём за день",
|
||||||
|
"kycMonth": "Объём за месяц",
|
||||||
|
"kycCurrent": "Текущий лимит",
|
||||||
|
"kycRemaining": "Доступно",
|
||||||
|
"kycAfter": "После одобрения",
|
||||||
|
"kycLimitsError": "Не удалось обновить лимиты. Значения ниже могут быть устаревшими. Повторите запрос.",
|
||||||
|
"kycLimitsEmpty": "Сервис не вернул лимиты. Обновите данные или уточните их в поддержке перед операцией.",
|
||||||
|
"kycLoading": "Загружаем ваш статус…",
|
||||||
|
"kycPassport": "Фото паспорта",
|
||||||
|
"kycSelfie": "Селфи с паспортом",
|
||||||
|
"kycReview": "Проверьте фото",
|
||||||
|
"kycPassportHelp": "Откройте действующий паспорт на странице с фото. Все четыре края и текст должны быть чётко видны. Избегайте бликов и размытия, не закрывайте данные.",
|
||||||
|
"kycSelfieHelp": "Держите открытый паспорт рядом с лицом. Лицо целиком и страница с фото должны быть в одном кадре, чёткими и ничем не закрытыми.",
|
||||||
|
"kycFileRules": "JPEG или PNG · до 10 МБ · не менее 600 × 600 пикселей. Сделайте фото камерой, затем выберите его здесь.",
|
||||||
|
"kycChoose": "Выбрать фото",
|
||||||
|
"kycReplace": "Выбрать другое фото",
|
||||||
|
"kycRemove": "Удалить фото",
|
||||||
|
"kycNext": "Продолжить",
|
||||||
|
"kycBack": "Назад",
|
||||||
|
"kycSkip": "Оставить базовые лимиты",
|
||||||
|
"kycClose": "К демоаккаунту",
|
||||||
|
"kycReviewHelp": "Убедитесь, что оба фото читаемы и на них один паспорт. Их проверит сотрудник. Отправка не повышает лимиты сразу.",
|
||||||
|
"kycConfirm": "На фото я и мой действующий паспорт.",
|
||||||
|
"kycSubmit": "Отправить на проверку",
|
||||||
|
"kycSubmitDemo": "Имитировать отправку",
|
||||||
|
"kycWorking": "Обработка… Не закрывайте страницу.",
|
||||||
|
"kycDiscardTitle": "Удалить выбранные фото?",
|
||||||
|
"kycDiscardBody": "Черновик хранится только в памяти. При выходе выбранные фото будут удалены. Позже можно начать заново.",
|
||||||
|
"kycStay": "Продолжить",
|
||||||
|
"kycDiscard": "Удалить и выйти",
|
||||||
|
"kycErrorUnavailable": "Сервис недоступен. Выбранные фото сохранены в памяти. Повторите попытку.",
|
||||||
|
"kycErrorInternal": "Произошла ошибка. Повторите попытку; выбранные фото сохранены.",
|
||||||
|
"kycErrorPermission": "Не удалось открыть фото. Проверьте доступ к файлам или выберите другое изображение.",
|
||||||
|
"kycErrorFormat": "Выберите изображение JPEG или PNG. Другие форматы не поддерживаются.",
|
||||||
|
"kycErrorLarge": "Изображение слишком большое. Выберите файл до 10 МБ и 40 мегапикселей.",
|
||||||
|
"kycErrorUnreadable": "Изображение повреждено или не читается. Выберите другое фото.",
|
||||||
|
"kycErrorSmall": "Фото слишком маленькое. Ширина и высота должны быть не менее 600 пикселей.",
|
||||||
|
"kycErrorIncomplete": "Выберите оба фото и подтвердите, что на них вы и ваш паспорт.",
|
||||||
|
"kycErrorSession": "Сессия истекла. Войдите снова и проверьте статус верификации.",
|
||||||
|
"kycErrorRate": "Слишком много запросов. Немного подождите и повторите попытку.",
|
||||||
|
"kycErrorConflict": "Статус проверки изменился. Обновите его, прежде чем продолжить.",
|
||||||
|
"kycDemoControls": "Демо: имитация решения сотрудника",
|
||||||
|
"kycDemoApprove": "Одобрить",
|
||||||
|
"kycDemoCorrect": "Запросить чёткое фото паспорта",
|
||||||
|
"kycDemoSelfie": "Запросить чёткое селфи",
|
||||||
|
"kycDemoExpired": "Паспорт просрочен",
|
||||||
|
"kycDemoDeny": "Отклонить",
|
||||||
|
"kycPreview": "Посмотреть верификацию и лимиты",
|
||||||
|
"kycChooseFirst": "Выберите фото для продолжения.",
|
||||||
|
"kycStatusStale": "Не удалось обновить статус. Показан последний известный результат."
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,8 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
|||||||
|
|
||||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
import 'package:sxchnge_theme/sxchnge_theme.dart';
|
import 'package:sxchnge_theme/sxchnge_theme.dart';
|
||||||
|
|
||||||
import 'providers/locale.dart';
|
import 'providers/locale.dart';
|
||||||
@@ -24,12 +26,18 @@ class SxchngeApp extends StatefulWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
this.localeProvider,
|
this.localeProvider,
|
||||||
this.analyticsService,
|
this.analyticsService,
|
||||||
|
this.identityService,
|
||||||
|
this.limitsService,
|
||||||
|
this.photoService = const FilePhotoService(),
|
||||||
this.loginService = const DummyLoginService(),
|
this.loginService = const DummyLoginService(),
|
||||||
this.signupService = const DummySignupService(),
|
this.signupService = const DummySignupService(),
|
||||||
this.verificationService = const DummyVerificationService(),
|
this.verificationService = const DummyVerificationService(),
|
||||||
});
|
});
|
||||||
final LocaleProvider? localeProvider;
|
final LocaleProvider? localeProvider;
|
||||||
final AnalyticsService? analyticsService;
|
final AnalyticsService? analyticsService;
|
||||||
|
final IdentityService? identityService;
|
||||||
|
final LimitsService? limitsService;
|
||||||
|
final PhotoService photoService;
|
||||||
final LoginService loginService;
|
final LoginService loginService;
|
||||||
final SignupService signupService;
|
final SignupService signupService;
|
||||||
final VerificationService verificationService;
|
final VerificationService verificationService;
|
||||||
@@ -41,9 +49,10 @@ class SxchngeApp extends StatefulWidget {
|
|||||||
class _SxchngeAppState extends State<SxchngeApp> {
|
class _SxchngeAppState extends State<SxchngeApp> {
|
||||||
late final _locale = widget.localeProvider ?? LocaleProvider();
|
late final _locale = widget.localeProvider ?? LocaleProvider();
|
||||||
|
|
||||||
late final _journeys = JourneyRecorder(
|
late final _analytics = widget.analyticsService ?? DummyAnalyticsService();
|
||||||
widget.analyticsService ?? DummyAnalyticsService(),
|
late final _identity = widget.identityService ?? DummyIdentityService();
|
||||||
);
|
late final _limits = widget.limitsService ?? DummyLimitsService(_identity);
|
||||||
|
late final _journeys = JourneyRecorder(_analytics);
|
||||||
late final _services = InstrumentedAuthServices(
|
late final _services = InstrumentedAuthServices(
|
||||||
recorder: _journeys,
|
recorder: _journeys,
|
||||||
loginService: widget.loginService,
|
loginService: widget.loginService,
|
||||||
@@ -81,6 +90,10 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
|||||||
Widget build(BuildContext context) => MultiProvider(
|
Widget build(BuildContext context) => MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
Provider<JourneyRecorder>.value(value: _journeys),
|
Provider<JourneyRecorder>.value(value: _journeys),
|
||||||
|
Provider<AnalyticsService>.value(value: _analytics),
|
||||||
|
Provider<IdentityService>.value(value: _identity),
|
||||||
|
Provider<LimitsService>.value(value: _limits),
|
||||||
|
Provider<PhotoService>.value(value: widget.photoService),
|
||||||
ChangeNotifierProvider.value(value: _locale),
|
ChangeNotifierProvider.value(value: _locale),
|
||||||
Provider<VerificationService>.value(value: _services),
|
Provider<VerificationService>.value(value: _services),
|
||||||
ChangeNotifierProvider(create: (_) => LoginProvider(_services)),
|
ChangeNotifierProvider(create: (_) => LoginProvider(_services)),
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../auth/page.dart';
|
import '../auth/page.dart';
|
||||||
|
import '../kyc/page.dart';
|
||||||
import '../verification/page.dart';
|
import '../verification/page.dart';
|
||||||
import 'routes.dart';
|
import 'routes.dart';
|
||||||
|
|
||||||
GoRouter createAppRouter() => GoRouter(
|
GoRouter createAppRouter() {
|
||||||
|
final identityKey = GlobalKey<IdentityPageState>();
|
||||||
|
return GoRouter(
|
||||||
// Signup email is an in-memory navigation extra, never browser-history data.
|
// Signup email is an in-memory navigation extra, never browser-history data.
|
||||||
extraCodec: const _TransientExtras(),
|
extraCodec: const _TransientExtras(),
|
||||||
routes: [
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/identity',
|
||||||
|
name: AppRoutes.identity,
|
||||||
|
builder: (context, state) => IdentityPage(key: identityKey),
|
||||||
|
onExit: (context, state) async =>
|
||||||
|
await identityKey.currentState?.allowExit() ?? true,
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/',
|
path: '/',
|
||||||
name: AppRoutes.login,
|
name: AppRoutes.login,
|
||||||
@@ -31,7 +43,8 @@ GoRouter createAppRouter() => GoRouter(
|
|||||||
],
|
],
|
||||||
// Preserve the existing fallback, and keep the visible URL canonical.
|
// Preserve the existing fallback, and keep the visible URL canonical.
|
||||||
onException: (context, state, router) => router.goNamed(AppRoutes.login),
|
onException: (context, state, router) => router.goNamed(AppRoutes.login),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
class _TransientExtras extends Codec<Object?, Object?> {
|
class _TransientExtras extends Codec<Object?, Object?> {
|
||||||
const _TransientExtras();
|
const _TransientExtras();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
abstract final class AppRoutes {
|
abstract final class AppRoutes {
|
||||||
|
static const identity = 'identity';
|
||||||
static const login = 'login';
|
static const login = 'login';
|
||||||
static const verificationSent = 'verification-sent';
|
static const verificationSent = 'verification-sent';
|
||||||
static const verifyEmail = 'verify-email';
|
static const verifyEmail = 'verify-email';
|
||||||
|
|||||||
@@ -325,6 +325,13 @@ class _VerificationPageState extends State<VerificationPage> {
|
|||||||
child: Text(l.verificationRequest),
|
child: Text(l.verificationRequest),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
if (verified) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => context.goNamed(AppRoutes.identity),
|
||||||
|
child: Text(l.kycPreview),
|
||||||
|
),
|
||||||
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => context.goNamed(AppRoutes.login),
|
onPressed: () => context.goNamed(AppRoutes.login),
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
packages:
|
||||||
|
archive:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: archive
|
||||||
|
sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.2.0"
|
||||||
async:
|
async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -41,6 +49,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
|
cross_file:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cross_file
|
||||||
|
sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.5+5"
|
||||||
cupertino_icons:
|
cupertino_icons:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -81,6 +97,70 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "7.0.1"
|
||||||
|
file_selector:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector
|
||||||
|
sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
|
file_selector_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_android
|
||||||
|
sha256: d670cd0ce77a2e785b18d8b4d0a8d6a222d6a813ec9b7ddf2790a1b4fb6fa92c
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.5.2+11"
|
||||||
|
file_selector_ios:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_ios
|
||||||
|
sha256: "97269e5307a0ab813b1fa2430bada0a96e0afb74848417f8676f64ba5de0051c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.5.3+6"
|
||||||
|
file_selector_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_linux
|
||||||
|
sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.4+1"
|
||||||
|
file_selector_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_macos
|
||||||
|
sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.5+1"
|
||||||
|
file_selector_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_platform_interface
|
||||||
|
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
|
file_selector_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_web
|
||||||
|
sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.5"
|
||||||
|
file_selector_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_windows
|
||||||
|
sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.3+6"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -117,6 +197,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "18.0.1"
|
version: "18.0.1"
|
||||||
|
http:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http
|
||||||
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.6.0"
|
||||||
|
http_parser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http_parser
|
||||||
|
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.2"
|
||||||
|
image:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image
|
||||||
|
sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.9.2"
|
||||||
intl:
|
intl:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -253,6 +357,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
version: "2.1.8"
|
||||||
|
posix:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: posix
|
||||||
|
sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.5.2"
|
||||||
provider:
|
provider:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -368,6 +480,13 @@ packages:
|
|||||||
relative: true
|
relative: true
|
||||||
source: path
|
source: path
|
||||||
version: "0.1.0"
|
version: "0.1.0"
|
||||||
|
sxchnge_kyc:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
path: "../packages/sxchnge_kyc"
|
||||||
|
relative: true
|
||||||
|
source: path
|
||||||
|
version: "0.1.0"
|
||||||
sxchnge_theme:
|
sxchnge_theme:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -391,6 +510,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.12"
|
version: "0.7.12"
|
||||||
|
typed_data:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: typed_data
|
||||||
|
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.0"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ environment:
|
|||||||
sdk: '>=3.13.0 <4.0.0'
|
sdk: '>=3.13.0 <4.0.0'
|
||||||
flutter: '>=3.47.0'
|
flutter: '>=3.47.0'
|
||||||
dependencies:
|
dependencies:
|
||||||
|
sxchnge_kyc:
|
||||||
|
path: ../packages/sxchnge_kyc
|
||||||
sxchnge_analytics:
|
sxchnge_analytics:
|
||||||
path: ../packages/sxchnge_analytics
|
path: ../packages/sxchnge_analytics
|
||||||
flutter:
|
flutter:
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_app/main.dart';
|
||||||
|
import 'package:sxchnge_app/providers/locale.dart';
|
||||||
|
|
||||||
|
class Photos implements PhotoService {
|
||||||
|
Photos(this.bytes);
|
||||||
|
final Uint8List bytes;
|
||||||
|
KycError? error;
|
||||||
|
@override
|
||||||
|
Future<Photo?> pick() async {
|
||||||
|
if (error != null) throw KycException(error!);
|
||||||
|
return Photo(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> tap(WidgetTester t, Finder finder) async {
|
||||||
|
await t.ensureVisible(finder);
|
||||||
|
await t.tap(finder);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late Uint8List bytes;
|
||||||
|
setUpAll(() async {
|
||||||
|
final r = ui.PictureRecorder();
|
||||||
|
final c = ui.Canvas(r);
|
||||||
|
c.drawRect(const Rect.fromLTWH(0, 0, 600, 600), Paint());
|
||||||
|
final pic = r.endRecording();
|
||||||
|
final image = await pic.toImage(600, 600);
|
||||||
|
bytes = (await image.toByteData(format: ui.ImageByteFormat.png))!.buffer
|
||||||
|
.asUint8List();
|
||||||
|
image.dispose();
|
||||||
|
pic.dispose();
|
||||||
|
});
|
||||||
|
Future<DummyIdentityService> open(
|
||||||
|
WidgetTester t, {
|
||||||
|
Photos? photos,
|
||||||
|
Locale? locale,
|
||||||
|
}) async {
|
||||||
|
t.platformDispatcher.defaultRouteNameTestValue = '/identity';
|
||||||
|
addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue);
|
||||||
|
final service = DummyIdentityService(delay: Duration.zero);
|
||||||
|
final provider = LocaleProvider();
|
||||||
|
if (locale != null) await provider.setLocale(locale);
|
||||||
|
await t.pumpWidget(
|
||||||
|
SxchngeApp(
|
||||||
|
identityService: service,
|
||||||
|
photoService: photos ?? Photos(bytes),
|
||||||
|
localeProvider: provider,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> submit(WidgetTester t) async {
|
||||||
|
await tap(t, find.byKey(const ValueKey('kyc-start')));
|
||||||
|
await tap(t, find.byKey(const ValueKey('pick-passport')));
|
||||||
|
await tap(t, find.byKey(const ValueKey('kyc-next')));
|
||||||
|
await tap(t, find.byKey(const ValueKey('pick-selfie')));
|
||||||
|
await tap(t, find.byKey(const ValueKey('kyc-next')));
|
||||||
|
await tap(t, find.byType(CheckboxListTile));
|
||||||
|
await tap(t, find.byKey(const ValueKey('kyc-submit')));
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('two photos, review, pending and approval with service limits', (
|
||||||
|
t,
|
||||||
|
) async {
|
||||||
|
await open(t);
|
||||||
|
expect(find.byKey(const ValueKey('kyc-unverified')), findsOneWidget);
|
||||||
|
await submit(t);
|
||||||
|
expect(find.byKey(const ValueKey('kyc-pending')), findsOneWidget);
|
||||||
|
await tap(t, find.byType(ExpansionTile));
|
||||||
|
await tap(t, find.text('Approve'));
|
||||||
|
expect(find.byKey(const ValueKey('kyc-approved')), findsOneWidget);
|
||||||
|
expect(find.text('Verified'), findsOneWidget);
|
||||||
|
expect(t.takeException(), null);
|
||||||
|
});
|
||||||
|
testWidgets('denial provides explanation and support, no resubmit', (
|
||||||
|
t,
|
||||||
|
) async {
|
||||||
|
await open(t);
|
||||||
|
await submit(t);
|
||||||
|
await tap(t, find.byType(ExpansionTile));
|
||||||
|
await tap(t, find.text('Decline'));
|
||||||
|
expect(find.byKey(const ValueKey('kyc-denied')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('kyc-start')), findsNothing);
|
||||||
|
await tap(t, find.text('Contact support'));
|
||||||
|
expect(find.byType(AlertDialog), findsOneWidget);
|
||||||
|
});
|
||||||
|
testWidgets('correctable rejection can be resubmitted', (t) async {
|
||||||
|
await open(t);
|
||||||
|
await submit(t);
|
||||||
|
await tap(t, find.byType(ExpansionTile));
|
||||||
|
await tap(t, find.text('Request clearer passport'));
|
||||||
|
expect(find.byKey(const ValueKey('kyc-changesRequested')), findsOneWidget);
|
||||||
|
await submit(t);
|
||||||
|
expect(find.byKey(const ValueKey('kyc-pending')), findsOneWidget);
|
||||||
|
});
|
||||||
|
testWidgets('picker error recovery and discard confirmation', (t) async {
|
||||||
|
final photos = Photos(bytes)..error = KycError.permission;
|
||||||
|
await open(t, photos: photos);
|
||||||
|
await tap(t, find.byKey(const ValueKey('kyc-start')));
|
||||||
|
await tap(t, find.byKey(const ValueKey('pick-passport')));
|
||||||
|
expect(find.byKey(const ValueKey('kyc-error')), findsOneWidget);
|
||||||
|
photos.error = null;
|
||||||
|
await tap(t, find.byKey(const ValueKey('pick-passport')));
|
||||||
|
await tap(t, find.text('Keep basic limits'));
|
||||||
|
expect(find.byType(AlertDialog), findsOneWidget);
|
||||||
|
await tap(t, find.text('Keep editing'));
|
||||||
|
expect(find.byKey(const ValueKey('pick-passport')), findsOneWidget);
|
||||||
|
await tap(t, find.text('Keep basic limits'));
|
||||||
|
await tap(t, find.text('Discard & leave'));
|
||||||
|
expect(find.byKey(const ValueKey('kyc-unverified')), findsOneWidget);
|
||||||
|
});
|
||||||
|
testWidgets(
|
||||||
|
'320px Russian at large text works through submission and keyboard',
|
||||||
|
(t) async {
|
||||||
|
t.view.physicalSize = const Size(320, 900);
|
||||||
|
t.view.devicePixelRatio = 1;
|
||||||
|
t.platformDispatcher.textScaleFactorTestValue = 1.6;
|
||||||
|
addTearDown(t.view.resetPhysicalSize);
|
||||||
|
addTearDown(t.view.resetDevicePixelRatio);
|
||||||
|
addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue);
|
||||||
|
await open(t, locale: const Locale('ru'));
|
||||||
|
expect(find.text('Верификация и лимиты'), findsOneWidget);
|
||||||
|
await t.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||||
|
await t.pump();
|
||||||
|
await submit(t);
|
||||||
|
expect(find.byKey(const ValueKey('kyc-pending')), findsOneWidget);
|
||||||
|
expect(t.takeException(), null);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
testWidgets('status failure exposes retry without asserting basic state', (
|
||||||
|
t,
|
||||||
|
) async {
|
||||||
|
t.platformDispatcher.defaultRouteNameTestValue = '/identity';
|
||||||
|
addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue);
|
||||||
|
final service = DummyIdentityService(delay: Duration.zero)
|
||||||
|
..nextError = KycError.unavailable;
|
||||||
|
await t.pumpWidget(
|
||||||
|
SxchngeApp(
|
||||||
|
identityService: service,
|
||||||
|
photoService: Photos(bytes),
|
||||||
|
analyticsService: DummyAnalyticsService(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(find.byKey(const ValueKey('kyc-unverified')), findsNothing);
|
||||||
|
await tap(t, find.text('Refresh status & limits'));
|
||||||
|
expect(find.byKey(const ValueKey('kyc-unverified')), findsOneWidget);
|
||||||
|
});
|
||||||
|
testWidgets('route guard preserves draft when navigation is cancelled', (
|
||||||
|
t,
|
||||||
|
) async {
|
||||||
|
await open(t);
|
||||||
|
await tap(t, find.byKey(const ValueKey('kyc-start')));
|
||||||
|
await tap(t, find.byKey(const ValueKey('pick-passport')));
|
||||||
|
final router = GoRouter.of(t.element(find.text('Verification & limits')));
|
||||||
|
router.go('/');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(find.byType(AlertDialog), findsOneWidget);
|
||||||
|
await tap(t, find.text('Keep editing'));
|
||||||
|
expect(find.byKey(const ValueKey('pick-passport')), findsOneWidget);
|
||||||
|
router.go('/');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
await tap(t, find.text('Discard & leave'));
|
||||||
|
expect(find.byKey(const ValueKey('kyc-start')), findsNothing);
|
||||||
|
expect(find.text('Explore verification & limits'), findsOneWidget);
|
||||||
|
});
|
||||||
|
testWidgets('overview controls have accessible labels and touch targets', (
|
||||||
|
t,
|
||||||
|
) async {
|
||||||
|
final semantics = t.ensureSemantics();
|
||||||
|
await open(t);
|
||||||
|
expect(t, meetsGuideline(labeledTapTargetGuideline));
|
||||||
|
expect(t, meetsGuideline(androidTapTargetGuideline));
|
||||||
|
final start = find.byKey(const ValueKey('kyc-start'));
|
||||||
|
await t.ensureVisible(start);
|
||||||
|
final buttonContext = t.element(start);
|
||||||
|
FocusScope.of(buttonContext).requestFocus(Focus.of(buttonContext));
|
||||||
|
await t.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||||
|
await t.pump();
|
||||||
|
expect(t.takeException(), null);
|
||||||
|
semantics.dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,11 +2,22 @@ import 'dart:async';
|
|||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
enum JourneyKind { login, signup, verification }
|
enum JourneyKind { login, signup, verification, kyc }
|
||||||
|
|
||||||
enum JourneyStep { login, signup, emailDelivery, emailConfirmation }
|
enum JourneyStep {
|
||||||
|
login,
|
||||||
|
signup,
|
||||||
|
emailDelivery,
|
||||||
|
emailConfirmation,
|
||||||
|
kycPreparation,
|
||||||
|
kycPassport,
|
||||||
|
kycSelfie,
|
||||||
|
kycSubmission,
|
||||||
|
kycStatus,
|
||||||
|
}
|
||||||
|
|
||||||
enum JourneySignal {
|
enum JourneySignal {
|
||||||
|
cancelled,
|
||||||
started,
|
started,
|
||||||
requested,
|
requested,
|
||||||
succeeded,
|
succeeded,
|
||||||
@@ -16,6 +27,7 @@ enum JourneySignal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum JourneyFailure {
|
enum JourneyFailure {
|
||||||
|
document,
|
||||||
invalidCredentials,
|
invalidCredentials,
|
||||||
emailInUse,
|
emailInUse,
|
||||||
unavailable,
|
unavailable,
|
||||||
@@ -66,8 +78,13 @@ class AnalyticsEvent {
|
|||||||
final bool terminal;
|
final bool terminal;
|
||||||
final bool simulated;
|
final bool simulated;
|
||||||
final DateTime occurredAt;
|
final DateTime occurredAt;
|
||||||
String get name =>
|
String get name {
|
||||||
'auth_${step == null ? 'journey' : _snake(step!.name)}_${signal.name}';
|
final area = kind == JourneyKind.kyc ? 'kyc' : 'auth';
|
||||||
|
var stage = step == null ? 'journey' : _snake(step!.name);
|
||||||
|
if (kind == JourneyKind.kyc) stage = stage.replaceFirst('kyc_', '');
|
||||||
|
return '${area}_${stage}_${signal.name}';
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, Object> get properties => Map.unmodifiable({
|
Map<String, Object> get properties => Map.unmodifiable({
|
||||||
'schema_version': 1,
|
'schema_version': 1,
|
||||||
'event_id': eventId,
|
'event_id': eventId,
|
||||||
@@ -165,16 +182,19 @@ class JourneyRecorder {
|
|||||||
bool simulated = false,
|
bool simulated = false,
|
||||||
JourneyFailure? failure,
|
JourneyFailure? failure,
|
||||||
bool terminal = false,
|
bool terminal = false,
|
||||||
|
bool cancelled = false,
|
||||||
}) {
|
}) {
|
||||||
final journey = operation._journey;
|
final journey = operation._journey;
|
||||||
if (_disposed || operation._finished || journey.closed) return;
|
if (_disposed || operation._finished || journey.closed) return;
|
||||||
operation._finished = true;
|
operation._finished = true;
|
||||||
journey.pending--;
|
journey.pending--;
|
||||||
journey.simulated |= simulated;
|
journey.simulated |= simulated;
|
||||||
final complete = failure == null && terminal;
|
final complete = failure == null && !cancelled && terminal;
|
||||||
_emit(
|
_emit(
|
||||||
journey,
|
journey,
|
||||||
failure != null
|
cancelled
|
||||||
|
? JourneySignal.cancelled
|
||||||
|
: failure != null
|
||||||
? JourneySignal.failed
|
? JourneySignal.failed
|
||||||
: (simulated || (complete && journey.simulated))
|
: (simulated || (complete && journey.simulated))
|
||||||
? JourneySignal.simulated
|
? JourneySignal.simulated
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.dart_tool/
|
||||||
|
build/
|
||||||
|
pubspec.lock
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# sxchnge_kyc
|
||||||
|
|
||||||
|
Shared Flutter identity verification and backend-owned visible limits. Both web
|
||||||
|
and future mobile clients consume the same models, providers and service contracts.
|
||||||
|
|
||||||
|
- `IdentityProvider` coordinates draft photos, validation errors, submission,
|
||||||
|
refresh and semantic analytics. Inject `IdentityService` and `PhotoService`.
|
||||||
|
- `LimitsProvider` fetches `LimitsService` models; it does not calculate caps from
|
||||||
|
a client-selected status. Refresh after status updates. Existing values are
|
||||||
|
marked stale after an error; out-of-order responses cannot replace newer data.
|
||||||
|
- `DummyIdentityService` keeps only review metadata in memory. `DummyLimitsService`
|
||||||
|
mimics backend policy with explicitly illustrative EUR fixtures. Neither is
|
||||||
|
production authorization. `review` exists only on the dummy service.
|
||||||
|
- `FilePhotoService` opens the platform picker and validates JPEG/PNG content,
|
||||||
|
10 MiB size, minimum 600px per dimension, maximum 40 megapixels and decodability.
|
||||||
|
It performs no face matching, OCR or identity approval. Camera capture is not
|
||||||
|
implemented; users can take a photo separately and select it.
|
||||||
|
- Photo models are transient; clients must dispose providers and evict sensitive
|
||||||
|
image cache entries. No disk, browser storage, network or document telemetry is
|
||||||
|
used by these stubs. No guarantee of cryptographic memory erasure is made.
|
||||||
|
|
||||||
|
Replace both identity and limits services when connecting a backend. A submit
|
||||||
|
adapter must reuse the supplied idempotency key across retries, scope requests to
|
||||||
|
the authenticated account, map safe errors and return server-confirmed status.
|
||||||
|
Numeric values use minor currency units (currently a two-decimal currency fixture);
|
||||||
|
extend the transport with currency precision before supporting other units.
|
||||||
|
|
||||||
|
The app's `/identity` route is an explicitly public demo, not an authenticated
|
||||||
|
account endpoint. Production requires session/email guards, server authorization,
|
||||||
|
private document storage, staff review and support integration. See
|
||||||
|
[the UX/measurement contract](../../../docs/ux/kyc.md).
|
||||||
|
|
||||||
|
Validation: `flutter pub get`, `flutter analyze`, `flutter test`.
|
||||||
|
|
||||||
|
Photo selection uses Flutter's [file_selector](https://pub.dev/packages/file_selector)
|
||||||
|
and [image metadata decoding](https://pub.dev/documentation/image/latest/image/Decoder-class.html).
|
||||||
|
For native macOS consumers enable the user-selected read-only file entitlement;
|
||||||
|
iOS/Android camera permissions are not required for this file-picker-only flow.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
enum IdentityStatus { unverified, pending, approved, changesRequested, denied }
|
||||||
|
|
||||||
|
enum ReviewReason {
|
||||||
|
passportUnreadable,
|
||||||
|
selfieUnreadable,
|
||||||
|
passportExpired,
|
||||||
|
unableToVerify,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PhotoKind { passport, selfie }
|
||||||
|
|
||||||
|
enum KycError {
|
||||||
|
unavailable,
|
||||||
|
internal,
|
||||||
|
permission,
|
||||||
|
format,
|
||||||
|
tooLarge,
|
||||||
|
unreadable,
|
||||||
|
tooSmall,
|
||||||
|
incomplete,
|
||||||
|
sessionExpired,
|
||||||
|
rateLimited,
|
||||||
|
conflict,
|
||||||
|
}
|
||||||
|
|
||||||
|
class KycException implements Exception {
|
||||||
|
const KycException(this.code);
|
||||||
|
final KycError code;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Identity {
|
||||||
|
const Identity({
|
||||||
|
required this.status,
|
||||||
|
this.reason,
|
||||||
|
this.submissionId,
|
||||||
|
this.simulated = false,
|
||||||
|
});
|
||||||
|
final IdentityStatus status;
|
||||||
|
final ReviewReason? reason;
|
||||||
|
final String? submissionId;
|
||||||
|
final bool simulated;
|
||||||
|
bool get canSubmit =>
|
||||||
|
status == IdentityStatus.unverified ||
|
||||||
|
status == IdentityStatus.changesRequested;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ephemeral image data only. Never serialize this model into storage or telemetry.
|
||||||
|
class Photo {
|
||||||
|
Photo(Uint8List bytes)
|
||||||
|
: bytes = Uint8List.fromList(bytes).asUnmodifiableView();
|
||||||
|
final Uint8List bytes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
enum AccountTier { basic, verified }
|
||||||
|
|
||||||
|
enum LimitPeriod { day, month }
|
||||||
|
|
||||||
|
class VisibleLimit {
|
||||||
|
const VisibleLimit({
|
||||||
|
required this.period,
|
||||||
|
required this.currency,
|
||||||
|
required this.basicMinor,
|
||||||
|
required this.verifiedMinor,
|
||||||
|
required this.currentMinor,
|
||||||
|
required this.remainingMinor,
|
||||||
|
});
|
||||||
|
final LimitPeriod period;
|
||||||
|
final String currency;
|
||||||
|
final int basicMinor;
|
||||||
|
final int verifiedMinor;
|
||||||
|
final int currentMinor;
|
||||||
|
final int remainingMinor;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Limits {
|
||||||
|
Limits({
|
||||||
|
required this.tier,
|
||||||
|
required List<VisibleLimit> rows,
|
||||||
|
required this.simulated,
|
||||||
|
}) : rows = List.unmodifiable(rows);
|
||||||
|
final AccountTier tier;
|
||||||
|
final List<VisibleLimit> rows;
|
||||||
|
final bool simulated;
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||||
|
|
||||||
|
import '../models/identity.dart';
|
||||||
|
import '../services/contracts.dart';
|
||||||
|
import '../services/dummy.dart';
|
||||||
|
|
||||||
|
class IdentityProvider extends ChangeNotifier {
|
||||||
|
IdentityProvider(
|
||||||
|
this.service,
|
||||||
|
this.photos,
|
||||||
|
this.journeys, {
|
||||||
|
this.onStatusChanged,
|
||||||
|
});
|
||||||
|
final Future<void> Function()? onStatusChanged;
|
||||||
|
final IdentityService service;
|
||||||
|
final PhotoService photos;
|
||||||
|
final JourneyRecorder journeys;
|
||||||
|
Identity? record;
|
||||||
|
Photo? passport;
|
||||||
|
Photo? selfie;
|
||||||
|
KycError? error;
|
||||||
|
PhotoKind? photoError;
|
||||||
|
bool busy = false;
|
||||||
|
bool stale = false;
|
||||||
|
bool editing = false;
|
||||||
|
bool confirmed = false;
|
||||||
|
int step = 0;
|
||||||
|
bool _disposed = false;
|
||||||
|
String? _key;
|
||||||
|
bool get hasDraft => passport != null || selfie != null;
|
||||||
|
bool get canSubmit =>
|
||||||
|
!busy &&
|
||||||
|
!stale &&
|
||||||
|
confirmed &&
|
||||||
|
passport != null &&
|
||||||
|
selfie != null &&
|
||||||
|
(record?.canSubmit ?? false);
|
||||||
|
bool get isDemo => service.isSimulated;
|
||||||
|
void _notify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
JourneyOperation _begin(JourneyStep step) =>
|
||||||
|
journeys.begin(JourneyKind.kyc, step, simulated: isDemo);
|
||||||
|
Future<void> refresh() async {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
error = null;
|
||||||
|
photoError = null;
|
||||||
|
_notify();
|
||||||
|
final op = _begin(JourneyStep.kycStatus);
|
||||||
|
try {
|
||||||
|
final result = await service.fetch();
|
||||||
|
if (_disposed) return;
|
||||||
|
record = result;
|
||||||
|
stale = false;
|
||||||
|
await onStatusChanged?.call();
|
||||||
|
if (_disposed) return;
|
||||||
|
if (!result.canSubmit) {
|
||||||
|
_clear();
|
||||||
|
editing = false;
|
||||||
|
}
|
||||||
|
journeys.finish(op, simulated: isDemo, terminal: true);
|
||||||
|
} catch (e) {
|
||||||
|
if (_disposed) return;
|
||||||
|
stale = true;
|
||||||
|
error = _error(e);
|
||||||
|
journeys.finish(op, failure: JourneyFailure.unavailable, terminal: false);
|
||||||
|
}
|
||||||
|
busy = false;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
void start() {
|
||||||
|
if (busy || stale || !(record?.canSubmit ?? false)) return;
|
||||||
|
editing = true;
|
||||||
|
error = null;
|
||||||
|
if (!hasDraft) {
|
||||||
|
step = 0;
|
||||||
|
confirmed = false;
|
||||||
|
}
|
||||||
|
final op = _begin(JourneyStep.kycPreparation);
|
||||||
|
journeys.finish(op, simulated: isDemo);
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pick(PhotoKind kind) async {
|
||||||
|
if (busy || !editing) return;
|
||||||
|
busy = true;
|
||||||
|
error = null;
|
||||||
|
photoError = null;
|
||||||
|
_notify();
|
||||||
|
final op = _begin(
|
||||||
|
kind == PhotoKind.passport
|
||||||
|
? JourneyStep.kycPassport
|
||||||
|
: JourneyStep.kycSelfie,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
final photo = await photos.pick();
|
||||||
|
if (_disposed) return;
|
||||||
|
if (photo != null) {
|
||||||
|
if (kind == PhotoKind.passport) {
|
||||||
|
passport = photo;
|
||||||
|
} else {
|
||||||
|
selfie = photo;
|
||||||
|
}
|
||||||
|
confirmed = false;
|
||||||
|
_key = null;
|
||||||
|
}
|
||||||
|
journeys.finish(op, simulated: isDemo, cancelled: photo == null);
|
||||||
|
} catch (e) {
|
||||||
|
if (_disposed) return;
|
||||||
|
error = _error(e);
|
||||||
|
photoError = kind;
|
||||||
|
journeys.finish(op, failure: JourneyFailure.document);
|
||||||
|
}
|
||||||
|
busy = false;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
void remove(PhotoKind kind) {
|
||||||
|
if (busy) return;
|
||||||
|
if (kind == PhotoKind.passport) {
|
||||||
|
passport = null;
|
||||||
|
} else {
|
||||||
|
selfie = null;
|
||||||
|
}
|
||||||
|
confirmed = false;
|
||||||
|
_key = null;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
void back() {
|
||||||
|
if (!busy && step > 0) {
|
||||||
|
step--;
|
||||||
|
error = null;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void next() {
|
||||||
|
if (busy ||
|
||||||
|
(step == 0 && passport == null) ||
|
||||||
|
(step == 1 && selfie == null))
|
||||||
|
return;
|
||||||
|
if (step < 2) step++;
|
||||||
|
error = null;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
void confirm(bool value) {
|
||||||
|
if (!busy) {
|
||||||
|
confirmed = value;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> submit() async {
|
||||||
|
if (!canSubmit) return;
|
||||||
|
busy = true;
|
||||||
|
error = null;
|
||||||
|
photoError = null;
|
||||||
|
_notify();
|
||||||
|
_key ??= List.generate(
|
||||||
|
16,
|
||||||
|
(_) => Random.secure().nextInt(256).toRadixString(16).padLeft(2, '0'),
|
||||||
|
).join();
|
||||||
|
final op = _begin(JourneyStep.kycSubmission);
|
||||||
|
try {
|
||||||
|
final result = await service.submit(
|
||||||
|
passport: passport!,
|
||||||
|
selfie: selfie!,
|
||||||
|
idempotencyKey: _key!,
|
||||||
|
);
|
||||||
|
if (_disposed) return;
|
||||||
|
if (result.canSubmit) throw const KycException(KycError.conflict);
|
||||||
|
record = result;
|
||||||
|
editing = false;
|
||||||
|
_clear();
|
||||||
|
await onStatusChanged?.call();
|
||||||
|
if (_disposed) return;
|
||||||
|
journeys.finish(op, simulated: isDemo, terminal: true);
|
||||||
|
} catch (e) {
|
||||||
|
if (_disposed) return;
|
||||||
|
error = _error(e);
|
||||||
|
if (error == KycError.conflict || error == KycError.sessionExpired)
|
||||||
|
stale = true;
|
||||||
|
journeys.finish(op, failure: JourneyFailure.unavailable);
|
||||||
|
}
|
||||||
|
busy = false;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> demoReview(IdentityStatus status, {ReviewReason? reason}) async {
|
||||||
|
if (busy || service is! DummyIdentityService) return;
|
||||||
|
try {
|
||||||
|
(service as DummyIdentityService).review(status, reason: reason);
|
||||||
|
} on KycException catch (e) {
|
||||||
|
error = e.code;
|
||||||
|
_notify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
void exit() {
|
||||||
|
if (busy) return;
|
||||||
|
journeys.abandon(JourneyExit.navigation);
|
||||||
|
_clear();
|
||||||
|
editing = false;
|
||||||
|
error = null;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clear() {
|
||||||
|
passport = null;
|
||||||
|
selfie = null;
|
||||||
|
confirmed = false;
|
||||||
|
_key = null;
|
||||||
|
step = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
KycError _error(Object e) => e is KycException ? e.code : KycError.internal;
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
_clear();
|
||||||
|
journeys.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../models/limits.dart';
|
||||||
|
import '../services/contracts.dart';
|
||||||
|
|
||||||
|
class LimitsProvider extends ChangeNotifier {
|
||||||
|
LimitsProvider(this.service);
|
||||||
|
final LimitsService service;
|
||||||
|
Limits? data;
|
||||||
|
bool loading = false;
|
||||||
|
bool failed = false;
|
||||||
|
int _revision = 0;
|
||||||
|
bool _disposed = false;
|
||||||
|
Future<void> refresh() async {
|
||||||
|
final revision = ++_revision;
|
||||||
|
loading = true;
|
||||||
|
failed = false;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
final result = await service.fetch();
|
||||||
|
if (_disposed || revision != _revision) return;
|
||||||
|
data = result;
|
||||||
|
} catch (_) {
|
||||||
|
if (_disposed || revision != _revision) return;
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
if (_disposed || revision != _revision) return;
|
||||||
|
loading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import '../models/identity.dart';
|
||||||
|
import '../models/limits.dart';
|
||||||
|
|
||||||
|
abstract interface class IdentityService {
|
||||||
|
bool get isSimulated;
|
||||||
|
Future<Identity> fetch();
|
||||||
|
|
||||||
|
/// Backend adapters must scope the key to the authenticated account and reject
|
||||||
|
/// stale/duplicate review transitions. Retrying the same key is idempotent.
|
||||||
|
Future<Identity> submit({
|
||||||
|
required Photo passport,
|
||||||
|
required Photo selfie,
|
||||||
|
required String idempotencyKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract interface class LimitsService {
|
||||||
|
/// Fetch server-owned visible limits. No client-selected KYC status or tier.
|
||||||
|
Future<Limits> fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract interface class PhotoService {
|
||||||
|
Future<Photo?> pick();
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import '../models/identity.dart';
|
||||||
|
import '../models/limits.dart';
|
||||||
|
import '../transport/contracts.dart';
|
||||||
|
import 'contracts.dart';
|
||||||
|
|
||||||
|
/// Process-local backend stand-in. It never stores or sends document bytes.
|
||||||
|
class DummyIdentityService implements IdentityService {
|
||||||
|
DummyIdentityService({this.delay = const Duration(milliseconds: 450)});
|
||||||
|
final Duration delay;
|
||||||
|
IdentityDTO _record = const IdentityDTO('unverified');
|
||||||
|
String? _lastKey;
|
||||||
|
KycError? nextError;
|
||||||
|
@override
|
||||||
|
bool get isSimulated => true;
|
||||||
|
Future<void> _wait() async {
|
||||||
|
await Future<void>.delayed(delay);
|
||||||
|
final error = nextError;
|
||||||
|
nextError = null;
|
||||||
|
if (error != null) throw KycException(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Identity> fetch() async {
|
||||||
|
await _wait();
|
||||||
|
return IdentityMapper.fromDTO(_record);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Identity> submit({
|
||||||
|
required Photo passport,
|
||||||
|
required Photo selfie,
|
||||||
|
required String idempotencyKey,
|
||||||
|
}) async {
|
||||||
|
await _wait();
|
||||||
|
if (_lastKey == idempotencyKey) return IdentityMapper.fromDTO(_record);
|
||||||
|
if (!IdentityMapper.fromDTO(_record).canSubmit)
|
||||||
|
throw const KycException(KycError.conflict);
|
||||||
|
if (passport.bytes.isEmpty || selfie.bytes.isEmpty)
|
||||||
|
throw const KycException(KycError.incomplete);
|
||||||
|
_lastKey = idempotencyKey;
|
||||||
|
_record = IdentityDTO('pending', id: idempotencyKey);
|
||||||
|
return IdentityMapper.fromDTO(_record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicit demo-only human decision stand-in; unavailable in real adapters.
|
||||||
|
void review(IdentityStatus status, {ReviewReason? reason}) {
|
||||||
|
if (_record.status != 'pending')
|
||||||
|
throw const KycException(KycError.conflict);
|
||||||
|
if (![
|
||||||
|
IdentityStatus.approved,
|
||||||
|
IdentityStatus.changesRequested,
|
||||||
|
IdentityStatus.denied,
|
||||||
|
].contains(status))
|
||||||
|
throw const KycException(KycError.conflict);
|
||||||
|
_record = IdentityDTO(status.name, id: _record.id, reason: reason?.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mimics backend policy evaluation. Widgets/providers never compute active caps.
|
||||||
|
/// Values are illustrative fixtures, not the product's real limits policy.
|
||||||
|
class DummyLimitsService implements LimitsService {
|
||||||
|
DummyLimitsService(this.identity);
|
||||||
|
final IdentityService identity;
|
||||||
|
@override
|
||||||
|
Future<Limits> fetch() async {
|
||||||
|
final record = await identity.fetch();
|
||||||
|
final approved = record.status == IdentityStatus.approved;
|
||||||
|
return LimitsMapper.fromDTO(
|
||||||
|
LimitsDTO(
|
||||||
|
tier: approved ? 'verified' : 'basic',
|
||||||
|
simulated: true,
|
||||||
|
rows: [
|
||||||
|
LimitDTO(
|
||||||
|
'day',
|
||||||
|
'EUR',
|
||||||
|
10000,
|
||||||
|
250000,
|
||||||
|
approved ? 250000 : 10000,
|
||||||
|
approved ? 250000 : 10000,
|
||||||
|
),
|
||||||
|
LimitDTO(
|
||||||
|
'month',
|
||||||
|
'EUR',
|
||||||
|
50000,
|
||||||
|
1000000,
|
||||||
|
approved ? 1000000 : 50000,
|
||||||
|
approved ? 1000000 : 50000,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:file_selector/file_selector.dart';
|
||||||
|
|
||||||
|
import 'package:image/image.dart' as metadata;
|
||||||
|
|
||||||
|
import '../models/identity.dart';
|
||||||
|
import 'contracts.dart';
|
||||||
|
|
||||||
|
class FilePhotoService implements PhotoService {
|
||||||
|
const FilePhotoService();
|
||||||
|
static const maxBytes = 10 * 1024 * 1024;
|
||||||
|
@override
|
||||||
|
Future<Photo?> pick() async {
|
||||||
|
try {
|
||||||
|
final file = await openFile(
|
||||||
|
acceptedTypeGroups: const [
|
||||||
|
XTypeGroup(
|
||||||
|
label: 'JPEG / PNG',
|
||||||
|
extensions: ['jpg', 'jpeg', 'png'],
|
||||||
|
mimeTypes: ['image/jpeg', 'image/png'],
|
||||||
|
uniformTypeIdentifiers: ['public.jpeg', 'public.png'],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (file == null) return null;
|
||||||
|
if (await file.length() > maxBytes)
|
||||||
|
throw const KycException(KycError.tooLarge);
|
||||||
|
final bytes = await file.readAsBytes();
|
||||||
|
return await validate(bytes);
|
||||||
|
} on KycException {
|
||||||
|
rethrow;
|
||||||
|
} catch (_) {
|
||||||
|
throw const KycException(KycError.permission);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Photo> validate(Uint8List bytes) async {
|
||||||
|
try {
|
||||||
|
if (bytes.length > maxBytes) throw const KycException(KycError.tooLarge);
|
||||||
|
final png =
|
||||||
|
bytes.length > 8 &&
|
||||||
|
bytes[0] == 137 &&
|
||||||
|
bytes[1] == 80 &&
|
||||||
|
bytes[2] == 78 &&
|
||||||
|
bytes[3] == 71;
|
||||||
|
final jpg =
|
||||||
|
bytes.length > 3 &&
|
||||||
|
bytes[0] == 255 &&
|
||||||
|
bytes[1] == 216 &&
|
||||||
|
bytes[2] == 255;
|
||||||
|
if (!png && !jpg) throw const KycException(KycError.format);
|
||||||
|
final metadata.Decoder decoder = png
|
||||||
|
? metadata.PngDecoder()
|
||||||
|
: metadata.JpegDecoder();
|
||||||
|
final info = decoder.startDecode(bytes);
|
||||||
|
if (info == null) throw const KycException(KycError.unreadable);
|
||||||
|
if (info.width < 600 || info.height < 600)
|
||||||
|
throw const KycException(KycError.tooSmall);
|
||||||
|
if (info.width * info.height > 40000000)
|
||||||
|
throw const KycException(KycError.tooLarge);
|
||||||
|
final codec = await ui.instantiateImageCodec(
|
||||||
|
bytes,
|
||||||
|
targetWidth: 1000,
|
||||||
|
allowUpscaling: false,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
final frame = await codec.getNextFrame();
|
||||||
|
frame.image.dispose();
|
||||||
|
} finally {
|
||||||
|
codec.dispose();
|
||||||
|
}
|
||||||
|
return Photo(bytes);
|
||||||
|
} on KycException {
|
||||||
|
rethrow;
|
||||||
|
} catch (_) {
|
||||||
|
throw const KycException(KycError.unreadable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import '../models/identity.dart';
|
||||||
|
import '../models/limits.dart';
|
||||||
|
|
||||||
|
// Transport contracts for the stub. Replace with generated backend DTOs later.
|
||||||
|
class IdentityDTO {
|
||||||
|
const IdentityDTO(this.status, {this.reason, this.id, this.simulated = true});
|
||||||
|
final String status;
|
||||||
|
final String? reason;
|
||||||
|
final String? id;
|
||||||
|
final bool simulated;
|
||||||
|
}
|
||||||
|
|
||||||
|
class IdentityMapper {
|
||||||
|
static Identity fromDTO(IdentityDTO dto) => Identity(
|
||||||
|
status: IdentityStatus.values.byName(dto.status),
|
||||||
|
reason: dto.reason == null ? null : ReviewReason.values.byName(dto.reason!),
|
||||||
|
submissionId: dto.id,
|
||||||
|
simulated: dto.simulated,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class LimitsDTO {
|
||||||
|
const LimitsDTO({
|
||||||
|
required this.tier,
|
||||||
|
required this.rows,
|
||||||
|
required this.simulated,
|
||||||
|
});
|
||||||
|
final String tier;
|
||||||
|
final List<LimitDTO> rows;
|
||||||
|
final bool simulated;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LimitDTO {
|
||||||
|
const LimitDTO(
|
||||||
|
this.period,
|
||||||
|
this.currency,
|
||||||
|
this.basic,
|
||||||
|
this.verified,
|
||||||
|
this.current,
|
||||||
|
this.remaining,
|
||||||
|
);
|
||||||
|
final String period;
|
||||||
|
final String currency;
|
||||||
|
final int basic, verified, current, remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LimitsMapper {
|
||||||
|
static Limits fromDTO(LimitsDTO dto) => Limits(
|
||||||
|
tier: AccountTier.values.byName(dto.tier),
|
||||||
|
simulated: dto.simulated,
|
||||||
|
rows: dto.rows
|
||||||
|
.map(
|
||||||
|
(r) => VisibleLimit(
|
||||||
|
period: LimitPeriod.values.byName(r.period),
|
||||||
|
currency: r.currency,
|
||||||
|
basicMinor: r.basic,
|
||||||
|
verifiedMinor: r.verified,
|
||||||
|
currentMinor: r.current,
|
||||||
|
remainingMinor: r.remaining,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export 'src/models/identity.dart';
|
||||||
|
export 'src/models/limits.dart';
|
||||||
|
export 'src/providers/identity.dart';
|
||||||
|
export 'src/providers/limits.dart';
|
||||||
|
export 'src/services/contracts.dart';
|
||||||
|
export 'src/services/dummy.dart';
|
||||||
|
export 'src/services/photos.dart';
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
name: sxchnge_kyc
|
||||||
|
description: Shared identity verification and visible limits for SXCHNGE clients.
|
||||||
|
version: 0.1.0
|
||||||
|
publish_to: none
|
||||||
|
environment:
|
||||||
|
sdk: '>=3.13.0 <4.0.0'
|
||||||
|
flutter: '>=3.47.0'
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
image: ^4.9.0
|
||||||
|
file_selector: ^1.1.0
|
||||||
|
sxchnge_analytics:
|
||||||
|
path: ../sxchnge_analytics
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
class Photos implements PhotoService {
|
||||||
|
Photo? result = Photo(Uint8List.fromList([1, 2, 3]));
|
||||||
|
KycError? error;
|
||||||
|
Completer<Photo?>? pending;
|
||||||
|
@override
|
||||||
|
Future<Photo?> pick() async {
|
||||||
|
if (error != null) throw KycException(error!);
|
||||||
|
return pending == null ? result : pending!.future;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LimitStub implements LimitsService {
|
||||||
|
final requests = <Completer<Limits>>[];
|
||||||
|
@override
|
||||||
|
Future<Limits> fetch() {
|
||||||
|
final c = Completer<Limits>();
|
||||||
|
requests.add(c);
|
||||||
|
return c.future;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BrokenAnalytics implements AnalyticsService {
|
||||||
|
@override
|
||||||
|
Future<void> capture(AnalyticsEvent event) => throw StateError('offline');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> draft(IdentityProvider p) async {
|
||||||
|
await p.refresh();
|
||||||
|
p.start();
|
||||||
|
await p.pick(PhotoKind.passport);
|
||||||
|
p.next();
|
||||||
|
await p.pick(PhotoKind.selfie);
|
||||||
|
p.next();
|
||||||
|
p.confirm(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
late DummyIdentityService service;
|
||||||
|
late Photos photos;
|
||||||
|
late DummyAnalyticsService sink;
|
||||||
|
late IdentityProvider p;
|
||||||
|
setUp(() {
|
||||||
|
service = DummyIdentityService(delay: Duration.zero);
|
||||||
|
photos = Photos();
|
||||||
|
sink = DummyAnalyticsService();
|
||||||
|
p = IdentityProvider(service, photos, JourneyRecorder(sink));
|
||||||
|
});
|
||||||
|
tearDown(() => p.dispose());
|
||||||
|
test(
|
||||||
|
'unverified, pending, correction and denial retain basic service limits',
|
||||||
|
() async {
|
||||||
|
final limits = LimitsProvider(DummyLimitsService(service));
|
||||||
|
addTearDown(limits.dispose);
|
||||||
|
await limits.refresh();
|
||||||
|
expect(limits.data!.tier, AccountTier.basic);
|
||||||
|
await draft(p);
|
||||||
|
await p.submit();
|
||||||
|
expect(p.record!.status, IdentityStatus.pending);
|
||||||
|
expect(p.hasDraft, false);
|
||||||
|
await limits.refresh();
|
||||||
|
expect(limits.data!.tier, AccountTier.basic);
|
||||||
|
await p.demoReview(
|
||||||
|
IdentityStatus.changesRequested,
|
||||||
|
reason: ReviewReason.passportExpired,
|
||||||
|
);
|
||||||
|
await limits.refresh();
|
||||||
|
expect(limits.data!.tier, AccountTier.basic);
|
||||||
|
await draft(p);
|
||||||
|
await p.submit();
|
||||||
|
await p.demoReview(
|
||||||
|
IdentityStatus.denied,
|
||||||
|
reason: ReviewReason.unableToVerify,
|
||||||
|
);
|
||||||
|
p.start();
|
||||||
|
expect(p.editing, false);
|
||||||
|
await limits.refresh();
|
||||||
|
expect(limits.data!.tier, AccountTier.basic);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test('approval refreshes backend limits through callback', () async {
|
||||||
|
final limits = LimitsProvider(DummyLimitsService(service));
|
||||||
|
addTearDown(limits.dispose);
|
||||||
|
final subject = IdentityProvider(
|
||||||
|
service,
|
||||||
|
photos,
|
||||||
|
JourneyRecorder(sink),
|
||||||
|
onStatusChanged: limits.refresh,
|
||||||
|
);
|
||||||
|
addTearDown(subject.dispose);
|
||||||
|
await draft(subject);
|
||||||
|
final before = limits.data!.rows.first.currentMinor;
|
||||||
|
await subject.submit();
|
||||||
|
await subject.demoReview(IdentityStatus.approved);
|
||||||
|
expect(limits.data!.tier, AccountTier.verified);
|
||||||
|
expect(limits.data!.rows.first.currentMinor, greaterThan(before));
|
||||||
|
});
|
||||||
|
test('cancel and invalid replacement preserve photo', () async {
|
||||||
|
await draft(p);
|
||||||
|
p.back();
|
||||||
|
final original = p.selfie;
|
||||||
|
photos.result = null;
|
||||||
|
await p.pick(PhotoKind.selfie);
|
||||||
|
expect(p.selfie, same(original));
|
||||||
|
expect(p.error, null);
|
||||||
|
photos.error = KycError.tooLarge;
|
||||||
|
await p.pick(PhotoKind.selfie);
|
||||||
|
expect(p.selfie, same(original));
|
||||||
|
expect(p.error, KycError.tooLarge);
|
||||||
|
expect(p.step, 1);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(sink.events.any((e) => e.signal == JourneySignal.cancelled), true);
|
||||||
|
});
|
||||||
|
test(
|
||||||
|
'submit retry preserves photos and counts one terminal simulated outcome',
|
||||||
|
() async {
|
||||||
|
await draft(p);
|
||||||
|
service.nextError = KycError.unavailable;
|
||||||
|
await p.submit();
|
||||||
|
expect(p.hasDraft, true);
|
||||||
|
expect(p.canSubmit, true);
|
||||||
|
await p.submit();
|
||||||
|
expect(p.record!.status, IdentityStatus.pending);
|
||||||
|
expect(p.hasDraft, false);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
final events = sink.events.where(
|
||||||
|
(e) => e.step == JourneyStep.kycSubmission,
|
||||||
|
);
|
||||||
|
expect(events.where((e) => e.terminal).length, 1);
|
||||||
|
expect(events.map((e) => e.journeyId).toSet().length, 1);
|
||||||
|
expect(events.every((e) => e.simulated), true);
|
||||||
|
expect(
|
||||||
|
sink.events
|
||||||
|
.expand((e) => e.properties.keys)
|
||||||
|
.any(
|
||||||
|
(k) => [
|
||||||
|
'email',
|
||||||
|
'filename',
|
||||||
|
'bytes',
|
||||||
|
'passport',
|
||||||
|
'token',
|
||||||
|
].contains(k),
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test('duplicate submit suppressed and service idempotency replays', () async {
|
||||||
|
await draft(p);
|
||||||
|
await Future.wait([p.submit(), p.submit()]);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(
|
||||||
|
sink.events
|
||||||
|
.where(
|
||||||
|
(e) =>
|
||||||
|
e.step == JourneyStep.kycSubmission &&
|
||||||
|
e.signal == JourneySignal.requested,
|
||||||
|
)
|
||||||
|
.length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
final replay = await service.submit(
|
||||||
|
passport: photos.result!,
|
||||||
|
selfie: photos.result!,
|
||||||
|
idempotencyKey: p.record!.submissionId!,
|
||||||
|
);
|
||||||
|
expect(replay.submissionId, p.record!.submissionId);
|
||||||
|
});
|
||||||
|
test('status error does not invent a tier or allow submission', () async {
|
||||||
|
service.nextError = KycError.unavailable;
|
||||||
|
await p.refresh();
|
||||||
|
expect(p.record, null);
|
||||||
|
expect(p.stale, true);
|
||||||
|
p.start();
|
||||||
|
expect(p.editing, false);
|
||||||
|
await p.refresh();
|
||||||
|
p.start();
|
||||||
|
expect(p.editing, true);
|
||||||
|
});
|
||||||
|
test(
|
||||||
|
'limits ignore late response and mark retained values stale on error',
|
||||||
|
() async {
|
||||||
|
final s = LimitStub();
|
||||||
|
final limits = LimitsProvider(s);
|
||||||
|
addTearDown(limits.dispose);
|
||||||
|
final old = limits.refresh();
|
||||||
|
final recent = limits.refresh();
|
||||||
|
s.requests[1].complete(
|
||||||
|
Limits(tier: AccountTier.verified, rows: [], simulated: true),
|
||||||
|
);
|
||||||
|
await recent;
|
||||||
|
s.requests[0].complete(
|
||||||
|
Limits(tier: AccountTier.basic, rows: [], simulated: true),
|
||||||
|
);
|
||||||
|
await old;
|
||||||
|
expect(limits.data!.tier, AccountTier.verified);
|
||||||
|
final failure = limits.refresh();
|
||||||
|
s.requests[2].completeError(StateError('offline'));
|
||||||
|
await failure;
|
||||||
|
expect(limits.failed, true);
|
||||||
|
expect(limits.data!.tier, AccountTier.verified);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'exit discards images; analytics errors cannot block submission',
|
||||||
|
() async {
|
||||||
|
await draft(p);
|
||||||
|
p.exit();
|
||||||
|
expect(p.hasDraft, false);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(sink.events.any((e) => e.signal == JourneySignal.abandoned), true);
|
||||||
|
final subject = IdentityProvider(
|
||||||
|
service,
|
||||||
|
photos,
|
||||||
|
JourneyRecorder(BrokenAnalytics()),
|
||||||
|
);
|
||||||
|
addTearDown(subject.dispose);
|
||||||
|
await draft(subject);
|
||||||
|
await subject.submit();
|
||||||
|
expect(subject.record!.status, IdentityStatus.pending);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test('late picker does not repopulate disposed state', () async {
|
||||||
|
final subject = IdentityProvider(service, photos, JourneyRecorder(sink));
|
||||||
|
await subject.refresh();
|
||||||
|
subject.start();
|
||||||
|
photos.pending = Completer<Photo?>();
|
||||||
|
final pending = subject.pick(PhotoKind.passport);
|
||||||
|
subject.dispose();
|
||||||
|
photos.pending!.complete(photos.result);
|
||||||
|
await pending;
|
||||||
|
expect(subject.hasDraft, false);
|
||||||
|
});
|
||||||
|
test('validates bytes, signature, corrupt encoding and resolution', () async {
|
||||||
|
Future<void> rejects(Uint8List bytes, KycError code) async => expectLater(
|
||||||
|
FilePhotoService.validate(bytes),
|
||||||
|
throwsA(isA<KycException>().having((e) => e.code, 'code', code)),
|
||||||
|
);
|
||||||
|
await rejects(Uint8List(FilePhotoService.maxBytes + 1), KycError.tooLarge);
|
||||||
|
await rejects(Uint8List.fromList([1, 2, 3]), KycError.format);
|
||||||
|
await rejects(Uint8List.fromList([255, 216, 255, 0]), KycError.unreadable);
|
||||||
|
final r = ui.PictureRecorder();
|
||||||
|
ui.Canvas(r);
|
||||||
|
final pic = r.endRecording();
|
||||||
|
final image = await pic.toImage(20, 20);
|
||||||
|
final bytes = (await image.toByteData(format: ui.ImageByteFormat.png))!
|
||||||
|
.buffer
|
||||||
|
.asUint8List();
|
||||||
|
image.dispose();
|
||||||
|
pic.dispose();
|
||||||
|
await rejects(bytes, KycError.tooSmall);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user