Plan exchange workspace and transaction journeys #25

Merged
tech merged 3 commits from exchange-shell-24 into main 2026-09-12 10:13:01 +00:00
74 changed files with 8016 additions and 247 deletions
Showing only changes of commit 3c56fad4bf - Show all commits

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.
+2 -2
View File
@@ -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.
+2
View File
@@ -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
+31
View File
@@ -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
View File
@@ -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.
+47 -8
View File
@@ -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
View File
@@ -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.
+4 -5
View File
@@ -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,
+9 -4
View File
@@ -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 => 'Создать новую операцию';
}
+37
View File
@@ -0,0 +1,37 @@
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import '../generated/i18n/app_localizations.dart';
String statusTitle(AppLocalizations l, IdentityStatus s) => switch (s) {
IdentityStatus.unverified => l.kycUnverified,
IdentityStatus.pending => l.kycPending,
IdentityStatus.approved => l.kycApproved,
IdentityStatus.changesRequested => l.kycChanges,
IdentityStatus.denied => l.kycDenied,
};
String statusBody(AppLocalizations l, IdentityStatus s) => switch (s) {
IdentityStatus.unverified => l.kycUnverifiedBody,
IdentityStatus.pending => l.kycPendingBody,
IdentityStatus.approved => l.kycApprovedBody,
IdentityStatus.changesRequested => l.kycChangesBody,
IdentityStatus.denied => l.kycDeniedBody,
};
String reasonText(AppLocalizations l, ReviewReason r) => switch (r) {
ReviewReason.passportUnreadable => l.kycPassportReason,
ReviewReason.selfieUnreadable => l.kycSelfieReason,
ReviewReason.passportExpired => l.kycExpiredReason,
ReviewReason.unableToVerify => l.kycDeniedReason,
};
String errorText(AppLocalizations l, KycError e) => switch (e) {
KycError.unavailable => l.kycErrorUnavailable,
KycError.internal => l.kycErrorInternal,
KycError.permission => l.kycErrorPermission,
KycError.format => l.kycErrorFormat,
KycError.tooLarge => l.kycErrorLarge,
KycError.unreadable => l.kycErrorUnreadable,
KycError.tooSmall => l.kycErrorSmall,
KycError.incomplete => l.kycErrorIncomplete,
KycError.sessionExpired => l.kycErrorSession,
KycError.rateLimited => l.kycErrorRate,
KycError.conflict => l.kycErrorConflict,
};
+87
View File
@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'photo.dart';
import '../generated/i18n/app_localizations.dart';
class IdentityForm extends StatelessWidget {
const IdentityForm({super.key, required this.provider});
final IdentityProvider provider;
@override
Widget build(BuildContext context) {
final p = provider;
final l = AppLocalizations.of(context)!;
final titles = [l.kycPassport, l.kycSelfie, l.kycReview];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Semantics(
header: true,
liveRegion: true,
child: Text(
'${p.step + 1} / 3 · ${titles[p.step]}',
style: Theme.of(context).textTheme.headlineSmall,
),
),
const SizedBox(height: 16),
LinearProgressIndicator(
value: (p.step + 1) / 3,
semanticsLabel: titles[p.step],
),
const SizedBox(height: 16),
if (p.step < 2)
PhotoPanel(
kind: p.step == 0 ? PhotoKind.passport : PhotoKind.selfie,
photo: p.step == 0 ? p.passport : p.selfie,
onPick: p.busy
? null
: () => p.pick(
p.step == 0 ? PhotoKind.passport : PhotoKind.selfie,
),
onRemove: p.busy
? null
: () => p.remove(
p.step == 0 ? PhotoKind.passport : PhotoKind.selfie,
),
),
if (p.step == 2) ...[
Text(l.kycReviewHelp),
const SizedBox(height: 16),
PhotoPanel(kind: PhotoKind.passport, photo: p.passport, review: true),
PhotoPanel(kind: PhotoKind.selfie, photo: p.selfie, review: true),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
value: p.confirmed,
onChanged: p.busy ? null : (v) => p.confirm(v ?? false),
title: Text(l.kycConfirm),
),
],
const SizedBox(height: 16),
if (p.step < 2) ...[
if ((p.step == 0 ? p.passport : p.selfie) == null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(l.kycChooseFirst),
),
FilledButton(
key: const ValueKey('kyc-next'),
onPressed: p.busy || (p.step == 0 ? p.passport : p.selfie) == null
? null
: p.next,
child: Text(l.kycNext),
),
] else
FilledButton(
key: const ValueKey('kyc-submit'),
onPressed: p.canSubmit ? p.submit : null,
child: Text(l.kycSubmit),
),
if (p.step > 0)
TextButton(onPressed: p.busy ? null : p.back, child: Text(l.kycBack)),
],
);
}
}
+132
View File
@@ -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,
),
],
);
}
+223
View File
@@ -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),
),
],
),
),
),
),
),
),
);
},
),
);
}
+78
View File
@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'preview.dart';
import '../generated/i18n/app_localizations.dart';
class PhotoPanel extends StatelessWidget {
const PhotoPanel({
super.key,
required this.kind,
required this.photo,
this.onPick,
this.onRemove,
this.review = false,
});
final PhotoKind kind;
final Photo? photo;
final VoidCallback? onPick;
final VoidCallback? onRemove;
final bool review;
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final t = Theme.of(context);
final passport = kind == PhotoKind.passport;
final title = passport ? l.kycPassport : l.kycSelfie;
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(title, style: t.textTheme.titleLarge),
const SizedBox(height: 16),
if (photo != null)
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: PhotoPreview(photo: photo!, label: title),
)
else
Container(
height: 140,
decoration: BoxDecoration(
color: t.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
),
child: Icon(
passport ? Icons.badge_outlined : Icons.portrait_outlined,
size: 64,
color: t.colorScheme.primary,
),
),
if (!review) ...[
const SizedBox(height: 16),
Text(passport ? l.kycPassportHelp : l.kycSelfieHelp),
const SizedBox(height: 12),
Text(l.kycFileRules, style: t.textTheme.bodySmall),
const SizedBox(height: 16),
OutlinedButton.icon(
key: ValueKey('pick-${kind.name}'),
onPressed: onPick,
icon: const Icon(Icons.add_photo_alternate_outlined),
label: Text(
photo == null ? l.kycChoose : l.kycReplace,
textAlign: TextAlign.center,
),
),
if (photo != null)
TextButton(onPressed: onRemove, child: Text(l.kycRemove)),
],
],
),
),
);
}
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import '../generated/i18n/app_localizations.dart';
/// Evict this sensitive preview's decoded cache entry on replacement or exit.
class PhotoPreview extends StatefulWidget {
const PhotoPreview({super.key, required this.photo, required this.label});
final Photo photo;
final String label;
@override
State<PhotoPreview> createState() => _PhotoPreviewState();
}
class _PhotoPreviewState extends State<PhotoPreview> {
late ImageProvider _image = _provider();
ImageProvider _provider() =>
ResizeImage.resizeIfNeeded(1000, null, MemoryImage(widget.photo.bytes));
@override
void didUpdateWidget(covariant PhotoPreview oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.photo, widget.photo)) {
_image.evict();
_image = _provider();
}
}
@override
void dispose() {
_image.evict();
super.dispose();
}
@override
Widget build(BuildContext context) => Image(
image: _image,
height: 220,
fit: BoxFit.contain,
semanticLabel: widget.label,
errorBuilder: (_, _, _) =>
Text(AppLocalizations.of(context)!.kycErrorUnreadable),
);
}
+158
View File
@@ -0,0 +1,158 @@
import 'package:flutter/material.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'copy.dart';
import '../generated/i18n/app_localizations.dart';
class StatusPanel extends StatelessWidget {
const StatusPanel({super.key, required this.provider});
final IdentityProvider provider;
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final t = Theme.of(context);
final p = provider;
final record = p.record!;
final status = record.status;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: t.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(24),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
switch (status) {
IdentityStatus.approved => Icons.verified_outlined,
IdentityStatus.pending => Icons.hourglass_top,
IdentityStatus.changesRequested ||
IdentityStatus.denied => Icons.info_outline,
_ => Icons.shield_outlined,
},
color: t.colorScheme.onPrimaryContainer,
size: 36,
),
const SizedBox(height: 16),
Semantics(
header: true,
liveRegion: true,
child: Text(
statusTitle(l, status),
key: ValueKey('kyc-${status.name}'),
style: t.textTheme.headlineMedium?.copyWith(
color: t.colorScheme.onPrimaryContainer,
),
),
),
const SizedBox(height: 12),
Text(
statusBody(l, status),
style: TextStyle(color: t.colorScheme.onPrimaryContainer),
),
if (record.reason != null) ...[
const SizedBox(height: 12),
Text(
reasonText(l, record.reason!),
style: TextStyle(
color: t.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.bold,
),
),
],
],
),
),
const SizedBox(height: 20),
if (record.canSubmit)
FilledButton(
key: const ValueKey('kyc-start'),
onPressed: p.busy || p.stale ? null : p.start,
child: Text(
status == IdentityStatus.changesRequested
? l.kycResubmit
: l.kycStart,
),
),
if (status == IdentityStatus.denied)
OutlinedButton(
onPressed: () => showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: Text(l.kycSupport),
content: Text(l.kycSupportBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l.kycBack),
),
],
),
),
child: Text(l.kycSupport),
),
if (status == IdentityStatus.pending &&
p.service is DummyIdentityService)
ExpansionTile(
title: Text(l.kycDemoControls),
children: [
Wrap(
spacing: 8,
runSpacing: 8,
children: [
TextButton(
onPressed: p.busy
? null
: () => p.demoReview(IdentityStatus.approved),
child: Text(l.kycDemoApprove),
),
TextButton(
onPressed: p.busy
? null
: () => p.demoReview(
IdentityStatus.changesRequested,
reason: ReviewReason.passportUnreadable,
),
child: Text(l.kycDemoCorrect),
),
TextButton(
onPressed: p.busy
? null
: () => p.demoReview(
IdentityStatus.changesRequested,
reason: ReviewReason.selfieUnreadable,
),
child: Text(l.kycDemoSelfie),
),
TextButton(
onPressed: p.busy
? null
: () => p.demoReview(
IdentityStatus.changesRequested,
reason: ReviewReason.passportExpired,
),
child: Text(l.kycDemoExpired),
),
TextButton(
onPressed: p.busy
? null
: () => p.demoReview(
IdentityStatus.denied,
reason: ReviewReason.unableToVerify,
),
child: Text(l.kycDemoDeny),
),
],
),
],
),
],
);
}
}
+165 -10
View File
@@ -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
View File
@@ -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": "Создать новую операцию"
}
+30 -3
View File
@@ -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)),
+25
View File
@@ -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();
}
}
+92 -1
View File
@@ -1,15 +1,105 @@
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(
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: '/',
name: AppRoutes.login,
@@ -32,6 +122,7 @@ GoRouter createAppRouter() => GoRouter(
// 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
View File
@@ -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';
+12 -19
View File
@@ -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),
-54
View File
@@ -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),
],
),
),
);
}
}
+245
View File
@@ -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),
),
],
),
),
),
),
);
}
}
+196
View File
@@ -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),
],
),
),
),
);
}
}
+52
View File
@@ -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,
};
+80
View File
@@ -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),
),
],
),
),
),
),
);
}
}
+152
View File
@@ -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),
),
],
],
),
),
),
);
}
}
+132
View File
@@ -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),
),
],
),
),
),
);
}
}
+130
View File
@@ -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]),
],
),
);
}
}
+84
View File
@@ -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,
),
],
);
}
+308
View File
@@ -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),
),
],
],
],
),
),
],
),
),
),
);
},
);
}
+134
View File
@@ -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:
+4
View File
@@ -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:
+199
View File
@@ -0,0 +1,199 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'package:sxchnge_app/main.dart';
import 'package:sxchnge_app/providers/locale.dart';
class Photos implements PhotoService {
Photos(this.bytes);
final Uint8List bytes;
KycError? error;
@override
Future<Photo?> pick() async {
if (error != null) throw KycException(error!);
return Photo(bytes);
}
}
Future<void> tap(WidgetTester t, Finder finder) async {
await t.ensureVisible(finder);
await t.tap(finder);
await t.pumpAndSettle();
}
void main() {
late Uint8List bytes;
setUpAll(() async {
final r = ui.PictureRecorder();
final c = ui.Canvas(r);
c.drawRect(const Rect.fromLTWH(0, 0, 600, 600), Paint());
final pic = r.endRecording();
final image = await pic.toImage(600, 600);
bytes = (await image.toByteData(format: ui.ImageByteFormat.png))!.buffer
.asUint8List();
image.dispose();
pic.dispose();
});
Future<DummyIdentityService> open(
WidgetTester t, {
Photos? photos,
Locale? locale,
}) async {
t.platformDispatcher.defaultRouteNameTestValue = '/identity';
addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue);
final service = DummyIdentityService(delay: Duration.zero);
final provider = LocaleProvider();
if (locale != null) await provider.setLocale(locale);
await t.pumpWidget(
SxchngeApp(
identityService: service,
photoService: photos ?? Photos(bytes),
localeProvider: provider,
),
);
await t.pumpAndSettle();
return service;
}
Future<void> submit(WidgetTester t) async {
await tap(t, find.byKey(const ValueKey('kyc-start')));
await tap(t, find.byKey(const ValueKey('pick-passport')));
await tap(t, find.byKey(const ValueKey('kyc-next')));
await tap(t, find.byKey(const ValueKey('pick-selfie')));
await tap(t, find.byKey(const ValueKey('kyc-next')));
await tap(t, find.byType(CheckboxListTile));
await tap(t, find.byKey(const ValueKey('kyc-submit')));
}
testWidgets('two photos, review, pending and approval with service limits', (
t,
) async {
await open(t);
expect(find.byKey(const ValueKey('kyc-unverified')), findsOneWidget);
await submit(t);
expect(find.byKey(const ValueKey('kyc-pending')), findsOneWidget);
await tap(t, find.byType(ExpansionTile));
await tap(t, find.text('Approve'));
expect(find.byKey(const ValueKey('kyc-approved')), findsOneWidget);
expect(find.text('Verified'), findsOneWidget);
expect(t.takeException(), null);
});
testWidgets('denial provides explanation and support, no resubmit', (
t,
) async {
await open(t);
await submit(t);
await tap(t, find.byType(ExpansionTile));
await tap(t, find.text('Decline'));
expect(find.byKey(const ValueKey('kyc-denied')), findsOneWidget);
expect(find.byKey(const ValueKey('kyc-start')), findsNothing);
await tap(t, find.text('Contact support'));
expect(find.byType(AlertDialog), findsOneWidget);
});
testWidgets('correctable rejection can be resubmitted', (t) async {
await open(t);
await submit(t);
await tap(t, find.byType(ExpansionTile));
await tap(t, find.text('Request clearer passport'));
expect(find.byKey(const ValueKey('kyc-changesRequested')), findsOneWidget);
await submit(t);
expect(find.byKey(const ValueKey('kyc-pending')), findsOneWidget);
});
testWidgets('picker error recovery and discard confirmation', (t) async {
final photos = Photos(bytes)..error = KycError.permission;
await open(t, photos: photos);
await tap(t, find.byKey(const ValueKey('kyc-start')));
await tap(t, find.byKey(const ValueKey('pick-passport')));
expect(find.byKey(const ValueKey('kyc-error')), findsOneWidget);
photos.error = null;
await tap(t, find.byKey(const ValueKey('pick-passport')));
await tap(t, find.text('Keep basic limits'));
expect(find.byType(AlertDialog), findsOneWidget);
await tap(t, find.text('Keep editing'));
expect(find.byKey(const ValueKey('pick-passport')), findsOneWidget);
await tap(t, find.text('Keep basic limits'));
await tap(t, find.text('Discard & leave'));
expect(find.byKey(const ValueKey('kyc-unverified')), findsOneWidget);
});
testWidgets(
'320px Russian at large text works through submission and keyboard',
(t) async {
t.view.physicalSize = const Size(320, 900);
t.view.devicePixelRatio = 1;
t.platformDispatcher.textScaleFactorTestValue = 1.6;
addTearDown(t.view.resetPhysicalSize);
addTearDown(t.view.resetDevicePixelRatio);
addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue);
await open(t, locale: const Locale('ru'));
expect(find.text('Верификация и лимиты'), findsOneWidget);
await t.sendKeyEvent(LogicalKeyboardKey.tab);
await t.pump();
await submit(t);
expect(find.byKey(const ValueKey('kyc-pending')), findsOneWidget);
expect(t.takeException(), null);
},
);
testWidgets('status failure exposes retry without asserting basic state', (
t,
) async {
t.platformDispatcher.defaultRouteNameTestValue = '/identity';
addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue);
final service = DummyIdentityService(delay: Duration.zero)
..nextError = KycError.unavailable;
await t.pumpWidget(
SxchngeApp(
identityService: service,
photoService: Photos(bytes),
analyticsService: DummyAnalyticsService(),
),
);
await t.pumpAndSettle();
expect(find.byKey(const ValueKey('kyc-unverified')), findsNothing);
await tap(t, find.text('Refresh status & limits'));
expect(find.byKey(const ValueKey('kyc-unverified')), findsOneWidget);
});
testWidgets('route guard preserves draft when navigation is cancelled', (
t,
) async {
await open(t);
await tap(t, find.byKey(const ValueKey('kyc-start')));
await tap(t, find.byKey(const ValueKey('pick-passport')));
final router = GoRouter.of(t.element(find.text('Verification & limits')));
router.go('/');
await t.pumpAndSettle();
expect(find.byType(AlertDialog), findsOneWidget);
await tap(t, find.text('Keep editing'));
expect(find.byKey(const ValueKey('pick-passport')), findsOneWidget);
router.go('/');
await t.pumpAndSettle();
await tap(t, find.text('Discard & leave'));
expect(find.byKey(const ValueKey('kyc-start')), findsNothing);
expect(find.text('Explore 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();
});
}
+88
View File
@@ -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);
},
);
}
}
+1 -9
View File
@@ -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);
+1 -1
View File
@@ -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(
+4 -4
View File
@@ -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(
+7 -10
View File
@@ -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);
});
}
+220
View File
@@ -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();
}
+3
View File
@@ -0,0 +1,3 @@
.dart_tool/
build/
pubspec.lock
+43
View File
@@ -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,
);
},
);
}