Build exchange workspace with clear money movement #29
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
.PHONY: test-frontend
|
.PHONY: test-frontend
|
||||||
|
|
||||||
test-frontend:
|
test-frontend:
|
||||||
|
cd frontend/packages/sxchnge_wallet && flutter test
|
||||||
cd frontend/packages/sxchnge_kyc && flutter test
|
cd frontend/packages/sxchnge_kyc && flutter test
|
||||||
cd frontend/packages/sxchnge_analytics && flutter test
|
cd frontend/packages/sxchnge_analytics && flutter test
|
||||||
cd frontend/packages/sxchnge_auth && flutter test
|
cd frontend/packages/sxchnge_auth && flutter test
|
||||||
|
|||||||
@@ -172,8 +172,7 @@ limitations in the task and [implementation record](docs/implementation.md).
|
|||||||
|
|
||||||
## Identity verification preview
|
## Identity verification preview
|
||||||
|
|
||||||
Open `/#/identity` or select **Explore verification & limits** from login or email
|
Open `/#/identity` or use **Limits → Verify identity** in the workspace. The optional flow covers passport and selfie selection, review,
|
||||||
confirmation. The optional flow covers passport and selfie selection, review,
|
|
||||||
submission, pending review, approval, correction requests and denial in EN/RU.
|
submission, pending review, approval, correction requests and denial in EN/RU.
|
||||||
Use sample images only: photos are never uploaded and drafts are not persisted.
|
Use sample images only: photos are never uploaded and drafts are not persisted.
|
||||||
|
|
||||||
@@ -184,3 +183,20 @@ review decisions demonstrate the future backend contract. Higher limits apply
|
|||||||
only when returned by that service. Production review/storage/support and financial
|
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,
|
authorization are not connected. See the [UX plan](docs/ux/kyc.md) for state, error,
|
||||||
privacy and measurement details.
|
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.
|
||||||
@@ -195,3 +195,42 @@ essential information, accessibility, and recovery. Documentation only.
|
|||||||
Validation: skill-creator `quick_validate.py` passed in an isolated environment
|
Validation: skill-creator `quick_validate.py` passed in an isolated environment
|
||||||
with PyYAML (the system Python lacked it); `git diff --check` passed. No runtime
|
with PyYAML (the system Python lacked it); `git diff --check` passed. No runtime
|
||||||
tests were needed. The PR is not merged.
|
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.
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
# Exchange workspace — UX plan
|
||||||
|
|
||||||
|
Task [#24](https://git.sendico.io/tech/sxchnge/issues/24). Approved plan; implementation tracked in task #28.
|
||||||
|
|
||||||
|
## Product direction
|
||||||
|
|
||||||
|
Make it easy to fund a currency account, exchange into another currency, and pay
|
||||||
|
out. Accounts answer “what can I use?”, Activity answers “what happened?”, Limits
|
||||||
|
answers “what can I do next?”, and Settings owns account preferences.
|
||||||
|
|
||||||
|
A new user should see a clear first step: **Top up an account**. A funded user
|
||||||
|
should be able to exchange or pay out from that account without navigating through
|
||||||
|
settings or a generic dashboard. Unverified is a valid state with basic limits.
|
||||||
|
KYC is offered when an upgrade is useful, not as a forced onboarding detour.
|
||||||
|
|
||||||
|
Use the existing SXCHNGE theme, rounded surfaces, typography and semantic colors.
|
||||||
|
Keep decoration secondary to balances, amounts, recipients and transaction terms.
|
||||||
|
|
||||||
|
## Navigation and screen hierarchy
|
||||||
|
|
||||||
|
| Destination | What it contains | Main next action |
|
||||||
|
|---|---|---|
|
||||||
|
| 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 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**.
|
||||||
|
Mobile: the same four destinations in bottom navigation. Highlight the active
|
||||||
|
section. Identity verification is reached from Limits and Settings; it does not
|
||||||
|
need a separate primary navigation item.
|
||||||
|
|
||||||
|
Top up, exchange and pay out are actions, not three competing dashboard tabs.
|
||||||
|
On Accounts and currency-detail screens, put them beside the selected account on
|
||||||
|
wide screens and in a clearly labeled action area beneath its balance on mobile.
|
||||||
|
The transaction flow uses a focused page with Back and Close, a visible source
|
||||||
|
account and a review step. Avoid full-screen wizard chrome on simple settings.
|
||||||
|
|
||||||
|
Proposed routes, to be finalized during implementation:
|
||||||
|
|
||||||
|
| Route | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `/accounts` | Overview |
|
||||||
|
| `/accounts/:accountId` | Currency account detail |
|
||||||
|
| `/top-up` | Account → method → instructions/payment → operation |
|
||||||
|
| `/exchange` | Source/destination/amount → quote review → operation |
|
||||||
|
| `/payout` | Account/method/recipient/amount → review → operation |
|
||||||
|
| `/activity` | Operation list |
|
||||||
|
| `/activity/:operationId` | Canonical operation detail |
|
||||||
|
| `/limits` | Usage, constraints and KYC upgrade |
|
||||||
|
| `/identity` | Existing identity verification flow |
|
||||||
|
| `/settings` | Account settings |
|
||||||
|
| `/settings/preferences` | Language and other supported preferences |
|
||||||
|
| `/settings/security` | Supported security actions |
|
||||||
|
|
||||||
|
Use opaque IDs in paths. Never put recipient details, document data, amounts,
|
||||||
|
verification tokens or sensitive filter text into route parameters/history.
|
||||||
|
URLs identify screens; providers hold transient input. Deep links to operation
|
||||||
|
status must reload authorized service data, not depend on a previous screen.
|
||||||
|
|
||||||
|
## Accounts overview
|
||||||
|
|
||||||
|
The first viewport should show account balances and useful actions, rather than
|
||||||
|
charts or promotional blocks.
|
||||||
|
|
||||||
|
```text
|
||||||
|
ACCOUNTS Account menu
|
||||||
|
|
||||||
|
Your accounts [Add currency account, if supported]
|
||||||
|
[ EUR · available balance ] [ USD · available balance ] …
|
||||||
|
|
||||||
|
Selected account: EUR
|
||||||
|
Available: …
|
||||||
|
[Top up] [Exchange] [Pay out]
|
||||||
|
|
||||||
|
Action needed / in progress (only when there is something useful to show)
|
||||||
|
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.
|
||||||
|
- 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.
|
||||||
|
- Zero balance: explain that the account is ready and make **Top up** primary.
|
||||||
|
Funded account: make **Exchange** primary, with Top up and Pay out visible.
|
||||||
|
Never rearrange actions while the user is interacting with them.
|
||||||
|
- No accounts: offer account creation only if the backend supports it. Otherwise
|
||||||
|
explain provisioning status and provide Refresh/Help; do not invent accounts.
|
||||||
|
- Partial failure: keep successfully loaded accounts usable. Mark stale balances,
|
||||||
|
offer refresh, and obtain fresh eligibility/quote data before execution.
|
||||||
|
- Context travels into the flow: starting from EUR preselects EUR. Account switching
|
||||||
|
invalidates any quote and refreshes methods and eligibility.
|
||||||
|
|
||||||
|
## Top-up journey
|
||||||
|
|
||||||
|
**Outcome: funds are credited and become available**, not merely instructions shown
|
||||||
|
or a payment button pressed.
|
||||||
|
|
||||||
|
1. Choose the receiving account. If there is one eligible account, preselect it.
|
||||||
|
2. Choose from service-provided methods. Show fees, minimum/maximum, expected timing
|
||||||
|
when supplied, and whether an external step is required. Do not advertise rails
|
||||||
|
that are not actually available.
|
||||||
|
3. Collect only the fields required by the selected method. Amount is required
|
||||||
|
only when that method needs it. Validate currency precision and current bounds.
|
||||||
|
4. Review what the user pays, fees, and what is expected to be credited. For bank
|
||||||
|
instructions, show exact destination/reference from the service with labeled
|
||||||
|
copy actions and “Copied” feedback. For redirects, explain the next step before
|
||||||
|
opening the provider. Never collect card details in generic custom fields.
|
||||||
|
5. Create/continue the operation and show its canonical status. “Awaiting payment”
|
||||||
|
or “Processing” remains pending until the service confirms credit.
|
||||||
|
6. On credit, refresh balances, usage and activity. Offer **Exchange this balance**
|
||||||
|
with the funded currency preselected, plus View account.
|
||||||
|
|
||||||
|
Returned from a provider does not mean paid. Reconcile using the operation ID.
|
||||||
|
Handle abandoned redirects, payment decline, expired instructions, wrong/partial
|
||||||
|
amount, delayed credit and service failure with method-specific service responses.
|
||||||
|
Do not invent a completion SLA. Never expose a “Mark paid” control as proof of funds.
|
||||||
|
|
||||||
|
## Exchange journey
|
||||||
|
|
||||||
|
**Outcome: the exchange is executed and both currency accounts reflect the result.**
|
||||||
|
|
||||||
|
1. One composition screen: source account, destination currency/account, “You send”
|
||||||
|
and “You receive.” Clearly mark which amount is editable; editing the other
|
||||||
|
side requests a new quote if the backend supports receive-exact quotations.
|
||||||
|
2. Show available source balance and eligibility near the input. Currency swap
|
||||||
|
exchanges the selection and invalidates the prior quote. Unsupported pairs are
|
||||||
|
explained before the review step. Offer Add account only if supported.
|
||||||
|
3. Quote response owns the rate, fees, total debit, net credit, quote ID and expiry.
|
||||||
|
Show **1 source currency = … destination currency**, the actual debit and net
|
||||||
|
receipt; never bury fees behind an unexplained estimate.
|
||||||
|
4. **Review exchange** summarizes both accounts, amounts, fees and rate. The final
|
||||||
|
button says **Exchange [amount/currency]**. Disable final execution while the
|
||||||
|
quote is missing, stale, expired or being refreshed, with a nearby reason.
|
||||||
|
5. If terms change, show the new quote and require confirmation again. Never
|
||||||
|
silently execute a refreshed rate. Prevent double submission and reuse the
|
||||||
|
idempotency key when recovering the same request.
|
||||||
|
6. Processing leads to operation detail. Only service-confirmed execution shows
|
||||||
|
**Exchange complete**. Refresh both accounts, activity and limits. Offer
|
||||||
|
**Pay out [destination currency]** and View account.
|
||||||
|
|
||||||
|
“Max” must use a service-provided spendable amount inclusive of fees and applicable
|
||||||
|
limits; do not assume the displayed balance can all be exchanged. If the amount is
|
||||||
|
above the current permitted limit, show the actual blocker and an upgrade link only
|
||||||
|
when KYC would help. Keep the entered amount when requesting a new quote.
|
||||||
|
|
||||||
|
## Payout journey
|
||||||
|
|
||||||
|
**Outcome: the payout reaches a service-confirmed terminal result.** Distinguish
|
||||||
|
“submitted,” “sent” and “received” when those are separate states in the contract.
|
||||||
|
|
||||||
|
1. Preselect the source currency account. Choose a supported payout method and an
|
||||||
|
existing recipient, or enter a new destination. Saved recipients are optional
|
||||||
|
future functionality; do not imply they exist without a service.
|
||||||
|
2. Method-specific fields and validation come from the service contract. Clearly
|
||||||
|
identify the beneficiary/destination, bank or network when applicable. Do not
|
||||||
|
guess which currencies support which destination types.
|
||||||
|
3. Enter amount; show fee, total debit, destination amount/currency, available funds,
|
||||||
|
limits and expected timing supplied by the backend.
|
||||||
|
4. Review the recipient and full destination sufficiently to detect a mistake,
|
||||||
|
alongside the amount and costs. The final button says **Pay out [amount/currency]**.
|
||||||
|
Explain irreversibility where applicable; avoid a redundant confirmation dialog
|
||||||
|
after an already clear review page. Perform step-up authentication when required
|
||||||
|
by the backend, without losing the pending operation context.
|
||||||
|
5. Show processing/status with a reference. Cancellation is offered only when the
|
||||||
|
service says the operation remains cancellable. If supported, confirm the
|
||||||
|
cancellation and refresh status rather than optimistically removing the payout.
|
||||||
|
6. On confirmed success, show the outcome and account/activity links. A returned or
|
||||||
|
reversed payout remains visible with its own explanation and linked reversal.
|
||||||
|
|
||||||
|
Insufficient funds should offer **Top up this account** or **Exchange into this
|
||||||
|
currency**, preserving the draft locally. After funding, revalidate recipient,
|
||||||
|
fees, available balance and limits before allowing submission. Never auto-submit.
|
||||||
|
A transfer destination must never be included in analytics, logs or URLs.
|
||||||
|
|
||||||
|
## Shared transaction states and recovery
|
||||||
|
|
||||||
|
| State | User-facing behavior |
|
||||||
|
|---|---|
|
||||||
|
| Initial/draft | Clear primary action, no invented balances/rates |
|
||||||
|
| Loading balance/methods/quote | Specific progress; keep unrelated navigation usable |
|
||||||
|
| Validation issue | Inline field message and focus on first problem; preserve input |
|
||||||
|
| Not eligible | Reason plus relevant next step: email, limits, account funding or support |
|
||||||
|
| No supported method/pair | Explain unavailability; let user change the selection |
|
||||||
|
| Quote expired | Keep input; request a new quote and require fresh confirmation |
|
||||||
|
| Submission in progress | Prevent duplicate execution and show progress |
|
||||||
|
| Connection lost after submit | “Checking the outcome”; retrieve by operation/idempotency ID, not a fresh submission |
|
||||||
|
| Pending external/human processing | Status and next useful action; pending is not failure or abandonment |
|
||||||
|
| Confirmed failure | Safe reason, support/reference where available; retry only when service permits |
|
||||||
|
| Confirmed success | Authoritative receipt, refreshed balances/limits and useful next action |
|
||||||
|
| Session expired | Reauthenticate and rehydrate operation status; do not retain sensitive fields in browser storage |
|
||||||
|
|
||||||
|
Close/back preserves an in-memory draft for a clear session-scoped lifetime.
|
||||||
|
Warn before discarding meaningful recipient/payment input. Quotes always expire
|
||||||
|
independently of the draft. Hard reload can discard draft input; submitted operations
|
||||||
|
must survive because they are reloaded from the backend. Logging out clears all
|
||||||
|
account-scoped providers and drafts to prevent cross-account leakage.
|
||||||
|
|
||||||
|
## Activity and operation detail
|
||||||
|
|
||||||
|
Activity is the common destination for the outcome of every transaction flow.
|
||||||
|
Show type, source/destination currencies, meaningful amount(s), time, status and
|
||||||
|
reference. Exchanges are one parent operation showing both debit and credit, not
|
||||||
|
two unrelated rows that look like duplicate transactions.
|
||||||
|
|
||||||
|
- Filters: all/top-ups/exchanges/payouts, status, currency/account and date range.
|
||||||
|
Preserve filters when returning from a detail page. Provide Clear filters and
|
||||||
|
distinguish “no operations yet” from “no matching results” and a load failure.
|
||||||
|
- Account-detail activity is the same view scoped to that account. Paginate with
|
||||||
|
stable service cursors; refresh without duplicating rows.
|
||||||
|
- Detail includes status timeline, amount breakdown, fees, relevant destination,
|
||||||
|
timestamps, available receipt/reference and service-permitted actions.
|
||||||
|
- Pending items show what is known and whether the user needs to act. Avoid endless
|
||||||
|
spinners or a fake percentage for external settlement.
|
||||||
|
- **Try again** creates a new draft only after confirmed failure and backend
|
||||||
|
permission; never replays a consequential request from a history button.
|
||||||
|
|
||||||
|
## Limits and identity
|
||||||
|
|
||||||
|
Reuse the current KYC and usage work ([PR #21](https://git.sendico.io/tech/sxchnge/pulls/21),
|
||||||
|
[PR #23](https://git.sendico.io/tech/sxchnge/pulls/23)). Limits must show used,
|
||||||
|
reserved, available and caps for the relevant operation/currency/period. Include
|
||||||
|
window/reset information only when supplied; do not imply a calendar reset for a
|
||||||
|
rolling window. Preserve service ordering and applicability.
|
||||||
|
|
||||||
|
The full page explains active tier and what verification would unlock. Transaction
|
||||||
|
forms show the relevant constraint in context and link to that page. A KYC upgrade
|
||||||
|
must not claim to resolve insufficient funds, an unsupported method or an unrelated
|
||||||
|
restriction. Submission/pending review does not increase limits; refetch the actual
|
||||||
|
backend projection after any decision.
|
||||||
|
|
||||||
|
## Account settings and language
|
||||||
|
|
||||||
|
**Language belongs to the account.** Remove page-level language selectors from the
|
||||||
|
main workspace and identity pages. Settings → Preferences contains English/Russian
|
||||||
|
with one clear selected value. Change language without resetting drafts or filters.
|
||||||
|
|
||||||
|
- Profile service owns `preferredLocale`; an account provider loads and updates it.
|
||||||
|
Device storage is a cache/guest fallback, not the source of truth for a signed-in
|
||||||
|
account. Use an injectable stub until profile endpoints are available.
|
||||||
|
- On sign-in, the server preference takes precedence. Before account creation,
|
||||||
|
use browser/device locale plus a remembered guest preference. If a guest needs
|
||||||
|
a manual override, expose it once through the auth shell's settings/help entry,
|
||||||
|
not on every login/verification page.
|
||||||
|
- On save failure, clearly state “Not saved to your account,” retain the choice for
|
||||||
|
the current session and offer Retry. Do not report a successful account update.
|
||||||
|
- Server-provided human-readable labels must be fetched for the selected language;
|
||||||
|
preserve stable IDs and in-progress inputs. Avoid mixed-language stale responses.
|
||||||
|
- Security settings show only capabilities actually implemented (password change,
|
||||||
|
sessions, MFA when supported). Do not present decorative toggles as protection.
|
||||||
|
- Identity status is linked here but managed in the existing verification flow.
|
||||||
|
Notification preferences require a real preference service; support has a genuine
|
||||||
|
destination before production. No placeholder action should pretend to complete.
|
||||||
|
|
||||||
|
## Service and architecture boundaries
|
||||||
|
|
||||||
|
Keep shared DTO → mapper → service → model → provider ownership for future mobile
|
||||||
|
reuse. Widgets consume provider state; they do not compute financial outcomes.
|
||||||
|
Proposed domains, not speculative API endpoints:
|
||||||
|
|
||||||
|
| Domain service | Owns authoritative data |
|
||||||
|
|---|---|
|
||||||
|
| Accounts | Account IDs, currencies, booked/available/held balances, status |
|
||||||
|
| Funding | Eligible methods, instructions, fees, top-up creation/status |
|
||||||
|
| Exchange | Pair eligibility, quotes/expiry, idempotent execution |
|
||||||
|
| Payout | Method/recipient validation, quotes, submission and permissible actions |
|
||||||
|
| Operations | History, canonical status, references, receipts and reconciliation |
|
||||||
|
| Limits | Applicable caps, committed/reserved usage, remaining and upgrade projection |
|
||||||
|
| Profile | Account preferences including language |
|
||||||
|
|
||||||
|
Use currency-aware decimal/minor-unit values with currency precision metadata;
|
||||||
|
never floating-point money or a universal two-decimal assumption. Balance held
|
||||||
|
amounts and limits reserved usage are different concepts, even when values match.
|
||||||
|
Backend authorization and limit reservations remain necessary at execution time.
|
||||||
|
A visible balance, valid quote or frontend KYC state cannot authorize a payment.
|
||||||
|
|
||||||
|
For the demo, shared stub state must be coherent across screens: exchange affects
|
||||||
|
both balances; pending payouts hold capacity; failures release simulated holds
|
||||||
|
where appropriate; activity and limits reflect the same simulated operation.
|
||||||
|
Never mix a simulated transaction with a live success claim. Avoid seeded history
|
||||||
|
that contradicts displayed balances or usage without explaining the fixtures.
|
||||||
|
|
||||||
|
## Responsiveness, accessibility and trust
|
||||||
|
|
||||||
|
- Mobile: one-column forms and review summaries; no horizontal table required to
|
||||||
|
complete a task. Wide screens may place quote summary beside input.
|
||||||
|
- Large values and long beneficiary names wrap safely. Use EN/RU, realistic currency
|
||||||
|
precision and text scaling in design/test fixtures.
|
||||||
|
- Monetary fields identify currency and precision; keyboard input accepts the active
|
||||||
|
locale's decimal separator without silently altering value. Avoid formatting the
|
||||||
|
value under the user's cursor on every keystroke.
|
||||||
|
- Provide labeled 48px targets, visible keyboard focus and predictable focus after
|
||||||
|
validation/navigation. Statuses have text/icons, never color alone.
|
||||||
|
- Announce meaningful quote/state changes, not every countdown tick. Honor reduced
|
||||||
|
motion. An expiry countdown accompanies a clear expired state.
|
||||||
|
- Mask sensitive destinations in lists, reveal enough in explicit review/detail
|
||||||
|
to verify them. No session replay/autocapture on money, recipient or identity forms.
|
||||||
|
- Keep unrelated accounts/history accessible during a pending operation. Do not
|
||||||
|
block the whole product behind an exchange spinner.
|
||||||
|
|
||||||
|
## Journey measurement plan
|
||||||
|
|
||||||
|
Reuse the typed analytics abstraction and maintain a separate journey per active
|
||||||
|
transaction draft. Random journey IDs correlate steps; opaque operation IDs link
|
||||||
|
accepted requests to server outcomes. Retries reuse the journey and idempotency
|
||||||
|
lineage. Never capture amount, balance, currency-account IDs, recipient, form input,
|
||||||
|
URLs or raw exceptions in client UX events by default.
|
||||||
|
|
||||||
|
| Journey | Start | Useful states | Actual success |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Top up | User begins funding | Method ready, instructions/payment ready, accepted/pending, failure | Service-confirmed credit |
|
||||||
|
| Exchange | User begins exchange | Valid quote, reviewed, accepted/pending, expired/failure | Service-confirmed execution |
|
||||||
|
| Payout | User begins payout | Valid destination/quote, reviewed, accepted/pending, failure | Contract-defined confirmed payout outcome |
|
||||||
|
|
||||||
|
Measure start-to-acceptance and acceptance-to-terminal conversion separately. Use
|
||||||
|
one count per journey for draft conversion and one per operation for financial
|
||||||
|
outcomes. Track review-to-submit drop-off, quote expiry, recoverable failure rate,
|
||||||
|
time to acceptance and server time to terminal outcome. Classify failures with safe
|
||||||
|
enums, such as insufficient funds, limits, expired quote, unavailable method,
|
||||||
|
session and service error; emitted events must have a concrete diagnostic use.
|
||||||
|
|
||||||
|
An explicit draft discard or 30 minutes of settled inactivity is draft abandonment.
|
||||||
|
Changing tabs while preserving a draft is not immediate abandonment. Accepted
|
||||||
|
operations leave the draft abandonment window and remain pending until authoritative
|
||||||
|
status; app closure must not fabricate failure. Multi-session reconciliation and
|
||||||
|
server-side outcome events are required before claiming production funnel coverage.
|
||||||
|
Deduplicate by event/operation identity. Analytics errors must not delay execution.
|
||||||
|
|
||||||
|
Account/language preference updates reuse profile outcome signals; do not add
|
||||||
|
click events for every setting, card or navigation tab. Simulated events are always
|
||||||
|
flagged and excluded from real transaction/verification conversion dashboards.
|
||||||
|
|
||||||
|
## Implementation sequence after approval
|
||||||
|
|
||||||
|
1. **Shared workspace foundation:** accounts/operations/profile contracts and coherent
|
||||||
|
stubs, shell/routes, account overview/detail, Activity/detail, account language
|
||||||
|
provider/settings. Move language selectors into settings. Preserve auth/email
|
||||||
|
and existing identity/limits navigation.
|
||||||
|
2. **Top-up vertical slice:** method/instruction/review/status, balance refresh and
|
||||||
|
an exchange handoff. Include cancellation, pending and failed outcomes.
|
||||||
|
3. **Exchange vertical slice:** input/quote/review, expiry, idempotent execution,
|
||||||
|
coherent balances/history/usage and payout handoff.
|
||||||
|
4. **Payout vertical slice:** method/destination/review, step-up contract hooks,
|
||||||
|
reconciliation and confirmed failure/return paths.
|
||||||
|
5. **Integrated review:** cross-flow drafts, limits/KYC return paths, language changes,
|
||||||
|
responsive/keyboard/screen-reader checks, shared-service tests and semantic event
|
||||||
|
assertions. Complete each slice's tests as it is built, not only at the end.
|
||||||
|
|
||||||
|
Actual supported currencies/rails, account provisioning, fee/rate policies,
|
||||||
|
recipient requirements, cancellation and support contracts must come from backend
|
||||||
|
capabilities. Demo fixtures can illustrate them but must be labeled and isolated.
|
||||||
|
Real execution is outside this UX-plan task.
|
||||||
|
|
||||||
|
## Plan acceptance and validation boundary
|
||||||
|
|
||||||
|
The plan connects all requested areas and defines optional KYC, usage, language
|
||||||
|
ownership, consequential review, state recovery, privacy and measurement. Before
|
||||||
|
calling implementation complete, walk: new account → top up → exchange → payout →
|
||||||
|
Activity; pending/unknown outcome recovery; exceeded limits → KYC → resume/requote;
|
||||||
|
and locale change with a preserved draft. Test stale/out-of-order responses,
|
||||||
|
repeated submit, expired quote, zero balances, no methods, denied KYC, and logout.
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -311,8 +311,8 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
],
|
],
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => context.goNamed(AppRoutes.identity),
|
onPressed: () => context.go('/accounts'),
|
||||||
child: Text(l.kycPreview),
|
child: Text(l.wEnter),
|
||||||
),
|
),
|
||||||
Wrap(
|
Wrap(
|
||||||
alignment: WrapAlignment.center,
|
alignment: WrapAlignment.center,
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import 'package:sxchnge_theme/sxchnge_theme.dart';
|
import 'package:sxchnge_theme/sxchnge_theme.dart';
|
||||||
|
|
||||||
import '../widgets/locale.dart';
|
|
||||||
|
|
||||||
import 'brand_panel.dart';
|
import 'brand_panel.dart';
|
||||||
import 'form.dart';
|
import 'form.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
class AuthPage extends StatefulWidget {
|
class AuthPage extends StatefulWidget {
|
||||||
const AuthPage({super.key});
|
const AuthPage({super.key});
|
||||||
|
|
||||||
@@ -36,9 +38,12 @@ class _AuthPageState extends State<AuthPage> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
const Align(
|
Align(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
child: LocaleSelector(),
|
child: TextButton(
|
||||||
|
onPressed: () => context.push('/settings'),
|
||||||
|
child: Text(AppLocalizations.of(context)!.wSettings),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (!wide) ...[
|
if (!wide) ...[
|
||||||
|
|||||||
@@ -1021,6 +1021,528 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'No capacity available'**
|
/// **'No capacity available'**
|
||||||
String get kycNoCapacity;
|
String get kycNoCapacity;
|
||||||
|
|
||||||
|
/// No description provided for @wAccounts.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Accounts'**
|
||||||
|
String get wAccounts;
|
||||||
|
|
||||||
|
/// No description provided for @wActivity.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Activity'**
|
||||||
|
String get wActivity;
|
||||||
|
|
||||||
|
/// No description provided for @wLimits.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Limits'**
|
||||||
|
String get wLimits;
|
||||||
|
|
||||||
|
/// No description provided for @wSettings.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Settings'**
|
||||||
|
String get wSettings;
|
||||||
|
|
||||||
|
/// No description provided for @wHeadline.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Your money.\nYour next move.'**
|
||||||
|
String get wHeadline;
|
||||||
|
|
||||||
|
/// No description provided for @wSubheading.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Top up. Exchange. Go further.'**
|
||||||
|
String get wSubheading;
|
||||||
|
|
||||||
|
/// No description provided for @wAvailable.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Available to you'**
|
||||||
|
String get wAvailable;
|
||||||
|
|
||||||
|
/// No description provided for @wTopUp.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Top up'**
|
||||||
|
String get wTopUp;
|
||||||
|
|
||||||
|
/// No description provided for @wExchange.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Exchange'**
|
||||||
|
String get wExchange;
|
||||||
|
|
||||||
|
/// No description provided for @wPayout.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Pay out'**
|
||||||
|
String get wPayout;
|
||||||
|
|
||||||
|
/// No description provided for @wRecent.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Recent activity'**
|
||||||
|
String get wRecent;
|
||||||
|
|
||||||
|
/// No description provided for @wSeeAll.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'See all'**
|
||||||
|
String get wSeeAll;
|
||||||
|
|
||||||
|
/// No description provided for @wEmptyActivity.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Your next move starts here. Your transactions will appear below.'**
|
||||||
|
String get wEmptyActivity;
|
||||||
|
|
||||||
|
/// No description provided for @wEmptyAccounts.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Your accounts are not ready yet. Try refreshing.'**
|
||||||
|
String get wEmptyAccounts;
|
||||||
|
|
||||||
|
/// No description provided for @wRefresh.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Refresh'**
|
||||||
|
String get wRefresh;
|
||||||
|
|
||||||
|
/// No description provided for @wDemo.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Demo workspace · No real money moves.'**
|
||||||
|
String get wDemo;
|
||||||
|
|
||||||
|
/// No description provided for @wSource.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'From account'**
|
||||||
|
String get wSource;
|
||||||
|
|
||||||
|
/// No description provided for @wDestination.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'To account'**
|
||||||
|
String get wDestination;
|
||||||
|
|
||||||
|
/// No description provided for @wReceiveAccount.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Receiving account'**
|
||||||
|
String get wReceiveAccount;
|
||||||
|
|
||||||
|
/// No description provided for @wAmount.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Amount'**
|
||||||
|
String get wAmount;
|
||||||
|
|
||||||
|
/// No description provided for @wRecipient.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Recipient'**
|
||||||
|
String get wRecipient;
|
||||||
|
|
||||||
|
/// No description provided for @wRecipientHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sample recipient — demo only'**
|
||||||
|
String get wRecipientHint;
|
||||||
|
|
||||||
|
/// No description provided for @wReview.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Review'**
|
||||||
|
String get wReview;
|
||||||
|
|
||||||
|
/// No description provided for @wReviewTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'One last look.'**
|
||||||
|
String get wReviewTitle;
|
||||||
|
|
||||||
|
/// No description provided for @wFee.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Fee'**
|
||||||
|
String get wFee;
|
||||||
|
|
||||||
|
/// No description provided for @wTotalDebit.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Leaves your account'**
|
||||||
|
String get wTotalDebit;
|
||||||
|
|
||||||
|
/// No description provided for @wCredit.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Arrives in your account'**
|
||||||
|
String get wCredit;
|
||||||
|
|
||||||
|
/// No description provided for @wRecipientGets.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Recipient receives'**
|
||||||
|
String get wRecipientGets;
|
||||||
|
|
||||||
|
/// No description provided for @wYouPay.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'You pay'**
|
||||||
|
String get wYouPay;
|
||||||
|
|
||||||
|
/// No description provided for @wRate.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Exchange rate'**
|
||||||
|
String get wRate;
|
||||||
|
|
||||||
|
/// No description provided for @wQuoteNotice.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'These terms are valid for 60 seconds. Confirm them before continuing.'**
|
||||||
|
String get wQuoteNotice;
|
||||||
|
|
||||||
|
/// No description provided for @wExpired.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This quote expired. Refresh it and review the new terms.'**
|
||||||
|
String get wExpired;
|
||||||
|
|
||||||
|
/// No description provided for @wNewQuote.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Refresh quote'**
|
||||||
|
String get wNewQuote;
|
||||||
|
|
||||||
|
/// No description provided for @wConfirm.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Confirm demo transaction'**
|
||||||
|
String get wConfirm;
|
||||||
|
|
||||||
|
/// No description provided for @wEdit.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Edit details'**
|
||||||
|
String get wEdit;
|
||||||
|
|
||||||
|
/// No description provided for @wClose.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Close'**
|
||||||
|
String get wClose;
|
||||||
|
|
||||||
|
/// No description provided for @wWorking.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Working on it…'**
|
||||||
|
String get wWorking;
|
||||||
|
|
||||||
|
/// No description provided for @wPending.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Processing'**
|
||||||
|
String get wPending;
|
||||||
|
|
||||||
|
/// No description provided for @wCompleted.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Completed'**
|
||||||
|
String get wCompleted;
|
||||||
|
|
||||||
|
/// No description provided for @wFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Not completed'**
|
||||||
|
String get wFailed;
|
||||||
|
|
||||||
|
/// No description provided for @wPendingTopUp.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Your top-up is processing. Your available balance has not changed yet.'**
|
||||||
|
String get wPendingTopUp;
|
||||||
|
|
||||||
|
/// No description provided for @wPendingExchange.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The amount and fee have left your available balance. The exchanged amount will appear in the receiving account when completed.'**
|
||||||
|
String get wPendingExchange;
|
||||||
|
|
||||||
|
/// No description provided for @wPendingPayout.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The amount and fee have left your available balance. The payout is still processing.'**
|
||||||
|
String get wPendingPayout;
|
||||||
|
|
||||||
|
/// No description provided for @wCompleteTopUp.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Your account has been credited. The money is available to use.'**
|
||||||
|
String get wCompleteTopUp;
|
||||||
|
|
||||||
|
/// No description provided for @wCompleteExchange.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Your exchange is complete. The money is available in the receiving account.'**
|
||||||
|
String get wCompleteExchange;
|
||||||
|
|
||||||
|
/// No description provided for @wCompletePayout.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Your payout is complete. The amount and fee have been deducted from your account.'**
|
||||||
|
String get wCompletePayout;
|
||||||
|
|
||||||
|
/// No description provided for @wFailedTopUp.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The top-up did not complete. No money was added to your account.'**
|
||||||
|
String get wFailedTopUp;
|
||||||
|
|
||||||
|
/// No description provided for @wFailedOutgoing.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The transaction did not complete. The amount and fee are available in your account again.'**
|
||||||
|
String get wFailedOutgoing;
|
||||||
|
|
||||||
|
/// No description provided for @wDemoMethod.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Demo transfer'**
|
||||||
|
String get wDemoMethod;
|
||||||
|
|
||||||
|
/// No description provided for @wDemoMethodHelp.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Try the flow with sample data. No bank or payment provider is contacted.'**
|
||||||
|
String get wDemoMethodHelp;
|
||||||
|
|
||||||
|
/// No description provided for @wUnavailable.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Could not refresh. The displayed information may be out of date. Try again.'**
|
||||||
|
String get wUnavailable;
|
||||||
|
|
||||||
|
/// No description provided for @wInvalidAmount.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Enter an amount greater than zero, using the currency’s decimal precision.'**
|
||||||
|
String get wInvalidAmount;
|
||||||
|
|
||||||
|
/// No description provided for @wInsufficient.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Not enough available money for this amount and fee. Top up or enter a smaller amount.'**
|
||||||
|
String get wInsufficient;
|
||||||
|
|
||||||
|
/// No description provided for @wLimitError.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This amount exceeds your available limit. Check your limits before continuing.'**
|
||||||
|
String get wLimitError;
|
||||||
|
|
||||||
|
/// No description provided for @wRecipientError.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Enter a sample recipient between 6 and 140 characters.'**
|
||||||
|
String get wRecipientError;
|
||||||
|
|
||||||
|
/// No description provided for @wUnsupported.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This combination is unavailable. Choose another account.'**
|
||||||
|
String get wUnsupported;
|
||||||
|
|
||||||
|
/// No description provided for @wNotFound.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This item could not be found. Return to your accounts.'**
|
||||||
|
String get wNotFound;
|
||||||
|
|
||||||
|
/// No description provided for @wUncertain.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'We could not confirm the outcome. Check it before trying another transaction.'**
|
||||||
|
String get wUncertain;
|
||||||
|
|
||||||
|
/// No description provided for @wCheckOutcome.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Check outcome'**
|
||||||
|
String get wCheckOutcome;
|
||||||
|
|
||||||
|
/// No description provided for @wNoSubmission.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No transaction was found. You can review and confirm again.'**
|
||||||
|
String get wNoSubmission;
|
||||||
|
|
||||||
|
/// No description provided for @wDiscardTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Leave this transaction?'**
|
||||||
|
String get wDiscardTitle;
|
||||||
|
|
||||||
|
/// No description provided for @wDiscardBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Your entered details will be cleared. No new transaction will be submitted.'**
|
||||||
|
String get wDiscardBody;
|
||||||
|
|
||||||
|
/// No description provided for @wStay.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Keep editing'**
|
||||||
|
String get wStay;
|
||||||
|
|
||||||
|
/// No description provided for @wAll.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'All'**
|
||||||
|
String get wAll;
|
||||||
|
|
||||||
|
/// No description provided for @wSearch.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Search by reference'**
|
||||||
|
String get wSearch;
|
||||||
|
|
||||||
|
/// No description provided for @wNoMatches.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No matching transactions. Try another filter.'**
|
||||||
|
String get wNoMatches;
|
||||||
|
|
||||||
|
/// No description provided for @wClearFilters.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Clear filters'**
|
||||||
|
String get wClearFilters;
|
||||||
|
|
||||||
|
/// No description provided for @wReference.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Reference'**
|
||||||
|
String get wReference;
|
||||||
|
|
||||||
|
/// No description provided for @wWhen.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Created'**
|
||||||
|
String get wWhen;
|
||||||
|
|
||||||
|
/// No description provided for @wViewAccount.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'View account'**
|
||||||
|
String get wViewAccount;
|
||||||
|
|
||||||
|
/// No description provided for @wPreferences.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Make it yours.'**
|
||||||
|
String get wPreferences;
|
||||||
|
|
||||||
|
/// No description provided for @wLanguage.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Account language'**
|
||||||
|
String get wLanguage;
|
||||||
|
|
||||||
|
/// No description provided for @wLanguageHelp.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Used throughout your account.'**
|
||||||
|
String get wLanguageHelp;
|
||||||
|
|
||||||
|
/// No description provided for @wLanguageSaved.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Saved to your demo account.'**
|
||||||
|
String get wLanguageSaved;
|
||||||
|
|
||||||
|
/// No description provided for @wLanguageFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Changed for this session, but not saved to your account. Retry below.'**
|
||||||
|
String get wLanguageFailed;
|
||||||
|
|
||||||
|
/// No description provided for @wProfileError.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Account preferences could not be loaded. Try again.'**
|
||||||
|
String get wProfileError;
|
||||||
|
|
||||||
|
/// No description provided for @wSecurity.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Security'**
|
||||||
|
String get wSecurity;
|
||||||
|
|
||||||
|
/// No description provided for @wSecurityBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Live sessions, password changes and two-step verification are not connected in this demo.'**
|
||||||
|
String get wSecurityBody;
|
||||||
|
|
||||||
|
/// No description provided for @wIdentity.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Identity verification'**
|
||||||
|
String get wIdentity;
|
||||||
|
|
||||||
|
/// No description provided for @wIdentityHelp.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Optional verification for higher limits.'**
|
||||||
|
String get wIdentityHelp;
|
||||||
|
|
||||||
|
/// No description provided for @wBackLogin.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Back to login'**
|
||||||
|
String get wBackLogin;
|
||||||
|
|
||||||
|
/// No description provided for @wEnter.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Explore your accounts'**
|
||||||
|
String get wEnter;
|
||||||
|
|
||||||
|
/// No description provided for @wUsage.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Used this period'**
|
||||||
|
String get wUsage;
|
||||||
|
|
||||||
|
/// No description provided for @wLimitsScope.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Outgoing activity · EUR equivalent'**
|
||||||
|
String get wLimitsScope;
|
||||||
|
|
||||||
|
/// No description provided for @wPendingHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Refresh to check the latest status. You can keep using your other accounts.'**
|
||||||
|
String get wPendingHint;
|
||||||
|
|
||||||
|
/// No description provided for @wFundsHeading.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Where is my money?'**
|
||||||
|
String get wFundsHeading;
|
||||||
|
|
||||||
|
/// No description provided for @wAnother.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Make another transaction'**
|
||||||
|
String get wAnother;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -518,4 +518,292 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get kycNoCapacity => 'No capacity available';
|
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 wDemo => 'Demo workspace · No real money moves.';
|
||||||
|
|
||||||
|
@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 => 'Sample recipient — demo only';
|
||||||
|
|
||||||
|
@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 demo 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 wDemoMethod => 'Demo transfer';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wDemoMethodHelp =>
|
||||||
|
'Try the flow with sample data. No bank or payment provider is contacted.';
|
||||||
|
|
||||||
|
@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 demo 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 =>
|
||||||
|
'Live sessions, password changes and two-step verification are not connected in this demo.';
|
||||||
|
|
||||||
|
@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';
|
||||||
}
|
}
|
||||||
@@ -519,4 +519,289 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get kycNoCapacity => 'Доступный объём исчерпан';
|
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 wDemo => 'Демонстрация · Реальные деньги не переводятся.';
|
||||||
|
|
||||||
|
@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 wDemoMethod => 'Тестовый перевод';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wDemoMethodHelp =>
|
||||||
|
'Попробуйте сценарий с тестовыми данными. Банки и платёжные системы не подключены.';
|
||||||
|
|
||||||
|
@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 => 'Создать новую операцию';
|
||||||
}
|
}
|
||||||
@@ -50,7 +50,6 @@ class LimitsPanel extends StatelessWidget {
|
|||||||
if (data.simulated)
|
if (data.simulated)
|
||||||
Text(l.kycLimitsDemo, style: t.textTheme.bodySmall),
|
Text(l.kycLimitsDemo, style: t.textTheme.bodySmall),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(l.kycUsageHelp, style: t.textTheme.bodySmall),
|
|
||||||
if (data.rows.isEmpty) Text(l.kycLimitsEmpty),
|
if (data.rows.isEmpty) Text(l.kycLimitsEmpty),
|
||||||
for (final row in data.rows) ...[
|
for (final row in data.rows) ...[
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
@@ -69,11 +68,10 @@ class LimitsPanel extends StatelessWidget {
|
|||||||
row.currentMinor,
|
row.currentMinor,
|
||||||
row.currency,
|
row.currency,
|
||||||
),
|
),
|
||||||
_amount(context, l.kycUsed, row.usedMinor, row.currency),
|
|
||||||
_amount(
|
_amount(
|
||||||
context,
|
context,
|
||||||
l.kycReserved,
|
l.wUsage,
|
||||||
row.reservedMinor,
|
row.usedMinor + row.reservedMinor,
|
||||||
row.currency,
|
row.currency,
|
||||||
),
|
),
|
||||||
_amount(
|
_amount(
|
||||||
@@ -98,7 +96,7 @@ class LimitsPanel extends StatelessWidget {
|
|||||||
minHeight: 8,
|
minHeight: 8,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
semanticsLabel:
|
semanticsLabel:
|
||||||
'${row.period == LimitPeriod.day ? l.kycDay : l.kycMonth}: ${l.kycOccupied}',
|
'${row.period == LimitPeriod.day ? l.kycDay : l.kycMonth}: ${l.wUsage}',
|
||||||
),
|
),
|
||||||
if (row.remainingMinor <= 0) ...[
|
if (row.remainingMinor <= 0) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
|||||||
|
|
||||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
import '../auth/brand_panel.dart';
|
|
||||||
import '../routing/routes.dart';
|
|
||||||
import '../widgets/locale.dart';
|
|
||||||
import 'copy.dart';
|
import 'copy.dart';
|
||||||
import 'form.dart';
|
import 'form.dart';
|
||||||
import 'limits.dart';
|
import 'limits.dart';
|
||||||
@@ -100,7 +97,13 @@ class IdentityPageState extends State<IdentityPage> {
|
|||||||
Future<void> _leave({bool account = false}) async {
|
Future<void> _leave({bool account = false}) async {
|
||||||
if (!await allowExit() || !mounted) return;
|
if (!await allowExit() || !mounted) return;
|
||||||
_identity.exit();
|
_identity.exit();
|
||||||
if (account) context.goNamed(AppRoutes.login);
|
if (account) {
|
||||||
|
if (context.canPop()) {
|
||||||
|
context.pop();
|
||||||
|
} else {
|
||||||
|
context.go('/limits');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -136,12 +139,6 @@ class IdentityPageState extends State<IdentityPage> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
const Wrap(
|
|
||||||
alignment: WrapAlignment.spaceBetween,
|
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
|
||||||
spacing: 16,
|
|
||||||
children: [BrandWordmark(), LocaleSelector()],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 36),
|
const SizedBox(height: 36),
|
||||||
Semantics(
|
Semantics(
|
||||||
header: true,
|
header: true,
|
||||||
@@ -225,7 +222,7 @@ class IdentityPageState extends State<IdentityPage> {
|
|||||||
onPressed: p.busy
|
onPressed: p.busy
|
||||||
? null
|
? null
|
||||||
: () => _leave(account: true),
|
: () => _leave(account: true),
|
||||||
child: Text(l.kycClose),
|
child: Text(l.wLimits),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -153,5 +153,92 @@
|
|||||||
"kycReserved": "Reserved",
|
"kycReserved": "Reserved",
|
||||||
"kycUsageHelp": "Used is completed activity. Reserved is held for pending transactions. Both count toward your limit.",
|
"kycUsageHelp": "Used is completed activity. Reserved is held for pending transactions. Both count toward your limit.",
|
||||||
"kycOccupied": "Used + reserved",
|
"kycOccupied": "Used + reserved",
|
||||||
"kycNoCapacity": "No capacity available"
|
"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",
|
||||||
|
"wDemo": "Demo workspace · No real money moves.",
|
||||||
|
"wSource": "From account",
|
||||||
|
"wDestination": "To account",
|
||||||
|
"wReceiveAccount": "Receiving account",
|
||||||
|
"wAmount": "Amount",
|
||||||
|
"wRecipient": "Recipient",
|
||||||
|
"wRecipientHint": "Sample recipient — demo only",
|
||||||
|
"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 demo 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.",
|
||||||
|
"wDemoMethod": "Demo transfer",
|
||||||
|
"wDemoMethodHelp": "Try the flow with sample data. No bank or payment provider is contacted.",
|
||||||
|
"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 demo 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": "Live sessions, password changes and two-step verification are not connected in this demo.",
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
@@ -153,5 +153,92 @@
|
|||||||
"kycReserved": "Зарезервировано",
|
"kycReserved": "Зарезервировано",
|
||||||
"kycUsageHelp": "Использовано — завершённые операции. Зарезервировано — суммы, удержанные для ожидающих операций. Обе суммы учитываются в лимите.",
|
"kycUsageHelp": "Использовано — завершённые операции. Зарезервировано — суммы, удержанные для ожидающих операций. Обе суммы учитываются в лимите.",
|
||||||
"kycOccupied": "Использовано + зарезервировано",
|
"kycOccupied": "Использовано + зарезервировано",
|
||||||
"kycNoCapacity": "Доступный объём исчерпан"
|
"kycNoCapacity": "Доступный объём исчерпан",
|
||||||
|
"wAccounts": "Счета",
|
||||||
|
"wActivity": "История",
|
||||||
|
"wLimits": "Лимиты",
|
||||||
|
"wSettings": "Настройки",
|
||||||
|
"wHeadline": "Ваши деньги.\nВаш следующий шаг.",
|
||||||
|
"wSubheading": "Пополняйте. Обменивайте. Двигайтесь дальше.",
|
||||||
|
"wAvailable": "Вам доступно",
|
||||||
|
"wTopUp": "Пополнить",
|
||||||
|
"wExchange": "Обменять",
|
||||||
|
"wPayout": "Вывести",
|
||||||
|
"wRecent": "Последние операции",
|
||||||
|
"wSeeAll": "Смотреть все",
|
||||||
|
"wEmptyActivity": "Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.",
|
||||||
|
"wEmptyAccounts": "Ваши счета ещё не готовы. Попробуйте обновить страницу.",
|
||||||
|
"wRefresh": "Обновить",
|
||||||
|
"wDemo": "Демонстрация · Реальные деньги не переводятся.",
|
||||||
|
"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": "Операция не выполнена. Сумма и комиссия снова доступны на вашем счёте.",
|
||||||
|
"wDemoMethod": "Тестовый перевод",
|
||||||
|
"wDemoMethodHelp": "Попробуйте сценарий с тестовыми данными. Банки и платёжные системы не подключены.",
|
||||||
|
"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": "Создать новую операцию"
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,8 @@ import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
|||||||
|
|
||||||
import 'package:sxchnge_theme/sxchnge_theme.dart';
|
import 'package:sxchnge_theme/sxchnge_theme.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
import 'providers/locale.dart';
|
import 'providers/locale.dart';
|
||||||
import 'routing/router.dart';
|
import 'routing/router.dart';
|
||||||
|
|
||||||
@@ -28,6 +30,7 @@ class SxchngeApp extends StatefulWidget {
|
|||||||
this.analyticsService,
|
this.analyticsService,
|
||||||
this.identityService,
|
this.identityService,
|
||||||
this.limitsService,
|
this.limitsService,
|
||||||
|
this.walletService,
|
||||||
this.photoService = const FilePhotoService(),
|
this.photoService = const FilePhotoService(),
|
||||||
this.loginService = const DummyLoginService(),
|
this.loginService = const DummyLoginService(),
|
||||||
this.signupService = const DummySignupService(),
|
this.signupService = const DummySignupService(),
|
||||||
@@ -37,6 +40,7 @@ class SxchngeApp extends StatefulWidget {
|
|||||||
final AnalyticsService? analyticsService;
|
final AnalyticsService? analyticsService;
|
||||||
final IdentityService? identityService;
|
final IdentityService? identityService;
|
||||||
final LimitsService? limitsService;
|
final LimitsService? limitsService;
|
||||||
|
final WalletService? walletService;
|
||||||
final PhotoService photoService;
|
final PhotoService photoService;
|
||||||
final LoginService loginService;
|
final LoginService loginService;
|
||||||
final SignupService signupService;
|
final SignupService signupService;
|
||||||
@@ -51,7 +55,15 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
|||||||
|
|
||||||
late final _analytics = widget.analyticsService ?? DummyAnalyticsService();
|
late final _analytics = widget.analyticsService ?? DummyAnalyticsService();
|
||||||
late final _identity = widget.identityService ?? DummyIdentityService();
|
late final _identity = widget.identityService ?? DummyIdentityService();
|
||||||
late final _limits = widget.limitsService ?? DummyLimitsService(_identity);
|
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 _journeys = JourneyRecorder(_analytics);
|
||||||
late final _services = InstrumentedAuthServices(
|
late final _services = InstrumentedAuthServices(
|
||||||
recorder: _journeys,
|
recorder: _journeys,
|
||||||
@@ -82,6 +94,7 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
|||||||
_router.routeInformationProvider.removeListener(_routeChanged);
|
_router.routeInformationProvider.removeListener(_routeChanged);
|
||||||
_router.dispose();
|
_router.dispose();
|
||||||
_journeys.dispose();
|
_journeys.dispose();
|
||||||
|
_wallet.dispose();
|
||||||
_locale.dispose();
|
_locale.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -89,6 +102,7 @@ class _SxchngeAppState extends State<SxchngeApp> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => MultiProvider(
|
Widget build(BuildContext context) => MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
|
ChangeNotifierProvider.value(value: _wallet),
|
||||||
Provider<JourneyRecorder>.value(value: _journeys),
|
Provider<JourneyRecorder>.value(value: _journeys),
|
||||||
Provider<AnalyticsService>.value(value: _analytics),
|
Provider<AnalyticsService>.value(value: _analytics),
|
||||||
Provider<IdentityService>.value(value: _identity),
|
Provider<IdentityService>.value(value: _identity),
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
class LocaleProvider extends ChangeNotifier {
|
class LocaleProvider extends ChangeNotifier {
|
||||||
static const storageKey = 'sxchnge_last_locale';
|
static const storageKey = 'sxchnge_last_locale';
|
||||||
LocaleProvider({Locale initialLocale = const Locale('en'), this.preferences})
|
LocaleProvider({Locale initialLocale = const Locale('en'), this.preferences})
|
||||||
: _locale = resolve(initialLocale.languageCode, const []);
|
: _locale = resolve(initialLocale.languageCode, const []);
|
||||||
|
|
||||||
Locale _locale;
|
Locale _locale;
|
||||||
|
bool _disposed = false;
|
||||||
final SharedPreferences? preferences;
|
final SharedPreferences? preferences;
|
||||||
Future<bool> _pendingSave = Future.value(true);
|
Future<bool> _pendingSave = Future.value(true);
|
||||||
Locale get locale => _locale;
|
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) {
|
Future<bool> setLocale(Locale value) {
|
||||||
final next = resolve(value.languageCode, const []);
|
final next = resolve(value.languageCode, const []);
|
||||||
if (next == _locale) return _pendingSave;
|
if (next == _locale) return _pendingSave;
|
||||||
@@ -47,4 +66,10 @@ class LocaleProvider extends ChangeNotifier {
|
|||||||
});
|
});
|
||||||
return _pendingSave;
|
return _pendingSave;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -4,23 +4,101 @@ import 'package:flutter/widgets.dart';
|
|||||||
|
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
import '../auth/page.dart';
|
import '../auth/page.dart';
|
||||||
import '../kyc/page.dart';
|
import '../kyc/page.dart';
|
||||||
import '../verification/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';
|
import 'routes.dart';
|
||||||
|
|
||||||
GoRouter createAppRouter() {
|
GoRouter createAppRouter() {
|
||||||
|
final topUpKey = GlobalKey<TransferPageState>();
|
||||||
|
final exchangeKey = GlobalKey<TransferPageState>();
|
||||||
|
final payoutKey = GlobalKey<TransferPageState>();
|
||||||
final identityKey = GlobalKey<IdentityPageState>();
|
final identityKey = GlobalKey<IdentityPageState>();
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
// Signup email is an in-memory navigation extra, never browser-history data.
|
// Signup email is an in-memory navigation extra, never browser-history data.
|
||||||
extraCodec: const _TransientExtras(),
|
extraCodec: const _TransientExtras(),
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
ShellRoute(
|
||||||
path: '/identity',
|
builder: (context, state, child) =>
|
||||||
name: AppRoutes.identity,
|
WorkspaceShell(path: state.uri.path, child: child),
|
||||||
builder: (context, state) => IdentityPage(key: identityKey),
|
routes: [
|
||||||
onExit: (context, state) async =>
|
GoRoute(
|
||||||
await identityKey.currentState?.allowExit() ?? true,
|
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(
|
GoRoute(
|
||||||
path: '/',
|
path: '/',
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import 'package:sxchnge_auth/sxchnge_auth.dart';
|
|||||||
import '../auth/brand_panel.dart';
|
import '../auth/brand_panel.dart';
|
||||||
import '../auth/validation.dart';
|
import '../auth/validation.dart';
|
||||||
import '../routing/routes.dart';
|
import '../routing/routes.dart';
|
||||||
import '../widgets/locale.dart';
|
|
||||||
|
|
||||||
import '../generated/i18n/app_localizations.dart';
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -160,10 +159,6 @@ class _VerificationPageState extends State<VerificationPage> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
const Align(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
child: LocaleSelector(),
|
|
||||||
),
|
|
||||||
const Align(
|
const Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: BrandWordmark(),
|
child: BrandWordmark(),
|
||||||
@@ -328,8 +323,8 @@ class _VerificationPageState extends State<VerificationPage> {
|
|||||||
if (verified) ...[
|
if (verified) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => context.goNamed(AppRoutes.identity),
|
onPressed: () => context.go('/accounts'),
|
||||||
child: Text(l.kycPreview),
|
child: Text(l.wEnter),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
|
|
||||||
import '../providers/locale.dart';
|
|
||||||
|
|
||||||
import '../generated/i18n/app_localizations.dart';
|
|
||||||
|
|
||||||
class LocaleSelector extends StatelessWidget {
|
|
||||||
const LocaleSelector({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final provider = context.watch<LocaleProvider>();
|
|
||||||
return PopupMenuButton<Locale>(
|
|
||||||
key: const ValueKey('locale-selector'),
|
|
||||||
tooltip: AppLocalizations.of(context)!.language,
|
|
||||||
onSelected: (locale) async {
|
|
||||||
final saved = await provider.setLocale(locale);
|
|
||||||
if (!saved && context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(AppLocalizations.of(context)!.preferenceNotSaved),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
itemBuilder: (_) => AppLocalizations.supportedLocales
|
|
||||||
.map(
|
|
||||||
(locale) => CheckedPopupMenuItem(
|
|
||||||
value: locale,
|
|
||||||
checked: locale == provider.locale,
|
|
||||||
child: Text(locale.languageCode == 'ru' ? 'Русский' : 'English'),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.language, size: 20),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
provider.locale.languageCode.toUpperCase(),
|
|
||||||
style: Theme.of(context).textTheme.labelLarge,
|
|
||||||
),
|
|
||||||
const Icon(Icons.expand_more, size: 20),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import 'activity.dart';
|
||||||
|
import 'copy.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
class AccountsPage extends StatelessWidget {
|
||||||
|
const AccountsPage({super.key, this.accountId});
|
||||||
|
final String? accountId;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final p = context.watch<WalletProvider>();
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final t = Theme.of(context);
|
||||||
|
final c = t.colorScheme;
|
||||||
|
final accounts = p.data?.accounts ?? [];
|
||||||
|
final selected = accountId == null
|
||||||
|
? accounts.firstOrNull
|
||||||
|
: p.account(accountId);
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1120),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(l.wHeadline, style: t.textTheme.displayMedium),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: l.wRefresh,
|
||||||
|
onPressed: p.loading ? null : p.refresh,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(l.wSubheading, style: t.textTheme.bodyLarge),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
if (p.loading) const LinearProgressIndicator(),
|
||||||
|
if (p.failed)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
child: Text(l.wUnavailable, style: TextStyle(color: c.error)),
|
||||||
|
),
|
||||||
|
if (!p.loading && accounts.isEmpty) Text(l.wEmptyAccounts),
|
||||||
|
if (accountId != null &&
|
||||||
|
p.account(accountId) == null &&
|
||||||
|
!p.loading)
|
||||||
|
Text(l.wNotFound),
|
||||||
|
if (selected != null) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(selected.currency, style: t.textTheme.titleLarge),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 12,
|
||||||
|
children: [
|
||||||
|
for (final kind in TransferKind.values)
|
||||||
|
_action(
|
||||||
|
context,
|
||||||
|
kind,
|
||||||
|
selected,
|
||||||
|
primary:
|
||||||
|
kind ==
|
||||||
|
(selected.available == 0
|
||||||
|
? TransferKind.topUp
|
||||||
|
: TransferKind.exchange),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
LayoutBuilder(
|
||||||
|
builder: (context, box) {
|
||||||
|
final width = box.maxWidth >= 660
|
||||||
|
? (box.maxWidth - 32) / 3
|
||||||
|
: box.maxWidth;
|
||||||
|
return Wrap(
|
||||||
|
spacing: 16,
|
||||||
|
runSpacing: 16,
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < accounts.length; i++)
|
||||||
|
SizedBox(
|
||||||
|
width: width,
|
||||||
|
child: _AccountCard(
|
||||||
|
account: accounts[i],
|
||||||
|
selected: accounts[i].id == selected?.id,
|
||||||
|
index: i,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 36),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(l.wRecent, style: t.textTheme.headlineSmall),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => context.go('/activity'),
|
||||||
|
child: Text(l.wSeeAll),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
...?p.data?.operations
|
||||||
|
.where(
|
||||||
|
(o) =>
|
||||||
|
accountId == null ||
|
||||||
|
o.quote.request.source == accountId ||
|
||||||
|
o.quote.request.destination == accountId,
|
||||||
|
)
|
||||||
|
.take(5)
|
||||||
|
.map((o) => OperationTile(operation: o)),
|
||||||
|
if (!(p.data?.operations.any(
|
||||||
|
(o) =>
|
||||||
|
accountId == null ||
|
||||||
|
o.quote.request.source == accountId ||
|
||||||
|
o.quote.request.destination == accountId,
|
||||||
|
) ??
|
||||||
|
false))
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(28),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: c.surfaceContainer,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
child: Text(l.wEmptyActivity),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _action(
|
||||||
|
BuildContext context,
|
||||||
|
TransferKind kind,
|
||||||
|
CurrencyAccount a, {
|
||||||
|
required bool primary,
|
||||||
|
}) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
void go() => context.go(
|
||||||
|
'/${kind.name == "topUp" ? "top-up" : kind.name}',
|
||||||
|
extra: a.id,
|
||||||
|
);
|
||||||
|
return primary
|
||||||
|
? FilledButton.icon(
|
||||||
|
key: ValueKey('${kind.name}-${a.id}'),
|
||||||
|
onPressed: go,
|
||||||
|
icon: Icon(kindIcon(kind)),
|
||||||
|
label: Text(kindText(l, kind)),
|
||||||
|
)
|
||||||
|
: FilledButton.tonalIcon(
|
||||||
|
key: ValueKey('${kind.name}-${a.id}'),
|
||||||
|
onPressed: go,
|
||||||
|
icon: Icon(kindIcon(kind)),
|
||||||
|
label: Text(kindText(l, kind)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AccountCard extends StatelessWidget {
|
||||||
|
const _AccountCard({
|
||||||
|
required this.account,
|
||||||
|
required this.selected,
|
||||||
|
required this.index,
|
||||||
|
});
|
||||||
|
final CurrencyAccount account;
|
||||||
|
final bool selected;
|
||||||
|
final int index;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final t = Theme.of(context);
|
||||||
|
final c = t.colorScheme;
|
||||||
|
final bg = selected
|
||||||
|
? c.primary
|
||||||
|
: index == 1
|
||||||
|
? c.tertiaryContainer
|
||||||
|
: c.primaryContainer;
|
||||||
|
final fg = selected
|
||||||
|
? c.onPrimary
|
||||||
|
: index == 1
|
||||||
|
? c.onTertiaryContainer
|
||||||
|
: c.onPrimaryContainer;
|
||||||
|
return Semantics(
|
||||||
|
selected: selected,
|
||||||
|
child: Material(
|
||||||
|
color: bg,
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
child: InkWell(
|
||||||
|
key: ValueKey('account-${account.id}'),
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
onTap: () => context.go('/accounts/${account.id}'),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
account.currency,
|
||||||
|
style: t.textTheme.titleLarge?.copyWith(color: fg),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Icon(
|
||||||
|
selected
|
||||||
|
? Icons.check_circle_outline
|
||||||
|
: Icons.arrow_outward_rounded,
|
||||||
|
color: fg,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
Text(l.wAvailable, style: TextStyle(color: fg)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
money(context, account, account.available),
|
||||||
|
key: ValueKey('balance-${account.id}'),
|
||||||
|
style: t.textTheme.headlineMedium?.copyWith(color: fg),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import 'copy.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
class ActivityPage extends StatefulWidget {
|
||||||
|
const ActivityPage({super.key});
|
||||||
|
@override
|
||||||
|
State<ActivityPage> createState() => _ActivityPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ActivityPageState extends State<ActivityPage> {
|
||||||
|
TransferKind? _kind;
|
||||||
|
OperationStatus? _status;
|
||||||
|
String _search = '';
|
||||||
|
String? _currency;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final p = context.watch<WalletProvider>();
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final list =
|
||||||
|
p.data?.operations
|
||||||
|
.where(
|
||||||
|
(o) =>
|
||||||
|
(_kind == null || o.quote.request.kind == _kind) &&
|
||||||
|
(_status == null || o.status == _status) &&
|
||||||
|
(_currency == null ||
|
||||||
|
o.quote.request.source == _currency ||
|
||||||
|
o.quote.request.destination == _currency) &&
|
||||||
|
o.id.toLowerCase().contains(_search.toLowerCase()),
|
||||||
|
)
|
||||||
|
.toList() ??
|
||||||
|
[];
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 1000),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l.wActivity,
|
||||||
|
style: Theme.of(context).textTheme.displaySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: l.wRefresh,
|
||||||
|
onPressed: p.loading ? null : p.refresh,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
TextField(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l.wSearch,
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
),
|
||||||
|
onChanged: (value) => setState(() => _search = value),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
ChoiceChip(
|
||||||
|
label: Text(l.wAll),
|
||||||
|
selected: _kind == null,
|
||||||
|
onSelected: (_) => setState(() => _kind = null),
|
||||||
|
),
|
||||||
|
for (final k in TransferKind.values)
|
||||||
|
ChoiceChip(
|
||||||
|
label: Text(kindText(l, k)),
|
||||||
|
selected: _kind == k,
|
||||||
|
onSelected: (_) => setState(() => _kind = k),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
for (final s in OperationStatus.values)
|
||||||
|
FilterChip(
|
||||||
|
label: Text(statusText(l, s)),
|
||||||
|
selected: _status == s,
|
||||||
|
onSelected: (value) =>
|
||||||
|
setState(() => _status = value ? s : null),
|
||||||
|
),
|
||||||
|
for (final a in p.data?.accounts ?? <CurrencyAccount>[])
|
||||||
|
FilterChip(
|
||||||
|
label: Text(a.currency),
|
||||||
|
selected: _currency == a.id,
|
||||||
|
onSelected: (value) =>
|
||||||
|
setState(() => _currency = value ? a.id : null),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
if (p.loading) const LinearProgressIndicator(),
|
||||||
|
if (p.failed) Text(l.wUnavailable),
|
||||||
|
if (list.isEmpty && !p.loading)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Text(
|
||||||
|
(p.data?.operations.isEmpty ?? true)
|
||||||
|
? l.wEmptyActivity
|
||||||
|
: l.wNoMatches,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (final o in list) OperationTile(operation: o),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class OperationTile extends StatelessWidget {
|
||||||
|
const OperationTile({super.key, required this.operation});
|
||||||
|
final WalletOperation operation;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final p = context.read<WalletProvider>();
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final o = operation;
|
||||||
|
final a = p.account(o.quote.request.source);
|
||||||
|
final to = p.account(o.quote.request.destination);
|
||||||
|
final t = Theme.of(context);
|
||||||
|
return Card(
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
onTap: () => context.push('/activity/${o.id}'),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
backgroundColor: t.colorScheme.primaryContainer,
|
||||||
|
child: Icon(
|
||||||
|
kindIcon(o.quote.request.kind),
|
||||||
|
color: t.colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
kindText(l, o.quote.request.kind),
|
||||||
|
style: t.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${a?.currency ?? ''}${to == null ? '' : ' → ${to.currency}'} · ${statusText(l, o.status)}',
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
DateFormat.yMMMd(
|
||||||
|
Localizations.localeOf(context).toLanguageTag(),
|
||||||
|
).add_Hm().format(o.createdAt),
|
||||||
|
style: t.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
a == null ? '' : money(context, a, o.quote.request.amount),
|
||||||
|
textAlign: TextAlign.end,
|
||||||
|
style: t.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(Icons.chevron_right),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
String money(BuildContext context, CurrencyAccount account, int amount) =>
|
||||||
|
NumberFormat.currency(
|
||||||
|
locale: Localizations.localeOf(context).toLanguageTag(),
|
||||||
|
name: account.currency,
|
||||||
|
symbol: '${account.currency} ',
|
||||||
|
decimalDigits: account.precision,
|
||||||
|
).format(amount / account.scale);
|
||||||
|
String kindText(AppLocalizations l, TransferKind kind) => switch (kind) {
|
||||||
|
TransferKind.topUp => l.wTopUp,
|
||||||
|
TransferKind.exchange => l.wExchange,
|
||||||
|
TransferKind.payout => l.wPayout,
|
||||||
|
};
|
||||||
|
IconData kindIcon(TransferKind kind) => switch (kind) {
|
||||||
|
TransferKind.topUp => Icons.add_rounded,
|
||||||
|
TransferKind.exchange => Icons.swap_horiz_rounded,
|
||||||
|
TransferKind.payout => Icons.north_east_rounded,
|
||||||
|
};
|
||||||
|
String statusText(AppLocalizations l, OperationStatus s) => switch (s) {
|
||||||
|
OperationStatus.pending => l.wPending,
|
||||||
|
OperationStatus.completed => l.wCompleted,
|
||||||
|
OperationStatus.failed => l.wFailed,
|
||||||
|
};
|
||||||
|
String fundsText(AppLocalizations l, WalletOperation o) =>
|
||||||
|
switch ((o.status, o.quote.request.kind)) {
|
||||||
|
(OperationStatus.pending, TransferKind.topUp) => l.wPendingTopUp,
|
||||||
|
(OperationStatus.pending, TransferKind.exchange) => l.wPendingExchange,
|
||||||
|
(OperationStatus.pending, TransferKind.payout) => l.wPendingPayout,
|
||||||
|
(OperationStatus.completed, TransferKind.topUp) => l.wCompleteTopUp,
|
||||||
|
(OperationStatus.completed, TransferKind.exchange) => l.wCompleteExchange,
|
||||||
|
(OperationStatus.completed, TransferKind.payout) => l.wCompletePayout,
|
||||||
|
(OperationStatus.failed, TransferKind.topUp) => l.wFailedTopUp,
|
||||||
|
_ => l.wFailedOutgoing,
|
||||||
|
};
|
||||||
|
String walletError(AppLocalizations l, WalletError e) => switch (e) {
|
||||||
|
WalletError.invalidAmount => l.wInvalidAmount,
|
||||||
|
WalletError.insufficientFunds => l.wInsufficient,
|
||||||
|
WalletError.limitExceeded => l.wLimitError,
|
||||||
|
WalletError.expiredQuote => l.wExpired,
|
||||||
|
WalletError.invalidRecipient => l.wRecipientError,
|
||||||
|
WalletError.unsupported => l.wUnsupported,
|
||||||
|
WalletError.notFound => l.wNotFound,
|
||||||
|
WalletError.uncertain => l.wUncertain,
|
||||||
|
_ => l.wUnavailable,
|
||||||
|
};
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import '../kyc/limits.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
class LimitsPage extends StatefulWidget {
|
||||||
|
const LimitsPage({super.key});
|
||||||
|
@override
|
||||||
|
State<LimitsPage> createState() => _LimitsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LimitsPageState extends State<LimitsPage> {
|
||||||
|
late final _provider = WorkspaceLimitsProvider(
|
||||||
|
context.read<LimitsService>(),
|
||||||
|
context.read<WalletProvider>(),
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) _provider.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_provider.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
return ChangeNotifierProvider<LimitsProvider>.value(
|
||||||
|
value: _provider,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 720),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l.wLimits,
|
||||||
|
style: Theme.of(context).textTheme.displaySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(l.wLimitsScope),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const LimitsPanel(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await context.push('/identity');
|
||||||
|
if (mounted) _provider.refresh();
|
||||||
|
},
|
||||||
|
child: Text(l.wIdentity),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _provider.refresh,
|
||||||
|
child: Text(l.wRefresh),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import 'copy.dart';
|
||||||
|
import 'summary.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
class OperationPage extends StatefulWidget {
|
||||||
|
const OperationPage({super.key, required this.id});
|
||||||
|
final String id;
|
||||||
|
@override
|
||||||
|
State<OperationPage> createState() => _OperationPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OperationPageState extends State<OperationPage> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) context.read<WalletProvider>().refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final p = context.watch<WalletProvider>();
|
||||||
|
final o = p.operation(widget.id);
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final t = Theme.of(context);
|
||||||
|
final c = t.colorScheme;
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 640),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: () => context.go('/activity'),
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
label: Text(l.wActivity),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (p.failed)
|
||||||
|
Text(l.wUnavailable, style: TextStyle(color: c.error)),
|
||||||
|
if (o == null) ...[
|
||||||
|
Text(p.loading ? l.wWorking : l.wNotFound),
|
||||||
|
TextButton(
|
||||||
|
onPressed: p.loading ? null : p.refresh,
|
||||||
|
child: Text(l.wRefresh),
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(28),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: o.status == OperationStatus.failed
|
||||||
|
? c.errorContainer
|
||||||
|
: c.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
o.status == OperationStatus.completed
|
||||||
|
? Icons.check_circle_outline
|
||||||
|
: o.status == OperationStatus.pending
|
||||||
|
? Icons.schedule
|
||||||
|
: Icons.info_outline,
|
||||||
|
size: 40,
|
||||||
|
color: o.status == OperationStatus.failed
|
||||||
|
? c.onErrorContainer
|
||||||
|
: c.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Semantics(
|
||||||
|
header: true,
|
||||||
|
liveRegion: true,
|
||||||
|
child: Text(
|
||||||
|
statusText(l, o.status),
|
||||||
|
key: ValueKey('operation-${o.status.name}'),
|
||||||
|
style: t.textTheme.displaySmall?.copyWith(
|
||||||
|
color: o.status == OperationStatus.failed
|
||||||
|
? c.onErrorContainer
|
||||||
|
: c.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
fundsText(l, o),
|
||||||
|
style: TextStyle(
|
||||||
|
color: o.status == OperationStatus.failed
|
||||||
|
? c.onErrorContainer
|
||||||
|
: c.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
TransferSummary(quote: o.quote),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('${l.wReference}: ${o.id}'),
|
||||||
|
if (o.status == OperationStatus.pending) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(l.wPendingHint),
|
||||||
|
TextButton(
|
||||||
|
onPressed: p.loading ? null : p.refresh,
|
||||||
|
child: Text(l.wRefresh),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
if (o.status == OperationStatus.completed &&
|
||||||
|
o.quote.request.kind != TransferKind.payout)
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => context.go(
|
||||||
|
o.quote.request.kind == TransferKind.topUp
|
||||||
|
? '/exchange'
|
||||||
|
: '/payout',
|
||||||
|
extra:
|
||||||
|
o.quote.request.destination ?? o.quote.request.source,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
o.quote.request.kind == TransferKind.topUp
|
||||||
|
? l.wExchange
|
||||||
|
: l.wPayout,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => context.go(
|
||||||
|
'/accounts/${o.quote.request.destination ?? o.quote.request.source}',
|
||||||
|
),
|
||||||
|
child: Text(l.wViewAccount),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import '../providers/locale.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
class SettingsPage extends StatelessWidget {
|
||||||
|
const SettingsPage({super.key});
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final p = context.watch<WalletProvider>();
|
||||||
|
final locale = context.watch<LocaleProvider>();
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final t = Theme.of(context);
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 720),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (context.canPop())
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: () => context.pop(),
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
label: Text(l.kycBack),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(l.wPreferences, style: t.textTheme.displayMedium),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(l.wLanguage, style: t.textTheme.titleLarge),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(l.wLanguageHelp),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
if (p.profileLoading) const LinearProgressIndicator(),
|
||||||
|
if (p.profileFailed) ...[
|
||||||
|
Text(l.wProfileError),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => locale.loadAccount(p),
|
||||||
|
child: Text(l.wRefresh),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
key: ValueKey(
|
||||||
|
'account-language-${locale.locale.languageCode}',
|
||||||
|
),
|
||||||
|
initialValue: locale.locale.languageCode,
|
||||||
|
isExpanded: true,
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: 'en', child: Text('English')),
|
||||||
|
DropdownMenuItem(value: 'ru', child: Text('Русский')),
|
||||||
|
],
|
||||||
|
onChanged: p.saving || p.profileLoading
|
||||||
|
? null
|
||||||
|
: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
locale.saveAccountLocale(p, Locale(value));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
decoration: InputDecoration(labelText: l.wLanguage),
|
||||||
|
),
|
||||||
|
if (p.saving) const LinearProgressIndicator(),
|
||||||
|
if (p.saveFailed) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Semantics(
|
||||||
|
liveRegion: true,
|
||||||
|
child: Text(l.wLanguageFailed),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: p.saving
|
||||||
|
? null
|
||||||
|
: () =>
|
||||||
|
locale.saveAccountLocale(p, locale.locale),
|
||||||
|
child: Text(l.wRefresh),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Card(
|
||||||
|
child: ListTile(
|
||||||
|
contentPadding: const EdgeInsets.all(24),
|
||||||
|
leading: const Icon(Icons.verified_user_outlined),
|
||||||
|
title: Text(l.wIdentity),
|
||||||
|
subtitle: Text(l.wIdentityHelp),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => context.go('/identity'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(l.wSecurity, style: t.textTheme.titleLarge),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(l.wSecurityBody),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => context.go('/'),
|
||||||
|
child: Text(l.wBackLogin),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
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: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: wide
|
||||||
|
? CrossAxisAlignment.end
|
||||||
|
: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (!wide) ...[const BrandWordmark(), const SizedBox(height: 8)],
|
||||||
|
Text(l.wDemo, style: t.textTheme.bodySmall),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(child: widget.child),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return Scaffold(
|
||||||
|
body: SafeArea(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (wide)
|
||||||
|
Container(
|
||||||
|
width: 220,
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: c.surfaceContainerLow,
|
||||||
|
border: Border(right: BorderSide(color: c.outlineVariant)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 24),
|
||||||
|
child: BrandWordmark(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 48),
|
||||||
|
for (var i = 0; i < 4; i++)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: ListTile(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
),
|
||||||
|
selected: i == index,
|
||||||
|
selectedTileColor: c.primaryContainer,
|
||||||
|
leading: Icon(icons[i]),
|
||||||
|
title: Text(labels[i]),
|
||||||
|
onTap: () => navigate(i),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(child: content),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
bottomNavigationBar: wide
|
||||||
|
? null
|
||||||
|
: NavigationBar(
|
||||||
|
selectedIndex: index,
|
||||||
|
onDestinationSelected: navigate,
|
||||||
|
destinations: [
|
||||||
|
for (var i = 0; i < 4; i++)
|
||||||
|
NavigationDestination(icon: Icon(icons[i]), label: labels[i]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import 'copy.dart';
|
||||||
|
|
||||||
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
class TransferSummary extends StatelessWidget {
|
||||||
|
const TransferSummary({super.key, required this.quote});
|
||||||
|
final Quote quote;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final p = context.read<WalletProvider>();
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final q = quote;
|
||||||
|
final a = p.account(q.request.source);
|
||||||
|
final b = p.account(q.request.destination) ?? a;
|
||||||
|
if (a == null || b == null) return Text(l.wNotFound);
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_row(
|
||||||
|
context,
|
||||||
|
q.request.kind == TransferKind.topUp ? l.wYouPay : l.wTotalDebit,
|
||||||
|
money(context, a, q.debit),
|
||||||
|
large: true,
|
||||||
|
),
|
||||||
|
const Divider(height: 32),
|
||||||
|
_row(context, l.wFee, money(context, a, q.fee)),
|
||||||
|
if (q.request.kind == TransferKind.exchange) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_row(
|
||||||
|
context,
|
||||||
|
l.wRate,
|
||||||
|
'1 ${a.currency} = ${NumberFormat('0.0000', Localizations.localeOf(context).toLanguageTag()).format(q.rateNumerator / q.rateDenominator)} ${b.currency}',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_row(
|
||||||
|
context,
|
||||||
|
q.request.kind == TransferKind.payout
|
||||||
|
? l.wRecipientGets
|
||||||
|
: l.wCredit,
|
||||||
|
money(context, b, q.credit),
|
||||||
|
large: true,
|
||||||
|
),
|
||||||
|
if (q.request.kind == TransferKind.payout) ...[
|
||||||
|
const Divider(height: 32),
|
||||||
|
Text(l.wRecipient),
|
||||||
|
SelectableText(q.request.recipient),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _row(
|
||||||
|
BuildContext context,
|
||||||
|
String label,
|
||||||
|
String value, {
|
||||||
|
bool large = false,
|
||||||
|
}) => Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: large
|
||||||
|
? Theme.of(context).textTheme.headlineSmall
|
||||||
|
: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
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),
|
||||||
|
Text(l.wDemoMethodHelp, style: t.textTheme.bodySmall),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -494,6 +494,13 @@ packages:
|
|||||||
relative: true
|
relative: true
|
||||||
source: path
|
source: path
|
||||||
version: "0.1.0"
|
version: "0.1.0"
|
||||||
|
sxchnge_wallet:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
path: "../packages/sxchnge_wallet"
|
||||||
|
relative: true
|
||||||
|
source: path
|
||||||
|
version: "0.1.0"
|
||||||
term_glyph:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ environment:
|
|||||||
sdk: '>=3.13.0 <4.0.0'
|
sdk: '>=3.13.0 <4.0.0'
|
||||||
flutter: '>=3.47.0'
|
flutter: '>=3.47.0'
|
||||||
dependencies:
|
dependencies:
|
||||||
|
sxchnge_wallet:
|
||||||
|
path: ../packages/sxchnge_wallet
|
||||||
sxchnge_kyc:
|
sxchnge_kyc:
|
||||||
path: ../packages/sxchnge_kyc
|
path: ../packages/sxchnge_kyc
|
||||||
sxchnge_analytics:
|
sxchnge_analytics:
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ void main() {
|
|||||||
await t.pumpAndSettle();
|
await t.pumpAndSettle();
|
||||||
await tap(t, find.text('Discard & leave'));
|
await tap(t, find.text('Discard & leave'));
|
||||||
expect(find.byKey(const ValueKey('kyc-start')), findsNothing);
|
expect(find.byKey(const ValueKey('kyc-start')), findsNothing);
|
||||||
expect(find.text('Explore verification & limits'), findsOneWidget);
|
expect(find.text('Explore your accounts'), findsOneWidget);
|
||||||
});
|
});
|
||||||
testWidgets('overview controls have accessible labels and touch targets', (
|
testWidgets('overview controls have accessible labels and touch targets', (
|
||||||
t,
|
t,
|
||||||
|
|||||||
@@ -63,12 +63,14 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(
|
expect(
|
||||||
find.text(locale == 'en' ? 'Used' : 'Использовано'),
|
find.text(
|
||||||
|
locale == 'en' ? 'Used this period' : 'Использовано за период',
|
||||||
|
),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
find.text(locale == 'en' ? 'Reserved' : 'Зарезервировано'),
|
find.text(locale == 'en' ? 'Reserved' : 'Зарезервировано'),
|
||||||
findsOneWidget,
|
findsNothing,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
tester
|
tester
|
||||||
|
|||||||
@@ -83,15 +83,7 @@ void main() {
|
|||||||
await tester.tap(find.byKey(const ValueKey('submit')));
|
await tester.tap(find.byKey(const ValueKey('submit')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Enter a valid email address.'), findsOneWidget);
|
expect(find.text('Enter a valid email address.'), findsOneWidget);
|
||||||
await tester.ensureVisible(find.byKey(const ValueKey('locale-selector')));
|
await locale.setLocale(const Locale('ru'));
|
||||||
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 tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Введите корректный адрес почты.'), findsOneWidget);
|
expect(find.text('Введите корректный адрес почты.'), findsOneWidget);
|
||||||
expect(find.text('Алексей'), findsOneWidget);
|
expect(find.text('Алексей'), findsOneWidget);
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||||
|
|
||||||
import 'package:sxchnge_app/main.dart';
|
import 'package:sxchnge_app/main.dart';
|
||||||
|
import 'package:sxchnge_app/providers/locale.dart';
|
||||||
|
|
||||||
class RecoveringService implements VerificationService {
|
class RecoveringService implements VerificationService {
|
||||||
int sends = 0;
|
int sends = 0;
|
||||||
@@ -120,16 +123,10 @@ void main() {
|
|||||||
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||||
await openVerification(tester, route);
|
await openVerification(tester, route);
|
||||||
expect(tester.takeException(), isNull);
|
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.pumpAndSettle();
|
||||||
await tester.tap(
|
expect(find.byKey(const ValueKey('locale-selector')), findsNothing);
|
||||||
find.ancestor(
|
|
||||||
of: find.text('Русский'),
|
|
||||||
matching: find.byType(CheckedPopupMenuItem<Locale>),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
expect(find.text('RU'), findsOneWidget);
|
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_app/main.dart';
|
||||||
|
import 'package:sxchnge_app/providers/locale.dart';
|
||||||
|
|
||||||
|
Future<void> tap(WidgetTester t, Finder f) async {
|
||||||
|
await t.ensureVisible(f);
|
||||||
|
await t.tap(f);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<DummyWalletService> open(
|
||||||
|
WidgetTester t, {
|
||||||
|
String path = '/accounts',
|
||||||
|
String locale = 'en',
|
||||||
|
Duration settlement = Duration.zero,
|
||||||
|
}) async {
|
||||||
|
t.platformDispatcher.defaultRouteNameTestValue = path;
|
||||||
|
addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue);
|
||||||
|
final identity = DummyIdentityService(delay: Duration.zero);
|
||||||
|
final service = DummyWalletService(
|
||||||
|
identity,
|
||||||
|
delay: Duration.zero,
|
||||||
|
settlement: settlement,
|
||||||
|
initialLocale: locale,
|
||||||
|
);
|
||||||
|
await t.pumpWidget(
|
||||||
|
SxchngeApp(
|
||||||
|
identityService: identity,
|
||||||
|
walletService: service,
|
||||||
|
localeProvider: LocaleProvider(initialLocale: Locale(locale)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
addTearDown(() => t.pumpWidget(const SizedBox.shrink()));
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('available-only accounts expose one set of actions', (t) async {
|
||||||
|
await open(t);
|
||||||
|
expect(find.text('Reserved'), findsNothing);
|
||||||
|
expect(find.text('Held'), findsNothing);
|
||||||
|
expect(find.byKey(const ValueKey('exchange-eur')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('exchange-usd')), findsNothing);
|
||||||
|
await tap(t, find.byKey(const ValueKey('account-usd')));
|
||||||
|
expect(find.byKey(const ValueKey('topUp-usd')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('exchange-eur')), findsNothing);
|
||||||
|
});
|
||||||
|
testWidgets('top-up exchange payout end-to-end and history', (t) async {
|
||||||
|
final s = await open(t);
|
||||||
|
await tap(t, find.byKey(const ValueKey('topUp-eur')));
|
||||||
|
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10');
|
||||||
|
await tap(t, find.byKey(const ValueKey('transfer-review')));
|
||||||
|
expect(find.text('You pay'), findsOneWidget);
|
||||||
|
await tap(t, find.byKey(const ValueKey('transfer-confirm')));
|
||||||
|
expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.text(
|
||||||
|
'Your account has been credited. The money is available to use.',
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
await tap(t, find.text('Exchange').last);
|
||||||
|
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '20');
|
||||||
|
await tap(t, find.byKey(const ValueKey('transfer-review')));
|
||||||
|
expect(find.text('Leaves your account'), findsOneWidget);
|
||||||
|
expect(find.text('Fee'), findsOneWidget);
|
||||||
|
await tap(t, find.byKey(const ValueKey('transfer-confirm')));
|
||||||
|
expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget);
|
||||||
|
await tap(t, find.text('Pay out').last);
|
||||||
|
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
|
||||||
|
await t.enterText(
|
||||||
|
find.byKey(const ValueKey('transfer-recipient')),
|
||||||
|
'Sample recipient',
|
||||||
|
);
|
||||||
|
await tap(t, find.byKey(const ValueKey('transfer-review')));
|
||||||
|
expect(find.text('Recipient receives'), findsOneWidget);
|
||||||
|
await tap(t, find.byKey(const ValueKey('transfer-confirm')));
|
||||||
|
expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget);
|
||||||
|
final data = (await t.runAsync(() => s.fetch()))!;
|
||||||
|
expect(data.operations.length, 3);
|
||||||
|
expect(data.accounts.first.available, 6490);
|
||||||
|
expect(data.accounts[1].available, 1600);
|
||||||
|
});
|
||||||
|
testWidgets(
|
||||||
|
'processing payout explains reduced available balance then failed refund',
|
||||||
|
(t) async {
|
||||||
|
final s = await open(
|
||||||
|
t,
|
||||||
|
path: '/payout',
|
||||||
|
settlement: const Duration(milliseconds: 100),
|
||||||
|
);
|
||||||
|
s.failNextOperation = true;
|
||||||
|
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10');
|
||||||
|
await t.enterText(
|
||||||
|
find.byKey(const ValueKey('transfer-recipient')),
|
||||||
|
'Sample recipient',
|
||||||
|
);
|
||||||
|
await tap(t, find.byKey(const ValueKey('transfer-review')));
|
||||||
|
await tap(t, find.byKey(const ValueKey('transfer-confirm')));
|
||||||
|
// Settlement is service-owned; refresh after the simulated processing period.
|
||||||
|
await t.runAsync(
|
||||||
|
() => Future<void>.delayed(const Duration(milliseconds: 150)),
|
||||||
|
);
|
||||||
|
await t.pump(const Duration(seconds: 3));
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(find.byKey(const ValueKey('operation-failed')), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.text(
|
||||||
|
'The transaction did not complete. The amount and fee are available in your account again.',
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
((await t.runAsync(() => s.fetch()))!).accounts.first.available,
|
||||||
|
7500,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
testWidgets(
|
||||||
|
'settings saves account language and hides page language selectors',
|
||||||
|
(t) async {
|
||||||
|
final s = await open(t, path: '/settings');
|
||||||
|
expect(find.byKey(const ValueKey('locale-selector')), findsNothing);
|
||||||
|
await tap(t, find.byKey(const ValueKey('account-language-en')));
|
||||||
|
await tap(t, find.text('Русский').last);
|
||||||
|
expect(((await t.runAsync(() => s.profile()))!).locale, 'ru');
|
||||||
|
expect(find.text('Язык аккаунта'), findsWidgets);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
testWidgets(
|
||||||
|
'language save failure retains session choice and provides retry',
|
||||||
|
(t) async {
|
||||||
|
final s = await open(t, path: '/settings');
|
||||||
|
s.nextError = WalletError.unavailable;
|
||||||
|
await tap(t, find.byKey(const ValueKey('account-language-en')));
|
||||||
|
await tap(t, find.text('Русский').last);
|
||||||
|
expect(
|
||||||
|
find.text(
|
||||||
|
'Изменено для текущей сессии, но не сохранено в аккаунте. Повторите попытку ниже.',
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(((await t.runAsync(() => s.profile()))!).locale, 'en');
|
||||||
|
await tap(t, find.text('Обновить').last);
|
||||||
|
expect(((await t.runAsync(() => s.profile()))!).locale, 'ru');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
testWidgets(
|
||||||
|
'insufficient funds preserves entered amount and can be corrected',
|
||||||
|
(t) async {
|
||||||
|
await open(t, path: '/exchange');
|
||||||
|
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '900');
|
||||||
|
await tap(t, find.byKey(const ValueKey('transfer-review')));
|
||||||
|
expect(find.byKey(const ValueKey('transfer-error')), findsOneWidget);
|
||||||
|
expect(find.text('900'), findsOneWidget);
|
||||||
|
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
|
||||||
|
await tap(t, find.byKey(const ValueKey('transfer-review')));
|
||||||
|
expect(find.byKey(const ValueKey('transfer-confirm')), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
testWidgets('uncertain submission checks outcome instead of sending twice', (
|
||||||
|
t,
|
||||||
|
) async {
|
||||||
|
final s = await open(t, path: '/exchange');
|
||||||
|
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
|
||||||
|
await tap(t, find.byKey(const ValueKey('transfer-review')));
|
||||||
|
s.loseNextResponse = true;
|
||||||
|
await tap(t, find.byKey(const ValueKey('transfer-confirm')));
|
||||||
|
expect(find.text('Check outcome'), findsOneWidget);
|
||||||
|
await tap(t, find.text('Check outcome'));
|
||||||
|
expect(((await t.runAsync(() => s.fetch()))!).operations.length, 1);
|
||||||
|
expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget);
|
||||||
|
});
|
||||||
|
for (final path in [
|
||||||
|
'/accounts',
|
||||||
|
'/exchange',
|
||||||
|
'/payout',
|
||||||
|
'/activity',
|
||||||
|
'/limits',
|
||||||
|
'/settings',
|
||||||
|
]) {
|
||||||
|
testWidgets('$path fits Russian at 320px with larger text', (t) async {
|
||||||
|
t.view.physicalSize = const Size(320, 1100);
|
||||||
|
t.view.devicePixelRatio = 1;
|
||||||
|
t.platformDispatcher.textScaleFactorTestValue = 1.6;
|
||||||
|
addTearDown(t.view.resetPhysicalSize);
|
||||||
|
addTearDown(t.view.resetDevicePixelRatio);
|
||||||
|
addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue);
|
||||||
|
await open(t, path: path, locale: 'ru');
|
||||||
|
expect(t.takeException(), null);
|
||||||
|
if (path == '/exchange') {
|
||||||
|
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
|
||||||
|
await tap(t, find.byKey(const ValueKey('transfer-review')));
|
||||||
|
expect(t.takeException(), null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
testWidgets('leaving a draft requires a clear discard choice', (t) async {
|
||||||
|
await open(t, path: '/exchange');
|
||||||
|
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
|
||||||
|
final router = GoRouter.of(
|
||||||
|
t.element(find.byKey(const ValueKey('transfer-amount'))),
|
||||||
|
);
|
||||||
|
router.go('/accounts');
|
||||||
|
await t.pumpAndSettle();
|
||||||
|
expect(find.text('Leave this transaction?'), findsOneWidget);
|
||||||
|
await tap(t, find.text('Keep editing'));
|
||||||
|
expect(find.text('5'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import 'dart:async';
|
|||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
enum JourneyKind { login, signup, verification, kyc }
|
enum JourneyKind { login, signup, verification, kyc, topUp, exchange, payout }
|
||||||
|
|
||||||
enum JourneyStep {
|
enum JourneyStep {
|
||||||
login,
|
login,
|
||||||
@@ -14,6 +14,10 @@ enum JourneyStep {
|
|||||||
kycSelfie,
|
kycSelfie,
|
||||||
kycSubmission,
|
kycSubmission,
|
||||||
kycStatus,
|
kycStatus,
|
||||||
|
transferDraft,
|
||||||
|
transferQuote,
|
||||||
|
transferSubmission,
|
||||||
|
settlement,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum JourneySignal {
|
enum JourneySignal {
|
||||||
@@ -27,6 +31,9 @@ enum JourneySignal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum JourneyFailure {
|
enum JourneyFailure {
|
||||||
|
insufficientFunds,
|
||||||
|
limitExceeded,
|
||||||
|
declined,
|
||||||
document,
|
document,
|
||||||
invalidCredentials,
|
invalidCredentials,
|
||||||
emailInUse,
|
emailInUse,
|
||||||
@@ -79,7 +86,13 @@ class AnalyticsEvent {
|
|||||||
final bool simulated;
|
final bool simulated;
|
||||||
final DateTime occurredAt;
|
final DateTime occurredAt;
|
||||||
String get name {
|
String get name {
|
||||||
final area = kind == JourneyKind.kyc ? 'kyc' : 'auth';
|
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);
|
var stage = step == null ? 'journey' : _snake(step!.name);
|
||||||
if (kind == JourneyKind.kyc) stage = stage.replaceFirst('kyc_', '');
|
if (kind == JourneyKind.kyc) stage = stage.replaceFirst('kyc_', '');
|
||||||
return '${area}_${stage}_${signal.name}';
|
return '${area}_${stage}_${signal.name}';
|
||||||
@@ -189,7 +202,7 @@ class JourneyRecorder {
|
|||||||
operation._finished = true;
|
operation._finished = true;
|
||||||
journey.pending--;
|
journey.pending--;
|
||||||
journey.simulated |= simulated;
|
journey.simulated |= simulated;
|
||||||
final complete = failure == null && !cancelled && terminal;
|
final complete = !cancelled && terminal;
|
||||||
_emit(
|
_emit(
|
||||||
journey,
|
journey,
|
||||||
cancelled
|
cancelled
|
||||||
@@ -257,8 +270,8 @@ class JourneyRecorder {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void dispose() {
|
void dispose({bool abandonActive = true}) {
|
||||||
abandon(JourneyExit.disposed);
|
if (abandonActive) abandon(JourneyExit.disposed);
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
_idle?.cancel();
|
_idle?.cancel();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/// Shared limit transport contract for service adapters.
|
||||||
|
export 'src/transport/contracts.dart' show LimitsDTO, LimitDTO, LimitsMapper;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.dart_tool/
|
||||||
|
build/
|
||||||
|
pubspec.lock
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# SXCHNGE wallet
|
||||||
|
|
||||||
|
Shared Flutter domain, injectable services and providers for accounts, quotes,
|
||||||
|
operations, limits synchronization and account language. Web and mobile clients
|
||||||
|
consume the same models; widgets do not call transport APIs.
|
||||||
|
|
||||||
|
`WalletService` returns mapped domain models. `DummyWalletService` is an in-memory
|
||||||
|
backend stand-in; `WalletProvider` owns snapshots and pending-operation refresh,
|
||||||
|
`TransferProvider` owns draft/review/submission/reconciliation, and
|
||||||
|
`WorkspaceLimitsProvider` refetches the separate `LimitsService` projection when
|
||||||
|
wallet state changes. The adapter reuses the KYC package's limit DTO and mapper.
|
||||||
|
|
||||||
|
## Demo behavior
|
||||||
|
|
||||||
|
- Opening balances: EUR 75, USD 0, GBP 0. No invented historical transactions.
|
||||||
|
- Fixed demo ratios: EUR/USD 1.1; GBP uses weight 1300 vs EUR 1100 and USD 1000.
|
||||||
|
- Top-up fee zero; exchange fee 0.5% rounded down, minimum one minor unit;
|
||||||
|
payout fee 100 minor units. Quote expiry is 60 seconds.
|
||||||
|
- Top-ups credit only after settlement. Outgoing total debit includes the fee;
|
||||||
|
it leaves available balance on acceptance. Failed outgoing operations restore
|
||||||
|
that exact total. Exchange destination credits only on completion.
|
||||||
|
- Outgoing usage, including fees, is converted upward to EUR minor units.
|
||||||
|
Basic caps: EUR 100/day and 500/month; verified: 2500/day and 10000/month.
|
||||||
|
Top-ups do not consume outgoing capacity. Windows follow the stub's local clock.
|
||||||
|
Policy, usage and remaining are returned by the service, not set in widgets.
|
||||||
|
- Submission rechecks balance, limits and expiry, is idempotent by request key,
|
||||||
|
and permits a quote to execute only once. Unknown outcomes reconcile before
|
||||||
|
any resubmission. `nextError`, `loseNextResponse`, `failNextOperation`, injectable
|
||||||
|
clock/delays support deterministic failure testing.
|
||||||
|
- Everything resets on application reload. Recipient values are demo text only;
|
||||||
|
no bank details, payment provider, storage or real money movement is connected.
|
||||||
|
|
||||||
|
## Integration boundary
|
||||||
|
|
||||||
|
Replace `WalletService` and `LimitsService` together with authenticated backend
|
||||||
|
adapters. The backend must provide authorization, authoritative quotes/balances,
|
||||||
|
atomic limits enforcement, durable idempotency and operation reconciliation.
|
||||||
|
Add supported payment methods and validated recipients from that contract, rather
|
||||||
|
than interpreting the demo input as production bank validation. Replace profile
|
||||||
|
storage with the signed-in account endpoint. No authorization is supplied by
|
||||||
|
public demo routes or an available balance.
|
||||||
|
|
||||||
|
Analytics use the shared typed recorder: draft, quote, submission and authoritative
|
||||||
|
settlement. Retried submissions keep a journey; each accepted operation closes
|
||||||
|
once on success/failure. Closing the app while pending does not count as draft
|
||||||
|
abandonment. No amounts, account IDs or recipient data enter events. All demo
|
||||||
|
signals are simulated. Cross-session outcomes require backend event delivery;
|
||||||
|
client-only analytics are not production accounting or conversion evidence.
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
enum TransferKind { topUp, exchange, payout }
|
||||||
|
|
||||||
|
enum OperationStatus { pending, completed, failed }
|
||||||
|
|
||||||
|
enum WalletError {
|
||||||
|
unavailable,
|
||||||
|
invalidAmount,
|
||||||
|
insufficientFunds,
|
||||||
|
limitExceeded,
|
||||||
|
expiredQuote,
|
||||||
|
invalidRecipient,
|
||||||
|
unsupported,
|
||||||
|
notFound,
|
||||||
|
uncertain,
|
||||||
|
}
|
||||||
|
|
||||||
|
class WalletException implements Exception {
|
||||||
|
const WalletException(this.code);
|
||||||
|
final WalletError code;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CurrencyAccount {
|
||||||
|
const CurrencyAccount({
|
||||||
|
required this.id,
|
||||||
|
required this.currency,
|
||||||
|
required this.precision,
|
||||||
|
required this.available,
|
||||||
|
});
|
||||||
|
final String id, currency;
|
||||||
|
final int precision, available;
|
||||||
|
|
||||||
|
/// Locale-aware decimal input, integer money. Grouping/exponents are rejected.
|
||||||
|
int? parse(String input) {
|
||||||
|
final value = input.trim().replaceAll(',', '.');
|
||||||
|
if (!RegExp(r'^\d+(\.\d+)?$').hasMatch(value)) return null;
|
||||||
|
final parts = value.split('.');
|
||||||
|
if (parts.length > 1 && parts[1].length > precision) return null;
|
||||||
|
final major = int.tryParse(parts[0]);
|
||||||
|
if (major == null || major > 100000000) return null;
|
||||||
|
final fraction = parts.length > 1
|
||||||
|
? int.parse(parts[1].padRight(precision, '0'))
|
||||||
|
: 0;
|
||||||
|
return major * scale + fraction;
|
||||||
|
}
|
||||||
|
|
||||||
|
int get scale {
|
||||||
|
var v = 1;
|
||||||
|
for (var i = 0; i < precision; i++) {
|
||||||
|
v *= 10;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TransferRequest {
|
||||||
|
const TransferRequest({
|
||||||
|
required this.kind,
|
||||||
|
required this.source,
|
||||||
|
required this.amount,
|
||||||
|
this.destination,
|
||||||
|
this.recipient = '',
|
||||||
|
});
|
||||||
|
final TransferKind kind;
|
||||||
|
final String source;
|
||||||
|
final int amount;
|
||||||
|
final String? destination;
|
||||||
|
final String recipient;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Quote {
|
||||||
|
const Quote({
|
||||||
|
required this.id,
|
||||||
|
required this.request,
|
||||||
|
required this.fee,
|
||||||
|
required this.debit,
|
||||||
|
required this.credit,
|
||||||
|
required this.expiresAt,
|
||||||
|
required this.rateNumerator,
|
||||||
|
required this.rateDenominator,
|
||||||
|
});
|
||||||
|
final String id;
|
||||||
|
final TransferRequest request;
|
||||||
|
final int fee, debit, credit, rateNumerator, rateDenominator;
|
||||||
|
final DateTime expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
class WalletOperation {
|
||||||
|
const WalletOperation({
|
||||||
|
required this.id,
|
||||||
|
required this.quote,
|
||||||
|
required this.status,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
final String id;
|
||||||
|
final Quote quote;
|
||||||
|
final OperationStatus status;
|
||||||
|
final DateTime createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
class WalletSnapshot {
|
||||||
|
WalletSnapshot({
|
||||||
|
required List<CurrencyAccount> accounts,
|
||||||
|
required List<WalletOperation> operations,
|
||||||
|
}) : accounts = List.unmodifiable(accounts),
|
||||||
|
operations = List.unmodifiable(operations);
|
||||||
|
final List<CurrencyAccount> accounts;
|
||||||
|
final List<WalletOperation> operations;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AccountProfile {
|
||||||
|
const AccountProfile(this.locale);
|
||||||
|
final String locale;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import '../models/wallet.dart';
|
||||||
|
import 'wallet.dart';
|
||||||
|
|
||||||
|
/// Refetch the backend projection when an operation changes the wallet.
|
||||||
|
class WorkspaceLimitsProvider extends LimitsProvider {
|
||||||
|
WorkspaceLimitsProvider(super.service, this.wallet) {
|
||||||
|
_snapshot = wallet.data;
|
||||||
|
wallet.addListener(_changed);
|
||||||
|
}
|
||||||
|
final WalletProvider wallet;
|
||||||
|
WalletSnapshot? _snapshot;
|
||||||
|
void _changed() {
|
||||||
|
if (identical(_snapshot, wallet.data)) return;
|
||||||
|
_snapshot = wallet.data;
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
wallet.removeListener(_changed);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||||
|
|
||||||
|
import '../models/wallet.dart';
|
||||||
|
import 'wallet.dart';
|
||||||
|
|
||||||
|
class TransferProvider extends ChangeNotifier {
|
||||||
|
TransferProvider(this.wallet, this.kind, {String? source})
|
||||||
|
: source = source ?? wallet.data?.accounts.firstOrNull?.id ?? 'eur',
|
||||||
|
recorder = JourneyRecorder(wallet.analytics) {
|
||||||
|
destination =
|
||||||
|
wallet.data?.accounts
|
||||||
|
.where((a) => a.id != this.source)
|
||||||
|
.firstOrNull
|
||||||
|
?.id ??
|
||||||
|
'usd';
|
||||||
|
final op = recorder.begin(
|
||||||
|
journey,
|
||||||
|
JourneyStep.transferDraft,
|
||||||
|
simulated: wallet.service.isSimulated,
|
||||||
|
);
|
||||||
|
recorder.finish(op, simulated: wallet.service.isSimulated);
|
||||||
|
}
|
||||||
|
final WalletProvider wallet;
|
||||||
|
final TransferKind kind;
|
||||||
|
final JourneyRecorder recorder;
|
||||||
|
String source;
|
||||||
|
String? destination;
|
||||||
|
String amount = '', recipient = '';
|
||||||
|
Quote? quote;
|
||||||
|
WalletOperation? result;
|
||||||
|
WalletError? error;
|
||||||
|
bool noSubmission = false;
|
||||||
|
bool busy = false, uncertain = false, _disposed = false, _handedOff = false;
|
||||||
|
int _revision = 0;
|
||||||
|
String? _key;
|
||||||
|
JourneyKind get journey => switch (kind) {
|
||||||
|
TransferKind.topUp => JourneyKind.topUp,
|
||||||
|
TransferKind.exchange => JourneyKind.exchange,
|
||||||
|
TransferKind.payout => JourneyKind.payout,
|
||||||
|
};
|
||||||
|
bool get hasDraft => amount.isNotEmpty || recipient.isNotEmpty;
|
||||||
|
bool get expired =>
|
||||||
|
quote != null && !DateTime.now().isBefore(quote!.expiresAt);
|
||||||
|
void change({
|
||||||
|
String? source,
|
||||||
|
String? destination,
|
||||||
|
String? amount,
|
||||||
|
String? recipient,
|
||||||
|
}) {
|
||||||
|
if (busy || uncertain) return;
|
||||||
|
this.source = source ?? this.source;
|
||||||
|
this.destination = destination ?? this.destination;
|
||||||
|
this.amount = amount ?? this.amount;
|
||||||
|
this.recipient = recipient ?? this.recipient;
|
||||||
|
quote = null;
|
||||||
|
noSubmission = false;
|
||||||
|
error = null;
|
||||||
|
_key = null;
|
||||||
|
_revision++;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void edit() {
|
||||||
|
if (!busy && !uncertain) {
|
||||||
|
quote = null;
|
||||||
|
error = null;
|
||||||
|
_key = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> review() async {
|
||||||
|
if (busy || uncertain) return;
|
||||||
|
final money = wallet.account(source)?.parse(amount);
|
||||||
|
if (money == null || money <= 0) {
|
||||||
|
error = WalletError.invalidAmount;
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
noSubmission = false;
|
||||||
|
error = null;
|
||||||
|
notifyListeners();
|
||||||
|
final revision = ++_revision;
|
||||||
|
final op = recorder.begin(
|
||||||
|
journey,
|
||||||
|
JourneyStep.transferQuote,
|
||||||
|
simulated: wallet.service.isSimulated,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
final q = await wallet.service.quote(
|
||||||
|
TransferRequest(
|
||||||
|
kind: kind,
|
||||||
|
source: source,
|
||||||
|
amount: money,
|
||||||
|
destination: kind == TransferKind.exchange ? destination : null,
|
||||||
|
recipient: recipient.trim(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (_disposed || revision != _revision) return;
|
||||||
|
quote = q;
|
||||||
|
_key = null;
|
||||||
|
recorder.finish(op, simulated: wallet.service.isSimulated);
|
||||||
|
} catch (e) {
|
||||||
|
if (_disposed) return;
|
||||||
|
error = _code(e);
|
||||||
|
recorder.finish(op, failure: _failure(error!));
|
||||||
|
}
|
||||||
|
if (_disposed) return;
|
||||||
|
busy = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> submit() async {
|
||||||
|
if (busy || quote == null || uncertain) return;
|
||||||
|
if (expired) {
|
||||||
|
error = WalletError.expiredQuote;
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
noSubmission = false;
|
||||||
|
error = null;
|
||||||
|
notifyListeners();
|
||||||
|
_key ??= List.generate(
|
||||||
|
16,
|
||||||
|
(_) => Random.secure().nextInt(256).toRadixString(16).padLeft(2, '0'),
|
||||||
|
).join();
|
||||||
|
final op = recorder.begin(
|
||||||
|
journey,
|
||||||
|
JourneyStep.transferSubmission,
|
||||||
|
simulated: wallet.service.isSimulated,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
final r = await wallet.service.submit(quote!.id, _key!);
|
||||||
|
if (_disposed) return;
|
||||||
|
_accepted(r, op);
|
||||||
|
} catch (e) {
|
||||||
|
if (_disposed) return;
|
||||||
|
error = _code(e);
|
||||||
|
// All transport/unexpected failures are ambiguous after execution started.
|
||||||
|
uncertain = [
|
||||||
|
WalletError.unavailable,
|
||||||
|
WalletError.uncertain,
|
||||||
|
].contains(error);
|
||||||
|
recorder.finish(op, failure: _failure(error!));
|
||||||
|
}
|
||||||
|
if (_disposed) return;
|
||||||
|
busy = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _accepted(WalletOperation value, JourneyOperation op) {
|
||||||
|
result = value;
|
||||||
|
uncertain = false;
|
||||||
|
error = null;
|
||||||
|
recipient = '';
|
||||||
|
recorder.finish(op, simulated: wallet.service.isSimulated);
|
||||||
|
wallet.track(value, recorder, journey);
|
||||||
|
_handedOff = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> reconcile() async {
|
||||||
|
if (busy || !uncertain || _key == null) return;
|
||||||
|
busy = true;
|
||||||
|
noSubmission = false;
|
||||||
|
error = null;
|
||||||
|
notifyListeners();
|
||||||
|
final op = recorder.begin(
|
||||||
|
journey,
|
||||||
|
JourneyStep.transferSubmission,
|
||||||
|
simulated: wallet.service.isSimulated,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
final r = await wallet.service.findSubmission(_key!);
|
||||||
|
if (_disposed) return;
|
||||||
|
if (r != null) {
|
||||||
|
_accepted(r, op);
|
||||||
|
} else {
|
||||||
|
uncertain = false;
|
||||||
|
noSubmission = true;
|
||||||
|
recorder.finish(op, simulated: wallet.service.isSimulated);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (_disposed) return;
|
||||||
|
error = _code(e);
|
||||||
|
recorder.finish(op, failure: JourneyFailure.unavailable);
|
||||||
|
}
|
||||||
|
if (_disposed) return;
|
||||||
|
busy = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
WalletError _code(Object e) =>
|
||||||
|
e is WalletException ? e.code : WalletError.unavailable;
|
||||||
|
JourneyFailure _failure(WalletError e) => switch (e) {
|
||||||
|
WalletError.insufficientFunds => JourneyFailure.insufficientFunds,
|
||||||
|
WalletError.limitExceeded => JourneyFailure.limitExceeded,
|
||||||
|
WalletError.expiredQuote => JourneyFailure.expired,
|
||||||
|
_ => JourneyFailure.unavailable,
|
||||||
|
};
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
recipient = '';
|
||||||
|
if (!_handedOff) recorder.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||||
|
|
||||||
|
import '../models/wallet.dart';
|
||||||
|
import '../services/contracts.dart';
|
||||||
|
|
||||||
|
class WalletProvider extends ChangeNotifier {
|
||||||
|
WalletProvider(this.service, this.analytics);
|
||||||
|
final WalletService service;
|
||||||
|
final AnalyticsService analytics;
|
||||||
|
WalletSnapshot? data;
|
||||||
|
bool loading = false,
|
||||||
|
failed = false,
|
||||||
|
profileLoading = false,
|
||||||
|
profileFailed = false,
|
||||||
|
saving = false,
|
||||||
|
saveFailed = false;
|
||||||
|
String? preferredLocale;
|
||||||
|
bool _disposed = false;
|
||||||
|
int _revision = 0;
|
||||||
|
Timer? _poll;
|
||||||
|
final _settlements = <String, (JourneyRecorder, JourneyOperation)>{};
|
||||||
|
CurrencyAccount? account(String? id) {
|
||||||
|
for (final a in data?.accounts ?? <CurrencyAccount>[]) {
|
||||||
|
if (a.id == id) return a;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
WalletOperation? operation(String id) {
|
||||||
|
for (final o in data?.operations ?? <WalletOperation>[]) {
|
||||||
|
if (o.id == id) return o;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get hasPending =>
|
||||||
|
data?.operations.any((o) => o.status == OperationStatus.pending) ?? false;
|
||||||
|
Future<void> refresh() async {
|
||||||
|
final rev = ++_revision;
|
||||||
|
loading = true;
|
||||||
|
failed = false;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
final result = await service.fetch();
|
||||||
|
if (_disposed || rev != _revision) return;
|
||||||
|
data = result;
|
||||||
|
for (final o in result.operations) {
|
||||||
|
if (o.status == OperationStatus.pending) continue;
|
||||||
|
final pair = _settlements.remove(o.id);
|
||||||
|
if (pair != null) {
|
||||||
|
pair.$1.finish(
|
||||||
|
pair.$2,
|
||||||
|
simulated: service.isSimulated,
|
||||||
|
failure: o.status == OperationStatus.failed
|
||||||
|
? JourneyFailure.declined
|
||||||
|
: null,
|
||||||
|
terminal: true,
|
||||||
|
);
|
||||||
|
pair.$1.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (_disposed || rev != _revision) return;
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
if (_disposed || rev != _revision) return;
|
||||||
|
loading = false;
|
||||||
|
_poll?.cancel();
|
||||||
|
if (hasPending) {
|
||||||
|
_poll = Timer(const Duration(seconds: 3), () {
|
||||||
|
if (!_disposed) refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void track(
|
||||||
|
WalletOperation operation,
|
||||||
|
JourneyRecorder recorder,
|
||||||
|
JourneyKind kind,
|
||||||
|
) {
|
||||||
|
final step = recorder.begin(
|
||||||
|
kind,
|
||||||
|
JourneyStep.settlement,
|
||||||
|
simulated: service.isSimulated,
|
||||||
|
);
|
||||||
|
_settlements[operation.id] = (recorder, step);
|
||||||
|
final snapshot = data;
|
||||||
|
if (snapshot != null) {
|
||||||
|
data = WalletSnapshot(
|
||||||
|
accounts: snapshot.accounts,
|
||||||
|
operations: [
|
||||||
|
operation,
|
||||||
|
...snapshot.operations.where((o) => o.id != operation.id),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
unawaited(refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadProfile() async {
|
||||||
|
if (profileLoading) return;
|
||||||
|
profileLoading = true;
|
||||||
|
profileFailed = false;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
final result = await service.profile();
|
||||||
|
if (_disposed) return;
|
||||||
|
preferredLocale = result.locale;
|
||||||
|
} catch (_) {
|
||||||
|
if (_disposed) return;
|
||||||
|
profileFailed = true;
|
||||||
|
}
|
||||||
|
profileLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> saveLocale(String locale) async {
|
||||||
|
if (saving) return false;
|
||||||
|
saving = true;
|
||||||
|
saveFailed = false;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
final result = await service.saveLocale(locale);
|
||||||
|
if (_disposed) return false;
|
||||||
|
preferredLocale = result.locale;
|
||||||
|
} catch (_) {
|
||||||
|
if (_disposed) return false;
|
||||||
|
saveFailed = true;
|
||||||
|
}
|
||||||
|
saving = false;
|
||||||
|
notifyListeners();
|
||||||
|
return !saveFailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
_poll?.cancel();
|
||||||
|
for (final p in _settlements.values) {
|
||||||
|
p.$1.dispose(abandonActive: false);
|
||||||
|
}
|
||||||
|
_settlements.clear();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import '../models/wallet.dart';
|
||||||
|
|
||||||
|
abstract interface class WalletService {
|
||||||
|
bool get isSimulated;
|
||||||
|
Future<WalletSnapshot> fetch();
|
||||||
|
Future<Quote> quote(TransferRequest request);
|
||||||
|
|
||||||
|
/// Same key must reconcile uncertain submissions without duplicate execution.
|
||||||
|
Future<WalletOperation> submit(String quoteId, String idempotencyKey);
|
||||||
|
Future<WalletOperation?> findSubmission(String idempotencyKey);
|
||||||
|
Future<AccountProfile> profile();
|
||||||
|
Future<AccountProfile> saveLocale(String locale);
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:sxchnge_kyc/limits_transport.dart';
|
||||||
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import '../models/wallet.dart';
|
||||||
|
import '../transport/wallet.dart';
|
||||||
|
import 'contracts.dart';
|
||||||
|
|
||||||
|
/// Coherent, account-scoped backend stand-in. No network or persistence.
|
||||||
|
class DummyWalletService implements WalletService {
|
||||||
|
DummyWalletService(
|
||||||
|
this.identity, {
|
||||||
|
this.delay = const Duration(milliseconds: 350),
|
||||||
|
this.settlement = const Duration(seconds: 2),
|
||||||
|
DateTime Function()? now,
|
||||||
|
String initialLocale = 'en',
|
||||||
|
}) : now = now ?? DateTime.now,
|
||||||
|
_locale = initialLocale;
|
||||||
|
final IdentityService identity;
|
||||||
|
final Duration delay, settlement;
|
||||||
|
final DateTime Function() now;
|
||||||
|
final _accounts = [
|
||||||
|
AccountDTO('eur', 'EUR', 2, 7500),
|
||||||
|
AccountDTO('usd', 'USD', 2, 0),
|
||||||
|
AccountDTO('gbp', 'GBP', 2, 0),
|
||||||
|
];
|
||||||
|
final Map<String, QuoteDTO> _quotes = {};
|
||||||
|
final _usedQuotes = <String>{};
|
||||||
|
final Map<String, OperationDTO> _submissions = {};
|
||||||
|
String _locale;
|
||||||
|
int _serial = 0;
|
||||||
|
WalletError? nextError;
|
||||||
|
bool failNextOperation = false;
|
||||||
|
bool loseNextResponse = false;
|
||||||
|
@override
|
||||||
|
bool get isSimulated => true;
|
||||||
|
Future<void> _wait() async {
|
||||||
|
await Future<void>.delayed(delay);
|
||||||
|
final error = nextError;
|
||||||
|
nextError = null;
|
||||||
|
if (error != null) throw WalletException(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
AccountDTO _account(String id) => _accounts.firstWhere(
|
||||||
|
(a) => a.id == id,
|
||||||
|
orElse: () => throw const WalletException(WalletError.notFound),
|
||||||
|
);
|
||||||
|
// Fixed demo ratios to EUR; never marketed as live rates.
|
||||||
|
int _weight(String id) => switch (id) {
|
||||||
|
'eur' => 1100,
|
||||||
|
'usd' => 1000,
|
||||||
|
'gbp' => 1300,
|
||||||
|
_ => throw const WalletException(WalletError.unsupported),
|
||||||
|
};
|
||||||
|
int _eur(int minor, String id) => (minor * _weight(id) + 1099) ~/ 1100;
|
||||||
|
void _settle() {
|
||||||
|
for (final o in _submissions.values) {
|
||||||
|
if (o.status != 'pending' || now().isBefore(o.ready)) continue;
|
||||||
|
o.status = o.fail ? 'failed' : 'completed';
|
||||||
|
final q = o.quote;
|
||||||
|
if (o.fail) {
|
||||||
|
if (q.request.kind != 'topUp')
|
||||||
|
_account(q.request.source).available += q.debit;
|
||||||
|
} else if (q.request.kind == 'topUp') {
|
||||||
|
_account(q.request.source).available += q.credit;
|
||||||
|
} else if (q.request.kind == 'exchange') {
|
||||||
|
_account(q.request.destination!).available += q.credit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int _usage({required bool monthly, required bool pending}) {
|
||||||
|
final current = now();
|
||||||
|
return _submissions.values
|
||||||
|
.where(
|
||||||
|
(o) =>
|
||||||
|
o.quote.request.kind != 'topUp' &&
|
||||||
|
o.status == (pending ? 'pending' : 'completed') &&
|
||||||
|
o.created.year == current.year &&
|
||||||
|
o.created.month == current.month &&
|
||||||
|
(monthly || o.created.day == current.day),
|
||||||
|
)
|
||||||
|
.fold(
|
||||||
|
0,
|
||||||
|
(total, o) => total + _eur(o.quote.debit, o.quote.request.source),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _verified() async =>
|
||||||
|
(await identity.fetch()).status == IdentityStatus.approved;
|
||||||
|
void _check(QuoteDTO q, bool verified) {
|
||||||
|
if (q.request.kind == 'topUp') return;
|
||||||
|
if (_account(q.request.source).available < q.debit)
|
||||||
|
throw const WalletException(WalletError.insufficientFunds);
|
||||||
|
final charge = _eur(q.debit, q.request.source);
|
||||||
|
if (charge +
|
||||||
|
_usage(monthly: false, pending: false) +
|
||||||
|
_usage(monthly: false, pending: true) >
|
||||||
|
(verified ? 250000 : 10000) ||
|
||||||
|
charge +
|
||||||
|
_usage(monthly: true, pending: false) +
|
||||||
|
_usage(monthly: true, pending: true) >
|
||||||
|
(verified ? 1000000 : 50000))
|
||||||
|
throw const WalletException(WalletError.limitExceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<WalletSnapshot> fetch() async {
|
||||||
|
await _wait();
|
||||||
|
_settle();
|
||||||
|
return WalletSnapshot(
|
||||||
|
accounts: _accounts.map(WalletMapper.account).toList(),
|
||||||
|
operations: _submissions.values
|
||||||
|
.toList()
|
||||||
|
.reversed
|
||||||
|
.map(WalletMapper.operation)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Quote> quote(TransferRequest r) async {
|
||||||
|
await _wait();
|
||||||
|
_settle();
|
||||||
|
_account(r.source);
|
||||||
|
if (r.amount <= 0 || r.amount > 100000000)
|
||||||
|
throw const WalletException(WalletError.invalidAmount);
|
||||||
|
if (r.kind == TransferKind.exchange &&
|
||||||
|
(r.source == r.destination || r.destination == null))
|
||||||
|
throw const WalletException(WalletError.unsupported);
|
||||||
|
if (r.kind == TransferKind.payout &&
|
||||||
|
(r.recipient.trim().length < 6 || r.recipient.length > 140))
|
||||||
|
throw const WalletException(WalletError.invalidRecipient);
|
||||||
|
if (r.kind == TransferKind.exchange) _account(r.destination!);
|
||||||
|
final fee = switch (r.kind) {
|
||||||
|
TransferKind.topUp => 0,
|
||||||
|
TransferKind.exchange => max(1, r.amount ~/ 200),
|
||||||
|
TransferKind.payout => 100,
|
||||||
|
};
|
||||||
|
final n = r.kind == TransferKind.exchange ? _weight(r.source) : 1;
|
||||||
|
final d = r.kind == TransferKind.exchange ? _weight(r.destination!) : 1;
|
||||||
|
final q = QuoteDTO(
|
||||||
|
'quote-${++_serial}',
|
||||||
|
WalletMapper.request(r),
|
||||||
|
fee,
|
||||||
|
r.kind == TransferKind.topUp ? r.amount : r.amount + fee,
|
||||||
|
r.amount * n ~/ d,
|
||||||
|
now().add(const Duration(seconds: 60)),
|
||||||
|
n,
|
||||||
|
d,
|
||||||
|
);
|
||||||
|
if (q.credit <= 0) throw const WalletException(WalletError.invalidAmount);
|
||||||
|
final verified = await _verified();
|
||||||
|
_settle();
|
||||||
|
_check(q, verified);
|
||||||
|
_quotes[q.id] = q;
|
||||||
|
return WalletMapper.quote(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<WalletOperation> submit(String quoteId, String idempotencyKey) async {
|
||||||
|
await _wait();
|
||||||
|
if (_submissions.containsKey(idempotencyKey))
|
||||||
|
return WalletMapper.operation(_submissions[idempotencyKey]!);
|
||||||
|
final q = _quotes[quoteId];
|
||||||
|
if (q == null) throw const WalletException(WalletError.expiredQuote);
|
||||||
|
final verified = await _verified();
|
||||||
|
_settle();
|
||||||
|
if (_submissions.containsKey(idempotencyKey))
|
||||||
|
return WalletMapper.operation(_submissions[idempotencyKey]!);
|
||||||
|
if (!now().isBefore(q.expires))
|
||||||
|
throw const WalletException(WalletError.expiredQuote);
|
||||||
|
if (_usedQuotes.contains(q.id))
|
||||||
|
throw const WalletException(WalletError.expiredQuote);
|
||||||
|
_check(q, verified);
|
||||||
|
_usedQuotes.add(q.id);
|
||||||
|
if (q.request.kind != 'topUp')
|
||||||
|
_account(q.request.source).available -= q.debit;
|
||||||
|
final o = OperationDTO(
|
||||||
|
'op-${++_serial}',
|
||||||
|
q,
|
||||||
|
now(),
|
||||||
|
now().add(settlement),
|
||||||
|
fail: failNextOperation,
|
||||||
|
);
|
||||||
|
failNextOperation = false;
|
||||||
|
_submissions[idempotencyKey] = o;
|
||||||
|
if (loseNextResponse) {
|
||||||
|
loseNextResponse = false;
|
||||||
|
throw const WalletException(WalletError.uncertain);
|
||||||
|
}
|
||||||
|
return WalletMapper.operation(o);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<WalletOperation?> findSubmission(String key) async {
|
||||||
|
await _wait();
|
||||||
|
_settle();
|
||||||
|
final o = _submissions[key];
|
||||||
|
return o == null ? null : WalletMapper.operation(o);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AccountProfile> profile() async {
|
||||||
|
await _wait();
|
||||||
|
return WalletMapper.profile(_locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AccountProfile> saveLocale(String locale) async {
|
||||||
|
await _wait();
|
||||||
|
if (!['en', 'ru'].contains(locale))
|
||||||
|
throw const WalletException(WalletError.unsupported);
|
||||||
|
_locale = locale;
|
||||||
|
return WalletMapper.profile(_locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kept separate from wallet fetch because LimitsService has a different result.
|
||||||
|
Future<Limits> fetchLimits() async {
|
||||||
|
await _wait();
|
||||||
|
final verified = await _verified();
|
||||||
|
_settle();
|
||||||
|
final rows = <LimitDTO>[];
|
||||||
|
for (final monthly in [false, true]) {
|
||||||
|
final basic = monthly ? 50000 : 10000;
|
||||||
|
final upgraded = monthly ? 1000000 : 250000;
|
||||||
|
final current = verified ? upgraded : basic;
|
||||||
|
final used = _usage(monthly: monthly, pending: false);
|
||||||
|
final pending = _usage(monthly: monthly, pending: true);
|
||||||
|
rows.add(
|
||||||
|
LimitDTO(
|
||||||
|
monthly ? 'month' : 'day',
|
||||||
|
'EUR',
|
||||||
|
basic,
|
||||||
|
upgraded,
|
||||||
|
current,
|
||||||
|
max(0, current - used - pending),
|
||||||
|
used,
|
||||||
|
pending,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return LimitsMapper.fromDTO(
|
||||||
|
LimitsDTO(
|
||||||
|
tier: verified ? 'verified' : 'basic',
|
||||||
|
rows: rows,
|
||||||
|
simulated: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class WalletLimitsService implements LimitsService {
|
||||||
|
const WalletLimitsService(this.wallet);
|
||||||
|
final DummyWalletService wallet;
|
||||||
|
@override
|
||||||
|
Future<Limits> fetch() => wallet.fetchLimits();
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import '../models/wallet.dart';
|
||||||
|
|
||||||
|
class AccountDTO {
|
||||||
|
AccountDTO(this.id, this.currency, this.precision, this.available);
|
||||||
|
final String id, currency;
|
||||||
|
final int precision;
|
||||||
|
int available;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RequestDTO {
|
||||||
|
const RequestDTO(
|
||||||
|
this.kind,
|
||||||
|
this.source,
|
||||||
|
this.destination,
|
||||||
|
this.amount,
|
||||||
|
this.recipient,
|
||||||
|
);
|
||||||
|
final String kind, source, recipient;
|
||||||
|
final String? destination;
|
||||||
|
final int amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
class QuoteDTO {
|
||||||
|
const QuoteDTO(
|
||||||
|
this.id,
|
||||||
|
this.request,
|
||||||
|
this.fee,
|
||||||
|
this.debit,
|
||||||
|
this.credit,
|
||||||
|
this.expires,
|
||||||
|
this.numerator,
|
||||||
|
this.denominator,
|
||||||
|
);
|
||||||
|
final String id;
|
||||||
|
final RequestDTO request;
|
||||||
|
final int fee, debit, credit, numerator, denominator;
|
||||||
|
final DateTime expires;
|
||||||
|
}
|
||||||
|
|
||||||
|
class OperationDTO {
|
||||||
|
OperationDTO(
|
||||||
|
this.id,
|
||||||
|
this.quote,
|
||||||
|
this.created,
|
||||||
|
this.ready, {
|
||||||
|
this.status = 'pending',
|
||||||
|
this.fail = false,
|
||||||
|
});
|
||||||
|
final String id;
|
||||||
|
final QuoteDTO quote;
|
||||||
|
final DateTime created, ready;
|
||||||
|
String status;
|
||||||
|
final bool fail;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract final class WalletMapper {
|
||||||
|
static CurrencyAccount account(AccountDTO d) => CurrencyAccount(
|
||||||
|
id: d.id,
|
||||||
|
currency: d.currency,
|
||||||
|
precision: d.precision,
|
||||||
|
available: d.available,
|
||||||
|
);
|
||||||
|
static RequestDTO request(TransferRequest r) =>
|
||||||
|
RequestDTO(r.kind.name, r.source, r.destination, r.amount, r.recipient);
|
||||||
|
static TransferRequest fromRequest(RequestDTO d) => TransferRequest(
|
||||||
|
kind: TransferKind.values.byName(d.kind),
|
||||||
|
source: d.source,
|
||||||
|
destination: d.destination,
|
||||||
|
amount: d.amount,
|
||||||
|
recipient: d.recipient,
|
||||||
|
);
|
||||||
|
static Quote quote(QuoteDTO d) => Quote(
|
||||||
|
id: d.id,
|
||||||
|
request: fromRequest(d.request),
|
||||||
|
fee: d.fee,
|
||||||
|
debit: d.debit,
|
||||||
|
credit: d.credit,
|
||||||
|
expiresAt: d.expires,
|
||||||
|
rateNumerator: d.numerator,
|
||||||
|
rateDenominator: d.denominator,
|
||||||
|
);
|
||||||
|
static WalletOperation operation(OperationDTO d) => WalletOperation(
|
||||||
|
id: d.id,
|
||||||
|
quote: quote(d.quote),
|
||||||
|
status: OperationStatus.values.byName(d.status),
|
||||||
|
createdAt: d.created,
|
||||||
|
);
|
||||||
|
static AccountProfile profile(String locale) => AccountProfile(locale);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export 'src/models/wallet.dart';
|
||||||
|
export 'src/providers/wallet.dart';
|
||||||
|
export 'src/providers/transfer.dart';
|
||||||
|
export 'src/services/contracts.dart';
|
||||||
|
export 'src/services/dummy.dart';
|
||||||
|
export 'src/providers/limits.dart';
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
name: sxchnge_wallet
|
||||||
|
description: Shared currency accounts, transaction flows and account preferences.
|
||||||
|
version: 0.1.0
|
||||||
|
publish_to: none
|
||||||
|
environment:
|
||||||
|
sdk: '>=3.13.0 <4.0.0'
|
||||||
|
flutter: '>=3.47.0'
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
sxchnge_analytics:
|
||||||
|
path: ../sxchnge_analytics
|
||||||
|
sxchnge_kyc:
|
||||||
|
path: ../sxchnge_kyc
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late DateTime now;
|
||||||
|
late DummyWalletService s;
|
||||||
|
late WalletProvider wallet;
|
||||||
|
late DummyAnalyticsService analytics;
|
||||||
|
setUp(() {
|
||||||
|
now = DateTime(2026, 9, 12, 10);
|
||||||
|
s = DummyWalletService(
|
||||||
|
DummyIdentityService(delay: Duration.zero),
|
||||||
|
delay: Duration.zero,
|
||||||
|
now: () => now,
|
||||||
|
);
|
||||||
|
analytics = DummyAnalyticsService();
|
||||||
|
wallet = WalletProvider(s, analytics);
|
||||||
|
});
|
||||||
|
tearDown(() => wallet.dispose());
|
||||||
|
Future<WalletOperation> transfer(
|
||||||
|
TransferKind kind,
|
||||||
|
int amount, {
|
||||||
|
String source = 'eur',
|
||||||
|
String? to,
|
||||||
|
String recipient = 'Sample recipient',
|
||||||
|
String key = 'one',
|
||||||
|
}) async {
|
||||||
|
final q = await s.quote(
|
||||||
|
TransferRequest(
|
||||||
|
kind: kind,
|
||||||
|
source: source,
|
||||||
|
destination: to,
|
||||||
|
amount: amount,
|
||||||
|
recipient: recipient,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return s.submit(q.id, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
test(
|
||||||
|
'funding credits only on completion; history and available balance agree',
|
||||||
|
() async {
|
||||||
|
final o = await transfer(TransferKind.topUp, 2500);
|
||||||
|
expect(o.status, OperationStatus.pending);
|
||||||
|
expect((await s.fetch()).accounts.first.available, 7500);
|
||||||
|
now = now.add(const Duration(seconds: 3));
|
||||||
|
final state = await s.fetch();
|
||||||
|
expect(state.accounts.first.available, 10000);
|
||||||
|
expect(state.operations.single.status, OperationStatus.completed);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'exchange debits total once, then credits target and updates usage',
|
||||||
|
() async {
|
||||||
|
final q = await s.quote(
|
||||||
|
const TransferRequest(
|
||||||
|
kind: TransferKind.exchange,
|
||||||
|
source: 'eur',
|
||||||
|
destination: 'usd',
|
||||||
|
amount: 2000,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(q.debit, 2010);
|
||||||
|
expect(q.credit, 2200);
|
||||||
|
await Future.wait([s.submit(q.id, 'same'), s.submit(q.id, 'same')]);
|
||||||
|
var data = await s.fetch();
|
||||||
|
expect(data.accounts.first.available, 5490);
|
||||||
|
expect(data.accounts[1].available, 0);
|
||||||
|
expect(data.operations.length, 1);
|
||||||
|
var limits = await s.fetchLimits();
|
||||||
|
expect(limits.rows.first.reservedMinor, 2010);
|
||||||
|
expect(limits.rows.first.usedMinor, 0);
|
||||||
|
now = now.add(const Duration(seconds: 3));
|
||||||
|
data = await s.fetch();
|
||||||
|
expect(data.accounts[1].available, 2200);
|
||||||
|
limits = await s.fetchLimits();
|
||||||
|
expect(limits.rows.first.usedMinor, 2010);
|
||||||
|
expect(limits.rows.first.reservedMinor, 0);
|
||||||
|
expect(limits.rows.first.remainingMinor, 7990);
|
||||||
|
await expectLater(
|
||||||
|
s.submit(q.id, 'different'),
|
||||||
|
throwsA(isA<WalletException>()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test('failed payout restores amount and fee, releases limit usage', () async {
|
||||||
|
s.failNextOperation = true;
|
||||||
|
await transfer(TransferKind.payout, 2000);
|
||||||
|
expect((await s.fetch()).accounts.first.available, 5400);
|
||||||
|
now = now.add(const Duration(seconds: 3));
|
||||||
|
final data = await s.fetch();
|
||||||
|
expect(data.accounts.first.available, 7500);
|
||||||
|
expect(data.operations.single.status, OperationStatus.failed);
|
||||||
|
expect((await s.fetchLimits()).rows.first.remainingMinor, 10000);
|
||||||
|
});
|
||||||
|
test('expired quotes and stale balance cannot execute', () async {
|
||||||
|
final q = await s.quote(
|
||||||
|
const TransferRequest(
|
||||||
|
kind: TransferKind.payout,
|
||||||
|
source: 'eur',
|
||||||
|
amount: 7000,
|
||||||
|
recipient: 'Sample recipient',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
now = now.add(const Duration(seconds: 61));
|
||||||
|
await expectLater(
|
||||||
|
s.submit(q.id, 'expired'),
|
||||||
|
throwsA(
|
||||||
|
isA<WalletException>().having(
|
||||||
|
(e) => e.code,
|
||||||
|
'code',
|
||||||
|
WalletError.expiredQuote,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect((await s.fetch()).accounts.first.available, 7500);
|
||||||
|
final a = await s.quote(
|
||||||
|
const TransferRequest(
|
||||||
|
kind: TransferKind.payout,
|
||||||
|
source: 'eur',
|
||||||
|
amount: 6000,
|
||||||
|
recipient: 'Sample recipient',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final b = await s.quote(
|
||||||
|
const TransferRequest(
|
||||||
|
kind: TransferKind.payout,
|
||||||
|
source: 'eur',
|
||||||
|
amount: 6000,
|
||||||
|
recipient: 'Sample recipient',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await s.submit(a.id, 'first');
|
||||||
|
await expectLater(
|
||||||
|
s.submit(b.id, 'second'),
|
||||||
|
throwsA(
|
||||||
|
isA<WalletException>().having(
|
||||||
|
(e) => e.code,
|
||||||
|
'code',
|
||||||
|
WalletError.insufficientFunds,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test('limits block a funded account independently of balance', () async {
|
||||||
|
await transfer(TransferKind.topUp, 20000);
|
||||||
|
now = now.add(const Duration(seconds: 3));
|
||||||
|
await s.fetch();
|
||||||
|
await expectLater(
|
||||||
|
s.quote(
|
||||||
|
const TransferRequest(
|
||||||
|
kind: TransferKind.payout,
|
||||||
|
source: 'eur',
|
||||||
|
amount: 11000,
|
||||||
|
recipient: 'Sample recipient',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
throwsA(
|
||||||
|
isA<WalletException>().having(
|
||||||
|
(e) => e.code,
|
||||||
|
'code',
|
||||||
|
WalletError.limitExceeded,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test('decimal parser accepts locale decimal, rejects grouping and excess precision', () {
|
||||||
|
const a = CurrencyAccount(
|
||||||
|
id: 'eur',
|
||||||
|
currency: 'EUR',
|
||||||
|
precision: 2,
|
||||||
|
available: 0,
|
||||||
|
);
|
||||||
|
expect(a.parse('12,34'), 1234);
|
||||||
|
expect(a.parse('12.3'), 1230);
|
||||||
|
expect(a.parse('1,000.00'), null);
|
||||||
|
expect(a.parse('1.234'), null);
|
||||||
|
expect(a.parse('-2'), null);
|
||||||
|
expect(a.parse('1e5'), null);
|
||||||
|
});
|
||||||
|
test(
|
||||||
|
'preferences are service-owned; failed save preserves stored locale',
|
||||||
|
() async {
|
||||||
|
expect((await s.profile()).locale, 'en');
|
||||||
|
await wallet.saveLocale('ru');
|
||||||
|
expect((await s.profile()).locale, 'ru');
|
||||||
|
s.nextError = WalletError.unavailable;
|
||||||
|
expect(await wallet.saveLocale('en'), false);
|
||||||
|
expect((await s.profile()).locale, 'ru');
|
||||||
|
expect(wallet.saveFailed, true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'provider reconciles lost response and records settlement once',
|
||||||
|
() async {
|
||||||
|
// Use real-clock quote lifetime for the provider UI expiry check.
|
||||||
|
final service = DummyWalletService(
|
||||||
|
DummyIdentityService(delay: Duration.zero),
|
||||||
|
delay: Duration.zero,
|
||||||
|
settlement: Duration.zero,
|
||||||
|
);
|
||||||
|
final p = WalletProvider(service, analytics);
|
||||||
|
addTearDown(p.dispose);
|
||||||
|
await p.refresh();
|
||||||
|
final draft = TransferProvider(p, TransferKind.exchange);
|
||||||
|
addTearDown(draft.dispose);
|
||||||
|
draft.change(amount: '20');
|
||||||
|
await draft.review();
|
||||||
|
service.loseNextResponse = true;
|
||||||
|
await draft.submit();
|
||||||
|
expect(draft.uncertain, true);
|
||||||
|
expect(draft.result, null);
|
||||||
|
await draft.reconcile();
|
||||||
|
expect(draft.uncertain, false);
|
||||||
|
expect(draft.result, isNotNull);
|
||||||
|
await p.refresh();
|
||||||
|
await p.refresh();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(p.data!.operations.length, 1);
|
||||||
|
expect(
|
||||||
|
analytics.events
|
||||||
|
.where((e) => e.step == JourneyStep.settlement && e.terminal)
|
||||||
|
.length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
analytics.events.every(
|
||||||
|
(e) =>
|
||||||
|
!e.properties.containsKey('amount') &&
|
||||||
|
!e.properties.containsKey('recipient'),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'pending operations refresh balances and limits without a detail screen',
|
||||||
|
() async {
|
||||||
|
await wallet.refresh();
|
||||||
|
final limits = WorkspaceLimitsProvider(WalletLimitsService(s), wallet);
|
||||||
|
addTearDown(limits.dispose);
|
||||||
|
await limits.refresh();
|
||||||
|
s.failNextOperation = true;
|
||||||
|
await transfer(TransferKind.payout, 2000);
|
||||||
|
await wallet.refresh();
|
||||||
|
expect(wallet.account('eur')!.available, 5400);
|
||||||
|
now = now.add(const Duration(seconds: 3));
|
||||||
|
final done = Completer<void>();
|
||||||
|
void changed() {
|
||||||
|
if (wallet.operation(wallet.data!.operations.single.id)?.status ==
|
||||||
|
OperationStatus.failed &&
|
||||||
|
!done.isCompleted)
|
||||||
|
done.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
wallet.addListener(changed);
|
||||||
|
await done.future.timeout(const Duration(seconds: 5));
|
||||||
|
wallet.removeListener(changed);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
expect(wallet.account('eur')!.available, 7500);
|
||||||
|
expect(limits.data!.rows.first.remainingMinor, 10000);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'closing a pending operation does not report draft abandonment',
|
||||||
|
() async {
|
||||||
|
final r = JourneyRecorder(analytics);
|
||||||
|
final op = r.begin(
|
||||||
|
JourneyKind.exchange,
|
||||||
|
JourneyStep.settlement,
|
||||||
|
simulated: true,
|
||||||
|
);
|
||||||
|
expect(op, isNotNull);
|
||||||
|
r.dispose(abandonActive: false);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(
|
||||||
|
analytics.events.any((e) => e.signal == JourneySignal.abandoned),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user