Integrate limits usage and exchange workspace #23

Merged
tech merged 8 commits from limits-22 into main 2026-09-12 10:12:33 +00:00
48 changed files with 4757 additions and 120 deletions
Showing only changes of commit c496da8a55 - Show all commits

No files matched your search

+1
View File
@@ -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
+18 -2
View File
@@ -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.
+39
View File
@@ -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.
+409
View File
@@ -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.
+2 -2
View File
@@ -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,
+9 -4
View File
@@ -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 => 'Создать новую операцию';
} }
+3 -5
View File
@@ -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 -11
View File
@@ -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),
), ),
], ],
), ),
+88 -1
View File
@@ -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"
} }
+88 -1
View File
@@ -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": "Создать новую операцию"
} }
+15 -1
View File
@@ -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),
+25
View File
@@ -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();
}
} }
+84 -6
View File
@@ -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: '/',
+2 -7
View File
@@ -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),
-54
View File
@@ -1,54 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/locale.dart';
import '../generated/i18n/app_localizations.dart';
class LocaleSelector extends StatelessWidget {
const LocaleSelector({super.key});
@override
Widget build(BuildContext context) {
final provider = context.watch<LocaleProvider>();
return PopupMenuButton<Locale>(
key: const ValueKey('locale-selector'),
tooltip: AppLocalizations.of(context)!.language,
onSelected: (locale) async {
final saved = await provider.setLocale(locale);
if (!saved && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.preferenceNotSaved),
),
);
}
},
itemBuilder: (_) => AppLocalizations.supportedLocales
.map(
(locale) => CheckedPopupMenuItem(
value: locale,
checked: locale == provider.locale,
child: Text(locale.languageCode == 'ru' ? 'Русский' : 'English'),
),
)
.toList(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.language, size: 20),
const SizedBox(width: 8),
Text(
provider.locale.languageCode.toUpperCase(),
style: Theme.of(context).textTheme.labelLarge,
),
const Icon(Icons.expand_more, size: 20),
],
),
),
);
}
}
+245
View File
@@ -0,0 +1,245 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import 'activity.dart';
import 'copy.dart';
import '../generated/i18n/app_localizations.dart';
class AccountsPage extends StatelessWidget {
const AccountsPage({super.key, this.accountId});
final String? accountId;
@override
Widget build(BuildContext context) {
final p = context.watch<WalletProvider>();
final l = AppLocalizations.of(context)!;
final t = Theme.of(context);
final c = t.colorScheme;
final accounts = p.data?.accounts ?? [];
final selected = accountId == null
? accounts.firstOrNull
: p.account(accountId);
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1120),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(l.wHeadline, style: t.textTheme.displayMedium),
),
IconButton(
tooltip: l.wRefresh,
onPressed: p.loading ? null : p.refresh,
icon: const Icon(Icons.refresh),
),
],
),
const SizedBox(height: 12),
Text(l.wSubheading, style: t.textTheme.bodyLarge),
const SizedBox(height: 28),
if (p.loading) const LinearProgressIndicator(),
if (p.failed)
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Text(l.wUnavailable, style: TextStyle(color: c.error)),
),
if (!p.loading && accounts.isEmpty) Text(l.wEmptyAccounts),
if (accountId != null &&
p.account(accountId) == null &&
!p.loading)
Text(l.wNotFound),
if (selected != null) ...[
const SizedBox(height: 24),
Text(selected.currency, style: t.textTheme.titleLarge),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
for (final kind in TransferKind.values)
_action(
context,
kind,
selected,
primary:
kind ==
(selected.available == 0
? TransferKind.topUp
: TransferKind.exchange),
),
],
),
],
const SizedBox(height: 24),
LayoutBuilder(
builder: (context, box) {
final width = box.maxWidth >= 660
? (box.maxWidth - 32) / 3
: box.maxWidth;
return Wrap(
spacing: 16,
runSpacing: 16,
children: [
for (var i = 0; i < accounts.length; i++)
SizedBox(
width: width,
child: _AccountCard(
account: accounts[i],
selected: accounts[i].id == selected?.id,
index: i,
),
),
],
);
},
),
const SizedBox(height: 36),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l.wRecent, style: t.textTheme.headlineSmall),
TextButton(
onPressed: () => context.go('/activity'),
child: Text(l.wSeeAll),
),
],
),
const SizedBox(height: 12),
...?p.data?.operations
.where(
(o) =>
accountId == null ||
o.quote.request.source == accountId ||
o.quote.request.destination == accountId,
)
.take(5)
.map((o) => OperationTile(operation: o)),
if (!(p.data?.operations.any(
(o) =>
accountId == null ||
o.quote.request.source == accountId ||
o.quote.request.destination == accountId,
) ??
false))
Container(
padding: const EdgeInsets.all(28),
decoration: BoxDecoration(
color: c.surfaceContainer,
borderRadius: BorderRadius.circular(24),
),
child: Text(l.wEmptyActivity),
),
],
),
),
),
);
}
Widget _action(
BuildContext context,
TransferKind kind,
CurrencyAccount a, {
required bool primary,
}) {
final l = AppLocalizations.of(context)!;
void go() => context.go(
'/${kind.name == "topUp" ? "top-up" : kind.name}',
extra: a.id,
);
return primary
? FilledButton.icon(
key: ValueKey('${kind.name}-${a.id}'),
onPressed: go,
icon: Icon(kindIcon(kind)),
label: Text(kindText(l, kind)),
)
: FilledButton.tonalIcon(
key: ValueKey('${kind.name}-${a.id}'),
onPressed: go,
icon: Icon(kindIcon(kind)),
label: Text(kindText(l, kind)),
);
}
}
class _AccountCard extends StatelessWidget {
const _AccountCard({
required this.account,
required this.selected,
required this.index,
});
final CurrencyAccount account;
final bool selected;
final int index;
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final t = Theme.of(context);
final c = t.colorScheme;
final bg = selected
? c.primary
: index == 1
? c.tertiaryContainer
: c.primaryContainer;
final fg = selected
? c.onPrimary
: index == 1
? c.onTertiaryContainer
: c.onPrimaryContainer;
return Semantics(
selected: selected,
child: Material(
color: bg,
borderRadius: BorderRadius.circular(28),
child: InkWell(
key: ValueKey('account-${account.id}'),
borderRadius: BorderRadius.circular(28),
onTap: () => context.go('/accounts/${account.id}'),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Text(
account.currency,
style: t.textTheme.titleLarge?.copyWith(color: fg),
),
const Spacer(),
Icon(
selected
? Icons.check_circle_outline
: Icons.arrow_outward_rounded,
color: fg,
),
],
),
const SizedBox(height: 32),
Text(l.wAvailable, style: TextStyle(color: fg)),
const SizedBox(height: 8),
Text(
money(context, account, account.available),
key: ValueKey('balance-${account.id}'),
style: t.textTheme.headlineMedium?.copyWith(color: fg),
),
],
),
),
),
),
);
}
}
+196
View File
@@ -0,0 +1,196 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import 'copy.dart';
import '../generated/i18n/app_localizations.dart';
class ActivityPage extends StatefulWidget {
const ActivityPage({super.key});
@override
State<ActivityPage> createState() => _ActivityPageState();
}
class _ActivityPageState extends State<ActivityPage> {
TransferKind? _kind;
OperationStatus? _status;
String _search = '';
String? _currency;
@override
Widget build(BuildContext context) {
final p = context.watch<WalletProvider>();
final l = AppLocalizations.of(context)!;
final list =
p.data?.operations
.where(
(o) =>
(_kind == null || o.quote.request.kind == _kind) &&
(_status == null || o.status == _status) &&
(_currency == null ||
o.quote.request.source == _currency ||
o.quote.request.destination == _currency) &&
o.id.toLowerCase().contains(_search.toLowerCase()),
)
.toList() ??
[];
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1000),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
l.wActivity,
style: Theme.of(context).textTheme.displaySmall,
),
),
IconButton(
tooltip: l.wRefresh,
onPressed: p.loading ? null : p.refresh,
icon: const Icon(Icons.refresh),
),
],
),
const SizedBox(height: 24),
TextField(
decoration: InputDecoration(
labelText: l.wSearch,
prefixIcon: const Icon(Icons.search),
),
onChanged: (value) => setState(() => _search = value),
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
ChoiceChip(
label: Text(l.wAll),
selected: _kind == null,
onSelected: (_) => setState(() => _kind = null),
),
for (final k in TransferKind.values)
ChoiceChip(
label: Text(kindText(l, k)),
selected: _kind == k,
onSelected: (_) => setState(() => _kind = k),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final s in OperationStatus.values)
FilterChip(
label: Text(statusText(l, s)),
selected: _status == s,
onSelected: (value) =>
setState(() => _status = value ? s : null),
),
for (final a in p.data?.accounts ?? <CurrencyAccount>[])
FilterChip(
label: Text(a.currency),
selected: _currency == a.id,
onSelected: (value) =>
setState(() => _currency = value ? a.id : null),
),
],
),
const SizedBox(height: 20),
if (p.loading) const LinearProgressIndicator(),
if (p.failed) Text(l.wUnavailable),
if (list.isEmpty && !p.loading)
Padding(
padding: const EdgeInsets.all(24),
child: Text(
(p.data?.operations.isEmpty ?? true)
? l.wEmptyActivity
: l.wNoMatches,
),
),
for (final o in list) OperationTile(operation: o),
],
),
),
),
);
}
}
class OperationTile extends StatelessWidget {
const OperationTile({super.key, required this.operation});
final WalletOperation operation;
@override
Widget build(BuildContext context) {
final p = context.read<WalletProvider>();
final l = AppLocalizations.of(context)!;
final o = operation;
final a = p.account(o.quote.request.source);
final to = p.account(o.quote.request.destination);
final t = Theme.of(context);
return Card(
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => context.push('/activity/${o.id}'),
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
CircleAvatar(
backgroundColor: t.colorScheme.primaryContainer,
child: Icon(
kindIcon(o.quote.request.kind),
color: t.colorScheme.onPrimaryContainer,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
kindText(l, o.quote.request.kind),
style: t.textTheme.titleMedium,
),
Text(
'${a?.currency ?? ''}${to == null ? '' : ' → ${to.currency}'} · ${statusText(l, o.status)}',
),
Text(
DateFormat.yMMMd(
Localizations.localeOf(context).toLanguageTag(),
).add_Hm().format(o.createdAt),
style: t.textTheme.bodySmall,
),
],
),
),
const SizedBox(width: 8),
Flexible(
child: Text(
a == null ? '' : money(context, a, o.quote.request.amount),
textAlign: TextAlign.end,
style: t.textTheme.titleMedium,
),
),
const Icon(Icons.chevron_right),
],
),
),
),
);
}
}
+52
View File
@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../generated/i18n/app_localizations.dart';
String money(BuildContext context, CurrencyAccount account, int amount) =>
NumberFormat.currency(
locale: Localizations.localeOf(context).toLanguageTag(),
name: account.currency,
symbol: '${account.currency} ',
decimalDigits: account.precision,
).format(amount / account.scale);
String kindText(AppLocalizations l, TransferKind kind) => switch (kind) {
TransferKind.topUp => l.wTopUp,
TransferKind.exchange => l.wExchange,
TransferKind.payout => l.wPayout,
};
IconData kindIcon(TransferKind kind) => switch (kind) {
TransferKind.topUp => Icons.add_rounded,
TransferKind.exchange => Icons.swap_horiz_rounded,
TransferKind.payout => Icons.north_east_rounded,
};
String statusText(AppLocalizations l, OperationStatus s) => switch (s) {
OperationStatus.pending => l.wPending,
OperationStatus.completed => l.wCompleted,
OperationStatus.failed => l.wFailed,
};
String fundsText(AppLocalizations l, WalletOperation o) =>
switch ((o.status, o.quote.request.kind)) {
(OperationStatus.pending, TransferKind.topUp) => l.wPendingTopUp,
(OperationStatus.pending, TransferKind.exchange) => l.wPendingExchange,
(OperationStatus.pending, TransferKind.payout) => l.wPendingPayout,
(OperationStatus.completed, TransferKind.topUp) => l.wCompleteTopUp,
(OperationStatus.completed, TransferKind.exchange) => l.wCompleteExchange,
(OperationStatus.completed, TransferKind.payout) => l.wCompletePayout,
(OperationStatus.failed, TransferKind.topUp) => l.wFailedTopUp,
_ => l.wFailedOutgoing,
};
String walletError(AppLocalizations l, WalletError e) => switch (e) {
WalletError.invalidAmount => l.wInvalidAmount,
WalletError.insufficientFunds => l.wInsufficient,
WalletError.limitExceeded => l.wLimitError,
WalletError.expiredQuote => l.wExpired,
WalletError.invalidRecipient => l.wRecipientError,
WalletError.unsupported => l.wUnsupported,
WalletError.notFound => l.wNotFound,
WalletError.uncertain => l.wUncertain,
_ => l.wUnavailable,
};
+80
View File
@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../kyc/limits.dart';
import '../generated/i18n/app_localizations.dart';
class LimitsPage extends StatefulWidget {
const LimitsPage({super.key});
@override
State<LimitsPage> createState() => _LimitsPageState();
}
class _LimitsPageState extends State<LimitsPage> {
late final _provider = WorkspaceLimitsProvider(
context.read<LimitsService>(),
context.read<WalletProvider>(),
);
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _provider.refresh();
});
}
@override
void dispose() {
_provider.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
return ChangeNotifierProvider<LimitsProvider>.value(
value: _provider,
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l.wLimits,
style: Theme.of(context).textTheme.displaySmall,
),
const SizedBox(height: 12),
Text(l.wLimitsScope),
const SizedBox(height: 24),
const LimitsPanel(),
const SizedBox(height: 20),
FilledButton(
onPressed: () async {
await context.push('/identity');
if (mounted) _provider.refresh();
},
child: Text(l.wIdentity),
),
TextButton(
onPressed: _provider.refresh,
child: Text(l.wRefresh),
),
],
),
),
),
),
);
}
}
+152
View File
@@ -0,0 +1,152 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import 'copy.dart';
import 'summary.dart';
import '../generated/i18n/app_localizations.dart';
class OperationPage extends StatefulWidget {
const OperationPage({super.key, required this.id});
final String id;
@override
State<OperationPage> createState() => _OperationPageState();
}
class _OperationPageState extends State<OperationPage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) context.read<WalletProvider>().refresh();
});
}
@override
Widget build(BuildContext context) {
final p = context.watch<WalletProvider>();
final o = p.operation(widget.id);
final l = AppLocalizations.of(context)!;
final t = Theme.of(context);
final c = t.colorScheme;
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () => context.go('/activity'),
icon: const Icon(Icons.arrow_back),
label: Text(l.wActivity),
),
),
if (p.failed)
Text(l.wUnavailable, style: TextStyle(color: c.error)),
if (o == null) ...[
Text(p.loading ? l.wWorking : l.wNotFound),
TextButton(
onPressed: p.loading ? null : p.refresh,
child: Text(l.wRefresh),
),
] else ...[
Container(
padding: const EdgeInsets.all(28),
decoration: BoxDecoration(
color: o.status == OperationStatus.failed
? c.errorContainer
: c.primaryContainer,
borderRadius: BorderRadius.circular(28),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
o.status == OperationStatus.completed
? Icons.check_circle_outline
: o.status == OperationStatus.pending
? Icons.schedule
: Icons.info_outline,
size: 40,
color: o.status == OperationStatus.failed
? c.onErrorContainer
: c.onPrimaryContainer,
),
const SizedBox(height: 16),
Semantics(
header: true,
liveRegion: true,
child: Text(
statusText(l, o.status),
key: ValueKey('operation-${o.status.name}'),
style: t.textTheme.displaySmall?.copyWith(
color: o.status == OperationStatus.failed
? c.onErrorContainer
: c.onPrimaryContainer,
),
),
),
const SizedBox(height: 16),
Text(
fundsText(l, o),
style: TextStyle(
color: o.status == OperationStatus.failed
? c.onErrorContainer
: c.onPrimaryContainer,
),
),
],
),
),
const SizedBox(height: 20),
TransferSummary(quote: o.quote),
const SizedBox(height: 16),
Text('${l.wReference}: ${o.id}'),
if (o.status == OperationStatus.pending) ...[
const SizedBox(height: 12),
Text(l.wPendingHint),
TextButton(
onPressed: p.loading ? null : p.refresh,
child: Text(l.wRefresh),
),
],
const SizedBox(height: 24),
if (o.status == OperationStatus.completed &&
o.quote.request.kind != TransferKind.payout)
FilledButton(
onPressed: () => context.go(
o.quote.request.kind == TransferKind.topUp
? '/exchange'
: '/payout',
extra:
o.quote.request.destination ?? o.quote.request.source,
),
child: Text(
o.quote.request.kind == TransferKind.topUp
? l.wExchange
: l.wPayout,
),
),
TextButton(
onPressed: () => context.go(
'/accounts/${o.quote.request.destination ?? o.quote.request.source}',
),
child: Text(l.wViewAccount),
),
],
],
),
),
),
);
}
}
+132
View File
@@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../providers/locale.dart';
import '../generated/i18n/app_localizations.dart';
class SettingsPage extends StatelessWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context) {
final p = context.watch<WalletProvider>();
final locale = context.watch<LocaleProvider>();
final l = AppLocalizations.of(context)!;
final t = Theme.of(context);
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (context.canPop())
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () => context.pop(),
icon: const Icon(Icons.arrow_back),
label: Text(l.kycBack),
),
),
Text(l.wPreferences, style: t.textTheme.displayMedium),
const SizedBox(height: 28),
Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l.wLanguage, style: t.textTheme.titleLarge),
const SizedBox(height: 8),
Text(l.wLanguageHelp),
const SizedBox(height: 20),
if (p.profileLoading) const LinearProgressIndicator(),
if (p.profileFailed) ...[
Text(l.wProfileError),
TextButton(
onPressed: () => locale.loadAccount(p),
child: Text(l.wRefresh),
),
],
DropdownButtonFormField<String>(
key: ValueKey(
'account-language-${locale.locale.languageCode}',
),
initialValue: locale.locale.languageCode,
isExpanded: true,
items: const [
DropdownMenuItem(value: 'en', child: Text('English')),
DropdownMenuItem(value: 'ru', child: Text('Русский')),
],
onChanged: p.saving || p.profileLoading
? null
: (value) {
if (value != null) {
locale.saveAccountLocale(p, Locale(value));
}
},
decoration: InputDecoration(labelText: l.wLanguage),
),
if (p.saving) const LinearProgressIndicator(),
if (p.saveFailed) ...[
const SizedBox(height: 12),
Semantics(
liveRegion: true,
child: Text(l.wLanguageFailed),
),
TextButton(
onPressed: p.saving
? null
: () =>
locale.saveAccountLocale(p, locale.locale),
child: Text(l.wRefresh),
),
],
],
),
),
),
const SizedBox(height: 16),
Card(
child: ListTile(
contentPadding: const EdgeInsets.all(24),
leading: const Icon(Icons.verified_user_outlined),
title: Text(l.wIdentity),
subtitle: Text(l.wIdentityHelp),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.go('/identity'),
),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l.wSecurity, style: t.textTheme.titleLarge),
const SizedBox(height: 12),
Text(l.wSecurityBody),
],
),
),
),
const SizedBox(height: 20),
TextButton(
onPressed: () => context.go('/'),
child: Text(l.wBackLogin),
),
],
),
),
),
);
}
}
+134
View File
@@ -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]),
],
),
);
}
}
+84
View File
@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import 'copy.dart';
import '../generated/i18n/app_localizations.dart';
class TransferSummary extends StatelessWidget {
const TransferSummary({super.key, required this.quote});
final Quote quote;
@override
Widget build(BuildContext context) {
final p = context.read<WalletProvider>();
final l = AppLocalizations.of(context)!;
final q = quote;
final a = p.account(q.request.source);
final b = p.account(q.request.destination) ?? a;
if (a == null || b == null) return Text(l.wNotFound);
return Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_row(
context,
q.request.kind == TransferKind.topUp ? l.wYouPay : l.wTotalDebit,
money(context, a, q.debit),
large: true,
),
const Divider(height: 32),
_row(context, l.wFee, money(context, a, q.fee)),
if (q.request.kind == TransferKind.exchange) ...[
const SizedBox(height: 16),
_row(
context,
l.wRate,
'1 ${a.currency} = ${NumberFormat('0.0000', Localizations.localeOf(context).toLanguageTag()).format(q.rateNumerator / q.rateDenominator)} ${b.currency}',
),
],
const SizedBox(height: 16),
_row(
context,
q.request.kind == TransferKind.payout
? l.wRecipientGets
: l.wCredit,
money(context, b, q.credit),
large: true,
),
if (q.request.kind == TransferKind.payout) ...[
const Divider(height: 32),
Text(l.wRecipient),
SelectableText(q.request.recipient),
],
],
),
),
);
}
Widget _row(
BuildContext context,
String label,
String value, {
bool large = false,
}) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label),
const SizedBox(height: 4),
Text(
value,
style: large
? Theme.of(context).textTheme.headlineSmall
: Theme.of(context).textTheme.titleMedium,
),
],
);
}
+310
View File
@@ -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),
),
],
],
],
),
),
],
),
),
),
);
},
);
}
+7
View File
@@ -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:
+2
View File
@@ -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:
+1 -1
View File
@@ -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,
+4 -2
View File
@@ -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
+1 -9
View File
@@ -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);
+6 -9
View File
@@ -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);
}); });
} }
+220
View File
@@ -0,0 +1,220 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import 'package:sxchnge_app/main.dart';
import 'package:sxchnge_app/providers/locale.dart';
Future<void> tap(WidgetTester t, Finder f) async {
await t.ensureVisible(f);
await t.tap(f);
await t.pumpAndSettle();
}
Future<DummyWalletService> open(
WidgetTester t, {
String path = '/accounts',
String locale = 'en',
Duration settlement = Duration.zero,
}) async {
t.platformDispatcher.defaultRouteNameTestValue = path;
addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue);
final identity = DummyIdentityService(delay: Duration.zero);
final service = DummyWalletService(
identity,
delay: Duration.zero,
settlement: settlement,
initialLocale: locale,
);
await t.pumpWidget(
SxchngeApp(
identityService: identity,
walletService: service,
localeProvider: LocaleProvider(initialLocale: Locale(locale)),
),
);
await t.pumpAndSettle();
addTearDown(() => t.pumpWidget(const SizedBox.shrink()));
return service;
}
void main() {
testWidgets('available-only accounts expose one set of actions', (t) async {
await open(t);
expect(find.text('Reserved'), findsNothing);
expect(find.text('Held'), findsNothing);
expect(find.byKey(const ValueKey('exchange-eur')), findsOneWidget);
expect(find.byKey(const ValueKey('exchange-usd')), findsNothing);
await tap(t, find.byKey(const ValueKey('account-usd')));
expect(find.byKey(const ValueKey('topUp-usd')), findsOneWidget);
expect(find.byKey(const ValueKey('exchange-eur')), findsNothing);
});
testWidgets('top-up exchange payout end-to-end and history', (t) async {
final s = await open(t);
await tap(t, find.byKey(const ValueKey('topUp-eur')));
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10');
await tap(t, find.byKey(const ValueKey('transfer-review')));
expect(find.text('You pay'), findsOneWidget);
await tap(t, find.byKey(const ValueKey('transfer-confirm')));
expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget);
expect(
find.text(
'Your account has been credited. The money is available to use.',
),
findsOneWidget,
);
await tap(t, find.text('Exchange').last);
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '20');
await tap(t, find.byKey(const ValueKey('transfer-review')));
expect(find.text('Leaves your account'), findsOneWidget);
expect(find.text('Fee'), findsOneWidget);
await tap(t, find.byKey(const ValueKey('transfer-confirm')));
expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget);
await tap(t, find.text('Pay out').last);
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
await t.enterText(
find.byKey(const ValueKey('transfer-recipient')),
'Sample recipient',
);
await tap(t, find.byKey(const ValueKey('transfer-review')));
expect(find.text('Recipient receives'), findsOneWidget);
await tap(t, find.byKey(const ValueKey('transfer-confirm')));
expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget);
final data = (await t.runAsync(() => s.fetch()))!;
expect(data.operations.length, 3);
expect(data.accounts.first.available, 6490);
expect(data.accounts[1].available, 1600);
});
testWidgets(
'processing payout explains reduced available balance then failed refund',
(t) async {
final s = await open(
t,
path: '/payout',
settlement: const Duration(milliseconds: 100),
);
s.failNextOperation = true;
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10');
await t.enterText(
find.byKey(const ValueKey('transfer-recipient')),
'Sample recipient',
);
await tap(t, find.byKey(const ValueKey('transfer-review')));
await tap(t, find.byKey(const ValueKey('transfer-confirm')));
// Settlement is service-owned; refresh after the simulated processing period.
await t.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 150)),
);
await t.pump(const Duration(seconds: 3));
await t.pumpAndSettle();
expect(find.byKey(const ValueKey('operation-failed')), findsOneWidget);
expect(
find.text(
'The transaction did not complete. The amount and fee are available in your account again.',
),
findsOneWidget,
);
expect(
((await t.runAsync(() => s.fetch()))!).accounts.first.available,
7500,
);
},
);
testWidgets(
'settings saves account language and hides page language selectors',
(t) async {
final s = await open(t, path: '/settings');
expect(find.byKey(const ValueKey('locale-selector')), findsNothing);
await tap(t, find.byKey(const ValueKey('account-language-en')));
await tap(t, find.text('Русский').last);
expect(((await t.runAsync(() => s.profile()))!).locale, 'ru');
expect(find.text('Язык аккаунта'), findsWidgets);
},
);
testWidgets(
'language save failure retains session choice and provides retry',
(t) async {
final s = await open(t, path: '/settings');
s.nextError = WalletError.unavailable;
await tap(t, find.byKey(const ValueKey('account-language-en')));
await tap(t, find.text('Русский').last);
expect(
find.text(
'Изменено для текущей сессии, но не сохранено в аккаунте. Повторите попытку ниже.',
),
findsOneWidget,
);
expect(((await t.runAsync(() => s.profile()))!).locale, 'en');
await tap(t, find.text('Обновить').last);
expect(((await t.runAsync(() => s.profile()))!).locale, 'ru');
},
);
testWidgets(
'insufficient funds preserves entered amount and can be corrected',
(t) async {
await open(t, path: '/exchange');
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '900');
await tap(t, find.byKey(const ValueKey('transfer-review')));
expect(find.byKey(const ValueKey('transfer-error')), findsOneWidget);
expect(find.text('900'), findsOneWidget);
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
await tap(t, find.byKey(const ValueKey('transfer-review')));
expect(find.byKey(const ValueKey('transfer-confirm')), findsOneWidget);
},
);
testWidgets('uncertain submission checks outcome instead of sending twice', (
t,
) async {
final s = await open(t, path: '/exchange');
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
await tap(t, find.byKey(const ValueKey('transfer-review')));
s.loseNextResponse = true;
await tap(t, find.byKey(const ValueKey('transfer-confirm')));
expect(find.text('Check outcome'), findsOneWidget);
await tap(t, find.text('Check outcome'));
expect(((await t.runAsync(() => s.fetch()))!).operations.length, 1);
expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget);
});
for (final path in [
'/accounts',
'/exchange',
'/payout',
'/activity',
'/limits',
'/settings',
]) {
testWidgets('$path fits Russian at 320px with larger text', (t) async {
t.view.physicalSize = const Size(320, 1100);
t.view.devicePixelRatio = 1;
t.platformDispatcher.textScaleFactorTestValue = 1.6;
addTearDown(t.view.resetPhysicalSize);
addTearDown(t.view.resetDevicePixelRatio);
addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue);
await open(t, path: path, locale: 'ru');
expect(t.takeException(), null);
if (path == '/exchange') {
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
await tap(t, find.byKey(const ValueKey('transfer-review')));
expect(t.takeException(), null);
}
});
}
testWidgets('leaving a draft requires a clear discard choice', (t) async {
await open(t, path: '/exchange');
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
final router = GoRouter.of(
t.element(find.byKey(const ValueKey('transfer-amount'))),
);
router.go('/accounts');
await t.pumpAndSettle();
expect(find.text('Leave this transaction?'), findsOneWidget);
await tap(t, find.text('Keep editing'));
expect(find.text('5'), findsOneWidget);
});
}
@@ -2,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,
);
},
);
}