Preserve implemented workspace plan while integrating plan history
This commit is contained in:
commit
3c56fad4bf
74 files changed
+8040
-271
No files matched your search
@@ -10,6 +10,10 @@ before reporting it complete. Rendering correctly is only one part of completion
|
||||
|
||||
## Establish the flow
|
||||
|
||||
- Prefer the simplest interface that lets users complete their task confidently.
|
||||
When options meet the same need, choose fewer concepts, choices, and controls.
|
||||
Add complexity only for a concrete user need; preserve essential information,
|
||||
accessibility, and recovery rather than hiding them to make a screen look minimal.
|
||||
- Identify the user's primary task, successful outcome, and shortest appropriate
|
||||
path to that outcome before choosing layouts or controls.
|
||||
- Inspect comparable product flows, components, navigation, terminology, and
|
||||
@@ -95,6 +99,8 @@ performed and any gaps; do not mark applicable unmet criteria as complete.
|
||||
- [ ] Are interactive elements recognizable, and can the user tell what is
|
||||
happening and what will happen next?
|
||||
- [ ] Are steps appropriate, with unnecessary interaction and cognitive load removed?
|
||||
- [ ] Is this the simplest usable solution, with each added concept, choice, or
|
||||
control serving a concrete user need?
|
||||
- [ ] Does the flow follow existing product patterns and consistent terminology,
|
||||
with a concrete reason for any departure?
|
||||
- [ ] Can users recover from errors, with input and context preserved where appropriate?
|
||||
File renamed without changes.
@@ -11,8 +11,8 @@
|
||||
## UX
|
||||
|
||||
For any user-facing interface work, follow the
|
||||
[UX Design](skills/ux-design/SKILL.md) and
|
||||
[UX Observability](skills/ux-observability/SKILL.md) skills.
|
||||
[UX Design](.agents/skills/ux-design/SKILL.md) and
|
||||
[UX Observability](.agents/skills/ux-observability/SKILL.md) skills.
|
||||
UX is not considered complete unless the interface is both usable and measurable.
|
||||
Before considering UI work complete, apply the UX Review checklist from these skills.
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
.PHONY: test-frontend
|
||||
|
||||
test-frontend:
|
||||
cd frontend/packages/sxchnge_wallet && flutter test
|
||||
cd frontend/packages/sxchnge_kyc && flutter test
|
||||
cd frontend/packages/sxchnge_analytics && flutter test
|
||||
cd frontend/packages/sxchnge_auth && flutter test
|
||||
cd frontend/packages/sxchnge_theme && flutter test
|
||||
|
||||
@@ -169,3 +169,34 @@ Follow [`AGENTS.md`](AGENTS.md) and the applicable scoped guidelines, including
|
||||
Implementation work uses a Gitea task and linked branch, validation, commits,
|
||||
a push and a PR. Merge only when authorized. Record scope, validation and known
|
||||
limitations in the task and [implementation record](docs/implementation.md).
|
||||
|
||||
## Identity verification preview
|
||||
|
||||
Open `/#/identity` or use **Limits → Verify identity** in the workspace. 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.
|
||||
|
||||
## Exchange workspace preview
|
||||
|
||||
Choose **Explore your accounts** on login/email confirmation, or open `/#/accounts`.
|
||||
Currency cards show only available funds. Select an account, then top up, exchange
|
||||
or pay out through input → review → status. Review shows fees and actual debit and
|
||||
credit; status explains where the money is, including processing and failed returns.
|
||||
Activity, account balances and limits use one coherent demo ledger.
|
||||
|
||||
Settings owns English/Russian account language. Limits links to optional KYC;
|
||||
no verification remains a valid account state with basic limits.
|
||||
|
||||
The shared [wallet package](frontend/packages/sxchnge_wallet/README.md) is reusable
|
||||
by mobile clients. Transactions, rates, fees, recipients and profile storage are
|
||||
explicitly simulated and reset on reload. No real money moves. See the
|
||||
[workspace UX plan](docs/ux/exchange-workspace.md) and
|
||||
[implementation record](docs/implementation.md) for scope and validation.
|
||||
+125
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
[Task #10](https://git.sendico.io/tech/sxchnge/issues/10), branch `skills-10`,
|
||||
delivered in [PR #11](https://git.sendico.io/tech/sxchnge/pulls/11) into `main`,
|
||||
adds a concise root UX policy linking two independent skills in `skills/`:
|
||||
adds a concise root UX policy linking two independent skills, now consolidated under `.agents/skills/`:
|
||||
UX Design covers usable interfaces and its design review; UX Observability covers
|
||||
purposeful journey measurement and its observability review. Both preserve product
|
||||
conventions and avoid prescribing frameworks, vendors, or visual styles.
|
||||
@@ -123,6 +123,16 @@ Implementation commit `f1cbf64`; 68 tests and affected-package analysis/formatti
|
||||
plus web build passed. Final browser verification confirms accessible recipient
|
||||
text and keyboard recovery. No merge or actual PostHog collection was performed.
|
||||
|
||||
## Consolidated skill location
|
||||
|
||||
Task [#16](https://git.sendico.io/tech/sxchnge/issues/16) consolidates all four
|
||||
repository skills under `.agents/skills/`. UX Design and UX Observability moved
|
||||
without content changes; root policy links and this record now use that location.
|
||||
The old root `skills/` directory is removed. Validation compares moved content
|
||||
byte-for-byte, checks all four skill paths and updated UX links, and runs
|
||||
`git diff --check`. No runtime tests are needed for this move.
|
||||
|
||||
Delivery: [PR #17](https://git.sendico.io/tech/sxchnge/pulls/17), branch `skills-16`.
|
||||
## Editor configuration cleanup
|
||||
|
||||
Task [#14](https://git.sendico.io/tech/sxchnge/issues/14) removes the root VS Code
|
||||
@@ -154,3 +164,117 @@ Documentation only; no runtime tests were run.
|
||||
implementation should incorporate KYC PR #21 and usage PR #23.
|
||||
- Validation: documentation links and diff checks; no runtime changes or tests.
|
||||
- Delivery: [PR #25](https://git.sendico.io/tech/sxchnge/pulls/25), committed and pushed; not merged.
|
||||
## 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.
|
||||
|
||||
## Limits usage display — task #22
|
||||
|
||||
- Branch `limits-22`, stacked on `kyc-20` / PR #21 (merge #21 first).
|
||||
- Added explicit used/committed and reserved fields through DTO, mapper, service
|
||||
and model. Remaining is independently supplied by the backend stand-in.
|
||||
- Daily/monthly cards show used, reserved, available, cap and upgrade amounts,
|
||||
with a labeled theme-based occupied-capacity indicator and exhausted text.
|
||||
Dummy fixtures contain nonzero activity; usage survives tier increases.
|
||||
- UX review: EN/RU labels explain pending holds, no color-only information,
|
||||
320px/1.6-scale widget checks, actual browser visual verification, zero and
|
||||
over-cap ratio handling. Existing KYC telemetry is sufficient; no usage amounts
|
||||
are sent to analytics.
|
||||
- Validation passed: all 91 frontend tests, analyze app and sxchnge_kyc,
|
||||
format check of all 9 affected Dart files, web build and diff checks.
|
||||
- Delivery: [PR #23](https://git.sendico.io/tech/sxchnge/pulls/23), unmerged. Merge #21 first and retarget #23 to main.
|
||||
|
||||
## UX simplicity preference
|
||||
|
||||
Task [#26](https://git.sendico.io/tech/sxchnge/issues/26), delivered in
|
||||
[PR #27](https://git.sendico.io/tech/sxchnge/pulls/27) on `skills-26`, makes
|
||||
simplicity an explicit preference in the UX Design skill and its review checklist.
|
||||
Prefer fewer concepts, choices, and controls when they serve the same need; retain
|
||||
essential information, accessibility, and recovery. Documentation only.
|
||||
Validation: skill-creator `quick_validate.py` passed in an isolated environment
|
||||
with PyYAML (the system Python lacked it); `git diff --check` passed. No runtime
|
||||
tests were needed. The PR is not merged.
|
||||
|
||||
## Exchange workspace — task #28
|
||||
|
||||
- Branch `workspace-28`, stacked on `limits-22` / PR #23. Merge KYC #21 first,
|
||||
then limits #23, then retarget this workspace contribution to main. The approved
|
||||
plan from #24/#25 is included and updated for the user's available-only direction.
|
||||
- Implemented Accounts, Activity/detail, Limits and Settings in a responsive
|
||||
go_router shell, with top-up, exchange and payout input/review/status flows.
|
||||
One action group follows the selected currency; all colors use theme roles.
|
||||
Only available balances are displayed. Status explains credit timing, outgoing
|
||||
amount plus fee, completion and full return after failure.
|
||||
- Shared `sxchnge_wallet` service/providers own coherent demo balances, quotes,
|
||||
idempotent submissions, reconciliation, history, account language and live
|
||||
snapshot refresh. The limits adapter reuses the KYC DTO/mapper and refreshes
|
||||
usage when operations change. Widgets consume provider models.
|
||||
- Language is in account settings, with failed-save feedback and retry. Public
|
||||
auth and verified-email screens enter the workspace. KYC remains optional;
|
||||
the demo service enforces its basic/verified outgoing caps at submission.
|
||||
- UX review: EN/RU at 320px with 1.6 text scaling; available-only presentation;
|
||||
field preservation after errors; explicit draft discard; quote expiry;
|
||||
loading/stale/empty states; operation outcome explanations; accessible labels,
|
||||
semantic status announcements and focus after quote/errors. Browser walkthrough
|
||||
verifies quote amounts and service-confirmed credit with the exchange handoff.
|
||||
Native mobile and dedicated VoiceOver/TalkBack testing remain release QA.
|
||||
- Measurement review: typed draft/quote/submission/settlement steps with scoped
|
||||
journey IDs, safe failure enums, simulated flag and deduplicated terminal
|
||||
outcomes. No financial amounts, recipients or account IDs in events. Pending
|
||||
app closure is not draft abandonment. Real PostHog delivery and cross-session
|
||||
server outcomes remain integration work.
|
||||
- Validation passed: `make test-frontend` (115 tests), `flutter analyze` in app,
|
||||
sxchnge_wallet, sxchnge_analytics and sxchnge_kyc; format check of all 36 changed
|
||||
Dart files; `flutter build web`; `git diff --check`. Review fixes included
|
||||
narrow Russian layouts, limit-provider typing, automatic pending refresh,
|
||||
single-use quotes and ambiguous-response recovery. No security scan requested.
|
||||
- Demo limits: opening EUR 75, fixed rates/fees and three currencies, no real
|
||||
payment methods/bank validation/storage/auth enforcement. Account preferences,
|
||||
history and balances reset on reload. No receipt export, date pagination,
|
||||
notification preferences or security settings beyond an honest capability note.
|
||||
Backend adapters must supply these before production use; see the wallet README.
|
||||
- Delivery: [PR #29](https://git.sendico.io/tech/sxchnge/pulls/29), pushed and
|
||||
unmerged. Task and PR have frontend label, tech owner and SXCHNGE project.
|
||||
Browser demo walkthrough also completed exchange and payout, confirming source
|
||||
currency handoffs and exact fee/debit/receipt values on the result screen.
|
||||
|
||||
### Workspace copy cleanup — follow-up to #28
|
||||
|
||||
At the user's request, removed repeated demo/preview banners from the workspace,
|
||||
transaction forms, auth, email confirmation, identity and limits. Confirmation
|
||||
and result copy now uses ordinary product wording in EN/RU; the mobile wordmark
|
||||
remains without the extra note. Removed unused localization entries and duplicate
|
||||
email-state copy. Existing journey measurement, simulation flags, service behavior,
|
||||
funds explanations and recovery controls are unchanged. Development constraints
|
||||
belong in this record and package README, not repeated throughout the product UI.
|
||||
|
||||
Delivery continues in [PR #29](https://git.sendico.io/tech/sxchnge/pulls/29) on
|
||||
`workspace-28`. This copy change does not connect real authentication, email,
|
||||
payment, support or backoffice services.
|
||||
Validation: all 115 frontend tests, app analysis, app Dart formatting, web build
|
||||
and diff checks pass. Updated existing copy expectations; telemetry tests still
|
||||
verify simulated events never count as real business success. No new click events
|
||||
were needed for this presentation-only cleanup.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Exchange workspace — UX plan
|
||||
|
||||
Task [#24](https://git.sendico.io/tech/sxchnge/issues/24). **Plan only; no app changes.**
|
||||
Task [#24](https://git.sendico.io/tech/sxchnge/issues/24). Approved plan; implementation tracked in task #28.
|
||||
|
||||
## Product direction
|
||||
|
||||
@@ -20,10 +20,10 @@ Keep decoration secondary to balances, amounts, recipients and transaction terms
|
||||
|
||||
| Destination | What it contains | Main next action |
|
||||
|---|---|---|
|
||||
| Accounts (default) | Currency accounts, available balances, reserved amounts, recent activity, actionable pending items | Top up, exchange, pay out |
|
||||
| Currency account | Available/booked balance, holds, relevant activity and applicable limits | Act on this currency |
|
||||
| Accounts (default) | Currency accounts, available balances, recent activity, actionable pending items | Top up, exchange, pay out |
|
||||
| Currency account | Available balance, relevant activity and applicable limits | Act on this currency |
|
||||
| Activity | All operations, filters, search, processing items | Inspect an operation or resolve an actionable issue |
|
||||
| Limits | Current caps, used, reserved, available, period/reset information and upgrade benefits | Verify identity when useful |
|
||||
| Limits | Current caps, used and available, period/reset information and upgrade benefits | Verify identity when useful |
|
||||
| Settings | Profile, language, security, notifications and help | Manage the account |
|
||||
|
||||
Desktop: a persistent sidebar with **Accounts · Activity · Limits · Settings**.
|
||||
@@ -71,7 +71,7 @@ Your accounts [Add currency account, if supported]
|
||||
[ EUR · available balance ] [ USD · available balance ] …
|
||||
|
||||
Selected account: EUR
|
||||
Available: … Reserved: …
|
||||
Available: …
|
||||
[Top up] [Exchange] [Pay out]
|
||||
|
||||
Action needed / in progress (only when there is something useful to show)
|
||||
@@ -80,9 +80,8 @@ Recent activity [See all]
|
||||
|
||||
- Each card shows currency code/name, available amount and a text status when
|
||||
restricted. Do not rely on flags to identify currencies.
|
||||
- Distinguish available, booked and reserved balances. “Available” is the default
|
||||
spendable number; disclose the balance breakdown on account detail. Backend
|
||||
balances are authoritative; a list of recent operations is not a balance ledger.
|
||||
- Show only available balances. Held/reserved/booked amounts are internal service
|
||||
details, not customer-facing concepts. Backend balances remain authoritative.
|
||||
- Do not sum different currencies directly. A future total in a reporting currency
|
||||
must be labeled estimated, with valuation currency/time and a service-provided
|
||||
conversion. It is not needed for the first interface.
|
||||
@@ -368,3 +367,43 @@ repeated submit, expired quote, zero balances, no methods, denied KYC, and logou
|
||||
This document is a design deliverable, not evidence that these screens exist or
|
||||
that accessibility/payment flows have been tested. Existing demo implementation
|
||||
and pending PRs remain unchanged by this task.
|
||||
|
||||
## Implementation simplification — user direction for #28
|
||||
|
||||
Only available balances are shown. Customer limits show used and available, with
|
||||
no held/reserved breakdown or explanatory jargon. Keep four navigation destinations
|
||||
and straightforward input → review → result flows. Demo funding/payout methods
|
||||
are explicitly simulated, not representations of live supported rails.
|
||||
|
||||
## Implemented demo scope — #28
|
||||
|
||||
Four primary destinations and three transaction journeys are implemented. The
|
||||
single demo method needs no method picker; top-up and exchange collect an amount,
|
||||
payout additionally collects sample destination text. Review shows fees, source
|
||||
debit, destination credit and rate. There are no live payment instructions,
|
||||
redirects, saved recipients, security controls or notification preferences.
|
||||
Settings implements account language and links to identity. Unsupported production
|
||||
capabilities remain future work, not decorative controls.
|
||||
|
||||
Pending top-up: money has not reached available balance. Pending outgoing: total
|
||||
amount plus fee has left available balance, and processing continues. Completed
|
||||
exchange/top-up: destination balance is credited. Failed outgoing: amount plus fee
|
||||
is available again. These explanations appear on canonical operation detail.
|
||||
Pending snapshots refresh automatically across screens; refresh failures mark data
|
||||
potentially stale. The limits service independently supplies usage and remaining.
|
||||
The customer sees aggregate consumed capacity and available capacity, not internal
|
||||
reservations.
|
||||
|
||||
Activity offers kind/status/currency filters and reference search; operation detail
|
||||
is deep-linkable while the demo session exists. Date filtering, pagination,
|
||||
receipts, persistent history, actual reset timestamps and production support await
|
||||
backend contracts. Transient drafts survive pushed settings/limits screens and
|
||||
locale changes; closing a draft requires discard confirmation. Browser reload
|
||||
resets the entire demo and is not durable recovery.
|
||||
|
||||
Measurement uses existing random journey IDs and typed quote/submission/settlement
|
||||
steps. Only service-confirmed terminal status closes the transaction outcome.
|
||||
Accepted operations are excluded from draft-abandonment on app disposal. Actual
|
||||
server events, persistent reconciliation and cross-session conversion remain
|
||||
integration gaps. No automatic click tracking, replay or financial data collection
|
||||
is introduced.
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
# 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.
|
||||
|
||||
## Limits usage correction — task #22
|
||||
|
||||
Visible limits include backend-provided committed (`usedMinor`) and reserved
|
||||
(`reservedMinor`) usage, matching Sendico's `VisibleLimitSlot`. Show both amounts,
|
||||
the current cap and authoritative remaining capacity for each period. A labeled
|
||||
theme-based indicator visualizes used plus reserved against the cap; clamp only
|
||||
the visual ratio, never rewrite the returned monetary values. No capacity is
|
||||
communicated in text when remaining is zero. Daily/monthly demo fixtures now have
|
||||
nonzero activity, retained when caps increase after approval.
|
||||
|
||||
This is a display correction within the existing KYC journey, so no additional
|
||||
analytics events or financial data collection are introduced. Tests cover mapping
|
||||
without inferring remaining, unused/exhausted/over-cap/zero-cap ratios and EN/RU
|
||||
usage display at 320px with 1.6 text scaling.
|
||||
@@ -133,11 +133,6 @@ class _AuthFormState extends State<AuthForm> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
l.earlyPreview,
|
||||
style: text.bodySmall!.copyWith(color: colors.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
_signUp ? l.signupHeading : l.loginHeading,
|
||||
@@ -310,6 +305,10 @@ class _AuthFormState extends State<AuthForm> {
|
||||
AuthFeedback(state: state, signUp: _signUp),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
TextButton(
|
||||
onPressed: () => context.go('/accounts'),
|
||||
child: Text(l.wEnter),
|
||||
),
|
||||
Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:sxchnge_theme/sxchnge_theme.dart';
|
||||
|
||||
import '../widgets/locale.dart';
|
||||
|
||||
import 'brand_panel.dart';
|
||||
import 'form.dart';
|
||||
|
||||
import '../generated/i18n/app_localizations.dart';
|
||||
|
||||
class AuthPage extends StatefulWidget {
|
||||
const AuthPage({super.key});
|
||||
|
||||
@@ -36,9 +38,12 @@ class _AuthPageState extends State<AuthPage> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Align(
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: LocaleSelector(),
|
||||
child: TextButton(
|
||||
onPressed: () => context.push('/settings'),
|
||||
child: Text(AppLocalizations.of(context)!.wSettings),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (!wide) ...[
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -70,12 +70,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get letsGo => 'Let’s go';
|
||||
|
||||
@override
|
||||
String get loginPreview =>
|
||||
'Demo login completed. No real session was created. Your credentials have not been sent or saved.';
|
||||
String get loginPreview => 'You’re signed in.';
|
||||
|
||||
@override
|
||||
String get signupPreview =>
|
||||
'Demo sign-up completed. No real account was created. Your credentials have not been sent or saved.';
|
||||
String get signupPreview => 'Your account is ready.';
|
||||
|
||||
@override
|
||||
String get alreadyMember => 'Already part of sxchnge?';
|
||||
@@ -89,10 +87,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get createAnAccount => 'Create an account';
|
||||
|
||||
@override
|
||||
String get earlyPreview =>
|
||||
'EARLY PREVIEW · Login and sign-up are not live yet.';
|
||||
|
||||
@override
|
||||
String get brandEyebrow => 'A LITTLE CHANGE. A BIG NEXT STEP.';
|
||||
|
||||
@@ -211,29 +205,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
'No email? Check your spam folder or request another below.';
|
||||
|
||||
@override
|
||||
String get verificationDemo =>
|
||||
'DEMO · No real email is sent and no account is verified.';
|
||||
|
||||
@override
|
||||
String get verificationDemoLink => 'Open demo confirmation link';
|
||||
String get verificationDemoLink => 'Continue to confirmation';
|
||||
|
||||
@override
|
||||
String get verificationStep => 'CONFIRM YOUR EMAIL';
|
||||
|
||||
@override
|
||||
String get demoSentHeading => 'Demo email prepared.';
|
||||
|
||||
@override
|
||||
String get demoSentBody =>
|
||||
'No email was sent. Use the demo confirmation below to preview the next step.';
|
||||
|
||||
@override
|
||||
String get demoVerifiedHeading => 'Demo verification\ncomplete.';
|
||||
|
||||
@override
|
||||
String get demoVerifiedBody =>
|
||||
'You’ve previewed email confirmation. No account was verified. Return to login to explore the demo.';
|
||||
|
||||
@override
|
||||
String get verificationRecipient => 'Recipient';
|
||||
|
||||
@@ -241,5 +217,545 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get verificationChangeEmail => 'Change email';
|
||||
|
||||
@override
|
||||
String get demoResend => 'Prepare demo email again';
|
||||
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 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 currently unavailable. Please try again later.';
|
||||
|
||||
@override
|
||||
String get kycBasic => 'Basic';
|
||||
|
||||
@override
|
||||
String get kycVerifiedTier => 'Verified';
|
||||
|
||||
@override
|
||||
String get kycLimits => 'Your limits';
|
||||
|
||||
@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';
|
||||
|
||||
@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 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 => 'Review scenarios';
|
||||
|
||||
@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.';
|
||||
|
||||
@override
|
||||
String get kycUsed => 'Used';
|
||||
|
||||
@override
|
||||
String get kycReserved => 'Reserved';
|
||||
|
||||
@override
|
||||
String get kycUsageHelp =>
|
||||
'Used is completed activity. Reserved is held for pending transactions. Both count toward your limit.';
|
||||
|
||||
@override
|
||||
String get kycOccupied => 'Used + reserved';
|
||||
|
||||
@override
|
||||
String get kycNoCapacity => 'No capacity available';
|
||||
|
||||
@override
|
||||
String get wAccounts => 'Accounts';
|
||||
|
||||
@override
|
||||
String get wActivity => 'Activity';
|
||||
|
||||
@override
|
||||
String get wLimits => 'Limits';
|
||||
|
||||
@override
|
||||
String get wSettings => 'Settings';
|
||||
|
||||
@override
|
||||
String get wHeadline => 'Your money.\nYour next move.';
|
||||
|
||||
@override
|
||||
String get wSubheading => 'Top up. Exchange. Go further.';
|
||||
|
||||
@override
|
||||
String get wAvailable => 'Available to you';
|
||||
|
||||
@override
|
||||
String get wTopUp => 'Top up';
|
||||
|
||||
@override
|
||||
String get wExchange => 'Exchange';
|
||||
|
||||
@override
|
||||
String get wPayout => 'Pay out';
|
||||
|
||||
@override
|
||||
String get wRecent => 'Recent activity';
|
||||
|
||||
@override
|
||||
String get wSeeAll => 'See all';
|
||||
|
||||
@override
|
||||
String get wEmptyActivity =>
|
||||
'Your next move starts here. Your transactions will appear below.';
|
||||
|
||||
@override
|
||||
String get wEmptyAccounts =>
|
||||
'Your accounts are not ready yet. Try refreshing.';
|
||||
|
||||
@override
|
||||
String get wRefresh => 'Refresh';
|
||||
|
||||
@override
|
||||
String get wSource => 'From account';
|
||||
|
||||
@override
|
||||
String get wDestination => 'To account';
|
||||
|
||||
@override
|
||||
String get wReceiveAccount => 'Receiving account';
|
||||
|
||||
@override
|
||||
String get wAmount => 'Amount';
|
||||
|
||||
@override
|
||||
String get wRecipient => 'Recipient';
|
||||
|
||||
@override
|
||||
String get wRecipientHint => 'Recipient name or reference';
|
||||
|
||||
@override
|
||||
String get wReview => 'Review';
|
||||
|
||||
@override
|
||||
String get wReviewTitle => 'One last look.';
|
||||
|
||||
@override
|
||||
String get wFee => 'Fee';
|
||||
|
||||
@override
|
||||
String get wTotalDebit => 'Leaves your account';
|
||||
|
||||
@override
|
||||
String get wCredit => 'Arrives in your account';
|
||||
|
||||
@override
|
||||
String get wRecipientGets => 'Recipient receives';
|
||||
|
||||
@override
|
||||
String get wYouPay => 'You pay';
|
||||
|
||||
@override
|
||||
String get wRate => 'Exchange rate';
|
||||
|
||||
@override
|
||||
String get wQuoteNotice =>
|
||||
'These terms are valid for 60 seconds. Confirm them before continuing.';
|
||||
|
||||
@override
|
||||
String get wExpired =>
|
||||
'This quote expired. Refresh it and review the new terms.';
|
||||
|
||||
@override
|
||||
String get wNewQuote => 'Refresh quote';
|
||||
|
||||
@override
|
||||
String get wConfirm => 'Confirm transaction';
|
||||
|
||||
@override
|
||||
String get wEdit => 'Edit details';
|
||||
|
||||
@override
|
||||
String get wClose => 'Close';
|
||||
|
||||
@override
|
||||
String get wWorking => 'Working on it…';
|
||||
|
||||
@override
|
||||
String get wPending => 'Processing';
|
||||
|
||||
@override
|
||||
String get wCompleted => 'Completed';
|
||||
|
||||
@override
|
||||
String get wFailed => 'Not completed';
|
||||
|
||||
@override
|
||||
String get wPendingTopUp =>
|
||||
'Your top-up is processing. Your available balance has not changed yet.';
|
||||
|
||||
@override
|
||||
String get wPendingExchange =>
|
||||
'The amount and fee have left your available balance. The exchanged amount will appear in the receiving account when completed.';
|
||||
|
||||
@override
|
||||
String get wPendingPayout =>
|
||||
'The amount and fee have left your available balance. The payout is still processing.';
|
||||
|
||||
@override
|
||||
String get wCompleteTopUp =>
|
||||
'Your account has been credited. The money is available to use.';
|
||||
|
||||
@override
|
||||
String get wCompleteExchange =>
|
||||
'Your exchange is complete. The money is available in the receiving account.';
|
||||
|
||||
@override
|
||||
String get wCompletePayout =>
|
||||
'Your payout is complete. The amount and fee have been deducted from your account.';
|
||||
|
||||
@override
|
||||
String get wFailedTopUp =>
|
||||
'The top-up did not complete. No money was added to your account.';
|
||||
|
||||
@override
|
||||
String get wFailedOutgoing =>
|
||||
'The transaction did not complete. The amount and fee are available in your account again.';
|
||||
|
||||
@override
|
||||
String get wUnavailable =>
|
||||
'Could not refresh. The displayed information may be out of date. Try again.';
|
||||
|
||||
@override
|
||||
String get wInvalidAmount =>
|
||||
'Enter an amount greater than zero, using the currency’s decimal precision.';
|
||||
|
||||
@override
|
||||
String get wInsufficient =>
|
||||
'Not enough available money for this amount and fee. Top up or enter a smaller amount.';
|
||||
|
||||
@override
|
||||
String get wLimitError =>
|
||||
'This amount exceeds your available limit. Check your limits before continuing.';
|
||||
|
||||
@override
|
||||
String get wRecipientError =>
|
||||
'Enter a sample recipient between 6 and 140 characters.';
|
||||
|
||||
@override
|
||||
String get wUnsupported =>
|
||||
'This combination is unavailable. Choose another account.';
|
||||
|
||||
@override
|
||||
String get wNotFound =>
|
||||
'This item could not be found. Return to your accounts.';
|
||||
|
||||
@override
|
||||
String get wUncertain =>
|
||||
'We could not confirm the outcome. Check it before trying another transaction.';
|
||||
|
||||
@override
|
||||
String get wCheckOutcome => 'Check outcome';
|
||||
|
||||
@override
|
||||
String get wNoSubmission =>
|
||||
'No transaction was found. You can review and confirm again.';
|
||||
|
||||
@override
|
||||
String get wDiscardTitle => 'Leave this transaction?';
|
||||
|
||||
@override
|
||||
String get wDiscardBody =>
|
||||
'Your entered details will be cleared. No new transaction will be submitted.';
|
||||
|
||||
@override
|
||||
String get wStay => 'Keep editing';
|
||||
|
||||
@override
|
||||
String get wAll => 'All';
|
||||
|
||||
@override
|
||||
String get wSearch => 'Search by reference';
|
||||
|
||||
@override
|
||||
String get wNoMatches => 'No matching transactions. Try another filter.';
|
||||
|
||||
@override
|
||||
String get wClearFilters => 'Clear filters';
|
||||
|
||||
@override
|
||||
String get wReference => 'Reference';
|
||||
|
||||
@override
|
||||
String get wWhen => 'Created';
|
||||
|
||||
@override
|
||||
String get wViewAccount => 'View account';
|
||||
|
||||
@override
|
||||
String get wPreferences => 'Make it yours.';
|
||||
|
||||
@override
|
||||
String get wLanguage => 'Account language';
|
||||
|
||||
@override
|
||||
String get wLanguageHelp => 'Used throughout your account.';
|
||||
|
||||
@override
|
||||
String get wLanguageSaved => 'Saved to your account.';
|
||||
|
||||
@override
|
||||
String get wLanguageFailed =>
|
||||
'Changed for this session, but not saved to your account. Retry below.';
|
||||
|
||||
@override
|
||||
String get wProfileError =>
|
||||
'Account preferences could not be loaded. Try again.';
|
||||
|
||||
@override
|
||||
String get wSecurity => 'Security';
|
||||
|
||||
@override
|
||||
String get wSecurityBody =>
|
||||
'Password and security settings are not available yet.';
|
||||
|
||||
@override
|
||||
String get wIdentity => 'Identity verification';
|
||||
|
||||
@override
|
||||
String get wIdentityHelp => 'Optional verification for higher limits.';
|
||||
|
||||
@override
|
||||
String get wBackLogin => 'Back to login';
|
||||
|
||||
@override
|
||||
String get wEnter => 'Explore your accounts';
|
||||
|
||||
@override
|
||||
String get wUsage => 'Used this period';
|
||||
|
||||
@override
|
||||
String get wLimitsScope => 'Outgoing activity · EUR equivalent';
|
||||
|
||||
@override
|
||||
String get wPendingHint =>
|
||||
'Refresh to check the latest status. You can keep using your other accounts.';
|
||||
|
||||
@override
|
||||
String get wFundsHeading => 'Where is my money?';
|
||||
|
||||
@override
|
||||
String get wAnother => 'Make another transaction';
|
||||
}
|
||||
@@ -70,12 +70,10 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get letsGo => 'Продолжить';
|
||||
|
||||
@override
|
||||
String get loginPreview =>
|
||||
'Демонстрационный вход выполнен. Реальная сессия не создана. Учётные данные не отправлены и не сохранены.';
|
||||
String get loginPreview => 'Вы вошли в аккаунт.';
|
||||
|
||||
@override
|
||||
String get signupPreview =>
|
||||
'Демонстрационная регистрация выполнена. Реальный аккаунт не создан. Учётные данные не отправлены и не сохранены.';
|
||||
String get signupPreview => 'Ваш аккаунт готов.';
|
||||
|
||||
@override
|
||||
String get alreadyMember => 'Уже есть аккаунт sxchnge?';
|
||||
@@ -89,10 +87,6 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get createAnAccount => 'Зарегистрироваться';
|
||||
|
||||
@override
|
||||
String get earlyPreview =>
|
||||
'ПРЕДВАРИТЕЛЬНАЯ ВЕРСИЯ · Вход и регистрация пока недоступны.';
|
||||
|
||||
@override
|
||||
String get brandEyebrow => 'МАЛЕНЬКАЯ ПЕРЕМЕНА. БОЛЬШОЙ ШАГ.';
|
||||
|
||||
@@ -212,29 +206,11 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
'Нет письма? Проверьте папку «Спам» или запросите новое ниже.';
|
||||
|
||||
@override
|
||||
String get verificationDemo =>
|
||||
'ДЕМО · Письмо не отправляется, реальный аккаунт не подтверждается.';
|
||||
|
||||
@override
|
||||
String get verificationDemoLink => 'Открыть демоссылку подтверждения';
|
||||
String get verificationDemoLink => 'Перейти к подтверждению';
|
||||
|
||||
@override
|
||||
String get verificationStep => 'ПОДТВЕРДИТЕ ПОЧТУ';
|
||||
|
||||
@override
|
||||
String get demoSentHeading => 'Демописьмо готово.';
|
||||
|
||||
@override
|
||||
String get demoSentBody =>
|
||||
'Письмо не отправлено. Используйте демоссылку ниже, чтобы посмотреть следующий шаг.';
|
||||
|
||||
@override
|
||||
String get demoVerifiedHeading => 'Демоподтверждение\nзавершено.';
|
||||
|
||||
@override
|
||||
String get demoVerifiedBody =>
|
||||
'Вы посмотрели подтверждение почты. Реальный аккаунт не подтверждён. Вернитесь ко входу, чтобы продолжить знакомство с демоверсией.';
|
||||
|
||||
@override
|
||||
String get verificationRecipient => 'Получатель';
|
||||
|
||||
@@ -242,5 +218,541 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get verificationChangeEmail => 'Изменить почту';
|
||||
|
||||
@override
|
||||
String get demoResend => 'Подготовить демописьмо ещё раз';
|
||||
String get kycTitle => 'Верификация и лимиты';
|
||||
|
||||
@override
|
||||
String get kycIntro =>
|
||||
'Ваш аккаунт — ваш темп. Подтвердите личность, когда понадобятся более высокие лимиты.';
|
||||
|
||||
@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 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 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 =>
|
||||
'Не удалось обновить статус. Показан последний известный результат.';
|
||||
|
||||
@override
|
||||
String get kycUsed => 'Использовано';
|
||||
|
||||
@override
|
||||
String get kycReserved => 'Зарезервировано';
|
||||
|
||||
@override
|
||||
String get kycUsageHelp =>
|
||||
'Использовано — завершённые операции. Зарезервировано — суммы, удержанные для ожидающих операций. Обе суммы учитываются в лимите.';
|
||||
|
||||
@override
|
||||
String get kycOccupied => 'Использовано + зарезервировано';
|
||||
|
||||
@override
|
||||
String get kycNoCapacity => 'Доступный объём исчерпан';
|
||||
|
||||
@override
|
||||
String get wAccounts => 'Счета';
|
||||
|
||||
@override
|
||||
String get wActivity => 'История';
|
||||
|
||||
@override
|
||||
String get wLimits => 'Лимиты';
|
||||
|
||||
@override
|
||||
String get wSettings => 'Настройки';
|
||||
|
||||
@override
|
||||
String get wHeadline => 'Ваши деньги.\nВаш следующий шаг.';
|
||||
|
||||
@override
|
||||
String get wSubheading => 'Пополняйте. Обменивайте. Двигайтесь дальше.';
|
||||
|
||||
@override
|
||||
String get wAvailable => 'Вам доступно';
|
||||
|
||||
@override
|
||||
String get wTopUp => 'Пополнить';
|
||||
|
||||
@override
|
||||
String get wExchange => 'Обменять';
|
||||
|
||||
@override
|
||||
String get wPayout => 'Вывести';
|
||||
|
||||
@override
|
||||
String get wRecent => 'Последние операции';
|
||||
|
||||
@override
|
||||
String get wSeeAll => 'Смотреть все';
|
||||
|
||||
@override
|
||||
String get wEmptyActivity =>
|
||||
'Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.';
|
||||
|
||||
@override
|
||||
String get wEmptyAccounts =>
|
||||
'Ваши счета ещё не готовы. Попробуйте обновить страницу.';
|
||||
|
||||
@override
|
||||
String get wRefresh => 'Обновить';
|
||||
|
||||
@override
|
||||
String get wSource => 'Со счёта';
|
||||
|
||||
@override
|
||||
String get wDestination => 'На счёт';
|
||||
|
||||
@override
|
||||
String get wReceiveAccount => 'Счёт для зачисления';
|
||||
|
||||
@override
|
||||
String get wAmount => 'Сумма';
|
||||
|
||||
@override
|
||||
String get wRecipient => 'Получатель';
|
||||
|
||||
@override
|
||||
String get wRecipientHint => 'Имя или реквизиты получателя';
|
||||
|
||||
@override
|
||||
String get wReview => 'Проверить';
|
||||
|
||||
@override
|
||||
String get wReviewTitle => 'Проверьте детали.';
|
||||
|
||||
@override
|
||||
String get wFee => 'Комиссия';
|
||||
|
||||
@override
|
||||
String get wTotalDebit => 'Спишется со счёта';
|
||||
|
||||
@override
|
||||
String get wCredit => 'Поступит на счёт';
|
||||
|
||||
@override
|
||||
String get wRecipientGets => 'Получатель получит';
|
||||
|
||||
@override
|
||||
String get wYouPay => 'Вы платите';
|
||||
|
||||
@override
|
||||
String get wRate => 'Курс обмена';
|
||||
|
||||
@override
|
||||
String get wQuoteNotice =>
|
||||
'Эти условия действуют 60 секунд. Подтвердите их, прежде чем продолжить.';
|
||||
|
||||
@override
|
||||
String get wExpired =>
|
||||
'Срок действия расчёта истёк. Обновите его и проверьте новые условия.';
|
||||
|
||||
@override
|
||||
String get wNewQuote => 'Обновить расчёт';
|
||||
|
||||
@override
|
||||
String get wConfirm => 'Подтвердить операцию';
|
||||
|
||||
@override
|
||||
String get wEdit => 'Изменить данные';
|
||||
|
||||
@override
|
||||
String get wClose => 'Закрыть';
|
||||
|
||||
@override
|
||||
String get wWorking => 'Обрабатываем…';
|
||||
|
||||
@override
|
||||
String get wPending => 'В обработке';
|
||||
|
||||
@override
|
||||
String get wCompleted => 'Завершено';
|
||||
|
||||
@override
|
||||
String get wFailed => 'Не выполнено';
|
||||
|
||||
@override
|
||||
String get wPendingTopUp =>
|
||||
'Пополнение обрабатывается. Доступный баланс пока не изменился.';
|
||||
|
||||
@override
|
||||
String get wPendingExchange =>
|
||||
'Сумма и комиссия уже вычтены из доступного баланса. После завершения обмена деньги появятся на счёте зачисления.';
|
||||
|
||||
@override
|
||||
String get wPendingPayout =>
|
||||
'Сумма и комиссия уже вычтены из доступного баланса. Вывод ещё обрабатывается.';
|
||||
|
||||
@override
|
||||
String get wCompleteTopUp => 'Счёт пополнен. Деньги доступны для операций.';
|
||||
|
||||
@override
|
||||
String get wCompleteExchange =>
|
||||
'Обмен завершён. Деньги доступны на счёте зачисления.';
|
||||
|
||||
@override
|
||||
String get wCompletePayout =>
|
||||
'Вывод завершён. Сумма и комиссия списаны с вашего счёта.';
|
||||
|
||||
@override
|
||||
String get wFailedTopUp =>
|
||||
'Пополнение не выполнено. На счёт ничего не зачислено.';
|
||||
|
||||
@override
|
||||
String get wFailedOutgoing =>
|
||||
'Операция не выполнена. Сумма и комиссия снова доступны на вашем счёте.';
|
||||
|
||||
@override
|
||||
String get wUnavailable =>
|
||||
'Не удалось обновить данные. Информация может быть устаревшей. Повторите попытку.';
|
||||
|
||||
@override
|
||||
String get wInvalidAmount =>
|
||||
'Введите сумму больше нуля с допустимым для валюты числом знаков после запятой.';
|
||||
|
||||
@override
|
||||
String get wInsufficient =>
|
||||
'Для этой суммы и комиссии недостаточно доступных средств. Пополните счёт или уменьшите сумму.';
|
||||
|
||||
@override
|
||||
String get wLimitError =>
|
||||
'Сумма превышает доступный лимит. Проверьте лимиты перед продолжением.';
|
||||
|
||||
@override
|
||||
String get wRecipientError =>
|
||||
'Введите тестового получателя длиной от 6 до 140 символов.';
|
||||
|
||||
@override
|
||||
String get wUnsupported => 'Эта комбинация недоступна. Выберите другой счёт.';
|
||||
|
||||
@override
|
||||
String get wNotFound => 'Не удалось найти запись. Вернитесь к счетам.';
|
||||
|
||||
@override
|
||||
String get wUncertain =>
|
||||
'Не удалось подтвердить результат. Проверьте его, прежде чем создавать другую операцию.';
|
||||
|
||||
@override
|
||||
String get wCheckOutcome => 'Проверить результат';
|
||||
|
||||
@override
|
||||
String get wNoSubmission =>
|
||||
'Операция не найдена. Вы можете снова проверить и подтвердить её.';
|
||||
|
||||
@override
|
||||
String get wDiscardTitle => 'Выйти из операции?';
|
||||
|
||||
@override
|
||||
String get wDiscardBody =>
|
||||
'Введённые данные будут удалены. Новая операция не будет отправлена.';
|
||||
|
||||
@override
|
||||
String get wStay => 'Продолжить';
|
||||
|
||||
@override
|
||||
String get wAll => 'Все';
|
||||
|
||||
@override
|
||||
String get wSearch => 'Поиск по номеру';
|
||||
|
||||
@override
|
||||
String get wNoMatches => 'Операции не найдены. Измените фильтр.';
|
||||
|
||||
@override
|
||||
String get wClearFilters => 'Сбросить фильтры';
|
||||
|
||||
@override
|
||||
String get wReference => 'Номер операции';
|
||||
|
||||
@override
|
||||
String get wWhen => 'Создано';
|
||||
|
||||
@override
|
||||
String get wViewAccount => 'Открыть счёт';
|
||||
|
||||
@override
|
||||
String get wPreferences => 'Настройте под себя.';
|
||||
|
||||
@override
|
||||
String get wLanguage => 'Язык аккаунта';
|
||||
|
||||
@override
|
||||
String get wLanguageHelp => 'Используется во всём аккаунте.';
|
||||
|
||||
@override
|
||||
String get wLanguageSaved => 'Сохранено в аккаунте.';
|
||||
|
||||
@override
|
||||
String get wLanguageFailed =>
|
||||
'Изменено для текущей сессии, но не сохранено в аккаунте. Повторите попытку ниже.';
|
||||
|
||||
@override
|
||||
String get wProfileError =>
|
||||
'Не удалось загрузить настройки аккаунта. Повторите попытку.';
|
||||
|
||||
@override
|
||||
String get wSecurity => 'Безопасность';
|
||||
|
||||
@override
|
||||
String get wSecurityBody =>
|
||||
'Настройки пароля и безопасности пока недоступны.';
|
||||
|
||||
@override
|
||||
String get wIdentity => 'Подтверждение личности';
|
||||
|
||||
@override
|
||||
String get wIdentityHelp => 'Необязательная проверка для повышения лимитов.';
|
||||
|
||||
@override
|
||||
String get wBackLogin => 'Вернуться ко входу';
|
||||
|
||||
@override
|
||||
String get wEnter => 'Открыть свои счета';
|
||||
|
||||
@override
|
||||
String get wUsage => 'Использовано за период';
|
||||
|
||||
@override
|
||||
String get wLimitsScope => 'Исходящие операции · эквивалент в EUR';
|
||||
|
||||
@override
|
||||
String get wPendingHint =>
|
||||
'Обновите, чтобы узнать текущий статус. Остальными счетами можно пользоваться.';
|
||||
|
||||
@override
|
||||
String get wFundsHeading => 'Где мои деньги?';
|
||||
|
||||
@override
|
||||
String get wAnother => 'Создать новую операцию';
|
||||
}
|
||||
@@ -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(l.kycSubmit),
|
||||
),
|
||||
if (p.step > 0)
|
||||
TextButton(onPressed: p.busy ? null : p.back, child: Text(l.kycBack)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
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,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
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.wUsage,
|
||||
row.usedMinor + row.reservedMinor,
|
||||
row.currency,
|
||||
),
|
||||
_amount(
|
||||
context,
|
||||
l.kycRemaining,
|
||||
row.remainingMinor,
|
||||
row.currency,
|
||||
),
|
||||
if (data.tier == AccountTier.basic)
|
||||
_amount(
|
||||
context,
|
||||
l.kycAfter,
|
||||
row.verifiedMinor,
|
||||
row.currency,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
LinearProgressIndicator(
|
||||
key: ValueKey('usage-${row.period.name}'),
|
||||
value: row.occupiedFraction,
|
||||
minHeight: 8,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
semanticsLabel:
|
||||
'${row.period == LimitPeriod.day ? l.kycDay : l.kycMonth}: ${l.wUsage}',
|
||||
),
|
||||
if (row.remainingMinor <= 0) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l.kycNoCapacity,
|
||||
style: TextStyle(color: t.colorScheme.error),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,223 @@
|
||||
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 '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) {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go('/limits');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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 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: 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.wLimits),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+165
-10
@@ -20,13 +20,12 @@
|
||||
"hideConfirmation": "Hide confirmation",
|
||||
"createAccount": "Create account",
|
||||
"letsGo": "Let’s go",
|
||||
"loginPreview": "Demo login completed. No real session was created. Your credentials have not been sent or saved.",
|
||||
"signupPreview": "Demo sign-up completed. No real account was created. Your credentials have not been sent or saved.",
|
||||
"loginPreview": "You’re signed in.",
|
||||
"signupPreview": "Your account is ready.",
|
||||
"alreadyMember": "Already part of sxchnge?",
|
||||
"newHere": "New around here?",
|
||||
"loginInstead": "Log in instead",
|
||||
"createAnAccount": "Create an account",
|
||||
"earlyPreview": "EARLY PREVIEW · Login and sign-up are not live yet.",
|
||||
"brandEyebrow": "A LITTLE CHANGE. A BIG NEXT STEP.",
|
||||
"brandHeading": "Good moves.\nStart here.",
|
||||
"brandIntro": "A fresh way forward.\nMake it yours with sxchnge.",
|
||||
@@ -63,14 +62,170 @@
|
||||
"verificationRetry": "Retry verification",
|
||||
"verificationBack": "Back to login",
|
||||
"verificationHint": "No email? Check your spam folder or request another below.",
|
||||
"verificationDemo": "DEMO · No real email is sent and no account is verified.",
|
||||
"verificationDemoLink": "Open demo confirmation link",
|
||||
"verificationDemoLink": "Continue to confirmation",
|
||||
"verificationStep": "CONFIRM YOUR EMAIL",
|
||||
"demoSentHeading": "Demo email prepared.",
|
||||
"demoSentBody": "No email was sent. Use the demo confirmation below to preview the next step.",
|
||||
"demoVerifiedHeading": "Demo verification\ncomplete.",
|
||||
"demoVerifiedBody": "You’ve previewed email confirmation. No account was verified. Return to login to explore the demo.",
|
||||
"verificationRecipient": "Recipient",
|
||||
"verificationChangeEmail": "Change email",
|
||||
"demoResend": "Prepare demo email again"
|
||||
"kycTitle": "Verification & limits",
|
||||
"kycIntro": "Your account, at your pace. Verify your identity when you need higher limits.",
|
||||
"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 currently unavailable. Please try again later.",
|
||||
"kycBasic": "Basic",
|
||||
"kycVerifiedTier": "Verified",
|
||||
"kycLimits": "Your limits",
|
||||
"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",
|
||||
"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",
|
||||
"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": "Review scenarios",
|
||||
"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.",
|
||||
"kycUsed": "Used",
|
||||
"kycReserved": "Reserved",
|
||||
"kycUsageHelp": "Used is completed activity. Reserved is held for pending transactions. Both count toward your limit.",
|
||||
"kycOccupied": "Used + reserved",
|
||||
"kycNoCapacity": "No capacity available",
|
||||
"wAccounts": "Accounts",
|
||||
"wActivity": "Activity",
|
||||
"wLimits": "Limits",
|
||||
"wSettings": "Settings",
|
||||
"wHeadline": "Your money.\nYour next move.",
|
||||
"wSubheading": "Top up. Exchange. Go further.",
|
||||
"wAvailable": "Available to you",
|
||||
"wTopUp": "Top up",
|
||||
"wExchange": "Exchange",
|
||||
"wPayout": "Pay out",
|
||||
"wRecent": "Recent activity",
|
||||
"wSeeAll": "See all",
|
||||
"wEmptyActivity": "Your next move starts here. Your transactions will appear below.",
|
||||
"wEmptyAccounts": "Your accounts are not ready yet. Try refreshing.",
|
||||
"wRefresh": "Refresh",
|
||||
"wSource": "From account",
|
||||
"wDestination": "To account",
|
||||
"wReceiveAccount": "Receiving account",
|
||||
"wAmount": "Amount",
|
||||
"wRecipient": "Recipient",
|
||||
"wRecipientHint": "Recipient name or reference",
|
||||
"wReview": "Review",
|
||||
"wReviewTitle": "One last look.",
|
||||
"wFee": "Fee",
|
||||
"wTotalDebit": "Leaves your account",
|
||||
"wCredit": "Arrives in your account",
|
||||
"wRecipientGets": "Recipient receives",
|
||||
"wYouPay": "You pay",
|
||||
"wRate": "Exchange rate",
|
||||
"wQuoteNotice": "These terms are valid for 60 seconds. Confirm them before continuing.",
|
||||
"wExpired": "This quote expired. Refresh it and review the new terms.",
|
||||
"wNewQuote": "Refresh quote",
|
||||
"wConfirm": "Confirm transaction",
|
||||
"wEdit": "Edit details",
|
||||
"wClose": "Close",
|
||||
"wWorking": "Working on it…",
|
||||
"wPending": "Processing",
|
||||
"wCompleted": "Completed",
|
||||
"wFailed": "Not completed",
|
||||
"wPendingTopUp": "Your top-up is processing. Your available balance has not changed yet.",
|
||||
"wPendingExchange": "The amount and fee have left your available balance. The exchanged amount will appear in the receiving account when completed.",
|
||||
"wPendingPayout": "The amount and fee have left your available balance. The payout is still processing.",
|
||||
"wCompleteTopUp": "Your account has been credited. The money is available to use.",
|
||||
"wCompleteExchange": "Your exchange is complete. The money is available in the receiving account.",
|
||||
"wCompletePayout": "Your payout is complete. The amount and fee have been deducted from your account.",
|
||||
"wFailedTopUp": "The top-up did not complete. No money was added to your account.",
|
||||
"wFailedOutgoing": "The transaction did not complete. The amount and fee are available in your account again.",
|
||||
"wUnavailable": "Could not refresh. The displayed information may be out of date. Try again.",
|
||||
"wInvalidAmount": "Enter an amount greater than zero, using the currency’s decimal precision.",
|
||||
"wInsufficient": "Not enough available money for this amount and fee. Top up or enter a smaller amount.",
|
||||
"wLimitError": "This amount exceeds your available limit. Check your limits before continuing.",
|
||||
"wRecipientError": "Enter a sample recipient between 6 and 140 characters.",
|
||||
"wUnsupported": "This combination is unavailable. Choose another account.",
|
||||
"wNotFound": "This item could not be found. Return to your accounts.",
|
||||
"wUncertain": "We could not confirm the outcome. Check it before trying another transaction.",
|
||||
"wCheckOutcome": "Check outcome",
|
||||
"wNoSubmission": "No transaction was found. You can review and confirm again.",
|
||||
"wDiscardTitle": "Leave this transaction?",
|
||||
"wDiscardBody": "Your entered details will be cleared. No new transaction will be submitted.",
|
||||
"wStay": "Keep editing",
|
||||
"wAll": "All",
|
||||
"wSearch": "Search by reference",
|
||||
"wNoMatches": "No matching transactions. Try another filter.",
|
||||
"wClearFilters": "Clear filters",
|
||||
"wReference": "Reference",
|
||||
"wWhen": "Created",
|
||||
"wViewAccount": "View account",
|
||||
"wPreferences": "Make it yours.",
|
||||
"wLanguage": "Account language",
|
||||
"wLanguageHelp": "Used throughout your account.",
|
||||
"wLanguageSaved": "Saved to your account.",
|
||||
"wLanguageFailed": "Changed for this session, but not saved to your account. Retry below.",
|
||||
"wProfileError": "Account preferences could not be loaded. Try again.",
|
||||
"wSecurity": "Security",
|
||||
"wSecurityBody": "Password and security settings are not available yet.",
|
||||
"wIdentity": "Identity verification",
|
||||
"wIdentityHelp": "Optional verification for higher limits.",
|
||||
"wBackLogin": "Back to login",
|
||||
"wEnter": "Explore your accounts",
|
||||
"wUsage": "Used this period",
|
||||
"wLimitsScope": "Outgoing activity · EUR equivalent",
|
||||
"wPendingHint": "Refresh to check the latest status. You can keep using your other accounts.",
|
||||
"wFundsHeading": "Where is my money?",
|
||||
"wAnother": "Make another transaction"
|
||||
}
|
||||
+165
-10
@@ -20,13 +20,12 @@
|
||||
"hideConfirmation": "Скрыть повтор пароля",
|
||||
"createAccount": "Создать аккаунт",
|
||||
"letsGo": "Продолжить",
|
||||
"loginPreview": "Демонстрационный вход выполнен. Реальная сессия не создана. Учётные данные не отправлены и не сохранены.",
|
||||
"signupPreview": "Демонстрационная регистрация выполнена. Реальный аккаунт не создан. Учётные данные не отправлены и не сохранены.",
|
||||
"loginPreview": "Вы вошли в аккаунт.",
|
||||
"signupPreview": "Ваш аккаунт готов.",
|
||||
"alreadyMember": "Уже есть аккаунт sxchnge?",
|
||||
"newHere": "Впервые здесь?",
|
||||
"loginInstead": "Войти в аккаунт",
|
||||
"createAnAccount": "Зарегистрироваться",
|
||||
"earlyPreview": "ПРЕДВАРИТЕЛЬНАЯ ВЕРСИЯ · Вход и регистрация пока недоступны.",
|
||||
"brandEyebrow": "МАЛЕНЬКАЯ ПЕРЕМЕНА. БОЛЬШОЙ ШАГ.",
|
||||
"brandHeading": "Всё начинается\nс перемен.",
|
||||
"brandIntro": "Новый путь вперёд.\nОткройте его вместе со sxchnge.",
|
||||
@@ -63,14 +62,170 @@
|
||||
"verificationRetry": "Повторить подтверждение",
|
||||
"verificationBack": "Вернуться ко входу",
|
||||
"verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.",
|
||||
"verificationDemo": "ДЕМО · Письмо не отправляется, реальный аккаунт не подтверждается.",
|
||||
"verificationDemoLink": "Открыть демоссылку подтверждения",
|
||||
"verificationDemoLink": "Перейти к подтверждению",
|
||||
"verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ",
|
||||
"demoSentHeading": "Демописьмо готово.",
|
||||
"demoSentBody": "Письмо не отправлено. Используйте демоссылку ниже, чтобы посмотреть следующий шаг.",
|
||||
"demoVerifiedHeading": "Демоподтверждение\nзавершено.",
|
||||
"demoVerifiedBody": "Вы посмотрели подтверждение почты. Реальный аккаунт не подтверждён. Вернитесь ко входу, чтобы продолжить знакомство с демоверсией.",
|
||||
"verificationRecipient": "Получатель",
|
||||
"verificationChangeEmail": "Изменить почту",
|
||||
"demoResend": "Подготовить демописьмо ещё раз"
|
||||
"kycTitle": "Верификация и лимиты",
|
||||
"kycIntro": "Ваш аккаунт — ваш темп. Подтвердите личность, когда понадобятся более высокие лимиты.",
|
||||
"kycUnverified": "Личность не подтверждена",
|
||||
"kycPending": "На проверке",
|
||||
"kycApproved": "Личность подтверждена",
|
||||
"kycChanges": "Нужны новые фото",
|
||||
"kycDenied": "Верификация отклонена",
|
||||
"kycUnverifiedBody": "Вы можете пользоваться аккаунтом с базовыми лимитами. Для повышения отправьте фото паспорта и селфи с ним.",
|
||||
"kycPendingBody": "Заявка ожидает проверки сотрудником. До одобрения действуют базовые лимиты. Повторная отправка не нужна.",
|
||||
"kycApprovedBody": "Проверка завершена. Актуальные лимиты указаны ниже.",
|
||||
"kycChangesBody": "Аккаунт доступен с базовыми лимитами. Замените фото и отправьте новую заявку.",
|
||||
"kycDeniedBody": "Базовые лимиты остаются доступными. Если вы не согласны с решением, обратитесь в поддержку для пересмотра.",
|
||||
"kycPassportReason": "Данные паспорта не удалось прочитать. Переснимите при хорошем освещении, чтобы все края были видны.",
|
||||
"kycSelfieReason": "На селфи плохо видно лицо или паспорт. Оба должны быть в кадре и в фокусе.",
|
||||
"kycExpiredReason": "Срок действия паспорта истёк. Используйте действующий паспорт и сделайте оба фото заново.",
|
||||
"kycDeniedReason": "По этой заявке не удалось подтвердить вашу личность.",
|
||||
"kycStart": "Подтвердить личность",
|
||||
"kycResubmit": "Заменить фото",
|
||||
"kycRefresh": "Обновить статус и лимиты",
|
||||
"kycSupport": "Обратиться в поддержку",
|
||||
"kycSupportBody": "Поддержка сейчас недоступна. Попробуйте позже.",
|
||||
"kycBasic": "Базовый",
|
||||
"kycVerifiedTier": "Подтверждённый",
|
||||
"kycLimits": "Ваши лимиты",
|
||||
"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": "Отправить на проверку",
|
||||
"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": "Не удалось обновить статус. Показан последний известный результат.",
|
||||
"kycUsed": "Использовано",
|
||||
"kycReserved": "Зарезервировано",
|
||||
"kycUsageHelp": "Использовано — завершённые операции. Зарезервировано — суммы, удержанные для ожидающих операций. Обе суммы учитываются в лимите.",
|
||||
"kycOccupied": "Использовано + зарезервировано",
|
||||
"kycNoCapacity": "Доступный объём исчерпан",
|
||||
"wAccounts": "Счета",
|
||||
"wActivity": "История",
|
||||
"wLimits": "Лимиты",
|
||||
"wSettings": "Настройки",
|
||||
"wHeadline": "Ваши деньги.\nВаш следующий шаг.",
|
||||
"wSubheading": "Пополняйте. Обменивайте. Двигайтесь дальше.",
|
||||
"wAvailable": "Вам доступно",
|
||||
"wTopUp": "Пополнить",
|
||||
"wExchange": "Обменять",
|
||||
"wPayout": "Вывести",
|
||||
"wRecent": "Последние операции",
|
||||
"wSeeAll": "Смотреть все",
|
||||
"wEmptyActivity": "Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.",
|
||||
"wEmptyAccounts": "Ваши счета ещё не готовы. Попробуйте обновить страницу.",
|
||||
"wRefresh": "Обновить",
|
||||
"wSource": "Со счёта",
|
||||
"wDestination": "На счёт",
|
||||
"wReceiveAccount": "Счёт для зачисления",
|
||||
"wAmount": "Сумма",
|
||||
"wRecipient": "Получатель",
|
||||
"wRecipientHint": "Имя или реквизиты получателя",
|
||||
"wReview": "Проверить",
|
||||
"wReviewTitle": "Проверьте детали.",
|
||||
"wFee": "Комиссия",
|
||||
"wTotalDebit": "Спишется со счёта",
|
||||
"wCredit": "Поступит на счёт",
|
||||
"wRecipientGets": "Получатель получит",
|
||||
"wYouPay": "Вы платите",
|
||||
"wRate": "Курс обмена",
|
||||
"wQuoteNotice": "Эти условия действуют 60 секунд. Подтвердите их, прежде чем продолжить.",
|
||||
"wExpired": "Срок действия расчёта истёк. Обновите его и проверьте новые условия.",
|
||||
"wNewQuote": "Обновить расчёт",
|
||||
"wConfirm": "Подтвердить операцию",
|
||||
"wEdit": "Изменить данные",
|
||||
"wClose": "Закрыть",
|
||||
"wWorking": "Обрабатываем…",
|
||||
"wPending": "В обработке",
|
||||
"wCompleted": "Завершено",
|
||||
"wFailed": "Не выполнено",
|
||||
"wPendingTopUp": "Пополнение обрабатывается. Доступный баланс пока не изменился.",
|
||||
"wPendingExchange": "Сумма и комиссия уже вычтены из доступного баланса. После завершения обмена деньги появятся на счёте зачисления.",
|
||||
"wPendingPayout": "Сумма и комиссия уже вычтены из доступного баланса. Вывод ещё обрабатывается.",
|
||||
"wCompleteTopUp": "Счёт пополнен. Деньги доступны для операций.",
|
||||
"wCompleteExchange": "Обмен завершён. Деньги доступны на счёте зачисления.",
|
||||
"wCompletePayout": "Вывод завершён. Сумма и комиссия списаны с вашего счёта.",
|
||||
"wFailedTopUp": "Пополнение не выполнено. На счёт ничего не зачислено.",
|
||||
"wFailedOutgoing": "Операция не выполнена. Сумма и комиссия снова доступны на вашем счёте.",
|
||||
"wUnavailable": "Не удалось обновить данные. Информация может быть устаревшей. Повторите попытку.",
|
||||
"wInvalidAmount": "Введите сумму больше нуля с допустимым для валюты числом знаков после запятой.",
|
||||
"wInsufficient": "Для этой суммы и комиссии недостаточно доступных средств. Пополните счёт или уменьшите сумму.",
|
||||
"wLimitError": "Сумма превышает доступный лимит. Проверьте лимиты перед продолжением.",
|
||||
"wRecipientError": "Введите тестового получателя длиной от 6 до 140 символов.",
|
||||
"wUnsupported": "Эта комбинация недоступна. Выберите другой счёт.",
|
||||
"wNotFound": "Не удалось найти запись. Вернитесь к счетам.",
|
||||
"wUncertain": "Не удалось подтвердить результат. Проверьте его, прежде чем создавать другую операцию.",
|
||||
"wCheckOutcome": "Проверить результат",
|
||||
"wNoSubmission": "Операция не найдена. Вы можете снова проверить и подтвердить её.",
|
||||
"wDiscardTitle": "Выйти из операции?",
|
||||
"wDiscardBody": "Введённые данные будут удалены. Новая операция не будет отправлена.",
|
||||
"wStay": "Продолжить",
|
||||
"wAll": "Все",
|
||||
"wSearch": "Поиск по номеру",
|
||||
"wNoMatches": "Операции не найдены. Измените фильтр.",
|
||||
"wClearFilters": "Сбросить фильтры",
|
||||
"wReference": "Номер операции",
|
||||
"wWhen": "Создано",
|
||||
"wViewAccount": "Открыть счёт",
|
||||
"wPreferences": "Настройте под себя.",
|
||||
"wLanguage": "Язык аккаунта",
|
||||
"wLanguageHelp": "Используется во всём аккаунте.",
|
||||
"wLanguageSaved": "Сохранено в аккаунте.",
|
||||
"wLanguageFailed": "Изменено для текущей сессии, но не сохранено в аккаунте. Повторите попытку ниже.",
|
||||
"wProfileError": "Не удалось загрузить настройки аккаунта. Повторите попытку.",
|
||||
"wSecurity": "Безопасность",
|
||||
"wSecurityBody": "Настройки пароля и безопасности пока недоступны.",
|
||||
"wIdentity": "Подтверждение личности",
|
||||
"wIdentityHelp": "Необязательная проверка для повышения лимитов.",
|
||||
"wBackLogin": "Вернуться ко входу",
|
||||
"wEnter": "Открыть свои счета",
|
||||
"wUsage": "Использовано за период",
|
||||
"wLimitsScope": "Исходящие операции · эквивалент в EUR",
|
||||
"wPendingHint": "Обновите, чтобы узнать текущий статус. Остальными счетами можно пользоваться.",
|
||||
"wFundsHeading": "Где мои деньги?",
|
||||
"wAnother": "Создать новую операцию"
|
||||
}
|
||||
@@ -6,8 +6,12 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
import 'package:sxchnge_theme/sxchnge_theme.dart';
|
||||
|
||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
|
||||
import 'providers/locale.dart';
|
||||
import 'routing/router.dart';
|
||||
|
||||
@@ -24,12 +28,20 @@ class SxchngeApp extends StatefulWidget {
|
||||
super.key,
|
||||
this.localeProvider,
|
||||
this.analyticsService,
|
||||
this.identityService,
|
||||
this.limitsService,
|
||||
this.walletService,
|
||||
this.photoService = const FilePhotoService(),
|
||||
this.loginService = const DummyLoginService(),
|
||||
this.signupService = const DummySignupService(),
|
||||
this.verificationService = const DummyVerificationService(),
|
||||
});
|
||||
final LocaleProvider? localeProvider;
|
||||
final AnalyticsService? analyticsService;
|
||||
final IdentityService? identityService;
|
||||
final LimitsService? limitsService;
|
||||
final WalletService? walletService;
|
||||
final PhotoService photoService;
|
||||
final LoginService loginService;
|
||||
final SignupService signupService;
|
||||
final VerificationService verificationService;
|
||||
@@ -41,9 +53,18 @@ class SxchngeApp extends StatefulWidget {
|
||||
class _SxchngeAppState extends State<SxchngeApp> {
|
||||
late final _locale = widget.localeProvider ?? LocaleProvider();
|
||||
|
||||
late final _journeys = JourneyRecorder(
|
||||
widget.analyticsService ?? DummyAnalyticsService(),
|
||||
);
|
||||
late final _analytics = widget.analyticsService ?? DummyAnalyticsService();
|
||||
late final _identity = widget.identityService ?? DummyIdentityService();
|
||||
late final _walletService =
|
||||
widget.walletService ??
|
||||
DummyWalletService(_identity, initialLocale: _locale.locale.languageCode);
|
||||
late final _wallet = WalletProvider(_walletService, _analytics);
|
||||
late final _limits =
|
||||
widget.limitsService ??
|
||||
(_walletService is DummyWalletService
|
||||
? WalletLimitsService(_walletService)
|
||||
: DummyLimitsService(_identity));
|
||||
late final _journeys = JourneyRecorder(_analytics);
|
||||
late final _services = InstrumentedAuthServices(
|
||||
recorder: _journeys,
|
||||
loginService: widget.loginService,
|
||||
@@ -73,6 +94,7 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
||||
_router.routeInformationProvider.removeListener(_routeChanged);
|
||||
_router.dispose();
|
||||
_journeys.dispose();
|
||||
_wallet.dispose();
|
||||
_locale.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -80,7 +102,12 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
||||
@override
|
||||
Widget build(BuildContext context) => MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider.value(value: _wallet),
|
||||
Provider<JourneyRecorder>.value(value: _journeys),
|
||||
Provider<AnalyticsService>.value(value: _analytics),
|
||||
Provider<IdentityService>.value(value: _identity),
|
||||
Provider<LimitsService>.value(value: _limits),
|
||||
Provider<PhotoService>.value(value: widget.photoService),
|
||||
ChangeNotifierProvider.value(value: _locale),
|
||||
Provider<VerificationService>.value(value: _services),
|
||||
ChangeNotifierProvider(create: (_) => LoginProvider(_services)),
|
||||
|
||||
@@ -2,12 +2,15 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
|
||||
class LocaleProvider extends ChangeNotifier {
|
||||
static const storageKey = 'sxchnge_last_locale';
|
||||
LocaleProvider({Locale initialLocale = const Locale('en'), this.preferences})
|
||||
: _locale = resolve(initialLocale.languageCode, const []);
|
||||
|
||||
Locale _locale;
|
||||
bool _disposed = false;
|
||||
final SharedPreferences? preferences;
|
||||
Future<bool> _pendingSave = Future.value(true);
|
||||
Locale get locale => _locale;
|
||||
@@ -31,6 +34,22 @@ class LocaleProvider extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadAccount(WalletProvider wallet) async {
|
||||
await wallet.loadProfile();
|
||||
if (_disposed) return;
|
||||
if (wallet.preferredLocale != null && !wallet.profileFailed) {
|
||||
_locale = resolve(wallet.preferredLocale, const []);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveAccountLocale(WalletProvider wallet, Locale value) async {
|
||||
if (_disposed || wallet.saving) return;
|
||||
_locale = resolve(value.languageCode, const []);
|
||||
notifyListeners();
|
||||
await wallet.saveLocale(_locale.languageCode);
|
||||
}
|
||||
|
||||
Future<bool> setLocale(Locale value) {
|
||||
final next = resolve(value.languageCode, const []);
|
||||
if (next == _locale) return _pendingSave;
|
||||
@@ -47,4 +66,10 @@ class LocaleProvider extends ChangeNotifier {
|
||||
});
|
||||
return _pendingSave;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,128 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
|
||||
import '../auth/page.dart';
|
||||
import '../kyc/page.dart';
|
||||
import '../verification/page.dart';
|
||||
import '../workspace/accounts.dart';
|
||||
import '../workspace/activity.dart';
|
||||
import '../workspace/limits.dart';
|
||||
import '../workspace/operation.dart';
|
||||
import '../workspace/settings.dart';
|
||||
import '../workspace/shell.dart';
|
||||
import '../workspace/transfer.dart';
|
||||
import 'routes.dart';
|
||||
|
||||
GoRouter createAppRouter() => GoRouter(
|
||||
// Signup email is an in-memory navigation extra, never browser-history data.
|
||||
extraCodec: const _TransientExtras(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
name: AppRoutes.login,
|
||||
builder: (context, state) => const AuthPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/verification-sent',
|
||||
name: AppRoutes.verificationSent,
|
||||
builder: (context, state) => VerificationPage(
|
||||
email: state.extra is String ? state.extra as String : null,
|
||||
GoRouter createAppRouter() {
|
||||
final topUpKey = GlobalKey<TransferPageState>();
|
||||
final exchangeKey = GlobalKey<TransferPageState>();
|
||||
final payoutKey = GlobalKey<TransferPageState>();
|
||||
final identityKey = GlobalKey<IdentityPageState>();
|
||||
return GoRouter(
|
||||
// Signup email is an in-memory navigation extra, never browser-history data.
|
||||
extraCodec: const _TransientExtras(),
|
||||
routes: [
|
||||
ShellRoute(
|
||||
builder: (context, state, child) =>
|
||||
WorkspaceShell(path: state.uri.path, child: child),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/accounts',
|
||||
builder: (context, state) => const AccountsPage(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: ':accountId',
|
||||
builder: (context, state) =>
|
||||
AccountsPage(accountId: state.pathParameters['accountId']),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: '/activity',
|
||||
builder: (context, state) => const ActivityPage(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: ':operationId',
|
||||
builder: (context, state) =>
|
||||
OperationPage(id: state.pathParameters['operationId']!),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: '/limits',
|
||||
builder: (context, state) => const LimitsPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
builder: (context, state) => const SettingsPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/top-up',
|
||||
builder: (context, state) => TransferPage(
|
||||
key: topUpKey,
|
||||
kind: TransferKind.topUp,
|
||||
source: state.extra is String ? state.extra as String : null,
|
||||
),
|
||||
onExit: (context, state) async =>
|
||||
await topUpKey.currentState?.allowExit() ?? true,
|
||||
),
|
||||
GoRoute(
|
||||
path: '/exchange',
|
||||
builder: (context, state) => TransferPage(
|
||||
key: exchangeKey,
|
||||
kind: TransferKind.exchange,
|
||||
source: state.extra is String ? state.extra as String : null,
|
||||
),
|
||||
onExit: (context, state) async =>
|
||||
await exchangeKey.currentState?.allowExit() ?? true,
|
||||
),
|
||||
GoRoute(
|
||||
path: '/payout',
|
||||
builder: (context, state) => TransferPage(
|
||||
key: payoutKey,
|
||||
kind: TransferKind.payout,
|
||||
source: state.extra is String ? state.extra as String : null,
|
||||
),
|
||||
onExit: (context, state) async =>
|
||||
await payoutKey.currentState?.allowExit() ?? true,
|
||||
),
|
||||
GoRoute(
|
||||
path: '/identity',
|
||||
name: AppRoutes.identity,
|
||||
builder: (context, state) => IdentityPage(key: identityKey),
|
||||
onExit: (context, state) async =>
|
||||
await identityKey.currentState?.allowExit() ?? true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/verify-email',
|
||||
name: AppRoutes.verifyEmail,
|
||||
builder: (context, state) =>
|
||||
VerificationPage(token: state.uri.queryParameters['token'] ?? ''),
|
||||
),
|
||||
],
|
||||
// Preserve the existing fallback, and keep the visible URL canonical.
|
||||
onException: (context, state, router) => router.goNamed(AppRoutes.login),
|
||||
);
|
||||
GoRoute(
|
||||
path: '/',
|
||||
name: AppRoutes.login,
|
||||
builder: (context, state) => const AuthPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/verification-sent',
|
||||
name: AppRoutes.verificationSent,
|
||||
builder: (context, state) => VerificationPage(
|
||||
email: state.extra is String ? state.extra as String : null,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/verify-email',
|
||||
name: AppRoutes.verifyEmail,
|
||||
builder: (context, state) =>
|
||||
VerificationPage(token: state.uri.queryParameters['token'] ?? ''),
|
||||
),
|
||||
],
|
||||
// Preserve the existing fallback, and keep the visible URL canonical.
|
||||
onException: (context, state, router) => router.goNamed(AppRoutes.login),
|
||||
);
|
||||
}
|
||||
|
||||
class _TransientExtras extends Codec<Object?, Object?> {
|
||||
const _TransientExtras();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
abstract final class AppRoutes {
|
||||
static const identity = 'identity';
|
||||
static const login = 'login';
|
||||
static const verificationSent = 'verification-sent';
|
||||
static const verifyEmail = 'verify-email';
|
||||
|
||||
@@ -11,7 +11,6 @@ import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
import '../auth/brand_panel.dart';
|
||||
import '../auth/validation.dart';
|
||||
import '../routing/routes.dart';
|
||||
import '../widgets/locale.dart';
|
||||
|
||||
import '../generated/i18n/app_localizations.dart';
|
||||
|
||||
@@ -128,11 +127,10 @@ class _VerificationPageState extends State<VerificationPage> {
|
||||
!_lastWasSend &&
|
||||
widget.token != null;
|
||||
final title = switch (status) {
|
||||
VerificationStatus.sent => demo ? l.demoSentHeading : l.checkInbox,
|
||||
VerificationStatus.sent => l.checkInbox,
|
||||
VerificationStatus.sending => l.verificationSending,
|
||||
VerificationStatus.checking => l.verificationChecking,
|
||||
VerificationStatus.verified =>
|
||||
demo ? l.demoVerifiedHeading : l.verificationVerified,
|
||||
VerificationStatus.verified => l.verificationVerified,
|
||||
VerificationStatus.expired => l.verificationExpired,
|
||||
VerificationStatus.invalid => l.verificationInvalid,
|
||||
VerificationStatus.error => l.verificationInternal,
|
||||
@@ -142,9 +140,8 @@ class _VerificationPageState extends State<VerificationPage> {
|
||||
: l.verificationRequest,
|
||||
};
|
||||
final body = switch (status) {
|
||||
VerificationStatus.sent => demo ? l.demoSentBody : l.verificationSent,
|
||||
VerificationStatus.verified =>
|
||||
demo ? l.demoVerifiedBody : l.verificationVerifiedBody,
|
||||
VerificationStatus.sent => l.verificationSent,
|
||||
VerificationStatus.verified => l.verificationVerifiedBody,
|
||||
VerificationStatus.expired => l.verificationExpiredBody,
|
||||
VerificationStatus.invalid => l.verificationInvalidBody,
|
||||
VerificationStatus.error => l.verificationInternalBody,
|
||||
@@ -160,10 +157,6 @@ class _VerificationPageState extends State<VerificationPage> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: LocaleSelector(),
|
||||
),
|
||||
const Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: BrandWordmark(),
|
||||
@@ -205,13 +198,6 @@ class _VerificationPageState extends State<VerificationPage> {
|
||||
color: colors.primary,
|
||||
),
|
||||
),
|
||||
if (demo) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l.verificationDemo,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Semantics(
|
||||
liveRegion: true,
|
||||
@@ -263,7 +249,7 @@ class _VerificationPageState extends State<VerificationPage> {
|
||||
key: const ValueKey('verification-resend'),
|
||||
onPressed: _send,
|
||||
child: Text(
|
||||
demo ? l.demoResend : l.verificationResend,
|
||||
l.verificationResend,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
@@ -325,6 +311,13 @@ class _VerificationPageState extends State<VerificationPage> {
|
||||
child: Text(l.verificationRequest),
|
||||
),
|
||||
],
|
||||
if (verified) ...[
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: () => context.go('/accounts'),
|
||||
child: Text(l.wEnter),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: () => context.goNamed(AppRoutes.login),
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../providers/locale.dart';
|
||||
|
||||
import '../generated/i18n/app_localizations.dart';
|
||||
|
||||
class LocaleSelector extends StatelessWidget {
|
||||
const LocaleSelector({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = context.watch<LocaleProvider>();
|
||||
return PopupMenuButton<Locale>(
|
||||
key: const ValueKey('locale-selector'),
|
||||
tooltip: AppLocalizations.of(context)!.language,
|
||||
onSelected: (locale) async {
|
||||
final saved = await provider.setLocale(locale);
|
||||
if (!saved && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.preferenceNotSaved),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
itemBuilder: (_) => AppLocalizations.supportedLocales
|
||||
.map(
|
||||
(locale) => CheckedPopupMenuItem(
|
||||
value: locale,
|
||||
checked: locale == provider.locale,
|
||||
child: Text(locale.languageCode == 'ru' ? 'Русский' : 'English'),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.language, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
provider.locale.languageCode.toUpperCase(),
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
const Icon(Icons.expand_more, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
|
||||
import 'activity.dart';
|
||||
import 'copy.dart';
|
||||
|
||||
import '../generated/i18n/app_localizations.dart';
|
||||
|
||||
class AccountsPage extends StatelessWidget {
|
||||
const AccountsPage({super.key, this.accountId});
|
||||
final String? accountId;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.watch<WalletProvider>();
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final t = Theme.of(context);
|
||||
final c = t.colorScheme;
|
||||
final accounts = p.data?.accounts ?? [];
|
||||
final selected = accountId == null
|
||||
? accounts.firstOrNull
|
||||
: p.account(accountId);
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1120),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(l.wHeadline, style: t.textTheme.displayMedium),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l.wRefresh,
|
||||
onPressed: p.loading ? null : p.refresh,
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(l.wSubheading, style: t.textTheme.bodyLarge),
|
||||
const SizedBox(height: 28),
|
||||
if (p.loading) const LinearProgressIndicator(),
|
||||
if (p.failed)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Text(l.wUnavailable, style: TextStyle(color: c.error)),
|
||||
),
|
||||
if (!p.loading && accounts.isEmpty) Text(l.wEmptyAccounts),
|
||||
if (accountId != null &&
|
||||
p.account(accountId) == null &&
|
||||
!p.loading)
|
||||
Text(l.wNotFound),
|
||||
if (selected != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
Text(selected.currency, style: t.textTheme.titleLarge),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
for (final kind in TransferKind.values)
|
||||
_action(
|
||||
context,
|
||||
kind,
|
||||
selected,
|
||||
primary:
|
||||
kind ==
|
||||
(selected.available == 0
|
||||
? TransferKind.topUp
|
||||
: TransferKind.exchange),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
LayoutBuilder(
|
||||
builder: (context, box) {
|
||||
final width = box.maxWidth >= 660
|
||||
? (box.maxWidth - 32) / 3
|
||||
: box.maxWidth;
|
||||
return Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
children: [
|
||||
for (var i = 0; i < accounts.length; i++)
|
||||
SizedBox(
|
||||
width: width,
|
||||
child: _AccountCard(
|
||||
account: accounts[i],
|
||||
selected: accounts[i].id == selected?.id,
|
||||
index: i,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 36),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l.wRecent, style: t.textTheme.headlineSmall),
|
||||
TextButton(
|
||||
onPressed: () => context.go('/activity'),
|
||||
child: Text(l.wSeeAll),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...?p.data?.operations
|
||||
.where(
|
||||
(o) =>
|
||||
accountId == null ||
|
||||
o.quote.request.source == accountId ||
|
||||
o.quote.request.destination == accountId,
|
||||
)
|
||||
.take(5)
|
||||
.map((o) => OperationTile(operation: o)),
|
||||
if (!(p.data?.operations.any(
|
||||
(o) =>
|
||||
accountId == null ||
|
||||
o.quote.request.source == accountId ||
|
||||
o.quote.request.destination == accountId,
|
||||
) ??
|
||||
false))
|
||||
Container(
|
||||
padding: const EdgeInsets.all(28),
|
||||
decoration: BoxDecoration(
|
||||
color: c.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Text(l.wEmptyActivity),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _action(
|
||||
BuildContext context,
|
||||
TransferKind kind,
|
||||
CurrencyAccount a, {
|
||||
required bool primary,
|
||||
}) {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
void go() => context.go(
|
||||
'/${kind.name == "topUp" ? "top-up" : kind.name}',
|
||||
extra: a.id,
|
||||
);
|
||||
return primary
|
||||
? FilledButton.icon(
|
||||
key: ValueKey('${kind.name}-${a.id}'),
|
||||
onPressed: go,
|
||||
icon: Icon(kindIcon(kind)),
|
||||
label: Text(kindText(l, kind)),
|
||||
)
|
||||
: FilledButton.tonalIcon(
|
||||
key: ValueKey('${kind.name}-${a.id}'),
|
||||
onPressed: go,
|
||||
icon: Icon(kindIcon(kind)),
|
||||
label: Text(kindText(l, kind)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AccountCard extends StatelessWidget {
|
||||
const _AccountCard({
|
||||
required this.account,
|
||||
required this.selected,
|
||||
required this.index,
|
||||
});
|
||||
final CurrencyAccount account;
|
||||
final bool selected;
|
||||
final int index;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final t = Theme.of(context);
|
||||
final c = t.colorScheme;
|
||||
final bg = selected
|
||||
? c.primary
|
||||
: index == 1
|
||||
? c.tertiaryContainer
|
||||
: c.primaryContainer;
|
||||
final fg = selected
|
||||
? c.onPrimary
|
||||
: index == 1
|
||||
? c.onTertiaryContainer
|
||||
: c.onPrimaryContainer;
|
||||
return Semantics(
|
||||
selected: selected,
|
||||
child: Material(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
child: InkWell(
|
||||
key: ValueKey('account-${account.id}'),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
onTap: () => context.go('/accounts/${account.id}'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
account.currency,
|
||||
style: t.textTheme.titleLarge?.copyWith(color: fg),
|
||||
),
|
||||
const Spacer(),
|
||||
Icon(
|
||||
selected
|
||||
? Icons.check_circle_outline
|
||||
: Icons.arrow_outward_rounded,
|
||||
color: fg,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(l.wAvailable, style: TextStyle(color: fg)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
money(context, account, account.available),
|
||||
key: ValueKey('balance-${account.id}'),
|
||||
style: t.textTheme.headlineMedium?.copyWith(color: fg),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
|
||||
import 'copy.dart';
|
||||
|
||||
import '../generated/i18n/app_localizations.dart';
|
||||
|
||||
class ActivityPage extends StatefulWidget {
|
||||
const ActivityPage({super.key});
|
||||
@override
|
||||
State<ActivityPage> createState() => _ActivityPageState();
|
||||
}
|
||||
|
||||
class _ActivityPageState extends State<ActivityPage> {
|
||||
TransferKind? _kind;
|
||||
OperationStatus? _status;
|
||||
String _search = '';
|
||||
String? _currency;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.watch<WalletProvider>();
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final list =
|
||||
p.data?.operations
|
||||
.where(
|
||||
(o) =>
|
||||
(_kind == null || o.quote.request.kind == _kind) &&
|
||||
(_status == null || o.status == _status) &&
|
||||
(_currency == null ||
|
||||
o.quote.request.source == _currency ||
|
||||
o.quote.request.destination == _currency) &&
|
||||
o.id.toLowerCase().contains(_search.toLowerCase()),
|
||||
)
|
||||
.toList() ??
|
||||
[];
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1000),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
l.wActivity,
|
||||
style: Theme.of(context).textTheme.displaySmall,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l.wRefresh,
|
||||
onPressed: p.loading ? null : p.refresh,
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: l.wSearch,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
),
|
||||
onChanged: (value) => setState(() => _search = value),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: Text(l.wAll),
|
||||
selected: _kind == null,
|
||||
onSelected: (_) => setState(() => _kind = null),
|
||||
),
|
||||
for (final k in TransferKind.values)
|
||||
ChoiceChip(
|
||||
label: Text(kindText(l, k)),
|
||||
selected: _kind == k,
|
||||
onSelected: (_) => setState(() => _kind = k),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final s in OperationStatus.values)
|
||||
FilterChip(
|
||||
label: Text(statusText(l, s)),
|
||||
selected: _status == s,
|
||||
onSelected: (value) =>
|
||||
setState(() => _status = value ? s : null),
|
||||
),
|
||||
for (final a in p.data?.accounts ?? <CurrencyAccount>[])
|
||||
FilterChip(
|
||||
label: Text(a.currency),
|
||||
selected: _currency == a.id,
|
||||
onSelected: (value) =>
|
||||
setState(() => _currency = value ? a.id : null),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (p.loading) const LinearProgressIndicator(),
|
||||
if (p.failed) Text(l.wUnavailable),
|
||||
if (list.isEmpty && !p.loading)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
(p.data?.operations.isEmpty ?? true)
|
||||
? l.wEmptyActivity
|
||||
: l.wNoMatches,
|
||||
),
|
||||
),
|
||||
for (final o in list) OperationTile(operation: o),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OperationTile extends StatelessWidget {
|
||||
const OperationTile({super.key, required this.operation});
|
||||
final WalletOperation operation;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.read<WalletProvider>();
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final o = operation;
|
||||
final a = p.account(o.quote.request.source);
|
||||
final to = p.account(o.quote.request.destination);
|
||||
final t = Theme.of(context);
|
||||
return Card(
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: () => context.push('/activity/${o.id}'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: t.colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
kindIcon(o.quote.request.kind),
|
||||
color: t.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
kindText(l, o.quote.request.kind),
|
||||
style: t.textTheme.titleMedium,
|
||||
),
|
||||
Text(
|
||||
'${a?.currency ?? ''}${to == null ? '' : ' → ${to.currency}'} · ${statusText(l, o.status)}',
|
||||
),
|
||||
Text(
|
||||
DateFormat.yMMMd(
|
||||
Localizations.localeOf(context).toLanguageTag(),
|
||||
).add_Hm().format(o.createdAt),
|
||||
style: t.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
a == null ? '' : money(context, a, o.quote.request.amount),
|
||||
textAlign: TextAlign.end,
|
||||
style: t.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
|
||||
import '../generated/i18n/app_localizations.dart';
|
||||
|
||||
String money(BuildContext context, CurrencyAccount account, int amount) =>
|
||||
NumberFormat.currency(
|
||||
locale: Localizations.localeOf(context).toLanguageTag(),
|
||||
name: account.currency,
|
||||
symbol: '${account.currency} ',
|
||||
decimalDigits: account.precision,
|
||||
).format(amount / account.scale);
|
||||
String kindText(AppLocalizations l, TransferKind kind) => switch (kind) {
|
||||
TransferKind.topUp => l.wTopUp,
|
||||
TransferKind.exchange => l.wExchange,
|
||||
TransferKind.payout => l.wPayout,
|
||||
};
|
||||
IconData kindIcon(TransferKind kind) => switch (kind) {
|
||||
TransferKind.topUp => Icons.add_rounded,
|
||||
TransferKind.exchange => Icons.swap_horiz_rounded,
|
||||
TransferKind.payout => Icons.north_east_rounded,
|
||||
};
|
||||
String statusText(AppLocalizations l, OperationStatus s) => switch (s) {
|
||||
OperationStatus.pending => l.wPending,
|
||||
OperationStatus.completed => l.wCompleted,
|
||||
OperationStatus.failed => l.wFailed,
|
||||
};
|
||||
String fundsText(AppLocalizations l, WalletOperation o) =>
|
||||
switch ((o.status, o.quote.request.kind)) {
|
||||
(OperationStatus.pending, TransferKind.topUp) => l.wPendingTopUp,
|
||||
(OperationStatus.pending, TransferKind.exchange) => l.wPendingExchange,
|
||||
(OperationStatus.pending, TransferKind.payout) => l.wPendingPayout,
|
||||
(OperationStatus.completed, TransferKind.topUp) => l.wCompleteTopUp,
|
||||
(OperationStatus.completed, TransferKind.exchange) => l.wCompleteExchange,
|
||||
(OperationStatus.completed, TransferKind.payout) => l.wCompletePayout,
|
||||
(OperationStatus.failed, TransferKind.topUp) => l.wFailedTopUp,
|
||||
_ => l.wFailedOutgoing,
|
||||
};
|
||||
String walletError(AppLocalizations l, WalletError e) => switch (e) {
|
||||
WalletError.invalidAmount => l.wInvalidAmount,
|
||||
WalletError.insufficientFunds => l.wInsufficient,
|
||||
WalletError.limitExceeded => l.wLimitError,
|
||||
WalletError.expiredQuote => l.wExpired,
|
||||
WalletError.invalidRecipient => l.wRecipientError,
|
||||
WalletError.unsupported => l.wUnsupported,
|
||||
WalletError.notFound => l.wNotFound,
|
||||
WalletError.uncertain => l.wUncertain,
|
||||
_ => l.wUnavailable,
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
|
||||
import '../kyc/limits.dart';
|
||||
|
||||
import '../generated/i18n/app_localizations.dart';
|
||||
|
||||
class LimitsPage extends StatefulWidget {
|
||||
const LimitsPage({super.key});
|
||||
@override
|
||||
State<LimitsPage> createState() => _LimitsPageState();
|
||||
}
|
||||
|
||||
class _LimitsPageState extends State<LimitsPage> {
|
||||
late final _provider = WorkspaceLimitsProvider(
|
||||
context.read<LimitsService>(),
|
||||
context.read<WalletProvider>(),
|
||||
);
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _provider.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_provider.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
return ChangeNotifierProvider<LimitsProvider>.value(
|
||||
value: _provider,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 720),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
l.wLimits,
|
||||
style: Theme.of(context).textTheme.displaySmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(l.wLimitsScope),
|
||||
const SizedBox(height: 24),
|
||||
const LimitsPanel(),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
await context.push('/identity');
|
||||
if (mounted) _provider.refresh();
|
||||
},
|
||||
child: Text(l.wIdentity),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _provider.refresh,
|
||||
child: Text(l.wRefresh),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
|
||||
import 'copy.dart';
|
||||
import 'summary.dart';
|
||||
|
||||
import '../generated/i18n/app_localizations.dart';
|
||||
|
||||
class OperationPage extends StatefulWidget {
|
||||
const OperationPage({super.key, required this.id});
|
||||
final String id;
|
||||
@override
|
||||
State<OperationPage> createState() => _OperationPageState();
|
||||
}
|
||||
|
||||
class _OperationPageState extends State<OperationPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) context.read<WalletProvider>().refresh();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.watch<WalletProvider>();
|
||||
final o = p.operation(widget.id);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final t = Theme.of(context);
|
||||
final c = t.colorScheme;
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 640),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => context.go('/activity'),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
label: Text(l.wActivity),
|
||||
),
|
||||
),
|
||||
if (p.failed)
|
||||
Text(l.wUnavailable, style: TextStyle(color: c.error)),
|
||||
if (o == null) ...[
|
||||
Text(p.loading ? l.wWorking : l.wNotFound),
|
||||
TextButton(
|
||||
onPressed: p.loading ? null : p.refresh,
|
||||
child: Text(l.wRefresh),
|
||||
),
|
||||
] else ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(28),
|
||||
decoration: BoxDecoration(
|
||||
color: o.status == OperationStatus.failed
|
||||
? c.errorContainer
|
||||
: c.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
o.status == OperationStatus.completed
|
||||
? Icons.check_circle_outline
|
||||
: o.status == OperationStatus.pending
|
||||
? Icons.schedule
|
||||
: Icons.info_outline,
|
||||
size: 40,
|
||||
color: o.status == OperationStatus.failed
|
||||
? c.onErrorContainer
|
||||
: c.onPrimaryContainer,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Semantics(
|
||||
header: true,
|
||||
liveRegion: true,
|
||||
child: Text(
|
||||
statusText(l, o.status),
|
||||
key: ValueKey('operation-${o.status.name}'),
|
||||
style: t.textTheme.displaySmall?.copyWith(
|
||||
color: o.status == OperationStatus.failed
|
||||
? c.onErrorContainer
|
||||
: c.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
fundsText(l, o),
|
||||
style: TextStyle(
|
||||
color: o.status == OperationStatus.failed
|
||||
? c.onErrorContainer
|
||||
: c.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
TransferSummary(quote: o.quote),
|
||||
const SizedBox(height: 16),
|
||||
Text('${l.wReference}: ${o.id}'),
|
||||
if (o.status == OperationStatus.pending) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(l.wPendingHint),
|
||||
TextButton(
|
||||
onPressed: p.loading ? null : p.refresh,
|
||||
child: Text(l.wRefresh),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
if (o.status == OperationStatus.completed &&
|
||||
o.quote.request.kind != TransferKind.payout)
|
||||
FilledButton(
|
||||
onPressed: () => context.go(
|
||||
o.quote.request.kind == TransferKind.topUp
|
||||
? '/exchange'
|
||||
: '/payout',
|
||||
extra:
|
||||
o.quote.request.destination ?? o.quote.request.source,
|
||||
),
|
||||
child: Text(
|
||||
o.quote.request.kind == TransferKind.topUp
|
||||
? l.wExchange
|
||||
: l.wPayout,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.go(
|
||||
'/accounts/${o.quote.request.destination ?? o.quote.request.source}',
|
||||
),
|
||||
child: Text(l.wViewAccount),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
|
||||
import '../providers/locale.dart';
|
||||
|
||||
import '../generated/i18n/app_localizations.dart';
|
||||
|
||||
class SettingsPage extends StatelessWidget {
|
||||
const SettingsPage({super.key});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.watch<WalletProvider>();
|
||||
final locale = context.watch<LocaleProvider>();
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final t = Theme.of(context);
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 720),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (context.canPop())
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => context.pop(),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
label: Text(l.kycBack),
|
||||
),
|
||||
),
|
||||
Text(l.wPreferences, style: t.textTheme.displayMedium),
|
||||
const SizedBox(height: 28),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(l.wLanguage, style: t.textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
Text(l.wLanguageHelp),
|
||||
const SizedBox(height: 20),
|
||||
if (p.profileLoading) const LinearProgressIndicator(),
|
||||
if (p.profileFailed) ...[
|
||||
Text(l.wProfileError),
|
||||
TextButton(
|
||||
onPressed: () => locale.loadAccount(p),
|
||||
child: Text(l.wRefresh),
|
||||
),
|
||||
],
|
||||
DropdownButtonFormField<String>(
|
||||
key: ValueKey(
|
||||
'account-language-${locale.locale.languageCode}',
|
||||
),
|
||||
initialValue: locale.locale.languageCode,
|
||||
isExpanded: true,
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'en', child: Text('English')),
|
||||
DropdownMenuItem(value: 'ru', child: Text('Русский')),
|
||||
],
|
||||
onChanged: p.saving || p.profileLoading
|
||||
? null
|
||||
: (value) {
|
||||
if (value != null) {
|
||||
locale.saveAccountLocale(p, Locale(value));
|
||||
}
|
||||
},
|
||||
decoration: InputDecoration(labelText: l.wLanguage),
|
||||
),
|
||||
if (p.saving) const LinearProgressIndicator(),
|
||||
if (p.saveFailed) ...[
|
||||
const SizedBox(height: 12),
|
||||
Semantics(
|
||||
liveRegion: true,
|
||||
child: Text(l.wLanguageFailed),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: p.saving
|
||||
? null
|
||||
: () =>
|
||||
locale.saveAccountLocale(p, locale.locale),
|
||||
child: Text(l.wRefresh),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.all(24),
|
||||
leading: const Icon(Icons.verified_user_outlined),
|
||||
title: Text(l.wIdentity),
|
||||
subtitle: Text(l.wIdentityHelp),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => context.go('/identity'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l.wSecurity, style: t.textTheme.titleLarge),
|
||||
const SizedBox(height: 12),
|
||||
Text(l.wSecurityBody),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
TextButton(
|
||||
onPressed: () => context.go('/'),
|
||||
child: Text(l.wBackLogin),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
|
||||
import '../auth/brand_panel.dart';
|
||||
import '../providers/locale.dart';
|
||||
|
||||
import '../generated/i18n/app_localizations.dart';
|
||||
|
||||
class WorkspaceShell extends StatefulWidget {
|
||||
const WorkspaceShell({super.key, required this.child, required this.path});
|
||||
final Widget child;
|
||||
final String path;
|
||||
@override
|
||||
State<WorkspaceShell> createState() => _WorkspaceShellState();
|
||||
}
|
||||
|
||||
class _WorkspaceShellState extends State<WorkspaceShell> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final wallet = context.read<WalletProvider>();
|
||||
if (wallet.data == null) wallet.refresh();
|
||||
context.read<LocaleProvider>().loadAccount(wallet);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final t = Theme.of(context);
|
||||
final c = t.colorScheme;
|
||||
final paths = ['/accounts', '/activity', '/limits', '/settings'];
|
||||
final labels = [l.wAccounts, l.wActivity, l.wLimits, l.wSettings];
|
||||
final icons = [
|
||||
Icons.account_balance_wallet_outlined,
|
||||
Icons.receipt_long_outlined,
|
||||
Icons.speed_outlined,
|
||||
Icons.tune_rounded,
|
||||
];
|
||||
final index = widget.path.startsWith('/activity')
|
||||
? 1
|
||||
: widget.path.startsWith('/limits') ||
|
||||
widget.path.startsWith('/identity')
|
||||
? 2
|
||||
: widget.path.startsWith('/settings')
|
||||
? 3
|
||||
: 0;
|
||||
void navigate(int i) {
|
||||
if (['/top-up', '/exchange', '/payout'].contains(widget.path) && i >= 2) {
|
||||
context.push(paths[i]);
|
||||
} else {
|
||||
context.go(paths[i]);
|
||||
}
|
||||
}
|
||||
|
||||
final wide = MediaQuery.sizeOf(context).width >= 900;
|
||||
final content = Column(
|
||||
children: [
|
||||
if (!wide)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: BrandWordmark(),
|
||||
),
|
||||
),
|
||||
Expanded(child: widget.child),
|
||||
],
|
||||
);
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Row(
|
||||
children: [
|
||||
if (wide)
|
||||
Container(
|
||||
width: 220,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: c.surfaceContainerLow,
|
||||
border: Border(right: BorderSide(color: c.outlineVariant)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: BrandWordmark(),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
for (var i = 0; i < 4; i++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
selected: i == index,
|
||||
selectedTileColor: c.primaryContainer,
|
||||
leading: Icon(icons[i]),
|
||||
title: Text(labels[i]),
|
||||
onTap: () => navigate(i),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(child: content),
|
||||
],
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: wide
|
||||
? null
|
||||
: NavigationBar(
|
||||
selectedIndex: index,
|
||||
onDestinationSelected: navigate,
|
||||
destinations: [
|
||||
for (var i = 0; i < 4; i++)
|
||||
NavigationDestination(icon: Icon(icons[i]), label: labels[i]),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
|
||||
import 'copy.dart';
|
||||
|
||||
import '../generated/i18n/app_localizations.dart';
|
||||
|
||||
class TransferSummary extends StatelessWidget {
|
||||
const TransferSummary({super.key, required this.quote});
|
||||
final Quote quote;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.read<WalletProvider>();
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final q = quote;
|
||||
final a = p.account(q.request.source);
|
||||
final b = p.account(q.request.destination) ?? a;
|
||||
if (a == null || b == null) return Text(l.wNotFound);
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_row(
|
||||
context,
|
||||
q.request.kind == TransferKind.topUp ? l.wYouPay : l.wTotalDebit,
|
||||
money(context, a, q.debit),
|
||||
large: true,
|
||||
),
|
||||
const Divider(height: 32),
|
||||
_row(context, l.wFee, money(context, a, q.fee)),
|
||||
if (q.request.kind == TransferKind.exchange) ...[
|
||||
const SizedBox(height: 16),
|
||||
_row(
|
||||
context,
|
||||
l.wRate,
|
||||
'1 ${a.currency} = ${NumberFormat('0.0000', Localizations.localeOf(context).toLanguageTag()).format(q.rateNumerator / q.rateDenominator)} ${b.currency}',
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
_row(
|
||||
context,
|
||||
q.request.kind == TransferKind.payout
|
||||
? l.wRecipientGets
|
||||
: l.wCredit,
|
||||
money(context, b, q.credit),
|
||||
large: true,
|
||||
),
|
||||
if (q.request.kind == TransferKind.payout) ...[
|
||||
const Divider(height: 32),
|
||||
Text(l.wRecipient),
|
||||
SelectableText(q.request.recipient),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(
|
||||
BuildContext context,
|
||||
String label,
|
||||
String value, {
|
||||
bool large = false,
|
||||
}) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: large
|
||||
? Theme.of(context).textTheme.headlineSmall
|
||||
: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
|
||||
import 'copy.dart';
|
||||
import 'summary.dart';
|
||||
|
||||
import '../generated/i18n/app_localizations.dart';
|
||||
|
||||
class TransferPage extends StatefulWidget {
|
||||
const TransferPage({super.key, required this.kind, this.source});
|
||||
final TransferKind kind;
|
||||
final String? source;
|
||||
@override
|
||||
State<TransferPage> createState() => TransferPageState();
|
||||
}
|
||||
|
||||
class TransferPageState extends State<TransferPage> {
|
||||
late final _provider = TransferProvider(
|
||||
context.read<WalletProvider>(),
|
||||
widget.kind,
|
||||
source: widget.source,
|
||||
);
|
||||
final _amount = TextEditingController();
|
||||
final _recipient = TextEditingController();
|
||||
final _focus = FocusNode();
|
||||
final _content = GlobalKey();
|
||||
Timer? _timer;
|
||||
bool _navigated = false;
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _provider.addListener(_changed);
|
||||
});
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (mounted && _provider.quote != null) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
void _changed() {
|
||||
if (_provider.result != null && !_navigated) {
|
||||
_navigated = true;
|
||||
context.go('/activity/${_provider.result!.id}');
|
||||
return;
|
||||
}
|
||||
if (!_provider.busy &&
|
||||
(_provider.quote != null || _provider.error != null)) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _content.currentContext != null) {
|
||||
Scrollable.ensureVisible(
|
||||
_content.currentContext!,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
);
|
||||
_focus.requestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> allowExit() async {
|
||||
if (_provider.result != null) return true;
|
||||
if (_provider.busy || _provider.uncertain) return false;
|
||||
if (!_provider.hasDraft) return true;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
return await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(l.wDiscardTitle),
|
||||
content: Text(l.wDiscardBody),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(l.wStay),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(l.wClose),
|
||||
),
|
||||
],
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_provider.removeListener(_changed);
|
||||
_provider.dispose();
|
||||
_amount.dispose();
|
||||
_recipient.dispose();
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ListenableBuilder(
|
||||
listenable: _provider,
|
||||
builder: (context, _) {
|
||||
final p = _provider;
|
||||
final wallet = context.watch<WalletProvider>();
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final t = Theme.of(context);
|
||||
final accounts = wallet.data?.accounts ?? [];
|
||||
final source = wallet.account(p.source);
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 640),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
p.quote == null
|
||||
? kindText(l, widget.kind)
|
||||
: l.wReviewTitle,
|
||||
style: t.textTheme.displaySmall,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l.wClose,
|
||||
onPressed: p.busy || p.uncertain
|
||||
? null
|
||||
: () => context.go('/accounts'),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Focus(
|
||||
key: _content,
|
||||
focusNode: _focus,
|
||||
skipTraversal: true,
|
||||
includeSemantics: false,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (p.busy) ...[
|
||||
const LinearProgressIndicator(),
|
||||
const SizedBox(height: 12),
|
||||
Semantics(liveRegion: true, child: Text(l.wWorking)),
|
||||
],
|
||||
if (p.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Semantics(
|
||||
liveRegion: true,
|
||||
child: Text(
|
||||
walletError(l, p.error!),
|
||||
key: const ValueKey('transfer-error'),
|
||||
style: TextStyle(color: t.colorScheme.error),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (p.noSubmission) Text(l.wNoSubmission),
|
||||
if (p.uncertain) ...[
|
||||
Text(l.wUncertain),
|
||||
FilledButton(
|
||||
onPressed: p.busy ? null : p.reconcile,
|
||||
child: Text(l.wCheckOutcome),
|
||||
),
|
||||
] else if (p.quote != null) ...[
|
||||
TransferSummary(quote: p.quote!),
|
||||
const SizedBox(height: 16),
|
||||
Text(p.expired ? l.wExpired : l.wQuoteNotice),
|
||||
const SizedBox(height: 20),
|
||||
if (p.expired)
|
||||
FilledButton(
|
||||
onPressed: p.busy ? null : p.review,
|
||||
child: Text(l.wNewQuote),
|
||||
)
|
||||
else
|
||||
FilledButton(
|
||||
key: const ValueKey('transfer-confirm'),
|
||||
onPressed: p.busy ? null : p.submit,
|
||||
child: Text(l.wConfirm),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: p.busy ? null : p.edit,
|
||||
child: Text(l.wEdit),
|
||||
),
|
||||
] else ...[
|
||||
if (accounts.isEmpty) ...[
|
||||
Text(wallet.loading ? l.wWorking : l.wEmptyAccounts),
|
||||
TextButton(
|
||||
onPressed: wallet.loading ? null : wallet.refresh,
|
||||
child: Text(l.wRefresh),
|
||||
),
|
||||
] else ...[
|
||||
DropdownButtonFormField<String>(
|
||||
key: ValueKey('source-${p.source}'),
|
||||
initialValue: accounts.any((a) => a.id == p.source)
|
||||
? p.source
|
||||
: null,
|
||||
isExpanded: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.kind == TransferKind.topUp
|
||||
? l.wReceiveAccount
|
||||
: l.wSource,
|
||||
),
|
||||
items: accounts
|
||||
.map(
|
||||
(a) => DropdownMenuItem(
|
||||
value: a.id,
|
||||
child: Text(a.currency),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: p.busy
|
||||
? null
|
||||
: (v) => p.change(source: v),
|
||||
),
|
||||
if (source != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'${l.wAvailable}: ${money(context, source, source.available)}',
|
||||
),
|
||||
],
|
||||
if (widget.kind == TransferKind.exchange) ...[
|
||||
const SizedBox(height: 20),
|
||||
DropdownButtonFormField<String>(
|
||||
key: ValueKey('destination-${p.destination}'),
|
||||
initialValue: p.destination,
|
||||
isExpanded: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: l.wDestination,
|
||||
),
|
||||
items: accounts
|
||||
.map(
|
||||
(a) => DropdownMenuItem(
|
||||
value: a.id,
|
||||
child: Text(a.currency),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: p.busy
|
||||
? null
|
||||
: (v) => p.change(destination: v),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
TextField(
|
||||
key: const ValueKey('transfer-amount'),
|
||||
controller: _amount,
|
||||
enabled: !p.busy,
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: l.wAmount,
|
||||
suffixText: source?.currency,
|
||||
),
|
||||
onChanged: (v) => p.change(amount: v),
|
||||
),
|
||||
if (widget.kind == TransferKind.payout) ...[
|
||||
const SizedBox(height: 20),
|
||||
TextField(
|
||||
key: const ValueKey('transfer-recipient'),
|
||||
controller: _recipient,
|
||||
enabled: !p.busy,
|
||||
decoration: InputDecoration(
|
||||
labelText: l.wRecipient,
|
||||
hintText: l.wRecipientHint,
|
||||
),
|
||||
onChanged: (v) => p.change(recipient: v),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 28),
|
||||
FilledButton(
|
||||
key: const ValueKey('transfer-review'),
|
||||
onPressed: p.busy ? null : p.review,
|
||||
child: Text(l.wReview),
|
||||
),
|
||||
if (p.error == WalletError.limitExceeded)
|
||||
TextButton(
|
||||
onPressed: () => context.push('/limits'),
|
||||
child: Text(l.wLimits),
|
||||
),
|
||||
if (p.error == WalletError.insufficientFunds)
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
context.push('/top-up', extra: p.source),
|
||||
child: Text(l.wTopUp),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: archive
|
||||
sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -41,6 +49,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cross_file
|
||||
sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.5+5"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -81,6 +97,70 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
file_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector
|
||||
sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
file_selector_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_android
|
||||
sha256: d670cd0ce77a2e785b18d8b4d0a8d6a222d6a813ec9b7ddf2790a1b4fb6fa92c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.2+11"
|
||||
file_selector_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_ios
|
||||
sha256: "97269e5307a0ab813b1fa2430bada0a96e0afb74848417f8676f64ba5de0051c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.3+6"
|
||||
file_selector_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_linux
|
||||
sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.4+1"
|
||||
file_selector_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_macos
|
||||
sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.5+1"
|
||||
file_selector_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_platform_interface
|
||||
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
file_selector_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_web
|
||||
sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.5"
|
||||
file_selector_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_windows
|
||||
sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.3+6"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -117,6 +197,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "18.0.1"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.9.2"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -253,6 +357,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: posix
|
||||
sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.5.2"
|
||||
provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -368,6 +480,13 @@ packages:
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.1.0"
|
||||
sxchnge_kyc:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../packages/sxchnge_kyc"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.1.0"
|
||||
sxchnge_theme:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -375,6 +494,13 @@ packages:
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.1.0"
|
||||
sxchnge_wallet:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../packages/sxchnge_wallet"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.1.0"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -391,6 +517,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -6,6 +6,10 @@ environment:
|
||||
sdk: '>=3.13.0 <4.0.0'
|
||||
flutter: '>=3.47.0'
|
||||
dependencies:
|
||||
sxchnge_wallet:
|
||||
path: ../packages/sxchnge_wallet
|
||||
sxchnge_kyc:
|
||||
path: ../packages/sxchnge_kyc
|
||||
sxchnge_analytics:
|
||||
path: ../packages/sxchnge_analytics
|
||||
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 your accounts'), 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();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
import 'package:sxchnge_theme/sxchnge_theme.dart';
|
||||
|
||||
import 'package:sxchnge_app/kyc/limits.dart';
|
||||
|
||||
import 'package:sxchnge_app/generated/i18n/app_localizations.dart';
|
||||
|
||||
class Service implements LimitsService {
|
||||
Service(this.row);
|
||||
final VisibleLimit row;
|
||||
@override
|
||||
Future<Limits> fetch() async =>
|
||||
Limits(tier: AccountTier.basic, rows: [row], simulated: true);
|
||||
}
|
||||
|
||||
void main() {
|
||||
for (final locale in ['en', 'ru']) {
|
||||
testWidgets(
|
||||
'usage and authoritative available capacity fit narrow $locale layout',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(320, 1800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.platformDispatcher.textScaleFactorTestValue = 1.6;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||
final provider = LimitsProvider(
|
||||
Service(
|
||||
const VisibleLimit(
|
||||
period: LimitPeriod.day,
|
||||
currency: 'EUR',
|
||||
basicMinor: 10000,
|
||||
verifiedMinor: 250000,
|
||||
currentMinor: 10000,
|
||||
remainingMinor: 5500,
|
||||
usedMinor: 3000,
|
||||
reservedMinor: 1000,
|
||||
),
|
||||
),
|
||||
);
|
||||
addTearDown(provider.dispose);
|
||||
await provider.refresh();
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider.value(
|
||||
value: provider,
|
||||
child: MaterialApp(
|
||||
theme: SxchngeTheme.light(),
|
||||
locale: Locale(locale),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const Scaffold(
|
||||
body: SingleChildScrollView(child: LimitsPanel()),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.text(
|
||||
locale == 'en' ? 'Used this period' : 'Использовано за период',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.text(locale == 'en' ? 'Reserved' : 'Зарезервировано'),
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<LinearProgressIndicator>(
|
||||
find.byKey(const ValueKey('usage-day')),
|
||||
)
|
||||
.value,
|
||||
0.4,
|
||||
);
|
||||
expect(find.textContaining('55'), findsOneWidget);
|
||||
expect(tester.takeException(), null);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -83,15 +83,7 @@ void main() {
|
||||
await tester.tap(find.byKey(const ValueKey('submit')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Enter a valid email address.'), findsOneWidget);
|
||||
await tester.ensureVisible(find.byKey(const ValueKey('locale-selector')));
|
||||
await tester.tap(find.byKey(const ValueKey('locale-selector')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.ancestor(
|
||||
of: find.text('Русский'),
|
||||
matching: find.byType(CheckedPopupMenuItem<Locale>),
|
||||
),
|
||||
);
|
||||
await locale.setLocale(const Locale('ru'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Введите корректный адрес почты.'), findsOneWidget);
|
||||
expect(find.text('Алексей'), findsOneWidget);
|
||||
|
||||
@@ -129,7 +129,7 @@ void main() {
|
||||
same(router),
|
||||
);
|
||||
expect(find.byKey(const ValueKey('verification-verified')), findsOneWidget);
|
||||
expect(find.text('Демоподтверждение\nзавершено.'), findsOneWidget);
|
||||
expect(find.text('Ваша почта\nподтверждена.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
|
||||
@@ -111,7 +111,7 @@ void main() {
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'signup to demo verification is correlated and never claims a business success',
|
||||
'signup verification keeps simulated analytics with ordinary product copy',
|
||||
(tester) async {
|
||||
final sink = DummyAnalyticsService();
|
||||
await open(tester, '/', sink);
|
||||
@@ -130,10 +130,10 @@ void main() {
|
||||
'example-password',
|
||||
);
|
||||
await tap(tester, find.byKey(const ValueKey('submit')));
|
||||
expect(find.text('Demo email prepared.'), findsOneWidget);
|
||||
expect(find.textContaining('We’ve sent you'), findsNothing);
|
||||
expect(find.text('Check your inbox.'), findsOneWidget);
|
||||
expect(find.textContaining('We’ve sent you'), findsOneWidget);
|
||||
await tap(tester, find.byKey(const ValueKey('demo-confirmation')));
|
||||
expect(find.text('Demo verification\ncomplete.'), findsOneWidget);
|
||||
expect(find.text('Your email\nis verified.'), findsOneWidget);
|
||||
expect(sink.events.map((e) => e.journeyId).toSet().length, 1);
|
||||
expect(sink.events.where((e) => e.terminal).length, 1);
|
||||
expect(
|
||||
|
||||
@@ -2,9 +2,12 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
|
||||
import 'package:sxchnge_app/main.dart';
|
||||
import 'package:sxchnge_app/providers/locale.dart';
|
||||
|
||||
class RecoveringService implements VerificationService {
|
||||
int sends = 0;
|
||||
@@ -54,7 +57,7 @@ void main() {
|
||||
find.byKey(ValueKey('verification-${scenario.value}')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.textContaining('DEMO'), findsOneWidget);
|
||||
expect(find.textContaining('DEMO'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
@@ -120,16 +123,10 @@ void main() {
|
||||
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||
await openVerification(tester, route);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.tap(find.byKey(const ValueKey('locale-selector')));
|
||||
final element = tester.element(find.byType(Scaffold).first);
|
||||
await element.read<LocaleProvider>().setLocale(const Locale('ru'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.ancestor(
|
||||
of: find.text('Русский'),
|
||||
matching: find.byType(CheckedPopupMenuItem<Locale>),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('RU'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('locale-selector')), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
|
||||
import 'package:sxchnge_app/main.dart';
|
||||
import 'package:sxchnge_app/providers/locale.dart';
|
||||
|
||||
Future<void> tap(WidgetTester t, Finder f) async {
|
||||
await t.ensureVisible(f);
|
||||
await t.tap(f);
|
||||
await t.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<DummyWalletService> open(
|
||||
WidgetTester t, {
|
||||
String path = '/accounts',
|
||||
String locale = 'en',
|
||||
Duration settlement = Duration.zero,
|
||||
}) async {
|
||||
t.platformDispatcher.defaultRouteNameTestValue = path;
|
||||
addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue);
|
||||
final identity = DummyIdentityService(delay: Duration.zero);
|
||||
final service = DummyWalletService(
|
||||
identity,
|
||||
delay: Duration.zero,
|
||||
settlement: settlement,
|
||||
initialLocale: locale,
|
||||
);
|
||||
await t.pumpWidget(
|
||||
SxchngeApp(
|
||||
identityService: identity,
|
||||
walletService: service,
|
||||
localeProvider: LocaleProvider(initialLocale: Locale(locale)),
|
||||
),
|
||||
);
|
||||
await t.pumpAndSettle();
|
||||
addTearDown(() => t.pumpWidget(const SizedBox.shrink()));
|
||||
return service;
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('available-only accounts expose one set of actions', (t) async {
|
||||
await open(t);
|
||||
expect(find.text('Reserved'), findsNothing);
|
||||
expect(find.text('Held'), findsNothing);
|
||||
expect(find.byKey(const ValueKey('exchange-eur')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('exchange-usd')), findsNothing);
|
||||
await tap(t, find.byKey(const ValueKey('account-usd')));
|
||||
expect(find.byKey(const ValueKey('topUp-usd')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('exchange-eur')), findsNothing);
|
||||
});
|
||||
testWidgets('top-up exchange payout end-to-end and history', (t) async {
|
||||
final s = await open(t);
|
||||
await tap(t, find.byKey(const ValueKey('topUp-eur')));
|
||||
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10');
|
||||
await tap(t, find.byKey(const ValueKey('transfer-review')));
|
||||
expect(find.text('You pay'), findsOneWidget);
|
||||
await tap(t, find.byKey(const ValueKey('transfer-confirm')));
|
||||
expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget);
|
||||
expect(
|
||||
find.text(
|
||||
'Your account has been credited. The money is available to use.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tap(t, find.text('Exchange').last);
|
||||
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '20');
|
||||
await tap(t, find.byKey(const ValueKey('transfer-review')));
|
||||
expect(find.text('Leaves your account'), findsOneWidget);
|
||||
expect(find.text('Fee'), findsOneWidget);
|
||||
await tap(t, find.byKey(const ValueKey('transfer-confirm')));
|
||||
expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget);
|
||||
await tap(t, find.text('Pay out').last);
|
||||
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
|
||||
await t.enterText(
|
||||
find.byKey(const ValueKey('transfer-recipient')),
|
||||
'Sample recipient',
|
||||
);
|
||||
await tap(t, find.byKey(const ValueKey('transfer-review')));
|
||||
expect(find.text('Recipient receives'), findsOneWidget);
|
||||
await tap(t, find.byKey(const ValueKey('transfer-confirm')));
|
||||
expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget);
|
||||
final data = (await t.runAsync(() => s.fetch()))!;
|
||||
expect(data.operations.length, 3);
|
||||
expect(data.accounts.first.available, 6490);
|
||||
expect(data.accounts[1].available, 1600);
|
||||
});
|
||||
testWidgets(
|
||||
'processing payout explains reduced available balance then failed refund',
|
||||
(t) async {
|
||||
final s = await open(
|
||||
t,
|
||||
path: '/payout',
|
||||
settlement: const Duration(milliseconds: 100),
|
||||
);
|
||||
s.failNextOperation = true;
|
||||
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10');
|
||||
await t.enterText(
|
||||
find.byKey(const ValueKey('transfer-recipient')),
|
||||
'Sample recipient',
|
||||
);
|
||||
await tap(t, find.byKey(const ValueKey('transfer-review')));
|
||||
await tap(t, find.byKey(const ValueKey('transfer-confirm')));
|
||||
// Settlement is service-owned; refresh after the simulated processing period.
|
||||
await t.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 150)),
|
||||
);
|
||||
await t.pump(const Duration(seconds: 3));
|
||||
await t.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('operation-failed')), findsOneWidget);
|
||||
expect(
|
||||
find.text(
|
||||
'The transaction did not complete. The amount and fee are available in your account again.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
((await t.runAsync(() => s.fetch()))!).accounts.first.available,
|
||||
7500,
|
||||
);
|
||||
},
|
||||
);
|
||||
testWidgets(
|
||||
'settings saves account language and hides page language selectors',
|
||||
(t) async {
|
||||
final s = await open(t, path: '/settings');
|
||||
expect(find.byKey(const ValueKey('locale-selector')), findsNothing);
|
||||
await tap(t, find.byKey(const ValueKey('account-language-en')));
|
||||
await tap(t, find.text('Русский').last);
|
||||
expect(((await t.runAsync(() => s.profile()))!).locale, 'ru');
|
||||
expect(find.text('Язык аккаунта'), findsWidgets);
|
||||
},
|
||||
);
|
||||
testWidgets(
|
||||
'language save failure retains session choice and provides retry',
|
||||
(t) async {
|
||||
final s = await open(t, path: '/settings');
|
||||
s.nextError = WalletError.unavailable;
|
||||
await tap(t, find.byKey(const ValueKey('account-language-en')));
|
||||
await tap(t, find.text('Русский').last);
|
||||
expect(
|
||||
find.text(
|
||||
'Изменено для текущей сессии, но не сохранено в аккаунте. Повторите попытку ниже.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(((await t.runAsync(() => s.profile()))!).locale, 'en');
|
||||
await tap(t, find.text('Обновить').last);
|
||||
expect(((await t.runAsync(() => s.profile()))!).locale, 'ru');
|
||||
},
|
||||
);
|
||||
testWidgets(
|
||||
'insufficient funds preserves entered amount and can be corrected',
|
||||
(t) async {
|
||||
await open(t, path: '/exchange');
|
||||
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '900');
|
||||
await tap(t, find.byKey(const ValueKey('transfer-review')));
|
||||
expect(find.byKey(const ValueKey('transfer-error')), findsOneWidget);
|
||||
expect(find.text('900'), findsOneWidget);
|
||||
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
|
||||
await tap(t, find.byKey(const ValueKey('transfer-review')));
|
||||
expect(find.byKey(const ValueKey('transfer-confirm')), findsOneWidget);
|
||||
},
|
||||
);
|
||||
testWidgets('uncertain submission checks outcome instead of sending twice', (
|
||||
t,
|
||||
) async {
|
||||
final s = await open(t, path: '/exchange');
|
||||
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
|
||||
await tap(t, find.byKey(const ValueKey('transfer-review')));
|
||||
s.loseNextResponse = true;
|
||||
await tap(t, find.byKey(const ValueKey('transfer-confirm')));
|
||||
expect(find.text('Check outcome'), findsOneWidget);
|
||||
await tap(t, find.text('Check outcome'));
|
||||
expect(((await t.runAsync(() => s.fetch()))!).operations.length, 1);
|
||||
expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget);
|
||||
});
|
||||
for (final path in [
|
||||
'/accounts',
|
||||
'/exchange',
|
||||
'/payout',
|
||||
'/activity',
|
||||
'/limits',
|
||||
'/settings',
|
||||
]) {
|
||||
testWidgets('$path fits Russian at 320px with larger text', (t) async {
|
||||
t.view.physicalSize = const Size(320, 1100);
|
||||
t.view.devicePixelRatio = 1;
|
||||
t.platformDispatcher.textScaleFactorTestValue = 1.6;
|
||||
addTearDown(t.view.resetPhysicalSize);
|
||||
addTearDown(t.view.resetDevicePixelRatio);
|
||||
addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue);
|
||||
await open(t, path: path, locale: 'ru');
|
||||
expect(t.takeException(), null);
|
||||
if (path == '/exchange') {
|
||||
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
|
||||
await tap(t, find.byKey(const ValueKey('transfer-review')));
|
||||
expect(t.takeException(), null);
|
||||
}
|
||||
});
|
||||
}
|
||||
testWidgets('leaving a draft requires a clear discard choice', (t) async {
|
||||
await open(t, path: '/exchange');
|
||||
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
|
||||
final router = GoRouter.of(
|
||||
t.element(find.byKey(const ValueKey('transfer-amount'))),
|
||||
);
|
||||
router.go('/accounts');
|
||||
await t.pumpAndSettle();
|
||||
expect(find.text('Leave this transaction?'), findsOneWidget);
|
||||
await tap(t, find.text('Keep editing'));
|
||||
expect(find.text('5'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -2,11 +2,26 @@ import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:math';
|
||||
|
||||
enum JourneyKind { login, signup, verification }
|
||||
enum JourneyKind { login, signup, verification, kyc, topUp, exchange, payout }
|
||||
|
||||
enum JourneyStep { login, signup, emailDelivery, emailConfirmation }
|
||||
enum JourneyStep {
|
||||
login,
|
||||
signup,
|
||||
emailDelivery,
|
||||
emailConfirmation,
|
||||
kycPreparation,
|
||||
kycPassport,
|
||||
kycSelfie,
|
||||
kycSubmission,
|
||||
kycStatus,
|
||||
transferDraft,
|
||||
transferQuote,
|
||||
transferSubmission,
|
||||
settlement,
|
||||
}
|
||||
|
||||
enum JourneySignal {
|
||||
cancelled,
|
||||
started,
|
||||
requested,
|
||||
succeeded,
|
||||
@@ -16,6 +31,10 @@ enum JourneySignal {
|
||||
}
|
||||
|
||||
enum JourneyFailure {
|
||||
insufficientFunds,
|
||||
limitExceeded,
|
||||
declined,
|
||||
document,
|
||||
invalidCredentials,
|
||||
emailInUse,
|
||||
unavailable,
|
||||
@@ -66,8 +85,19 @@ class AnalyticsEvent {
|
||||
final bool terminal;
|
||||
final bool simulated;
|
||||
final DateTime occurredAt;
|
||||
String get name =>
|
||||
'auth_${step == null ? 'journey' : _snake(step!.name)}_${signal.name}';
|
||||
String get name {
|
||||
final area = switch (kind) {
|
||||
JourneyKind.kyc => 'kyc',
|
||||
JourneyKind.topUp => 'top_up',
|
||||
JourneyKind.exchange => 'exchange',
|
||||
JourneyKind.payout => 'payout',
|
||||
_ => '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({
|
||||
'schema_version': 1,
|
||||
'event_id': eventId,
|
||||
@@ -165,16 +195,19 @@ class JourneyRecorder {
|
||||
bool simulated = false,
|
||||
JourneyFailure? failure,
|
||||
bool terminal = false,
|
||||
bool cancelled = false,
|
||||
}) {
|
||||
final journey = operation._journey;
|
||||
if (_disposed || operation._finished || journey.closed) return;
|
||||
operation._finished = true;
|
||||
journey.pending--;
|
||||
journey.simulated |= simulated;
|
||||
final complete = failure == null && terminal;
|
||||
final complete = !cancelled && terminal;
|
||||
_emit(
|
||||
journey,
|
||||
failure != null
|
||||
cancelled
|
||||
? JourneySignal.cancelled
|
||||
: failure != null
|
||||
? JourneySignal.failed
|
||||
: (simulated || (complete && journey.simulated))
|
||||
? JourneySignal.simulated
|
||||
@@ -237,8 +270,8 @@ class JourneyRecorder {
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
abandon(JourneyExit.disposed);
|
||||
void dispose({bool abandonActive = true}) {
|
||||
if (abandonActive) abandon(JourneyExit.disposed);
|
||||
_disposed = true;
|
||||
_idle?.cancel();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.dart_tool/
|
||||
build/
|
||||
pubspec.lock
|
||||
@@ -0,0 +1,43 @@
|
||||
# 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.
|
||||
|
||||
Visible limit rows also expose `usedMinor` (committed activity) and `reservedMinor`
|
||||
(pending holds). `remainingMinor` is independently provided by the backend and
|
||||
must not be inferred as cap minus those fields. The display-only occupied ratio
|
||||
includes used plus reserved and is bounded for progress rendering.
|
||||
@@ -0,0 +1,2 @@
|
||||
/// Shared limit transport contract for service adapters.
|
||||
export 'src/transport/contracts.dart' show LimitsDTO, LimitDTO, LimitsMapper;
|
||||
@@ -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,40 @@
|
||||
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,
|
||||
required this.usedMinor,
|
||||
required this.reservedMinor,
|
||||
});
|
||||
final LimitPeriod period;
|
||||
final String currency;
|
||||
final int basicMinor;
|
||||
final int verifiedMinor;
|
||||
final int currentMinor;
|
||||
final int remainingMinor;
|
||||
final int usedMinor;
|
||||
final int reservedMinor;
|
||||
|
||||
/// Presentation only: remaining capacity and payment authorization stay server-owned.
|
||||
double get occupiedFraction => currentMinor <= 0
|
||||
? 0
|
||||
: ((usedMinor + reservedMinor) / currentMinor).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
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,97 @@
|
||||
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) - 4000,
|
||||
3000,
|
||||
1000,
|
||||
),
|
||||
LimitDTO(
|
||||
'month',
|
||||
'EUR',
|
||||
50000,
|
||||
1000000,
|
||||
approved ? 1000000 : 50000,
|
||||
(approved ? 1000000 : 50000) - 19000,
|
||||
18000,
|
||||
1000,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,68 @@
|
||||
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,
|
||||
this.used,
|
||||
this.reserved,
|
||||
);
|
||||
final String period;
|
||||
final String currency;
|
||||
final int basic, verified, current, remaining, used, reserved;
|
||||
}
|
||||
|
||||
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,
|
||||
usedMinor: r.used,
|
||||
reservedMinor: r.reserved,
|
||||
),
|
||||
)
|
||||
.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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
import 'package:sxchnge_kyc/src/transport/contracts.dart';
|
||||
|
||||
void main() {
|
||||
test('mapping preserves backend usage and independent remaining value', () {
|
||||
final data = LimitsMapper.fromDTO(
|
||||
const LimitsDTO(
|
||||
tier: 'basic',
|
||||
simulated: false,
|
||||
rows: [LimitDTO('day', 'EUR', 10000, 250000, 10000, 5500, 3000, 1000)],
|
||||
),
|
||||
);
|
||||
final row = data.rows.single;
|
||||
expect(row.usedMinor, 3000);
|
||||
expect(row.reservedMinor, 1000);
|
||||
expect(row.remainingMinor, 5500);
|
||||
expect(row.occupiedFraction, 0.4);
|
||||
});
|
||||
test('capacity visualization handles unused, exhausted and zero caps', () {
|
||||
VisibleLimit row(int cap, int used, int reserved) => VisibleLimit(
|
||||
period: LimitPeriod.day,
|
||||
currency: 'EUR',
|
||||
basicMinor: cap,
|
||||
verifiedMinor: cap,
|
||||
currentMinor: cap,
|
||||
remainingMinor: 0,
|
||||
usedMinor: used,
|
||||
reservedMinor: reserved,
|
||||
);
|
||||
expect(row(10000, 0, 0).occupiedFraction, 0);
|
||||
expect(row(10000, 8000, 2000).occupiedFraction, 1);
|
||||
expect(row(10000, 12000, 2000).occupiedFraction, 1);
|
||||
expect(row(0, 0, 0).occupiedFraction, 0);
|
||||
});
|
||||
test('stub provides nonzero daily and monthly usage', () async {
|
||||
final identity = DummyIdentityService(delay: Duration.zero);
|
||||
final service = DummyLimitsService(identity);
|
||||
final data = await service.fetch();
|
||||
expect(data.rows.first.usedMinor, 3000);
|
||||
expect(data.rows.first.reservedMinor, 1000);
|
||||
expect(data.rows.first.remainingMinor, 6000);
|
||||
expect(data.rows.last.usedMinor, 18000);
|
||||
expect(data.rows.last.remainingMinor, 31000);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.dart_tool/
|
||||
build/
|
||||
pubspec.lock
|
||||
@@ -0,0 +1,48 @@
|
||||
# SXCHNGE wallet
|
||||
|
||||
Shared Flutter domain, injectable services and providers for accounts, quotes,
|
||||
operations, limits synchronization and account language. Web and mobile clients
|
||||
consume the same models; widgets do not call transport APIs.
|
||||
|
||||
`WalletService` returns mapped domain models. `DummyWalletService` is an in-memory
|
||||
backend stand-in; `WalletProvider` owns snapshots and pending-operation refresh,
|
||||
`TransferProvider` owns draft/review/submission/reconciliation, and
|
||||
`WorkspaceLimitsProvider` refetches the separate `LimitsService` projection when
|
||||
wallet state changes. The adapter reuses the KYC package's limit DTO and mapper.
|
||||
|
||||
## Demo behavior
|
||||
|
||||
- Opening balances: EUR 75, USD 0, GBP 0. No invented historical transactions.
|
||||
- Fixed demo ratios: EUR/USD 1.1; GBP uses weight 1300 vs EUR 1100 and USD 1000.
|
||||
- Top-up fee zero; exchange fee 0.5% rounded down, minimum one minor unit;
|
||||
payout fee 100 minor units. Quote expiry is 60 seconds.
|
||||
- Top-ups credit only after settlement. Outgoing total debit includes the fee;
|
||||
it leaves available balance on acceptance. Failed outgoing operations restore
|
||||
that exact total. Exchange destination credits only on completion.
|
||||
- Outgoing usage, including fees, is converted upward to EUR minor units.
|
||||
Basic caps: EUR 100/day and 500/month; verified: 2500/day and 10000/month.
|
||||
Top-ups do not consume outgoing capacity. Windows follow the stub's local clock.
|
||||
Policy, usage and remaining are returned by the service, not set in widgets.
|
||||
- Submission rechecks balance, limits and expiry, is idempotent by request key,
|
||||
and permits a quote to execute only once. Unknown outcomes reconcile before
|
||||
any resubmission. `nextError`, `loseNextResponse`, `failNextOperation`, injectable
|
||||
clock/delays support deterministic failure testing.
|
||||
- Everything resets on application reload. Recipient values are demo text only;
|
||||
no bank details, payment provider, storage or real money movement is connected.
|
||||
|
||||
## Integration boundary
|
||||
|
||||
Replace `WalletService` and `LimitsService` together with authenticated backend
|
||||
adapters. The backend must provide authorization, authoritative quotes/balances,
|
||||
atomic limits enforcement, durable idempotency and operation reconciliation.
|
||||
Add supported payment methods and validated recipients from that contract, rather
|
||||
than interpreting the demo input as production bank validation. Replace profile
|
||||
storage with the signed-in account endpoint. No authorization is supplied by
|
||||
public demo routes or an available balance.
|
||||
|
||||
Analytics use the shared typed recorder: draft, quote, submission and authoritative
|
||||
settlement. Retried submissions keep a journey; each accepted operation closes
|
||||
once on success/failure. Closing the app while pending does not count as draft
|
||||
abandonment. No amounts, account IDs or recipient data enter events. All demo
|
||||
signals are simulated. Cross-session outcomes require backend event delivery;
|
||||
client-only analytics are not production accounting or conversion evidence.
|
||||
@@ -0,0 +1,113 @@
|
||||
enum TransferKind { topUp, exchange, payout }
|
||||
|
||||
enum OperationStatus { pending, completed, failed }
|
||||
|
||||
enum WalletError {
|
||||
unavailable,
|
||||
invalidAmount,
|
||||
insufficientFunds,
|
||||
limitExceeded,
|
||||
expiredQuote,
|
||||
invalidRecipient,
|
||||
unsupported,
|
||||
notFound,
|
||||
uncertain,
|
||||
}
|
||||
|
||||
class WalletException implements Exception {
|
||||
const WalletException(this.code);
|
||||
final WalletError code;
|
||||
}
|
||||
|
||||
class CurrencyAccount {
|
||||
const CurrencyAccount({
|
||||
required this.id,
|
||||
required this.currency,
|
||||
required this.precision,
|
||||
required this.available,
|
||||
});
|
||||
final String id, currency;
|
||||
final int precision, available;
|
||||
|
||||
/// Locale-aware decimal input, integer money. Grouping/exponents are rejected.
|
||||
int? parse(String input) {
|
||||
final value = input.trim().replaceAll(',', '.');
|
||||
if (!RegExp(r'^\d+(\.\d+)?$').hasMatch(value)) return null;
|
||||
final parts = value.split('.');
|
||||
if (parts.length > 1 && parts[1].length > precision) return null;
|
||||
final major = int.tryParse(parts[0]);
|
||||
if (major == null || major > 100000000) return null;
|
||||
final fraction = parts.length > 1
|
||||
? int.parse(parts[1].padRight(precision, '0'))
|
||||
: 0;
|
||||
return major * scale + fraction;
|
||||
}
|
||||
|
||||
int get scale {
|
||||
var v = 1;
|
||||
for (var i = 0; i < precision; i++) {
|
||||
v *= 10;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
class TransferRequest {
|
||||
const TransferRequest({
|
||||
required this.kind,
|
||||
required this.source,
|
||||
required this.amount,
|
||||
this.destination,
|
||||
this.recipient = '',
|
||||
});
|
||||
final TransferKind kind;
|
||||
final String source;
|
||||
final int amount;
|
||||
final String? destination;
|
||||
final String recipient;
|
||||
}
|
||||
|
||||
class Quote {
|
||||
const Quote({
|
||||
required this.id,
|
||||
required this.request,
|
||||
required this.fee,
|
||||
required this.debit,
|
||||
required this.credit,
|
||||
required this.expiresAt,
|
||||
required this.rateNumerator,
|
||||
required this.rateDenominator,
|
||||
});
|
||||
final String id;
|
||||
final TransferRequest request;
|
||||
final int fee, debit, credit, rateNumerator, rateDenominator;
|
||||
final DateTime expiresAt;
|
||||
}
|
||||
|
||||
class WalletOperation {
|
||||
const WalletOperation({
|
||||
required this.id,
|
||||
required this.quote,
|
||||
required this.status,
|
||||
required this.createdAt,
|
||||
});
|
||||
final String id;
|
||||
final Quote quote;
|
||||
final OperationStatus status;
|
||||
final DateTime createdAt;
|
||||
}
|
||||
|
||||
class WalletSnapshot {
|
||||
WalletSnapshot({
|
||||
required List<CurrencyAccount> accounts,
|
||||
required List<WalletOperation> operations,
|
||||
}) : accounts = List.unmodifiable(accounts),
|
||||
operations = List.unmodifiable(operations);
|
||||
final List<CurrencyAccount> accounts;
|
||||
final List<WalletOperation> operations;
|
||||
}
|
||||
|
||||
class AccountProfile {
|
||||
const AccountProfile(this.locale);
|
||||
final String locale;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
import '../models/wallet.dart';
|
||||
import 'wallet.dart';
|
||||
|
||||
/// Refetch the backend projection when an operation changes the wallet.
|
||||
class WorkspaceLimitsProvider extends LimitsProvider {
|
||||
WorkspaceLimitsProvider(super.service, this.wallet) {
|
||||
_snapshot = wallet.data;
|
||||
wallet.addListener(_changed);
|
||||
}
|
||||
final WalletProvider wallet;
|
||||
WalletSnapshot? _snapshot;
|
||||
void _changed() {
|
||||
if (identical(_snapshot, wallet.data)) return;
|
||||
_snapshot = wallet.data;
|
||||
refresh();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
wallet.removeListener(_changed);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
import '../models/wallet.dart';
|
||||
import 'wallet.dart';
|
||||
|
||||
class TransferProvider extends ChangeNotifier {
|
||||
TransferProvider(this.wallet, this.kind, {String? source})
|
||||
: source = source ?? wallet.data?.accounts.firstOrNull?.id ?? 'eur',
|
||||
recorder = JourneyRecorder(wallet.analytics) {
|
||||
destination =
|
||||
wallet.data?.accounts
|
||||
.where((a) => a.id != this.source)
|
||||
.firstOrNull
|
||||
?.id ??
|
||||
'usd';
|
||||
final op = recorder.begin(
|
||||
journey,
|
||||
JourneyStep.transferDraft,
|
||||
simulated: wallet.service.isSimulated,
|
||||
);
|
||||
recorder.finish(op, simulated: wallet.service.isSimulated);
|
||||
}
|
||||
final WalletProvider wallet;
|
||||
final TransferKind kind;
|
||||
final JourneyRecorder recorder;
|
||||
String source;
|
||||
String? destination;
|
||||
String amount = '', recipient = '';
|
||||
Quote? quote;
|
||||
WalletOperation? result;
|
||||
WalletError? error;
|
||||
bool noSubmission = false;
|
||||
bool busy = false, uncertain = false, _disposed = false, _handedOff = false;
|
||||
int _revision = 0;
|
||||
String? _key;
|
||||
JourneyKind get journey => switch (kind) {
|
||||
TransferKind.topUp => JourneyKind.topUp,
|
||||
TransferKind.exchange => JourneyKind.exchange,
|
||||
TransferKind.payout => JourneyKind.payout,
|
||||
};
|
||||
bool get hasDraft => amount.isNotEmpty || recipient.isNotEmpty;
|
||||
bool get expired =>
|
||||
quote != null && !DateTime.now().isBefore(quote!.expiresAt);
|
||||
void change({
|
||||
String? source,
|
||||
String? destination,
|
||||
String? amount,
|
||||
String? recipient,
|
||||
}) {
|
||||
if (busy || uncertain) return;
|
||||
this.source = source ?? this.source;
|
||||
this.destination = destination ?? this.destination;
|
||||
this.amount = amount ?? this.amount;
|
||||
this.recipient = recipient ?? this.recipient;
|
||||
quote = null;
|
||||
noSubmission = false;
|
||||
error = null;
|
||||
_key = null;
|
||||
_revision++;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void edit() {
|
||||
if (!busy && !uncertain) {
|
||||
quote = null;
|
||||
error = null;
|
||||
_key = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> review() async {
|
||||
if (busy || uncertain) return;
|
||||
final money = wallet.account(source)?.parse(amount);
|
||||
if (money == null || money <= 0) {
|
||||
error = WalletError.invalidAmount;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
noSubmission = false;
|
||||
error = null;
|
||||
notifyListeners();
|
||||
final revision = ++_revision;
|
||||
final op = recorder.begin(
|
||||
journey,
|
||||
JourneyStep.transferQuote,
|
||||
simulated: wallet.service.isSimulated,
|
||||
);
|
||||
try {
|
||||
final q = await wallet.service.quote(
|
||||
TransferRequest(
|
||||
kind: kind,
|
||||
source: source,
|
||||
amount: money,
|
||||
destination: kind == TransferKind.exchange ? destination : null,
|
||||
recipient: recipient.trim(),
|
||||
),
|
||||
);
|
||||
if (_disposed || revision != _revision) return;
|
||||
quote = q;
|
||||
_key = null;
|
||||
recorder.finish(op, simulated: wallet.service.isSimulated);
|
||||
} catch (e) {
|
||||
if (_disposed) return;
|
||||
error = _code(e);
|
||||
recorder.finish(op, failure: _failure(error!));
|
||||
}
|
||||
if (_disposed) return;
|
||||
busy = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> submit() async {
|
||||
if (busy || quote == null || uncertain) return;
|
||||
if (expired) {
|
||||
error = WalletError.expiredQuote;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
noSubmission = false;
|
||||
error = null;
|
||||
notifyListeners();
|
||||
_key ??= List.generate(
|
||||
16,
|
||||
(_) => Random.secure().nextInt(256).toRadixString(16).padLeft(2, '0'),
|
||||
).join();
|
||||
final op = recorder.begin(
|
||||
journey,
|
||||
JourneyStep.transferSubmission,
|
||||
simulated: wallet.service.isSimulated,
|
||||
);
|
||||
try {
|
||||
final r = await wallet.service.submit(quote!.id, _key!);
|
||||
if (_disposed) return;
|
||||
_accepted(r, op);
|
||||
} catch (e) {
|
||||
if (_disposed) return;
|
||||
error = _code(e);
|
||||
// All transport/unexpected failures are ambiguous after execution started.
|
||||
uncertain = [
|
||||
WalletError.unavailable,
|
||||
WalletError.uncertain,
|
||||
].contains(error);
|
||||
recorder.finish(op, failure: _failure(error!));
|
||||
}
|
||||
if (_disposed) return;
|
||||
busy = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _accepted(WalletOperation value, JourneyOperation op) {
|
||||
result = value;
|
||||
uncertain = false;
|
||||
error = null;
|
||||
recipient = '';
|
||||
recorder.finish(op, simulated: wallet.service.isSimulated);
|
||||
wallet.track(value, recorder, journey);
|
||||
_handedOff = true;
|
||||
}
|
||||
|
||||
Future<void> reconcile() async {
|
||||
if (busy || !uncertain || _key == null) return;
|
||||
busy = true;
|
||||
noSubmission = false;
|
||||
error = null;
|
||||
notifyListeners();
|
||||
final op = recorder.begin(
|
||||
journey,
|
||||
JourneyStep.transferSubmission,
|
||||
simulated: wallet.service.isSimulated,
|
||||
);
|
||||
try {
|
||||
final r = await wallet.service.findSubmission(_key!);
|
||||
if (_disposed) return;
|
||||
if (r != null) {
|
||||
_accepted(r, op);
|
||||
} else {
|
||||
uncertain = false;
|
||||
noSubmission = true;
|
||||
recorder.finish(op, simulated: wallet.service.isSimulated);
|
||||
}
|
||||
} catch (e) {
|
||||
if (_disposed) return;
|
||||
error = _code(e);
|
||||
recorder.finish(op, failure: JourneyFailure.unavailable);
|
||||
}
|
||||
if (_disposed) return;
|
||||
busy = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
WalletError _code(Object e) =>
|
||||
e is WalletException ? e.code : WalletError.unavailable;
|
||||
JourneyFailure _failure(WalletError e) => switch (e) {
|
||||
WalletError.insufficientFunds => JourneyFailure.insufficientFunds,
|
||||
WalletError.limitExceeded => JourneyFailure.limitExceeded,
|
||||
WalletError.expiredQuote => JourneyFailure.expired,
|
||||
_ => JourneyFailure.unavailable,
|
||||
};
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
recipient = '';
|
||||
if (!_handedOff) recorder.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
import '../models/wallet.dart';
|
||||
import '../services/contracts.dart';
|
||||
|
||||
class WalletProvider extends ChangeNotifier {
|
||||
WalletProvider(this.service, this.analytics);
|
||||
final WalletService service;
|
||||
final AnalyticsService analytics;
|
||||
WalletSnapshot? data;
|
||||
bool loading = false,
|
||||
failed = false,
|
||||
profileLoading = false,
|
||||
profileFailed = false,
|
||||
saving = false,
|
||||
saveFailed = false;
|
||||
String? preferredLocale;
|
||||
bool _disposed = false;
|
||||
int _revision = 0;
|
||||
Timer? _poll;
|
||||
final _settlements = <String, (JourneyRecorder, JourneyOperation)>{};
|
||||
CurrencyAccount? account(String? id) {
|
||||
for (final a in data?.accounts ?? <CurrencyAccount>[]) {
|
||||
if (a.id == id) return a;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
WalletOperation? operation(String id) {
|
||||
for (final o in data?.operations ?? <WalletOperation>[]) {
|
||||
if (o.id == id) return o;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool get hasPending =>
|
||||
data?.operations.any((o) => o.status == OperationStatus.pending) ?? false;
|
||||
Future<void> refresh() async {
|
||||
final rev = ++_revision;
|
||||
loading = true;
|
||||
failed = false;
|
||||
notifyListeners();
|
||||
try {
|
||||
final result = await service.fetch();
|
||||
if (_disposed || rev != _revision) return;
|
||||
data = result;
|
||||
for (final o in result.operations) {
|
||||
if (o.status == OperationStatus.pending) continue;
|
||||
final pair = _settlements.remove(o.id);
|
||||
if (pair != null) {
|
||||
pair.$1.finish(
|
||||
pair.$2,
|
||||
simulated: service.isSimulated,
|
||||
failure: o.status == OperationStatus.failed
|
||||
? JourneyFailure.declined
|
||||
: null,
|
||||
terminal: true,
|
||||
);
|
||||
pair.$1.dispose();
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
if (_disposed || rev != _revision) return;
|
||||
failed = true;
|
||||
}
|
||||
if (_disposed || rev != _revision) return;
|
||||
loading = false;
|
||||
_poll?.cancel();
|
||||
if (hasPending) {
|
||||
_poll = Timer(const Duration(seconds: 3), () {
|
||||
if (!_disposed) refresh();
|
||||
});
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void track(
|
||||
WalletOperation operation,
|
||||
JourneyRecorder recorder,
|
||||
JourneyKind kind,
|
||||
) {
|
||||
final step = recorder.begin(
|
||||
kind,
|
||||
JourneyStep.settlement,
|
||||
simulated: service.isSimulated,
|
||||
);
|
||||
_settlements[operation.id] = (recorder, step);
|
||||
final snapshot = data;
|
||||
if (snapshot != null) {
|
||||
data = WalletSnapshot(
|
||||
accounts: snapshot.accounts,
|
||||
operations: [
|
||||
operation,
|
||||
...snapshot.operations.where((o) => o.id != operation.id),
|
||||
],
|
||||
);
|
||||
}
|
||||
unawaited(refresh());
|
||||
}
|
||||
|
||||
Future<void> loadProfile() async {
|
||||
if (profileLoading) return;
|
||||
profileLoading = true;
|
||||
profileFailed = false;
|
||||
notifyListeners();
|
||||
try {
|
||||
final result = await service.profile();
|
||||
if (_disposed) return;
|
||||
preferredLocale = result.locale;
|
||||
} catch (_) {
|
||||
if (_disposed) return;
|
||||
profileFailed = true;
|
||||
}
|
||||
profileLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<bool> saveLocale(String locale) async {
|
||||
if (saving) return false;
|
||||
saving = true;
|
||||
saveFailed = false;
|
||||
notifyListeners();
|
||||
try {
|
||||
final result = await service.saveLocale(locale);
|
||||
if (_disposed) return false;
|
||||
preferredLocale = result.locale;
|
||||
} catch (_) {
|
||||
if (_disposed) return false;
|
||||
saveFailed = true;
|
||||
}
|
||||
saving = false;
|
||||
notifyListeners();
|
||||
return !saveFailed;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_poll?.cancel();
|
||||
for (final p in _settlements.values) {
|
||||
p.$1.dispose(abandonActive: false);
|
||||
}
|
||||
_settlements.clear();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import '../models/wallet.dart';
|
||||
|
||||
abstract interface class WalletService {
|
||||
bool get isSimulated;
|
||||
Future<WalletSnapshot> fetch();
|
||||
Future<Quote> quote(TransferRequest request);
|
||||
|
||||
/// Same key must reconcile uncertain submissions without duplicate execution.
|
||||
Future<WalletOperation> submit(String quoteId, String idempotencyKey);
|
||||
Future<WalletOperation?> findSubmission(String idempotencyKey);
|
||||
Future<AccountProfile> profile();
|
||||
Future<AccountProfile> saveLocale(String locale);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:sxchnge_kyc/limits_transport.dart';
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
import '../models/wallet.dart';
|
||||
import '../transport/wallet.dart';
|
||||
import 'contracts.dart';
|
||||
|
||||
/// Coherent, account-scoped backend stand-in. No network or persistence.
|
||||
class DummyWalletService implements WalletService {
|
||||
DummyWalletService(
|
||||
this.identity, {
|
||||
this.delay = const Duration(milliseconds: 350),
|
||||
this.settlement = const Duration(seconds: 2),
|
||||
DateTime Function()? now,
|
||||
String initialLocale = 'en',
|
||||
}) : now = now ?? DateTime.now,
|
||||
_locale = initialLocale;
|
||||
final IdentityService identity;
|
||||
final Duration delay, settlement;
|
||||
final DateTime Function() now;
|
||||
final _accounts = [
|
||||
AccountDTO('eur', 'EUR', 2, 7500),
|
||||
AccountDTO('usd', 'USD', 2, 0),
|
||||
AccountDTO('gbp', 'GBP', 2, 0),
|
||||
];
|
||||
final Map<String, QuoteDTO> _quotes = {};
|
||||
final _usedQuotes = <String>{};
|
||||
final Map<String, OperationDTO> _submissions = {};
|
||||
String _locale;
|
||||
int _serial = 0;
|
||||
WalletError? nextError;
|
||||
bool failNextOperation = false;
|
||||
bool loseNextResponse = false;
|
||||
@override
|
||||
bool get isSimulated => true;
|
||||
Future<void> _wait() async {
|
||||
await Future<void>.delayed(delay);
|
||||
final error = nextError;
|
||||
nextError = null;
|
||||
if (error != null) throw WalletException(error);
|
||||
}
|
||||
|
||||
AccountDTO _account(String id) => _accounts.firstWhere(
|
||||
(a) => a.id == id,
|
||||
orElse: () => throw const WalletException(WalletError.notFound),
|
||||
);
|
||||
// Fixed demo ratios to EUR; never marketed as live rates.
|
||||
int _weight(String id) => switch (id) {
|
||||
'eur' => 1100,
|
||||
'usd' => 1000,
|
||||
'gbp' => 1300,
|
||||
_ => throw const WalletException(WalletError.unsupported),
|
||||
};
|
||||
int _eur(int minor, String id) => (minor * _weight(id) + 1099) ~/ 1100;
|
||||
void _settle() {
|
||||
for (final o in _submissions.values) {
|
||||
if (o.status != 'pending' || now().isBefore(o.ready)) continue;
|
||||
o.status = o.fail ? 'failed' : 'completed';
|
||||
final q = o.quote;
|
||||
if (o.fail) {
|
||||
if (q.request.kind != 'topUp')
|
||||
_account(q.request.source).available += q.debit;
|
||||
} else if (q.request.kind == 'topUp') {
|
||||
_account(q.request.source).available += q.credit;
|
||||
} else if (q.request.kind == 'exchange') {
|
||||
_account(q.request.destination!).available += q.credit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int _usage({required bool monthly, required bool pending}) {
|
||||
final current = now();
|
||||
return _submissions.values
|
||||
.where(
|
||||
(o) =>
|
||||
o.quote.request.kind != 'topUp' &&
|
||||
o.status == (pending ? 'pending' : 'completed') &&
|
||||
o.created.year == current.year &&
|
||||
o.created.month == current.month &&
|
||||
(monthly || o.created.day == current.day),
|
||||
)
|
||||
.fold(
|
||||
0,
|
||||
(total, o) => total + _eur(o.quote.debit, o.quote.request.source),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _verified() async =>
|
||||
(await identity.fetch()).status == IdentityStatus.approved;
|
||||
void _check(QuoteDTO q, bool verified) {
|
||||
if (q.request.kind == 'topUp') return;
|
||||
if (_account(q.request.source).available < q.debit)
|
||||
throw const WalletException(WalletError.insufficientFunds);
|
||||
final charge = _eur(q.debit, q.request.source);
|
||||
if (charge +
|
||||
_usage(monthly: false, pending: false) +
|
||||
_usage(monthly: false, pending: true) >
|
||||
(verified ? 250000 : 10000) ||
|
||||
charge +
|
||||
_usage(monthly: true, pending: false) +
|
||||
_usage(monthly: true, pending: true) >
|
||||
(verified ? 1000000 : 50000))
|
||||
throw const WalletException(WalletError.limitExceeded);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<WalletSnapshot> fetch() async {
|
||||
await _wait();
|
||||
_settle();
|
||||
return WalletSnapshot(
|
||||
accounts: _accounts.map(WalletMapper.account).toList(),
|
||||
operations: _submissions.values
|
||||
.toList()
|
||||
.reversed
|
||||
.map(WalletMapper.operation)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Quote> quote(TransferRequest r) async {
|
||||
await _wait();
|
||||
_settle();
|
||||
_account(r.source);
|
||||
if (r.amount <= 0 || r.amount > 100000000)
|
||||
throw const WalletException(WalletError.invalidAmount);
|
||||
if (r.kind == TransferKind.exchange &&
|
||||
(r.source == r.destination || r.destination == null))
|
||||
throw const WalletException(WalletError.unsupported);
|
||||
if (r.kind == TransferKind.payout &&
|
||||
(r.recipient.trim().length < 6 || r.recipient.length > 140))
|
||||
throw const WalletException(WalletError.invalidRecipient);
|
||||
if (r.kind == TransferKind.exchange) _account(r.destination!);
|
||||
final fee = switch (r.kind) {
|
||||
TransferKind.topUp => 0,
|
||||
TransferKind.exchange => max(1, r.amount ~/ 200),
|
||||
TransferKind.payout => 100,
|
||||
};
|
||||
final n = r.kind == TransferKind.exchange ? _weight(r.source) : 1;
|
||||
final d = r.kind == TransferKind.exchange ? _weight(r.destination!) : 1;
|
||||
final q = QuoteDTO(
|
||||
'quote-${++_serial}',
|
||||
WalletMapper.request(r),
|
||||
fee,
|
||||
r.kind == TransferKind.topUp ? r.amount : r.amount + fee,
|
||||
r.amount * n ~/ d,
|
||||
now().add(const Duration(seconds: 60)),
|
||||
n,
|
||||
d,
|
||||
);
|
||||
if (q.credit <= 0) throw const WalletException(WalletError.invalidAmount);
|
||||
final verified = await _verified();
|
||||
_settle();
|
||||
_check(q, verified);
|
||||
_quotes[q.id] = q;
|
||||
return WalletMapper.quote(q);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<WalletOperation> submit(String quoteId, String idempotencyKey) async {
|
||||
await _wait();
|
||||
if (_submissions.containsKey(idempotencyKey))
|
||||
return WalletMapper.operation(_submissions[idempotencyKey]!);
|
||||
final q = _quotes[quoteId];
|
||||
if (q == null) throw const WalletException(WalletError.expiredQuote);
|
||||
final verified = await _verified();
|
||||
_settle();
|
||||
if (_submissions.containsKey(idempotencyKey))
|
||||
return WalletMapper.operation(_submissions[idempotencyKey]!);
|
||||
if (!now().isBefore(q.expires))
|
||||
throw const WalletException(WalletError.expiredQuote);
|
||||
if (_usedQuotes.contains(q.id))
|
||||
throw const WalletException(WalletError.expiredQuote);
|
||||
_check(q, verified);
|
||||
_usedQuotes.add(q.id);
|
||||
if (q.request.kind != 'topUp')
|
||||
_account(q.request.source).available -= q.debit;
|
||||
final o = OperationDTO(
|
||||
'op-${++_serial}',
|
||||
q,
|
||||
now(),
|
||||
now().add(settlement),
|
||||
fail: failNextOperation,
|
||||
);
|
||||
failNextOperation = false;
|
||||
_submissions[idempotencyKey] = o;
|
||||
if (loseNextResponse) {
|
||||
loseNextResponse = false;
|
||||
throw const WalletException(WalletError.uncertain);
|
||||
}
|
||||
return WalletMapper.operation(o);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<WalletOperation?> findSubmission(String key) async {
|
||||
await _wait();
|
||||
_settle();
|
||||
final o = _submissions[key];
|
||||
return o == null ? null : WalletMapper.operation(o);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AccountProfile> profile() async {
|
||||
await _wait();
|
||||
return WalletMapper.profile(_locale);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AccountProfile> saveLocale(String locale) async {
|
||||
await _wait();
|
||||
if (!['en', 'ru'].contains(locale))
|
||||
throw const WalletException(WalletError.unsupported);
|
||||
_locale = locale;
|
||||
return WalletMapper.profile(_locale);
|
||||
}
|
||||
|
||||
// Kept separate from wallet fetch because LimitsService has a different result.
|
||||
Future<Limits> fetchLimits() async {
|
||||
await _wait();
|
||||
final verified = await _verified();
|
||||
_settle();
|
||||
final rows = <LimitDTO>[];
|
||||
for (final monthly in [false, true]) {
|
||||
final basic = monthly ? 50000 : 10000;
|
||||
final upgraded = monthly ? 1000000 : 250000;
|
||||
final current = verified ? upgraded : basic;
|
||||
final used = _usage(monthly: monthly, pending: false);
|
||||
final pending = _usage(monthly: monthly, pending: true);
|
||||
rows.add(
|
||||
LimitDTO(
|
||||
monthly ? 'month' : 'day',
|
||||
'EUR',
|
||||
basic,
|
||||
upgraded,
|
||||
current,
|
||||
max(0, current - used - pending),
|
||||
used,
|
||||
pending,
|
||||
),
|
||||
);
|
||||
}
|
||||
return LimitsMapper.fromDTO(
|
||||
LimitsDTO(
|
||||
tier: verified ? 'verified' : 'basic',
|
||||
rows: rows,
|
||||
simulated: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WalletLimitsService implements LimitsService {
|
||||
const WalletLimitsService(this.wallet);
|
||||
final DummyWalletService wallet;
|
||||
@override
|
||||
Future<Limits> fetch() => wallet.fetchLimits();
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import '../models/wallet.dart';
|
||||
|
||||
class AccountDTO {
|
||||
AccountDTO(this.id, this.currency, this.precision, this.available);
|
||||
final String id, currency;
|
||||
final int precision;
|
||||
int available;
|
||||
}
|
||||
|
||||
class RequestDTO {
|
||||
const RequestDTO(
|
||||
this.kind,
|
||||
this.source,
|
||||
this.destination,
|
||||
this.amount,
|
||||
this.recipient,
|
||||
);
|
||||
final String kind, source, recipient;
|
||||
final String? destination;
|
||||
final int amount;
|
||||
}
|
||||
|
||||
class QuoteDTO {
|
||||
const QuoteDTO(
|
||||
this.id,
|
||||
this.request,
|
||||
this.fee,
|
||||
this.debit,
|
||||
this.credit,
|
||||
this.expires,
|
||||
this.numerator,
|
||||
this.denominator,
|
||||
);
|
||||
final String id;
|
||||
final RequestDTO request;
|
||||
final int fee, debit, credit, numerator, denominator;
|
||||
final DateTime expires;
|
||||
}
|
||||
|
||||
class OperationDTO {
|
||||
OperationDTO(
|
||||
this.id,
|
||||
this.quote,
|
||||
this.created,
|
||||
this.ready, {
|
||||
this.status = 'pending',
|
||||
this.fail = false,
|
||||
});
|
||||
final String id;
|
||||
final QuoteDTO quote;
|
||||
final DateTime created, ready;
|
||||
String status;
|
||||
final bool fail;
|
||||
}
|
||||
|
||||
abstract final class WalletMapper {
|
||||
static CurrencyAccount account(AccountDTO d) => CurrencyAccount(
|
||||
id: d.id,
|
||||
currency: d.currency,
|
||||
precision: d.precision,
|
||||
available: d.available,
|
||||
);
|
||||
static RequestDTO request(TransferRequest r) =>
|
||||
RequestDTO(r.kind.name, r.source, r.destination, r.amount, r.recipient);
|
||||
static TransferRequest fromRequest(RequestDTO d) => TransferRequest(
|
||||
kind: TransferKind.values.byName(d.kind),
|
||||
source: d.source,
|
||||
destination: d.destination,
|
||||
amount: d.amount,
|
||||
recipient: d.recipient,
|
||||
);
|
||||
static Quote quote(QuoteDTO d) => Quote(
|
||||
id: d.id,
|
||||
request: fromRequest(d.request),
|
||||
fee: d.fee,
|
||||
debit: d.debit,
|
||||
credit: d.credit,
|
||||
expiresAt: d.expires,
|
||||
rateNumerator: d.numerator,
|
||||
rateDenominator: d.denominator,
|
||||
);
|
||||
static WalletOperation operation(OperationDTO d) => WalletOperation(
|
||||
id: d.id,
|
||||
quote: quote(d.quote),
|
||||
status: OperationStatus.values.byName(d.status),
|
||||
createdAt: d.created,
|
||||
);
|
||||
static AccountProfile profile(String locale) => AccountProfile(locale);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export 'src/models/wallet.dart';
|
||||
export 'src/providers/wallet.dart';
|
||||
export 'src/providers/transfer.dart';
|
||||
export 'src/services/contracts.dart';
|
||||
export 'src/services/dummy.dart';
|
||||
export 'src/providers/limits.dart';
|
||||
@@ -0,0 +1,17 @@
|
||||
name: sxchnge_wallet
|
||||
description: Shared currency accounts, transaction flows and account preferences.
|
||||
version: 0.1.0
|
||||
publish_to: none
|
||||
environment:
|
||||
sdk: '>=3.13.0 <4.0.0'
|
||||
flutter: '>=3.47.0'
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
sxchnge_analytics:
|
||||
path: ../sxchnge_analytics
|
||||
sxchnge_kyc:
|
||||
path: ../sxchnge_kyc
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
@@ -0,0 +1,289 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
|
||||
void main() {
|
||||
late DateTime now;
|
||||
late DummyWalletService s;
|
||||
late WalletProvider wallet;
|
||||
late DummyAnalyticsService analytics;
|
||||
setUp(() {
|
||||
now = DateTime(2026, 9, 12, 10);
|
||||
s = DummyWalletService(
|
||||
DummyIdentityService(delay: Duration.zero),
|
||||
delay: Duration.zero,
|
||||
now: () => now,
|
||||
);
|
||||
analytics = DummyAnalyticsService();
|
||||
wallet = WalletProvider(s, analytics);
|
||||
});
|
||||
tearDown(() => wallet.dispose());
|
||||
Future<WalletOperation> transfer(
|
||||
TransferKind kind,
|
||||
int amount, {
|
||||
String source = 'eur',
|
||||
String? to,
|
||||
String recipient = 'Sample recipient',
|
||||
String key = 'one',
|
||||
}) async {
|
||||
final q = await s.quote(
|
||||
TransferRequest(
|
||||
kind: kind,
|
||||
source: source,
|
||||
destination: to,
|
||||
amount: amount,
|
||||
recipient: recipient,
|
||||
),
|
||||
);
|
||||
return s.submit(q.id, key);
|
||||
}
|
||||
|
||||
test(
|
||||
'funding credits only on completion; history and available balance agree',
|
||||
() async {
|
||||
final o = await transfer(TransferKind.topUp, 2500);
|
||||
expect(o.status, OperationStatus.pending);
|
||||
expect((await s.fetch()).accounts.first.available, 7500);
|
||||
now = now.add(const Duration(seconds: 3));
|
||||
final state = await s.fetch();
|
||||
expect(state.accounts.first.available, 10000);
|
||||
expect(state.operations.single.status, OperationStatus.completed);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'exchange debits total once, then credits target and updates usage',
|
||||
() async {
|
||||
final q = await s.quote(
|
||||
const TransferRequest(
|
||||
kind: TransferKind.exchange,
|
||||
source: 'eur',
|
||||
destination: 'usd',
|
||||
amount: 2000,
|
||||
),
|
||||
);
|
||||
expect(q.debit, 2010);
|
||||
expect(q.credit, 2200);
|
||||
await Future.wait([s.submit(q.id, 'same'), s.submit(q.id, 'same')]);
|
||||
var data = await s.fetch();
|
||||
expect(data.accounts.first.available, 5490);
|
||||
expect(data.accounts[1].available, 0);
|
||||
expect(data.operations.length, 1);
|
||||
var limits = await s.fetchLimits();
|
||||
expect(limits.rows.first.reservedMinor, 2010);
|
||||
expect(limits.rows.first.usedMinor, 0);
|
||||
now = now.add(const Duration(seconds: 3));
|
||||
data = await s.fetch();
|
||||
expect(data.accounts[1].available, 2200);
|
||||
limits = await s.fetchLimits();
|
||||
expect(limits.rows.first.usedMinor, 2010);
|
||||
expect(limits.rows.first.reservedMinor, 0);
|
||||
expect(limits.rows.first.remainingMinor, 7990);
|
||||
await expectLater(
|
||||
s.submit(q.id, 'different'),
|
||||
throwsA(isA<WalletException>()),
|
||||
);
|
||||
},
|
||||
);
|
||||
test('failed payout restores amount and fee, releases limit usage', () async {
|
||||
s.failNextOperation = true;
|
||||
await transfer(TransferKind.payout, 2000);
|
||||
expect((await s.fetch()).accounts.first.available, 5400);
|
||||
now = now.add(const Duration(seconds: 3));
|
||||
final data = await s.fetch();
|
||||
expect(data.accounts.first.available, 7500);
|
||||
expect(data.operations.single.status, OperationStatus.failed);
|
||||
expect((await s.fetchLimits()).rows.first.remainingMinor, 10000);
|
||||
});
|
||||
test('expired quotes and stale balance cannot execute', () async {
|
||||
final q = await s.quote(
|
||||
const TransferRequest(
|
||||
kind: TransferKind.payout,
|
||||
source: 'eur',
|
||||
amount: 7000,
|
||||
recipient: 'Sample recipient',
|
||||
),
|
||||
);
|
||||
now = now.add(const Duration(seconds: 61));
|
||||
await expectLater(
|
||||
s.submit(q.id, 'expired'),
|
||||
throwsA(
|
||||
isA<WalletException>().having(
|
||||
(e) => e.code,
|
||||
'code',
|
||||
WalletError.expiredQuote,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect((await s.fetch()).accounts.first.available, 7500);
|
||||
final a = await s.quote(
|
||||
const TransferRequest(
|
||||
kind: TransferKind.payout,
|
||||
source: 'eur',
|
||||
amount: 6000,
|
||||
recipient: 'Sample recipient',
|
||||
),
|
||||
);
|
||||
final b = await s.quote(
|
||||
const TransferRequest(
|
||||
kind: TransferKind.payout,
|
||||
source: 'eur',
|
||||
amount: 6000,
|
||||
recipient: 'Sample recipient',
|
||||
),
|
||||
);
|
||||
await s.submit(a.id, 'first');
|
||||
await expectLater(
|
||||
s.submit(b.id, 'second'),
|
||||
throwsA(
|
||||
isA<WalletException>().having(
|
||||
(e) => e.code,
|
||||
'code',
|
||||
WalletError.insufficientFunds,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
test('limits block a funded account independently of balance', () async {
|
||||
await transfer(TransferKind.topUp, 20000);
|
||||
now = now.add(const Duration(seconds: 3));
|
||||
await s.fetch();
|
||||
await expectLater(
|
||||
s.quote(
|
||||
const TransferRequest(
|
||||
kind: TransferKind.payout,
|
||||
source: 'eur',
|
||||
amount: 11000,
|
||||
recipient: 'Sample recipient',
|
||||
),
|
||||
),
|
||||
throwsA(
|
||||
isA<WalletException>().having(
|
||||
(e) => e.code,
|
||||
'code',
|
||||
WalletError.limitExceeded,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
test('decimal parser accepts locale decimal, rejects grouping and excess precision', () {
|
||||
const a = CurrencyAccount(
|
||||
id: 'eur',
|
||||
currency: 'EUR',
|
||||
precision: 2,
|
||||
available: 0,
|
||||
);
|
||||
expect(a.parse('12,34'), 1234);
|
||||
expect(a.parse('12.3'), 1230);
|
||||
expect(a.parse('1,000.00'), null);
|
||||
expect(a.parse('1.234'), null);
|
||||
expect(a.parse('-2'), null);
|
||||
expect(a.parse('1e5'), null);
|
||||
});
|
||||
test(
|
||||
'preferences are service-owned; failed save preserves stored locale',
|
||||
() async {
|
||||
expect((await s.profile()).locale, 'en');
|
||||
await wallet.saveLocale('ru');
|
||||
expect((await s.profile()).locale, 'ru');
|
||||
s.nextError = WalletError.unavailable;
|
||||
expect(await wallet.saveLocale('en'), false);
|
||||
expect((await s.profile()).locale, 'ru');
|
||||
expect(wallet.saveFailed, true);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'provider reconciles lost response and records settlement once',
|
||||
() async {
|
||||
// Use real-clock quote lifetime for the provider UI expiry check.
|
||||
final service = DummyWalletService(
|
||||
DummyIdentityService(delay: Duration.zero),
|
||||
delay: Duration.zero,
|
||||
settlement: Duration.zero,
|
||||
);
|
||||
final p = WalletProvider(service, analytics);
|
||||
addTearDown(p.dispose);
|
||||
await p.refresh();
|
||||
final draft = TransferProvider(p, TransferKind.exchange);
|
||||
addTearDown(draft.dispose);
|
||||
draft.change(amount: '20');
|
||||
await draft.review();
|
||||
service.loseNextResponse = true;
|
||||
await draft.submit();
|
||||
expect(draft.uncertain, true);
|
||||
expect(draft.result, null);
|
||||
await draft.reconcile();
|
||||
expect(draft.uncertain, false);
|
||||
expect(draft.result, isNotNull);
|
||||
await p.refresh();
|
||||
await p.refresh();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(p.data!.operations.length, 1);
|
||||
expect(
|
||||
analytics.events
|
||||
.where((e) => e.step == JourneyStep.settlement && e.terminal)
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
analytics.events.every(
|
||||
(e) =>
|
||||
!e.properties.containsKey('amount') &&
|
||||
!e.properties.containsKey('recipient'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'pending operations refresh balances and limits without a detail screen',
|
||||
() async {
|
||||
await wallet.refresh();
|
||||
final limits = WorkspaceLimitsProvider(WalletLimitsService(s), wallet);
|
||||
addTearDown(limits.dispose);
|
||||
await limits.refresh();
|
||||
s.failNextOperation = true;
|
||||
await transfer(TransferKind.payout, 2000);
|
||||
await wallet.refresh();
|
||||
expect(wallet.account('eur')!.available, 5400);
|
||||
now = now.add(const Duration(seconds: 3));
|
||||
final done = Completer<void>();
|
||||
void changed() {
|
||||
if (wallet.operation(wallet.data!.operations.single.id)?.status ==
|
||||
OperationStatus.failed &&
|
||||
!done.isCompleted)
|
||||
done.complete();
|
||||
}
|
||||
|
||||
wallet.addListener(changed);
|
||||
await done.future.timeout(const Duration(seconds: 5));
|
||||
wallet.removeListener(changed);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
expect(wallet.account('eur')!.available, 7500);
|
||||
expect(limits.data!.rows.first.remainingMinor, 10000);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'closing a pending operation does not report draft abandonment',
|
||||
() async {
|
||||
final r = JourneyRecorder(analytics);
|
||||
final op = r.begin(
|
||||
JourneyKind.exchange,
|
||||
JourneyStep.settlement,
|
||||
simulated: true,
|
||||
);
|
||||
expect(op, isNotNull);
|
||||
r.dispose(abandonActive: false);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(
|
||||
analytics.events.any((e) => e.signal == JourneySignal.abandoned),
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user