diff --git a/Makefile b/Makefile index 79b83f4..0652fcd 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ .PHONY: test-frontend test-frontend: + cd frontend/packages/sxchnge_wallet && flutter test cd frontend/packages/sxchnge_kyc && flutter test cd frontend/packages/sxchnge_analytics && flutter test cd frontend/packages/sxchnge_auth && flutter test diff --git a/README.md b/README.md index e0083c6..da77542 100644 --- a/README.md +++ b/README.md @@ -172,8 +172,7 @@ limitations in the task and [implementation record](docs/implementation.md). ## Identity verification preview -Open `/#/identity` or select **Explore verification & limits** from login or email -confirmation. The optional flow covers passport and selfie selection, review, +Open `/#/identity` or use **Limits → Verify identity** in the workspace. The optional flow covers passport and selfie selection, review, submission, pending review, approval, correction requests and denial in EN/RU. Use sample images only: photos are never uploaded and drafts are not persisted. @@ -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 authorization are not connected. See the [UX plan](docs/ux/kyc.md) for state, error, privacy and measurement details. + +## Exchange workspace preview + +Choose **Explore your accounts** on login/email confirmation, or open `/#/accounts`. +Currency cards show only available funds. Select an account, then top up, exchange +or pay out through input → review → status. Review shows fees and actual debit and +credit; status explains where the money is, including processing and failed returns. +Activity, account balances and limits use one coherent demo ledger. + +Settings owns English/Russian account language. Limits links to optional KYC; +no verification remains a valid account state with basic limits. + +The shared [wallet package](frontend/packages/sxchnge_wallet/README.md) is reusable +by mobile clients. Transactions, rates, fees, recipients and profile storage are +explicitly simulated and reset on reload. No real money moves. See the +[workspace UX plan](docs/ux/exchange-workspace.md) and +[implementation record](docs/implementation.md) for scope and validation. diff --git a/docs/implementation.md b/docs/implementation.md index 9943303..c7a539c 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -184,3 +184,75 @@ Documentation only; no runtime tests were run. - Validation passed: all 91 frontend tests, analyze app and sxchnge_kyc, format check of all 9 affected Dart files, web build and diff checks. - Delivery: [PR #23](https://git.sendico.io/tech/sxchnge/pulls/23), unmerged. Merge #21 first and retarget #23 to main. + +## UX simplicity preference + +Task [#26](https://git.sendico.io/tech/sxchnge/issues/26), delivered in +[PR #27](https://git.sendico.io/tech/sxchnge/pulls/27) on `skills-26`, makes +simplicity an explicit preference in the UX Design skill and its review checklist. +Prefer fewer concepts, choices, and controls when they serve the same need; retain +essential information, accessibility, and recovery. Documentation only. +Validation: skill-creator `quick_validate.py` passed in an isolated environment +with PyYAML (the system Python lacked it); `git diff --check` passed. No runtime +tests were needed. The PR is not merged. + +## Exchange workspace — task #28 + +- Branch `workspace-28`, stacked on `limits-22` / PR #23. Merge KYC #21 first, + then limits #23, then retarget this workspace contribution to main. The approved + plan from #24/#25 is included and updated for the user's available-only direction. +- Implemented Accounts, Activity/detail, Limits and Settings in a responsive + go_router shell, with top-up, exchange and payout input/review/status flows. + One action group follows the selected currency; all colors use theme roles. + Only available balances are displayed. Status explains credit timing, outgoing + amount plus fee, completion and full return after failure. +- Shared `sxchnge_wallet` service/providers own coherent demo balances, quotes, + idempotent submissions, reconciliation, history, account language and live + snapshot refresh. The limits adapter reuses the KYC DTO/mapper and refreshes + usage when operations change. Widgets consume provider models. +- Language is in account settings, with failed-save feedback and retry. Public + auth and verified-email screens enter the workspace. KYC remains optional; + the demo service enforces its basic/verified outgoing caps at submission. +- UX review: EN/RU at 320px with 1.6 text scaling; available-only presentation; + field preservation after errors; explicit draft discard; quote expiry; + loading/stale/empty states; operation outcome explanations; accessible labels, + semantic status announcements and focus after quote/errors. Browser walkthrough + verifies quote amounts and service-confirmed credit with the exchange handoff. + Native mobile and dedicated VoiceOver/TalkBack testing remain release QA. +- Measurement review: typed draft/quote/submission/settlement steps with scoped + journey IDs, safe failure enums, simulated flag and deduplicated terminal + outcomes. No financial amounts, recipients or account IDs in events. Pending + app closure is not draft abandonment. Real PostHog delivery and cross-session + server outcomes remain integration work. +- Validation passed: `make test-frontend` (115 tests), `flutter analyze` in app, + sxchnge_wallet, sxchnge_analytics and sxchnge_kyc; format check of all 36 changed + Dart files; `flutter build web`; `git diff --check`. Review fixes included + narrow Russian layouts, limit-provider typing, automatic pending refresh, + single-use quotes and ambiguous-response recovery. No security scan requested. +- Demo limits: opening EUR 75, fixed rates/fees and three currencies, no real + payment methods/bank validation/storage/auth enforcement. Account preferences, + history and balances reset on reload. No receipt export, date pagination, + notification preferences or security settings beyond an honest capability note. + Backend adapters must supply these before production use; see the wallet README. +- Delivery: [PR #29](https://git.sendico.io/tech/sxchnge/pulls/29), pushed and + unmerged. Task and PR have frontend label, tech owner and SXCHNGE project. + Browser demo walkthrough also completed exchange and payout, confirming source + currency handoffs and exact fee/debit/receipt values on the result screen. + +### Workspace copy cleanup — follow-up to #28 + +At the user's request, removed repeated demo/preview banners from the workspace, +transaction forms, auth, email confirmation, identity and limits. Confirmation +and result copy now uses ordinary product wording in EN/RU; the mobile wordmark +remains without the extra note. Removed unused localization entries and duplicate +email-state copy. Existing journey measurement, simulation flags, service behavior, +funds explanations and recovery controls are unchanged. Development constraints +belong in this record and package README, not repeated throughout the product UI. + +Delivery continues in [PR #29](https://git.sendico.io/tech/sxchnge/pulls/29) on +`workspace-28`. This copy change does not connect real authentication, email, +payment, support or backoffice services. +Validation: all 115 frontend tests, app analysis, app Dart formatting, web build +and diff checks pass. Updated existing copy expectations; telemetry tests still +verify simulated events never count as real business success. No new click events +were needed for this presentation-only cleanup. diff --git a/docs/ux/exchange-workspace.md b/docs/ux/exchange-workspace.md new file mode 100644 index 0000000..f828190 --- /dev/null +++ b/docs/ux/exchange-workspace.md @@ -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. diff --git a/frontend/app/lib/auth/form.dart b/frontend/app/lib/auth/form.dart index e97b243..9445146 100644 --- a/frontend/app/lib/auth/form.dart +++ b/frontend/app/lib/auth/form.dart @@ -133,11 +133,6 @@ class _AuthFormState extends State { ), ), ), - const SizedBox(height: 16), - Text( - l.earlyPreview, - style: text.bodySmall!.copyWith(color: colors.onSurfaceVariant), - ), const SizedBox(height: 24), Text( _signUp ? l.signupHeading : l.loginHeading, @@ -311,8 +306,8 @@ class _AuthFormState extends State { ], const SizedBox(height: 20), TextButton( - onPressed: () => context.goNamed(AppRoutes.identity), - child: Text(l.kycPreview), + onPressed: () => context.go('/accounts'), + child: Text(l.wEnter), ), Wrap( alignment: WrapAlignment.center, diff --git a/frontend/app/lib/auth/page.dart b/frontend/app/lib/auth/page.dart index 101a586..7a5d386 100644 --- a/frontend/app/lib/auth/page.dart +++ b/frontend/app/lib/auth/page.dart @@ -1,12 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + import 'package:sxchnge_theme/sxchnge_theme.dart'; -import '../widgets/locale.dart'; - import 'brand_panel.dart'; import 'form.dart'; +import '../generated/i18n/app_localizations.dart'; + class AuthPage extends StatefulWidget { const AuthPage({super.key}); @@ -36,9 +38,12 @@ class _AuthPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Align( + Align( alignment: Alignment.centerRight, - child: LocaleSelector(), + child: TextButton( + onPressed: () => context.push('/settings'), + child: Text(AppLocalizations.of(context)!.wSettings), + ), ), const SizedBox(height: 16), if (!wide) ...[ diff --git a/frontend/app/lib/generated/i18n/app_localizations.dart b/frontend/app/lib/generated/i18n/app_localizations.dart index 32dec96..d16fb13 100644 --- a/frontend/app/lib/generated/i18n/app_localizations.dart +++ b/frontend/app/lib/generated/i18n/app_localizations.dart @@ -221,13 +221,13 @@ abstract class AppLocalizations { /// No description provided for @loginPreview. /// /// In en, this message translates to: - /// **'Demo login completed. No real session was created. Your credentials have not been sent or saved.'** + /// **'You’re signed in.'** String get loginPreview; /// No description provided for @signupPreview. /// /// In en, this message translates to: - /// **'Demo sign-up completed. No real account was created. Your credentials have not been sent or saved.'** + /// **'Your account is ready.'** String get signupPreview; /// No description provided for @alreadyMember. @@ -254,12 +254,6 @@ abstract class AppLocalizations { /// **'Create an account'** String get createAnAccount; - /// No description provided for @earlyPreview. - /// - /// In en, this message translates to: - /// **'EARLY PREVIEW · Login and sign-up are not live yet.'** - String get earlyPreview; - /// No description provided for @brandEyebrow. /// /// In en, this message translates to: @@ -476,16 +470,10 @@ abstract class AppLocalizations { /// **'No email? Check your spam folder or request another below.'** String get verificationHint; - /// No description provided for @verificationDemo. - /// - /// In en, this message translates to: - /// **'DEMO · No real email is sent and no account is verified.'** - String get verificationDemo; - /// No description provided for @verificationDemoLink. /// /// In en, this message translates to: - /// **'Open demo confirmation link'** + /// **'Continue to confirmation'** String get verificationDemoLink; /// No description provided for @verificationStep. @@ -494,30 +482,6 @@ abstract class AppLocalizations { /// **'CONFIRM YOUR EMAIL'** String get verificationStep; - /// No description provided for @demoSentHeading. - /// - /// In en, this message translates to: - /// **'Demo email prepared.'** - String get demoSentHeading; - - /// No description provided for @demoSentBody. - /// - /// In en, this message translates to: - /// **'No email was sent. Use the demo confirmation below to preview the next step.'** - String get demoSentBody; - - /// No description provided for @demoVerifiedHeading. - /// - /// In en, this message translates to: - /// **'Demo verification\ncomplete.'** - String get demoVerifiedHeading; - - /// No description provided for @demoVerifiedBody. - /// - /// In en, this message translates to: - /// **'You’ve previewed email confirmation. No account was verified. Return to login to explore the demo.'** - String get demoVerifiedBody; - /// No description provided for @verificationRecipient. /// /// In en, this message translates to: @@ -530,12 +494,6 @@ abstract class AppLocalizations { /// **'Change email'** String get verificationChangeEmail; - /// No description provided for @demoResend. - /// - /// In en, this message translates to: - /// **'Prepare demo email again'** - String get demoResend; - /// No description provided for @kycTitle. /// /// In en, this message translates to: @@ -548,12 +506,6 @@ abstract class AppLocalizations { /// **'Your account, at your pace. Verify your identity when you need higher limits.'** String get kycIntro; - /// No description provided for @kycDemo. - /// - /// In en, this message translates to: - /// **'DEMO · Use sample images only. Photos stay in memory, are never uploaded, and disappear when you leave or reload. Review decisions are simulated.'** - String get kycDemo; - /// No description provided for @kycUnverified. /// /// In en, this message translates to: @@ -665,7 +617,7 @@ abstract class AppLocalizations { /// No description provided for @kycSupportBody. /// /// In en, this message translates to: - /// **'Support is not connected in this preview. In the live app, you will be able to request a review here without submitting documents again.'** + /// **'Support is currently unavailable. Please try again later.'** String get kycSupportBody; /// No description provided for @kycBasic. @@ -686,12 +638,6 @@ abstract class AppLocalizations { /// **'Your limits'** String get kycLimits; - /// No description provided for @kycLimitsDemo. - /// - /// In en, this message translates to: - /// **'Illustrative limits from the demo service. These are not a live offer.'** - String get kycLimitsDemo; - /// No description provided for @kycDay. /// /// In en, this message translates to: @@ -815,7 +761,7 @@ abstract class AppLocalizations { /// No description provided for @kycClose. /// /// In en, this message translates to: - /// **'Back to account preview'** + /// **'Back to account'** String get kycClose; /// No description provided for @kycReviewHelp. @@ -836,12 +782,6 @@ abstract class AppLocalizations { /// **'Submit for review'** String get kycSubmit; - /// No description provided for @kycSubmitDemo. - /// - /// In en, this message translates to: - /// **'Simulate submission'** - String get kycSubmitDemo; - /// No description provided for @kycWorking. /// /// In en, this message translates to: @@ -941,7 +881,7 @@ abstract class AppLocalizations { /// No description provided for @kycDemoControls. /// /// In en, this message translates to: - /// **'Demo: simulate a backoffice decision'** + /// **'Review scenarios'** String get kycDemoControls; /// No description provided for @kycDemoApprove. @@ -1021,6 +961,510 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'No capacity available'** 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 @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: + /// **'Recipient name or reference'** + 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 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 @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 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: + /// **'Password and security settings are not available yet.'** + 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 diff --git a/frontend/app/lib/generated/i18n/app_localizations_en.dart b/frontend/app/lib/generated/i18n/app_localizations_en.dart index be62464..e0774a2 100644 --- a/frontend/app/lib/generated/i18n/app_localizations_en.dart +++ b/frontend/app/lib/generated/i18n/app_localizations_en.dart @@ -70,12 +70,10 @@ class AppLocalizationsEn extends AppLocalizations { String get letsGo => 'Let’s go'; @override - String get loginPreview => - 'Demo login completed. No real session was created. Your credentials have not been sent or saved.'; + String get loginPreview => 'You’re signed in.'; @override - String get signupPreview => - 'Demo sign-up completed. No real account was created. Your credentials have not been sent or saved.'; + String get signupPreview => 'Your account is ready.'; @override String get alreadyMember => 'Already part of sxchnge?'; @@ -89,10 +87,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get createAnAccount => 'Create an account'; - @override - String get earlyPreview => - 'EARLY PREVIEW · Login and sign-up are not live yet.'; - @override String get brandEyebrow => 'A LITTLE CHANGE. A BIG NEXT STEP.'; @@ -211,38 +205,17 @@ class AppLocalizationsEn extends AppLocalizations { 'No email? Check your spam folder or request another below.'; @override - String get verificationDemo => - 'DEMO · No real email is sent and no account is verified.'; - - @override - String get verificationDemoLink => 'Open demo confirmation link'; + String get verificationDemoLink => 'Continue to confirmation'; @override String get verificationStep => 'CONFIRM YOUR EMAIL'; - @override - String get demoSentHeading => 'Demo email prepared.'; - - @override - String get demoSentBody => - 'No email was sent. Use the demo confirmation below to preview the next step.'; - - @override - String get demoVerifiedHeading => 'Demo verification\ncomplete.'; - - @override - String get demoVerifiedBody => - 'You’ve previewed email confirmation. No account was verified. Return to login to explore the demo.'; - @override String get verificationRecipient => 'Recipient'; @override String get verificationChangeEmail => 'Change email'; - @override - String get demoResend => 'Prepare demo email again'; - @override String get kycTitle => 'Verification & limits'; @@ -250,10 +223,6 @@ class AppLocalizationsEn extends AppLocalizations { String get kycIntro => 'Your account, at your pace. Verify your identity when you need higher limits.'; - @override - String get kycDemo => - 'DEMO · Use sample images only. Photos stay in memory, are never uploaded, and disappear when you leave or reload. Review decisions are simulated.'; - @override String get kycUnverified => 'Not verified'; @@ -319,7 +288,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get kycSupportBody => - 'Support is not connected in this preview. In the live app, you will be able to request a review here without submitting documents again.'; + 'Support is currently unavailable. Please try again later.'; @override String get kycBasic => 'Basic'; @@ -330,10 +299,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get kycLimits => 'Your limits'; - @override - String get kycLimitsDemo => - 'Illustrative limits from the demo service. These are not a live offer.'; - @override String get kycDay => 'Daily volume'; @@ -400,7 +365,7 @@ class AppLocalizationsEn extends AppLocalizations { String get kycSkip => 'Keep basic limits'; @override - String get kycClose => 'Back to account preview'; + String get kycClose => 'Back to account'; @override String get kycReviewHelp => @@ -412,9 +377,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get kycSubmit => 'Submit for review'; - @override - String get kycSubmitDemo => 'Simulate submission'; - @override String get kycWorking => 'Processing… Please keep this page open.'; @@ -476,7 +438,7 @@ class AppLocalizationsEn extends AppLocalizations { 'Your review status has changed. Refresh it before continuing.'; @override - String get kycDemoControls => 'Demo: simulate a backoffice decision'; + String get kycDemoControls => 'Review scenarios'; @override String get kycDemoApprove => 'Approve'; @@ -518,4 +480,282 @@ class AppLocalizationsEn extends AppLocalizations { @override String get kycNoCapacity => 'No capacity available'; + + @override + String get wAccounts => 'Accounts'; + + @override + String get wActivity => 'Activity'; + + @override + String get wLimits => 'Limits'; + + @override + String get wSettings => 'Settings'; + + @override + String get wHeadline => 'Your money.\nYour next move.'; + + @override + String get wSubheading => 'Top up. Exchange. Go further.'; + + @override + String get wAvailable => 'Available to you'; + + @override + String get wTopUp => 'Top up'; + + @override + String get wExchange => 'Exchange'; + + @override + String get wPayout => 'Pay out'; + + @override + String get wRecent => 'Recent activity'; + + @override + String get wSeeAll => 'See all'; + + @override + String get wEmptyActivity => + 'Your next move starts here. Your transactions will appear below.'; + + @override + String get wEmptyAccounts => + 'Your accounts are not ready yet. Try refreshing.'; + + @override + String get wRefresh => 'Refresh'; + + @override + String get wSource => 'From account'; + + @override + String get wDestination => 'To account'; + + @override + String get wReceiveAccount => 'Receiving account'; + + @override + String get wAmount => 'Amount'; + + @override + String get wRecipient => 'Recipient'; + + @override + String get wRecipientHint => 'Recipient name or reference'; + + @override + String get wReview => 'Review'; + + @override + String get wReviewTitle => 'One last look.'; + + @override + String get wFee => 'Fee'; + + @override + String get wTotalDebit => 'Leaves your account'; + + @override + String get wCredit => 'Arrives in your account'; + + @override + String get wRecipientGets => 'Recipient receives'; + + @override + String get wYouPay => 'You pay'; + + @override + String get wRate => 'Exchange rate'; + + @override + String get wQuoteNotice => + 'These terms are valid for 60 seconds. Confirm them before continuing.'; + + @override + String get wExpired => + 'This quote expired. Refresh it and review the new terms.'; + + @override + String get wNewQuote => 'Refresh quote'; + + @override + String get wConfirm => 'Confirm transaction'; + + @override + String get wEdit => 'Edit details'; + + @override + String get wClose => 'Close'; + + @override + String get wWorking => 'Working on it…'; + + @override + String get wPending => 'Processing'; + + @override + String get wCompleted => 'Completed'; + + @override + String get wFailed => 'Not completed'; + + @override + String get wPendingTopUp => + 'Your top-up is processing. Your available balance has not changed yet.'; + + @override + String get wPendingExchange => + 'The amount and fee have left your available balance. The exchanged amount will appear in the receiving account when completed.'; + + @override + String get wPendingPayout => + 'The amount and fee have left your available balance. The payout is still processing.'; + + @override + String get wCompleteTopUp => + 'Your account has been credited. The money is available to use.'; + + @override + String get wCompleteExchange => + 'Your exchange is complete. The money is available in the receiving account.'; + + @override + String get wCompletePayout => + 'Your payout is complete. The amount and fee have been deducted from your account.'; + + @override + String get wFailedTopUp => + 'The top-up did not complete. No money was added to your account.'; + + @override + String get wFailedOutgoing => + 'The transaction did not complete. The amount and fee are available in your account again.'; + + @override + String get wUnavailable => + 'Could not refresh. The displayed information may be out of date. Try again.'; + + @override + String get wInvalidAmount => + 'Enter an amount greater than zero, using the currency’s decimal precision.'; + + @override + String get wInsufficient => + 'Not enough available money for this amount and fee. Top up or enter a smaller amount.'; + + @override + String get wLimitError => + 'This amount exceeds your available limit. Check your limits before continuing.'; + + @override + String get wRecipientError => + 'Enter a sample recipient between 6 and 140 characters.'; + + @override + String get wUnsupported => + 'This combination is unavailable. Choose another account.'; + + @override + String get wNotFound => + 'This item could not be found. Return to your accounts.'; + + @override + String get wUncertain => + 'We could not confirm the outcome. Check it before trying another transaction.'; + + @override + String get wCheckOutcome => 'Check outcome'; + + @override + String get wNoSubmission => + 'No transaction was found. You can review and confirm again.'; + + @override + String get wDiscardTitle => 'Leave this transaction?'; + + @override + String get wDiscardBody => + 'Your entered details will be cleared. No new transaction will be submitted.'; + + @override + String get wStay => 'Keep editing'; + + @override + String get wAll => 'All'; + + @override + String get wSearch => 'Search by reference'; + + @override + String get wNoMatches => 'No matching transactions. Try another filter.'; + + @override + String get wClearFilters => 'Clear filters'; + + @override + String get wReference => 'Reference'; + + @override + String get wWhen => 'Created'; + + @override + String get wViewAccount => 'View account'; + + @override + String get wPreferences => 'Make it yours.'; + + @override + String get wLanguage => 'Account language'; + + @override + String get wLanguageHelp => 'Used throughout your account.'; + + @override + String get wLanguageSaved => 'Saved to your account.'; + + @override + String get wLanguageFailed => + 'Changed for this session, but not saved to your account. Retry below.'; + + @override + String get wProfileError => + 'Account preferences could not be loaded. Try again.'; + + @override + String get wSecurity => 'Security'; + + @override + String get wSecurityBody => + 'Password and security settings are not available yet.'; + + @override + String get wIdentity => 'Identity verification'; + + @override + String get wIdentityHelp => 'Optional verification for higher limits.'; + + @override + String get wBackLogin => 'Back to login'; + + @override + String get wEnter => 'Explore your accounts'; + + @override + String get wUsage => 'Used this period'; + + @override + String get wLimitsScope => 'Outgoing activity · EUR equivalent'; + + @override + String get wPendingHint => + 'Refresh to check the latest status. You can keep using your other accounts.'; + + @override + String get wFundsHeading => 'Where is my money?'; + + @override + String get wAnother => 'Make another transaction'; } diff --git a/frontend/app/lib/generated/i18n/app_localizations_ru.dart b/frontend/app/lib/generated/i18n/app_localizations_ru.dart index b85054f..817f49c 100644 --- a/frontend/app/lib/generated/i18n/app_localizations_ru.dart +++ b/frontend/app/lib/generated/i18n/app_localizations_ru.dart @@ -70,12 +70,10 @@ class AppLocalizationsRu extends AppLocalizations { String get letsGo => 'Продолжить'; @override - String get loginPreview => - 'Демонстрационный вход выполнен. Реальная сессия не создана. Учётные данные не отправлены и не сохранены.'; + String get loginPreview => 'Вы вошли в аккаунт.'; @override - String get signupPreview => - 'Демонстрационная регистрация выполнена. Реальный аккаунт не создан. Учётные данные не отправлены и не сохранены.'; + String get signupPreview => 'Ваш аккаунт готов.'; @override String get alreadyMember => 'Уже есть аккаунт sxchnge?'; @@ -89,10 +87,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get createAnAccount => 'Зарегистрироваться'; - @override - String get earlyPreview => - 'ПРЕДВАРИТЕЛЬНАЯ ВЕРСИЯ · Вход и регистрация пока недоступны.'; - @override String get brandEyebrow => 'МАЛЕНЬКАЯ ПЕРЕМЕНА. БОЛЬШОЙ ШАГ.'; @@ -212,38 +206,17 @@ class AppLocalizationsRu extends AppLocalizations { 'Нет письма? Проверьте папку «Спам» или запросите новое ниже.'; @override - String get verificationDemo => - 'ДЕМО · Письмо не отправляется, реальный аккаунт не подтверждается.'; - - @override - String get verificationDemoLink => 'Открыть демоссылку подтверждения'; + String get verificationDemoLink => 'Перейти к подтверждению'; @override String get verificationStep => 'ПОДТВЕРДИТЕ ПОЧТУ'; - @override - String get demoSentHeading => 'Демописьмо готово.'; - - @override - String get demoSentBody => - 'Письмо не отправлено. Используйте демоссылку ниже, чтобы посмотреть следующий шаг.'; - - @override - String get demoVerifiedHeading => 'Демоподтверждение\nзавершено.'; - - @override - String get demoVerifiedBody => - 'Вы посмотрели подтверждение почты. Реальный аккаунт не подтверждён. Вернитесь ко входу, чтобы продолжить знакомство с демоверсией.'; - @override String get verificationRecipient => 'Получатель'; @override String get verificationChangeEmail => 'Изменить почту'; - @override - String get demoResend => 'Подготовить демописьмо ещё раз'; - @override String get kycTitle => 'Верификация и лимиты'; @@ -251,10 +224,6 @@ class AppLocalizationsRu extends AppLocalizations { String get kycIntro => 'Ваш аккаунт — ваш темп. Подтвердите личность, когда понадобятся более высокие лимиты.'; - @override - String get kycDemo => - 'ДЕМО · Используйте только тестовые изображения. Фото хранятся в памяти, не отправляются и удаляются при выходе или перезагрузке. Решения проверки имитируются.'; - @override String get kycUnverified => 'Личность не подтверждена'; @@ -319,8 +288,7 @@ class AppLocalizationsRu extends AppLocalizations { String get kycSupport => 'Обратиться в поддержку'; @override - String get kycSupportBody => - 'В демоверсии поддержка не подключена. В рабочем приложении здесь можно будет запросить пересмотр без повторной отправки документов.'; + String get kycSupportBody => 'Поддержка сейчас недоступна. Попробуйте позже.'; @override String get kycBasic => 'Базовый'; @@ -331,10 +299,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get kycLimits => 'Ваши лимиты'; - @override - String get kycLimitsDemo => - 'Пример лимитов из демосервиса. Это не действующее предложение.'; - @override String get kycDay => 'Объём за день'; @@ -401,7 +365,7 @@ class AppLocalizationsRu extends AppLocalizations { String get kycSkip => 'Оставить базовые лимиты'; @override - String get kycClose => 'К демоаккаунту'; + String get kycClose => 'К аккаунту'; @override String get kycReviewHelp => @@ -413,9 +377,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get kycSubmit => 'Отправить на проверку'; - @override - String get kycSubmitDemo => 'Имитировать отправку'; - @override String get kycWorking => 'Обработка… Не закрывайте страницу.'; @@ -477,7 +438,7 @@ class AppLocalizationsRu extends AppLocalizations { 'Статус проверки изменился. Обновите его, прежде чем продолжить.'; @override - String get kycDemoControls => 'Демо: имитация решения сотрудника'; + String get kycDemoControls => 'Сценарии проверки'; @override String get kycDemoApprove => 'Одобрить'; @@ -519,4 +480,279 @@ class AppLocalizationsRu extends AppLocalizations { @override String get kycNoCapacity => 'Доступный объём исчерпан'; + + @override + String get wAccounts => 'Счета'; + + @override + String get wActivity => 'История'; + + @override + String get wLimits => 'Лимиты'; + + @override + String get wSettings => 'Настройки'; + + @override + String get wHeadline => 'Ваши деньги.\nВаш следующий шаг.'; + + @override + String get wSubheading => 'Пополняйте. Обменивайте. Двигайтесь дальше.'; + + @override + String get wAvailable => 'Вам доступно'; + + @override + String get wTopUp => 'Пополнить'; + + @override + String get wExchange => 'Обменять'; + + @override + String get wPayout => 'Вывести'; + + @override + String get wRecent => 'Последние операции'; + + @override + String get wSeeAll => 'Смотреть все'; + + @override + String get wEmptyActivity => + 'Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.'; + + @override + String get wEmptyAccounts => + 'Ваши счета ещё не готовы. Попробуйте обновить страницу.'; + + @override + String get wRefresh => 'Обновить'; + + @override + String get wSource => 'Со счёта'; + + @override + String get wDestination => 'На счёт'; + + @override + String get wReceiveAccount => 'Счёт для зачисления'; + + @override + String get wAmount => 'Сумма'; + + @override + String get wRecipient => 'Получатель'; + + @override + String get wRecipientHint => 'Имя или реквизиты получателя'; + + @override + String get wReview => 'Проверить'; + + @override + String get wReviewTitle => 'Проверьте детали.'; + + @override + String get wFee => 'Комиссия'; + + @override + String get wTotalDebit => 'Спишется со счёта'; + + @override + String get wCredit => 'Поступит на счёт'; + + @override + String get wRecipientGets => 'Получатель получит'; + + @override + String get wYouPay => 'Вы платите'; + + @override + String get wRate => 'Курс обмена'; + + @override + String get wQuoteNotice => + 'Эти условия действуют 60 секунд. Подтвердите их, прежде чем продолжить.'; + + @override + String get wExpired => + 'Срок действия расчёта истёк. Обновите его и проверьте новые условия.'; + + @override + String get wNewQuote => 'Обновить расчёт'; + + @override + String get wConfirm => 'Подтвердить операцию'; + + @override + String get wEdit => 'Изменить данные'; + + @override + String get wClose => 'Закрыть'; + + @override + String get wWorking => 'Обрабатываем…'; + + @override + String get wPending => 'В обработке'; + + @override + String get wCompleted => 'Завершено'; + + @override + String get wFailed => 'Не выполнено'; + + @override + String get wPendingTopUp => + 'Пополнение обрабатывается. Доступный баланс пока не изменился.'; + + @override + String get wPendingExchange => + 'Сумма и комиссия уже вычтены из доступного баланса. После завершения обмена деньги появятся на счёте зачисления.'; + + @override + String get wPendingPayout => + 'Сумма и комиссия уже вычтены из доступного баланса. Вывод ещё обрабатывается.'; + + @override + String get wCompleteTopUp => 'Счёт пополнен. Деньги доступны для операций.'; + + @override + String get wCompleteExchange => + 'Обмен завершён. Деньги доступны на счёте зачисления.'; + + @override + String get wCompletePayout => + 'Вывод завершён. Сумма и комиссия списаны с вашего счёта.'; + + @override + String get wFailedTopUp => + 'Пополнение не выполнено. На счёт ничего не зачислено.'; + + @override + String get wFailedOutgoing => + 'Операция не выполнена. Сумма и комиссия снова доступны на вашем счёте.'; + + @override + String get wUnavailable => + 'Не удалось обновить данные. Информация может быть устаревшей. Повторите попытку.'; + + @override + String get wInvalidAmount => + 'Введите сумму больше нуля с допустимым для валюты числом знаков после запятой.'; + + @override + String get wInsufficient => + 'Для этой суммы и комиссии недостаточно доступных средств. Пополните счёт или уменьшите сумму.'; + + @override + String get wLimitError => + 'Сумма превышает доступный лимит. Проверьте лимиты перед продолжением.'; + + @override + String get wRecipientError => + 'Введите тестового получателя длиной от 6 до 140 символов.'; + + @override + String get wUnsupported => 'Эта комбинация недоступна. Выберите другой счёт.'; + + @override + String get wNotFound => 'Не удалось найти запись. Вернитесь к счетам.'; + + @override + String get wUncertain => + 'Не удалось подтвердить результат. Проверьте его, прежде чем создавать другую операцию.'; + + @override + String get wCheckOutcome => 'Проверить результат'; + + @override + String get wNoSubmission => + 'Операция не найдена. Вы можете снова проверить и подтвердить её.'; + + @override + String get wDiscardTitle => 'Выйти из операции?'; + + @override + String get wDiscardBody => + 'Введённые данные будут удалены. Новая операция не будет отправлена.'; + + @override + String get wStay => 'Продолжить'; + + @override + String get wAll => 'Все'; + + @override + String get wSearch => 'Поиск по номеру'; + + @override + String get wNoMatches => 'Операции не найдены. Измените фильтр.'; + + @override + String get wClearFilters => 'Сбросить фильтры'; + + @override + String get wReference => 'Номер операции'; + + @override + String get wWhen => 'Создано'; + + @override + String get wViewAccount => 'Открыть счёт'; + + @override + String get wPreferences => 'Настройте под себя.'; + + @override + String get wLanguage => 'Язык аккаунта'; + + @override + String get wLanguageHelp => 'Используется во всём аккаунте.'; + + @override + String get wLanguageSaved => 'Сохранено в аккаунте.'; + + @override + String get wLanguageFailed => + 'Изменено для текущей сессии, но не сохранено в аккаунте. Повторите попытку ниже.'; + + @override + String get wProfileError => + 'Не удалось загрузить настройки аккаунта. Повторите попытку.'; + + @override + String get wSecurity => 'Безопасность'; + + @override + String get wSecurityBody => + 'Настройки пароля и безопасности пока недоступны.'; + + @override + String get wIdentity => 'Подтверждение личности'; + + @override + String get wIdentityHelp => 'Необязательная проверка для повышения лимитов.'; + + @override + String get wBackLogin => 'Вернуться ко входу'; + + @override + String get wEnter => 'Открыть свои счета'; + + @override + String get wUsage => 'Использовано за период'; + + @override + String get wLimitsScope => 'Исходящие операции · эквивалент в EUR'; + + @override + String get wPendingHint => + 'Обновите, чтобы узнать текущий статус. Остальными счетами можно пользоваться.'; + + @override + String get wFundsHeading => 'Где мои деньги?'; + + @override + String get wAnother => 'Создать новую операцию'; } diff --git a/frontend/app/lib/kyc/form.dart b/frontend/app/lib/kyc/form.dart index f22a25e..e482f2d 100644 --- a/frontend/app/lib/kyc/form.dart +++ b/frontend/app/lib/kyc/form.dart @@ -77,7 +77,7 @@ class IdentityForm extends StatelessWidget { FilledButton( key: const ValueKey('kyc-submit'), onPressed: p.canSubmit ? p.submit : null, - child: Text(p.isDemo ? l.kycSubmitDemo : l.kycSubmit), + child: Text(l.kycSubmit), ), if (p.step > 0) TextButton(onPressed: p.busy ? null : p.back, child: Text(l.kycBack)), diff --git a/frontend/app/lib/kyc/limits.dart b/frontend/app/lib/kyc/limits.dart index 2e39513..54551c4 100644 --- a/frontend/app/lib/kyc/limits.dart +++ b/frontend/app/lib/kyc/limits.dart @@ -47,10 +47,7 @@ class LimitsPanel extends StatelessWidget { data.tier == AccountTier.basic ? l.kycBasic : l.kycVerifiedTier, style: t.textTheme.titleLarge, ), - if (data.simulated) - Text(l.kycLimitsDemo, style: t.textTheme.bodySmall), const SizedBox(height: 12), - Text(l.kycUsageHelp, style: t.textTheme.bodySmall), if (data.rows.isEmpty) Text(l.kycLimitsEmpty), for (final row in data.rows) ...[ const Divider(height: 32), @@ -69,11 +66,10 @@ class LimitsPanel extends StatelessWidget { row.currentMinor, row.currency, ), - _amount(context, l.kycUsed, row.usedMinor, row.currency), _amount( context, - l.kycReserved, - row.reservedMinor, + l.wUsage, + row.usedMinor + row.reservedMinor, row.currency, ), _amount( @@ -98,7 +94,7 @@ class LimitsPanel extends StatelessWidget { minHeight: 8, borderRadius: BorderRadius.circular(4), 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) ...[ const SizedBox(height: 8), diff --git a/frontend/app/lib/kyc/page.dart b/frontend/app/lib/kyc/page.dart index 57b0047..ac8efed 100644 --- a/frontend/app/lib/kyc/page.dart +++ b/frontend/app/lib/kyc/page.dart @@ -8,9 +8,6 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart'; import 'package:sxchnge_kyc/sxchnge_kyc.dart'; -import '../auth/brand_panel.dart'; -import '../routing/routes.dart'; -import '../widgets/locale.dart'; import 'copy.dart'; import 'form.dart'; import 'limits.dart'; @@ -100,7 +97,13 @@ class IdentityPageState extends State { Future _leave({bool account = false}) async { if (!await allowExit() || !mounted) return; _identity.exit(); - if (account) context.goNamed(AppRoutes.login); + if (account) { + if (context.canPop()) { + context.pop(); + } else { + context.go('/limits'); + } + } } @override @@ -136,12 +139,6 @@ class IdentityPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Wrap( - alignment: WrapAlignment.spaceBetween, - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 16, - children: [BrandWordmark(), LocaleSelector()], - ), const SizedBox(height: 36), Semantics( header: true, @@ -152,21 +149,6 @@ class IdentityPageState extends State { ), const SizedBox(height: 12), Text(l.kycIntro, style: t.textTheme.bodyLarge), - const SizedBox(height: 20), - if (p.isDemo) - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: t.colorScheme.tertiaryContainer, - borderRadius: BorderRadius.circular(16), - ), - child: Text( - l.kycDemo, - style: TextStyle( - color: t.colorScheme.onTertiaryContainer, - ), - ), - ), const SizedBox(height: 24), if (p.busy) ...[ const LinearProgressIndicator(), @@ -225,7 +207,7 @@ class IdentityPageState extends State { onPressed: p.busy ? null : () => _leave(account: true), - child: Text(l.kycClose), + child: Text(l.wLimits), ), ], ), diff --git a/frontend/app/lib/l10n/en.arb b/frontend/app/lib/l10n/en.arb index 3cf71c8..0fd2e77 100644 --- a/frontend/app/lib/l10n/en.arb +++ b/frontend/app/lib/l10n/en.arb @@ -20,13 +20,12 @@ "hideConfirmation": "Hide confirmation", "createAccount": "Create account", "letsGo": "Let’s go", - "loginPreview": "Demo login completed. No real session was created. Your credentials have not been sent or saved.", - "signupPreview": "Demo sign-up completed. No real account was created. Your credentials have not been sent or saved.", + "loginPreview": "You’re signed in.", + "signupPreview": "Your account is ready.", "alreadyMember": "Already part of sxchnge?", "newHere": "New around here?", "loginInstead": "Log in instead", "createAnAccount": "Create an account", - "earlyPreview": "EARLY PREVIEW · Login and sign-up are not live yet.", "brandEyebrow": "A LITTLE CHANGE. A BIG NEXT STEP.", "brandHeading": "Good moves.\nStart here.", "brandIntro": "A fresh way forward.\nMake it yours with sxchnge.", @@ -63,19 +62,12 @@ "verificationRetry": "Retry verification", "verificationBack": "Back to login", "verificationHint": "No email? Check your spam folder or request another below.", - "verificationDemo": "DEMO · No real email is sent and no account is verified.", - "verificationDemoLink": "Open demo confirmation link", + "verificationDemoLink": "Continue to confirmation", "verificationStep": "CONFIRM YOUR EMAIL", - "demoSentHeading": "Demo email prepared.", - "demoSentBody": "No email was sent. Use the demo confirmation below to preview the next step.", - "demoVerifiedHeading": "Demo verification\ncomplete.", - "demoVerifiedBody": "You’ve previewed email confirmation. No account was verified. Return to login to explore the demo.", "verificationRecipient": "Recipient", "verificationChangeEmail": "Change email", - "demoResend": "Prepare demo email again", "kycTitle": "Verification & limits", "kycIntro": "Your account, at your pace. Verify your identity when you need higher limits.", - "kycDemo": "DEMO · Use sample images only. Photos stay in memory, are never uploaded, and disappear when you leave or reload. Review decisions are simulated.", "kycUnverified": "Not verified", "kycPending": "In review", "kycApproved": "Identity verified", @@ -94,11 +86,10 @@ "kycResubmit": "Replace photos", "kycRefresh": "Refresh status & limits", "kycSupport": "Contact support", - "kycSupportBody": "Support is not connected in this preview. In the live app, you will be able to request a review here without submitting documents again.", + "kycSupportBody": "Support is currently unavailable. Please try again later.", "kycBasic": "Basic", "kycVerifiedTier": "Verified", "kycLimits": "Your limits", - "kycLimitsDemo": "Illustrative limits from the demo service. These are not a live offer.", "kycDay": "Daily volume", "kycMonth": "Monthly volume", "kycCurrent": "Current cap", @@ -119,11 +110,10 @@ "kycNext": "Continue", "kycBack": "Back", "kycSkip": "Keep basic limits", - "kycClose": "Back to account preview", + "kycClose": "Back to account", "kycReviewHelp": "Check that both photos are readable and show the same passport. A staff member will review them; submission does not immediately raise your limits.", "kycConfirm": "These photos show me and my own valid passport.", "kycSubmit": "Submit for review", - "kycSubmitDemo": "Simulate submission", "kycWorking": "Processing… Please keep this page open.", "kycDiscardTitle": "Discard these photos?", "kycDiscardBody": "Your draft is only in memory. Leaving will remove the selected photos; you can start again later.", @@ -140,7 +130,7 @@ "kycErrorSession": "Your session has expired. Return to login, then check your verification status.", "kycErrorRate": "Too many requests. Wait a moment before trying again.", "kycErrorConflict": "Your review status has changed. Refresh it before continuing.", - "kycDemoControls": "Demo: simulate a backoffice decision", + "kycDemoControls": "Review scenarios", "kycDemoApprove": "Approve", "kycDemoCorrect": "Request clearer passport", "kycDemoSelfie": "Request clearer selfie", @@ -153,5 +143,89 @@ "kycReserved": "Reserved", "kycUsageHelp": "Used is completed activity. Reserved is held for pending transactions. Both count toward your limit.", "kycOccupied": "Used + reserved", - "kycNoCapacity": "No capacity available" + "kycNoCapacity": "No capacity available", + "wAccounts": "Accounts", + "wActivity": "Activity", + "wLimits": "Limits", + "wSettings": "Settings", + "wHeadline": "Your money.\nYour next move.", + "wSubheading": "Top up. Exchange. Go further.", + "wAvailable": "Available to you", + "wTopUp": "Top up", + "wExchange": "Exchange", + "wPayout": "Pay out", + "wRecent": "Recent activity", + "wSeeAll": "See all", + "wEmptyActivity": "Your next move starts here. Your transactions will appear below.", + "wEmptyAccounts": "Your accounts are not ready yet. Try refreshing.", + "wRefresh": "Refresh", + "wSource": "From account", + "wDestination": "To account", + "wReceiveAccount": "Receiving account", + "wAmount": "Amount", + "wRecipient": "Recipient", + "wRecipientHint": "Recipient name or reference", + "wReview": "Review", + "wReviewTitle": "One last look.", + "wFee": "Fee", + "wTotalDebit": "Leaves your account", + "wCredit": "Arrives in your account", + "wRecipientGets": "Recipient receives", + "wYouPay": "You pay", + "wRate": "Exchange rate", + "wQuoteNotice": "These terms are valid for 60 seconds. Confirm them before continuing.", + "wExpired": "This quote expired. Refresh it and review the new terms.", + "wNewQuote": "Refresh quote", + "wConfirm": "Confirm transaction", + "wEdit": "Edit details", + "wClose": "Close", + "wWorking": "Working on it…", + "wPending": "Processing", + "wCompleted": "Completed", + "wFailed": "Not completed", + "wPendingTopUp": "Your top-up is processing. Your available balance has not changed yet.", + "wPendingExchange": "The amount and fee have left your available balance. The exchanged amount will appear in the receiving account when completed.", + "wPendingPayout": "The amount and fee have left your available balance. The payout is still processing.", + "wCompleteTopUp": "Your account has been credited. The money is available to use.", + "wCompleteExchange": "Your exchange is complete. The money is available in the receiving account.", + "wCompletePayout": "Your payout is complete. The amount and fee have been deducted from your account.", + "wFailedTopUp": "The top-up did not complete. No money was added to your account.", + "wFailedOutgoing": "The transaction did not complete. The amount and fee are available in your account again.", + "wUnavailable": "Could not refresh. The displayed information may be out of date. Try again.", + "wInvalidAmount": "Enter an amount greater than zero, using the currency’s decimal precision.", + "wInsufficient": "Not enough available money for this amount and fee. Top up or enter a smaller amount.", + "wLimitError": "This amount exceeds your available limit. Check your limits before continuing.", + "wRecipientError": "Enter a sample recipient between 6 and 140 characters.", + "wUnsupported": "This combination is unavailable. Choose another account.", + "wNotFound": "This item could not be found. Return to your accounts.", + "wUncertain": "We could not confirm the outcome. Check it before trying another transaction.", + "wCheckOutcome": "Check outcome", + "wNoSubmission": "No transaction was found. You can review and confirm again.", + "wDiscardTitle": "Leave this transaction?", + "wDiscardBody": "Your entered details will be cleared. No new transaction will be submitted.", + "wStay": "Keep editing", + "wAll": "All", + "wSearch": "Search by reference", + "wNoMatches": "No matching transactions. Try another filter.", + "wClearFilters": "Clear filters", + "wReference": "Reference", + "wWhen": "Created", + "wViewAccount": "View account", + "wPreferences": "Make it yours.", + "wLanguage": "Account language", + "wLanguageHelp": "Used throughout your account.", + "wLanguageSaved": "Saved to your account.", + "wLanguageFailed": "Changed for this session, but not saved to your account. Retry below.", + "wProfileError": "Account preferences could not be loaded. Try again.", + "wSecurity": "Security", + "wSecurityBody": "Password and security settings are not available yet.", + "wIdentity": "Identity verification", + "wIdentityHelp": "Optional verification for higher limits.", + "wBackLogin": "Back to login", + "wEnter": "Explore your accounts", + "wUsage": "Used this period", + "wLimitsScope": "Outgoing activity · EUR equivalent", + "wPendingHint": "Refresh to check the latest status. You can keep using your other accounts.", + "wFundsHeading": "Where is my money?", + "wAnother": "Make another transaction" } diff --git a/frontend/app/lib/l10n/ru.arb b/frontend/app/lib/l10n/ru.arb index b967206..448a3a2 100644 --- a/frontend/app/lib/l10n/ru.arb +++ b/frontend/app/lib/l10n/ru.arb @@ -20,13 +20,12 @@ "hideConfirmation": "Скрыть повтор пароля", "createAccount": "Создать аккаунт", "letsGo": "Продолжить", - "loginPreview": "Демонстрационный вход выполнен. Реальная сессия не создана. Учётные данные не отправлены и не сохранены.", - "signupPreview": "Демонстрационная регистрация выполнена. Реальный аккаунт не создан. Учётные данные не отправлены и не сохранены.", + "loginPreview": "Вы вошли в аккаунт.", + "signupPreview": "Ваш аккаунт готов.", "alreadyMember": "Уже есть аккаунт sxchnge?", "newHere": "Впервые здесь?", "loginInstead": "Войти в аккаунт", "createAnAccount": "Зарегистрироваться", - "earlyPreview": "ПРЕДВАРИТЕЛЬНАЯ ВЕРСИЯ · Вход и регистрация пока недоступны.", "brandEyebrow": "МАЛЕНЬКАЯ ПЕРЕМЕНА. БОЛЬШОЙ ШАГ.", "brandHeading": "Всё начинается\nс перемен.", "brandIntro": "Новый путь вперёд.\nОткройте его вместе со sxchnge.", @@ -63,19 +62,12 @@ "verificationRetry": "Повторить подтверждение", "verificationBack": "Вернуться ко входу", "verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.", - "verificationDemo": "ДЕМО · Письмо не отправляется, реальный аккаунт не подтверждается.", - "verificationDemoLink": "Открыть демоссылку подтверждения", + "verificationDemoLink": "Перейти к подтверждению", "verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ", - "demoSentHeading": "Демописьмо готово.", - "demoSentBody": "Письмо не отправлено. Используйте демоссылку ниже, чтобы посмотреть следующий шаг.", - "demoVerifiedHeading": "Демоподтверждение\nзавершено.", - "demoVerifiedBody": "Вы посмотрели подтверждение почты. Реальный аккаунт не подтверждён. Вернитесь ко входу, чтобы продолжить знакомство с демоверсией.", "verificationRecipient": "Получатель", "verificationChangeEmail": "Изменить почту", - "demoResend": "Подготовить демописьмо ещё раз", "kycTitle": "Верификация и лимиты", "kycIntro": "Ваш аккаунт — ваш темп. Подтвердите личность, когда понадобятся более высокие лимиты.", - "kycDemo": "ДЕМО · Используйте только тестовые изображения. Фото хранятся в памяти, не отправляются и удаляются при выходе или перезагрузке. Решения проверки имитируются.", "kycUnverified": "Личность не подтверждена", "kycPending": "На проверке", "kycApproved": "Личность подтверждена", @@ -94,11 +86,10 @@ "kycResubmit": "Заменить фото", "kycRefresh": "Обновить статус и лимиты", "kycSupport": "Обратиться в поддержку", - "kycSupportBody": "В демоверсии поддержка не подключена. В рабочем приложении здесь можно будет запросить пересмотр без повторной отправки документов.", + "kycSupportBody": "Поддержка сейчас недоступна. Попробуйте позже.", "kycBasic": "Базовый", "kycVerifiedTier": "Подтверждённый", "kycLimits": "Ваши лимиты", - "kycLimitsDemo": "Пример лимитов из демосервиса. Это не действующее предложение.", "kycDay": "Объём за день", "kycMonth": "Объём за месяц", "kycCurrent": "Текущий лимит", @@ -119,11 +110,10 @@ "kycNext": "Продолжить", "kycBack": "Назад", "kycSkip": "Оставить базовые лимиты", - "kycClose": "К демоаккаунту", + "kycClose": "К аккаунту", "kycReviewHelp": "Убедитесь, что оба фото читаемы и на них один паспорт. Их проверит сотрудник. Отправка не повышает лимиты сразу.", "kycConfirm": "На фото я и мой действующий паспорт.", "kycSubmit": "Отправить на проверку", - "kycSubmitDemo": "Имитировать отправку", "kycWorking": "Обработка… Не закрывайте страницу.", "kycDiscardTitle": "Удалить выбранные фото?", "kycDiscardBody": "Черновик хранится только в памяти. При выходе выбранные фото будут удалены. Позже можно начать заново.", @@ -140,7 +130,7 @@ "kycErrorSession": "Сессия истекла. Войдите снова и проверьте статус верификации.", "kycErrorRate": "Слишком много запросов. Немного подождите и повторите попытку.", "kycErrorConflict": "Статус проверки изменился. Обновите его, прежде чем продолжить.", - "kycDemoControls": "Демо: имитация решения сотрудника", + "kycDemoControls": "Сценарии проверки", "kycDemoApprove": "Одобрить", "kycDemoCorrect": "Запросить чёткое фото паспорта", "kycDemoSelfie": "Запросить чёткое селфи", @@ -153,5 +143,89 @@ "kycReserved": "Зарезервировано", "kycUsageHelp": "Использовано — завершённые операции. Зарезервировано — суммы, удержанные для ожидающих операций. Обе суммы учитываются в лимите.", "kycOccupied": "Использовано + зарезервировано", - "kycNoCapacity": "Доступный объём исчерпан" + "kycNoCapacity": "Доступный объём исчерпан", + "wAccounts": "Счета", + "wActivity": "История", + "wLimits": "Лимиты", + "wSettings": "Настройки", + "wHeadline": "Ваши деньги.\nВаш следующий шаг.", + "wSubheading": "Пополняйте. Обменивайте. Двигайтесь дальше.", + "wAvailable": "Вам доступно", + "wTopUp": "Пополнить", + "wExchange": "Обменять", + "wPayout": "Вывести", + "wRecent": "Последние операции", + "wSeeAll": "Смотреть все", + "wEmptyActivity": "Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.", + "wEmptyAccounts": "Ваши счета ещё не готовы. Попробуйте обновить страницу.", + "wRefresh": "Обновить", + "wSource": "Со счёта", + "wDestination": "На счёт", + "wReceiveAccount": "Счёт для зачисления", + "wAmount": "Сумма", + "wRecipient": "Получатель", + "wRecipientHint": "Имя или реквизиты получателя", + "wReview": "Проверить", + "wReviewTitle": "Проверьте детали.", + "wFee": "Комиссия", + "wTotalDebit": "Спишется со счёта", + "wCredit": "Поступит на счёт", + "wRecipientGets": "Получатель получит", + "wYouPay": "Вы платите", + "wRate": "Курс обмена", + "wQuoteNotice": "Эти условия действуют 60 секунд. Подтвердите их, прежде чем продолжить.", + "wExpired": "Срок действия расчёта истёк. Обновите его и проверьте новые условия.", + "wNewQuote": "Обновить расчёт", + "wConfirm": "Подтвердить операцию", + "wEdit": "Изменить данные", + "wClose": "Закрыть", + "wWorking": "Обрабатываем…", + "wPending": "В обработке", + "wCompleted": "Завершено", + "wFailed": "Не выполнено", + "wPendingTopUp": "Пополнение обрабатывается. Доступный баланс пока не изменился.", + "wPendingExchange": "Сумма и комиссия уже вычтены из доступного баланса. После завершения обмена деньги появятся на счёте зачисления.", + "wPendingPayout": "Сумма и комиссия уже вычтены из доступного баланса. Вывод ещё обрабатывается.", + "wCompleteTopUp": "Счёт пополнен. Деньги доступны для операций.", + "wCompleteExchange": "Обмен завершён. Деньги доступны на счёте зачисления.", + "wCompletePayout": "Вывод завершён. Сумма и комиссия списаны с вашего счёта.", + "wFailedTopUp": "Пополнение не выполнено. На счёт ничего не зачислено.", + "wFailedOutgoing": "Операция не выполнена. Сумма и комиссия снова доступны на вашем счёте.", + "wUnavailable": "Не удалось обновить данные. Информация может быть устаревшей. Повторите попытку.", + "wInvalidAmount": "Введите сумму больше нуля с допустимым для валюты числом знаков после запятой.", + "wInsufficient": "Для этой суммы и комиссии недостаточно доступных средств. Пополните счёт или уменьшите сумму.", + "wLimitError": "Сумма превышает доступный лимит. Проверьте лимиты перед продолжением.", + "wRecipientError": "Введите тестового получателя длиной от 6 до 140 символов.", + "wUnsupported": "Эта комбинация недоступна. Выберите другой счёт.", + "wNotFound": "Не удалось найти запись. Вернитесь к счетам.", + "wUncertain": "Не удалось подтвердить результат. Проверьте его, прежде чем создавать другую операцию.", + "wCheckOutcome": "Проверить результат", + "wNoSubmission": "Операция не найдена. Вы можете снова проверить и подтвердить её.", + "wDiscardTitle": "Выйти из операции?", + "wDiscardBody": "Введённые данные будут удалены. Новая операция не будет отправлена.", + "wStay": "Продолжить", + "wAll": "Все", + "wSearch": "Поиск по номеру", + "wNoMatches": "Операции не найдены. Измените фильтр.", + "wClearFilters": "Сбросить фильтры", + "wReference": "Номер операции", + "wWhen": "Создано", + "wViewAccount": "Открыть счёт", + "wPreferences": "Настройте под себя.", + "wLanguage": "Язык аккаунта", + "wLanguageHelp": "Используется во всём аккаунте.", + "wLanguageSaved": "Сохранено в аккаунте.", + "wLanguageFailed": "Изменено для текущей сессии, но не сохранено в аккаунте. Повторите попытку ниже.", + "wProfileError": "Не удалось загрузить настройки аккаунта. Повторите попытку.", + "wSecurity": "Безопасность", + "wSecurityBody": "Настройки пароля и безопасности пока недоступны.", + "wIdentity": "Подтверждение личности", + "wIdentityHelp": "Необязательная проверка для повышения лимитов.", + "wBackLogin": "Вернуться ко входу", + "wEnter": "Открыть свои счета", + "wUsage": "Использовано за период", + "wLimitsScope": "Исходящие операции · эквивалент в EUR", + "wPendingHint": "Обновите, чтобы узнать текущий статус. Остальными счетами можно пользоваться.", + "wFundsHeading": "Где мои деньги?", + "wAnother": "Создать новую операцию" } diff --git a/frontend/app/lib/main.dart b/frontend/app/lib/main.dart index 8986a0e..45e7217 100644 --- a/frontend/app/lib/main.dart +++ b/frontend/app/lib/main.dart @@ -10,6 +10,8 @@ import 'package:sxchnge_kyc/sxchnge_kyc.dart'; import 'package:sxchnge_theme/sxchnge_theme.dart'; +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + import 'providers/locale.dart'; import 'routing/router.dart'; @@ -28,6 +30,7 @@ class SxchngeApp extends StatefulWidget { this.analyticsService, this.identityService, this.limitsService, + this.walletService, this.photoService = const FilePhotoService(), this.loginService = const DummyLoginService(), this.signupService = const DummySignupService(), @@ -37,6 +40,7 @@ class SxchngeApp extends StatefulWidget { final AnalyticsService? analyticsService; final IdentityService? identityService; final LimitsService? limitsService; + final WalletService? walletService; final PhotoService photoService; final LoginService loginService; final SignupService signupService; @@ -51,7 +55,15 @@ class _SxchngeAppState extends State { late final _analytics = widget.analyticsService ?? DummyAnalyticsService(); 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 _services = InstrumentedAuthServices( recorder: _journeys, @@ -82,6 +94,7 @@ class _SxchngeAppState extends State { _router.routeInformationProvider.removeListener(_routeChanged); _router.dispose(); _journeys.dispose(); + _wallet.dispose(); _locale.dispose(); super.dispose(); } @@ -89,6 +102,7 @@ class _SxchngeAppState extends State { @override Widget build(BuildContext context) => MultiProvider( providers: [ + ChangeNotifierProvider.value(value: _wallet), Provider.value(value: _journeys), Provider.value(value: _analytics), Provider.value(value: _identity), diff --git a/frontend/app/lib/providers/locale.dart b/frontend/app/lib/providers/locale.dart index 2798933..c65c56e 100644 --- a/frontend/app/lib/providers/locale.dart +++ b/frontend/app/lib/providers/locale.dart @@ -2,12 +2,15 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + class LocaleProvider extends ChangeNotifier { static const storageKey = 'sxchnge_last_locale'; LocaleProvider({Locale initialLocale = const Locale('en'), this.preferences}) : _locale = resolve(initialLocale.languageCode, const []); Locale _locale; + bool _disposed = false; final SharedPreferences? preferences; Future _pendingSave = Future.value(true); Locale get locale => _locale; @@ -31,6 +34,22 @@ class LocaleProvider extends ChangeNotifier { } } + Future loadAccount(WalletProvider wallet) async { + await wallet.loadProfile(); + if (_disposed) return; + if (wallet.preferredLocale != null && !wallet.profileFailed) { + _locale = resolve(wallet.preferredLocale, const []); + notifyListeners(); + } + } + + Future saveAccountLocale(WalletProvider wallet, Locale value) async { + if (_disposed || wallet.saving) return; + _locale = resolve(value.languageCode, const []); + notifyListeners(); + await wallet.saveLocale(_locale.languageCode); + } + Future setLocale(Locale value) { final next = resolve(value.languageCode, const []); if (next == _locale) return _pendingSave; @@ -47,4 +66,10 @@ class LocaleProvider extends ChangeNotifier { }); return _pendingSave; } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } } diff --git a/frontend/app/lib/routing/router.dart b/frontend/app/lib/routing/router.dart index f7c5da1..74fc17c 100644 --- a/frontend/app/lib/routing/router.dart +++ b/frontend/app/lib/routing/router.dart @@ -4,23 +4,101 @@ import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + import '../auth/page.dart'; import '../kyc/page.dart'; import '../verification/page.dart'; +import '../workspace/accounts.dart'; +import '../workspace/activity.dart'; +import '../workspace/limits.dart'; +import '../workspace/operation.dart'; +import '../workspace/settings.dart'; +import '../workspace/shell.dart'; +import '../workspace/transfer.dart'; import 'routes.dart'; GoRouter createAppRouter() { + final topUpKey = GlobalKey(); + final exchangeKey = GlobalKey(); + final payoutKey = GlobalKey(); final identityKey = GlobalKey(); return GoRouter( // Signup email is an in-memory navigation extra, never browser-history data. extraCodec: const _TransientExtras(), routes: [ - GoRoute( - path: '/identity', - name: AppRoutes.identity, - builder: (context, state) => IdentityPage(key: identityKey), - onExit: (context, state) async => - await identityKey.currentState?.allowExit() ?? true, + ShellRoute( + builder: (context, state, child) => + WorkspaceShell(path: state.uri.path, child: child), + routes: [ + GoRoute( + path: '/accounts', + builder: (context, state) => const AccountsPage(), + routes: [ + GoRoute( + path: ':accountId', + builder: (context, state) => + AccountsPage(accountId: state.pathParameters['accountId']), + ), + ], + ), + GoRoute( + path: '/activity', + builder: (context, state) => const ActivityPage(), + routes: [ + GoRoute( + path: ':operationId', + builder: (context, state) => + OperationPage(id: state.pathParameters['operationId']!), + ), + ], + ), + GoRoute( + path: '/limits', + builder: (context, state) => const LimitsPage(), + ), + GoRoute( + path: '/settings', + builder: (context, state) => const SettingsPage(), + ), + GoRoute( + path: '/top-up', + builder: (context, state) => TransferPage( + key: topUpKey, + kind: TransferKind.topUp, + source: state.extra is String ? state.extra as String : null, + ), + onExit: (context, state) async => + await topUpKey.currentState?.allowExit() ?? true, + ), + GoRoute( + path: '/exchange', + builder: (context, state) => TransferPage( + key: exchangeKey, + kind: TransferKind.exchange, + source: state.extra is String ? state.extra as String : null, + ), + onExit: (context, state) async => + await exchangeKey.currentState?.allowExit() ?? true, + ), + GoRoute( + path: '/payout', + builder: (context, state) => TransferPage( + key: payoutKey, + kind: TransferKind.payout, + source: state.extra is String ? state.extra as String : null, + ), + onExit: (context, state) async => + await payoutKey.currentState?.allowExit() ?? true, + ), + GoRoute( + path: '/identity', + name: AppRoutes.identity, + builder: (context, state) => IdentityPage(key: identityKey), + onExit: (context, state) async => + await identityKey.currentState?.allowExit() ?? true, + ), + ], ), GoRoute( path: '/', diff --git a/frontend/app/lib/verification/page.dart b/frontend/app/lib/verification/page.dart index 90cc69f..d5eb2e2 100644 --- a/frontend/app/lib/verification/page.dart +++ b/frontend/app/lib/verification/page.dart @@ -11,7 +11,6 @@ import 'package:sxchnge_auth/sxchnge_auth.dart'; import '../auth/brand_panel.dart'; import '../auth/validation.dart'; import '../routing/routes.dart'; -import '../widgets/locale.dart'; import '../generated/i18n/app_localizations.dart'; @@ -128,11 +127,10 @@ class _VerificationPageState extends State { !_lastWasSend && widget.token != null; final title = switch (status) { - VerificationStatus.sent => demo ? l.demoSentHeading : l.checkInbox, + VerificationStatus.sent => l.checkInbox, VerificationStatus.sending => l.verificationSending, VerificationStatus.checking => l.verificationChecking, - VerificationStatus.verified => - demo ? l.demoVerifiedHeading : l.verificationVerified, + VerificationStatus.verified => l.verificationVerified, VerificationStatus.expired => l.verificationExpired, VerificationStatus.invalid => l.verificationInvalid, VerificationStatus.error => l.verificationInternal, @@ -142,9 +140,8 @@ class _VerificationPageState extends State { : l.verificationRequest, }; final body = switch (status) { - VerificationStatus.sent => demo ? l.demoSentBody : l.verificationSent, - VerificationStatus.verified => - demo ? l.demoVerifiedBody : l.verificationVerifiedBody, + VerificationStatus.sent => l.verificationSent, + VerificationStatus.verified => l.verificationVerifiedBody, VerificationStatus.expired => l.verificationExpiredBody, VerificationStatus.invalid => l.verificationInvalidBody, VerificationStatus.error => l.verificationInternalBody, @@ -160,10 +157,6 @@ class _VerificationPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Align( - alignment: Alignment.centerRight, - child: LocaleSelector(), - ), const Align( alignment: Alignment.centerLeft, child: BrandWordmark(), @@ -205,13 +198,6 @@ class _VerificationPageState extends State { color: colors.primary, ), ), - if (demo) ...[ - const SizedBox(height: 12), - Text( - l.verificationDemo, - style: theme.textTheme.bodyMedium, - ), - ], const SizedBox(height: 12), Semantics( liveRegion: true, @@ -263,7 +249,7 @@ class _VerificationPageState extends State { key: const ValueKey('verification-resend'), onPressed: _send, child: Text( - demo ? l.demoResend : l.verificationResend, + l.verificationResend, textAlign: TextAlign.center, ), ), @@ -328,8 +314,8 @@ class _VerificationPageState extends State { if (verified) ...[ const SizedBox(height: 16), FilledButton( - onPressed: () => context.goNamed(AppRoutes.identity), - child: Text(l.kycPreview), + onPressed: () => context.go('/accounts'), + child: Text(l.wEnter), ), ], const SizedBox(height: 16), diff --git a/frontend/app/lib/widgets/locale.dart b/frontend/app/lib/widgets/locale.dart deleted file mode 100644 index 0e02876..0000000 --- a/frontend/app/lib/widgets/locale.dart +++ /dev/null @@ -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(); - return PopupMenuButton( - 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), - ], - ), - ), - ); - } -} diff --git a/frontend/app/lib/workspace/accounts.dart b/frontend/app/lib/workspace/accounts.dart new file mode 100644 index 0000000..996a9d1 --- /dev/null +++ b/frontend/app/lib/workspace/accounts.dart @@ -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(); + 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), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/frontend/app/lib/workspace/activity.dart b/frontend/app/lib/workspace/activity.dart new file mode 100644 index 0000000..fa931ec --- /dev/null +++ b/frontend/app/lib/workspace/activity.dart @@ -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 createState() => _ActivityPageState(); +} + +class _ActivityPageState extends State { + TransferKind? _kind; + OperationStatus? _status; + String _search = ''; + String? _currency; + @override + Widget build(BuildContext context) { + final p = context.watch(); + 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 ?? []) + 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(); + 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), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/app/lib/workspace/copy.dart b/frontend/app/lib/workspace/copy.dart new file mode 100644 index 0000000..bfe3001 --- /dev/null +++ b/frontend/app/lib/workspace/copy.dart @@ -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, +}; diff --git a/frontend/app/lib/workspace/limits.dart b/frontend/app/lib/workspace/limits.dart new file mode 100644 index 0000000..543af5c --- /dev/null +++ b/frontend/app/lib/workspace/limits.dart @@ -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 createState() => _LimitsPageState(); +} + +class _LimitsPageState extends State { + late final _provider = WorkspaceLimitsProvider( + context.read(), + context.read(), + ); + @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.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), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/frontend/app/lib/workspace/operation.dart b/frontend/app/lib/workspace/operation.dart new file mode 100644 index 0000000..79ce917 --- /dev/null +++ b/frontend/app/lib/workspace/operation.dart @@ -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 createState() => _OperationPageState(); +} + +class _OperationPageState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) context.read().refresh(); + }); + } + + @override + Widget build(BuildContext context) { + final p = context.watch(); + 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), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/frontend/app/lib/workspace/settings.dart b/frontend/app/lib/workspace/settings.dart new file mode 100644 index 0000000..ac691f9 --- /dev/null +++ b/frontend/app/lib/workspace/settings.dart @@ -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(); + final locale = context.watch(); + 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( + 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), + ), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/app/lib/workspace/shell.dart b/frontend/app/lib/workspace/shell.dart new file mode 100644 index 0000000..940e64f --- /dev/null +++ b/frontend/app/lib/workspace/shell.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; + +import 'package:provider/provider.dart'; + +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + +import '../auth/brand_panel.dart'; +import '../providers/locale.dart'; + +import '../generated/i18n/app_localizations.dart'; + +class WorkspaceShell extends StatefulWidget { + const WorkspaceShell({super.key, required this.child, required this.path}); + final Widget child; + final String path; + @override + State createState() => _WorkspaceShellState(); +} + +class _WorkspaceShellState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final wallet = context.read(); + if (wallet.data == null) wallet.refresh(); + context.read().loadAccount(wallet); + }); + } + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + final t = Theme.of(context); + final c = t.colorScheme; + final paths = ['/accounts', '/activity', '/limits', '/settings']; + final labels = [l.wAccounts, l.wActivity, l.wLimits, l.wSettings]; + final icons = [ + Icons.account_balance_wallet_outlined, + Icons.receipt_long_outlined, + Icons.speed_outlined, + Icons.tune_rounded, + ]; + final index = widget.path.startsWith('/activity') + ? 1 + : widget.path.startsWith('/limits') || + widget.path.startsWith('/identity') + ? 2 + : widget.path.startsWith('/settings') + ? 3 + : 0; + void navigate(int i) { + if (['/top-up', '/exchange', '/payout'].contains(widget.path) && i >= 2) { + context.push(paths[i]); + } else { + context.go(paths[i]); + } + } + + final wide = MediaQuery.sizeOf(context).width >= 900; + final content = Column( + children: [ + if (!wide) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12), + child: Align( + alignment: Alignment.centerLeft, + child: BrandWordmark(), + ), + ), + Expanded(child: widget.child), + ], + ); + return Scaffold( + body: SafeArea( + child: Row( + children: [ + if (wide) + Container( + width: 220, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: c.surfaceContainerLow, + border: Border(right: BorderSide(color: c.outlineVariant)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: BrandWordmark(), + ), + const SizedBox(height: 48), + for (var i = 0; i < 4; i++) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: ListTile( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + ), + selected: i == index, + selectedTileColor: c.primaryContainer, + leading: Icon(icons[i]), + title: Text(labels[i]), + onTap: () => navigate(i), + ), + ), + ], + ), + ), + Expanded(child: content), + ], + ), + ), + bottomNavigationBar: wide + ? null + : NavigationBar( + selectedIndex: index, + onDestinationSelected: navigate, + destinations: [ + for (var i = 0; i < 4; i++) + NavigationDestination(icon: Icon(icons[i]), label: labels[i]), + ], + ), + ); + } +} diff --git a/frontend/app/lib/workspace/summary.dart b/frontend/app/lib/workspace/summary.dart new file mode 100644 index 0000000..e36323f --- /dev/null +++ b/frontend/app/lib/workspace/summary.dart @@ -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(); + 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, + ), + ], + ); +} diff --git a/frontend/app/lib/workspace/transfer.dart b/frontend/app/lib/workspace/transfer.dart new file mode 100644 index 0000000..2268b48 --- /dev/null +++ b/frontend/app/lib/workspace/transfer.dart @@ -0,0 +1,308 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; + +import 'package:provider/provider.dart'; + +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + +import 'copy.dart'; +import 'summary.dart'; + +import '../generated/i18n/app_localizations.dart'; + +class TransferPage extends StatefulWidget { + const TransferPage({super.key, required this.kind, this.source}); + final TransferKind kind; + final String? source; + @override + State createState() => TransferPageState(); +} + +class TransferPageState extends State { + late final _provider = TransferProvider( + context.read(), + 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 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( + 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(); + final l = AppLocalizations.of(context)!; + final t = Theme.of(context); + final accounts = wallet.data?.accounts ?? []; + final source = wallet.account(p.source); + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 640), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + p.quote == null + ? kindText(l, widget.kind) + : l.wReviewTitle, + style: t.textTheme.displaySmall, + ), + ), + IconButton( + tooltip: l.wClose, + onPressed: p.busy || p.uncertain + ? null + : () => context.go('/accounts'), + icon: const Icon(Icons.close), + ), + ], + ), + const SizedBox(height: 20), + Focus( + key: _content, + focusNode: _focus, + skipTraversal: true, + includeSemantics: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (p.busy) ...[ + const LinearProgressIndicator(), + const SizedBox(height: 12), + Semantics(liveRegion: true, child: Text(l.wWorking)), + ], + if (p.error != null) + Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Semantics( + liveRegion: true, + child: Text( + walletError(l, p.error!), + key: const ValueKey('transfer-error'), + style: TextStyle(color: t.colorScheme.error), + ), + ), + ), + if (p.noSubmission) Text(l.wNoSubmission), + if (p.uncertain) ...[ + Text(l.wUncertain), + FilledButton( + onPressed: p.busy ? null : p.reconcile, + child: Text(l.wCheckOutcome), + ), + ] else if (p.quote != null) ...[ + TransferSummary(quote: p.quote!), + const SizedBox(height: 16), + Text(p.expired ? l.wExpired : l.wQuoteNotice), + const SizedBox(height: 20), + if (p.expired) + FilledButton( + onPressed: p.busy ? null : p.review, + child: Text(l.wNewQuote), + ) + else + FilledButton( + key: const ValueKey('transfer-confirm'), + onPressed: p.busy ? null : p.submit, + child: Text(l.wConfirm), + ), + TextButton( + onPressed: p.busy ? null : p.edit, + child: Text(l.wEdit), + ), + ] else ...[ + if (accounts.isEmpty) ...[ + Text(wallet.loading ? l.wWorking : l.wEmptyAccounts), + TextButton( + onPressed: wallet.loading ? null : wallet.refresh, + child: Text(l.wRefresh), + ), + ] else ...[ + DropdownButtonFormField( + 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( + 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), + ), + ], + ], + ], + ), + ), + ], + ), + ), + ), + ); + }, + ); +} diff --git a/frontend/app/pubspec.lock b/frontend/app/pubspec.lock index 81e429f..7b13355 100644 --- a/frontend/app/pubspec.lock +++ b/frontend/app/pubspec.lock @@ -494,6 +494,13 @@ packages: relative: true source: path version: "0.1.0" + sxchnge_wallet: + dependency: "direct main" + description: + path: "../packages/sxchnge_wallet" + relative: true + source: path + version: "0.1.0" term_glyph: dependency: transitive description: diff --git a/frontend/app/pubspec.yaml b/frontend/app/pubspec.yaml index c89dde1..f659714 100644 --- a/frontend/app/pubspec.yaml +++ b/frontend/app/pubspec.yaml @@ -6,6 +6,8 @@ environment: sdk: '>=3.13.0 <4.0.0' flutter: '>=3.47.0' dependencies: + sxchnge_wallet: + path: ../packages/sxchnge_wallet sxchnge_kyc: path: ../packages/sxchnge_kyc sxchnge_analytics: diff --git a/frontend/app/test/kyc_test.dart b/frontend/app/test/kyc_test.dart index f088859..6e2ae9f 100644 --- a/frontend/app/test/kyc_test.dart +++ b/frontend/app/test/kyc_test.dart @@ -178,7 +178,7 @@ void main() { await t.pumpAndSettle(); await tap(t, find.text('Discard & leave')); expect(find.byKey(const ValueKey('kyc-start')), findsNothing); - expect(find.text('Explore verification & limits'), findsOneWidget); + expect(find.text('Explore your accounts'), findsOneWidget); }); testWidgets('overview controls have accessible labels and touch targets', ( t, diff --git a/frontend/app/test/limits_test.dart b/frontend/app/test/limits_test.dart index 553bebd..cfac36a 100644 --- a/frontend/app/test/limits_test.dart +++ b/frontend/app/test/limits_test.dart @@ -63,12 +63,14 @@ void main() { ); await tester.pumpAndSettle(); expect( - find.text(locale == 'en' ? 'Used' : 'Использовано'), + find.text( + locale == 'en' ? 'Used this period' : 'Использовано за период', + ), findsOneWidget, ); expect( find.text(locale == 'en' ? 'Reserved' : 'Зарезервировано'), - findsOneWidget, + findsNothing, ); expect( tester diff --git a/frontend/app/test/localization_test.dart b/frontend/app/test/localization_test.dart index 2aa8f3e..971586d 100644 --- a/frontend/app/test/localization_test.dart +++ b/frontend/app/test/localization_test.dart @@ -83,15 +83,7 @@ void main() { await tester.tap(find.byKey(const ValueKey('submit'))); await tester.pumpAndSettle(); expect(find.text('Enter a valid email address.'), findsOneWidget); - await tester.ensureVisible(find.byKey(const ValueKey('locale-selector'))); - await tester.tap(find.byKey(const ValueKey('locale-selector'))); - await tester.pumpAndSettle(); - await tester.tap( - find.ancestor( - of: find.text('Русский'), - matching: find.byType(CheckedPopupMenuItem), - ), - ); + await locale.setLocale(const Locale('ru')); await tester.pumpAndSettle(); expect(find.text('Введите корректный адрес почты.'), findsOneWidget); expect(find.text('Алексей'), findsOneWidget); diff --git a/frontend/app/test/routing_test.dart b/frontend/app/test/routing_test.dart index 5d8ed39..6d8d645 100644 --- a/frontend/app/test/routing_test.dart +++ b/frontend/app/test/routing_test.dart @@ -129,7 +129,7 @@ void main() { same(router), ); expect(find.byKey(const ValueKey('verification-verified')), findsOneWidget); - expect(find.text('Демоподтверждение\nзавершено.'), findsOneWidget); + expect(find.text('Ваша почта\nподтверждена.'), findsOneWidget); }); testWidgets( diff --git a/frontend/app/test/ux_analytics_test.dart b/frontend/app/test/ux_analytics_test.dart index 0f42221..3e9daf9 100644 --- a/frontend/app/test/ux_analytics_test.dart +++ b/frontend/app/test/ux_analytics_test.dart @@ -111,7 +111,7 @@ void main() { ); testWidgets( - 'signup to demo verification is correlated and never claims a business success', + 'signup verification keeps simulated analytics with ordinary product copy', (tester) async { final sink = DummyAnalyticsService(); await open(tester, '/', sink); @@ -130,10 +130,10 @@ void main() { 'example-password', ); await tap(tester, find.byKey(const ValueKey('submit'))); - expect(find.text('Demo email prepared.'), findsOneWidget); - expect(find.textContaining('We’ve sent you'), findsNothing); + expect(find.text('Check your inbox.'), findsOneWidget); + expect(find.textContaining('We’ve sent you'), findsOneWidget); await tap(tester, find.byKey(const ValueKey('demo-confirmation'))); - expect(find.text('Demo verification\ncomplete.'), findsOneWidget); + expect(find.text('Your email\nis verified.'), findsOneWidget); expect(sink.events.map((e) => e.journeyId).toSet().length, 1); expect(sink.events.where((e) => e.terminal).length, 1); expect( diff --git a/frontend/app/test/verification_test.dart b/frontend/app/test/verification_test.dart index 28744bb..2e69817 100644 --- a/frontend/app/test/verification_test.dart +++ b/frontend/app/test/verification_test.dart @@ -2,9 +2,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + import 'package:sxchnge_auth/sxchnge_auth.dart'; import 'package:sxchnge_app/main.dart'; +import 'package:sxchnge_app/providers/locale.dart'; class RecoveringService implements VerificationService { int sends = 0; @@ -54,7 +57,7 @@ void main() { find.byKey(ValueKey('verification-${scenario.value}')), findsOneWidget, ); - expect(find.textContaining('DEMO'), findsOneWidget); + expect(find.textContaining('DEMO'), findsNothing); expect(tester.takeException(), isNull); }, ); @@ -120,16 +123,10 @@ void main() { addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); await openVerification(tester, route); expect(tester.takeException(), isNull); - await tester.tap(find.byKey(const ValueKey('locale-selector'))); + final element = tester.element(find.byType(Scaffold).first); + await element.read().setLocale(const Locale('ru')); await tester.pumpAndSettle(); - await tester.tap( - find.ancestor( - of: find.text('Русский'), - matching: find.byType(CheckedPopupMenuItem), - ), - ); - await tester.pumpAndSettle(); - expect(find.text('RU'), findsOneWidget); + expect(find.byKey(const ValueKey('locale-selector')), findsNothing); expect(tester.takeException(), isNull); }); } diff --git a/frontend/app/test/workspace_test.dart b/frontend/app/test/workspace_test.dart new file mode 100644 index 0000000..bbb9be2 --- /dev/null +++ b/frontend/app/test/workspace_test.dart @@ -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 tap(WidgetTester t, Finder f) async { + await t.ensureVisible(f); + await t.tap(f); + await t.pumpAndSettle(); +} + +Future 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.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); + }); +} diff --git a/frontend/packages/sxchnge_analytics/lib/sxchnge_analytics.dart b/frontend/packages/sxchnge_analytics/lib/sxchnge_analytics.dart index 6ff550c..da8aa0f 100644 --- a/frontend/packages/sxchnge_analytics/lib/sxchnge_analytics.dart +++ b/frontend/packages/sxchnge_analytics/lib/sxchnge_analytics.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:collection'; import 'dart:math'; -enum JourneyKind { login, signup, verification, kyc } +enum JourneyKind { login, signup, verification, kyc, topUp, exchange, payout } enum JourneyStep { login, @@ -14,6 +14,10 @@ enum JourneyStep { kycSelfie, kycSubmission, kycStatus, + transferDraft, + transferQuote, + transferSubmission, + settlement, } enum JourneySignal { @@ -27,6 +31,9 @@ enum JourneySignal { } enum JourneyFailure { + insufficientFunds, + limitExceeded, + declined, document, invalidCredentials, emailInUse, @@ -79,7 +86,13 @@ class AnalyticsEvent { final bool simulated; final DateTime occurredAt; 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); if (kind == JourneyKind.kyc) stage = stage.replaceFirst('kyc_', ''); return '${area}_${stage}_${signal.name}'; @@ -189,7 +202,7 @@ class JourneyRecorder { operation._finished = true; journey.pending--; journey.simulated |= simulated; - final complete = failure == null && !cancelled && terminal; + final complete = !cancelled && terminal; _emit( journey, cancelled @@ -257,8 +270,8 @@ class JourneyRecorder { ); } - void dispose() { - abandon(JourneyExit.disposed); + void dispose({bool abandonActive = true}) { + if (abandonActive) abandon(JourneyExit.disposed); _disposed = true; _idle?.cancel(); } diff --git a/frontend/packages/sxchnge_kyc/lib/limits_transport.dart b/frontend/packages/sxchnge_kyc/lib/limits_transport.dart new file mode 100644 index 0000000..e0980f2 --- /dev/null +++ b/frontend/packages/sxchnge_kyc/lib/limits_transport.dart @@ -0,0 +1,2 @@ +/// Shared limit transport contract for service adapters. +export 'src/transport/contracts.dart' show LimitsDTO, LimitDTO, LimitsMapper; diff --git a/frontend/packages/sxchnge_wallet/.gitignore b/frontend/packages/sxchnge_wallet/.gitignore new file mode 100644 index 0000000..61608ef --- /dev/null +++ b/frontend/packages/sxchnge_wallet/.gitignore @@ -0,0 +1,3 @@ +.dart_tool/ +build/ +pubspec.lock diff --git a/frontend/packages/sxchnge_wallet/README.md b/frontend/packages/sxchnge_wallet/README.md new file mode 100644 index 0000000..17a7fcc --- /dev/null +++ b/frontend/packages/sxchnge_wallet/README.md @@ -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. diff --git a/frontend/packages/sxchnge_wallet/lib/src/models/wallet.dart b/frontend/packages/sxchnge_wallet/lib/src/models/wallet.dart new file mode 100644 index 0000000..7548ea8 --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/models/wallet.dart @@ -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 accounts, + required List operations, + }) : accounts = List.unmodifiable(accounts), + operations = List.unmodifiable(operations); + final List accounts; + final List operations; +} + +class AccountProfile { + const AccountProfile(this.locale); + final String locale; +} diff --git a/frontend/packages/sxchnge_wallet/lib/src/providers/limits.dart b/frontend/packages/sxchnge_wallet/lib/src/providers/limits.dart new file mode 100644 index 0000000..4ec2ab7 --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/providers/limits.dart @@ -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(); + } +} diff --git a/frontend/packages/sxchnge_wallet/lib/src/providers/transfer.dart b/frontend/packages/sxchnge_wallet/lib/src/providers/transfer.dart new file mode 100644 index 0000000..8cd61bb --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/providers/transfer.dart @@ -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 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 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 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(); + } +} diff --git a/frontend/packages/sxchnge_wallet/lib/src/providers/wallet.dart b/frontend/packages/sxchnge_wallet/lib/src/providers/wallet.dart new file mode 100644 index 0000000..6e263b1 --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/providers/wallet.dart @@ -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 = {}; + CurrencyAccount? account(String? id) { + for (final a in data?.accounts ?? []) { + if (a.id == id) return a; + } + return null; + } + + WalletOperation? operation(String id) { + for (final o in data?.operations ?? []) { + if (o.id == id) return o; + } + return null; + } + + bool get hasPending => + data?.operations.any((o) => o.status == OperationStatus.pending) ?? false; + Future 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 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 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(); + } +} diff --git a/frontend/packages/sxchnge_wallet/lib/src/services/contracts.dart b/frontend/packages/sxchnge_wallet/lib/src/services/contracts.dart new file mode 100644 index 0000000..874c6d0 --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/services/contracts.dart @@ -0,0 +1,13 @@ +import '../models/wallet.dart'; + +abstract interface class WalletService { + bool get isSimulated; + Future fetch(); + Future quote(TransferRequest request); + + /// Same key must reconcile uncertain submissions without duplicate execution. + Future submit(String quoteId, String idempotencyKey); + Future findSubmission(String idempotencyKey); + Future profile(); + Future saveLocale(String locale); +} diff --git a/frontend/packages/sxchnge_wallet/lib/src/services/dummy.dart b/frontend/packages/sxchnge_wallet/lib/src/services/dummy.dart new file mode 100644 index 0000000..ec8cde2 --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/services/dummy.dart @@ -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 _quotes = {}; + final _usedQuotes = {}; + final Map _submissions = {}; + String _locale; + int _serial = 0; + WalletError? nextError; + bool failNextOperation = false; + bool loseNextResponse = false; + @override + bool get isSimulated => true; + Future _wait() async { + await Future.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 _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 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(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 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 findSubmission(String key) async { + await _wait(); + _settle(); + final o = _submissions[key]; + return o == null ? null : WalletMapper.operation(o); + } + + @override + Future profile() async { + await _wait(); + return WalletMapper.profile(_locale); + } + + @override + Future 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 fetchLimits() async { + await _wait(); + final verified = await _verified(); + _settle(); + final rows = []; + 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 fetch() => wallet.fetchLimits(); +} diff --git a/frontend/packages/sxchnge_wallet/lib/src/transport/wallet.dart b/frontend/packages/sxchnge_wallet/lib/src/transport/wallet.dart new file mode 100644 index 0000000..2614608 --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/transport/wallet.dart @@ -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); +} diff --git a/frontend/packages/sxchnge_wallet/lib/sxchnge_wallet.dart b/frontend/packages/sxchnge_wallet/lib/sxchnge_wallet.dart new file mode 100644 index 0000000..bbc4d41 --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/sxchnge_wallet.dart @@ -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'; diff --git a/frontend/packages/sxchnge_wallet/pubspec.yaml b/frontend/packages/sxchnge_wallet/pubspec.yaml new file mode 100644 index 0000000..990ab45 --- /dev/null +++ b/frontend/packages/sxchnge_wallet/pubspec.yaml @@ -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 diff --git a/frontend/packages/sxchnge_wallet/test/wallet_test.dart b/frontend/packages/sxchnge_wallet/test/wallet_test.dart new file mode 100644 index 0000000..efc2435 --- /dev/null +++ b/frontend/packages/sxchnge_wallet/test/wallet_test.dart @@ -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 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()), + ); + }, + ); + 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().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().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().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.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 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.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.delayed(Duration.zero); + expect( + analytics.events.any((e) => e.signal == JourneySignal.abandoned), + false, + ); + }, + ); +} diff --git a/skills/ux-design/SKILL.md b/skills/ux-design/SKILL.md index 9a72d17..1645605 100644 --- a/skills/ux-design/SKILL.md +++ b/skills/ux-design/SKILL.md @@ -10,6 +10,10 @@ before reporting it complete. Rendering correctly is only one part of completion ## Establish the flow +- Prefer the simplest interface that lets users complete their task confidently. + When options meet the same need, choose fewer concepts, choices, and controls. + Add complexity only for a concrete user need; preserve essential information, + accessibility, and recovery rather than hiding them to make a screen look minimal. - Identify the user's primary task, successful outcome, and shortest appropriate path to that outcome before choosing layouts or controls. - Inspect comparable product flows, components, navigation, terminology, and @@ -95,6 +99,8 @@ performed and any gaps; do not mark applicable unmet criteria as complete. - [ ] Are interactive elements recognizable, and can the user tell what is happening and what will happen next? - [ ] Are steps appropriate, with unnecessary interaction and cognitive load removed? +- [ ] Is this the simplest usable solution, with each added concept, choice, or + control serving a concrete user need? - [ ] Does the flow follow existing product patterns and consistent terminology, with a concrete reason for any departure? - [ ] Can users recover from errors, with input and context preserved where appropriate?